From 11b67a1cef99c64f58910568cc00a7f231d7e81b Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sun, 28 Jun 2026 15:28:35 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20=D1=80=D0=B0=D1=81=D1=88=D0=B8=D1=80?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D0=B8=D0=BD=D0=B2=D0=B5=D0=BD=D1=82=D0=B0?= =?UTF-8?q?=D1=80=D1=8C,=20ops=20=D0=B8=20=D0=B8=D0=BD=D1=82=D0=B5=D0=B3?= =?UTF-8?q?=D1=80=D0=B0=D1=86=D0=B8=D0=B8=20=D0=BF=D0=BE=20roadmap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Добавлены bulk-операции VPS, карточка /vps/:id, CRUD проектов, Command Palette, календарь продлений, webhooks, uptime-проверки, audit log, кастомные поля и адаптеры провайдеров. Co-authored-by: Cursor --- apps/api/src/index.ts | 2 + apps/api/src/routes/audit.ts | 14 + apps/api/src/routes/projects.ts | 25 ++ apps/api/src/routes/vps.ts | 9 +- apps/api/src/services/audit.ts | 13 + apps/api/src/services/dashboard-stats.ts | 43 ++- apps/api/src/services/providers/index.ts | 53 ++++ apps/api/src/services/providers/types.ts | 24 ++ apps/api/src/services/scheduler.ts | 47 +++- apps/api/src/services/uptime-check.ts | 75 ++++++ apps/api/src/services/webhook.ts | 40 +++ apps/web/src/components/data-grid-card.tsx | 52 +++- .../components/domain/account-edit-sheet.tsx | 57 +++- .../components/domain/payment-edit-sheet.tsx | 31 ++- .../components/domain/project-edit-sheet.tsx | 35 ++- .../components/domain/vps-bulk-toolbar.tsx | 80 ++++++ .../src/components/domain/vps-edit-sheet.tsx | 78 ++++++ apps/web/src/components/global-search.tsx | 130 +++++++++ .../web/src/components/health-mode-banner.tsx | 33 +++ apps/web/src/components/layout/app-shell.tsx | 11 +- apps/web/src/lib/api-client.ts | 19 ++ apps/web/src/lib/custom-fields.ts | 34 +++ apps/web/src/lib/schemas.ts | 10 + apps/web/src/lib/tariff-diff.ts | 59 +++++ apps/web/src/lib/vps-sync-fields.ts | 29 ++ apps/web/src/routeTree.gen.ts | 82 +++++- apps/web/src/routes/_auth/accounts.tsx | 49 +++- apps/web/src/routes/_auth/audit.tsx | 109 ++++++++ apps/web/src/routes/_auth/payments.tsx | 15 +- apps/web/src/routes/_auth/projects.tsx | 102 +++++-- apps/web/src/routes/_auth/renewals.tsx | 153 +++++++++++ apps/web/src/routes/_auth/settings.tsx | 94 ++++++- apps/web/src/routes/_auth/tariffs.tsx | 36 ++- apps/web/src/routes/_auth/vps.$vpsId.tsx | 249 ++++++++++++++++++ apps/web/src/routes/_auth/vps.tsx | 107 +++++++- packages/db/src/index.ts | 2 + packages/db/src/repositories/audit-log.ts | 56 ++++ packages/db/src/repositories/settings.ts | 20 +- packages/db/src/repositories/vps.ts | 11 + packages/db/src/runtime-migrate.ts | 46 ++++ packages/db/src/schema/index.ts | 26 ++ packages/shared/src/contracts/settings.ts | 3 + packages/shared/src/contracts/vps.ts | 1 + 43 files changed, 2085 insertions(+), 79 deletions(-) create mode 100644 apps/api/src/routes/audit.ts create mode 100644 apps/api/src/services/audit.ts create mode 100644 apps/api/src/services/providers/index.ts create mode 100644 apps/api/src/services/providers/types.ts create mode 100644 apps/api/src/services/uptime-check.ts create mode 100644 apps/api/src/services/webhook.ts create mode 100644 apps/web/src/components/domain/vps-bulk-toolbar.tsx create mode 100644 apps/web/src/components/global-search.tsx create mode 100644 apps/web/src/components/health-mode-banner.tsx create mode 100644 apps/web/src/lib/custom-fields.ts create mode 100644 apps/web/src/lib/tariff-diff.ts create mode 100644 apps/web/src/lib/vps-sync-fields.ts create mode 100644 apps/web/src/routes/_auth/audit.tsx create mode 100644 apps/web/src/routes/_auth/renewals.tsx create mode 100644 apps/web/src/routes/_auth/vps.$vpsId.tsx create mode 100644 packages/db/src/repositories/audit-log.ts create mode 100644 packages/db/src/runtime-migrate.ts diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 4543f99..8a79fa5 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -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)) { diff --git a/apps/api/src/routes/audit.ts b/apps/api/src/routes/audit.ts new file mode 100644 index 0000000..3a0e73c --- /dev/null +++ b/apps/api/src/routes/audit.ts @@ -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), + ) +} diff --git a/apps/api/src/routes/projects.ts b/apps/api/src/routes/projects.ts index 5f5aae2..ac1a448 100644 --- a/apps/api/src/routes/projects.ts +++ b/apps/api/src/routes/projects.ts @@ -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() + }) } diff --git a/apps/api/src/routes/vps.ts b/apps/api/src/routes/vps.ts index 3c456fe..24052d3 100644 --- a/apps/api/src/routes/vps.ts +++ b/apps/api/src/routes/vps.ts @@ -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) + 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) 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() }) diff --git a/apps/api/src/services/audit.ts b/apps/api/src/services/audit.ts new file mode 100644 index 0000000..d233435 --- /dev/null +++ b/apps/api/src/services/audit.ts @@ -0,0 +1,13 @@ +import { auditLogRepository } from '@cfdm/db/repositories/audit-log' + +export function auditCreate(entity: string, entityId: string, data?: Record): void { + auditLogRepository.append({ entity, entityId, action: 'create', diff: data }) +} + +export function auditUpdate(entity: string, entityId: string, patch: Record): void { + auditLogRepository.append({ entity, entityId, action: 'update', diff: patch }) +} + +export function auditDelete(entity: string, entityId: string): void { + auditLogRepository.append({ entity, entityId, action: 'delete' }) +} diff --git a/apps/api/src/services/dashboard-stats.ts b/apps/api/src/services/dashboard-stats.ts index 3ad4a68..502f23c 100644 --- a/apps/api/src/services/dashboard-stats.ts +++ b/apps/api/src/services/dashboard-stats.ts @@ -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) { diff --git a/apps/api/src/services/providers/index.ts b/apps/api/src/services/providers/index.ts new file mode 100644 index 0000000..e4e2083 --- /dev/null +++ b/apps/api/src/services/providers/index.ts @@ -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 { + 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 = { + billmanager: billmanagerAdapter, + manual: manualAdapter, + none: manualAdapter, +} + +export function getProviderAdapter(apiType: string | null | undefined): ProviderAdapter { + const key = (apiType || 'none').toLowerCase().trim() + return adapters[key] ?? manualAdapter +} diff --git a/apps/api/src/services/providers/types.ts b/apps/api/src/services/providers/types.ts new file mode 100644 index 0000000..cc74fa6 --- /dev/null +++ b/apps/api/src/services/providers/types.ts @@ -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 +} diff --git a/apps/api/src/services/scheduler.ts b/apps/api/src/services/scheduler.ts index 2e8f33c..9517fb4 100644 --- a/apps/api/src/services/scheduler.ts +++ b/apps/api/src/services/scheduler.ts @@ -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 | null = null let syncTariffsIntervalId: ReturnType | null = null +let uptimeIntervalId: ReturnType | null = null const UPCOMING_DAYS = 7 const SETTINGS_ID = 'settings-main' @@ -152,6 +155,9 @@ async function sendPaymentExpiryNotifications(): Promise { text, settings.telegramMessageThreadId, ) + await notifyWebhook(settings, 'payment_expiry', text.replace(/<[^>]+>/g, ''), { + count: upcoming.length, + }) } export async function runScheduledSync(): Promise { @@ -197,10 +203,14 @@ export async function runScheduledSync(): Promise { } if (canTg && settings.notifySyncDigestEnabled && digestLines.length > 0) { - await sendTelegramMessage(token!, chatId!, `📋 Синхронизация VPS\n\n${digestLines.join('\n')}`, settings.telegramMessageThreadId) + const msg = `📋 Синхронизация VPS\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!, `💰 Низкий баланс\n\n${lowBalanceLines.join('\n')}`, settings.telegramMessageThreadId) + const msg = `💰 Низкий баланс\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 { } } +export async function runScheduledUptimeChecks(): Promise { + 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, + `🔴 ${msg}`, + 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 { diff --git a/apps/api/src/services/uptime-check.ts b/apps/api/src/services/uptime-check.ts new file mode 100644 index 0000000..3154922 --- /dev/null +++ b/apps/api/src/services/uptime-check.ts @@ -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() +} diff --git a/apps/api/src/services/webhook.ts b/apps/api/src/services/webhook.ts new file mode 100644 index 0000000..4171dcc --- /dev/null +++ b/apps/api/src/services/webhook.ts @@ -0,0 +1,40 @@ +export interface WebhookPayload { + event: string + message: string + data?: Record + timestamp: string +} + +export async function sendWebhook(url: string, payload: WebhookPayload): Promise { + 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, +): Promise { + if (!settings.webhookEnabled) return + const url = settings.webhookUrl?.trim() + if (!url) return + await sendWebhook(url, { + event, + message, + data, + timestamp: new Date().toISOString(), + }) +} diff --git a/apps/web/src/components/data-grid-card.tsx b/apps/web/src/components/data-grid-card.tsx index 3d699b6..7623c60 100644 --- a/apps/web/src/components/data-grid-card.tsx +++ b/apps/web/src/components/data-grid-card.tsx @@ -7,9 +7,11 @@ import { flexRender, type ColumnDef, type SortingState, + type RowSelectionState, } from '@tanstack/react-table' import { Card, CardContent, CardHeader, CardTitle } from '@cfdm/ui/components/card' +import { Checkbox } from '@cfdm/ui/components/checkbox' import { cn } from '@cfdm/ui/lib/utils' import { DataGrid, @@ -64,6 +66,10 @@ export interface DataGridCardProps { virtualization?: boolean /** Высота viewport для виртуализации (px). По умолчанию 480. */ height?: number + /** Включить выбор строк (чекбоксы). */ + enableRowSelection?: boolean + /** Callback при изменении выбора. */ + onRowSelectionChange?: (selectedIds: string[]) => void className?: string } @@ -155,19 +161,58 @@ export function DataGridCard({ initialSorting, virtualization = false, height = 480, + enableRowSelection = false, + onRowSelectionChange, className, }: DataGridCardProps) { const [sorting, setSorting] = useState(initialSorting ?? []) + const [rowSelection, setRowSelection] = useState({}) - const lastColId = pinLastColumn ? columns[columns.length - 1]?.id ?? '' : '' + const selectColumn: ColumnDef = { + id: 'select', + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!value)} + aria-label="Выбрать все" + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!value)} + aria-label="Выбрать строку" + onClick={(e) => e.stopPropagation()} + /> + ), + enableSorting: false, + meta: { cellClassName: 'w-10' }, + } + + const tableColumns = enableRowSelection ? [selectColumn, ...columns] : columns + + const lastColId = pinLastColumn ? tableColumns[tableColumns.length - 1]?.id ?? '' : '' const showPagination = pagination ?? true const table = useReactTable({ data, - columns, - state: { sorting }, + columns: tableColumns, + state: { sorting, ...(enableRowSelection ? { rowSelection } : {}) }, onSortingChange: setSorting, + onRowSelectionChange: enableRowSelection + ? (updater) => { + setRowSelection((prev) => { + const next = typeof updater === 'function' ? updater(prev) : updater + if (onRowSelectionChange && rowId) { + const ids = Object.keys(next).filter((k) => next[k]) + onRowSelectionChange(ids) + } + return next + }) + } + : undefined, getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), getPaginationRowModel: showPagination ? getPaginationRowModel() : undefined, @@ -179,6 +224,7 @@ export function DataGridCard({ ? (row, index) => rowId(row, index) : undefined, enableColumnPinning: pinLastColumn, + enableRowSelection, }) const hasHeader = Boolean(title || description || actions) diff --git a/apps/web/src/components/domain/account-edit-sheet.tsx b/apps/web/src/components/domain/account-edit-sheet.tsx index dfcbae0..26cfd54 100644 --- a/apps/web/src/components/domain/account-edit-sheet.tsx +++ b/apps/web/src/components/domain/account-edit-sheet.tsx @@ -1,12 +1,18 @@ +import { useMutation } from '@tanstack/react-query' +import { PlugIcon, RefreshCwIcon } from 'lucide-react' +import { toast } from 'sonner' + import { FormSheetRhf } from '@/components/form-sheet-rhf' import { FormField } from '@/components/form-field' import { Input } from '@cfdm/ui/components/input' import { Textarea } from '@cfdm/ui/components/textarea' import { SelectField } from '@/components/select-field' +import { LoadingButton } from '@/components/loading-button' import type { ZodType } from 'zod' import { providerAccountSchema, type ProviderAccountFormValues } from '@/lib/schemas' import type { BillingMode, Provider } from '@/types/entities' import { billingModeLabel } from '@/lib/format' +import { api, ApiError } from '@/lib/api-client' const EMPTY: ProviderAccountFormValues = { providerId: '', @@ -25,6 +31,7 @@ interface ProviderAccountEditSheetProps { providers: Provider[] onSubmit: (values: ProviderAccountFormValues) => void submitting?: boolean + onBalanceRefreshed?: () => void } export function providerAccountFormDefaults( @@ -48,9 +55,26 @@ export function ProviderAccountEditSheet({ providers, onSubmit, submitting, + onBalanceRefreshed, }: ProviderAccountEditSheetProps) { const isEdit = Boolean(defaultValues.id) + const testMut = useMutation({ + mutationFn: async (values: { apiBaseUrl: string; apiCredentials: string }) => + api.testConnection(values.apiBaseUrl, values.apiCredentials), + onSuccess: () => toast.success('Подключение успешно'), + onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка подключения'), + }) + + const balanceMut = useMutation({ + mutationFn: (accountId: string) => api.fetchAccountBalance(accountId), + onSuccess: (data) => { + toast.success(`Баланс: ${data.balance} ${data.currency}`) + onBalanceRefreshed?.() + }, + onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка обновления баланса'), + }) + return ( {(form) => { const { register, formState: { errors }, watch, setValue } = form + const providerId = watch('providerId') + const provider = providers.find((p) => p.id === providerId) + const apiBaseUrl = (provider?.apiBaseUrl ?? '').trim() + const creds = watch('apiCredentials')?.trim() ?? '' + const canTest = Boolean(apiBaseUrl && creds) + return ( <> setValue('providerId', v ?? '', { shouldValidate: true })} options={providers.map((p) => ({ value: p.id, label: p.name }))} /> @@ -88,6 +118,31 @@ export function ProviderAccountEditSheet({ > +
+ testMut.mutate({ apiBaseUrl, apiCredentials: creds })} + > + + Проверить подключение + + {isEdit && defaultValues.id ? ( + balanceMut.mutate(defaultValues.id!)} + > + + Обновить баланс + + ) : null} +
void submitting?: boolean } @@ -37,7 +39,7 @@ export function paymentFormDefaults( if (!edit) { return { ...EMPTY, providerAccountId: fallbackAccountId } } - return { ...EMPTY, ...edit } + return { ...EMPTY, ...edit, vpsId: edit.vpsId ?? '' } } export function PaymentEditSheet({ @@ -46,6 +48,7 @@ export function PaymentEditSheet({ defaultValues, providerAccounts, providers, + vpsRows, onSubmit, submitting, }: PaymentEditSheetProps) { @@ -63,6 +66,14 @@ export function PaymentEditSheet({ > {(form) => { const { register, formState: { errors }, watch, setValue } = form + const accountId = watch('providerAccountId') + const vpsOptions = vpsRows + .filter((v) => !accountId || v.providerAccountId === accountId) + .map((v) => ({ + value: v.id, + label: [v.ip, v.dns, v.project].filter(Boolean).join(' · ') || v.id, + })) + return ( <> @@ -82,14 +93,26 @@ export function PaymentEditSheet({ setValue('providerAccountId', v ?? '', { shouldValidate: true })} + value={accountId} + onValueChange={(v) => { + setValue('providerAccountId', v ?? '', { shouldValidate: true }) + setValue('vpsId', '') + }} options={providerAccounts.map((a) => ({ value: a.id, label: accountSelectLabel(a, providerById), }))} /> + + setValue('vpsId', v ?? '')} + options={[{ value: '', label: '—' }, ...vpsOptions]} + /> +
diff --git a/apps/web/src/components/domain/project-edit-sheet.tsx b/apps/web/src/components/domain/project-edit-sheet.tsx index 7782b98..5fcb2ea 100644 --- a/apps/web/src/components/domain/project-edit-sheet.tsx +++ b/apps/web/src/components/domain/project-edit-sheet.tsx @@ -3,33 +3,52 @@ import { FormField } from '@/components/form-field' import { Input } from '@cfdm/ui/components/input' import { projectSchema, type ProjectFormValues } from '@/lib/schemas' -const EMPTY: ProjectFormValues = { name: '' } +const EMPTY: ProjectFormValues = { name: '', color: '' } interface ProjectEditSheetProps { open: boolean onOpenChange: (open: boolean) => void + defaultValues?: ProjectFormValues onSubmit: (values: ProjectFormValues) => void submitting?: boolean } -export function ProjectEditSheet({ open, onOpenChange, onSubmit, submitting }: ProjectEditSheetProps) { +export function projectFormDefaults(edit?: Partial | null): ProjectFormValues { + if (!edit) return { ...EMPTY } + return { ...EMPTY, ...edit, color: edit.color ?? '' } +} + +export function ProjectEditSheet({ + open, + onOpenChange, + defaultValues = EMPTY, + onSubmit, + submitting, +}: ProjectEditSheetProps) { + const isEdit = Boolean(defaultValues.id) + return ( } + defaultValues={defaultValues} onSubmit={onSubmit} submitting={submitting} > {(form) => { const { register, formState: { errors } } = form return ( - - - + <> + + + + + + + ) }} diff --git a/apps/web/src/components/domain/vps-bulk-toolbar.tsx b/apps/web/src/components/domain/vps-bulk-toolbar.tsx new file mode 100644 index 0000000..79f4f86 --- /dev/null +++ b/apps/web/src/components/domain/vps-bulk-toolbar.tsx @@ -0,0 +1,80 @@ +import { ArchiveIcon, FolderKanbanIcon, PauseIcon, PlayIcon, Trash2Icon } from 'lucide-react' +import { Button } from '@cfdm/ui/components/button' +import { SelectField } from '@/components/select-field' +import { ConfirmDialog } from '@/components/confirm-dialog' +import { useState } from 'react' +import { vpsStatusLabel } from '@/lib/format' + +interface VpsBulkToolbarProps { + selectedCount: number + projectOptions: string[] + onSetStatus: (status: 'active' | 'paused' | 'archived') => void + onSetProject: (project: string) => void + onDelete: () => void + busy?: boolean +} + +export function VpsBulkToolbar({ + selectedCount, + projectOptions, + onSetStatus, + onSetProject, + onDelete, + busy, +}: VpsBulkToolbarProps) { + const [projectValue, setProjectValue] = useState('') + + if (selectedCount === 0) return null + + return ( +
+ Выбрано: {selectedCount} + + + +
+ setProjectValue(v ?? '')} + options={projectOptions.map((p) => ({ value: p, label: p }))} + triggerClassName="w-40" + /> + +
+ + + Удалить + + } + title={`Удалить ${selectedCount} VPS?`} + description="Записи будут удалены безвозвратно." + confirmLabel="Удалить" + destructive + onConfirm={onDelete} + /> +
+ ) +} diff --git a/apps/web/src/components/domain/vps-edit-sheet.tsx b/apps/web/src/components/domain/vps-edit-sheet.tsx index 38759f9..ee1cf49 100644 --- a/apps/web/src/components/domain/vps-edit-sheet.tsx +++ b/apps/web/src/components/domain/vps-edit-sheet.tsx @@ -5,6 +5,8 @@ import { Input } from '@cfdm/ui/components/input' import { SelectField } from '@/components/select-field' import { AutoCompleteInput } from '@/components/auto-complete-input' import { Textarea } from '@cfdm/ui/components/textarea' +import { Checkbox } from '@cfdm/ui/components/checkbox' +import { Label } from '@cfdm/ui/components/label' import { NumberField, NumberFieldGroup, @@ -16,6 +18,8 @@ import { FormDatePicker } from '@/components/form-date-picker' import { vpsSchema, type VpsFormValues } from '@/lib/schemas' import { vpsStatusLabel, tariffTypeLabel } from '@/lib/format' import { buildCityOptions, cityMatchesCountry, resolveCountryForCityFromRows } from '@cfdm/shared/geo' +import { VPS_SYNC_OVERRIDE_FIELDS, parseUserOverrides } from '@/lib/vps-sync-fields' +import { parseCustomData, type CustomFieldDef } from '@/lib/custom-fields' import type { Provider, ProviderAccount, Vps } from '@/types/entities' import type { ZodType } from 'zod' @@ -38,6 +42,8 @@ export const VPS_FORM_EMPTY: VpsFormValues = { paidUntil: '', project: '', notes: '', + userOverrides: [] as string[], + customData: {} as Record, } export function vpsFormFromRow(v: Vps): VpsFormValues { @@ -61,6 +67,8 @@ export function vpsFormFromRow(v: Vps): VpsFormValues { paidUntil: v.paidUntil ?? '', project: v.project ?? '', notes: v.notes ?? '', + userOverrides: parseUserOverrides((v as Vps & { userOverrides?: unknown }).userOverrides), + customData: parseCustomData((v as Vps & { customData?: unknown }).customData), } } @@ -73,6 +81,7 @@ interface VpsEditSheetProps { providerAccounts: ProviderAccount[] vpsRows: Vps[] formCountryOptions: Array<{ value: string; label: string }> + customFieldDefs?: CustomFieldDef[] onSubmit: (values: VpsFormValues) => void submitting?: boolean } @@ -86,6 +95,7 @@ export function VpsEditSheet({ providerAccounts, vpsRows, formCountryOptions, + customFieldDefs = [], onSubmit, submitting, }: VpsEditSheetProps) { @@ -326,6 +336,74 @@ export function VpsEditSheet({