Добавлены bulk-операции VPS, карточка /vps/:id, CRUD проектов, Command Palette, календарь продлений, webhooks, uptime-проверки, audit log, кастомные поля и адаптеры провайдеров. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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)) {
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user