feat: BILLmanager API на уровне хостера, миграция со старых аккаунтов
Docker / build (push) Failing after 18s

Made-with: Cursor
This commit is contained in:
Denozordec
2026-03-21 00:52:49 +07:00
parent 2da1f6a6f0
commit 28d723983e
19 changed files with 445 additions and 97 deletions
+29
View File
@@ -0,0 +1,29 @@
/**
* BILLmanager: URL на хостере, учётные данные на аккаунте.
*/
export function providerByIdMap(providers) {
return new Map(providers.map((p) => [p.id, p]))
}
/** Аккаунты, для которых можно вызывать синк (есть URL у хостера и креды у аккаунта). */
export function billmanagerSyncableAccounts(providerAccounts, providers) {
const pmap = providerByIdMap(providers)
return providerAccounts.filter((a) => {
const p = pmap.get(a.providerId)
return p?.apiType === 'billmanager' && (p.apiBaseUrl || '').trim() && a.apiCredentialsSet
})
}
/** Показывать кнопки баланса/синка и брать баланс из API. */
export function accountBillmanagerUiReady(account, provider) {
return (
provider?.apiType === 'billmanager' &&
(provider.apiBaseUrl || '').trim() &&
account.apiCredentialsSet
)
}
export function accountUsesBillmanagerBalanceApi(account, provider) {
return provider?.apiType === 'billmanager' && account.balance_api != null
}
+14 -8
View File
@@ -53,13 +53,15 @@ export function lastOkSyncFinishedAt(accountId, syncLog) {
* @param {{
* vps: object[],
* providerAccounts: object[],
* providers: object[],
* payments: object[],
* balanceLedger: object[],
* syncLog?: object[],
* }} input
*/
export function computeInventoryHealth(input) {
const { vps, providerAccounts, payments, balanceLedger, syncLog = [] } = input
const { vps, providerAccounts, providers = [], payments, balanceLedger, syncLog = [] } = input
const providerById = new Map(providers.map((p) => [p.id, p]))
const now = new Date()
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const ctx = { vps, providerAccounts, payments, balanceLedger, now }
@@ -109,9 +111,10 @@ export function computeInventoryHealth(input) {
})
}
const bmAccounts = providerAccounts.filter(
(a) => a.apiType === 'billmanager' && (a.apiBaseUrl || '').trim() && a.apiCredentialsSet,
)
const bmAccounts = providerAccounts.filter((a) => {
const p = providerById.get(a.providerId)
return p?.apiType === 'billmanager' && (p.apiBaseUrl || '').trim() && a.apiCredentialsSet
})
const staleMs = STALE_SYNC_HOURS * 60 * 60 * 1000
const staleAccounts = bmAccounts.filter((a) => {
const t = lastOkSyncFinishedAt(a.id, syncLog)
@@ -144,12 +147,15 @@ export function computeInventoryHealth(input) {
/**
* @param {object[]} providerAccounts
* @param {object[]} providers
* @param {object[]} syncLog
*/
export function getStaleSyncAccountIds(providerAccounts, syncLog, now = new Date()) {
const bmAccounts = providerAccounts.filter(
(a) => a.apiType === 'billmanager' && (a.apiBaseUrl || '').trim() && a.apiCredentialsSet,
)
export function getStaleSyncAccountIds(providerAccounts, providers, syncLog, now = new Date()) {
const providerById = new Map(providers.map((p) => [p.id, p]))
const bmAccounts = providerAccounts.filter((a) => {
const p = providerById.get(a.providerId)
return p?.apiType === 'billmanager' && (p.apiBaseUrl || '').trim() && a.apiCredentialsSet
})
const staleMs = STALE_SYNC_HOURS * 60 * 60 * 1000
return bmAccounts
.filter((a) => {
+46 -42
View File
@@ -12,6 +12,11 @@ import { ConvertedAmount } from '../components/ConvertedAmount'
import { syncAccount, testApiConnection, fetchAccountBalance, fetchSyncStatus } from '../lib/api'
import { noBrowserSuggestProps, passwordCredentialInputProps } from '../lib/noBrowserSuggestProps'
import { getBalanceMismatchAccountIds, getStaleSyncAccountIds } from '../lib/inventory-health'
import {
billmanagerSyncableAccounts,
accountBillmanagerUiReady,
accountUsesBillmanagerBalanceApi,
} from '../lib/billmanager-ui'
import { IconRefresh, IconPlugConnected } from '@tabler/icons-react'
const emptyForm = {
@@ -21,8 +26,6 @@ const emptyForm = {
currency: 'USD',
billingMode: 'monthly',
notes: '',
apiType: '',
apiBaseUrl: '',
apiLogin: '',
apiPassword: '',
balance_alert_below: '',
@@ -51,8 +54,8 @@ export function AccountsPage({ db, actions, settings, ratesData }) {
}, [])
const billmanagerAccounts = useMemo(
() => db.providerAccounts.filter((a) => a.apiType === 'billmanager' && a.apiBaseUrl),
[db.providerAccounts],
() => billmanagerSyncableAccounts(db.providerAccounts, db.providers),
[db.providerAccounts, db.providers],
)
const [testConnectionLoading, setTestConnectionLoading] = useState(false)
const [testConnectionResult, setTestConnectionResult] = useState(null)
@@ -60,13 +63,13 @@ export function AccountsPage({ db, actions, settings, ratesData }) {
const highlightAccountIds = useMemo(() => {
if (accountsHealth === 'stale-sync') {
return new Set(getStaleSyncAccountIds(db.providerAccounts, syncLog))
return new Set(getStaleSyncAccountIds(db.providerAccounts, db.providers, syncLog))
}
if (accountsHealth === 'balance-mismatch') {
return new Set(getBalanceMismatchAccountIds(db.providerAccounts, db.balanceLedger))
}
return null
}, [accountsHealth, db.providerAccounts, db.balanceLedger, syncLog])
}, [accountsHealth, db.providerAccounts, db.providers, db.balanceLedger, syncLog])
const balances = useMemo(() => {
return db.providerAccounts.map((account) => {
@@ -84,7 +87,8 @@ export function AccountsPage({ db, actions, settings, ratesData }) {
const getBalance = (accountId) => balances.find((item) => item.accountId === accountId)?.balance || 0
const getDisplayBalance = (account) => {
if (account.apiType === 'billmanager' && account.balance_api != null) {
const provider = db.providers.find((p) => p.id === account.providerId)
if (accountUsesBillmanagerBalanceApi(account, provider)) {
return account.balance_api
}
return getBalance(account.id)
@@ -116,10 +120,11 @@ export function AccountsPage({ db, actions, settings, ratesData }) {
currency: form.currency,
billingMode: form.billingMode,
notes: form.notes,
apiType: form.apiType || '',
apiBaseUrl: form.apiType === 'billmanager' ? form.apiBaseUrl : '',
}
if (form.apiType === 'billmanager' && form.apiLogin && form.apiPassword) {
const selectedProvider = db.providers.find((p) => p.id === form.providerId)
const bmReady =
selectedProvider?.apiType === 'billmanager' && (selectedProvider.apiBaseUrl || '').trim()
if (bmReady && form.apiLogin && form.apiPassword) {
payload.apiCredentials = `${form.apiLogin}:${form.apiPassword}`
}
{
@@ -146,14 +151,19 @@ export function AccountsPage({ db, actions, settings, ratesData }) {
}
const onTestConnection = async () => {
if (!form.apiBaseUrl?.trim() || !form.apiLogin?.trim() || !form.apiPassword?.trim()) {
setTestConnectionResult({ ok: false, error: 'Заполните URL, логин и пароль' })
const p = db.providers.find((x) => x.id === form.providerId)
const baseUrl = p?.apiType === 'billmanager' ? (p.apiBaseUrl || '').trim() : ''
if (!baseUrl || !form.apiLogin?.trim() || !form.apiPassword?.trim()) {
setTestConnectionResult({
ok: false,
error: 'В настройках хостера укажите URL API; здесь — логин и пароль',
})
return
}
setTestConnectionLoading(true)
setTestConnectionResult(null)
try {
const result = await testApiConnection(form.apiBaseUrl, `${form.apiLogin}:${form.apiPassword}`)
const result = await testApiConnection(baseUrl, `${form.apiLogin}:${form.apiPassword}`)
setTestConnectionResult(result)
} catch (err) {
setTestConnectionResult({ ok: false, error: err.message || 'Ошибка проверки' })
@@ -170,8 +180,6 @@ export function AccountsPage({ db, actions, settings, ratesData }) {
currency: account.currency || 'USD',
billingMode: account.billingMode || 'monthly',
notes: account.notes || '',
apiType: account.apiType || '',
apiBaseUrl: account.apiBaseUrl || '',
apiLogin: '',
apiPassword: '',
balance_alert_below:
@@ -185,7 +193,12 @@ export function AccountsPage({ db, actions, settings, ratesData }) {
}
const editingAccount = editingId ? db.providerAccounts.find((a) => a.id === editingId) : null
const canTestConnection = form.apiType === 'billmanager' && form.apiBaseUrl?.trim() && form.apiLogin?.trim() && form.apiPassword?.trim()
const formProvider = db.providers.find((p) => p.id === form.providerId)
const formBmUrl =
formProvider?.apiType === 'billmanager' ? (formProvider.apiBaseUrl || '').trim() : ''
const canTestConnection = Boolean(
formBmUrl && form.apiLogin?.trim() && form.apiPassword?.trim(),
)
const onSync = async (accountId) => {
setSyncLoadingId(accountId)
@@ -333,6 +346,7 @@ export function AccountsPage({ db, actions, settings, ratesData }) {
<tbody>
{db.providerAccounts.map((account) => {
const provider = db.providers.find((item) => item.id === account.providerId)
const bmUi = accountBillmanagerUiReady(account, provider)
const linkedVps = db.vps.filter((item) => item.providerAccountId === account.id)
const rowWarn = highlightAccountIds?.has(account.id)
return (
@@ -374,7 +388,7 @@ export function AccountsPage({ db, actions, settings, ratesData }) {
</td>
<td className="text-end">
<div className="table-actions d-flex gap-1 flex-wrap justify-content-end">
{account.apiType === 'billmanager' && account.apiBaseUrl ? (
{bmUi ? (
<>
<button
type="button"
@@ -511,32 +525,22 @@ export function AccountsPage({ db, actions, settings, ratesData }) {
</div>
<div className="col-12">
<hr className="my-2" />
<h6 className="text-secondary mb-2">Интеграция API</h6>
</div>
<div className="col-12">
<label className="form-label">Тип API</label>
<select autoComplete="off"
className="form-select"
value={form.apiType}
onChange={(e) => setForm((prev) => ({ ...prev, apiType: e.target.value, apiBaseUrl: '', apiLogin: '', apiPassword: '' }))}
>
<option value=""> Не использовать </option>
<option value="billmanager">BILLmanager</option>
</select>
</div>
{form.apiType === 'billmanager' ? (
<>
<div className="col-12">
<label className="form-label">URL API BILLmanager</label>
<input {...noBrowserSuggestProps}
className="form-control"
placeholder="https://bill.example.com:1500/billmgr"
value={form.apiBaseUrl}
onChange={(e) => setForm((prev) => ({ ...prev, apiBaseUrl: e.target.value }))}
/>
<h6 className="text-secondary mb-2">Учётные данные API BILLmanager</h6>
{formProvider?.apiType === 'billmanager' && (formProvider.apiBaseUrl || '').trim() ? (
<div className="text-secondary small mb-2">
URL: <code className="user-select-all">{formProvider.apiBaseUrl}</code>
</div>
) : (
<div className="alert alert-secondary py-2 small mb-2">
В карточке хостера выберите тип API BILLmanager и укажите URL один на все аккаунты этого
хостера.
</div>
)}
</div>
{formProvider?.apiType === 'billmanager' && (formProvider.apiBaseUrl || '').trim() ? (
<>
<div className="col-12 col-sm-6">
<label className="form-label">Логин</label>
<label className="form-label">Логин API</label>
<input {...noBrowserSuggestProps}
className="form-control"
placeholder="admin"
@@ -545,7 +549,7 @@ export function AccountsPage({ db, actions, settings, ratesData }) {
/>
</div>
<div className="col-12 col-sm-6">
<label className="form-label">Пароль</label>
<label className="form-label">Пароль API</label>
<input {...passwordCredentialInputProps}
type="password"
className="form-control"
+2 -1
View File
@@ -204,11 +204,12 @@ export function DashboardPage({ db = {}, settings, ratesData }) {
computeInventoryHealth({
vps,
providerAccounts,
providers,
payments,
balanceLedger,
syncLog: syncLogRows,
}),
[vps, providerAccounts, payments, balanceLedger, syncLogRows],
[vps, providerAccounts, providers, payments, balanceLedger, syncLogRows],
)
const recentSyncFeed = useMemo(() => {
+137 -2
View File
@@ -3,7 +3,9 @@ import { UiModal } from '../components/UiModal'
import { EmptyState } from '../components/EmptyState'
import { PageHeader } from '../components/PageHeader'
import { faviconUrlFromWebsite } from '../lib/utils'
import { noBrowserSuggestProps } from '../lib/noBrowserSuggestProps'
import { noBrowserSuggestProps, passwordCredentialInputProps } from '../lib/noBrowserSuggestProps'
import { testApiConnection } from '../lib/api'
import { IconPlugConnected } from '@tabler/icons-react'
const emptyForm = {
name: '',
@@ -13,12 +15,18 @@ const emptyForm = {
usdRate: '',
eurRate: '',
notes: '',
apiType: '',
apiBaseUrl: '',
}
export function ProvidersPage({ db, actions }) {
const [form, setForm] = useState(emptyForm)
const [editingId, setEditingId] = useState(null)
const [isModalOpen, setIsModalOpen] = useState(false)
const [apiProbeLogin, setApiProbeLogin] = useState('')
const [apiProbePassword, setApiProbePassword] = useState('')
const [testConnectionLoading, setTestConnectionLoading] = useState(false)
const [testConnectionResult, setTestConnectionResult] = useState(null)
const onSubmit = (event) => {
event.preventDefault()
@@ -32,6 +40,9 @@ export function ProvidersPage({ db, actions }) {
}
setForm(emptyForm)
setEditingId(null)
setApiProbeLogin('')
setApiProbePassword('')
setTestConnectionResult(null)
setIsModalOpen(false)
}
@@ -44,11 +55,39 @@ export function ProvidersPage({ db, actions }) {
usdRate: provider.usdRate || '',
eurRate: provider.eurRate || '',
notes: provider.notes || '',
apiType: provider.apiType || '',
apiBaseUrl: provider.apiBaseUrl || '',
})
setEditingId(provider.id)
setApiProbeLogin('')
setApiProbePassword('')
setTestConnectionResult(null)
setIsModalOpen(true)
}
const canTestApi =
form.apiType === 'billmanager' &&
form.apiBaseUrl?.trim() &&
apiProbeLogin?.trim() &&
apiProbePassword?.trim()
const onTestConnection = async () => {
if (!canTestApi) {
setTestConnectionResult({ ok: false, error: 'Заполните URL API и тестовый логин/пароль аккаунта' })
return
}
setTestConnectionLoading(true)
setTestConnectionResult(null)
try {
const result = await testApiConnection(form.apiBaseUrl, `${apiProbeLogin}:${apiProbePassword}`)
setTestConnectionResult(result)
} catch (err) {
setTestConnectionResult({ ok: false, error: err.message || 'Ошибка проверки' })
} finally {
setTestConnectionLoading(false)
}
}
return (
<>
<PageHeader pretitle="Справочники" title="Хостеры" />
@@ -64,6 +103,9 @@ export function ProvidersPage({ db, actions }) {
onClick={() => {
setForm(emptyForm)
setEditingId(null)
setApiProbeLogin('')
setApiProbePassword('')
setTestConnectionResult(null)
setIsModalOpen(true)
}}
>
@@ -79,6 +121,7 @@ export function ProvidersPage({ db, actions }) {
<th>Сайт</th>
<th>Валюта / курсы</th>
<th>Контакт</th>
<th>API</th>
<th />
</tr>
</thead>
@@ -107,6 +150,13 @@ export function ProvidersPage({ db, actions }) {
</div>
</td>
<td>{provider.contact || '-'}</td>
<td>
{provider.apiType === 'billmanager' && (provider.apiBaseUrl || '').trim() ? (
<span className="text-secondary small">BILLmanager</span>
) : (
'—'
)}
</td>
<td className="text-end">
<div className="table-actions">
<button
@@ -128,7 +178,7 @@ export function ProvidersPage({ db, actions }) {
</tr>
))}
{db.providers.length === 0 ? (
<EmptyState message="Пока нет хостеров" colSpan={5} />
<EmptyState message="Пока нет хостеров" colSpan={6} />
) : null}
</tbody>
</table>
@@ -144,6 +194,9 @@ export function ProvidersPage({ db, actions }) {
setIsModalOpen(false)
setEditingId(null)
setForm(emptyForm)
setApiProbeLogin('')
setApiProbePassword('')
setTestConnectionResult(null)
}}
size="modal-md"
>
@@ -218,6 +271,88 @@ export function ProvidersPage({ db, actions }) {
onChange={(e) => setForm((prev) => ({ ...prev, notes: e.target.value }))}
/>
</div>
<div className="col-12">
<hr className="my-2" />
<h6 className="text-secondary mb-2">Интеграция API (один URL на хостера)</h6>
<div className="text-secondary small mb-2">
Логин и пароль задаются в карточке каждого аккаунта этого хостера.
</div>
</div>
<div className="col-12">
<label className="form-label">Тип API</label>
<select
autoComplete="off"
className="form-select"
value={form.apiType}
onChange={(e) =>
setForm((prev) => ({
...prev,
apiType: e.target.value,
apiBaseUrl: e.target.value === 'billmanager' ? prev.apiBaseUrl : '',
}))
}
>
<option value=""> Не использовать </option>
<option value="billmanager">BILLmanager</option>
</select>
</div>
{form.apiType === 'billmanager' ? (
<>
<div className="col-12">
<label className="form-label">URL API BILLmanager</label>
<input
{...noBrowserSuggestProps}
className="form-control"
placeholder="https://bill.example.com:1500/billmgr"
value={form.apiBaseUrl}
onChange={(e) => setForm((prev) => ({ ...prev, apiBaseUrl: e.target.value }))}
/>
</div>
<div className="col-12 col-sm-6">
<label className="form-label">Тест: логин аккаунта</label>
<input
{...noBrowserSuggestProps}
className="form-control"
placeholder="не сохраняется"
value={apiProbeLogin}
onChange={(e) => setApiProbeLogin(e.target.value)}
/>
</div>
<div className="col-12 col-sm-6">
<label className="form-label">Тест: пароль</label>
<input
{...passwordCredentialInputProps}
type="password"
className="form-control"
placeholder="не сохраняется"
value={apiProbePassword}
onChange={(e) => setApiProbePassword(e.target.value)}
/>
</div>
<div className="col-12 d-flex align-items-center gap-2 flex-wrap">
<button
type="button"
className="btn btn-outline-secondary btn-sm"
onClick={onTestConnection}
disabled={!canTestApi || testConnectionLoading}
>
{testConnectionLoading ? (
<span className="spinner-border spinner-border-sm me-1" role="status" />
) : (
<IconPlugConnected size={14} className="me-1" />
)}
Проверить соединение
</button>
{testConnectionResult ? (
<span className={testConnectionResult.ok ? 'text-success small' : 'text-danger small'}>
{testConnectionResult.ok
? `Соединение успешно${testConnectionResult.vdsCount != null ? `, VDS: ${testConnectionResult.vdsCount}` : ''}`
: testConnectionResult.error}
</span>
) : null}
</div>
</>
) : null}
<div className="col-12 d-flex gap-2 justify-content-end">
<button type="button" className="btn btn-outline-secondary" onClick={() => setIsModalOpen(false)}>
Отмена
+3 -2
View File
@@ -12,6 +12,7 @@ import { syncAccount } from '../lib/api'
import { EmptyState } from '../components/EmptyState'
import { PageHeader } from '../components/PageHeader'
import { noBrowserSuggestProps } from '../lib/noBrowserSuggestProps'
import { billmanagerSyncableAccounts } from '../lib/billmanager-ui'
const SORT_COLUMNS = ['name', 'vcpu', 'ramGb', 'diskGb', 'diskType', 'virtualization', 'channel', 'country', 'location', 'price']
@@ -61,8 +62,8 @@ export function TariffsPage({ db, actions, settings, ratesData }) {
const baseCurrency = (settings?.[0]?.baseCurrency || 'RUB').toUpperCase()
const billmanagerAccounts = useMemo(
() => db.providerAccounts.filter((a) => a.apiType === 'billmanager' && a.apiBaseUrl),
[db.providerAccounts],
() => billmanagerSyncableAccounts(db.providerAccounts, db.providers),
[db.providerAccounts, db.providers],
)
const filteredAndSortedTariffs = useMemo(() => {
+3 -2
View File
@@ -27,6 +27,7 @@ import { PageHeader } from '../components/PageHeader'
import { ProjectSuggestInput } from '../components/ProjectSuggestInput'
import { noBrowserSuggestProps } from '../lib/noBrowserSuggestProps'
import { getPaidUntilDate as computePaidUntilForHealth } from '../lib/paid-until'
import { billmanagerSyncableAccounts } from '../lib/billmanager-ui'
const emptyForm = {
ip: '',
@@ -140,8 +141,8 @@ export function VpsPage({ db, actions, settings, ratesData }) {
const baseCurrency = settings?.[0]?.baseCurrency || 'RUB'
const billmanagerAccounts = useMemo(
() => db.providerAccounts.filter((a) => a.apiType === 'billmanager' && a.apiBaseUrl),
[db.providerAccounts],
() => billmanagerSyncableAccounts(db.providerAccounts, db.providers),
[db.providerAccounts, db.providers],
)
const providerById = useMemo(