feat: расширить инвентарь, ops и интеграции по roadmap
Docker / build (push) Has been cancelled

Добавлены bulk-операции VPS, карточка /vps/:id, CRUD проектов, Command Palette,
календарь продлений, webhooks, uptime-проверки, audit log, кастомные поля и адаптеры провайдеров.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-06-28 15:28:35 +07:00
co-authored by Cursor
parent 0ea92746e4
commit 11b67a1cef
43 changed files with 2085 additions and 79 deletions
+2
View File
@@ -21,6 +21,7 @@ import { backupRoutes } from './routes/backup.js'
import { ratesProxyRoutes } from './routes/rates-proxy.js'
import { migrateRoutes } from './routes/migrate.js'
import { dashboardRoutes } from './routes/dashboard.js'
import { auditRoutes } from './routes/audit.js'
import { startScheduler } from './services/scheduler.js'
const __dirname = dirname(fileURLToPath(import.meta.url))
@@ -54,6 +55,7 @@ export async function buildApp(opts: BuildAppOptions = {}) {
await app.register(ratesProxyRoutes)
await app.register(migrateRoutes)
await app.register(dashboardRoutes)
await app.register(auditRoutes)
const staticDir = opts.staticDir ?? join(__dirname, '..', '..', 'web', 'dist')
if (existsSync(staticDir)) {
+14
View File
@@ -0,0 +1,14 @@
import type { FastifyPluginAsync } from 'fastify'
import { auditLogRepository } from '@cfdm/db/repositories/audit-log'
export const auditRoutes: FastifyPluginAsync = async (app) => {
app.get('/api/audit', async (req) => {
const limit = Number((req.query as { limit?: string })?.limit) || 100
return auditLogRepository.list(limit)
})
app.get<{ Params: { entity: string; entityId: string } }>(
'/api/audit/:entity/:entityId',
async (req) => auditLogRepository.listForEntity(req.params.entity, req.params.entityId),
)
}
+25
View File
@@ -27,4 +27,29 @@ export const projectsRoutes: FastifyPluginAsync = async (app) => {
}
return reply.code(201).send(resolveOrCreateProject(name))
})
app.put<{ Params: { id: string } }>('/api/projects/:id', async (req, reply) => {
const body = req.body as { name?: unknown; color?: string | null; notes?: string | null }
const name = body.name != null ? normalizeProjectNameInput(body.name) : undefined
if (name === '') {
return reply.code(400).send({ error: { code: 'VALIDATION', message: 'name cannot be empty' } })
}
const updated = projectsRepository.update(req.params.id, {
...(name ? { name } : {}),
color: body.color,
notes: body.notes,
})
if (!updated) {
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
}
return updated
})
app.delete<{ Params: { id: string } }>('/api/projects/:id', async (req, reply) => {
const ok = projectsRepository.delete(req.params.id)
if (!ok) {
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
}
return reply.code(204).send()
})
}
+8 -1
View File
@@ -1,6 +1,7 @@
import type { FastifyPluginAsync } from 'fastify'
import { vpsRepository } from '@cfdm/db/repositories/vps'
import { vpsSchema } from '@cfdm/shared/contracts/vps'
import { auditCreate, auditDelete, auditUpdate } from '../services/audit.js'
export const vpsRoutes: FastifyPluginAsync = async (app) => {
app.get('/api/vps', async () => vpsRepository.list())
@@ -10,7 +11,11 @@ export const vpsRoutes: FastifyPluginAsync = async (app) => {
if (!parsed.success) {
return reply.code(400).send({ error: { code: 'VALIDATION', message: parsed.error.message } })
}
return reply.code(201).send(vpsRepository.create(parsed.data))
const created = vpsRepository.create(parsed.data)
const list = Array.isArray(created) ? created : [created]
const last = list[list.length - 1] as { id?: string } | undefined
if (last?.id) auditCreate('vps', last.id, parsed.data as Record<string, unknown>)
return reply.code(201).send(created)
})
app.put<{ Params: { id: string } }>('/api/vps/:id', async (req, reply) => {
@@ -22,6 +27,7 @@ export const vpsRoutes: FastifyPluginAsync = async (app) => {
if (!updated) {
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
}
auditUpdate('vps', req.params.id, parsed.data as Record<string, unknown>)
return updated
})
@@ -30,6 +36,7 @@ export const vpsRoutes: FastifyPluginAsync = async (app) => {
if (!ok) {
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
}
auditDelete('vps', req.params.id)
return reply.code(204).send()
})
+13
View File
@@ -0,0 +1,13 @@
import { auditLogRepository } from '@cfdm/db/repositories/audit-log'
export function auditCreate(entity: string, entityId: string, data?: Record<string, unknown>): void {
auditLogRepository.append({ entity, entityId, action: 'create', diff: data })
}
export function auditUpdate(entity: string, entityId: string, patch: Record<string, unknown>): void {
auditLogRepository.append({ entity, entityId, action: 'update', diff: patch })
}
export function auditDelete(entity: string, entityId: string): void {
auditLogRepository.append({ entity, entityId, action: 'delete' })
}
+32 -11
View File
@@ -95,21 +95,42 @@ export function computeDashboardStats(): DashboardStats {
return balance < threshold
}).length
const noRateCount = activeVps.filter((v) => {
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
}).length
const paidOverdueCount = activeVps.filter((v) => {
if (!v.paidUntil) return false
const d = new Date(v.paidUntil)
if (Number.isNaN(d.getTime())) return false
return d < todayStart
}).length
const balanceMismatchCount = snap.providerAccounts.filter((a) => {
const apiBalance = a.balanceApi != null ? Number(a.balanceApi) : null
if (apiBalance == null || !Number.isFinite(apiBalance)) return false
const rows = snap.balanceLedger.filter((r) => r.providerAccountId === a.id)
if (rows.length === 0) return false
const credits = rows.filter((r) => r.direction === 'credit').reduce((acc, r) => acc + Number(r.amount || 0), 0)
const debits = rows.filter((r) => r.direction === 'debit').reduce((acc, r) => acc + Number(r.amount || 0), 0)
const ledger = credits - debits
if (!Number.isFinite(ledger)) return false
const diff = Math.abs(apiBalance - ledger)
const tol = Math.max(10, Math.abs(apiBalance) * 0.05)
return diff > tol
}).length
let issuesCount = 0
if (
activeVps.some((v) => {
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
})
) {
issuesCount++
}
if (noRateCount > 0) issuesCount++
if (paidOverdueCount > 0) issuesCount++
if (expiringWithin7Days > 0) issuesCount++
if (staleSyncAccountCount > 0) issuesCount++
if (lowBalanceAccountCount > 0) issuesCount++
if (balanceMismatchCount > 0) issuesCount++
let lastGlobalSyncAt: string | null = null
for (const row of snap.syncLog) {
+53
View File
@@ -0,0 +1,53 @@
import type { ProviderAdapter, SyncResult } from './types.js'
import { syncFromBillmanager } from '../billmanager/sync.js'
import type { BillmanagerSyncAccount } from '../billmanager/context.js'
import { testConnection as bmTestConnection } from '../billmanager/operations.js'
export const billmanagerAdapter: ProviderAdapter = {
type: 'billmanager',
async testConnection(apiBaseUrl: string, apiCredentials: string) {
const result = await bmTestConnection(apiBaseUrl, apiCredentials)
return { ok: result.ok, message: result.error }
},
async syncAccount(account: BillmanagerSyncAccount, options?: { skipTariffs?: boolean; skipVpsPayments?: boolean }): Promise<SyncResult> {
const result = await syncFromBillmanager(account, options)
return {
vpsCount: result.vpsCount,
paymentsCount: result.paymentsCount,
tariffsCount: result.tariffsCount,
balance: result.balance,
syncSummary: result.syncSummary,
newTariffs: result.newTariffs,
}
},
}
export const manualAdapter: ProviderAdapter = {
type: 'manual',
async testConnection() {
return { ok: true, message: 'Ручной учёт — API не требуется' }
},
async syncAccount() {
return {
vpsCount: 0,
paymentsCount: 0,
tariffsCount: 0,
balance: null,
syncSummary: { added: [], updated: [], paymentsAdded: 0 },
newTariffs: [],
}
},
}
const adapters: Record<string, ProviderAdapter> = {
billmanager: billmanagerAdapter,
manual: manualAdapter,
none: manualAdapter,
}
export function getProviderAdapter(apiType: string | null | undefined): ProviderAdapter {
const key = (apiType || 'none').toLowerCase().trim()
return adapters[key] ?? manualAdapter
}
+24
View File
@@ -0,0 +1,24 @@
export interface SyncSummary {
added: { id: string; label: string }[]
updated: { id: string; label: string; fields?: string[] }[]
paymentsAdded: number
tariffsOnly?: boolean
}
export interface SyncResult {
vpsCount: number
paymentsCount: number
tariffsCount: number
balance: { balance?: number; currency?: string } | null
syncSummary: SyncSummary
newTariffs: { name: string; price: string; providerId: string }[]
}
export interface ProviderAdapter {
type: string
testConnection(apiBaseUrl: string, apiCredentials: string): Promise<{ ok: boolean; message?: string }>
syncAccount(
account: unknown,
options?: { skipTariffs?: boolean; skipVpsPayments?: boolean },
): Promise<SyncResult>
}
+44 -3
View File
@@ -5,9 +5,12 @@ import { settingsRepository } from '@cfdm/db/repositories/settings'
import { billmanagerAccountRowForSync } from './billmanager/context.js'
import { runBillmanagerAccountSync } from './billmanager/sync-job.js'
import { sendTelegramMessage } from './telegram.js'
import { notifyWebhook } from './webhook.js'
import { runVpsUptimeChecks } from './uptime-check.js'
let syncIntervalId: ReturnType<typeof setInterval> | null = null
let syncTariffsIntervalId: ReturnType<typeof setInterval> | null = null
let uptimeIntervalId: ReturnType<typeof setInterval> | null = null
const UPCOMING_DAYS = 7
const SETTINGS_ID = 'settings-main'
@@ -152,6 +155,9 @@ async function sendPaymentExpiryNotifications(): Promise<void> {
text,
settings.telegramMessageThreadId,
)
await notifyWebhook(settings, 'payment_expiry', text.replace(/<[^>]+>/g, ''), {
count: upcoming.length,
})
}
export async function runScheduledSync(): Promise<void> {
@@ -197,10 +203,14 @@ export async function runScheduledSync(): Promise<void> {
}
if (canTg && settings.notifySyncDigestEnabled && digestLines.length > 0) {
await sendTelegramMessage(token!, chatId!, `📋 <b>Синхронизация VPS</b>\n\n${digestLines.join('\n')}`, settings.telegramMessageThreadId)
const msg = `📋 <b>Синхронизация VPS</b>\n\n${digestLines.join('\n')}`
await sendTelegramMessage(token!, chatId!, msg, settings.telegramMessageThreadId)
await notifyWebhook(settings, 'sync_digest', msg.replace(/<[^>]+>/g, ''), { lines: digestLines })
}
if (canTg && settings.notifyLowBalanceEnabled && lowBalanceLines.length > 0) {
await sendTelegramMessage(token!, chatId!, `💰 <b>Низкий баланс</b>\n\n${lowBalanceLines.join('\n')}`, settings.telegramMessageThreadId)
const msg = `💰 <b>Низкий баланс</b>\n\n${lowBalanceLines.join('\n')}`
await sendTelegramMessage(token!, chatId!, msg, settings.telegramMessageThreadId)
await notifyWebhook(settings, 'low_balance', msg.replace(/<[^>]+>/g, ''), { lines: lowBalanceLines })
}
if (settings.notifyPaymentExpiryEnabled) {
@@ -248,11 +258,38 @@ export async function runScheduledSyncTariffs(): Promise<void> {
}
}
export async function runScheduledUptimeChecks(): Promise<void> {
try {
const settings = settingsRepository.getRow(SETTINGS_ID)
const { checked, down } = await runVpsUptimeChecks()
if (down > 0 && settings) {
const msg = `VPS недоступны: ${down} из ${checked}`
if (
settings.notifyVpsDownEnabled &&
settings.telegramBotToken?.trim() &&
settings.telegramChatId?.trim()
) {
await sendTelegramMessage(
settings.telegramBotToken,
settings.telegramChatId,
`🔴 <b>${msg}</b>`,
settings.telegramMessageThreadId,
)
}
await notifyWebhook(settings, 'vps_down', msg, { checked, down })
}
} catch (err) {
console.warn('Uptime check error:', err instanceof Error ? err.message : err)
}
}
export function startScheduler(): void {
if (syncIntervalId) clearInterval(syncIntervalId)
syncIntervalId = null
if (syncTariffsIntervalId) clearInterval(syncTariffsIntervalId)
syncTariffsIntervalId = null
if (uptimeIntervalId) clearInterval(uptimeIntervalId)
uptimeIntervalId = null
try {
const settings = settingsRepository.getRow(SETTINGS_ID)
@@ -262,8 +299,10 @@ export function startScheduler(): void {
const tariffsInterval = Math.max(60, Number(settings.syncTariffsIntervalMinutes) || 1440)
syncIntervalId = setInterval(() => void runScheduledSync(), interval * 60 * 1000)
syncTariffsIntervalId = setInterval(() => void runScheduledSyncTariffs(), tariffsInterval * 60 * 1000)
uptimeIntervalId = setInterval(() => void runScheduledUptimeChecks(), 5 * 60 * 1000)
void runScheduledUptimeChecks()
console.log(
`Scheduled sync enabled: VPS/payments every ${interval} min, tariffs every ${tariffsInterval} min`,
`Scheduled sync enabled: VPS/payments every ${interval} min, tariffs every ${tariffsInterval} min, uptime every 5 min`,
)
} catch {
// ignore
@@ -275,6 +314,8 @@ export function stopScheduler(): void {
syncIntervalId = null
if (syncTariffsIntervalId) clearInterval(syncTariffsIntervalId)
syncTariffsIntervalId = null
if (uptimeIntervalId) clearInterval(uptimeIntervalId)
uptimeIntervalId = null
}
export function restartScheduler(): void {
+75
View File
@@ -0,0 +1,75 @@
import { randomUUID } from 'node:crypto'
import { createConnection } from 'node:net'
import { desc, eq } from 'drizzle-orm'
import { getDb, schema } from '@cfdm/db'
const CHECK_TIMEOUT_MS = 5000
function tcpCheck(host: string, port: number): Promise<{ ok: boolean; latencyMs: number; error?: string }> {
const started = Date.now()
return new Promise((resolve) => {
const socket = createConnection({ host, port, timeout: CHECK_TIMEOUT_MS })
const done = (ok: boolean, error?: string) => {
socket.destroy()
resolve({ ok, latencyMs: Date.now() - started, error })
}
socket.on('connect', () => done(true))
socket.on('timeout', () => done(false, 'timeout'))
socket.on('error', (err) => done(false, err.message))
})
}
export async function runVpsUptimeChecks(): Promise<{ checked: number; down: number }> {
const db = getDb()
const rows = db
.select()
.from(schema.vps)
.where(eq(schema.vps.monitoringEnabled, 1))
.all()
let checked = 0
let down = 0
const now = new Date().toISOString()
for (const row of rows) {
if (row.status !== 'active') continue
const host = (row.ip || '').trim()
if (!host) continue
const port = Number(row.sshPort) || 22
const result = await tcpCheck(host, port)
checked++
const status = result.ok ? 'up' : 'down'
if (!result.ok) down++
db.insert(schema.vpsHealthChecks)
.values({
id: `hc-${randomUUID()}`,
vpsId: row.id,
checkedAt: now,
status,
latencyMs: result.latencyMs,
error: result.error ?? null,
})
.run()
db.update(schema.vps)
.set({
lastHealthStatus: status,
lastHealthCheckedAt: now,
})
.where(eq(schema.vps.id, row.id))
.run()
}
return { checked, down }
}
export function listRecentHealthChecks(vpsId: string, limit = 20) {
return getDb()
.select()
.from(schema.vpsHealthChecks)
.where(eq(schema.vpsHealthChecks.vpsId, vpsId))
.orderBy(desc(schema.vpsHealthChecks.checkedAt))
.limit(limit)
.all()
}
+40
View File
@@ -0,0 +1,40 @@
export interface WebhookPayload {
event: string
message: string
data?: Record<string, unknown>
timestamp: string
}
export async function sendWebhook(url: string, payload: WebhookPayload): Promise<void> {
const target = url?.trim()
if (!target) return
try {
const res = await fetch(target, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
if (!res.ok) {
console.warn(`Webhook failed (${res.status}): ${target}`)
}
} catch (err) {
console.warn('Webhook error:', err instanceof Error ? err.message : err)
}
}
export async function notifyWebhook(
settings: { webhookUrl?: string | null; webhookEnabled?: number | boolean | null },
event: string,
message: string,
data?: Record<string, unknown>,
): Promise<void> {
if (!settings.webhookEnabled) return
const url = settings.webhookUrl?.trim()
if (!url) return
await sendWebhook(url, {
event,
message,
data,
timestamp: new Date().toISOString(),
})
}