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 || {}
|
||||
|
||||
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) {
|
||||
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
@@ -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 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) {
|
||||
const vps = mapVdsToVps(item, providerId, accountId)
|
||||
const id = `vps-bm-${accountId}-${vps.externalId}`
|
||||
@@ -82,6 +90,14 @@ export async function syncFromBillmanager(account, db, opts = {}) {
|
||||
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,
|
||||
merged.ip,
|
||||
merged.ipv6,
|
||||
@@ -101,6 +117,8 @@ export async function syncFromBillmanager(account, db, opts = {}) {
|
||||
existing.id,
|
||||
)
|
||||
} else {
|
||||
const label = vps.dns || vps.ip || id
|
||||
syncSummary.added.push({ id, label })
|
||||
db.run(vpsInsertSql,
|
||||
id,
|
||||
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)
|
||||
existingPayments.add(note)
|
||||
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
|
||||
*/
|
||||
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))
|
||||
// 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')
|
||||
|
||||
let dbInstance = null
|
||||
/** @type {import('sql.js').SqlJsStatic | null} */
|
||||
let sqlJsFactory = null
|
||||
|
||||
export async function initDb() {
|
||||
const dataDir = join(__dirname, '..', '..', 'data')
|
||||
@@ -25,6 +27,7 @@ export async function initDb() {
|
||||
}
|
||||
|
||||
const SQL = await initSqlJs()
|
||||
sqlJsFactory = SQL
|
||||
let db
|
||||
|
||||
if (existsSync(DB_PATH)) {
|
||||
@@ -110,3 +113,27 @@ export function saveDb() {
|
||||
const data = dbInstance.export()
|
||||
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`)
|
||||
},
|
||||
},
|
||||
{
|
||||
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 syncRouter from './routes/sync.js'
|
||||
import projectsRouter from './routes/projects.js'
|
||||
import backupRouter from './routes/backup.js'
|
||||
|
||||
const app = express()
|
||||
const PORT = process.env.PORT || 3001
|
||||
|
||||
app.use(cors())
|
||||
app.use(express.json())
|
||||
app.use(express.json({ limit: '50mb' }))
|
||||
|
||||
;(async () => {
|
||||
await initDb()
|
||||
@@ -31,6 +32,7 @@ app.use(express.json())
|
||||
app.use('/api/settings', settingsRouter)
|
||||
app.use('/api/sync', syncRouter)
|
||||
app.use('/api/projects', projectsRouter)
|
||||
app.use('/api/backup', backupRouter)
|
||||
|
||||
const { startScheduler } = await import('./sync-scheduler.js')
|
||||
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 r = req.body
|
||||
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(`
|
||||
INSERT INTO provider_accounts (id, providerId, name, panelUrl, currency, billingMode, notes, apiType, apiBaseUrl, apiCredentials)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO provider_accounts (id, providerId, name, panelUrl, currency, billingMode, notes, apiType, apiBaseUrl, apiCredentials, balance_alert_below)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
id,
|
||||
r.providerId ?? '',
|
||||
@@ -38,6 +42,7 @@ router.post('/', (req, res) => {
|
||||
r.apiType ?? '',
|
||||
r.apiBaseUrl ?? '',
|
||||
r.apiCredentials ?? '',
|
||||
Number.isFinite(alertBelow) ? alertBelow : null,
|
||||
)
|
||||
const row = db.prepare('SELECT * FROM provider_accounts WHERE id = ?').get(id)
|
||||
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 apiBaseUrl = r.apiBaseUrl !== undefined ? String(r.apiBaseUrl || '') : (existing.apiBaseUrl || '')
|
||||
const apiCredentials = r.apiCredentials !== undefined ? String(r.apiCredentials || '') : (existing.apiCredentials || '')
|
||||
let balanceAlertBelow = existing.balance_alert_below
|
||||
if (r.balance_alert_below !== undefined) {
|
||||
const v = r.balance_alert_below
|
||||
balanceAlertBelow =
|
||||
v === '' || v == null
|
||||
? null
|
||||
: Number.isFinite(Number(v))
|
||||
? Number(v)
|
||||
: null
|
||||
}
|
||||
db.prepare(`
|
||||
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 = ?
|
||||
`).run(
|
||||
r.providerId ?? existing.providerId ?? '',
|
||||
@@ -70,6 +85,7 @@ router.put('/:id', (req, res) => {
|
||||
apiType,
|
||||
apiBaseUrl,
|
||||
apiCredentials,
|
||||
balanceAlertBelow,
|
||||
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),
|
||||
notifyPaymentExpiryEnabled: Boolean(row.notifyPaymentExpiryEnabled),
|
||||
notifyNewTariffsEnabled: Boolean(row.notifyNewTariffsEnabled),
|
||||
notifyLowBalanceEnabled: Boolean(row.notifyLowBalanceEnabled),
|
||||
notifySyncDigestEnabled: Boolean(row.notifySyncDigestEnabled),
|
||||
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 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 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 telegramChatId = r.telegramChatId !== undefined ? (r.telegramChatId || '') : (existing?.telegramChatId ?? '')
|
||||
const telegramMessageThreadId = r.telegramMessageThreadId !== undefined ? (r.telegramMessageThreadId || '') : (existing?.telegramMessageThreadId ?? '')
|
||||
@@ -78,7 +82,8 @@ router.put('/:id', (req, res) => {
|
||||
db.prepare(`
|
||||
UPDATE settings SET
|
||||
baseCurrency = ?, ratesUrl = ?, autoConvert = ?, ratesUpdatedAt = ?, syncEnabled = ?, syncIntervalMinutes = ?, syncTariffsIntervalMinutes = ?,
|
||||
telegramBotToken = ?, telegramChatId = ?, telegramMessageThreadId = ?, notifyPaymentExpiryEnabled = ?, notifyNewTariffsEnabled = ?, customFields = ?
|
||||
telegramBotToken = ?, telegramChatId = ?, telegramMessageThreadId = ?, notifyPaymentExpiryEnabled = ?, notifyNewTariffsEnabled = ?, customFields = ?,
|
||||
notifyLowBalanceEnabled = ?, notifySyncDigestEnabled = ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
r.baseCurrency ?? existing.baseCurrency ?? 'RUB',
|
||||
@@ -94,12 +99,14 @@ router.put('/:id', (req, res) => {
|
||||
notifyPaymentExpiryEnabled,
|
||||
notifyNewTariffsEnabled,
|
||||
customFields,
|
||||
notifyLowBalanceEnabled,
|
||||
notifySyncDigestEnabled,
|
||||
id,
|
||||
)
|
||||
} else {
|
||||
db.prepare(`
|
||||
INSERT INTO settings (id, baseCurrency, ratesUrl, autoConvert, ratesUpdatedAt, syncEnabled, syncIntervalMinutes, syncTariffsIntervalMinutes, telegramBotToken, telegramChatId, telegramMessageThreadId, notifyPaymentExpiryEnabled, notifyNewTariffsEnabled, customFields)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO settings (id, baseCurrency, ratesUrl, autoConvert, ratesUpdatedAt, syncEnabled, syncIntervalMinutes, syncTariffsIntervalMinutes, telegramBotToken, telegramChatId, telegramMessageThreadId, notifyPaymentExpiryEnabled, notifyNewTariffsEnabled, customFields, notifyLowBalanceEnabled, notifySyncDigestEnabled)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
id,
|
||||
r.baseCurrency ?? 'RUB',
|
||||
@@ -115,6 +122,8 @@ router.put('/:id', (req, res) => {
|
||||
notifyPaymentExpiryEnabled,
|
||||
notifyNewTariffsEnabled,
|
||||
customFields,
|
||||
notifyLowBalanceEnabled,
|
||||
notifySyncDigestEnabled,
|
||||
)
|
||||
}
|
||||
startScheduler()
|
||||
@@ -135,13 +144,15 @@ router.post('/', (req, res) => {
|
||||
const syncTariffsIntervalMinutes = Math.max(60, Number(r.syncTariffsIntervalMinutes) || 1440)
|
||||
const notifyPaymentExpiryEnabled = r.notifyPaymentExpiryEnabled ? 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 telegramChatId = r.telegramChatId ?? ''
|
||||
const telegramMessageThreadId = r.telegramMessageThreadId ?? ''
|
||||
const customFields = serializeCustomFields(r.customFields)
|
||||
db.prepare(`
|
||||
INSERT INTO settings (id, baseCurrency, ratesUrl, autoConvert, ratesUpdatedAt, syncEnabled, syncIntervalMinutes, syncTariffsIntervalMinutes, telegramBotToken, telegramChatId, telegramMessageThreadId, notifyPaymentExpiryEnabled, notifyNewTariffsEnabled, customFields)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO settings (id, baseCurrency, ratesUrl, autoConvert, ratesUpdatedAt, syncEnabled, syncIntervalMinutes, syncTariffsIntervalMinutes, telegramBotToken, telegramChatId, telegramMessageThreadId, notifyPaymentExpiryEnabled, notifyNewTariffsEnabled, customFields, notifyLowBalanceEnabled, notifySyncDigestEnabled)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
id,
|
||||
r.baseCurrency ?? 'RUB',
|
||||
@@ -157,6 +168,8 @@ router.post('/', (req, res) => {
|
||||
notifyPaymentExpiryEnabled,
|
||||
notifyNewTariffsEnabled,
|
||||
customFields,
|
||||
notifyLowBalanceEnabled,
|
||||
notifySyncDigestEnabled,
|
||||
)
|
||||
startScheduler()
|
||||
const row = db.prepare('SELECT * FROM settings WHERE id = ?').get(id)
|
||||
|
||||
+17
-26
@@ -1,6 +1,7 @@
|
||||
import { Router } from 'express'
|
||||
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()
|
||||
|
||||
@@ -21,10 +22,22 @@ router.get('/status', (req, res) => {
|
||||
try {
|
||||
const db = getDb()
|
||||
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
|
||||
`).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) {
|
||||
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' })
|
||||
}
|
||||
|
||||
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 result = await syncFromBillmanager(row, db, opts)
|
||||
|
||||
db.prepare(`
|
||||
UPDATE sync_log SET finishedAt=?, status=?, vpsCount=?, paymentsCount=?
|
||||
WHERE id=?
|
||||
`).run(new Date().toISOString(), 'ok', result.vpsCount, result.paymentsCount, logId)
|
||||
const result = await runBillmanagerAccountSync(row, opts)
|
||||
|
||||
res.json({
|
||||
ok: true,
|
||||
@@ -100,17 +102,6 @@ router.post('/:accountId', async (req, res) => {
|
||||
})
|
||||
} catch (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' })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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 { syncFromBillmanager } from './adapters/billmanager/index.js'
|
||||
import { runBillmanagerAccountSync } from './sync-account-job.js'
|
||||
import { sendTelegramMessage } from './telegram.js'
|
||||
|
||||
let syncIntervalId = null
|
||||
@@ -113,13 +113,53 @@ export async function runScheduledSync() {
|
||||
SELECT * FROM provider_accounts
|
||||
WHERE apiType = 'billmanager' AND apiBaseUrl IS NOT NULL AND apiBaseUrl != '' AND apiCredentials IS NOT NULL AND apiCredentials != ''
|
||||
`).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) {
|
||||
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) {
|
||||
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) {
|
||||
await sendPaymentExpiryNotifications(db)
|
||||
}
|
||||
@@ -141,7 +181,7 @@ export async function runScheduledSyncTariffs() {
|
||||
|
||||
for (const account of accounts) {
|
||||
try {
|
||||
const result = await syncFromBillmanager(account, db, { skipVpsPayments: true })
|
||||
const result = await runBillmanagerAccountSync(account, { skipVpsPayments: true })
|
||||
const newTariffs = result?.newTariffs || []
|
||||
if (newTariffs.length > 0 && settings?.notifyNewTariffsEnabled && settings?.telegramBotToken?.trim() && settings?.telegramChatId?.trim()) {
|
||||
const provider = providers.find((p) => p.id === account.providerId)
|
||||
|
||||
Reference in New Issue
Block a user