feat: BILLmanager API на уровне хостера, миграция со старых аккаунтов
Docker / build (push) Failing after 18s
Docker / build (push) Failing after 18s
Made-with: Cursor
This commit is contained in:
@@ -37,8 +37,8 @@ vps-tracker/
|
||||
|
||||
| Сущность | Описание |
|
||||
|----------|----------|
|
||||
| **providers** | Хостинг-провайдеры (Selectel, Firstbyte и т.д.) |
|
||||
| **provider_accounts** | Аккаунты у провайдера, могут иметь apiType=billmanager для синка |
|
||||
| **providers** | Хостинг-провайдеры; для BILLmanager: **apiType**, **apiBaseUrl** (один URL на хостера) |
|
||||
| **provider_accounts** | Аккаунты у провайдера; **apiCredentials** (логин:пароль API) для синка с BILLmanager |
|
||||
| **vps** | Виртуальные серверы (ip, ram, disk, tariffType, paidUntil) |
|
||||
| **payments** | Платежи (пополнение баланса, оплата VPS) |
|
||||
| **balance_ledger** | Движения по балансу |
|
||||
|
||||
@@ -7,7 +7,7 @@ import { mapVdsToVps, mapPaymentToPayment } from './mappers.js'
|
||||
|
||||
/**
|
||||
* 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} [opts] - { paymentDaysBack, skipTariffs, skipVpsPayments }
|
||||
* @returns {{ vpsCount: number, paymentsCount: number, tariffsCount: number, balance?: object }}
|
||||
|
||||
@@ -4,6 +4,60 @@
|
||||
|
||||
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 = [
|
||||
{
|
||||
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
@@ -11,7 +11,9 @@ CREATE TABLE IF NOT EXISTS providers (
|
||||
baseCurrency TEXT,
|
||||
usdRate TEXT,
|
||||
eurRate TEXT,
|
||||
notes TEXT
|
||||
notes TEXT,
|
||||
apiType TEXT,
|
||||
apiBaseUrl TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS provider_accounts (
|
||||
|
||||
+23
-3
@@ -31,17 +31,37 @@ export function seed(db, seedDir) {
|
||||
const run = db.run.bind(db)
|
||||
for (const r of providers) {
|
||||
run(
|
||||
'INSERT OR IGNORE INTO providers (id, name, website, contact, baseCurrency, usdRate, eurRate, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[r.id, r.name ?? '', r.website ?? '', r.contact ?? '', r.baseCurrency ?? '', r.usdRate ?? '', r.eurRate ?? '', r.notes ?? ''],
|
||||
'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.apiType ?? '',
|
||||
r.apiBaseUrl ?? '',
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
const providerAccounts = loadJson(join(seedDir, 'provider-accounts.json'))
|
||||
for (const r of providerAccounts) {
|
||||
const legacyType = r.apiType ?? ''
|
||||
const legacyUrl = r.apiBaseUrl ?? ''
|
||||
run(
|
||||
'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'))
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Router } from 'express'
|
||||
import express from 'express'
|
||||
import { readFileSync, existsSync } from 'node:fs'
|
||||
import { getDb, saveDb, DB_PATH, reloadDatabaseFromBuffer } from '../db.js'
|
||||
import { consolidateProviderApiFromAccounts } from '../db/migrations.js'
|
||||
import { rowToVps } from './vps.js'
|
||||
import { rowToActiveTariff, rowToTariffSyncOptions } from '../utils/row-mappers.js'
|
||||
|
||||
@@ -111,7 +112,7 @@ function importJsonSnapshot(data) {
|
||||
const providers = Array.isArray(data.providers) ? data.providers : []
|
||||
for (const p of providers) {
|
||||
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.name ?? '',
|
||||
p.website ?? '',
|
||||
@@ -120,6 +121,8 @@ function importJsonSnapshot(data) {
|
||||
p.usdRate ?? '',
|
||||
p.eurRate ?? '',
|
||||
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] : []
|
||||
for (const s of settingsList) {
|
||||
let customFields = s.customFields
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Router } from 'express'
|
||||
import { getDb, saveDb } from '../db.js'
|
||||
import { consolidateProviderApiFromAccounts } from '../db/migrations.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
@@ -14,10 +15,22 @@ router.post('/', (req, res) => {
|
||||
const settingsList = Array.isArray(data.settings) ? data.settings : (data.settings ? [data.settings] : [])
|
||||
|
||||
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) {
|
||||
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) {
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
consolidateProviderApiFromAccounts(db)
|
||||
saveDb()
|
||||
|
||||
res.json({ ok: true })
|
||||
|
||||
@@ -30,7 +30,7 @@ router.post('/', (req, res) => {
|
||||
: null
|
||||
db.prepare(`
|
||||
INSERT INTO provider_accounts (id, providerId, name, panelUrl, currency, billingMode, notes, apiType, apiBaseUrl, apiCredentials, balance_alert_below)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, '', '', ?, ?)
|
||||
`).run(
|
||||
id,
|
||||
r.providerId ?? '',
|
||||
@@ -39,8 +39,6 @@ router.post('/', (req, res) => {
|
||||
r.currency ?? '',
|
||||
r.billingMode ?? '',
|
||||
r.notes ?? '',
|
||||
r.apiType ?? '',
|
||||
r.apiBaseUrl ?? '',
|
||||
r.apiCredentials ?? '',
|
||||
Number.isFinite(alertBelow) ? alertBelow : null,
|
||||
)
|
||||
@@ -58,8 +56,6 @@ router.put('/:id', (req, res) => {
|
||||
const r = req.body
|
||||
const existing = db.prepare('SELECT * FROM provider_accounts 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 || '')
|
||||
const apiCredentials = r.apiCredentials !== undefined ? String(r.apiCredentials || '') : (existing.apiCredentials || '')
|
||||
let balanceAlertBelow = existing.balance_alert_below
|
||||
if (r.balance_alert_below !== undefined) {
|
||||
@@ -73,7 +69,8 @@ router.put('/:id', (req, res) => {
|
||||
}
|
||||
db.prepare(`
|
||||
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 = ?
|
||||
`).run(
|
||||
r.providerId ?? existing.providerId ?? '',
|
||||
@@ -82,8 +79,6 @@ router.put('/:id', (req, res) => {
|
||||
r.currency ?? existing.currency ?? '',
|
||||
r.billingMode ?? existing.billingMode ?? '',
|
||||
r.notes ?? existing.notes ?? '',
|
||||
apiType,
|
||||
apiBaseUrl,
|
||||
apiCredentials,
|
||||
balanceAlertBelow,
|
||||
id,
|
||||
|
||||
@@ -19,8 +19,8 @@ router.post('/', (req, res) => {
|
||||
const r = req.body
|
||||
const id = r.id || `provider-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
|
||||
db.prepare(`
|
||||
INSERT INTO providers (id, name, website, contact, baseCurrency, usdRate, eurRate, notes)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO providers (id, name, website, contact, baseCurrency, usdRate, eurRate, notes, apiType, apiBaseUrl)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
id,
|
||||
r.name ?? '',
|
||||
@@ -30,6 +30,8 @@ router.post('/', (req, res) => {
|
||||
r.usdRate ?? '',
|
||||
r.eurRate ?? '',
|
||||
r.notes ?? '',
|
||||
r.apiType ?? '',
|
||||
r.apiBaseUrl ?? '',
|
||||
)
|
||||
const row = db.prepare('SELECT * FROM providers WHERE id = ?').get(id)
|
||||
res.status(201).json(row)
|
||||
@@ -43,9 +45,15 @@ router.put('/:id', (req, res) => {
|
||||
const db = getDb()
|
||||
const { id } = req.params
|
||||
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(`
|
||||
UPDATE providers SET
|
||||
name = ?, website = ?, contact = ?, baseCurrency = ?, usdRate = ?, eurRate = ?, notes = ?
|
||||
name = ?, website = ?, contact = ?, baseCurrency = ?, usdRate = ?, eurRate = ?, notes = ?,
|
||||
apiType = ?, apiBaseUrl = ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
r.name ?? '',
|
||||
@@ -55,10 +63,11 @@ router.put('/:id', (req, res) => {
|
||||
r.usdRate ?? '',
|
||||
r.eurRate ?? '',
|
||||
r.notes ?? '',
|
||||
apiType,
|
||||
apiBaseUrl,
|
||||
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)
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message })
|
||||
|
||||
+23
-12
@@ -2,6 +2,7 @@ import { Router } from 'express'
|
||||
import { getDb } from '../db.js'
|
||||
import { fetchDashboardInfo, testConnection } from '../adapters/billmanager/index.js'
|
||||
import { runBillmanagerAccountSync } from '../sync-account-job.js'
|
||||
import { billmanagerAccountRowForSync } from '../utils/billmanager-context.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
@@ -51,13 +52,19 @@ router.get('/:accountId/balance', async (req, res) => {
|
||||
if (!row) {
|
||||
return res.status(404).json({ error: 'Account not found' })
|
||||
}
|
||||
if (row.apiType !== 'billmanager') {
|
||||
return res.status(400).json({ error: 'Account is not configured for BILLmanager API' })
|
||||
const provider = row.providerId
|
||||
? 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()) {
|
||||
return res.status(400).json({ error: 'API URL and credentials are required' })
|
||||
}
|
||||
const info = await fetchDashboardInfo(row.apiBaseUrl, row.apiCredentials.trim(), { fallbackCurrency: row.currency })
|
||||
const info = await fetchDashboardInfo(syncRow.apiBaseUrl, syncRow.apiCredentials.trim(), {
|
||||
fallbackCurrency: row.currency,
|
||||
})
|
||||
db.run(
|
||||
'UPDATE provider_accounts SET balance_api=?, balance_currency=?, balance_updated_at=?, enoughmoneyto=? WHERE id=?',
|
||||
info.balance,
|
||||
@@ -82,15 +89,19 @@ router.post('/:accountId', async (req, res) => {
|
||||
if (!row) {
|
||||
return res.status(404).json({ error: 'Account not found' })
|
||||
}
|
||||
if (row.apiType !== 'billmanager') {
|
||||
return res.status(400).json({ error: 'Account is not configured for BILLmanager API' })
|
||||
}
|
||||
if (!row.apiBaseUrl?.trim() || !row.apiCredentials?.trim()) {
|
||||
return res.status(400).json({ error: 'API URL and credentials are required' })
|
||||
const provider = row.providerId
|
||||
? 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',
|
||||
})
|
||||
}
|
||||
|
||||
const opts = onlyTariffs ? { skipVpsPayments: true } : { skipTariffs: true }
|
||||
const result = await runBillmanagerAccountSync(row, opts)
|
||||
const result = await runBillmanagerAccountSync(syncRow, opts)
|
||||
|
||||
res.json({
|
||||
ok: true,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getDb } from './db.js'
|
||||
import { runBillmanagerAccountSync } from './sync-account-job.js'
|
||||
import { sendTelegramMessage } from './telegram.js'
|
||||
import { billmanagerAccountRowForSync } from './utils/billmanager-context.js'
|
||||
|
||||
let syncIntervalId = null
|
||||
let syncTariffsIntervalId = null
|
||||
@@ -109,10 +110,18 @@ export async function runScheduledSync() {
|
||||
const db = getDb()
|
||||
const settings = db.prepare('SELECT * FROM settings WHERE id = ?').get('settings-main')
|
||||
if (!settings?.syncEnabled) return
|
||||
const accounts = db.prepare(`
|
||||
SELECT * FROM provider_accounts
|
||||
WHERE apiType = 'billmanager' AND apiBaseUrl IS NOT NULL AND apiBaseUrl != '' AND apiCredentials IS NOT NULL AND apiCredentials != ''
|
||||
const accountRows = db.prepare(`
|
||||
SELECT pa.* FROM provider_accounts pa
|
||||
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()
|
||||
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 lowBalanceLines = []
|
||||
@@ -173,11 +182,18 @@ export async function runScheduledSyncTariffs() {
|
||||
const db = getDb()
|
||||
const settings = db.prepare('SELECT * FROM settings WHERE id = ?').get('settings-main')
|
||||
if (!settings?.syncEnabled) return
|
||||
const accounts = db.prepare(`
|
||||
SELECT * FROM provider_accounts
|
||||
WHERE apiType = 'billmanager' AND apiBaseUrl IS NOT NULL AND apiBaseUrl != '' AND apiCredentials IS NOT NULL AND apiCredentials != ''
|
||||
const accountRows = db.prepare(`
|
||||
SELECT pa.* FROM provider_accounts pa
|
||||
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()
|
||||
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) {
|
||||
try {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
@@ -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)}>
|
||||
Отмена
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user