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
+2 -2
View File
@@ -37,8 +37,8 @@ vps-tracker/
| Сущность | Описание | | Сущность | Описание |
|----------|----------| |----------|----------|
| **providers** | Хостинг-провайдеры (Selectel, Firstbyte и т.д.) | | **providers** | Хостинг-провайдеры; для BILLmanager: **apiType**, **apiBaseUrl** (один URL на хостера) |
| **provider_accounts** | Аккаунты у провайдера, могут иметь apiType=billmanager для синка | | **provider_accounts** | Аккаунты у провайдера; **apiCredentials** (логин:пароль API) для синка с BILLmanager |
| **vps** | Виртуальные серверы (ip, ram, disk, tariffType, paidUntil) | | **vps** | Виртуальные серверы (ip, ram, disk, tariffType, paidUntil) |
| **payments** | Платежи (пополнение баланса, оплата VPS) | | **payments** | Платежи (пополнение баланса, оплата VPS) |
| **balance_ledger** | Движения по балансу | | **balance_ledger** | Движения по балансу |
+1 -1
View File
@@ -7,7 +7,7 @@ import { mapVdsToVps, mapPaymentToPayment } from './mappers.js'
/** /**
* Sync BILLmanager data into vps-tracker DB * Sync BILLmanager data into vps-tracker DB
* @param {object} account - provider_account with apiBaseUrl, apiCredentials * @param {object} account - provider_account с apiCredentials и apiBaseUrl (URL с хостера или уже подставленный)
* @param {object} db - getDb() wrapper * @param {object} db - getDb() wrapper
* @param {object} [opts] - { paymentDaysBack, skipTariffs, skipVpsPayments } * @param {object} [opts] - { paymentDaysBack, skipTariffs, skipVpsPayments }
* @returns {{ vpsCount: number, paymentsCount: number, tariffsCount: number, balance?: object }} * @returns {{ vpsCount: number, paymentsCount: number, tariffsCount: number, balance?: object }}
+70
View File
@@ -4,6 +4,60 @@
import { randomUUID } from 'node:crypto' import { randomUUID } from 'node:crypto'
/**
* Перенос apiType/apiBaseUrl с аккаунтов на хостера (idempotent).
* Вызывается из миграции и после импорта бэкапа / старого migrate API.
* @param {import('sql.js').Database} db
*/
function selectAllObjects(db, sql, params = []) {
const prepared = db.prepare(sql)
if (typeof prepared.all === 'function') {
return prepared.all(...params)
}
const stmt = prepared
stmt.bind(params)
const rows = []
while (stmt.step()) {
rows.push(stmt.getAsObject())
}
stmt.free()
return rows
}
export function consolidateProviderApiFromAccounts(db) {
const provRows = selectAllObjects(db, 'SELECT id, apiType, apiBaseUrl FROM providers')
for (const prov of provRows) {
const pid = prov.id
if (String(prov.apiType || '').trim() || String(prov.apiBaseUrl || '').trim()) {
continue
}
const accRows = selectAllObjects(
db,
`SELECT apiBaseUrl FROM provider_accounts
WHERE providerId = ?
AND lower(trim(COALESCE(apiType, ''))) = 'billmanager'
AND length(trim(COALESCE(apiBaseUrl, ''))) > 0
ORDER BY id`,
[pid],
)
if (!accRows.length) continue
const urls = [...new Set(accRows.map((r) => String(r.apiBaseUrl || '').trim()).filter(Boolean))]
if (urls.length > 1) {
console.warn(
`[vps-tracker] У хостера ${pid} у нескольких аккаунтов разный URL BILLmanager — в настройках хостера взят первый.`,
)
}
const apiBaseUrl = urls[0]
db.run(`UPDATE providers SET apiType = ?, apiBaseUrl = ? WHERE id = ?`, 'billmanager', apiBaseUrl, pid)
db.run(
`UPDATE provider_accounts SET apiType = '', apiBaseUrl = ''
WHERE providerId = ? AND lower(trim(COALESCE(apiType, ''))) = 'billmanager'
AND length(trim(COALESCE(apiBaseUrl, ''))) > 0`,
pid,
)
}
}
export const MIGRATIONS = [ export const MIGRATIONS = [
{ {
name: 'provider_accounts_api', name: 'provider_accounts_api',
@@ -266,4 +320,20 @@ export const MIGRATIONS = [
} }
}, },
}, },
{
name: 'providers_api_integration',
run(db) {
try {
db.exec('ALTER TABLE providers ADD COLUMN apiType TEXT')
} catch (e) {
if (!String(e.message || e).includes('duplicate column')) throw e
}
try {
db.exec('ALTER TABLE providers ADD COLUMN apiBaseUrl TEXT')
} catch (e) {
if (!String(e.message || e).includes('duplicate column')) throw e
}
consolidateProviderApiFromAccounts(db)
},
},
] ]
+3 -1
View File
@@ -11,7 +11,9 @@ CREATE TABLE IF NOT EXISTS providers (
baseCurrency TEXT, baseCurrency TEXT,
usdRate TEXT, usdRate TEXT,
eurRate TEXT, eurRate TEXT,
notes TEXT notes TEXT,
apiType TEXT,
apiBaseUrl TEXT
); );
CREATE TABLE IF NOT EXISTS provider_accounts ( CREATE TABLE IF NOT EXISTS provider_accounts (
+23 -3
View File
@@ -31,17 +31,37 @@ export function seed(db, seedDir) {
const run = db.run.bind(db) const run = db.run.bind(db)
for (const r of providers) { for (const r of providers) {
run( run(
'INSERT OR IGNORE INTO providers (id, name, website, contact, baseCurrency, usdRate, eurRate, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', 'INSERT OR IGNORE INTO providers (id, name, website, contact, baseCurrency, usdRate, eurRate, notes, apiType, apiBaseUrl) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[r.id, r.name ?? '', r.website ?? '', r.contact ?? '', r.baseCurrency ?? '', r.usdRate ?? '', r.eurRate ?? '', r.notes ?? ''], [
r.id,
r.name ?? '',
r.website ?? '',
r.contact ?? '',
r.baseCurrency ?? '',
r.usdRate ?? '',
r.eurRate ?? '',
r.notes ?? '',
r.apiType ?? '',
r.apiBaseUrl ?? '',
],
) )
} }
const providerAccounts = loadJson(join(seedDir, 'provider-accounts.json')) const providerAccounts = loadJson(join(seedDir, 'provider-accounts.json'))
for (const r of providerAccounts) { for (const r of providerAccounts) {
const legacyType = r.apiType ?? ''
const legacyUrl = r.apiBaseUrl ?? ''
run( run(
'INSERT OR IGNORE INTO provider_accounts (id, providerId, name, panelUrl, currency, billingMode, notes, apiType, apiBaseUrl, apiCredentials) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', 'INSERT OR IGNORE INTO provider_accounts (id, providerId, name, panelUrl, currency, billingMode, notes, apiType, apiBaseUrl, apiCredentials) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[r.id, r.providerId ?? '', r.name ?? '', r.panelUrl ?? '', r.currency ?? '', r.billingMode ?? '', r.notes ?? '', r.apiType ?? '', r.apiBaseUrl ?? '', r.apiCredentials ?? ''], [r.id, r.providerId ?? '', r.name ?? '', r.panelUrl ?? '', r.currency ?? '', r.billingMode ?? '', r.notes ?? '', '', '', r.apiCredentials ?? ''],
) )
if (legacyType === 'billmanager' && String(legacyUrl).trim()) {
run(
`UPDATE providers SET apiType = 'billmanager', apiBaseUrl = ? WHERE id = ? AND length(trim(COALESCE(apiBaseUrl,''))) = 0`,
String(legacyUrl).trim(),
r.providerId ?? '',
)
}
} }
const vpsList = loadJson(join(seedDir, 'vps.json')) const vpsList = loadJson(join(seedDir, 'vps.json'))
+6 -1
View File
@@ -2,6 +2,7 @@ import { Router } from 'express'
import express from 'express' import express from 'express'
import { readFileSync, existsSync } from 'node:fs' import { readFileSync, existsSync } from 'node:fs'
import { getDb, saveDb, DB_PATH, reloadDatabaseFromBuffer } from '../db.js' import { getDb, saveDb, DB_PATH, reloadDatabaseFromBuffer } from '../db.js'
import { consolidateProviderApiFromAccounts } from '../db/migrations.js'
import { rowToVps } from './vps.js' import { rowToVps } from './vps.js'
import { rowToActiveTariff, rowToTariffSyncOptions } from '../utils/row-mappers.js' import { rowToActiveTariff, rowToTariffSyncOptions } from '../utils/row-mappers.js'
@@ -111,7 +112,7 @@ function importJsonSnapshot(data) {
const providers = Array.isArray(data.providers) ? data.providers : [] const providers = Array.isArray(data.providers) ? data.providers : []
for (const p of providers) { for (const p of providers) {
run( run(
`INSERT OR REPLACE INTO providers (id, name, website, contact, baseCurrency, usdRate, eurRate, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, `INSERT OR REPLACE INTO providers (id, name, website, contact, baseCurrency, usdRate, eurRate, notes, apiType, apiBaseUrl) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
p.id ?? '', p.id ?? '',
p.name ?? '', p.name ?? '',
p.website ?? '', p.website ?? '',
@@ -120,6 +121,8 @@ function importJsonSnapshot(data) {
p.usdRate ?? '', p.usdRate ?? '',
p.eurRate ?? '', p.eurRate ?? '',
p.notes ?? '', p.notes ?? '',
p.apiType ?? '',
p.apiBaseUrl ?? '',
) )
} }
@@ -159,6 +162,8 @@ function importJsonSnapshot(data) {
) )
} }
consolidateProviderApiFromAccounts(db)
const settingsList = Array.isArray(data.settings) ? data.settings : data.settings ? [data.settings] : [] const settingsList = Array.isArray(data.settings) ? data.settings : data.settings ? [data.settings] : []
for (const s of settingsList) { for (const s of settingsList) {
let customFields = s.customFields let customFields = s.customFields
+16 -2
View File
@@ -1,5 +1,6 @@
import { Router } from 'express' import { Router } from 'express'
import { getDb, saveDb } from '../db.js' import { getDb, saveDb } from '../db.js'
import { consolidateProviderApiFromAccounts } from '../db/migrations.js'
const router = Router() const router = Router()
@@ -14,10 +15,22 @@ router.post('/', (req, res) => {
const settingsList = Array.isArray(data.settings) ? data.settings : (data.settings ? [data.settings] : []) const settingsList = Array.isArray(data.settings) ? data.settings : (data.settings ? [data.settings] : [])
if (Array.isArray(data.providers) && data.providers.length > 0) { if (Array.isArray(data.providers) && data.providers.length > 0) {
const sql = `INSERT OR REPLACE INTO providers (id, name, website, contact, baseCurrency, usdRate, eurRate, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?)` const sql = `INSERT OR REPLACE INTO providers (id, name, website, contact, baseCurrency, usdRate, eurRate, notes, apiType, apiBaseUrl) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
for (const r of data.providers) { for (const r of data.providers) {
const p = typeof r === 'object' ? r : {} const p = typeof r === 'object' ? r : {}
db.run(sql, p.id ?? '', p.name ?? '', p.website ?? '', p.contact ?? '', p.baseCurrency ?? '', p.usdRate ?? '', p.eurRate ?? '', p.notes ?? '') db.run(
sql,
p.id ?? '',
p.name ?? '',
p.website ?? '',
p.contact ?? '',
p.baseCurrency ?? '',
p.usdRate ?? '',
p.eurRate ?? '',
p.notes ?? '',
p.apiType ?? '',
p.apiBaseUrl ?? '',
)
} }
} }
if (Array.isArray(data.providerAccounts) && data.providerAccounts.length > 0) { if (Array.isArray(data.providerAccounts) && data.providerAccounts.length > 0) {
@@ -59,6 +72,7 @@ router.post('/', (req, res) => {
db.run(sql, s.id ?? 'settings-main', s.baseCurrency ?? 'RUB', s.ratesUrl ?? '', s.autoConvert !== false ? 1 : 0, s.ratesUpdatedAt ?? '', s.syncEnabled ? 1 : 0, s.syncIntervalMinutes ?? 60) db.run(sql, s.id ?? 'settings-main', s.baseCurrency ?? 'RUB', s.ratesUrl ?? '', s.autoConvert !== false ? 1 : 0, s.ratesUpdatedAt ?? '', s.syncEnabled ? 1 : 0, s.syncIntervalMinutes ?? 60)
} }
} }
consolidateProviderApiFromAccounts(db)
saveDb() saveDb()
res.json({ ok: true }) res.json({ ok: true })
+3 -8
View File
@@ -30,7 +30,7 @@ router.post('/', (req, res) => {
: null : null
db.prepare(` db.prepare(`
INSERT INTO provider_accounts (id, providerId, name, panelUrl, currency, billingMode, notes, apiType, apiBaseUrl, apiCredentials, balance_alert_below) INSERT INTO provider_accounts (id, providerId, name, panelUrl, currency, billingMode, notes, apiType, apiBaseUrl, apiCredentials, balance_alert_below)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, '', '', ?, ?)
`).run( `).run(
id, id,
r.providerId ?? '', r.providerId ?? '',
@@ -39,8 +39,6 @@ router.post('/', (req, res) => {
r.currency ?? '', r.currency ?? '',
r.billingMode ?? '', r.billingMode ?? '',
r.notes ?? '', r.notes ?? '',
r.apiType ?? '',
r.apiBaseUrl ?? '',
r.apiCredentials ?? '', r.apiCredentials ?? '',
Number.isFinite(alertBelow) ? alertBelow : null, Number.isFinite(alertBelow) ? alertBelow : null,
) )
@@ -58,8 +56,6 @@ router.put('/:id', (req, res) => {
const r = req.body const r = req.body
const existing = db.prepare('SELECT * FROM provider_accounts WHERE id = ?').get(id) const existing = db.prepare('SELECT * FROM provider_accounts WHERE id = ?').get(id)
if (!existing) return res.status(404).json({ error: 'Not found' }) if (!existing) return res.status(404).json({ error: 'Not found' })
const apiType = r.apiType !== undefined ? String(r.apiType || '') : (existing.apiType || '')
const apiBaseUrl = r.apiBaseUrl !== undefined ? String(r.apiBaseUrl || '') : (existing.apiBaseUrl || '')
const apiCredentials = r.apiCredentials !== undefined ? String(r.apiCredentials || '') : (existing.apiCredentials || '') const apiCredentials = r.apiCredentials !== undefined ? String(r.apiCredentials || '') : (existing.apiCredentials || '')
let balanceAlertBelow = existing.balance_alert_below let balanceAlertBelow = existing.balance_alert_below
if (r.balance_alert_below !== undefined) { if (r.balance_alert_below !== undefined) {
@@ -73,7 +69,8 @@ router.put('/:id', (req, res) => {
} }
db.prepare(` db.prepare(`
UPDATE provider_accounts SET UPDATE provider_accounts SET
providerId = ?, name = ?, panelUrl = ?, currency = ?, billingMode = ?, notes = ?, apiType = ?, apiBaseUrl = ?, apiCredentials = ?, balance_alert_below = ? providerId = ?, name = ?, panelUrl = ?, currency = ?, billingMode = ?, notes = ?,
apiType = '', apiBaseUrl = '', apiCredentials = ?, balance_alert_below = ?
WHERE id = ? WHERE id = ?
`).run( `).run(
r.providerId ?? existing.providerId ?? '', r.providerId ?? existing.providerId ?? '',
@@ -82,8 +79,6 @@ router.put('/:id', (req, res) => {
r.currency ?? existing.currency ?? '', r.currency ?? existing.currency ?? '',
r.billingMode ?? existing.billingMode ?? '', r.billingMode ?? existing.billingMode ?? '',
r.notes ?? existing.notes ?? '', r.notes ?? existing.notes ?? '',
apiType,
apiBaseUrl,
apiCredentials, apiCredentials,
balanceAlertBelow, balanceAlertBelow,
id, id,
+13 -4
View File
@@ -19,8 +19,8 @@ router.post('/', (req, res) => {
const r = req.body const r = req.body
const id = r.id || `provider-${Date.now()}-${Math.random().toString(36).slice(2, 9)}` const id = r.id || `provider-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
db.prepare(` db.prepare(`
INSERT INTO providers (id, name, website, contact, baseCurrency, usdRate, eurRate, notes) INSERT INTO providers (id, name, website, contact, baseCurrency, usdRate, eurRate, notes, apiType, apiBaseUrl)
VALUES (?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run( `).run(
id, id,
r.name ?? '', r.name ?? '',
@@ -30,6 +30,8 @@ router.post('/', (req, res) => {
r.usdRate ?? '', r.usdRate ?? '',
r.eurRate ?? '', r.eurRate ?? '',
r.notes ?? '', r.notes ?? '',
r.apiType ?? '',
r.apiBaseUrl ?? '',
) )
const row = db.prepare('SELECT * FROM providers WHERE id = ?').get(id) const row = db.prepare('SELECT * FROM providers WHERE id = ?').get(id)
res.status(201).json(row) res.status(201).json(row)
@@ -43,9 +45,15 @@ router.put('/:id', (req, res) => {
const db = getDb() const db = getDb()
const { id } = req.params const { id } = req.params
const r = req.body const r = req.body
const existing = db.prepare('SELECT * FROM providers WHERE id = ?').get(id)
if (!existing) return res.status(404).json({ error: 'Not found' })
const apiType = r.apiType !== undefined ? String(r.apiType || '') : (existing.apiType || '')
const apiBaseUrl =
r.apiBaseUrl !== undefined ? String(r.apiBaseUrl || '') : (existing.apiBaseUrl || '')
db.prepare(` db.prepare(`
UPDATE providers SET UPDATE providers SET
name = ?, website = ?, contact = ?, baseCurrency = ?, usdRate = ?, eurRate = ?, notes = ? name = ?, website = ?, contact = ?, baseCurrency = ?, usdRate = ?, eurRate = ?, notes = ?,
apiType = ?, apiBaseUrl = ?
WHERE id = ? WHERE id = ?
`).run( `).run(
r.name ?? '', r.name ?? '',
@@ -55,10 +63,11 @@ router.put('/:id', (req, res) => {
r.usdRate ?? '', r.usdRate ?? '',
r.eurRate ?? '', r.eurRate ?? '',
r.notes ?? '', r.notes ?? '',
apiType,
apiBaseUrl,
id, id,
) )
const row = db.prepare('SELECT * FROM providers WHERE id = ?').get(id) const row = db.prepare('SELECT * FROM providers WHERE id = ?').get(id)
if (!row) return res.status(404).json({ error: 'Not found' })
res.json(row) res.json(row)
} catch (err) { } catch (err) {
res.status(500).json({ error: err.message }) res.status(500).json({ error: err.message })
+23 -12
View File
@@ -2,6 +2,7 @@ import { Router } from 'express'
import { getDb } from '../db.js' import { getDb } from '../db.js'
import { fetchDashboardInfo, testConnection } from '../adapters/billmanager/index.js' import { fetchDashboardInfo, testConnection } from '../adapters/billmanager/index.js'
import { runBillmanagerAccountSync } from '../sync-account-job.js' import { runBillmanagerAccountSync } from '../sync-account-job.js'
import { billmanagerAccountRowForSync } from '../utils/billmanager-context.js'
const router = Router() const router = Router()
@@ -51,13 +52,19 @@ router.get('/:accountId/balance', async (req, res) => {
if (!row) { if (!row) {
return res.status(404).json({ error: 'Account not found' }) return res.status(404).json({ error: 'Account not found' })
} }
if (row.apiType !== 'billmanager') { const provider = row.providerId
return res.status(400).json({ error: 'Account is not configured for BILLmanager API' }) ? db.prepare('SELECT * FROM providers WHERE id = ?').get(row.providerId)
: null
const syncRow = billmanagerAccountRowForSync(row, provider)
if (!syncRow) {
return res.status(400).json({
error:
'Укажите в настройках хостера тип API BILLmanager и URL; в аккаунте — логин и пароль API',
})
} }
if (!row.apiBaseUrl?.trim() || !row.apiCredentials?.trim()) { const info = await fetchDashboardInfo(syncRow.apiBaseUrl, syncRow.apiCredentials.trim(), {
return res.status(400).json({ error: 'API URL and credentials are required' }) fallbackCurrency: row.currency,
} })
const info = await fetchDashboardInfo(row.apiBaseUrl, row.apiCredentials.trim(), { fallbackCurrency: row.currency })
db.run( db.run(
'UPDATE provider_accounts SET balance_api=?, balance_currency=?, balance_updated_at=?, enoughmoneyto=? WHERE id=?', 'UPDATE provider_accounts SET balance_api=?, balance_currency=?, balance_updated_at=?, enoughmoneyto=? WHERE id=?',
info.balance, info.balance,
@@ -82,15 +89,19 @@ router.post('/:accountId', async (req, res) => {
if (!row) { if (!row) {
return res.status(404).json({ error: 'Account not found' }) return res.status(404).json({ error: 'Account not found' })
} }
if (row.apiType !== 'billmanager') { const provider = row.providerId
return res.status(400).json({ error: 'Account is not configured for BILLmanager API' }) ? db.prepare('SELECT * FROM providers WHERE id = ?').get(row.providerId)
} : null
if (!row.apiBaseUrl?.trim() || !row.apiCredentials?.trim()) { const syncRow = billmanagerAccountRowForSync(row, provider)
return res.status(400).json({ error: 'API URL and credentials are required' }) if (!syncRow) {
return res.status(400).json({
error:
'Укажите в настройках хостера тип API BILLmanager и URL; в аккаунте — логин и пароль API',
})
} }
const opts = onlyTariffs ? { skipVpsPayments: true } : { skipTariffs: true } const opts = onlyTariffs ? { skipVpsPayments: true } : { skipTariffs: true }
const result = await runBillmanagerAccountSync(row, opts) const result = await runBillmanagerAccountSync(syncRow, opts)
res.json({ res.json({
ok: true, ok: true,
+22 -6
View File
@@ -1,6 +1,7 @@
import { getDb } from './db.js' import { getDb } from './db.js'
import { runBillmanagerAccountSync } from './sync-account-job.js' import { runBillmanagerAccountSync } from './sync-account-job.js'
import { sendTelegramMessage } from './telegram.js' import { sendTelegramMessage } from './telegram.js'
import { billmanagerAccountRowForSync } from './utils/billmanager-context.js'
let syncIntervalId = null let syncIntervalId = null
let syncTariffsIntervalId = null let syncTariffsIntervalId = null
@@ -109,10 +110,18 @@ export async function runScheduledSync() {
const db = getDb() const db = getDb()
const settings = db.prepare('SELECT * FROM settings WHERE id = ?').get('settings-main') const settings = db.prepare('SELECT * FROM settings WHERE id = ?').get('settings-main')
if (!settings?.syncEnabled) return if (!settings?.syncEnabled) return
const accounts = db.prepare(` const accountRows = db.prepare(`
SELECT * FROM provider_accounts SELECT pa.* FROM provider_accounts pa
WHERE apiType = 'billmanager' AND apiBaseUrl IS NOT NULL AND apiBaseUrl != '' AND apiCredentials IS NOT NULL AND apiCredentials != '' INNER JOIN providers p ON p.id = pa.providerId
WHERE lower(trim(COALESCE(p.apiType, ''))) = 'billmanager'
AND length(trim(COALESCE(p.apiBaseUrl, ''))) > 0
AND pa.apiCredentials IS NOT NULL AND length(trim(pa.apiCredentials)) > 0
`).all() `).all()
const providers = db.prepare('SELECT * FROM providers').all()
const providerById = new Map(providers.map((p) => [p.id, p]))
const accounts = accountRows
.map((a) => billmanagerAccountRowForSync(a, providerById.get(a.providerId)))
.filter(Boolean)
const digestLines = [] const digestLines = []
const lowBalanceLines = [] const lowBalanceLines = []
@@ -173,11 +182,18 @@ export async function runScheduledSyncTariffs() {
const db = getDb() const db = getDb()
const settings = db.prepare('SELECT * FROM settings WHERE id = ?').get('settings-main') const settings = db.prepare('SELECT * FROM settings WHERE id = ?').get('settings-main')
if (!settings?.syncEnabled) return if (!settings?.syncEnabled) return
const accounts = db.prepare(` const accountRows = db.prepare(`
SELECT * FROM provider_accounts SELECT pa.* FROM provider_accounts pa
WHERE apiType = 'billmanager' AND apiBaseUrl IS NOT NULL AND apiBaseUrl != '' AND apiCredentials IS NOT NULL AND apiCredentials != '' INNER JOIN providers p ON p.id = pa.providerId
WHERE lower(trim(COALESCE(p.apiType, ''))) = 'billmanager'
AND length(trim(COALESCE(p.apiBaseUrl, ''))) > 0
AND pa.apiCredentials IS NOT NULL AND length(trim(pa.apiCredentials)) > 0
`).all() `).all()
const providers = db.prepare('SELECT * FROM providers ORDER BY name').all() const providers = db.prepare('SELECT * FROM providers ORDER BY name').all()
const providerById = new Map(providers.map((p) => [p.id, p]))
const accounts = accountRows
.map((a) => billmanagerAccountRowForSync(a, providerById.get(a.providerId)))
.filter(Boolean)
for (const account of accounts) { for (const account of accounts) {
try { try {
+29
View File
@@ -0,0 +1,29 @@
/**
* BILLmanager: URL и тип API задаются на хостере (providers), учётные данные — на аккаунте.
* Поддержка fallback на поля аккаунта для старых данных до миграции.
*/
/**
* @param {object|null|undefined} accountRow
* @param {object|null|undefined} providerRow
* @returns {{ apiType: string, apiBaseUrl: string }}
*/
export function resolveBillmanagerApi(accountRow, providerRow) {
const apiType = String(providerRow?.apiType || accountRow?.apiType || '').trim()
const apiBaseUrl = String(providerRow?.apiBaseUrl || accountRow?.apiBaseUrl || '').trim()
return { apiType, apiBaseUrl }
}
/**
* Объект аккаунта с подставленным URL для syncFromBillmanager / balance.
* @param {object} accountRow
* @param {object|null|undefined} providerRow
* @returns {object|null} null если не готово к запросам API
*/
export function billmanagerAccountRowForSync(accountRow, providerRow) {
if (!accountRow) return null
const { apiType, apiBaseUrl } = resolveBillmanagerApi(accountRow, providerRow)
const cred = String(accountRow.apiCredentials || '').trim()
if (apiType !== 'billmanager' || !apiBaseUrl || !cred) return null
return { ...accountRow, apiType: 'billmanager', apiBaseUrl }
}
+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 {{ * @param {{
* vps: object[], * vps: object[],
* providerAccounts: object[], * providerAccounts: object[],
* providers: object[],
* payments: object[], * payments: object[],
* balanceLedger: object[], * balanceLedger: object[],
* syncLog?: object[], * syncLog?: object[],
* }} input * }} input
*/ */
export function computeInventoryHealth(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 now = new Date()
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()) const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const ctx = { vps, providerAccounts, payments, balanceLedger, now } const ctx = { vps, providerAccounts, payments, balanceLedger, now }
@@ -109,9 +111,10 @@ export function computeInventoryHealth(input) {
}) })
} }
const bmAccounts = providerAccounts.filter( const bmAccounts = providerAccounts.filter((a) => {
(a) => a.apiType === 'billmanager' && (a.apiBaseUrl || '').trim() && a.apiCredentialsSet, const p = providerById.get(a.providerId)
) return p?.apiType === 'billmanager' && (p.apiBaseUrl || '').trim() && a.apiCredentialsSet
})
const staleMs = STALE_SYNC_HOURS * 60 * 60 * 1000 const staleMs = STALE_SYNC_HOURS * 60 * 60 * 1000
const staleAccounts = bmAccounts.filter((a) => { const staleAccounts = bmAccounts.filter((a) => {
const t = lastOkSyncFinishedAt(a.id, syncLog) const t = lastOkSyncFinishedAt(a.id, syncLog)
@@ -144,12 +147,15 @@ export function computeInventoryHealth(input) {
/** /**
* @param {object[]} providerAccounts * @param {object[]} providerAccounts
* @param {object[]} providers
* @param {object[]} syncLog * @param {object[]} syncLog
*/ */
export function getStaleSyncAccountIds(providerAccounts, syncLog, now = new Date()) { export function getStaleSyncAccountIds(providerAccounts, providers, syncLog, now = new Date()) {
const bmAccounts = providerAccounts.filter( const providerById = new Map(providers.map((p) => [p.id, p]))
(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 staleMs = STALE_SYNC_HOURS * 60 * 60 * 1000
return bmAccounts return bmAccounts
.filter((a) => { .filter((a) => {
+46 -42
View File
@@ -12,6 +12,11 @@ import { ConvertedAmount } from '../components/ConvertedAmount'
import { syncAccount, testApiConnection, fetchAccountBalance, fetchSyncStatus } from '../lib/api' import { syncAccount, testApiConnection, fetchAccountBalance, fetchSyncStatus } from '../lib/api'
import { noBrowserSuggestProps, passwordCredentialInputProps } from '../lib/noBrowserSuggestProps' import { noBrowserSuggestProps, passwordCredentialInputProps } from '../lib/noBrowserSuggestProps'
import { getBalanceMismatchAccountIds, getStaleSyncAccountIds } from '../lib/inventory-health' import { getBalanceMismatchAccountIds, getStaleSyncAccountIds } from '../lib/inventory-health'
import {
billmanagerSyncableAccounts,
accountBillmanagerUiReady,
accountUsesBillmanagerBalanceApi,
} from '../lib/billmanager-ui'
import { IconRefresh, IconPlugConnected } from '@tabler/icons-react' import { IconRefresh, IconPlugConnected } from '@tabler/icons-react'
const emptyForm = { const emptyForm = {
@@ -21,8 +26,6 @@ const emptyForm = {
currency: 'USD', currency: 'USD',
billingMode: 'monthly', billingMode: 'monthly',
notes: '', notes: '',
apiType: '',
apiBaseUrl: '',
apiLogin: '', apiLogin: '',
apiPassword: '', apiPassword: '',
balance_alert_below: '', balance_alert_below: '',
@@ -51,8 +54,8 @@ export function AccountsPage({ db, actions, settings, ratesData }) {
}, []) }, [])
const billmanagerAccounts = useMemo( const billmanagerAccounts = useMemo(
() => db.providerAccounts.filter((a) => a.apiType === 'billmanager' && a.apiBaseUrl), () => billmanagerSyncableAccounts(db.providerAccounts, db.providers),
[db.providerAccounts], [db.providerAccounts, db.providers],
) )
const [testConnectionLoading, setTestConnectionLoading] = useState(false) const [testConnectionLoading, setTestConnectionLoading] = useState(false)
const [testConnectionResult, setTestConnectionResult] = useState(null) const [testConnectionResult, setTestConnectionResult] = useState(null)
@@ -60,13 +63,13 @@ export function AccountsPage({ db, actions, settings, ratesData }) {
const highlightAccountIds = useMemo(() => { const highlightAccountIds = useMemo(() => {
if (accountsHealth === 'stale-sync') { if (accountsHealth === 'stale-sync') {
return new Set(getStaleSyncAccountIds(db.providerAccounts, syncLog)) return new Set(getStaleSyncAccountIds(db.providerAccounts, db.providers, syncLog))
} }
if (accountsHealth === 'balance-mismatch') { if (accountsHealth === 'balance-mismatch') {
return new Set(getBalanceMismatchAccountIds(db.providerAccounts, db.balanceLedger)) return new Set(getBalanceMismatchAccountIds(db.providerAccounts, db.balanceLedger))
} }
return null return null
}, [accountsHealth, db.providerAccounts, db.balanceLedger, syncLog]) }, [accountsHealth, db.providerAccounts, db.providers, db.balanceLedger, syncLog])
const balances = useMemo(() => { const balances = useMemo(() => {
return db.providerAccounts.map((account) => { 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 getBalance = (accountId) => balances.find((item) => item.accountId === accountId)?.balance || 0
const getDisplayBalance = (account) => { 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 account.balance_api
} }
return getBalance(account.id) return getBalance(account.id)
@@ -116,10 +120,11 @@ export function AccountsPage({ db, actions, settings, ratesData }) {
currency: form.currency, currency: form.currency,
billingMode: form.billingMode, billingMode: form.billingMode,
notes: form.notes, 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}` payload.apiCredentials = `${form.apiLogin}:${form.apiPassword}`
} }
{ {
@@ -146,14 +151,19 @@ export function AccountsPage({ db, actions, settings, ratesData }) {
} }
const onTestConnection = async () => { const onTestConnection = async () => {
if (!form.apiBaseUrl?.trim() || !form.apiLogin?.trim() || !form.apiPassword?.trim()) { const p = db.providers.find((x) => x.id === form.providerId)
setTestConnectionResult({ ok: false, error: 'Заполните URL, логин и пароль' }) const baseUrl = p?.apiType === 'billmanager' ? (p.apiBaseUrl || '').trim() : ''
if (!baseUrl || !form.apiLogin?.trim() || !form.apiPassword?.trim()) {
setTestConnectionResult({
ok: false,
error: 'В настройках хостера укажите URL API; здесь — логин и пароль',
})
return return
} }
setTestConnectionLoading(true) setTestConnectionLoading(true)
setTestConnectionResult(null) setTestConnectionResult(null)
try { try {
const result = await testApiConnection(form.apiBaseUrl, `${form.apiLogin}:${form.apiPassword}`) const result = await testApiConnection(baseUrl, `${form.apiLogin}:${form.apiPassword}`)
setTestConnectionResult(result) setTestConnectionResult(result)
} catch (err) { } catch (err) {
setTestConnectionResult({ ok: false, error: err.message || 'Ошибка проверки' }) setTestConnectionResult({ ok: false, error: err.message || 'Ошибка проверки' })
@@ -170,8 +180,6 @@ export function AccountsPage({ db, actions, settings, ratesData }) {
currency: account.currency || 'USD', currency: account.currency || 'USD',
billingMode: account.billingMode || 'monthly', billingMode: account.billingMode || 'monthly',
notes: account.notes || '', notes: account.notes || '',
apiType: account.apiType || '',
apiBaseUrl: account.apiBaseUrl || '',
apiLogin: '', apiLogin: '',
apiPassword: '', apiPassword: '',
balance_alert_below: 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 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) => { const onSync = async (accountId) => {
setSyncLoadingId(accountId) setSyncLoadingId(accountId)
@@ -333,6 +346,7 @@ export function AccountsPage({ db, actions, settings, ratesData }) {
<tbody> <tbody>
{db.providerAccounts.map((account) => { {db.providerAccounts.map((account) => {
const provider = db.providers.find((item) => item.id === account.providerId) 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 linkedVps = db.vps.filter((item) => item.providerAccountId === account.id)
const rowWarn = highlightAccountIds?.has(account.id) const rowWarn = highlightAccountIds?.has(account.id)
return ( return (
@@ -374,7 +388,7 @@ export function AccountsPage({ db, actions, settings, ratesData }) {
</td> </td>
<td className="text-end"> <td className="text-end">
<div className="table-actions d-flex gap-1 flex-wrap justify-content-end"> <div className="table-actions d-flex gap-1 flex-wrap justify-content-end">
{account.apiType === 'billmanager' && account.apiBaseUrl ? ( {bmUi ? (
<> <>
<button <button
type="button" type="button"
@@ -511,32 +525,22 @@ export function AccountsPage({ db, actions, settings, ratesData }) {
</div> </div>
<div className="col-12"> <div className="col-12">
<hr className="my-2" /> <hr className="my-2" />
<h6 className="text-secondary mb-2">Интеграция API</h6> <h6 className="text-secondary mb-2">Учётные данные API BILLmanager</h6>
</div> {formProvider?.apiType === 'billmanager' && (formProvider.apiBaseUrl || '').trim() ? (
<div className="col-12"> <div className="text-secondary small mb-2">
<label className="form-label">Тип API</label> URL: <code className="user-select-all">{formProvider.apiBaseUrl}</code>
<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 }))}
/>
</div> </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"> <div className="col-12 col-sm-6">
<label className="form-label">Логин</label> <label className="form-label">Логин API</label>
<input {...noBrowserSuggestProps} <input {...noBrowserSuggestProps}
className="form-control" className="form-control"
placeholder="admin" placeholder="admin"
@@ -545,7 +549,7 @@ export function AccountsPage({ db, actions, settings, ratesData }) {
/> />
</div> </div>
<div className="col-12 col-sm-6"> <div className="col-12 col-sm-6">
<label className="form-label">Пароль</label> <label className="form-label">Пароль API</label>
<input {...passwordCredentialInputProps} <input {...passwordCredentialInputProps}
type="password" type="password"
className="form-control" className="form-control"
+2 -1
View File
@@ -204,11 +204,12 @@ export function DashboardPage({ db = {}, settings, ratesData }) {
computeInventoryHealth({ computeInventoryHealth({
vps, vps,
providerAccounts, providerAccounts,
providers,
payments, payments,
balanceLedger, balanceLedger,
syncLog: syncLogRows, syncLog: syncLogRows,
}), }),
[vps, providerAccounts, payments, balanceLedger, syncLogRows], [vps, providerAccounts, providers, payments, balanceLedger, syncLogRows],
) )
const recentSyncFeed = useMemo(() => { const recentSyncFeed = useMemo(() => {
+137 -2
View File
@@ -3,7 +3,9 @@ import { UiModal } from '../components/UiModal'
import { EmptyState } from '../components/EmptyState' import { EmptyState } from '../components/EmptyState'
import { PageHeader } from '../components/PageHeader' import { PageHeader } from '../components/PageHeader'
import { faviconUrlFromWebsite } from '../lib/utils' 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 = { const emptyForm = {
name: '', name: '',
@@ -13,12 +15,18 @@ const emptyForm = {
usdRate: '', usdRate: '',
eurRate: '', eurRate: '',
notes: '', notes: '',
apiType: '',
apiBaseUrl: '',
} }
export function ProvidersPage({ db, actions }) { export function ProvidersPage({ db, actions }) {
const [form, setForm] = useState(emptyForm) const [form, setForm] = useState(emptyForm)
const [editingId, setEditingId] = useState(null) const [editingId, setEditingId] = useState(null)
const [isModalOpen, setIsModalOpen] = useState(false) 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) => { const onSubmit = (event) => {
event.preventDefault() event.preventDefault()
@@ -32,6 +40,9 @@ export function ProvidersPage({ db, actions }) {
} }
setForm(emptyForm) setForm(emptyForm)
setEditingId(null) setEditingId(null)
setApiProbeLogin('')
setApiProbePassword('')
setTestConnectionResult(null)
setIsModalOpen(false) setIsModalOpen(false)
} }
@@ -44,11 +55,39 @@ export function ProvidersPage({ db, actions }) {
usdRate: provider.usdRate || '', usdRate: provider.usdRate || '',
eurRate: provider.eurRate || '', eurRate: provider.eurRate || '',
notes: provider.notes || '', notes: provider.notes || '',
apiType: provider.apiType || '',
apiBaseUrl: provider.apiBaseUrl || '',
}) })
setEditingId(provider.id) setEditingId(provider.id)
setApiProbeLogin('')
setApiProbePassword('')
setTestConnectionResult(null)
setIsModalOpen(true) 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 ( return (
<> <>
<PageHeader pretitle="Справочники" title="Хостеры" /> <PageHeader pretitle="Справочники" title="Хостеры" />
@@ -64,6 +103,9 @@ export function ProvidersPage({ db, actions }) {
onClick={() => { onClick={() => {
setForm(emptyForm) setForm(emptyForm)
setEditingId(null) setEditingId(null)
setApiProbeLogin('')
setApiProbePassword('')
setTestConnectionResult(null)
setIsModalOpen(true) setIsModalOpen(true)
}} }}
> >
@@ -79,6 +121,7 @@ export function ProvidersPage({ db, actions }) {
<th>Сайт</th> <th>Сайт</th>
<th>Валюта / курсы</th> <th>Валюта / курсы</th>
<th>Контакт</th> <th>Контакт</th>
<th>API</th>
<th /> <th />
</tr> </tr>
</thead> </thead>
@@ -107,6 +150,13 @@ export function ProvidersPage({ db, actions }) {
</div> </div>
</td> </td>
<td>{provider.contact || '-'}</td> <td>{provider.contact || '-'}</td>
<td>
{provider.apiType === 'billmanager' && (provider.apiBaseUrl || '').trim() ? (
<span className="text-secondary small">BILLmanager</span>
) : (
'—'
)}
</td>
<td className="text-end"> <td className="text-end">
<div className="table-actions"> <div className="table-actions">
<button <button
@@ -128,7 +178,7 @@ export function ProvidersPage({ db, actions }) {
</tr> </tr>
))} ))}
{db.providers.length === 0 ? ( {db.providers.length === 0 ? (
<EmptyState message="Пока нет хостеров" colSpan={5} /> <EmptyState message="Пока нет хостеров" colSpan={6} />
) : null} ) : null}
</tbody> </tbody>
</table> </table>
@@ -144,6 +194,9 @@ export function ProvidersPage({ db, actions }) {
setIsModalOpen(false) setIsModalOpen(false)
setEditingId(null) setEditingId(null)
setForm(emptyForm) setForm(emptyForm)
setApiProbeLogin('')
setApiProbePassword('')
setTestConnectionResult(null)
}} }}
size="modal-md" size="modal-md"
> >
@@ -218,6 +271,88 @@ export function ProvidersPage({ db, actions }) {
onChange={(e) => setForm((prev) => ({ ...prev, notes: e.target.value }))} onChange={(e) => setForm((prev) => ({ ...prev, notes: e.target.value }))}
/> />
</div> </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"> <div className="col-12 d-flex gap-2 justify-content-end">
<button type="button" className="btn btn-outline-secondary" onClick={() => setIsModalOpen(false)}> <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 { EmptyState } from '../components/EmptyState'
import { PageHeader } from '../components/PageHeader' import { PageHeader } from '../components/PageHeader'
import { noBrowserSuggestProps } from '../lib/noBrowserSuggestProps' import { noBrowserSuggestProps } from '../lib/noBrowserSuggestProps'
import { billmanagerSyncableAccounts } from '../lib/billmanager-ui'
const SORT_COLUMNS = ['name', 'vcpu', 'ramGb', 'diskGb', 'diskType', 'virtualization', 'channel', 'country', 'location', 'price'] 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 baseCurrency = (settings?.[0]?.baseCurrency || 'RUB').toUpperCase()
const billmanagerAccounts = useMemo( const billmanagerAccounts = useMemo(
() => db.providerAccounts.filter((a) => a.apiType === 'billmanager' && a.apiBaseUrl), () => billmanagerSyncableAccounts(db.providerAccounts, db.providers),
[db.providerAccounts], [db.providerAccounts, db.providers],
) )
const filteredAndSortedTariffs = useMemo(() => { const filteredAndSortedTariffs = useMemo(() => {
+3 -2
View File
@@ -27,6 +27,7 @@ import { PageHeader } from '../components/PageHeader'
import { ProjectSuggestInput } from '../components/ProjectSuggestInput' import { ProjectSuggestInput } from '../components/ProjectSuggestInput'
import { noBrowserSuggestProps } from '../lib/noBrowserSuggestProps' import { noBrowserSuggestProps } from '../lib/noBrowserSuggestProps'
import { getPaidUntilDate as computePaidUntilForHealth } from '../lib/paid-until' import { getPaidUntilDate as computePaidUntilForHealth } from '../lib/paid-until'
import { billmanagerSyncableAccounts } from '../lib/billmanager-ui'
const emptyForm = { const emptyForm = {
ip: '', ip: '',
@@ -140,8 +141,8 @@ export function VpsPage({ db, actions, settings, ratesData }) {
const baseCurrency = settings?.[0]?.baseCurrency || 'RUB' const baseCurrency = settings?.[0]?.baseCurrency || 'RUB'
const billmanagerAccounts = useMemo( const billmanagerAccounts = useMemo(
() => db.providerAccounts.filter((a) => a.apiType === 'billmanager' && a.apiBaseUrl), () => billmanagerSyncableAccounts(db.providerAccounts, db.providers),
[db.providerAccounts], [db.providerAccounts, db.providers],
) )
const providerById = useMemo( const providerById = useMemo(