Enhance database management and synchronization features
- Added new backup and restore functionality for JSON and SQLite database formats. - Implemented notification settings for low balance alerts and sync digests in the settings page. - Updated database schema to include new columns for balance alert thresholds and notification preferences. - Refactored synchronization logic to provide detailed summaries and improved error handling. - Enhanced user interface for managing accounts and settings, including new input fields for balance alerts. - Improved sync log display to include summary information for better tracking of synchronization results.
This commit is contained in:
@@ -35,6 +35,8 @@ export async function syncFromBillmanager(account, db, opts = {}) {
|
|||||||
const { tariffItems = [], slist = {} } = tariffResult || {}
|
const { tariffItems = [], slist = {} } = tariffResult || {}
|
||||||
|
|
||||||
let vpsCount = 0
|
let vpsCount = 0
|
||||||
|
/** @type {{ added: { id: string, label: string }[], updated: { id: string, label: string, fields: string[] }[], paymentsAdded: number }} */
|
||||||
|
const syncSummary = { added: [], updated: [], paymentsAdded: 0 }
|
||||||
if (fetchVpsPayments) {
|
if (fetchVpsPayments) {
|
||||||
const vpsInsertSql = `INSERT INTO vps (id, ip, ipv6, additionalIps, dns, providerId, providerAccountId, country, city, datacenter, os, vcpu, ramGb, diskGb, diskType, virtualization, bandwidthTb, sshPort, rootUser, purpose, environment, project, projectId, monitoringEnabled, backupEnabled, status, tariffType, currency, dailyRate, monthlyRate, createdAt, paidUntil, notes, userOverrides)
|
const vpsInsertSql = `INSERT INTO vps (id, ip, ipv6, additionalIps, dns, providerId, providerAccountId, country, city, datacenter, os, vcpu, ramGb, diskGb, diskType, virtualization, bandwidthTb, sshPort, rootUser, purpose, environment, project, projectId, monitoringEnabled, backupEnabled, status, tariffType, currency, dailyRate, monthlyRate, createdAt, paidUntil, notes, userOverrides)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
@@ -43,6 +45,12 @@ export async function syncFromBillmanager(account, db, opts = {}) {
|
|||||||
|
|
||||||
const SYNC_UPDATE_FIELDS = ['country', 'city', 'datacenter', 'os', 'notes', 'status', 'tariffType', 'currency', 'dailyRate', 'monthlyRate', 'paidUntil']
|
const SYNC_UPDATE_FIELDS = ['country', 'city', 'datacenter', 'os', 'notes', 'status', 'tariffType', 'currency', 'dailyRate', 'monthlyRate', 'paidUntil']
|
||||||
|
|
||||||
|
const normVal = (v) => {
|
||||||
|
if (v == null || v === '') return ''
|
||||||
|
if (typeof v === 'number') return Number.isFinite(v) ? String(v) : ''
|
||||||
|
return String(v)
|
||||||
|
}
|
||||||
|
|
||||||
for (const item of vdsItems) {
|
for (const item of vdsItems) {
|
||||||
const vps = mapVdsToVps(item, providerId, accountId)
|
const vps = mapVdsToVps(item, providerId, accountId)
|
||||||
const id = `vps-bm-${accountId}-${vps.externalId}`
|
const id = `vps-bm-${accountId}-${vps.externalId}`
|
||||||
@@ -82,6 +90,14 @@ export async function syncFromBillmanager(account, db, opts = {}) {
|
|||||||
merged[f] = existing[f]
|
merged[f] = existing[f]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const compareFields = ['ip', 'ipv6', 'dns', ...SYNC_UPDATE_FIELDS]
|
||||||
|
const changedFields = compareFields.filter(
|
||||||
|
(f) => normVal(merged[f]) !== normVal(existing[f]),
|
||||||
|
)
|
||||||
|
if (changedFields.length > 0) {
|
||||||
|
const label = merged.dns || merged.ip || existing.id
|
||||||
|
syncSummary.updated.push({ id: existing.id, label, fields: changedFields })
|
||||||
|
}
|
||||||
db.run(vpsUpdateSql,
|
db.run(vpsUpdateSql,
|
||||||
merged.ip,
|
merged.ip,
|
||||||
merged.ipv6,
|
merged.ipv6,
|
||||||
@@ -101,6 +117,8 @@ export async function syncFromBillmanager(account, db, opts = {}) {
|
|||||||
existing.id,
|
existing.id,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
const label = vps.dns || vps.ip || id
|
||||||
|
syncSummary.added.push({ id, label })
|
||||||
db.run(vpsInsertSql,
|
db.run(vpsInsertSql,
|
||||||
id,
|
id,
|
||||||
vps.ip,
|
vps.ip,
|
||||||
@@ -159,6 +177,7 @@ export async function syncFromBillmanager(account, db, opts = {}) {
|
|||||||
db.run(paymentInsertSql, id, payment.type, payment.date, payment.amount, payment.currency, payment.providerAccountId, payment.vpsId, note)
|
db.run(paymentInsertSql, id, payment.type, payment.date, payment.amount, payment.currency, payment.providerAccountId, payment.vpsId, note)
|
||||||
existingPayments.add(note)
|
existingPayments.add(note)
|
||||||
paymentsCount++
|
paymentsCount++
|
||||||
|
syncSummary.paymentsAdded += 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,5 +248,8 @@ export async function syncFromBillmanager(account, db, opts = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { vpsCount, paymentsCount, tariffsCount, newTariffs, balance: dashboardInfo }
|
if (!fetchVpsPayments) {
|
||||||
|
syncSummary.tariffsOnly = true
|
||||||
|
}
|
||||||
|
return { vpsCount, paymentsCount, tariffsCount, newTariffs, balance: dashboardInfo, syncSummary }
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
/**
|
/**
|
||||||
* Re-export from db module
|
* Re-export from db module
|
||||||
*/
|
*/
|
||||||
export { initDb, getDb, saveDb } from './db/index.js'
|
export { initDb, getDb, saveDb, DB_PATH, reloadDatabaseFromBuffer } from './db/index.js'
|
||||||
|
|||||||
+28
-1
@@ -13,10 +13,12 @@ import { seed, isDbEmpty } from './seed.js'
|
|||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
// db/ is in server/, so .. = server, .. again = project root
|
// db/ is in server/, so .. = server, .. again = project root
|
||||||
const DB_PATH = join(__dirname, '..', '..', 'data', 'vps-tracker.db')
|
export const DB_PATH = join(__dirname, '..', '..', 'data', 'vps-tracker.db')
|
||||||
const SEED_DIR = join(__dirname, '..', '..', 'public', 'data')
|
const SEED_DIR = join(__dirname, '..', '..', 'public', 'data')
|
||||||
|
|
||||||
let dbInstance = null
|
let dbInstance = null
|
||||||
|
/** @type {import('sql.js').SqlJsStatic | null} */
|
||||||
|
let sqlJsFactory = null
|
||||||
|
|
||||||
export async function initDb() {
|
export async function initDb() {
|
||||||
const dataDir = join(__dirname, '..', '..', 'data')
|
const dataDir = join(__dirname, '..', '..', 'data')
|
||||||
@@ -25,6 +27,7 @@ export async function initDb() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SQL = await initSqlJs()
|
const SQL = await initSqlJs()
|
||||||
|
sqlJsFactory = SQL
|
||||||
let db
|
let db
|
||||||
|
|
||||||
if (existsSync(DB_PATH)) {
|
if (existsSync(DB_PATH)) {
|
||||||
@@ -110,3 +113,27 @@ export function saveDb() {
|
|||||||
const data = dbInstance.export()
|
const data = dbInstance.export()
|
||||||
writeFileSync(DB_PATH, Buffer.from(data))
|
writeFileSync(DB_PATH, Buffer.from(data))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Заменить in-memory БД из буфера SQLite и сохранить на диск.
|
||||||
|
* @param {Buffer|Uint8Array} buffer
|
||||||
|
*/
|
||||||
|
export async function reloadDatabaseFromBuffer(buffer) {
|
||||||
|
const SQL = sqlJsFactory || (await initSqlJs())
|
||||||
|
sqlJsFactory = SQL
|
||||||
|
if (dbInstance) {
|
||||||
|
dbInstance.close()
|
||||||
|
dbInstance = null
|
||||||
|
}
|
||||||
|
const u8 = buffer instanceof Buffer ? new Uint8Array(buffer) : buffer
|
||||||
|
dbInstance = new SQL.Database(u8)
|
||||||
|
dbInstance.exec(SCHEMA)
|
||||||
|
for (const m of MIGRATIONS) {
|
||||||
|
try {
|
||||||
|
m.run(dbInstance)
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`Migration ${m.name} after reload failed:`, err.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
saveDb()
|
||||||
|
}
|
||||||
|
|||||||
@@ -231,4 +231,39 @@ export const MIGRATIONS = [
|
|||||||
) WHERE length(trim(COALESCE(vps.project, ''))) > 0`)
|
) WHERE length(trim(COALESCE(vps.project, ''))) > 0`)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'sync_log_summary',
|
||||||
|
run(db) {
|
||||||
|
try {
|
||||||
|
db.exec('ALTER TABLE sync_log ADD COLUMN summary TEXT')
|
||||||
|
} catch (e) {
|
||||||
|
if (!String(e.message || e).includes('duplicate column')) throw e
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'settings_notify_balance_digest',
|
||||||
|
run(db) {
|
||||||
|
try {
|
||||||
|
db.exec('ALTER TABLE settings ADD COLUMN notifyLowBalanceEnabled INTEGER')
|
||||||
|
} catch (e) {
|
||||||
|
if (!String(e.message || e).includes('duplicate column')) throw e
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
db.exec('ALTER TABLE settings ADD COLUMN notifySyncDigestEnabled INTEGER')
|
||||||
|
} catch (e) {
|
||||||
|
if (!String(e.message || e).includes('duplicate column')) throw e
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'provider_accounts_balance_alert_below',
|
||||||
|
run(db) {
|
||||||
|
try {
|
||||||
|
db.exec('ALTER TABLE provider_accounts ADD COLUMN balance_alert_below REAL')
|
||||||
|
} catch (e) {
|
||||||
|
if (!String(e.message || e).includes('duplicate column')) throw e
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
+3
-1
@@ -11,12 +11,13 @@ import balanceLedgerRouter from './routes/balance-ledger.js'
|
|||||||
import settingsRouter from './routes/settings.js'
|
import settingsRouter from './routes/settings.js'
|
||||||
import syncRouter from './routes/sync.js'
|
import syncRouter from './routes/sync.js'
|
||||||
import projectsRouter from './routes/projects.js'
|
import projectsRouter from './routes/projects.js'
|
||||||
|
import backupRouter from './routes/backup.js'
|
||||||
|
|
||||||
const app = express()
|
const app = express()
|
||||||
const PORT = process.env.PORT || 3001
|
const PORT = process.env.PORT || 3001
|
||||||
|
|
||||||
app.use(cors())
|
app.use(cors())
|
||||||
app.use(express.json())
|
app.use(express.json({ limit: '50mb' }))
|
||||||
|
|
||||||
;(async () => {
|
;(async () => {
|
||||||
await initDb()
|
await initDb()
|
||||||
@@ -31,6 +32,7 @@ app.use(express.json())
|
|||||||
app.use('/api/settings', settingsRouter)
|
app.use('/api/settings', settingsRouter)
|
||||||
app.use('/api/sync', syncRouter)
|
app.use('/api/sync', syncRouter)
|
||||||
app.use('/api/projects', projectsRouter)
|
app.use('/api/projects', projectsRouter)
|
||||||
|
app.use('/api/backup', backupRouter)
|
||||||
|
|
||||||
const { startScheduler } = await import('./sync-scheduler.js')
|
const { startScheduler } = await import('./sync-scheduler.js')
|
||||||
startScheduler()
|
startScheduler()
|
||||||
|
|||||||
@@ -0,0 +1,343 @@
|
|||||||
|
import { Router } from 'express'
|
||||||
|
import express from 'express'
|
||||||
|
import { readFileSync, existsSync } from 'node:fs'
|
||||||
|
import { getDb, saveDb, DB_PATH, reloadDatabaseFromBuffer } from '../db.js'
|
||||||
|
import { rowToVps } from './vps.js'
|
||||||
|
import { rowToActiveTariff, rowToTariffSyncOptions } from '../utils/row-mappers.js'
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
const BACKUP_VERSION = 1
|
||||||
|
|
||||||
|
function buildJsonSnapshot() {
|
||||||
|
saveDb()
|
||||||
|
const db = getDb()
|
||||||
|
const vps = db.prepare('SELECT * FROM vps ORDER BY createdAt DESC').all()
|
||||||
|
const providers = db.prepare('SELECT * FROM providers ORDER BY name').all()
|
||||||
|
const providerAccounts = db.prepare('SELECT * FROM provider_accounts ORDER BY name').all()
|
||||||
|
const payments = db.prepare('SELECT * FROM payments ORDER BY date DESC').all()
|
||||||
|
const balanceLedger = db.prepare('SELECT * FROM balance_ledger ORDER BY date DESC').all()
|
||||||
|
const settingsRows = db.prepare('SELECT * FROM settings ORDER BY id').all()
|
||||||
|
const activeTariffs = db.prepare('SELECT * FROM active_tariffs ORDER BY name').all()
|
||||||
|
const tariffSyncOptions = db.prepare('SELECT * FROM tariff_sync_options').all()
|
||||||
|
let serverProjects = []
|
||||||
|
try {
|
||||||
|
serverProjects = db
|
||||||
|
.prepare('SELECT id, name, color, sortOrder, notes, createdAt FROM server_projects ORDER BY name')
|
||||||
|
.all()
|
||||||
|
} catch {
|
||||||
|
serverProjects = []
|
||||||
|
}
|
||||||
|
let syncLog = []
|
||||||
|
try {
|
||||||
|
syncLog = db.prepare('SELECT * FROM sync_log ORDER BY startedAt DESC LIMIT 500').all()
|
||||||
|
} catch {
|
||||||
|
syncLog = []
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
backupVersion: BACKUP_VERSION,
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
vps: vps.map(rowToVps),
|
||||||
|
serverProjects,
|
||||||
|
providers,
|
||||||
|
providerAccounts,
|
||||||
|
payments,
|
||||||
|
balanceLedger,
|
||||||
|
settings: settingsRows,
|
||||||
|
activeTariffs: activeTariffs.map(rowToActiveTariff),
|
||||||
|
tariffSyncOptions: tariffSyncOptions.map(rowToTariffSyncOptions),
|
||||||
|
syncLog,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/json', (req, res) => {
|
||||||
|
try {
|
||||||
|
const snapshot = buildJsonSnapshot()
|
||||||
|
res.setHeader('Content-Type', 'application/json; charset=utf-8')
|
||||||
|
res.setHeader('Content-Disposition', 'attachment; filename="vps-tracker-backup.json"')
|
||||||
|
res.send(JSON.stringify(snapshot, null, 2))
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.get('/database', (req, res) => {
|
||||||
|
try {
|
||||||
|
saveDb()
|
||||||
|
if (!existsSync(DB_PATH)) {
|
||||||
|
return res.status(404).json({ error: 'Файл базы не найден' })
|
||||||
|
}
|
||||||
|
const buf = readFileSync(DB_PATH)
|
||||||
|
res.setHeader('Content-Type', 'application/octet-stream')
|
||||||
|
res.setHeader('Content-Disposition', 'attachment; filename="vps-tracker.db"')
|
||||||
|
res.send(buf)
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.post('/json', (req, res) => {
|
||||||
|
try {
|
||||||
|
const payload = req.body
|
||||||
|
if (!payload || typeof payload !== 'object') {
|
||||||
|
return res.status(400).json({ error: 'Неверное тело запроса' })
|
||||||
|
}
|
||||||
|
importJsonSnapshot(payload)
|
||||||
|
res.json({ ok: true })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Backup JSON import error:', err)
|
||||||
|
res.status(500).json({ error: err.message || 'Импорт не удался' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} data
|
||||||
|
*/
|
||||||
|
function importJsonSnapshot(data) {
|
||||||
|
const db = getDb()
|
||||||
|
const run = (sql, ...params) => db.prepare(sql).run(...params)
|
||||||
|
|
||||||
|
run('DELETE FROM sync_log')
|
||||||
|
run('DELETE FROM tariff_sync_options')
|
||||||
|
run('DELETE FROM active_tariffs')
|
||||||
|
run('DELETE FROM balance_ledger')
|
||||||
|
run('DELETE FROM payments')
|
||||||
|
run('DELETE FROM vps')
|
||||||
|
run('DELETE FROM provider_accounts')
|
||||||
|
run('DELETE FROM server_projects')
|
||||||
|
run('DELETE FROM providers')
|
||||||
|
run('DELETE FROM settings')
|
||||||
|
|
||||||
|
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 (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
p.id ?? '',
|
||||||
|
p.name ?? '',
|
||||||
|
p.website ?? '',
|
||||||
|
p.contact ?? '',
|
||||||
|
p.baseCurrency ?? '',
|
||||||
|
p.usdRate ?? '',
|
||||||
|
p.eurRate ?? '',
|
||||||
|
p.notes ?? '',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const projects = Array.isArray(data.serverProjects) ? data.serverProjects : []
|
||||||
|
for (const sp of projects) {
|
||||||
|
run(
|
||||||
|
`INSERT OR REPLACE INTO server_projects (id, name, color, sortOrder, notes, createdAt) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
sp.id ?? '',
|
||||||
|
sp.name ?? '',
|
||||||
|
sp.color ?? null,
|
||||||
|
sp.sortOrder ?? 0,
|
||||||
|
sp.notes ?? null,
|
||||||
|
sp.createdAt ?? null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const accounts = Array.isArray(data.providerAccounts) ? data.providerAccounts : []
|
||||||
|
for (const acc of accounts) {
|
||||||
|
run(
|
||||||
|
`INSERT OR REPLACE INTO provider_accounts (id, providerId, name, panelUrl, currency, billingMode, notes, apiType, apiBaseUrl, apiCredentials, balance_api, balance_currency, balance_updated_at, enoughmoneyto, balance_alert_below)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
acc.id ?? '',
|
||||||
|
acc.providerId ?? '',
|
||||||
|
acc.name ?? '',
|
||||||
|
acc.panelUrl ?? '',
|
||||||
|
acc.currency ?? '',
|
||||||
|
acc.billingMode ?? '',
|
||||||
|
acc.notes ?? '',
|
||||||
|
acc.apiType ?? '',
|
||||||
|
acc.apiBaseUrl ?? '',
|
||||||
|
acc.apiCredentials ?? '',
|
||||||
|
acc.balance_api ?? null,
|
||||||
|
acc.balance_currency ?? null,
|
||||||
|
acc.balance_updated_at ?? null,
|
||||||
|
acc.enoughmoneyto ?? null,
|
||||||
|
acc.balance_alert_below != null && acc.balance_alert_below !== '' ? Number(acc.balance_alert_below) : null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const settingsList = Array.isArray(data.settings) ? data.settings : data.settings ? [data.settings] : []
|
||||||
|
for (const s of settingsList) {
|
||||||
|
let customFields = s.customFields
|
||||||
|
if (Array.isArray(customFields)) customFields = JSON.stringify(customFields)
|
||||||
|
if (customFields === undefined) customFields = null
|
||||||
|
run(
|
||||||
|
`INSERT OR REPLACE INTO settings (id, baseCurrency, ratesUrl, autoConvert, ratesUpdatedAt, syncEnabled, syncIntervalMinutes, syncTariffsIntervalMinutes, telegramBotToken, telegramChatId, telegramMessageThreadId, notifyPaymentExpiryEnabled, notifyNewTariffsEnabled, customFields, notifyLowBalanceEnabled, notifySyncDigestEnabled)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
s.id ?? 'settings-main',
|
||||||
|
s.baseCurrency ?? 'RUB',
|
||||||
|
s.ratesUrl ?? '',
|
||||||
|
s.autoConvert !== false && s.autoConvert !== 0 ? 1 : 0,
|
||||||
|
s.ratesUpdatedAt ?? '',
|
||||||
|
s.syncEnabled ? 1 : 0,
|
||||||
|
s.syncIntervalMinutes ?? 60,
|
||||||
|
s.syncTariffsIntervalMinutes ?? 1440,
|
||||||
|
s.telegramBotToken ?? '',
|
||||||
|
s.telegramChatId ?? '',
|
||||||
|
s.telegramMessageThreadId ?? '',
|
||||||
|
s.notifyPaymentExpiryEnabled ? 1 : 0,
|
||||||
|
s.notifyNewTariffsEnabled ? 1 : 0,
|
||||||
|
customFields,
|
||||||
|
s.notifyLowBalanceEnabled ? 1 : 0,
|
||||||
|
s.notifySyncDigestEnabled ? 1 : 0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const vpsRows = Array.isArray(data.vps) ? data.vps : []
|
||||||
|
const vpsSql = `INSERT OR REPLACE INTO vps (id, ip, ipv6, additionalIps, dns, providerId, providerAccountId, country, city, datacenter, os, vcpu, ramGb, diskGb, diskType, virtualization, bandwidthTb, sshPort, rootUser, purpose, environment, project, projectId, monitoringEnabled, backupEnabled, status, tariffType, currency, dailyRate, monthlyRate, createdAt, paidUntil, notes, userOverrides)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
|
for (const v of vpsRows) {
|
||||||
|
const additionalIps = Array.isArray(v.additionalIps) ? JSON.stringify(v.additionalIps) : '[]'
|
||||||
|
const dailyRate = v.dailyRate === '' || v.dailyRate == null ? null : Number(v.dailyRate)
|
||||||
|
const monthlyRate = v.monthlyRate === '' || v.monthlyRate == null ? null : Number(v.monthlyRate)
|
||||||
|
const userOverrides = Array.isArray(v.userOverrides) ? JSON.stringify(v.userOverrides) : (v.userOverrides ?? '[]')
|
||||||
|
run(
|
||||||
|
vpsSql,
|
||||||
|
v.id ?? '',
|
||||||
|
v.ip ?? '',
|
||||||
|
v.ipv6 ?? '',
|
||||||
|
additionalIps,
|
||||||
|
v.dns ?? '',
|
||||||
|
v.providerId ?? '',
|
||||||
|
v.providerAccountId ?? '',
|
||||||
|
v.country ?? '',
|
||||||
|
v.city ?? '',
|
||||||
|
v.datacenter ?? '',
|
||||||
|
v.os ?? '',
|
||||||
|
v.vcpu ?? 0,
|
||||||
|
v.ramGb ?? 0,
|
||||||
|
v.diskGb ?? 0,
|
||||||
|
v.diskType ?? '',
|
||||||
|
v.virtualization ?? '',
|
||||||
|
v.bandwidthTb ?? 0,
|
||||||
|
v.sshPort ?? 22,
|
||||||
|
v.rootUser ?? '',
|
||||||
|
v.purpose ?? '',
|
||||||
|
v.environment ?? '',
|
||||||
|
v.project ?? '',
|
||||||
|
v.projectId ?? null,
|
||||||
|
v.monitoringEnabled ? 1 : 0,
|
||||||
|
v.backupEnabled ? 1 : 0,
|
||||||
|
v.status ?? 'active',
|
||||||
|
v.tariffType ?? '',
|
||||||
|
v.currency ?? '',
|
||||||
|
dailyRate,
|
||||||
|
monthlyRate,
|
||||||
|
v.createdAt ?? '',
|
||||||
|
v.paidUntil ?? '',
|
||||||
|
v.notes ?? '',
|
||||||
|
userOverrides,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const payments = Array.isArray(data.payments) ? data.payments : []
|
||||||
|
for (const pm of payments) {
|
||||||
|
run(
|
||||||
|
`INSERT OR REPLACE INTO payments (id, type, date, amount, currency, providerAccountId, vpsId, note) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
pm.id ?? '',
|
||||||
|
pm.type ?? '',
|
||||||
|
pm.date ?? '',
|
||||||
|
Number(pm.amount) || 0,
|
||||||
|
pm.currency ?? '',
|
||||||
|
pm.providerAccountId ?? '',
|
||||||
|
pm.vpsId ?? '',
|
||||||
|
pm.note ?? '',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ledger = Array.isArray(data.balanceLedger) ? data.balanceLedger : []
|
||||||
|
for (const bl of ledger) {
|
||||||
|
run(
|
||||||
|
`INSERT OR REPLACE INTO balance_ledger (id, type, date, amount, currency, direction, providerAccountId, vpsId, note) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
bl.id ?? '',
|
||||||
|
bl.type ?? '',
|
||||||
|
bl.date ?? '',
|
||||||
|
Number(bl.amount) || 0,
|
||||||
|
bl.currency ?? '',
|
||||||
|
bl.direction ?? '',
|
||||||
|
bl.providerAccountId ?? '',
|
||||||
|
bl.vpsId ?? '',
|
||||||
|
bl.note ?? '',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tariffs = Array.isArray(data.activeTariffs) ? data.activeTariffs : []
|
||||||
|
for (const t of tariffs) {
|
||||||
|
run(
|
||||||
|
`INSERT OR REPLACE INTO active_tariffs (id, providerAccountId, providerId, externalId, datacenterKey, datacenterName, name, desc, vcpu, ramGb, diskGb, diskType, virtualization, channel, location, country, cpuModel, orderAvailable, price, syncedAt)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
t.id ?? '',
|
||||||
|
t.providerAccountId ?? '',
|
||||||
|
t.providerId ?? '',
|
||||||
|
t.externalId ?? '',
|
||||||
|
t.datacenterKey ?? '',
|
||||||
|
t.datacenterName ?? '',
|
||||||
|
t.name ?? '',
|
||||||
|
t.desc ?? '',
|
||||||
|
t.vcpu ?? 0,
|
||||||
|
t.ramGb ?? 0,
|
||||||
|
t.diskGb ?? 0,
|
||||||
|
t.diskType ?? '',
|
||||||
|
t.virtualization ?? '',
|
||||||
|
t.channel ?? '',
|
||||||
|
t.location ?? '',
|
||||||
|
t.country ?? '',
|
||||||
|
t.cpuModel ?? '',
|
||||||
|
t.orderAvailable ? 1 : 0,
|
||||||
|
t.price ?? '',
|
||||||
|
t.syncedAt ?? '',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tso = Array.isArray(data.tariffSyncOptions) ? data.tariffSyncOptions : []
|
||||||
|
for (const o of tso) {
|
||||||
|
const dcs = typeof o.datacenters === 'string' ? o.datacenters : JSON.stringify(o.datacenters || [])
|
||||||
|
const pers = typeof o.periods === 'string' ? o.periods : JSON.stringify(o.periods || [])
|
||||||
|
run(
|
||||||
|
`INSERT OR REPLACE INTO tariff_sync_options (providerAccountId, datacenters, periods, syncedAt) VALUES (?, ?, ?, ?)`,
|
||||||
|
o.providerAccountId ?? '',
|
||||||
|
dcs,
|
||||||
|
pers,
|
||||||
|
o.syncedAt ?? '',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const logs = Array.isArray(data.syncLog) ? data.syncLog : []
|
||||||
|
for (const log of logs) {
|
||||||
|
run(
|
||||||
|
`INSERT OR REPLACE INTO sync_log (id, accountId, startedAt, finishedAt, status, vpsCount, paymentsCount, error, summary) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
log.id ?? '',
|
||||||
|
log.accountId ?? '',
|
||||||
|
log.startedAt ?? '',
|
||||||
|
log.finishedAt ?? null,
|
||||||
|
log.status ?? '',
|
||||||
|
log.vpsCount ?? null,
|
||||||
|
log.paymentsCount ?? null,
|
||||||
|
log.error ?? null,
|
||||||
|
typeof log.summary === 'string' ? log.summary : log.summary ? JSON.stringify(log.summary) : null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
saveDb()
|
||||||
|
}
|
||||||
|
|
||||||
|
router.post('/database', express.raw({ limit: '100mb', type: '*/*' }), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const buf = Buffer.isBuffer(req.body) ? req.body : Buffer.from(req.body || [])
|
||||||
|
if (!buf.length) {
|
||||||
|
return res.status(400).json({ error: 'Пустой файл' })
|
||||||
|
}
|
||||||
|
await reloadDatabaseFromBuffer(buf)
|
||||||
|
const { startScheduler } = await import('../sync-scheduler.js')
|
||||||
|
startScheduler()
|
||||||
|
res.json({ ok: true })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Backup DB restore error:', err)
|
||||||
|
res.status(500).json({ error: err.message || 'Восстановление не удалось' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
@@ -24,9 +24,13 @@ router.post('/', (req, res) => {
|
|||||||
const db = getDb()
|
const db = getDb()
|
||||||
const r = req.body
|
const r = req.body
|
||||||
const id = r.id || `account-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
|
const id = r.id || `account-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
|
||||||
|
const alertBelow =
|
||||||
|
r.balance_alert_below != null && r.balance_alert_below !== ''
|
||||||
|
? Number(r.balance_alert_below)
|
||||||
|
: null
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
INSERT INTO provider_accounts (id, providerId, name, panelUrl, currency, billingMode, notes, apiType, apiBaseUrl, apiCredentials)
|
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 ?? '',
|
||||||
@@ -38,6 +42,7 @@ router.post('/', (req, res) => {
|
|||||||
r.apiType ?? '',
|
r.apiType ?? '',
|
||||||
r.apiBaseUrl ?? '',
|
r.apiBaseUrl ?? '',
|
||||||
r.apiCredentials ?? '',
|
r.apiCredentials ?? '',
|
||||||
|
Number.isFinite(alertBelow) ? alertBelow : null,
|
||||||
)
|
)
|
||||||
const row = db.prepare('SELECT * FROM provider_accounts WHERE id = ?').get(id)
|
const row = db.prepare('SELECT * FROM provider_accounts WHERE id = ?').get(id)
|
||||||
res.status(201).json(sanitizeAccount(row))
|
res.status(201).json(sanitizeAccount(row))
|
||||||
@@ -56,9 +61,19 @@ router.put('/:id', (req, res) => {
|
|||||||
const apiType = r.apiType !== undefined ? String(r.apiType || '') : (existing.apiType || '')
|
const apiType = r.apiType !== undefined ? String(r.apiType || '') : (existing.apiType || '')
|
||||||
const apiBaseUrl = r.apiBaseUrl !== undefined ? String(r.apiBaseUrl || '') : (existing.apiBaseUrl || '')
|
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
|
||||||
|
if (r.balance_alert_below !== undefined) {
|
||||||
|
const v = r.balance_alert_below
|
||||||
|
balanceAlertBelow =
|
||||||
|
v === '' || v == null
|
||||||
|
? null
|
||||||
|
: Number.isFinite(Number(v))
|
||||||
|
? Number(v)
|
||||||
|
: null
|
||||||
|
}
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
UPDATE provider_accounts SET
|
UPDATE provider_accounts SET
|
||||||
providerId = ?, name = ?, panelUrl = ?, currency = ?, billingMode = ?, notes = ?, apiType = ?, apiBaseUrl = ?, apiCredentials = ?
|
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 ?? '',
|
||||||
@@ -70,6 +85,7 @@ router.put('/:id', (req, res) => {
|
|||||||
apiType,
|
apiType,
|
||||||
apiBaseUrl,
|
apiBaseUrl,
|
||||||
apiCredentials,
|
apiCredentials,
|
||||||
|
balanceAlertBelow,
|
||||||
id,
|
id,
|
||||||
)
|
)
|
||||||
const row = db.prepare('SELECT * FROM provider_accounts WHERE id = ?').get(id)
|
const row = db.prepare('SELECT * FROM provider_accounts WHERE id = ?').get(id)
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ export function rowToSettings(row) {
|
|||||||
syncEnabled: Boolean(row.syncEnabled),
|
syncEnabled: Boolean(row.syncEnabled),
|
||||||
notifyPaymentExpiryEnabled: Boolean(row.notifyPaymentExpiryEnabled),
|
notifyPaymentExpiryEnabled: Boolean(row.notifyPaymentExpiryEnabled),
|
||||||
notifyNewTariffsEnabled: Boolean(row.notifyNewTariffsEnabled),
|
notifyNewTariffsEnabled: Boolean(row.notifyNewTariffsEnabled),
|
||||||
|
notifyLowBalanceEnabled: Boolean(row.notifyLowBalanceEnabled),
|
||||||
|
notifySyncDigestEnabled: Boolean(row.notifySyncDigestEnabled),
|
||||||
customFields: Array.isArray(customFields) ? customFields : [],
|
customFields: Array.isArray(customFields) ? customFields : [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -70,6 +72,8 @@ router.put('/:id', (req, res) => {
|
|||||||
const syncTariffsIntervalMinutes = r.syncTariffsIntervalMinutes !== undefined ? Math.max(60, Number(r.syncTariffsIntervalMinutes) || 1440) : (existing?.syncTariffsIntervalMinutes ?? 1440)
|
const syncTariffsIntervalMinutes = r.syncTariffsIntervalMinutes !== undefined ? Math.max(60, Number(r.syncTariffsIntervalMinutes) || 1440) : (existing?.syncTariffsIntervalMinutes ?? 1440)
|
||||||
const notifyPaymentExpiryEnabled = r.notifyPaymentExpiryEnabled !== undefined ? (r.notifyPaymentExpiryEnabled ? 1 : 0) : (existing?.notifyPaymentExpiryEnabled ? 1 : 0)
|
const notifyPaymentExpiryEnabled = r.notifyPaymentExpiryEnabled !== undefined ? (r.notifyPaymentExpiryEnabled ? 1 : 0) : (existing?.notifyPaymentExpiryEnabled ? 1 : 0)
|
||||||
const notifyNewTariffsEnabled = r.notifyNewTariffsEnabled !== undefined ? (r.notifyNewTariffsEnabled ? 1 : 0) : (existing?.notifyNewTariffsEnabled ? 1 : 0)
|
const notifyNewTariffsEnabled = r.notifyNewTariffsEnabled !== undefined ? (r.notifyNewTariffsEnabled ? 1 : 0) : (existing?.notifyNewTariffsEnabled ? 1 : 0)
|
||||||
|
const notifyLowBalanceEnabled = r.notifyLowBalanceEnabled !== undefined ? (r.notifyLowBalanceEnabled ? 1 : 0) : (existing?.notifyLowBalanceEnabled ? 1 : 0)
|
||||||
|
const notifySyncDigestEnabled = r.notifySyncDigestEnabled !== undefined ? (r.notifySyncDigestEnabled ? 1 : 0) : (existing?.notifySyncDigestEnabled ? 1 : 0)
|
||||||
const telegramBotToken = r.telegramBotToken !== undefined ? (r.telegramBotToken || '') : (existing?.telegramBotToken ?? '')
|
const telegramBotToken = r.telegramBotToken !== undefined ? (r.telegramBotToken || '') : (existing?.telegramBotToken ?? '')
|
||||||
const telegramChatId = r.telegramChatId !== undefined ? (r.telegramChatId || '') : (existing?.telegramChatId ?? '')
|
const telegramChatId = r.telegramChatId !== undefined ? (r.telegramChatId || '') : (existing?.telegramChatId ?? '')
|
||||||
const telegramMessageThreadId = r.telegramMessageThreadId !== undefined ? (r.telegramMessageThreadId || '') : (existing?.telegramMessageThreadId ?? '')
|
const telegramMessageThreadId = r.telegramMessageThreadId !== undefined ? (r.telegramMessageThreadId || '') : (existing?.telegramMessageThreadId ?? '')
|
||||||
@@ -78,7 +82,8 @@ router.put('/:id', (req, res) => {
|
|||||||
db.prepare(`
|
db.prepare(`
|
||||||
UPDATE settings SET
|
UPDATE settings SET
|
||||||
baseCurrency = ?, ratesUrl = ?, autoConvert = ?, ratesUpdatedAt = ?, syncEnabled = ?, syncIntervalMinutes = ?, syncTariffsIntervalMinutes = ?,
|
baseCurrency = ?, ratesUrl = ?, autoConvert = ?, ratesUpdatedAt = ?, syncEnabled = ?, syncIntervalMinutes = ?, syncTariffsIntervalMinutes = ?,
|
||||||
telegramBotToken = ?, telegramChatId = ?, telegramMessageThreadId = ?, notifyPaymentExpiryEnabled = ?, notifyNewTariffsEnabled = ?, customFields = ?
|
telegramBotToken = ?, telegramChatId = ?, telegramMessageThreadId = ?, notifyPaymentExpiryEnabled = ?, notifyNewTariffsEnabled = ?, customFields = ?,
|
||||||
|
notifyLowBalanceEnabled = ?, notifySyncDigestEnabled = ?
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`).run(
|
`).run(
|
||||||
r.baseCurrency ?? existing.baseCurrency ?? 'RUB',
|
r.baseCurrency ?? existing.baseCurrency ?? 'RUB',
|
||||||
@@ -94,12 +99,14 @@ router.put('/:id', (req, res) => {
|
|||||||
notifyPaymentExpiryEnabled,
|
notifyPaymentExpiryEnabled,
|
||||||
notifyNewTariffsEnabled,
|
notifyNewTariffsEnabled,
|
||||||
customFields,
|
customFields,
|
||||||
|
notifyLowBalanceEnabled,
|
||||||
|
notifySyncDigestEnabled,
|
||||||
id,
|
id,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
INSERT INTO settings (id, baseCurrency, ratesUrl, autoConvert, ratesUpdatedAt, syncEnabled, syncIntervalMinutes, syncTariffsIntervalMinutes, telegramBotToken, telegramChatId, telegramMessageThreadId, notifyPaymentExpiryEnabled, notifyNewTariffsEnabled, customFields)
|
INSERT INTO settings (id, baseCurrency, ratesUrl, autoConvert, ratesUpdatedAt, syncEnabled, syncIntervalMinutes, syncTariffsIntervalMinutes, telegramBotToken, telegramChatId, telegramMessageThreadId, notifyPaymentExpiryEnabled, notifyNewTariffsEnabled, customFields, notifyLowBalanceEnabled, notifySyncDigestEnabled)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`).run(
|
`).run(
|
||||||
id,
|
id,
|
||||||
r.baseCurrency ?? 'RUB',
|
r.baseCurrency ?? 'RUB',
|
||||||
@@ -115,6 +122,8 @@ router.put('/:id', (req, res) => {
|
|||||||
notifyPaymentExpiryEnabled,
|
notifyPaymentExpiryEnabled,
|
||||||
notifyNewTariffsEnabled,
|
notifyNewTariffsEnabled,
|
||||||
customFields,
|
customFields,
|
||||||
|
notifyLowBalanceEnabled,
|
||||||
|
notifySyncDigestEnabled,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
startScheduler()
|
startScheduler()
|
||||||
@@ -135,13 +144,15 @@ router.post('/', (req, res) => {
|
|||||||
const syncTariffsIntervalMinutes = Math.max(60, Number(r.syncTariffsIntervalMinutes) || 1440)
|
const syncTariffsIntervalMinutes = Math.max(60, Number(r.syncTariffsIntervalMinutes) || 1440)
|
||||||
const notifyPaymentExpiryEnabled = r.notifyPaymentExpiryEnabled ? 1 : 0
|
const notifyPaymentExpiryEnabled = r.notifyPaymentExpiryEnabled ? 1 : 0
|
||||||
const notifyNewTariffsEnabled = r.notifyNewTariffsEnabled ? 1 : 0
|
const notifyNewTariffsEnabled = r.notifyNewTariffsEnabled ? 1 : 0
|
||||||
|
const notifyLowBalanceEnabled = r.notifyLowBalanceEnabled ? 1 : 0
|
||||||
|
const notifySyncDigestEnabled = r.notifySyncDigestEnabled ? 1 : 0
|
||||||
const telegramBotToken = r.telegramBotToken ?? ''
|
const telegramBotToken = r.telegramBotToken ?? ''
|
||||||
const telegramChatId = r.telegramChatId ?? ''
|
const telegramChatId = r.telegramChatId ?? ''
|
||||||
const telegramMessageThreadId = r.telegramMessageThreadId ?? ''
|
const telegramMessageThreadId = r.telegramMessageThreadId ?? ''
|
||||||
const customFields = serializeCustomFields(r.customFields)
|
const customFields = serializeCustomFields(r.customFields)
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
INSERT INTO settings (id, baseCurrency, ratesUrl, autoConvert, ratesUpdatedAt, syncEnabled, syncIntervalMinutes, syncTariffsIntervalMinutes, telegramBotToken, telegramChatId, telegramMessageThreadId, notifyPaymentExpiryEnabled, notifyNewTariffsEnabled, customFields)
|
INSERT INTO settings (id, baseCurrency, ratesUrl, autoConvert, ratesUpdatedAt, syncEnabled, syncIntervalMinutes, syncTariffsIntervalMinutes, telegramBotToken, telegramChatId, telegramMessageThreadId, notifyPaymentExpiryEnabled, notifyNewTariffsEnabled, customFields, notifyLowBalanceEnabled, notifySyncDigestEnabled)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`).run(
|
`).run(
|
||||||
id,
|
id,
|
||||||
r.baseCurrency ?? 'RUB',
|
r.baseCurrency ?? 'RUB',
|
||||||
@@ -157,6 +168,8 @@ router.post('/', (req, res) => {
|
|||||||
notifyPaymentExpiryEnabled,
|
notifyPaymentExpiryEnabled,
|
||||||
notifyNewTariffsEnabled,
|
notifyNewTariffsEnabled,
|
||||||
customFields,
|
customFields,
|
||||||
|
notifyLowBalanceEnabled,
|
||||||
|
notifySyncDigestEnabled,
|
||||||
)
|
)
|
||||||
startScheduler()
|
startScheduler()
|
||||||
const row = db.prepare('SELECT * FROM settings WHERE id = ?').get(id)
|
const row = db.prepare('SELECT * FROM settings WHERE id = ?').get(id)
|
||||||
|
|||||||
+17
-26
@@ -1,6 +1,7 @@
|
|||||||
import { Router } from 'express'
|
import { Router } from 'express'
|
||||||
import { getDb } from '../db.js'
|
import { getDb } from '../db.js'
|
||||||
import { syncFromBillmanager, fetchDashboardInfo, testConnection } from '../adapters/billmanager/index.js'
|
import { fetchDashboardInfo, testConnection } from '../adapters/billmanager/index.js'
|
||||||
|
import { runBillmanagerAccountSync } from '../sync-account-job.js'
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
|
|
||||||
@@ -21,10 +22,22 @@ router.get('/status', (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const db = getDb()
|
const db = getDb()
|
||||||
const rows = db.prepare(`
|
const rows = db.prepare(`
|
||||||
SELECT id, accountId, startedAt, finishedAt, status, vpsCount, paymentsCount, error
|
SELECT id, accountId, startedAt, finishedAt, status, vpsCount, paymentsCount, error, summary
|
||||||
FROM sync_log ORDER BY startedAt DESC LIMIT 50
|
FROM sync_log ORDER BY startedAt DESC LIMIT 50
|
||||||
`).all()
|
`).all()
|
||||||
res.json(rows)
|
res.json(
|
||||||
|
rows.map((row) => {
|
||||||
|
let summaryParsed = null
|
||||||
|
if (row.summary) {
|
||||||
|
try {
|
||||||
|
summaryParsed = JSON.parse(row.summary)
|
||||||
|
} catch {
|
||||||
|
summaryParsed = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ...row, summary: summaryParsed }
|
||||||
|
}),
|
||||||
|
)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ error: err.message })
|
res.status(500).json({ error: err.message })
|
||||||
}
|
}
|
||||||
@@ -76,19 +89,8 @@ router.post('/:accountId', async (req, res) => {
|
|||||||
return res.status(400).json({ error: 'API URL and credentials are required' })
|
return res.status(400).json({ error: 'API URL and credentials are required' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const logId = `sync-${accountId}-${Date.now()}`
|
|
||||||
db.prepare(`
|
|
||||||
INSERT INTO sync_log (id, accountId, startedAt, status)
|
|
||||||
VALUES (?, ?, ?, ?)
|
|
||||||
`).run(logId, accountId, new Date().toISOString(), 'running')
|
|
||||||
|
|
||||||
const opts = onlyTariffs ? { skipVpsPayments: true } : { skipTariffs: true }
|
const opts = onlyTariffs ? { skipVpsPayments: true } : { skipTariffs: true }
|
||||||
const result = await syncFromBillmanager(row, db, opts)
|
const result = await runBillmanagerAccountSync(row, opts)
|
||||||
|
|
||||||
db.prepare(`
|
|
||||||
UPDATE sync_log SET finishedAt=?, status=?, vpsCount=?, paymentsCount=?
|
|
||||||
WHERE id=?
|
|
||||||
`).run(new Date().toISOString(), 'ok', result.vpsCount, result.paymentsCount, logId)
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -100,17 +102,6 @@ router.post('/:accountId', async (req, res) => {
|
|||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Sync error:', err)
|
console.error('Sync error:', err)
|
||||||
const { accountId } = req.params
|
|
||||||
const db = getDb()
|
|
||||||
const logRows = db.prepare('SELECT id FROM sync_log WHERE accountId=? AND status=? ORDER BY startedAt DESC LIMIT 1').all(accountId, 'running')
|
|
||||||
if (logRows.length > 0) {
|
|
||||||
db.prepare('UPDATE sync_log SET finishedAt=?, status=?, error=? WHERE id=?').run(
|
|
||||||
new Date().toISOString(),
|
|
||||||
'error',
|
|
||||||
err.message || 'Unknown error',
|
|
||||||
logRows[0].id,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
res.status(500).json({ ok: false, error: err.message || 'Sync failed' })
|
res.status(500).json({ ok: false, error: err.message || 'Sync failed' })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* Запуск синхронизации BILLmanager с записью в sync_log (для API и планировщика)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getDb } from './db.js'
|
||||||
|
import { syncFromBillmanager } from './adapters/billmanager/index.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} account - строка provider_accounts
|
||||||
|
* @param {object} [opts] - { skipTariffs, skipVpsPayments }
|
||||||
|
* @returns {Promise<object>} результат syncFromBillmanager + ok, logId
|
||||||
|
*/
|
||||||
|
export async function runBillmanagerAccountSync(account, opts = {}) {
|
||||||
|
const db = getDb()
|
||||||
|
const logId = `sync-${account.id}-${Date.now()}`
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO sync_log (id, accountId, startedAt, status)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
`).run(logId, account.id, new Date().toISOString(), 'running')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await syncFromBillmanager(account, db, opts)
|
||||||
|
const summaryPayload = {
|
||||||
|
...(result.syncSummary || {}),
|
||||||
|
vpsCount: result.vpsCount,
|
||||||
|
paymentsCount: result.paymentsCount,
|
||||||
|
tariffsCount: result.tariffsCount ?? 0,
|
||||||
|
}
|
||||||
|
db.prepare(`
|
||||||
|
UPDATE sync_log SET finishedAt=?, status=?, vpsCount=?, paymentsCount=?, summary=?
|
||||||
|
WHERE id=?
|
||||||
|
`).run(
|
||||||
|
new Date().toISOString(),
|
||||||
|
'ok',
|
||||||
|
result.vpsCount,
|
||||||
|
result.paymentsCount,
|
||||||
|
JSON.stringify(summaryPayload),
|
||||||
|
logId,
|
||||||
|
)
|
||||||
|
return { ok: true, logId, ...result }
|
||||||
|
} catch (err) {
|
||||||
|
db.prepare(`
|
||||||
|
UPDATE sync_log SET finishedAt=?, status=?, error=?, summary=?
|
||||||
|
WHERE id=?
|
||||||
|
`).run(
|
||||||
|
new Date().toISOString(),
|
||||||
|
'error',
|
||||||
|
err.message || 'Unknown error',
|
||||||
|
JSON.stringify({ error: err.message || 'Unknown error' }),
|
||||||
|
logId,
|
||||||
|
)
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { getDb } from './db.js'
|
import { getDb } from './db.js'
|
||||||
import { syncFromBillmanager } from './adapters/billmanager/index.js'
|
import { runBillmanagerAccountSync } from './sync-account-job.js'
|
||||||
import { sendTelegramMessage } from './telegram.js'
|
import { sendTelegramMessage } from './telegram.js'
|
||||||
|
|
||||||
let syncIntervalId = null
|
let syncIntervalId = null
|
||||||
@@ -113,13 +113,53 @@ export async function runScheduledSync() {
|
|||||||
SELECT * FROM provider_accounts
|
SELECT * FROM provider_accounts
|
||||||
WHERE apiType = 'billmanager' AND apiBaseUrl IS NOT NULL AND apiBaseUrl != '' AND apiCredentials IS NOT NULL AND apiCredentials != ''
|
WHERE apiType = 'billmanager' AND apiBaseUrl IS NOT NULL AND apiBaseUrl != '' AND apiCredentials IS NOT NULL AND apiCredentials != ''
|
||||||
`).all()
|
`).all()
|
||||||
|
|
||||||
|
const digestLines = []
|
||||||
|
const lowBalanceLines = []
|
||||||
|
const token = settings?.telegramBotToken?.trim()
|
||||||
|
const chatId = settings?.telegramChatId?.trim()
|
||||||
|
const canTg = Boolean(token && chatId)
|
||||||
|
|
||||||
for (const account of accounts) {
|
for (const account of accounts) {
|
||||||
try {
|
try {
|
||||||
await syncFromBillmanager(account, db, { skipTariffs: true })
|
const result = await runBillmanagerAccountSync(account, { skipTariffs: true })
|
||||||
|
const s = result.syncSummary || {}
|
||||||
|
const parts = []
|
||||||
|
if (s.added?.length) parts.push(`+${s.added.length} VPS`)
|
||||||
|
if (s.updated?.length) parts.push(`изм. ${s.updated.length}`)
|
||||||
|
if (result.paymentsCount) parts.push(`платежи +${result.paymentsCount}`)
|
||||||
|
digestLines.push(`✓ ${account.name}: ${parts.length ? parts.join(', ') : 'без изменений'}`)
|
||||||
|
|
||||||
|
const apiBal = result.balance?.balance
|
||||||
|
const threshold = account.balance_alert_below
|
||||||
|
if (
|
||||||
|
canTg &&
|
||||||
|
settings.notifyLowBalanceEnabled &&
|
||||||
|
threshold != null &&
|
||||||
|
Number.isFinite(Number(threshold)) &&
|
||||||
|
apiBal != null &&
|
||||||
|
Number.isFinite(Number(apiBal)) &&
|
||||||
|
Number(apiBal) < Number(threshold)
|
||||||
|
) {
|
||||||
|
const cur = result.balance?.currency || account.balance_currency || account.currency || ''
|
||||||
|
lowBalanceLines.push(
|
||||||
|
`• ${account.name}: ${apiBal} ${cur} (порог ${threshold})`,
|
||||||
|
)
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn(`Sync VPS/payments failed for account ${account.id}:`, err.message)
|
digestLines.push(`✗ ${account.name}: ${err.message || 'ошибка'}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (canTg && settings.notifySyncDigestEnabled && digestLines.length > 0) {
|
||||||
|
const text = `📋 <b>Синхронизация VPS</b>\n\n${digestLines.join('\n')}`
|
||||||
|
await sendTelegramMessage(token, chatId, text, settings.telegramMessageThreadId)
|
||||||
|
}
|
||||||
|
if (canTg && settings.notifyLowBalanceEnabled && lowBalanceLines.length > 0) {
|
||||||
|
const text = `💰 <b>Низкий баланс</b>\n\n${lowBalanceLines.join('\n')}`
|
||||||
|
await sendTelegramMessage(token, chatId, text, settings.telegramMessageThreadId)
|
||||||
|
}
|
||||||
|
|
||||||
if (settings?.notifyPaymentExpiryEnabled) {
|
if (settings?.notifyPaymentExpiryEnabled) {
|
||||||
await sendPaymentExpiryNotifications(db)
|
await sendPaymentExpiryNotifications(db)
|
||||||
}
|
}
|
||||||
@@ -141,7 +181,7 @@ export async function runScheduledSyncTariffs() {
|
|||||||
|
|
||||||
for (const account of accounts) {
|
for (const account of accounts) {
|
||||||
try {
|
try {
|
||||||
const result = await syncFromBillmanager(account, db, { skipVpsPayments: true })
|
const result = await runBillmanagerAccountSync(account, { skipVpsPayments: true })
|
||||||
const newTariffs = result?.newTariffs || []
|
const newTariffs = result?.newTariffs || []
|
||||||
if (newTariffs.length > 0 && settings?.notifyNewTariffsEnabled && settings?.telegramBotToken?.trim() && settings?.telegramChatId?.trim()) {
|
if (newTariffs.length > 0 && settings?.notifyNewTariffsEnabled && settings?.telegramBotToken?.trim() && settings?.telegramChatId?.trim()) {
|
||||||
const provider = providers.find((p) => p.id === account.providerId)
|
const provider = providers.find((p) => p.id === account.providerId)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { EmptyState } from './EmptyState'
|
import { EmptyState } from './EmptyState'
|
||||||
|
import { formatSyncSummaryLine } from '../lib/inventory-health'
|
||||||
|
|
||||||
export function SyncLogTable({ syncLog = [], providerAccounts = [] }) {
|
export function SyncLogTable({ syncLog = [], providerAccounts = [] }) {
|
||||||
const getAccountName = (accountId) => {
|
const getAccountName = (accountId) => {
|
||||||
@@ -38,6 +39,7 @@ export function SyncLogTable({ syncLog = [], providerAccounts = [] }) {
|
|||||||
<th>Статус</th>
|
<th>Статус</th>
|
||||||
<th>VPS</th>
|
<th>VPS</th>
|
||||||
<th>Платежи</th>
|
<th>Платежи</th>
|
||||||
|
<th>Итог</th>
|
||||||
<th>Ошибка</th>
|
<th>Ошибка</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -54,6 +56,9 @@ export function SyncLogTable({ syncLog = [], providerAccounts = [] }) {
|
|||||||
</td>
|
</td>
|
||||||
<td>{row.vpsCount ?? '—'}</td>
|
<td>{row.vpsCount ?? '—'}</td>
|
||||||
<td>{row.paymentsCount ?? '—'}</td>
|
<td>{row.paymentsCount ?? '—'}</td>
|
||||||
|
<td className="small">
|
||||||
|
{formatSyncSummaryLine(row.summary) || '—'}
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{row.error ? (
|
{row.error ? (
|
||||||
<span className="text-danger small" title={row.error}>
|
<span className="text-danger small" title={row.error}>
|
||||||
@@ -66,7 +71,7 @@ export function SyncLogTable({ syncLog = [], providerAccounts = [] }) {
|
|||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{syncLog.length === 0 ? (
|
{syncLog.length === 0 ? (
|
||||||
<EmptyState message="Нет записей синхронизации" colSpan={7} />
|
<EmptyState message="Нет записей синхронизации" colSpan={8} />
|
||||||
) : null}
|
) : null}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -176,3 +176,64 @@ export async function sendTelegramTestNotification() {
|
|||||||
const res = await fetchApi('/api/settings/telegram/test', { method: 'POST' })
|
const res = await fetchApi('/api/settings/telegram/test', { method: 'POST' })
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function downloadBackupJsonBlob() {
|
||||||
|
const url = `${API_BASE}/api/backup/json`
|
||||||
|
const res = await fetch(url)
|
||||||
|
if (!res.ok) {
|
||||||
|
let message = res.statusText || 'Ошибка выгрузки'
|
||||||
|
try {
|
||||||
|
const data = await res.json()
|
||||||
|
if (data?.error) message = data.error
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
throw new Error(message)
|
||||||
|
}
|
||||||
|
return res.blob()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadBackupDatabaseBlob() {
|
||||||
|
const url = `${API_BASE}/api/backup/database`
|
||||||
|
const res = await fetch(url)
|
||||||
|
if (!res.ok) {
|
||||||
|
let message = res.statusText || 'Ошибка выгрузки'
|
||||||
|
try {
|
||||||
|
const data = await res.json()
|
||||||
|
if (data?.error) message = data.error
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
throw new Error(message)
|
||||||
|
}
|
||||||
|
return res.blob()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importBackupJson(payload) {
|
||||||
|
return fetchApi('/api/backup/json', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importBackupDatabaseBuffer(buffer) {
|
||||||
|
const url = `${API_BASE}/api/backup/database`
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/octet-stream' },
|
||||||
|
body: buffer,
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
let message = res.statusText || 'Ошибка восстановления'
|
||||||
|
try {
|
||||||
|
const data = await res.json()
|
||||||
|
if (data?.error) message = data.error
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
const err = new Error(message)
|
||||||
|
err.status = res.status
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import { getPaidUntilDate } from './paid-until'
|
||||||
|
|
||||||
|
const STALE_SYNC_HOURS = 48
|
||||||
|
|
||||||
|
function ledgerBalanceInCurrency(account, balanceLedger) {
|
||||||
|
const cur = (account.balance_currency || account.currency || '').trim()
|
||||||
|
const rows = balanceLedger.filter((row) => row.providerAccountId === account.id)
|
||||||
|
const filtered = cur
|
||||||
|
? rows.filter((row) => !row.currency || row.currency === cur)
|
||||||
|
: rows
|
||||||
|
const credits = filtered
|
||||||
|
.filter((row) => row.direction === 'credit')
|
||||||
|
.reduce((acc, row) => acc + Number(row.amount || 0), 0)
|
||||||
|
const debits = filtered
|
||||||
|
.filter((row) => row.direction === 'debit')
|
||||||
|
.reduce((acc, row) => acc + Number(row.amount || 0), 0)
|
||||||
|
return credits - debits
|
||||||
|
}
|
||||||
|
|
||||||
|
export function lastOkSyncFinishedAt(accountId, syncLog) {
|
||||||
|
const rows = (syncLog || []).filter(
|
||||||
|
(r) => r.accountId === accountId && r.status === 'ok' && r.finishedAt,
|
||||||
|
)
|
||||||
|
let best = null
|
||||||
|
for (const r of rows) {
|
||||||
|
const t = new Date(r.finishedAt).getTime()
|
||||||
|
if (!Number.isNaN(t) && (!best || t > best)) best = t
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{
|
||||||
|
* vps: object[],
|
||||||
|
* providerAccounts: object[],
|
||||||
|
* payments: object[],
|
||||||
|
* balanceLedger: object[],
|
||||||
|
* syncLog?: object[],
|
||||||
|
* }} input
|
||||||
|
*/
|
||||||
|
export function computeInventoryHealth(input) {
|
||||||
|
const { vps, providerAccounts, payments, balanceLedger, syncLog = [] } = input
|
||||||
|
const now = new Date()
|
||||||
|
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||||
|
const ctx = { vps, providerAccounts, payments, balanceLedger, now }
|
||||||
|
|
||||||
|
/** @type {{ key: string, title: string, count: number, to: string, hint?: string }[]} */
|
||||||
|
const issues = []
|
||||||
|
|
||||||
|
const noProject = vps.filter((v) => v.status === 'active' && !(v.project || '').trim())
|
||||||
|
if (noProject.length) {
|
||||||
|
issues.push({
|
||||||
|
key: 'no-project',
|
||||||
|
title: 'Активные VPS без проекта',
|
||||||
|
count: noProject.length,
|
||||||
|
to: '/vps?health=no-project',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const noRate = vps.filter((v) => {
|
||||||
|
if (v.status !== 'active') return false
|
||||||
|
const dr = Number(v.dailyRate || 0)
|
||||||
|
const mr = Number(v.monthlyRate || 0)
|
||||||
|
const noMoney =
|
||||||
|
(!Number.isFinite(dr) || dr <= 0) && (!Number.isFinite(mr) || mr <= 0)
|
||||||
|
const noCur = !(v.currency || '').trim()
|
||||||
|
return noMoney || noCur
|
||||||
|
})
|
||||||
|
if (noRate.length) {
|
||||||
|
issues.push({
|
||||||
|
key: 'no-rate',
|
||||||
|
title: 'Нет ставки или валюты',
|
||||||
|
count: noRate.length,
|
||||||
|
to: '/vps?health=no-rate',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const paidOverdue = vps.filter((v) => {
|
||||||
|
if (v.status !== 'active') return false
|
||||||
|
const d = getPaidUntilDate(v, ctx)
|
||||||
|
return d && d < todayStart
|
||||||
|
})
|
||||||
|
if (paidOverdue.length) {
|
||||||
|
issues.push({
|
||||||
|
key: 'paid-overdue',
|
||||||
|
title: 'Просрочена оплата (оценка)',
|
||||||
|
count: paidOverdue.length,
|
||||||
|
to: '/vps?health=paid-overdue',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const bmAccounts = providerAccounts.filter(
|
||||||
|
(a) => a.apiType === 'billmanager' && (a.apiBaseUrl || '').trim() && a.apiCredentialsSet,
|
||||||
|
)
|
||||||
|
const staleMs = STALE_SYNC_HOURS * 60 * 60 * 1000
|
||||||
|
const staleAccounts = bmAccounts.filter((a) => {
|
||||||
|
const t = lastOkSyncFinishedAt(a.id, syncLog)
|
||||||
|
if (t == null) return true
|
||||||
|
return now.getTime() - t > staleMs
|
||||||
|
})
|
||||||
|
if (staleAccounts.length) {
|
||||||
|
issues.push({
|
||||||
|
key: 'stale-sync',
|
||||||
|
title: `Нет успешного синка > ${STALE_SYNC_HOURS} ч`,
|
||||||
|
count: staleAccounts.length,
|
||||||
|
to: '/accounts?health=stale-sync',
|
||||||
|
hint: 'Проверьте API и журнал синхронизации',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const mismatchAccounts = providerAccounts.filter((a) => {
|
||||||
|
if (a.balance_api == null || !Number.isFinite(Number(a.balance_api))) return false
|
||||||
|
const ledger = ledgerBalanceInCurrency(a, balanceLedger)
|
||||||
|
if (!Number.isFinite(ledger)) return false
|
||||||
|
const api = Number(a.balance_api)
|
||||||
|
const diff = Math.abs(api - ledger)
|
||||||
|
const tol = Math.max(10, Math.abs(api) * 0.05)
|
||||||
|
return diff > tol
|
||||||
|
})
|
||||||
|
if (mismatchAccounts.length) {
|
||||||
|
issues.push({
|
||||||
|
key: 'balance-mismatch',
|
||||||
|
title: 'Баланс API и ledger расходятся',
|
||||||
|
count: mismatchAccounts.length,
|
||||||
|
to: '/accounts?health=balance-mismatch',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return issues
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object[]} providerAccounts
|
||||||
|
* @param {object[]} syncLog
|
||||||
|
*/
|
||||||
|
export function getStaleSyncAccountIds(providerAccounts, syncLog, now = new Date()) {
|
||||||
|
const bmAccounts = providerAccounts.filter(
|
||||||
|
(a) => a.apiType === 'billmanager' && (a.apiBaseUrl || '').trim() && a.apiCredentialsSet,
|
||||||
|
)
|
||||||
|
const staleMs = STALE_SYNC_HOURS * 60 * 60 * 1000
|
||||||
|
return bmAccounts
|
||||||
|
.filter((a) => {
|
||||||
|
const t = lastOkSyncFinishedAt(a.id, syncLog)
|
||||||
|
if (t == null) return true
|
||||||
|
return now.getTime() - t > staleMs
|
||||||
|
})
|
||||||
|
.map((a) => a.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object[]} providerAccounts
|
||||||
|
* @param {object[]} balanceLedger
|
||||||
|
*/
|
||||||
|
export function getBalanceMismatchAccountIds(providerAccounts, balanceLedger) {
|
||||||
|
return providerAccounts
|
||||||
|
.filter((a) => {
|
||||||
|
if (a.balance_api == null || !Number.isFinite(Number(a.balance_api))) return false
|
||||||
|
const ledger = ledgerBalanceInCurrency(a, balanceLedger)
|
||||||
|
if (!Number.isFinite(ledger)) return false
|
||||||
|
const api = Number(a.balance_api)
|
||||||
|
const diff = Math.abs(api - ledger)
|
||||||
|
const tol = Math.max(10, Math.abs(api) * 0.05)
|
||||||
|
return diff > tol
|
||||||
|
})
|
||||||
|
.map((a) => a.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object|null} summary
|
||||||
|
*/
|
||||||
|
export function formatSyncSummaryLine(summary) {
|
||||||
|
if (!summary || typeof summary !== 'object') return ''
|
||||||
|
if (summary.error) return String(summary.error)
|
||||||
|
const parts = []
|
||||||
|
if (summary.added?.length) parts.push(`+${summary.added.length} VPS`)
|
||||||
|
if (summary.updated?.length) parts.push(`~${summary.updated.length} изм.`)
|
||||||
|
if (summary.paymentsAdded) parts.push(`+${summary.paymentsAdded} платежей`)
|
||||||
|
if (summary.tariffsOnly && summary.tariffsCount != null) parts.push(`тарифы: ${summary.tariffsCount}`)
|
||||||
|
if (parts.length) return parts.join(', ')
|
||||||
|
if (summary.vpsCount != null || summary.paymentsCount != null) {
|
||||||
|
return `синк: VPS ${summary.vpsCount ?? 0}, платежи ${summary.paymentsCount ?? 0}`
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* Дата «оплачено до» для VPS (как на дашборде).
|
||||||
|
*/
|
||||||
|
|
||||||
|
function getAccountBalance(accountId, providerAccounts, balanceLedger) {
|
||||||
|
const account = providerAccounts.find((a) => a.id === accountId)
|
||||||
|
if (account?.balance_api != null && Number.isFinite(Number(account.balance_api))) {
|
||||||
|
return Number(account.balance_api)
|
||||||
|
}
|
||||||
|
const ledgerRows = balanceLedger.filter((row) => row.providerAccountId === accountId)
|
||||||
|
const credits = ledgerRows
|
||||||
|
.filter((row) => row.direction === 'credit')
|
||||||
|
.reduce((acc, row) => acc + Number(row.amount || 0), 0)
|
||||||
|
const debits = ledgerRows
|
||||||
|
.filter((row) => row.direction === 'debit')
|
||||||
|
.reduce((acc, row) => acc + Number(row.amount || 0), 0)
|
||||||
|
return credits - debits
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} item - VPS
|
||||||
|
* @param {{ vps: object[], providerAccounts: object[], payments: object[], balanceLedger: object[], now?: Date }} ctx
|
||||||
|
* @returns {Date|null}
|
||||||
|
*/
|
||||||
|
export function getPaidUntilDate(item, ctx) {
|
||||||
|
const { vps, providerAccounts, payments, balanceLedger, now = new Date() } = ctx
|
||||||
|
if (item.status !== 'active') return null
|
||||||
|
const account = providerAccounts.find((a) => a.id === item.providerAccountId)
|
||||||
|
const tariffType = item.tariffType || (Number(item.dailyRate || 0) > 0 ? 'daily' : 'monthly')
|
||||||
|
const isDailyBilling = tariffType === 'daily' || account?.billingMode === 'daily'
|
||||||
|
|
||||||
|
const paidUntilFromApi = item.paidUntil
|
||||||
|
? (() => {
|
||||||
|
const d = new Date(item.paidUntil)
|
||||||
|
return Number.isNaN(d.getTime()) ? null : d
|
||||||
|
})()
|
||||||
|
: null
|
||||||
|
|
||||||
|
const isPaidUntilNextDay =
|
||||||
|
paidUntilFromApi &&
|
||||||
|
(() => {
|
||||||
|
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||||
|
const diffMs = paidUntilFromApi - today
|
||||||
|
const diffDays = Math.round(diffMs / (24 * 60 * 60 * 1000))
|
||||||
|
return diffDays >= 0 && diffDays <= 2
|
||||||
|
})()
|
||||||
|
|
||||||
|
const shouldCalculateFromBalance = isDailyBilling || isPaidUntilNextDay
|
||||||
|
|
||||||
|
if (!shouldCalculateFromBalance && paidUntilFromApi) {
|
||||||
|
return paidUntilFromApi
|
||||||
|
}
|
||||||
|
|
||||||
|
const dailyRate = Number(item.dailyRate || 0)
|
||||||
|
const monthlyRate = Number(item.monthlyRate || 0)
|
||||||
|
const burnRate = tariffType === 'daily' ? dailyRate : monthlyRate / 30
|
||||||
|
if (!Number.isFinite(burnRate) || burnRate <= 0) return paidUntilFromApi
|
||||||
|
|
||||||
|
const accountBalance = getAccountBalance(item.providerAccountId, providerAccounts, balanceLedger)
|
||||||
|
const activeInAccount = vps.filter(
|
||||||
|
(v) => v.providerAccountId === item.providerAccountId && v.status === 'active',
|
||||||
|
).length
|
||||||
|
const allocatedBalance = activeInAccount > 0 ? Math.max(0, accountBalance) / activeInAccount : 0
|
||||||
|
const directPayments = payments
|
||||||
|
.filter((p) => p.vpsId === item.id && p.type === 'direct_vps_payment')
|
||||||
|
.reduce((acc, p) => acc + Number(p.amount || 0), 0)
|
||||||
|
const funds = directPayments + allocatedBalance
|
||||||
|
const coveredDays = Math.floor(funds / burnRate)
|
||||||
|
if (!Number.isFinite(coveredDays) || coveredDays <= 0) return paidUntilFromApi
|
||||||
|
|
||||||
|
const paidUntil = new Date(now)
|
||||||
|
paidUntil.setDate(paidUntil.getDate() + coveredDays)
|
||||||
|
return paidUntil
|
||||||
|
}
|
||||||
@@ -340,3 +340,16 @@ export function downloadTextFile(fileName, content) {
|
|||||||
anchor.click()
|
anchor.click()
|
||||||
URL.revokeObjectURL(url)
|
URL.revokeObjectURL(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} fileName
|
||||||
|
* @param {Blob} blob
|
||||||
|
*/
|
||||||
|
export function downloadBlob(fileName, blob) {
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const anchor = document.createElement('a')
|
||||||
|
anchor.href = url
|
||||||
|
anchor.download = fileName
|
||||||
|
anchor.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { Link, useSearchParams } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
billingModeLabel,
|
billingModeLabel,
|
||||||
faviconUrlFromWebsite,
|
faviconUrlFromWebsite,
|
||||||
@@ -10,6 +11,7 @@ import { PageHeader } from '../components/PageHeader'
|
|||||||
import { ConvertedAmount } from '../components/ConvertedAmount'
|
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 { IconRefresh, IconPlugConnected } from '@tabler/icons-react'
|
import { IconRefresh, IconPlugConnected } from '@tabler/icons-react'
|
||||||
|
|
||||||
const emptyForm = {
|
const emptyForm = {
|
||||||
@@ -23,9 +25,12 @@ const emptyForm = {
|
|||||||
apiBaseUrl: '',
|
apiBaseUrl: '',
|
||||||
apiLogin: '',
|
apiLogin: '',
|
||||||
apiPassword: '',
|
apiPassword: '',
|
||||||
|
balance_alert_below: '',
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AccountsPage({ db, actions, settings, ratesData }) {
|
export function AccountsPage({ db, actions, settings, ratesData }) {
|
||||||
|
const [searchParams] = useSearchParams()
|
||||||
|
const accountsHealth = (searchParams.get('health') || '').trim()
|
||||||
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)
|
||||||
@@ -53,6 +58,16 @@ export function AccountsPage({ db, actions, settings, ratesData }) {
|
|||||||
const [testConnectionResult, setTestConnectionResult] = useState(null)
|
const [testConnectionResult, setTestConnectionResult] = useState(null)
|
||||||
const [syncLog, setSyncLog] = useState([])
|
const [syncLog, setSyncLog] = useState([])
|
||||||
|
|
||||||
|
const highlightAccountIds = useMemo(() => {
|
||||||
|
if (accountsHealth === 'stale-sync') {
|
||||||
|
return new Set(getStaleSyncAccountIds(db.providerAccounts, syncLog))
|
||||||
|
}
|
||||||
|
if (accountsHealth === 'balance-mismatch') {
|
||||||
|
return new Set(getBalanceMismatchAccountIds(db.providerAccounts, db.balanceLedger))
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}, [accountsHealth, db.providerAccounts, db.balanceLedger, syncLog])
|
||||||
|
|
||||||
const balances = useMemo(() => {
|
const balances = useMemo(() => {
|
||||||
return db.providerAccounts.map((account) => {
|
return db.providerAccounts.map((account) => {
|
||||||
const rows = db.balanceLedger.filter((row) => row.providerAccountId === account.id)
|
const rows = db.balanceLedger.filter((row) => row.providerAccountId === account.id)
|
||||||
@@ -107,6 +122,14 @@ export function AccountsPage({ db, actions, settings, ratesData }) {
|
|||||||
if (form.apiType === 'billmanager' && form.apiLogin && form.apiPassword) {
|
if (form.apiType === 'billmanager' && form.apiLogin && form.apiPassword) {
|
||||||
payload.apiCredentials = `${form.apiLogin}:${form.apiPassword}`
|
payload.apiCredentials = `${form.apiLogin}:${form.apiPassword}`
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
let b = null
|
||||||
|
if (String(form.balance_alert_below || '').trim() !== '') {
|
||||||
|
const x = Number(form.balance_alert_below)
|
||||||
|
if (Number.isFinite(x)) b = x
|
||||||
|
}
|
||||||
|
payload.balance_alert_below = b
|
||||||
|
}
|
||||||
setSaveError(null)
|
setSaveError(null)
|
||||||
try {
|
try {
|
||||||
if (editingId) {
|
if (editingId) {
|
||||||
@@ -151,6 +174,10 @@ export function AccountsPage({ db, actions, settings, ratesData }) {
|
|||||||
apiBaseUrl: account.apiBaseUrl || '',
|
apiBaseUrl: account.apiBaseUrl || '',
|
||||||
apiLogin: '',
|
apiLogin: '',
|
||||||
apiPassword: '',
|
apiPassword: '',
|
||||||
|
balance_alert_below:
|
||||||
|
account.balance_alert_below != null && account.balance_alert_below !== ''
|
||||||
|
? String(account.balance_alert_below)
|
||||||
|
: '',
|
||||||
})
|
})
|
||||||
setEditingId(account.id)
|
setEditingId(account.id)
|
||||||
setIsModalOpen(true)
|
setIsModalOpen(true)
|
||||||
@@ -212,6 +239,44 @@ export function AccountsPage({ db, actions, settings, ratesData }) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader pretitle="Справочники" title="Аккаунты хостеров" />
|
<PageHeader pretitle="Справочники" title="Аккаунты хостеров" />
|
||||||
|
{accountsHealth === 'stale-sync' ? (
|
||||||
|
highlightAccountIds?.size ? (
|
||||||
|
<div className="alert alert-warning d-flex align-items-center justify-content-between flex-wrap gap-2">
|
||||||
|
<span>
|
||||||
|
Подсвечены аккаунты без успешного синка дольше 48 ч или без записей в журнале.
|
||||||
|
</span>
|
||||||
|
<Link to="/accounts" className="btn btn-sm btn-outline-secondary">
|
||||||
|
Сбросить фильтр
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="alert alert-success d-flex align-items-center justify-content-between flex-wrap gap-2">
|
||||||
|
<span>Все BILLmanager-аккаунты имеют недавний успешный синк.</span>
|
||||||
|
<Link to="/accounts" className="btn btn-sm btn-outline-secondary">
|
||||||
|
Закрыть
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
{accountsHealth === 'balance-mismatch' ? (
|
||||||
|
highlightAccountIds?.size ? (
|
||||||
|
<div className="alert alert-warning d-flex align-items-center justify-content-between flex-wrap gap-2">
|
||||||
|
<span>
|
||||||
|
Подсвечены аккаунты, где баланс API заметно расходится с суммой по ledger (та же валюта).
|
||||||
|
</span>
|
||||||
|
<Link to="/accounts" className="btn btn-sm btn-outline-secondary">
|
||||||
|
Сбросить фильтр
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="alert alert-success d-flex align-items-center justify-content-between flex-wrap gap-2">
|
||||||
|
<span>Расхождений баланса API и ledger по выбранным правилам не найдено.</span>
|
||||||
|
<Link to="/accounts" className="btn btn-sm btn-outline-secondary">
|
||||||
|
Закрыть
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
<div className="row row-cards">
|
<div className="row row-cards">
|
||||||
<div className="col-12">
|
<div className="col-12">
|
||||||
<div className="card">
|
<div className="card">
|
||||||
@@ -267,8 +332,9 @@ export function AccountsPage({ db, actions, settings, ratesData }) {
|
|||||||
{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 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)
|
||||||
return (
|
return (
|
||||||
<tr key={account.id}>
|
<tr key={account.id} className={rowWarn ? 'table-warning' : undefined}>
|
||||||
<td>{account.name}</td>
|
<td>{account.name}</td>
|
||||||
<td>
|
<td>
|
||||||
<div className="d-flex align-items-center gap-2">
|
<div className="d-flex align-items-center gap-2">
|
||||||
@@ -508,6 +574,23 @@ export function AccountsPage({ db, actions, settings, ratesData }) {
|
|||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="col-12">
|
||||||
|
<label className="form-label">Порог баланса для Telegram</label>
|
||||||
|
<input
|
||||||
|
{...noBrowserSuggestProps}
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
className="form-control"
|
||||||
|
placeholder="Пусто — не слать по балансу"
|
||||||
|
value={form.balance_alert_below}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((prev) => ({ ...prev, balance_alert_below: e.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="form-hint">
|
||||||
|
При плановом синке, если баланс API ниже этого значения — уведомление (вкл. в настройках).
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="col-12">
|
<div className="col-12">
|
||||||
|
|||||||
+123
-82
@@ -1,10 +1,9 @@
|
|||||||
import { useMemo } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import {
|
import { Link } from 'react-router-dom'
|
||||||
billingModeLabel,
|
import { convertCurrency, formatCurrency, monthKey } from '../lib/utils'
|
||||||
convertCurrency,
|
import { getPaidUntilDate as computePaidUntil } from '../lib/paid-until'
|
||||||
formatCurrency,
|
import { computeInventoryHealth, formatSyncSummaryLine } from '../lib/inventory-health'
|
||||||
monthKey,
|
import { fetchSyncStatus } from '../lib/api'
|
||||||
} from '../lib/utils'
|
|
||||||
import { ConvertedAmount } from '../components/ConvertedAmount'
|
import { ConvertedAmount } from '../components/ConvertedAmount'
|
||||||
import { EmptyState } from '../components/EmptyState'
|
import { EmptyState } from '../components/EmptyState'
|
||||||
import { ExpenseChart } from '../components/ExpenseChart'
|
import { ExpenseChart } from '../components/ExpenseChart'
|
||||||
@@ -24,6 +23,13 @@ export function DashboardPage({ db = {}, settings, ratesData }) {
|
|||||||
const payments = Array.isArray(db.payments) ? db.payments : []
|
const payments = Array.isArray(db.payments) ? db.payments : []
|
||||||
const providers = Array.isArray(db.providers) ? db.providers : []
|
const providers = Array.isArray(db.providers) ? db.providers : []
|
||||||
|
|
||||||
|
const [syncLogRows, setSyncLogRows] = useState([])
|
||||||
|
useEffect(() => {
|
||||||
|
fetchSyncStatus()
|
||||||
|
.then(setSyncLogRows)
|
||||||
|
.catch(() => setSyncLogRows([]))
|
||||||
|
}, [db.vps?.length, db.providerAccounts?.length])
|
||||||
|
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const currentMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
|
const currentMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
|
||||||
const baseCurrency = settings?.[0]?.baseCurrency || 'RUB'
|
const baseCurrency = settings?.[0]?.baseCurrency || 'RUB'
|
||||||
@@ -189,89 +195,124 @@ export function DashboardPage({ db = {}, settings, ratesData }) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const UPCOMING_DAYS = 7
|
const UPCOMING_DAYS = 7
|
||||||
const getAccountBalance = (accountId) => {
|
const paidUntilCtx = { vps, providerAccounts, payments, balanceLedger, now }
|
||||||
const account = providerAccounts.find((a) => a.id === accountId)
|
const getPaidUntilDate = (item) => computePaidUntil(item, paidUntilCtx)
|
||||||
if (account?.balance_api != null && Number.isFinite(Number(account.balance_api))) {
|
|
||||||
return Number(account.balance_api)
|
|
||||||
}
|
|
||||||
const ledgerRows = balanceLedger.filter((row) => row.providerAccountId === accountId)
|
|
||||||
const credits = ledgerRows
|
|
||||||
.filter((row) => row.direction === 'credit')
|
|
||||||
.reduce((acc, row) => acc + Number(row.amount || 0), 0)
|
|
||||||
const debits = ledgerRows
|
|
||||||
.filter((row) => row.direction === 'debit')
|
|
||||||
.reduce((acc, row) => acc + Number(row.amount || 0), 0)
|
|
||||||
return credits - debits
|
|
||||||
}
|
|
||||||
|
|
||||||
const getPaidUntilDate = (item) => {
|
const inventoryIssues = useMemo(
|
||||||
if (item.status !== 'active') return null
|
() =>
|
||||||
const account = providerAccounts.find((a) => a.id === item.providerAccountId)
|
computeInventoryHealth({
|
||||||
const tariffType = item.tariffType || (Number(item.dailyRate || 0) > 0 ? 'daily' : 'monthly')
|
vps,
|
||||||
const isDailyBilling = tariffType === 'daily' || account?.billingMode === 'daily'
|
providerAccounts,
|
||||||
|
payments,
|
||||||
|
balanceLedger,
|
||||||
|
syncLog: syncLogRows,
|
||||||
|
}),
|
||||||
|
[vps, providerAccounts, payments, balanceLedger, syncLogRows],
|
||||||
|
)
|
||||||
|
|
||||||
const paidUntilFromApi = item.paidUntil
|
const recentSyncFeed = useMemo(() => {
|
||||||
? (() => {
|
return [...syncLogRows]
|
||||||
const d = new Date(item.paidUntil)
|
.filter((r) => r.finishedAt && (r.status === 'ok' || r.status === 'error'))
|
||||||
return Number.isNaN(d.getTime()) ? null : d
|
.slice(0, 12)
|
||||||
})()
|
}, [syncLogRows])
|
||||||
: null
|
|
||||||
|
|
||||||
const isPaidUntilNextDay =
|
const upcomingThreshold = new Date(now)
|
||||||
paidUntilFromApi &&
|
upcomingThreshold.setDate(upcomingThreshold.getDate() + UPCOMING_DAYS)
|
||||||
(() => {
|
const todayStartForUpcoming = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
const upcoming = vps
|
||||||
const diffMs = paidUntilFromApi - today
|
.filter((item) => item.status === 'active')
|
||||||
const diffDays = Math.round(diffMs / (24 * 60 * 60 * 1000))
|
.map((item) => {
|
||||||
return diffDays >= 0 && diffDays <= 2
|
const date = getPaidUntilDate(item)
|
||||||
})()
|
return { vps: item, paidUntil: date }
|
||||||
|
})
|
||||||
const shouldCalculateFromBalance = isDailyBilling || isPaidUntilNextDay
|
.filter(
|
||||||
|
({ paidUntil }) =>
|
||||||
if (!shouldCalculateFromBalance && paidUntilFromApi) {
|
paidUntil &&
|
||||||
return paidUntilFromApi
|
paidUntil <= upcomingThreshold &&
|
||||||
}
|
paidUntil >= todayStartForUpcoming,
|
||||||
|
)
|
||||||
const dailyRate = Number(item.dailyRate || 0)
|
.sort((a, b) => a.paidUntil - b.paidUntil)
|
||||||
const monthlyRate = Number(item.monthlyRate || 0)
|
.slice(0, 10)
|
||||||
const burnRate = tariffType === 'daily' ? dailyRate : monthlyRate / 30
|
|
||||||
if (!Number.isFinite(burnRate) || burnRate <= 0) return paidUntilFromApi
|
|
||||||
|
|
||||||
const accountBalance = getAccountBalance(item.providerAccountId)
|
|
||||||
const activeInAccount = vps.filter(
|
|
||||||
(v) => v.providerAccountId === item.providerAccountId && v.status === 'active',
|
|
||||||
).length
|
|
||||||
const allocatedBalance = activeInAccount > 0 ? Math.max(0, accountBalance) / activeInAccount : 0
|
|
||||||
const directPayments = payments
|
|
||||||
.filter((p) => p.vpsId === item.id && p.type === 'direct_vps_payment')
|
|
||||||
.reduce((acc, p) => acc + Number(p.amount || 0), 0)
|
|
||||||
const funds = directPayments + allocatedBalance
|
|
||||||
const coveredDays = Math.floor(funds / burnRate)
|
|
||||||
if (!Number.isFinite(coveredDays) || coveredDays <= 0) return paidUntilFromApi
|
|
||||||
|
|
||||||
const paidUntil = new Date()
|
|
||||||
paidUntil.setDate(paidUntil.getDate() + coveredDays)
|
|
||||||
return paidUntil
|
|
||||||
}
|
|
||||||
|
|
||||||
const upcoming = useMemo(() => {
|
|
||||||
const threshold = new Date()
|
|
||||||
threshold.setDate(threshold.getDate() + UPCOMING_DAYS)
|
|
||||||
return vps
|
|
||||||
.filter((item) => item.status === 'active')
|
|
||||||
.map((item) => {
|
|
||||||
const date = getPaidUntilDate(item)
|
|
||||||
return { vps: item, paidUntil: date }
|
|
||||||
})
|
|
||||||
.filter(({ paidUntil }) => paidUntil && paidUntil <= threshold && paidUntil >= new Date(now.getFullYear(), now.getMonth(), now.getDate()))
|
|
||||||
.sort((a, b) => a.paidUntil - b.paidUntil)
|
|
||||||
.slice(0, 10)
|
|
||||||
}, [vps, payments, balanceLedger, providerAccounts])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader pretitle="Обзор" title="Дашборд" />
|
<PageHeader pretitle="Обзор" title="Дашборд" />
|
||||||
<div className="row row-cards">
|
<div className="row row-cards">
|
||||||
|
{inventoryIssues.length > 0 ? (
|
||||||
|
<div className="col-12">
|
||||||
|
<div className="card border-warning">
|
||||||
|
<div className="card-header">
|
||||||
|
<h3 className="card-title">Здоровье инвентаря</h3>
|
||||||
|
</div>
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="row g-2">
|
||||||
|
{inventoryIssues.map((issue) => (
|
||||||
|
<div className="col-md-6 col-xl-4" key={issue.key}>
|
||||||
|
<Link className="card card-link" to={issue.to}>
|
||||||
|
<div className="card-body py-2 px-3">
|
||||||
|
<div className="d-flex justify-content-between align-items-center">
|
||||||
|
<span className="fw-medium">{issue.title}</span>
|
||||||
|
<span className="badge bg-orange-lt text-orange">{issue.count}</span>
|
||||||
|
</div>
|
||||||
|
{issue.hint ? <div className="text-secondary small mt-1">{issue.hint}</div> : null}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="col-12">
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header">
|
||||||
|
<h3 className="card-title">Последние синхронизации</h3>
|
||||||
|
<div className="card-actions">
|
||||||
|
<Link to="/accounts" className="btn btn-sm btn-outline-secondary">
|
||||||
|
Аккаунты
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="list-group list-group-flush">
|
||||||
|
{recentSyncFeed.map((row) => {
|
||||||
|
const acc = providerAccounts.find((a) => a.id === row.accountId)
|
||||||
|
const line = formatSyncSummaryLine(row.summary)
|
||||||
|
return (
|
||||||
|
<div key={row.id} className="list-group-item">
|
||||||
|
<div className="d-flex justify-content-between align-items-start gap-2">
|
||||||
|
<div>
|
||||||
|
<div className="fw-medium">{acc?.name || row.accountId}</div>
|
||||||
|
<div className="text-secondary small">
|
||||||
|
{row.status === 'error' ? (
|
||||||
|
<span className="text-danger">{row.error || line}</span>
|
||||||
|
) : (
|
||||||
|
line || 'OK'
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className={`badge ${row.status === 'ok' ? 'bg-green-lt text-green' : 'bg-red-lt text-red'}`}>
|
||||||
|
{row.status === 'ok' ? 'OK' : 'Ошибка'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-secondary small mt-1">
|
||||||
|
{row.finishedAt
|
||||||
|
? new Date(row.finishedAt).toLocaleString('ru-RU')
|
||||||
|
: '—'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{recentSyncFeed.length === 0 ? (
|
||||||
|
<div className="list-group-item text-secondary text-center py-4">
|
||||||
|
Запустите синхронизацию на странице аккаунтов — здесь появится краткий итог
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="col-sm-6 col-lg-3">
|
<div className="col-sm-6 col-lg-3">
|
||||||
<div className="card metric-card metric-blue h-100">
|
<div className="card metric-card metric-blue h-100">
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
|
|||||||
+157
-3
@@ -1,7 +1,14 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { IconPlus, IconSend, IconTrash } from '@tabler/icons-react'
|
import { IconPlus, IconSend, IconTrash } from '@tabler/icons-react'
|
||||||
import { PageHeader } from '../components/PageHeader'
|
import { PageHeader } from '../components/PageHeader'
|
||||||
import { sendTelegramTestNotification } from '../lib/api'
|
import {
|
||||||
|
downloadBackupDatabaseBlob,
|
||||||
|
downloadBackupJsonBlob,
|
||||||
|
importBackupDatabaseBuffer,
|
||||||
|
importBackupJson,
|
||||||
|
sendTelegramTestNotification,
|
||||||
|
} from '../lib/api'
|
||||||
|
import { downloadBlob } from '../lib/utils'
|
||||||
import { noBrowserSuggestProps, passwordCredentialInputProps } from '../lib/noBrowserSuggestProps'
|
import { noBrowserSuggestProps, passwordCredentialInputProps } from '../lib/noBrowserSuggestProps'
|
||||||
|
|
||||||
const defaultSettings = {
|
const defaultSettings = {
|
||||||
@@ -16,6 +23,8 @@ const defaultSettings = {
|
|||||||
telegramMessageThreadId: '',
|
telegramMessageThreadId: '',
|
||||||
notifyPaymentExpiryEnabled: false,
|
notifyPaymentExpiryEnabled: false,
|
||||||
notifyNewTariffsEnabled: false,
|
notifyNewTariffsEnabled: false,
|
||||||
|
notifyLowBalanceEnabled: false,
|
||||||
|
notifySyncDigestEnabled: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SettingsPage({ db, actions, ratesData, ratesError }) {
|
export function SettingsPage({ db, actions, ratesData, ratesError }) {
|
||||||
@@ -32,15 +41,18 @@ export function SettingsPage({ db, actions, ratesData, ratesError }) {
|
|||||||
telegramMessageThreadId: current.telegramMessageThreadId ?? '',
|
telegramMessageThreadId: current.telegramMessageThreadId ?? '',
|
||||||
notifyPaymentExpiryEnabled: Boolean(current.notifyPaymentExpiryEnabled),
|
notifyPaymentExpiryEnabled: Boolean(current.notifyPaymentExpiryEnabled),
|
||||||
notifyNewTariffsEnabled: Boolean(current.notifyNewTariffsEnabled),
|
notifyNewTariffsEnabled: Boolean(current.notifyNewTariffsEnabled),
|
||||||
|
notifyLowBalanceEnabled: Boolean(current.notifyLowBalanceEnabled),
|
||||||
|
notifySyncDigestEnabled: Boolean(current.notifySyncDigestEnabled),
|
||||||
})
|
})
|
||||||
const [telegramTokenEdited, setTelegramTokenEdited] = useState(false)
|
const [telegramTokenEdited, setTelegramTokenEdited] = useState(false)
|
||||||
const [telegramTestLoading, setTelegramTestLoading] = useState(false)
|
const [telegramTestLoading, setTelegramTestLoading] = useState(false)
|
||||||
const [telegramTestMessage, setTelegramTestMessage] = useState(null)
|
const [telegramTestMessage, setTelegramTestMessage] = useState(null)
|
||||||
|
const [backupBusy, setBackupBusy] = useState(false)
|
||||||
|
const [backupMessage, setBackupMessage] = useState(null)
|
||||||
const [newFieldLabel, setNewFieldLabel] = useState('')
|
const [newFieldLabel, setNewFieldLabel] = useState('')
|
||||||
const customFields = Array.isArray(current.customFields) ? current.customFields : []
|
const customFields = Array.isArray(current.customFields) ? current.customFields : []
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
/* eslint-disable-next-line react-hooks/set-state-in-effect -- sync form when settings change from parent */
|
|
||||||
setForm((prev) => ({
|
setForm((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
baseCurrency: current.baseCurrency || 'RUB',
|
baseCurrency: current.baseCurrency || 'RUB',
|
||||||
@@ -53,8 +65,10 @@ export function SettingsPage({ db, actions, ratesData, ratesError }) {
|
|||||||
telegramMessageThreadId: current.telegramMessageThreadId ?? '',
|
telegramMessageThreadId: current.telegramMessageThreadId ?? '',
|
||||||
notifyPaymentExpiryEnabled: Boolean(current.notifyPaymentExpiryEnabled),
|
notifyPaymentExpiryEnabled: Boolean(current.notifyPaymentExpiryEnabled),
|
||||||
notifyNewTariffsEnabled: Boolean(current.notifyNewTariffsEnabled),
|
notifyNewTariffsEnabled: Boolean(current.notifyNewTariffsEnabled),
|
||||||
|
notifyLowBalanceEnabled: Boolean(current.notifyLowBalanceEnabled),
|
||||||
|
notifySyncDigestEnabled: Boolean(current.notifySyncDigestEnabled),
|
||||||
}))
|
}))
|
||||||
}, [current.baseCurrency, current.ratesUrl, current.autoConvert, current.syncEnabled, current.syncIntervalMinutes, current.syncTariffsIntervalMinutes, current.telegramChatId, current.telegramMessageThreadId, current.notifyPaymentExpiryEnabled, current.notifyNewTariffsEnabled])
|
}, [current.baseCurrency, current.ratesUrl, current.autoConvert, current.syncEnabled, current.syncIntervalMinutes, current.syncTariffsIntervalMinutes, current.telegramChatId, current.telegramMessageThreadId, current.notifyPaymentExpiryEnabled, current.notifyNewTariffsEnabled, current.notifyLowBalanceEnabled, current.notifySyncDigestEnabled])
|
||||||
|
|
||||||
const availableCurrencies = useMemo(() => {
|
const availableCurrencies = useMemo(() => {
|
||||||
const list = new Set(['RUB', 'USD', 'EUR'])
|
const list = new Set(['RUB', 'USD', 'EUR'])
|
||||||
@@ -103,6 +117,8 @@ export function SettingsPage({ db, actions, ratesData, ratesError }) {
|
|||||||
telegramMessageThreadId: form.telegramMessageThreadId || '',
|
telegramMessageThreadId: form.telegramMessageThreadId || '',
|
||||||
notifyPaymentExpiryEnabled: form.notifyPaymentExpiryEnabled,
|
notifyPaymentExpiryEnabled: form.notifyPaymentExpiryEnabled,
|
||||||
notifyNewTariffsEnabled: form.notifyNewTariffsEnabled,
|
notifyNewTariffsEnabled: form.notifyNewTariffsEnabled,
|
||||||
|
notifyLowBalanceEnabled: form.notifyLowBalanceEnabled,
|
||||||
|
notifySyncDigestEnabled: form.notifySyncDigestEnabled,
|
||||||
}
|
}
|
||||||
if (telegramTokenEdited && form.telegramBotToken !== undefined) {
|
if (telegramTokenEdited && form.telegramBotToken !== undefined) {
|
||||||
payload.telegramBotToken = form.telegramBotToken || ''
|
payload.telegramBotToken = form.telegramBotToken || ''
|
||||||
@@ -368,6 +384,30 @@ export function SettingsPage({ db, actions, ratesData, ratesError }) {
|
|||||||
<span className="form-check-label">Уведомления о новых тарифах</span>
|
<span className="form-check-label">Уведомления о новых тарифах</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="col-12">
|
||||||
|
<label className="form-check">
|
||||||
|
<input {...noBrowserSuggestProps}
|
||||||
|
className="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.notifyLowBalanceEnabled}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, notifyLowBalanceEnabled: e.target.checked }))}
|
||||||
|
/>
|
||||||
|
<span className="form-check-label">
|
||||||
|
Низкий баланс (порог задаётся у каждого аккаунта BILLmanager)
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="col-12">
|
||||||
|
<label className="form-check">
|
||||||
|
<input {...noBrowserSuggestProps}
|
||||||
|
className="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.notifySyncDigestEnabled}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, notifySyncDigestEnabled: e.target.checked }))}
|
||||||
|
/>
|
||||||
|
<span className="form-check-label">Краткий итог после планового синка VPS</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<div className="col-12 d-flex justify-content-end gap-2">
|
<div className="col-12 d-flex justify-content-end gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -401,6 +441,120 @@ export function SettingsPage({ db, actions, ratesData, ratesError }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="col-12">
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header">
|
||||||
|
<h3 className="card-title">Резервная копия</h3>
|
||||||
|
</div>
|
||||||
|
<div className="card-body">
|
||||||
|
<p className="text-secondary small mb-3">
|
||||||
|
JSON включает все данные (в т.ч. API-ключи и токен Telegram). Файл SQLite — точная копия базы.
|
||||||
|
Восстановление перезаписывает текущие данные.
|
||||||
|
</p>
|
||||||
|
<div className="d-flex flex-wrap gap-2 mb-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline-primary"
|
||||||
|
disabled={backupBusy}
|
||||||
|
onClick={async () => {
|
||||||
|
setBackupMessage(null)
|
||||||
|
setBackupBusy(true)
|
||||||
|
try {
|
||||||
|
const blob = await downloadBackupJsonBlob()
|
||||||
|
downloadBlob('vps-tracker-backup.json', blob)
|
||||||
|
} catch (err) {
|
||||||
|
setBackupMessage({ type: 'danger', text: err.message || 'Ошибка выгрузки' })
|
||||||
|
} finally {
|
||||||
|
setBackupBusy(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Скачать JSON
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline-primary"
|
||||||
|
disabled={backupBusy}
|
||||||
|
onClick={async () => {
|
||||||
|
setBackupMessage(null)
|
||||||
|
setBackupBusy(true)
|
||||||
|
try {
|
||||||
|
const blob = await downloadBackupDatabaseBlob()
|
||||||
|
downloadBlob('vps-tracker.db', blob)
|
||||||
|
} catch (err) {
|
||||||
|
setBackupMessage({ type: 'danger', text: err.message || 'Ошибка выгрузки' })
|
||||||
|
} finally {
|
||||||
|
setBackupBusy(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Скачать SQLite
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="row g-2 align-items-end">
|
||||||
|
<div className="col-12 col-md-6">
|
||||||
|
<label className="form-label">Восстановить из JSON</label>
|
||||||
|
<input
|
||||||
|
{...noBrowserSuggestProps}
|
||||||
|
type="file"
|
||||||
|
accept="application/json,.json"
|
||||||
|
className="form-control"
|
||||||
|
disabled={backupBusy}
|
||||||
|
onChange={async (e) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
e.target.value = ''
|
||||||
|
if (!file) return
|
||||||
|
setBackupMessage(null)
|
||||||
|
setBackupBusy(true)
|
||||||
|
try {
|
||||||
|
const text = await file.text()
|
||||||
|
const data = JSON.parse(text)
|
||||||
|
await importBackupJson(data)
|
||||||
|
await actions.refreshData()
|
||||||
|
setBackupMessage({ type: 'success', text: 'Данные восстановлены из JSON' })
|
||||||
|
} catch (err) {
|
||||||
|
setBackupMessage({ type: 'danger', text: err.message || 'Ошибка импорта' })
|
||||||
|
} finally {
|
||||||
|
setBackupBusy(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-12 col-md-6">
|
||||||
|
<label className="form-label">Восстановить из SQLite</label>
|
||||||
|
<input
|
||||||
|
{...noBrowserSuggestProps}
|
||||||
|
type="file"
|
||||||
|
accept=".db,application/octet-stream"
|
||||||
|
className="form-control"
|
||||||
|
disabled={backupBusy}
|
||||||
|
onChange={async (e) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
e.target.value = ''
|
||||||
|
if (!file) return
|
||||||
|
setBackupMessage(null)
|
||||||
|
setBackupBusy(true)
|
||||||
|
try {
|
||||||
|
const buf = await file.arrayBuffer()
|
||||||
|
await importBackupDatabaseBuffer(buf)
|
||||||
|
await actions.refreshData()
|
||||||
|
setBackupMessage({ type: 'success', text: 'База восстановлена из SQLite' })
|
||||||
|
} catch (err) {
|
||||||
|
setBackupMessage({ type: 'danger', text: err.message || 'Ошибка восстановления' })
|
||||||
|
} finally {
|
||||||
|
setBackupBusy(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{backupMessage ? (
|
||||||
|
<div className={`alert alert-${backupMessage.type} py-2 mt-3 mb-0`}>{backupMessage.text}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="col-12 col-lg-6">
|
<div className="col-12 col-lg-6">
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="card-header">
|
<div className="card-header">
|
||||||
|
|||||||
+55
-1
@@ -1,4 +1,5 @@
|
|||||||
import { Fragment, useMemo, useState } from 'react'
|
import { Fragment, useMemo, useState } from 'react'
|
||||||
|
import { Link, useSearchParams } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
convertCurrency,
|
convertCurrency,
|
||||||
faviconUrlFromWebsite,
|
faviconUrlFromWebsite,
|
||||||
@@ -25,6 +26,7 @@ import { EmptyState } from '../components/EmptyState'
|
|||||||
import { PageHeader } from '../components/PageHeader'
|
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'
|
||||||
|
|
||||||
const emptyForm = {
|
const emptyForm = {
|
||||||
ip: '',
|
ip: '',
|
||||||
@@ -111,6 +113,9 @@ function buildDefaultVpsFilters(customFields) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function VpsPage({ db, actions, settings, ratesData }) {
|
export function VpsPage({ db, actions, settings, ratesData }) {
|
||||||
|
const [searchParams] = useSearchParams()
|
||||||
|
const healthKey = (searchParams.get('health') || '').trim()
|
||||||
|
|
||||||
const customFields = Array.isArray(settings?.[0]?.customFields) ? settings[0].customFields : []
|
const customFields = Array.isArray(settings?.[0]?.customFields) ? settings[0].customFields : []
|
||||||
const [form, setForm] = useState(emptyForm)
|
const [form, setForm] = useState(emptyForm)
|
||||||
const [editingId, setEditingId] = useState(null)
|
const [editingId, setEditingId] = useState(null)
|
||||||
@@ -150,8 +155,43 @@ export function VpsPage({ db, actions, settings, ratesData }) {
|
|||||||
tableCompact: false,
|
tableCompact: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const healthPredicate = useMemo(() => {
|
||||||
|
if (!healthKey) return null
|
||||||
|
const todayStart = new Date()
|
||||||
|
todayStart.setHours(0, 0, 0, 0)
|
||||||
|
const ctx = {
|
||||||
|
vps: db.vps,
|
||||||
|
providerAccounts: db.providerAccounts,
|
||||||
|
payments: db.payments,
|
||||||
|
balanceLedger: db.balanceLedger,
|
||||||
|
}
|
||||||
|
if (healthKey === 'no-project') {
|
||||||
|
return (item) => item.status === 'active' && !(item.project || '').trim()
|
||||||
|
}
|
||||||
|
if (healthKey === 'no-rate') {
|
||||||
|
return (item) => {
|
||||||
|
if (item.status !== 'active') return false
|
||||||
|
const dr = Number(item.dailyRate || 0)
|
||||||
|
const mr = Number(item.monthlyRate || 0)
|
||||||
|
const noMoney =
|
||||||
|
(!Number.isFinite(dr) || dr <= 0) && (!Number.isFinite(mr) || mr <= 0)
|
||||||
|
const noCur = !(item.currency || '').trim()
|
||||||
|
return noMoney || noCur
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (healthKey === 'paid-overdue') {
|
||||||
|
return (item) => {
|
||||||
|
if (item.status !== 'active') return false
|
||||||
|
const d = computePaidUntilForHealth(item, ctx)
|
||||||
|
return Boolean(d && d < todayStart)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}, [healthKey, db.vps, db.providerAccounts, db.payments, db.balanceLedger])
|
||||||
|
|
||||||
const filteredVps = useMemo(() => {
|
const filteredVps = useMemo(() => {
|
||||||
return db.vps.filter((item) => {
|
return db.vps.filter((item) => {
|
||||||
|
if (healthPredicate && !healthPredicate(item)) return false
|
||||||
const search = filters.search.toLowerCase()
|
const search = filters.search.toLowerCase()
|
||||||
const extraIps = Array.isArray(item.additionalIps) ? item.additionalIps.join(' ') : ''
|
const extraIps = Array.isArray(item.additionalIps) ? item.additionalIps.join(' ') : ''
|
||||||
const minVcpu = Number(filters.minVcpu || 0)
|
const minVcpu = Number(filters.minVcpu || 0)
|
||||||
@@ -217,7 +257,7 @@ export function VpsPage({ db, actions, settings, ratesData }) {
|
|||||||
byProject
|
byProject
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}, [db.vps, filters, customFields])
|
}, [db.vps, filters, customFields, healthPredicate])
|
||||||
|
|
||||||
const projectNameOptions = useMemo(() => {
|
const projectNameOptions = useMemo(() => {
|
||||||
const names = new Set()
|
const names = new Set()
|
||||||
@@ -598,6 +638,20 @@ export function VpsPage({ db, actions, settings, ratesData }) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader pretitle="Управление инфраструктурой" title="VPS серверы" />
|
<PageHeader pretitle="Управление инфраструктурой" title="VPS серверы" />
|
||||||
|
{healthKey && healthPredicate ? (
|
||||||
|
<div className="alert alert-info d-flex align-items-center justify-content-between flex-wrap gap-2">
|
||||||
|
<span>Показаны только VPS по замечанию с дашборда ({healthKey}).</span>
|
||||||
|
<Link to="/vps" className="btn btn-sm btn-outline-primary">
|
||||||
|
Сбросить ссылку
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{healthKey && !healthPredicate ? (
|
||||||
|
<div className="alert alert-warning">
|
||||||
|
Неизвестный параметр health="{healthKey}".{' '}
|
||||||
|
<Link to="/vps">К полному списку</Link>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<div className="row row-cards">
|
<div className="row row-cards">
|
||||||
<div className="col-12 card-stack">
|
<div className="col-12 card-stack">
|
||||||
<div className="card">
|
<div className="card">
|
||||||
|
|||||||
Reference in New Issue
Block a user