Добавлены bulk-операции VPS, карточка /vps/:id, CRUD проектов, Command Palette, календарь продлений, webhooks, uptime-проверки, audit log, кастомные поля и адаптеры провайдеров. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -21,6 +21,7 @@ import { backupRoutes } from './routes/backup.js'
|
|||||||
import { ratesProxyRoutes } from './routes/rates-proxy.js'
|
import { ratesProxyRoutes } from './routes/rates-proxy.js'
|
||||||
import { migrateRoutes } from './routes/migrate.js'
|
import { migrateRoutes } from './routes/migrate.js'
|
||||||
import { dashboardRoutes } from './routes/dashboard.js'
|
import { dashboardRoutes } from './routes/dashboard.js'
|
||||||
|
import { auditRoutes } from './routes/audit.js'
|
||||||
import { startScheduler } from './services/scheduler.js'
|
import { startScheduler } from './services/scheduler.js'
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
@@ -54,6 +55,7 @@ export async function buildApp(opts: BuildAppOptions = {}) {
|
|||||||
await app.register(ratesProxyRoutes)
|
await app.register(ratesProxyRoutes)
|
||||||
await app.register(migrateRoutes)
|
await app.register(migrateRoutes)
|
||||||
await app.register(dashboardRoutes)
|
await app.register(dashboardRoutes)
|
||||||
|
await app.register(auditRoutes)
|
||||||
|
|
||||||
const staticDir = opts.staticDir ?? join(__dirname, '..', '..', 'web', 'dist')
|
const staticDir = opts.staticDir ?? join(__dirname, '..', '..', 'web', 'dist')
|
||||||
if (existsSync(staticDir)) {
|
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))
|
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 type { FastifyPluginAsync } from 'fastify'
|
||||||
import { vpsRepository } from '@cfdm/db/repositories/vps'
|
import { vpsRepository } from '@cfdm/db/repositories/vps'
|
||||||
import { vpsSchema } from '@cfdm/shared/contracts/vps'
|
import { vpsSchema } from '@cfdm/shared/contracts/vps'
|
||||||
|
import { auditCreate, auditDelete, auditUpdate } from '../services/audit.js'
|
||||||
|
|
||||||
export const vpsRoutes: FastifyPluginAsync = async (app) => {
|
export const vpsRoutes: FastifyPluginAsync = async (app) => {
|
||||||
app.get('/api/vps', async () => vpsRepository.list())
|
app.get('/api/vps', async () => vpsRepository.list())
|
||||||
@@ -10,7 +11,11 @@ export const vpsRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return reply.code(400).send({ error: { code: 'VALIDATION', message: parsed.error.message } })
|
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) => {
|
app.put<{ Params: { id: string } }>('/api/vps/:id', async (req, reply) => {
|
||||||
@@ -22,6 +27,7 @@ export const vpsRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
if (!updated) {
|
if (!updated) {
|
||||||
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
|
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
|
return updated
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -30,6 +36,7 @@ export const vpsRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
if (!ok) {
|
if (!ok) {
|
||||||
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
|
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
|
||||||
}
|
}
|
||||||
|
auditDelete('vps', req.params.id)
|
||||||
return reply.code(204).send()
|
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
|
return balance < threshold
|
||||||
}).length
|
}).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
|
let issuesCount = 0
|
||||||
if (
|
if (noRateCount > 0) issuesCount++
|
||||||
activeVps.some((v) => {
|
if (paidOverdueCount > 0) issuesCount++
|
||||||
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 (expiringWithin7Days > 0) issuesCount++
|
if (expiringWithin7Days > 0) issuesCount++
|
||||||
if (staleSyncAccountCount > 0) issuesCount++
|
if (staleSyncAccountCount > 0) issuesCount++
|
||||||
if (lowBalanceAccountCount > 0) issuesCount++
|
if (lowBalanceAccountCount > 0) issuesCount++
|
||||||
|
if (balanceMismatchCount > 0) issuesCount++
|
||||||
|
|
||||||
let lastGlobalSyncAt: string | null = null
|
let lastGlobalSyncAt: string | null = null
|
||||||
for (const row of snap.syncLog) {
|
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 { billmanagerAccountRowForSync } from './billmanager/context.js'
|
||||||
import { runBillmanagerAccountSync } from './billmanager/sync-job.js'
|
import { runBillmanagerAccountSync } from './billmanager/sync-job.js'
|
||||||
import { sendTelegramMessage } from './telegram.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 syncIntervalId: ReturnType<typeof setInterval> | null = null
|
||||||
let syncTariffsIntervalId: ReturnType<typeof setInterval> | null = null
|
let syncTariffsIntervalId: ReturnType<typeof setInterval> | null = null
|
||||||
|
let uptimeIntervalId: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
const UPCOMING_DAYS = 7
|
const UPCOMING_DAYS = 7
|
||||||
const SETTINGS_ID = 'settings-main'
|
const SETTINGS_ID = 'settings-main'
|
||||||
@@ -152,6 +155,9 @@ async function sendPaymentExpiryNotifications(): Promise<void> {
|
|||||||
text,
|
text,
|
||||||
settings.telegramMessageThreadId,
|
settings.telegramMessageThreadId,
|
||||||
)
|
)
|
||||||
|
await notifyWebhook(settings, 'payment_expiry', text.replace(/<[^>]+>/g, ''), {
|
||||||
|
count: upcoming.length,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runScheduledSync(): Promise<void> {
|
export async function runScheduledSync(): Promise<void> {
|
||||||
@@ -197,10 +203,14 @@ export async function runScheduledSync(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (canTg && settings.notifySyncDigestEnabled && digestLines.length > 0) {
|
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) {
|
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) {
|
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 {
|
export function startScheduler(): void {
|
||||||
if (syncIntervalId) clearInterval(syncIntervalId)
|
if (syncIntervalId) clearInterval(syncIntervalId)
|
||||||
syncIntervalId = null
|
syncIntervalId = null
|
||||||
if (syncTariffsIntervalId) clearInterval(syncTariffsIntervalId)
|
if (syncTariffsIntervalId) clearInterval(syncTariffsIntervalId)
|
||||||
syncTariffsIntervalId = null
|
syncTariffsIntervalId = null
|
||||||
|
if (uptimeIntervalId) clearInterval(uptimeIntervalId)
|
||||||
|
uptimeIntervalId = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const settings = settingsRepository.getRow(SETTINGS_ID)
|
const settings = settingsRepository.getRow(SETTINGS_ID)
|
||||||
@@ -262,8 +299,10 @@ export function startScheduler(): void {
|
|||||||
const tariffsInterval = Math.max(60, Number(settings.syncTariffsIntervalMinutes) || 1440)
|
const tariffsInterval = Math.max(60, Number(settings.syncTariffsIntervalMinutes) || 1440)
|
||||||
syncIntervalId = setInterval(() => void runScheduledSync(), interval * 60 * 1000)
|
syncIntervalId = setInterval(() => void runScheduledSync(), interval * 60 * 1000)
|
||||||
syncTariffsIntervalId = setInterval(() => void runScheduledSyncTariffs(), tariffsInterval * 60 * 1000)
|
syncTariffsIntervalId = setInterval(() => void runScheduledSyncTariffs(), tariffsInterval * 60 * 1000)
|
||||||
|
uptimeIntervalId = setInterval(() => void runScheduledUptimeChecks(), 5 * 60 * 1000)
|
||||||
|
void runScheduledUptimeChecks()
|
||||||
console.log(
|
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 {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
@@ -275,6 +314,8 @@ export function stopScheduler(): void {
|
|||||||
syncIntervalId = null
|
syncIntervalId = null
|
||||||
if (syncTariffsIntervalId) clearInterval(syncTariffsIntervalId)
|
if (syncTariffsIntervalId) clearInterval(syncTariffsIntervalId)
|
||||||
syncTariffsIntervalId = null
|
syncTariffsIntervalId = null
|
||||||
|
if (uptimeIntervalId) clearInterval(uptimeIntervalId)
|
||||||
|
uptimeIntervalId = null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function restartScheduler(): void {
|
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(),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -7,9 +7,11 @@ import {
|
|||||||
flexRender,
|
flexRender,
|
||||||
type ColumnDef,
|
type ColumnDef,
|
||||||
type SortingState,
|
type SortingState,
|
||||||
|
type RowSelectionState,
|
||||||
} from '@tanstack/react-table'
|
} from '@tanstack/react-table'
|
||||||
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@cfdm/ui/components/card'
|
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 { cn } from '@cfdm/ui/lib/utils'
|
||||||
import {
|
import {
|
||||||
DataGrid,
|
DataGrid,
|
||||||
@@ -64,6 +66,10 @@ export interface DataGridCardProps<TData extends object> {
|
|||||||
virtualization?: boolean
|
virtualization?: boolean
|
||||||
/** Высота viewport для виртуализации (px). По умолчанию 480. */
|
/** Высота viewport для виртуализации (px). По умолчанию 480. */
|
||||||
height?: number
|
height?: number
|
||||||
|
/** Включить выбор строк (чекбоксы). */
|
||||||
|
enableRowSelection?: boolean
|
||||||
|
/** Callback при изменении выбора. */
|
||||||
|
onRowSelectionChange?: (selectedIds: string[]) => void
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,19 +161,58 @@ export function DataGridCard<TData extends object>({
|
|||||||
initialSorting,
|
initialSorting,
|
||||||
virtualization = false,
|
virtualization = false,
|
||||||
height = 480,
|
height = 480,
|
||||||
|
enableRowSelection = false,
|
||||||
|
onRowSelectionChange,
|
||||||
className,
|
className,
|
||||||
}: DataGridCardProps<TData>) {
|
}: DataGridCardProps<TData>) {
|
||||||
const [sorting, setSorting] = useState<SortingState>(initialSorting ?? [])
|
const [sorting, setSorting] = useState<SortingState>(initialSorting ?? [])
|
||||||
|
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||||
|
|
||||||
const lastColId = pinLastColumn ? columns[columns.length - 1]?.id ?? '' : ''
|
const selectColumn: ColumnDef<TData, unknown> = {
|
||||||
|
id: 'select',
|
||||||
|
header: ({ table }) => (
|
||||||
|
<Checkbox
|
||||||
|
checked={table.getIsAllPageRowsSelected()}
|
||||||
|
indeterminate={table.getIsSomePageRowsSelected() && !table.getIsAllPageRowsSelected()}
|
||||||
|
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||||
|
aria-label="Выбрать все"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Checkbox
|
||||||
|
checked={row.getIsSelected()}
|
||||||
|
onCheckedChange={(value) => 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 showPagination = pagination ?? true
|
||||||
|
|
||||||
const table = useReactTable<TData>({
|
const table = useReactTable<TData>({
|
||||||
data,
|
data,
|
||||||
columns,
|
columns: tableColumns,
|
||||||
state: { sorting },
|
state: { sorting, ...(enableRowSelection ? { rowSelection } : {}) },
|
||||||
onSortingChange: setSorting,
|
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(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
getSortedRowModel: getSortedRowModel(),
|
getSortedRowModel: getSortedRowModel(),
|
||||||
getPaginationRowModel: showPagination ? getPaginationRowModel() : undefined,
|
getPaginationRowModel: showPagination ? getPaginationRowModel() : undefined,
|
||||||
@@ -179,6 +224,7 @@ export function DataGridCard<TData extends object>({
|
|||||||
? (row, index) => rowId(row, index)
|
? (row, index) => rowId(row, index)
|
||||||
: undefined,
|
: undefined,
|
||||||
enableColumnPinning: pinLastColumn,
|
enableColumnPinning: pinLastColumn,
|
||||||
|
enableRowSelection,
|
||||||
})
|
})
|
||||||
|
|
||||||
const hasHeader = Boolean(title || description || actions)
|
const hasHeader = Boolean(title || description || actions)
|
||||||
|
|||||||
@@ -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 { FormSheetRhf } from '@/components/form-sheet-rhf'
|
||||||
import { FormField } from '@/components/form-field'
|
import { FormField } from '@/components/form-field'
|
||||||
import { Input } from '@cfdm/ui/components/input'
|
import { Input } from '@cfdm/ui/components/input'
|
||||||
import { Textarea } from '@cfdm/ui/components/textarea'
|
import { Textarea } from '@cfdm/ui/components/textarea'
|
||||||
import { SelectField } from '@/components/select-field'
|
import { SelectField } from '@/components/select-field'
|
||||||
|
import { LoadingButton } from '@/components/loading-button'
|
||||||
import type { ZodType } from 'zod'
|
import type { ZodType } from 'zod'
|
||||||
import { providerAccountSchema, type ProviderAccountFormValues } from '@/lib/schemas'
|
import { providerAccountSchema, type ProviderAccountFormValues } from '@/lib/schemas'
|
||||||
import type { BillingMode, Provider } from '@/types/entities'
|
import type { BillingMode, Provider } from '@/types/entities'
|
||||||
import { billingModeLabel } from '@/lib/format'
|
import { billingModeLabel } from '@/lib/format'
|
||||||
|
import { api, ApiError } from '@/lib/api-client'
|
||||||
|
|
||||||
const EMPTY: ProviderAccountFormValues = {
|
const EMPTY: ProviderAccountFormValues = {
|
||||||
providerId: '',
|
providerId: '',
|
||||||
@@ -25,6 +31,7 @@ interface ProviderAccountEditSheetProps {
|
|||||||
providers: Provider[]
|
providers: Provider[]
|
||||||
onSubmit: (values: ProviderAccountFormValues) => void
|
onSubmit: (values: ProviderAccountFormValues) => void
|
||||||
submitting?: boolean
|
submitting?: boolean
|
||||||
|
onBalanceRefreshed?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function providerAccountFormDefaults(
|
export function providerAccountFormDefaults(
|
||||||
@@ -48,9 +55,26 @@ export function ProviderAccountEditSheet({
|
|||||||
providers,
|
providers,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
submitting,
|
submitting,
|
||||||
|
onBalanceRefreshed,
|
||||||
}: ProviderAccountEditSheetProps) {
|
}: ProviderAccountEditSheetProps) {
|
||||||
const isEdit = Boolean(defaultValues.id)
|
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 (
|
return (
|
||||||
<FormSheetRhf
|
<FormSheetRhf
|
||||||
open={open}
|
open={open}
|
||||||
@@ -64,13 +88,19 @@ export function ProviderAccountEditSheet({
|
|||||||
>
|
>
|
||||||
{(form) => {
|
{(form) => {
|
||||||
const { register, formState: { errors }, watch, setValue } = 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<FormField label="Хостер" htmlFor="acc-provider" error={errors.providerId?.message}>
|
<FormField label="Хостер" htmlFor="acc-provider" error={errors.providerId?.message}>
|
||||||
<SelectField
|
<SelectField
|
||||||
triggerId="acc-provider"
|
triggerId="acc-provider"
|
||||||
placeholder="Выберите хостера"
|
placeholder="Выберите хостера"
|
||||||
value={watch('providerId')}
|
value={providerId}
|
||||||
onValueChange={(v) => setValue('providerId', v ?? '', { shouldValidate: true })}
|
onValueChange={(v) => setValue('providerId', v ?? '', { shouldValidate: true })}
|
||||||
options={providers.map((p) => ({ value: p.id, label: p.name }))}
|
options={providers.map((p) => ({ value: p.id, label: p.name }))}
|
||||||
/>
|
/>
|
||||||
@@ -88,6 +118,31 @@ export function ProviderAccountEditSheet({
|
|||||||
>
|
>
|
||||||
<Input id="acc-creds" type="password" {...register('apiCredentials')} />
|
<Input id="acc-creds" type="password" {...register('apiCredentials')} />
|
||||||
</FormField>
|
</FormField>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<LoadingButton
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={!canTest}
|
||||||
|
loading={testMut.isPending}
|
||||||
|
onClick={() => testMut.mutate({ apiBaseUrl, apiCredentials: creds })}
|
||||||
|
>
|
||||||
|
<PlugIcon data-icon="inline-start" />
|
||||||
|
Проверить подключение
|
||||||
|
</LoadingButton>
|
||||||
|
{isEdit && defaultValues.id ? (
|
||||||
|
<LoadingButton
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
loading={balanceMut.isPending}
|
||||||
|
onClick={() => balanceMut.mutate(defaultValues.id!)}
|
||||||
|
>
|
||||||
|
<RefreshCwIcon data-icon="inline-start" />
|
||||||
|
Обновить баланс
|
||||||
|
</LoadingButton>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
<FormField label="Режим биллинга" htmlFor="acc-mode">
|
<FormField label="Режим биллинга" htmlFor="acc-mode">
|
||||||
<SelectField
|
<SelectField
|
||||||
triggerId="acc-mode"
|
triggerId="acc-mode"
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Textarea } from '@cfdm/ui/components/textarea'
|
|||||||
import { SelectField } from '@/components/select-field'
|
import { SelectField } from '@/components/select-field'
|
||||||
import type { ZodType } from 'zod'
|
import type { ZodType } from 'zod'
|
||||||
import { paymentSchema, type PaymentFormValues } from '@/lib/schemas'
|
import { paymentSchema, type PaymentFormValues } from '@/lib/schemas'
|
||||||
import type { PaymentType, Provider, ProviderAccount } from '@/types/entities'
|
import type { PaymentType, Provider, ProviderAccount, Vps } from '@/types/entities'
|
||||||
import { paymentTypeLabel } from '@/lib/format'
|
import { paymentTypeLabel } from '@/lib/format'
|
||||||
import { accountSelectLabel, providerByIdMap } from '@/lib/billmanager'
|
import { accountSelectLabel, providerByIdMap } from '@/lib/billmanager'
|
||||||
|
|
||||||
@@ -17,6 +17,7 @@ const EMPTY: PaymentFormValues = {
|
|||||||
amount: 0,
|
amount: 0,
|
||||||
currency: 'RUB',
|
currency: 'RUB',
|
||||||
providerAccountId: '',
|
providerAccountId: '',
|
||||||
|
vpsId: '',
|
||||||
note: '',
|
note: '',
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,6 +27,7 @@ interface PaymentEditSheetProps {
|
|||||||
defaultValues: PaymentFormValues
|
defaultValues: PaymentFormValues
|
||||||
providerAccounts: ProviderAccount[]
|
providerAccounts: ProviderAccount[]
|
||||||
providers: Provider[]
|
providers: Provider[]
|
||||||
|
vpsRows: Vps[]
|
||||||
onSubmit: (values: PaymentFormValues) => void
|
onSubmit: (values: PaymentFormValues) => void
|
||||||
submitting?: boolean
|
submitting?: boolean
|
||||||
}
|
}
|
||||||
@@ -37,7 +39,7 @@ export function paymentFormDefaults(
|
|||||||
if (!edit) {
|
if (!edit) {
|
||||||
return { ...EMPTY, providerAccountId: fallbackAccountId }
|
return { ...EMPTY, providerAccountId: fallbackAccountId }
|
||||||
}
|
}
|
||||||
return { ...EMPTY, ...edit }
|
return { ...EMPTY, ...edit, vpsId: edit.vpsId ?? '' }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PaymentEditSheet({
|
export function PaymentEditSheet({
|
||||||
@@ -46,6 +48,7 @@ export function PaymentEditSheet({
|
|||||||
defaultValues,
|
defaultValues,
|
||||||
providerAccounts,
|
providerAccounts,
|
||||||
providers,
|
providers,
|
||||||
|
vpsRows,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
submitting,
|
submitting,
|
||||||
}: PaymentEditSheetProps) {
|
}: PaymentEditSheetProps) {
|
||||||
@@ -63,6 +66,14 @@ export function PaymentEditSheet({
|
|||||||
>
|
>
|
||||||
{(form) => {
|
{(form) => {
|
||||||
const { register, formState: { errors }, watch, setValue } = 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<FormField label="Тип" htmlFor="pay-type" error={errors.type?.message}>
|
<FormField label="Тип" htmlFor="pay-type" error={errors.type?.message}>
|
||||||
@@ -82,14 +93,26 @@ export function PaymentEditSheet({
|
|||||||
<SelectField
|
<SelectField
|
||||||
triggerId="pay-acc"
|
triggerId="pay-acc"
|
||||||
placeholder="Выберите аккаунт"
|
placeholder="Выберите аккаунт"
|
||||||
value={watch('providerAccountId')}
|
value={accountId}
|
||||||
onValueChange={(v) => setValue('providerAccountId', v ?? '', { shouldValidate: true })}
|
onValueChange={(v) => {
|
||||||
|
setValue('providerAccountId', v ?? '', { shouldValidate: true })
|
||||||
|
setValue('vpsId', '')
|
||||||
|
}}
|
||||||
options={providerAccounts.map((a) => ({
|
options={providerAccounts.map((a) => ({
|
||||||
value: a.id,
|
value: a.id,
|
||||||
label: accountSelectLabel(a, providerById),
|
label: accountSelectLabel(a, providerById),
|
||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
<FormField label="VPS (необязательно)" htmlFor="pay-vps">
|
||||||
|
<SelectField
|
||||||
|
triggerId="pay-vps"
|
||||||
|
placeholder="Не привязан"
|
||||||
|
value={watch('vpsId') || ''}
|
||||||
|
onValueChange={(v) => setValue('vpsId', v ?? '')}
|
||||||
|
options={[{ value: '', label: '—' }, ...vpsOptions]}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
<FormField label="Дата" htmlFor="pay-date" error={errors.date?.message}>
|
<FormField label="Дата" htmlFor="pay-date" error={errors.date?.message}>
|
||||||
<Input id="pay-date" type="date" {...register('date')} />
|
<Input id="pay-date" type="date" {...register('date')} />
|
||||||
|
|||||||
@@ -3,33 +3,52 @@ import { FormField } from '@/components/form-field'
|
|||||||
import { Input } from '@cfdm/ui/components/input'
|
import { Input } from '@cfdm/ui/components/input'
|
||||||
import { projectSchema, type ProjectFormValues } from '@/lib/schemas'
|
import { projectSchema, type ProjectFormValues } from '@/lib/schemas'
|
||||||
|
|
||||||
const EMPTY: ProjectFormValues = { name: '' }
|
const EMPTY: ProjectFormValues = { name: '', color: '' }
|
||||||
|
|
||||||
interface ProjectEditSheetProps {
|
interface ProjectEditSheetProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
onOpenChange: (open: boolean) => void
|
onOpenChange: (open: boolean) => void
|
||||||
|
defaultValues?: ProjectFormValues
|
||||||
onSubmit: (values: ProjectFormValues) => void
|
onSubmit: (values: ProjectFormValues) => void
|
||||||
submitting?: boolean
|
submitting?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProjectEditSheet({ open, onOpenChange, onSubmit, submitting }: ProjectEditSheetProps) {
|
export function projectFormDefaults(edit?: Partial<ProjectFormValues> | 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 (
|
return (
|
||||||
<FormSheetRhf
|
<FormSheetRhf
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={onOpenChange}
|
onOpenChange={onOpenChange}
|
||||||
title="Новый проект"
|
title={isEdit ? 'Редактировать проект' : 'Новый проект'}
|
||||||
description="Имя будет доступно в автодополнении на форме VPS"
|
description="Имя будет доступно в автодополнении на форме VPS"
|
||||||
schema={projectSchema}
|
schema={projectSchema as import('zod').ZodType<ProjectFormValues>}
|
||||||
defaultValues={EMPTY}
|
defaultValues={defaultValues}
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
submitting={submitting}
|
submitting={submitting}
|
||||||
>
|
>
|
||||||
{(form) => {
|
{(form) => {
|
||||||
const { register, formState: { errors } } = form
|
const { register, formState: { errors } } = form
|
||||||
return (
|
return (
|
||||||
<FormField label="Название" htmlFor="project-name" error={errors.name?.message} invalid={!!errors.name}>
|
<>
|
||||||
<Input id="project-name" aria-invalid={!!errors.name} {...register('name')} />
|
<FormField label="Название" htmlFor="project-name" error={errors.name?.message} invalid={!!errors.name}>
|
||||||
</FormField>
|
<Input id="project-name" aria-invalid={!!errors.name} {...register('name')} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Цвет (hex)" htmlFor="project-color" description="Например #3b82f6 — для badge в списке VPS">
|
||||||
|
<Input id="project-color" placeholder="#3b82f6" {...register('color')} />
|
||||||
|
</FormField>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</FormSheetRhf>
|
</FormSheetRhf>
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className="flex flex-wrap items-center gap-2 rounded-lg border bg-muted/40 px-3 py-2">
|
||||||
|
<span className="text-sm font-medium tabular-nums">Выбрано: {selectedCount}</span>
|
||||||
|
<Button variant="outline" size="sm" disabled={busy} onClick={() => onSetStatus('active')}>
|
||||||
|
<PlayIcon data-icon="inline-start" />
|
||||||
|
{vpsStatusLabel('active')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" disabled={busy} onClick={() => onSetStatus('paused')}>
|
||||||
|
<PauseIcon data-icon="inline-start" />
|
||||||
|
{vpsStatusLabel('paused')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" disabled={busy} onClick={() => onSetStatus('archived')}>
|
||||||
|
<ArchiveIcon data-icon="inline-start" />
|
||||||
|
{vpsStatusLabel('archived')}
|
||||||
|
</Button>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<SelectField
|
||||||
|
placeholder="Проект…"
|
||||||
|
value={projectValue}
|
||||||
|
onValueChange={(v) => setProjectValue(v ?? '')}
|
||||||
|
options={projectOptions.map((p) => ({ value: p, label: p }))}
|
||||||
|
triggerClassName="w-40"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={busy || !projectValue}
|
||||||
|
onClick={() => {
|
||||||
|
onSetProject(projectValue)
|
||||||
|
setProjectValue('')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FolderKanbanIcon data-icon="inline-start" />
|
||||||
|
Назначить
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<ConfirmDialog
|
||||||
|
trigger={
|
||||||
|
<Button variant="destructive" size="sm" disabled={busy}>
|
||||||
|
<Trash2Icon data-icon="inline-start" />
|
||||||
|
Удалить
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
title={`Удалить ${selectedCount} VPS?`}
|
||||||
|
description="Записи будут удалены безвозвратно."
|
||||||
|
confirmLabel="Удалить"
|
||||||
|
destructive
|
||||||
|
onConfirm={onDelete}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ import { Input } from '@cfdm/ui/components/input'
|
|||||||
import { SelectField } from '@/components/select-field'
|
import { SelectField } from '@/components/select-field'
|
||||||
import { AutoCompleteInput } from '@/components/auto-complete-input'
|
import { AutoCompleteInput } from '@/components/auto-complete-input'
|
||||||
import { Textarea } from '@cfdm/ui/components/textarea'
|
import { Textarea } from '@cfdm/ui/components/textarea'
|
||||||
|
import { Checkbox } from '@cfdm/ui/components/checkbox'
|
||||||
|
import { Label } from '@cfdm/ui/components/label'
|
||||||
import {
|
import {
|
||||||
NumberField,
|
NumberField,
|
||||||
NumberFieldGroup,
|
NumberFieldGroup,
|
||||||
@@ -16,6 +18,8 @@ import { FormDatePicker } from '@/components/form-date-picker'
|
|||||||
import { vpsSchema, type VpsFormValues } from '@/lib/schemas'
|
import { vpsSchema, type VpsFormValues } from '@/lib/schemas'
|
||||||
import { vpsStatusLabel, tariffTypeLabel } from '@/lib/format'
|
import { vpsStatusLabel, tariffTypeLabel } from '@/lib/format'
|
||||||
import { buildCityOptions, cityMatchesCountry, resolveCountryForCityFromRows } from '@cfdm/shared/geo'
|
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 { Provider, ProviderAccount, Vps } from '@/types/entities'
|
||||||
import type { ZodType } from 'zod'
|
import type { ZodType } from 'zod'
|
||||||
|
|
||||||
@@ -38,6 +42,8 @@ export const VPS_FORM_EMPTY: VpsFormValues = {
|
|||||||
paidUntil: '',
|
paidUntil: '',
|
||||||
project: '',
|
project: '',
|
||||||
notes: '',
|
notes: '',
|
||||||
|
userOverrides: [] as string[],
|
||||||
|
customData: {} as Record<string, string | number | boolean>,
|
||||||
}
|
}
|
||||||
|
|
||||||
export function vpsFormFromRow(v: Vps): VpsFormValues {
|
export function vpsFormFromRow(v: Vps): VpsFormValues {
|
||||||
@@ -61,6 +67,8 @@ export function vpsFormFromRow(v: Vps): VpsFormValues {
|
|||||||
paidUntil: v.paidUntil ?? '',
|
paidUntil: v.paidUntil ?? '',
|
||||||
project: v.project ?? '',
|
project: v.project ?? '',
|
||||||
notes: v.notes ?? '',
|
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[]
|
providerAccounts: ProviderAccount[]
|
||||||
vpsRows: Vps[]
|
vpsRows: Vps[]
|
||||||
formCountryOptions: Array<{ value: string; label: string }>
|
formCountryOptions: Array<{ value: string; label: string }>
|
||||||
|
customFieldDefs?: CustomFieldDef[]
|
||||||
onSubmit: (values: VpsFormValues) => void
|
onSubmit: (values: VpsFormValues) => void
|
||||||
submitting?: boolean
|
submitting?: boolean
|
||||||
}
|
}
|
||||||
@@ -86,6 +95,7 @@ export function VpsEditSheet({
|
|||||||
providerAccounts,
|
providerAccounts,
|
||||||
vpsRows,
|
vpsRows,
|
||||||
formCountryOptions,
|
formCountryOptions,
|
||||||
|
customFieldDefs = [],
|
||||||
onSubmit,
|
onSubmit,
|
||||||
submitting,
|
submitting,
|
||||||
}: VpsEditSheetProps) {
|
}: VpsEditSheetProps) {
|
||||||
@@ -326,6 +336,74 @@ export function VpsEditSheet({
|
|||||||
<FormField label="Заметки" htmlFor="vps-notes">
|
<FormField label="Заметки" htmlFor="vps-notes">
|
||||||
<Textarea id="vps-notes" {...register('notes')} />
|
<Textarea id="vps-notes" {...register('notes')} />
|
||||||
</FormField>
|
</FormField>
|
||||||
|
{customFieldDefs.length > 0 ? (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<p className="text-sm font-medium">Дополнительные поля</p>
|
||||||
|
{customFieldDefs.map((field) => {
|
||||||
|
const customData = watch('customData') ?? {}
|
||||||
|
if (field.type === 'bool') {
|
||||||
|
return (
|
||||||
|
<div key={field.key} className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id={`custom-${field.key}`}
|
||||||
|
checked={Boolean(customData[field.key])}
|
||||||
|
onCheckedChange={(v) =>
|
||||||
|
setValue('customData', { ...customData, [field.key]: Boolean(v) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label htmlFor={`custom-${field.key}`} className="font-normal">
|
||||||
|
{field.label}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<FormField key={field.key} label={field.label} htmlFor={`custom-${field.key}`}>
|
||||||
|
<Input
|
||||||
|
id={`custom-${field.key}`}
|
||||||
|
type={field.type === 'number' ? 'number' : 'text'}
|
||||||
|
value={String(customData[field.key] ?? '')}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val =
|
||||||
|
field.type === 'number' ? Number(e.target.value) : e.target.value
|
||||||
|
setValue('customData', { ...customData, [field.key]: val })
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{editingId ? (
|
||||||
|
<FormField
|
||||||
|
label="Не перезаписывать при синке"
|
||||||
|
description="Отмеченные поля сохранят ручные значения при синхронизации с BILLmanager"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{VPS_SYNC_OVERRIDE_FIELDS.map(({ key, label }) => {
|
||||||
|
const overrides = watch('userOverrides') ?? []
|
||||||
|
const checked = overrides.includes(key)
|
||||||
|
return (
|
||||||
|
<div key={key} className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id={`vps-override-${key}`}
|
||||||
|
checked={checked}
|
||||||
|
onCheckedChange={(value) => {
|
||||||
|
const next = value
|
||||||
|
? [...overrides, key]
|
||||||
|
: overrides.filter((f) => f !== key)
|
||||||
|
setValue('userOverrides', next)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Label htmlFor={`vps-override-${key}`} className="font-normal">
|
||||||
|
{label}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</FormField>
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { useEffect, useMemo } from 'react'
|
||||||
|
import { useNavigate } from '@tanstack/react-router'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import {
|
||||||
|
ServerIcon,
|
||||||
|
WalletIcon,
|
||||||
|
Building2Icon,
|
||||||
|
FolderKanbanIcon,
|
||||||
|
LayoutDashboardIcon,
|
||||||
|
SearchIcon,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
import {
|
||||||
|
CommandDialog,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
CommandSeparator,
|
||||||
|
} from '@cfdm/ui/components/command'
|
||||||
|
import { snapshotQueryOptions } from '@/queries/snapshot'
|
||||||
|
import { providerByIdMap } from '@/lib/billmanager'
|
||||||
|
|
||||||
|
interface GlobalSearchProps {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GlobalSearch({ open, onOpenChange }: GlobalSearchProps) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { data: snapshot } = useQuery({ ...snapshotQueryOptions(), enabled: open })
|
||||||
|
const providerById = snapshot ? providerByIdMap(snapshot.providers) : new Map()
|
||||||
|
|
||||||
|
const go = (to: string, search?: Record<string, string>) => {
|
||||||
|
onOpenChange(false)
|
||||||
|
void navigate({ to, search })
|
||||||
|
}
|
||||||
|
|
||||||
|
const vpsItems = useMemo(() => snapshot?.vps ?? [], [snapshot])
|
||||||
|
const accountItems = useMemo(() => snapshot?.providerAccounts ?? [], [snapshot])
|
||||||
|
const projectItems = useMemo(() => snapshot?.serverProjects ?? [], [snapshot])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CommandDialog open={open} onOpenChange={onOpenChange} title="Поиск" description="VPS, аккаунты, проекты и навигация">
|
||||||
|
<CommandInput placeholder="IP, DNS, проект, аккаунт…" />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>Ничего не найдено</CommandEmpty>
|
||||||
|
<CommandGroup heading="Навигация">
|
||||||
|
<CommandItem onSelect={() => go('/dashboard')}>
|
||||||
|
<LayoutDashboardIcon />
|
||||||
|
Дашборд
|
||||||
|
</CommandItem>
|
||||||
|
<CommandItem onSelect={() => go('/vps')}>
|
||||||
|
<ServerIcon />
|
||||||
|
Все VPS
|
||||||
|
</CommandItem>
|
||||||
|
</CommandGroup>
|
||||||
|
<CommandSeparator />
|
||||||
|
<CommandGroup heading="VPS">
|
||||||
|
{vpsItems.slice(0, 50).map((v) => (
|
||||||
|
<CommandItem key={v.id} value={`${v.ip} ${v.dns} ${v.project}`} onSelect={() => go('/vps/$vpsId', { vpsId: v.id })}>
|
||||||
|
<ServerIcon />
|
||||||
|
<span>{v.ip || v.dns || v.id}</span>
|
||||||
|
{v.project ? <span className="text-muted-foreground text-xs">· {v.project}</span> : null}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
<CommandGroup heading="Аккаунты">
|
||||||
|
{accountItems.map((a) => (
|
||||||
|
<CommandItem
|
||||||
|
key={a.id}
|
||||||
|
value={`${a.name} ${providerById.get(a.providerId)?.name ?? ''}`}
|
||||||
|
onSelect={() => go('/accounts')}
|
||||||
|
>
|
||||||
|
<WalletIcon />
|
||||||
|
{a.name}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
<CommandGroup heading="Проекты">
|
||||||
|
{projectItems.map((p) => {
|
||||||
|
const row = p as { id: string; name: string }
|
||||||
|
return (
|
||||||
|
<CommandItem key={row.id} value={row.name} onSelect={() => go('/vps', { project: row.name })}>
|
||||||
|
<FolderKanbanIcon />
|
||||||
|
{row.name}
|
||||||
|
</CommandItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</CommandGroup>
|
||||||
|
<CommandGroup heading="Хостеры">
|
||||||
|
{(snapshot?.providers ?? []).map((p) => (
|
||||||
|
<CommandItem key={p.id} value={p.name} onSelect={() => go('/providers')}>
|
||||||
|
<Building2Icon />
|
||||||
|
{p.name}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</CommandDialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGlobalSearchHotkey(onOpen: () => void) {
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
|
||||||
|
e.preventDefault()
|
||||||
|
onOpen()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', handler)
|
||||||
|
return () => window.removeEventListener('keydown', handler)
|
||||||
|
}, [onOpen])
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GlobalSearchTrigger({ onClick }: { onClick: () => void }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className="hidden items-center gap-2 rounded-md border bg-muted/50 px-3 py-1.5 text-sm text-muted-foreground hover:bg-muted md:flex"
|
||||||
|
>
|
||||||
|
<SearchIcon className="size-4" />
|
||||||
|
<span>Поиск</span>
|
||||||
|
<kbd className="pointer-events-none rounded border bg-background px-1.5 font-mono text-xs">Ctrl+K</kbd>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { AlertCircleIcon, XIcon } from 'lucide-react'
|
||||||
|
import { Link } from '@tanstack/react-router'
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@cfdm/ui/components/alert'
|
||||||
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
|
|
||||||
|
const HEALTH_LABELS: Record<string, string> = {
|
||||||
|
'no-rate': 'Нет ставки или валюты',
|
||||||
|
'paid-overdue': 'Просрочена оплата (оценка)',
|
||||||
|
'stale-sync': 'Нет успешного синка > 48 ч',
|
||||||
|
'balance-mismatch': 'Баланс API и ledger расходятся',
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HealthModeBannerProps {
|
||||||
|
health: string
|
||||||
|
exitTo: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HealthModeBanner({ health, exitTo }: HealthModeBannerProps) {
|
||||||
|
const title = HEALTH_LABELS[health] ?? 'Режим диагностики'
|
||||||
|
return (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertCircleIcon />
|
||||||
|
<AlertTitle>{title}</AlertTitle>
|
||||||
|
<AlertDescription className="flex flex-wrap items-center gap-2">
|
||||||
|
<span>Показаны только записи с этой проблемой.</span>
|
||||||
|
<Button variant="outline" size="sm" render={<Link to={exitTo} search={{}} />}>
|
||||||
|
<XIcon data-icon="inline-start" />
|
||||||
|
Выйти из режима
|
||||||
|
</Button>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -42,9 +42,10 @@ import { Badge } from '@cfdm/ui/components/badge'
|
|||||||
|
|
||||||
import { Link, useRouterState } from '@tanstack/react-router'
|
import { Link, useRouterState } from '@tanstack/react-router'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import type { ReactNode } from 'react'
|
import { useState, type ReactNode } from 'react'
|
||||||
|
|
||||||
import { ModeToggle } from '@/components/mode-toggle'
|
import { ModeToggle } from '@/components/mode-toggle'
|
||||||
|
import { GlobalSearch, GlobalSearchTrigger, useGlobalSearchHotkey } from '@/components/global-search'
|
||||||
import { dashboardStatsQueryOptions } from '@/queries/dashboard'
|
import { dashboardStatsQueryOptions } from '@/queries/dashboard'
|
||||||
import { formatRelativeSyncTime } from '@/lib/sync-format'
|
import { formatRelativeSyncTime } from '@/lib/sync-format'
|
||||||
|
|
||||||
@@ -87,12 +88,14 @@ const NAV_GROUPS: NavGroup[] = [
|
|||||||
items: [
|
items: [
|
||||||
{ to: '/reports', label: 'Отчёты', icon: ChartColumnBig },
|
{ to: '/reports', label: 'Отчёты', icon: ChartColumnBig },
|
||||||
{ to: '/resources', label: 'Ресурсы', icon: ChartBar },
|
{ to: '/resources', label: 'Ресурсы', icon: ChartBar },
|
||||||
|
{ to: '/renewals', label: 'Продления', icon: RefreshCwIcon },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Система',
|
label: 'Система',
|
||||||
items: [
|
items: [
|
||||||
{ to: '/sync-journal', label: 'Журнал синка', icon: HistoryIcon },
|
{ to: '/sync-journal', label: 'Журнал синка', icon: HistoryIcon },
|
||||||
|
{ to: '/audit', label: 'Журнал изменений', icon: HistoryIcon },
|
||||||
{ to: '/settings', label: 'Настройки', icon: Settings },
|
{ to: '/settings', label: 'Настройки', icon: Settings },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -114,11 +117,15 @@ const PARENT_ROUTE: Record<string, string> = {
|
|||||||
'/balance': '/dashboard',
|
'/balance': '/dashboard',
|
||||||
'/reports': '/dashboard',
|
'/reports': '/dashboard',
|
||||||
'/resources': '/dashboard',
|
'/resources': '/dashboard',
|
||||||
|
'/renewals': '/dashboard',
|
||||||
'/sync-journal': '/settings',
|
'/sync-journal': '/settings',
|
||||||
|
'/audit': '/settings',
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppShell({ children }: { children: ReactNode }) {
|
export function AppShell({ children }: { children: ReactNode }) {
|
||||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||||
|
const [searchOpen, setSearchOpen] = useState(false)
|
||||||
|
useGlobalSearchHotkey(() => setSearchOpen(true))
|
||||||
const activeItem = ALL_NAV_ITEMS.find((i) => pathname === i.to || pathname.startsWith(`${i.to}/`)) ?? ALL_NAV_ITEMS[0]
|
const activeItem = ALL_NAV_ITEMS.find((i) => pathname === i.to || pathname.startsWith(`${i.to}/`)) ?? ALL_NAV_ITEMS[0]
|
||||||
const parentTo = PARENT_ROUTE[activeItem.to]
|
const parentTo = PARENT_ROUTE[activeItem.to]
|
||||||
const parentLabel = parentTo ? ROUTE_LABELS[parentTo] : null
|
const parentLabel = parentTo ? ROUTE_LABELS[parentTo] : null
|
||||||
@@ -227,6 +234,7 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
</BreadcrumbList>
|
</BreadcrumbList>
|
||||||
</Breadcrumb>
|
</Breadcrumb>
|
||||||
<div className="ml-auto flex items-center gap-2">
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
<GlobalSearchTrigger onClick={() => setSearchOpen(true)} />
|
||||||
{stats?.issuesCount ? (
|
{stats?.issuesCount ? (
|
||||||
<Badge variant="destructive" className="hidden sm:inline-flex">
|
<Badge variant="destructive" className="hidden sm:inline-flex">
|
||||||
{stats.issuesCount} проблем
|
{stats.issuesCount} проблем
|
||||||
@@ -237,6 +245,7 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
</header>
|
</header>
|
||||||
<main className="flex flex-1 flex-col gap-4 p-4 md:gap-6 md:p-6">{children}</main>
|
<main className="flex flex-1 flex-col gap-4 p-4 md:gap-6 md:p-6">{children}</main>
|
||||||
</SidebarInset>
|
</SidebarInset>
|
||||||
|
<GlobalSearch open={searchOpen} onOpenChange={setSearchOpen} />
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -150,6 +150,25 @@ export const api = {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ name }),
|
body: JSON.stringify({ name }),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
updateProject: (id: string, patch: { name?: string; color?: string | null; notes?: string | null }) =>
|
||||||
|
fetchApi<{ id: string; name: string }>(`/api/projects/${encodeURIComponent(id)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(patch),
|
||||||
|
}),
|
||||||
|
|
||||||
|
deleteProject: (id: string) =>
|
||||||
|
fetchApi<void>(`/api/projects/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
|
fetchAuditLog: (limit = 100) =>
|
||||||
|
fetchApi<Array<{
|
||||||
|
id: string
|
||||||
|
entity: string
|
||||||
|
entityId: string
|
||||||
|
action: string
|
||||||
|
diff: Record<string, unknown> | null
|
||||||
|
createdAt: string
|
||||||
|
}>>(`/api/audit?limit=${limit}`),
|
||||||
}
|
}
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
export interface CustomFieldDef {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
type?: 'text' | 'number' | 'bool'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseCustomFieldDefs(raw: unknown): CustomFieldDef[] {
|
||||||
|
if (!Array.isArray(raw)) return []
|
||||||
|
return raw
|
||||||
|
.filter((item): item is Record<string, unknown> => item != null && typeof item === 'object')
|
||||||
|
.map((item) => ({
|
||||||
|
key: String(item.key ?? '').trim(),
|
||||||
|
label: String(item.label ?? item.key ?? '').trim(),
|
||||||
|
type: (item.type as CustomFieldDef['type']) ?? 'text',
|
||||||
|
}))
|
||||||
|
.filter((f) => f.key.length > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseCustomData(raw: unknown): Record<string, string | number | boolean> {
|
||||||
|
if (typeof raw === 'string' && raw.trim()) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as unknown
|
||||||
|
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||||
|
return parsed as Record<string, string | number | boolean>
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
|
||||||
|
return raw as Record<string, string | number | boolean>
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
}
|
||||||
@@ -57,6 +57,8 @@ export const vpsSchema = z.object({
|
|||||||
paidUntil: z.string().optional().default(''),
|
paidUntil: z.string().optional().default(''),
|
||||||
project: z.string().optional().default(''),
|
project: z.string().optional().default(''),
|
||||||
notes: z.string().optional().default(''),
|
notes: z.string().optional().default(''),
|
||||||
|
userOverrides: z.array(z.string()).optional().default([]),
|
||||||
|
customData: z.record(z.union([z.string(), z.number(), z.boolean()])).optional().default({}),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const paymentSchema = z.object({
|
export const paymentSchema = z.object({
|
||||||
@@ -66,6 +68,7 @@ export const paymentSchema = z.object({
|
|||||||
amount: z.coerce.number().min(0, 'Сумма должна быть ≥ 0'),
|
amount: z.coerce.number().min(0, 'Сумма должна быть ≥ 0'),
|
||||||
currency: z.string().min(1).default('RUB'),
|
currency: z.string().min(1).default('RUB'),
|
||||||
providerAccountId: z.string().min(1, 'Выберите аккаунт'),
|
providerAccountId: z.string().min(1, 'Выберите аккаунт'),
|
||||||
|
vpsId: z.string().optional().default(''),
|
||||||
note: z.string().optional().default(''),
|
note: z.string().optional().default(''),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -88,14 +91,21 @@ export const settingsSchema = z.object({
|
|||||||
syncTariffsIntervalMinutes: z.coerce.number().min(60).optional().default(1440),
|
syncTariffsIntervalMinutes: z.coerce.number().min(60).optional().default(1440),
|
||||||
telegramChatId: z.string().optional().default(''),
|
telegramChatId: z.string().optional().default(''),
|
||||||
telegramBotToken: z.string().optional().default(''),
|
telegramBotToken: z.string().optional().default(''),
|
||||||
|
telegramMessageThreadId: z.string().optional().default(''),
|
||||||
notifyPaymentExpiryEnabled: z.boolean().optional().default(true),
|
notifyPaymentExpiryEnabled: z.boolean().optional().default(true),
|
||||||
notifyNewTariffsEnabled: z.boolean().optional().default(true),
|
notifyNewTariffsEnabled: z.boolean().optional().default(true),
|
||||||
notifyLowBalanceEnabled: z.boolean().optional().default(true),
|
notifyLowBalanceEnabled: z.boolean().optional().default(true),
|
||||||
notifySyncDigestEnabled: z.boolean().optional().default(true),
|
notifySyncDigestEnabled: z.boolean().optional().default(true),
|
||||||
|
notifyVpsDownEnabled: z.boolean().optional().default(true),
|
||||||
|
webhookUrl: z.string().url('Невалидный URL').or(z.literal('')).optional().default(''),
|
||||||
|
webhookEnabled: z.boolean().optional().default(false),
|
||||||
|
customFieldsJson: z.string().optional().default('[]'),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const projectSchema = z.object({
|
export const projectSchema = z.object({
|
||||||
|
id: z.string().optional(),
|
||||||
name: z.string().min(1, 'Укажите название проекта').max(120),
|
name: z.string().min(1, 'Укажите название проекта').max(120),
|
||||||
|
color: z.string().optional().default(''),
|
||||||
})
|
})
|
||||||
|
|
||||||
export type ProjectFormValues = z.infer<typeof projectSchema>
|
export type ProjectFormValues = z.infer<typeof projectSchema>
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import type { ActiveTariff, Vps } from '@/types/entities'
|
||||||
|
|
||||||
|
export interface TariffVpsDiff {
|
||||||
|
vpsId: string
|
||||||
|
vpsLabel: string
|
||||||
|
tariffName: string
|
||||||
|
issues: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function normName(s: string | null | undefined): string {
|
||||||
|
return (s || '').trim().toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findMatchingTariff(
|
||||||
|
vps: Vps,
|
||||||
|
tariffs: ActiveTariff[],
|
||||||
|
): ActiveTariff | undefined {
|
||||||
|
const byName = tariffs.filter(
|
||||||
|
(t) =>
|
||||||
|
t.providerAccountId === vps.providerAccountId &&
|
||||||
|
normName(t.name) === normName(vps.tariffType),
|
||||||
|
)
|
||||||
|
if (byName.length === 1) return byName[0]
|
||||||
|
return tariffs.find(
|
||||||
|
(t) =>
|
||||||
|
t.providerAccountId === vps.providerAccountId &&
|
||||||
|
t.vcpu === vps.vcpu &&
|
||||||
|
t.ramGb === vps.ramGb &&
|
||||||
|
t.diskGb === vps.diskGb,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeTariffDiffs(vpsList: Vps[], tariffs: ActiveTariff[]): TariffVpsDiff[] {
|
||||||
|
const active = vpsList.filter((v) => v.status === 'active')
|
||||||
|
const out: TariffVpsDiff[] = []
|
||||||
|
for (const v of active) {
|
||||||
|
const tariff = findMatchingTariff(v, tariffs)
|
||||||
|
if (!tariff) continue
|
||||||
|
const issues: string[] = []
|
||||||
|
if (tariff.vcpu != null && v.vcpu !== tariff.vcpu) {
|
||||||
|
issues.push(`vCPU: факт ${v.vcpu}, тариф ${tariff.vcpu}`)
|
||||||
|
}
|
||||||
|
if (tariff.ramGb != null && Number(v.ramGb) !== Number(tariff.ramGb)) {
|
||||||
|
issues.push(`RAM: факт ${v.ramGb} GB, тариф ${tariff.ramGb} GB`)
|
||||||
|
}
|
||||||
|
if (tariff.diskGb != null && v.diskGb !== tariff.diskGb) {
|
||||||
|
issues.push(`Disk: факт ${v.diskGb} GB, тариф ${tariff.diskGb} GB`)
|
||||||
|
}
|
||||||
|
if (issues.length) {
|
||||||
|
out.push({
|
||||||
|
vpsId: v.id,
|
||||||
|
vpsLabel: v.ip || v.dns || v.id,
|
||||||
|
tariffName: tariff.name || String(tariff.pricelistId ?? ''),
|
||||||
|
issues,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/** Поля VPS, которые BILLmanager-синк может перезаписывать (см. sync.ts). */
|
||||||
|
export const VPS_SYNC_OVERRIDE_FIELDS = [
|
||||||
|
{ key: 'country', label: 'Страна' },
|
||||||
|
{ key: 'city', label: 'Город' },
|
||||||
|
{ key: 'datacenter', label: 'Дата-центр' },
|
||||||
|
{ key: 'os', label: 'ОС' },
|
||||||
|
{ key: 'notes', label: 'Заметки' },
|
||||||
|
{ key: 'status', label: 'Статус' },
|
||||||
|
{ key: 'tariffType', label: 'Тип тарифа' },
|
||||||
|
{ key: 'currency', label: 'Валюта' },
|
||||||
|
{ key: 'dailyRate', label: 'Ставка/день' },
|
||||||
|
{ key: 'monthlyRate', label: 'Ставка/мес' },
|
||||||
|
{ key: 'paidUntil', label: 'Оплачено до' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export type VpsSyncOverrideField = (typeof VPS_SYNC_OVERRIDE_FIELDS)[number]['key']
|
||||||
|
|
||||||
|
export function parseUserOverrides(raw: unknown): string[] {
|
||||||
|
if (Array.isArray(raw)) return raw.filter((x) => typeof x === 'string')
|
||||||
|
if (typeof raw === 'string' && raw.trim()) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as unknown
|
||||||
|
if (Array.isArray(parsed)) return parsed.filter((x) => typeof x === 'string')
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
@@ -17,12 +17,15 @@ import { Route as AuthSyncJournalRouteImport } from './routes/_auth/sync-journal
|
|||||||
import { Route as AuthSettingsRouteImport } from './routes/_auth/settings'
|
import { Route as AuthSettingsRouteImport } from './routes/_auth/settings'
|
||||||
import { Route as AuthResourcesRouteImport } from './routes/_auth/resources'
|
import { Route as AuthResourcesRouteImport } from './routes/_auth/resources'
|
||||||
import { Route as AuthReportsRouteImport } from './routes/_auth/reports'
|
import { Route as AuthReportsRouteImport } from './routes/_auth/reports'
|
||||||
|
import { Route as AuthRenewalsRouteImport } from './routes/_auth/renewals'
|
||||||
import { Route as AuthProvidersRouteImport } from './routes/_auth/providers'
|
import { Route as AuthProvidersRouteImport } from './routes/_auth/providers'
|
||||||
import { Route as AuthProjectsRouteImport } from './routes/_auth/projects'
|
import { Route as AuthProjectsRouteImport } from './routes/_auth/projects'
|
||||||
import { Route as AuthPaymentsRouteImport } from './routes/_auth/payments'
|
import { Route as AuthPaymentsRouteImport } from './routes/_auth/payments'
|
||||||
import { Route as AuthDashboardRouteImport } from './routes/_auth/dashboard'
|
import { Route as AuthDashboardRouteImport } from './routes/_auth/dashboard'
|
||||||
import { Route as AuthBalanceRouteImport } from './routes/_auth/balance'
|
import { Route as AuthBalanceRouteImport } from './routes/_auth/balance'
|
||||||
|
import { Route as AuthAuditRouteImport } from './routes/_auth/audit'
|
||||||
import { Route as AuthAccountsRouteImport } from './routes/_auth/accounts'
|
import { Route as AuthAccountsRouteImport } from './routes/_auth/accounts'
|
||||||
|
import { Route as AuthVpsVpsIdRouteImport } from './routes/_auth/vps.$vpsId'
|
||||||
|
|
||||||
const AuthRoute = AuthRouteImport.update({
|
const AuthRoute = AuthRouteImport.update({
|
||||||
id: '/_auth',
|
id: '/_auth',
|
||||||
@@ -63,6 +66,11 @@ const AuthReportsRoute = AuthReportsRouteImport.update({
|
|||||||
path: '/reports',
|
path: '/reports',
|
||||||
getParentRoute: () => AuthRoute,
|
getParentRoute: () => AuthRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AuthRenewalsRoute = AuthRenewalsRouteImport.update({
|
||||||
|
id: '/renewals',
|
||||||
|
path: '/renewals',
|
||||||
|
getParentRoute: () => AuthRoute,
|
||||||
|
} as any)
|
||||||
const AuthProvidersRoute = AuthProvidersRouteImport.update({
|
const AuthProvidersRoute = AuthProvidersRouteImport.update({
|
||||||
id: '/providers',
|
id: '/providers',
|
||||||
path: '/providers',
|
path: '/providers',
|
||||||
@@ -88,106 +96,134 @@ const AuthBalanceRoute = AuthBalanceRouteImport.update({
|
|||||||
path: '/balance',
|
path: '/balance',
|
||||||
getParentRoute: () => AuthRoute,
|
getParentRoute: () => AuthRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AuthAuditRoute = AuthAuditRouteImport.update({
|
||||||
|
id: '/audit',
|
||||||
|
path: '/audit',
|
||||||
|
getParentRoute: () => AuthRoute,
|
||||||
|
} as any)
|
||||||
const AuthAccountsRoute = AuthAccountsRouteImport.update({
|
const AuthAccountsRoute = AuthAccountsRouteImport.update({
|
||||||
id: '/accounts',
|
id: '/accounts',
|
||||||
path: '/accounts',
|
path: '/accounts',
|
||||||
getParentRoute: () => AuthRoute,
|
getParentRoute: () => AuthRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AuthVpsVpsIdRoute = AuthVpsVpsIdRouteImport.update({
|
||||||
|
id: '/$vpsId',
|
||||||
|
path: '/$vpsId',
|
||||||
|
getParentRoute: () => AuthVpsRoute,
|
||||||
|
} as any)
|
||||||
|
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/accounts': typeof AuthAccountsRoute
|
'/accounts': typeof AuthAccountsRoute
|
||||||
|
'/audit': typeof AuthAuditRoute
|
||||||
'/balance': typeof AuthBalanceRoute
|
'/balance': typeof AuthBalanceRoute
|
||||||
'/dashboard': typeof AuthDashboardRoute
|
'/dashboard': typeof AuthDashboardRoute
|
||||||
'/payments': typeof AuthPaymentsRoute
|
'/payments': typeof AuthPaymentsRoute
|
||||||
'/projects': typeof AuthProjectsRoute
|
'/projects': typeof AuthProjectsRoute
|
||||||
'/providers': typeof AuthProvidersRoute
|
'/providers': typeof AuthProvidersRoute
|
||||||
|
'/renewals': typeof AuthRenewalsRoute
|
||||||
'/reports': typeof AuthReportsRoute
|
'/reports': typeof AuthReportsRoute
|
||||||
'/resources': typeof AuthResourcesRoute
|
'/resources': typeof AuthResourcesRoute
|
||||||
'/settings': typeof AuthSettingsRoute
|
'/settings': typeof AuthSettingsRoute
|
||||||
'/sync-journal': typeof AuthSyncJournalRoute
|
'/sync-journal': typeof AuthSyncJournalRoute
|
||||||
'/tariffs': typeof AuthTariffsRoute
|
'/tariffs': typeof AuthTariffsRoute
|
||||||
'/vps': typeof AuthVpsRoute
|
'/vps': typeof AuthVpsRouteWithChildren
|
||||||
|
'/vps/$vpsId': typeof AuthVpsVpsIdRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/accounts': typeof AuthAccountsRoute
|
'/accounts': typeof AuthAccountsRoute
|
||||||
|
'/audit': typeof AuthAuditRoute
|
||||||
'/balance': typeof AuthBalanceRoute
|
'/balance': typeof AuthBalanceRoute
|
||||||
'/dashboard': typeof AuthDashboardRoute
|
'/dashboard': typeof AuthDashboardRoute
|
||||||
'/payments': typeof AuthPaymentsRoute
|
'/payments': typeof AuthPaymentsRoute
|
||||||
'/projects': typeof AuthProjectsRoute
|
'/projects': typeof AuthProjectsRoute
|
||||||
'/providers': typeof AuthProvidersRoute
|
'/providers': typeof AuthProvidersRoute
|
||||||
|
'/renewals': typeof AuthRenewalsRoute
|
||||||
'/reports': typeof AuthReportsRoute
|
'/reports': typeof AuthReportsRoute
|
||||||
'/resources': typeof AuthResourcesRoute
|
'/resources': typeof AuthResourcesRoute
|
||||||
'/settings': typeof AuthSettingsRoute
|
'/settings': typeof AuthSettingsRoute
|
||||||
'/sync-journal': typeof AuthSyncJournalRoute
|
'/sync-journal': typeof AuthSyncJournalRoute
|
||||||
'/tariffs': typeof AuthTariffsRoute
|
'/tariffs': typeof AuthTariffsRoute
|
||||||
'/vps': typeof AuthVpsRoute
|
'/vps': typeof AuthVpsRouteWithChildren
|
||||||
|
'/vps/$vpsId': typeof AuthVpsVpsIdRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/_auth': typeof AuthRouteWithChildren
|
'/_auth': typeof AuthRouteWithChildren
|
||||||
'/_auth/accounts': typeof AuthAccountsRoute
|
'/_auth/accounts': typeof AuthAccountsRoute
|
||||||
|
'/_auth/audit': typeof AuthAuditRoute
|
||||||
'/_auth/balance': typeof AuthBalanceRoute
|
'/_auth/balance': typeof AuthBalanceRoute
|
||||||
'/_auth/dashboard': typeof AuthDashboardRoute
|
'/_auth/dashboard': typeof AuthDashboardRoute
|
||||||
'/_auth/payments': typeof AuthPaymentsRoute
|
'/_auth/payments': typeof AuthPaymentsRoute
|
||||||
'/_auth/projects': typeof AuthProjectsRoute
|
'/_auth/projects': typeof AuthProjectsRoute
|
||||||
'/_auth/providers': typeof AuthProvidersRoute
|
'/_auth/providers': typeof AuthProvidersRoute
|
||||||
|
'/_auth/renewals': typeof AuthRenewalsRoute
|
||||||
'/_auth/reports': typeof AuthReportsRoute
|
'/_auth/reports': typeof AuthReportsRoute
|
||||||
'/_auth/resources': typeof AuthResourcesRoute
|
'/_auth/resources': typeof AuthResourcesRoute
|
||||||
'/_auth/settings': typeof AuthSettingsRoute
|
'/_auth/settings': typeof AuthSettingsRoute
|
||||||
'/_auth/sync-journal': typeof AuthSyncJournalRoute
|
'/_auth/sync-journal': typeof AuthSyncJournalRoute
|
||||||
'/_auth/tariffs': typeof AuthTariffsRoute
|
'/_auth/tariffs': typeof AuthTariffsRoute
|
||||||
'/_auth/vps': typeof AuthVpsRoute
|
'/_auth/vps': typeof AuthVpsRouteWithChildren
|
||||||
|
'/_auth/vps/$vpsId': typeof AuthVpsVpsIdRoute
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
fullPaths:
|
fullPaths:
|
||||||
| '/'
|
| '/'
|
||||||
| '/accounts'
|
| '/accounts'
|
||||||
|
| '/audit'
|
||||||
| '/balance'
|
| '/balance'
|
||||||
| '/dashboard'
|
| '/dashboard'
|
||||||
| '/payments'
|
| '/payments'
|
||||||
| '/projects'
|
| '/projects'
|
||||||
| '/providers'
|
| '/providers'
|
||||||
|
| '/renewals'
|
||||||
| '/reports'
|
| '/reports'
|
||||||
| '/resources'
|
| '/resources'
|
||||||
| '/settings'
|
| '/settings'
|
||||||
| '/sync-journal'
|
| '/sync-journal'
|
||||||
| '/tariffs'
|
| '/tariffs'
|
||||||
| '/vps'
|
| '/vps'
|
||||||
|
| '/vps/$vpsId'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to:
|
to:
|
||||||
| '/'
|
| '/'
|
||||||
| '/accounts'
|
| '/accounts'
|
||||||
|
| '/audit'
|
||||||
| '/balance'
|
| '/balance'
|
||||||
| '/dashboard'
|
| '/dashboard'
|
||||||
| '/payments'
|
| '/payments'
|
||||||
| '/projects'
|
| '/projects'
|
||||||
| '/providers'
|
| '/providers'
|
||||||
|
| '/renewals'
|
||||||
| '/reports'
|
| '/reports'
|
||||||
| '/resources'
|
| '/resources'
|
||||||
| '/settings'
|
| '/settings'
|
||||||
| '/sync-journal'
|
| '/sync-journal'
|
||||||
| '/tariffs'
|
| '/tariffs'
|
||||||
| '/vps'
|
| '/vps'
|
||||||
|
| '/vps/$vpsId'
|
||||||
id:
|
id:
|
||||||
| '__root__'
|
| '__root__'
|
||||||
| '/'
|
| '/'
|
||||||
| '/_auth'
|
| '/_auth'
|
||||||
| '/_auth/accounts'
|
| '/_auth/accounts'
|
||||||
|
| '/_auth/audit'
|
||||||
| '/_auth/balance'
|
| '/_auth/balance'
|
||||||
| '/_auth/dashboard'
|
| '/_auth/dashboard'
|
||||||
| '/_auth/payments'
|
| '/_auth/payments'
|
||||||
| '/_auth/projects'
|
| '/_auth/projects'
|
||||||
| '/_auth/providers'
|
| '/_auth/providers'
|
||||||
|
| '/_auth/renewals'
|
||||||
| '/_auth/reports'
|
| '/_auth/reports'
|
||||||
| '/_auth/resources'
|
| '/_auth/resources'
|
||||||
| '/_auth/settings'
|
| '/_auth/settings'
|
||||||
| '/_auth/sync-journal'
|
| '/_auth/sync-journal'
|
||||||
| '/_auth/tariffs'
|
| '/_auth/tariffs'
|
||||||
| '/_auth/vps'
|
| '/_auth/vps'
|
||||||
|
| '/_auth/vps/$vpsId'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
@@ -253,6 +289,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AuthReportsRouteImport
|
preLoaderRoute: typeof AuthReportsRouteImport
|
||||||
parentRoute: typeof AuthRoute
|
parentRoute: typeof AuthRoute
|
||||||
}
|
}
|
||||||
|
'/_auth/renewals': {
|
||||||
|
id: '/_auth/renewals'
|
||||||
|
path: '/renewals'
|
||||||
|
fullPath: '/renewals'
|
||||||
|
preLoaderRoute: typeof AuthRenewalsRouteImport
|
||||||
|
parentRoute: typeof AuthRoute
|
||||||
|
}
|
||||||
'/_auth/providers': {
|
'/_auth/providers': {
|
||||||
id: '/_auth/providers'
|
id: '/_auth/providers'
|
||||||
path: '/providers'
|
path: '/providers'
|
||||||
@@ -288,6 +331,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AuthBalanceRouteImport
|
preLoaderRoute: typeof AuthBalanceRouteImport
|
||||||
parentRoute: typeof AuthRoute
|
parentRoute: typeof AuthRoute
|
||||||
}
|
}
|
||||||
|
'/_auth/audit': {
|
||||||
|
id: '/_auth/audit'
|
||||||
|
path: '/audit'
|
||||||
|
fullPath: '/audit'
|
||||||
|
preLoaderRoute: typeof AuthAuditRouteImport
|
||||||
|
parentRoute: typeof AuthRoute
|
||||||
|
}
|
||||||
'/_auth/accounts': {
|
'/_auth/accounts': {
|
||||||
id: '/_auth/accounts'
|
id: '/_auth/accounts'
|
||||||
path: '/accounts'
|
path: '/accounts'
|
||||||
@@ -295,37 +345,59 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AuthAccountsRouteImport
|
preLoaderRoute: typeof AuthAccountsRouteImport
|
||||||
parentRoute: typeof AuthRoute
|
parentRoute: typeof AuthRoute
|
||||||
}
|
}
|
||||||
|
'/_auth/vps/$vpsId': {
|
||||||
|
id: '/_auth/vps/$vpsId'
|
||||||
|
path: '/$vpsId'
|
||||||
|
fullPath: '/vps/$vpsId'
|
||||||
|
preLoaderRoute: typeof AuthVpsVpsIdRouteImport
|
||||||
|
parentRoute: typeof AuthVpsRoute
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AuthVpsRouteChildren {
|
||||||
|
AuthVpsVpsIdRoute: typeof AuthVpsVpsIdRoute
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthVpsRouteChildren: AuthVpsRouteChildren = {
|
||||||
|
AuthVpsVpsIdRoute: AuthVpsVpsIdRoute,
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthVpsRouteWithChildren =
|
||||||
|
AuthVpsRoute._addFileChildren(AuthVpsRouteChildren)
|
||||||
|
|
||||||
interface AuthRouteChildren {
|
interface AuthRouteChildren {
|
||||||
AuthAccountsRoute: typeof AuthAccountsRoute
|
AuthAccountsRoute: typeof AuthAccountsRoute
|
||||||
|
AuthAuditRoute: typeof AuthAuditRoute
|
||||||
AuthBalanceRoute: typeof AuthBalanceRoute
|
AuthBalanceRoute: typeof AuthBalanceRoute
|
||||||
AuthDashboardRoute: typeof AuthDashboardRoute
|
AuthDashboardRoute: typeof AuthDashboardRoute
|
||||||
AuthPaymentsRoute: typeof AuthPaymentsRoute
|
AuthPaymentsRoute: typeof AuthPaymentsRoute
|
||||||
AuthProjectsRoute: typeof AuthProjectsRoute
|
AuthProjectsRoute: typeof AuthProjectsRoute
|
||||||
AuthProvidersRoute: typeof AuthProvidersRoute
|
AuthProvidersRoute: typeof AuthProvidersRoute
|
||||||
|
AuthRenewalsRoute: typeof AuthRenewalsRoute
|
||||||
AuthReportsRoute: typeof AuthReportsRoute
|
AuthReportsRoute: typeof AuthReportsRoute
|
||||||
AuthResourcesRoute: typeof AuthResourcesRoute
|
AuthResourcesRoute: typeof AuthResourcesRoute
|
||||||
AuthSettingsRoute: typeof AuthSettingsRoute
|
AuthSettingsRoute: typeof AuthSettingsRoute
|
||||||
AuthSyncJournalRoute: typeof AuthSyncJournalRoute
|
AuthSyncJournalRoute: typeof AuthSyncJournalRoute
|
||||||
AuthTariffsRoute: typeof AuthTariffsRoute
|
AuthTariffsRoute: typeof AuthTariffsRoute
|
||||||
AuthVpsRoute: typeof AuthVpsRoute
|
AuthVpsRoute: typeof AuthVpsRouteWithChildren
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthRouteChildren: AuthRouteChildren = {
|
const AuthRouteChildren: AuthRouteChildren = {
|
||||||
AuthAccountsRoute: AuthAccountsRoute,
|
AuthAccountsRoute: AuthAccountsRoute,
|
||||||
|
AuthAuditRoute: AuthAuditRoute,
|
||||||
AuthBalanceRoute: AuthBalanceRoute,
|
AuthBalanceRoute: AuthBalanceRoute,
|
||||||
AuthDashboardRoute: AuthDashboardRoute,
|
AuthDashboardRoute: AuthDashboardRoute,
|
||||||
AuthPaymentsRoute: AuthPaymentsRoute,
|
AuthPaymentsRoute: AuthPaymentsRoute,
|
||||||
AuthProjectsRoute: AuthProjectsRoute,
|
AuthProjectsRoute: AuthProjectsRoute,
|
||||||
AuthProvidersRoute: AuthProvidersRoute,
|
AuthProvidersRoute: AuthProvidersRoute,
|
||||||
|
AuthRenewalsRoute: AuthRenewalsRoute,
|
||||||
AuthReportsRoute: AuthReportsRoute,
|
AuthReportsRoute: AuthReportsRoute,
|
||||||
AuthResourcesRoute: AuthResourcesRoute,
|
AuthResourcesRoute: AuthResourcesRoute,
|
||||||
AuthSettingsRoute: AuthSettingsRoute,
|
AuthSettingsRoute: AuthSettingsRoute,
|
||||||
AuthSyncJournalRoute: AuthSyncJournalRoute,
|
AuthSyncJournalRoute: AuthSyncJournalRoute,
|
||||||
AuthTariffsRoute: AuthTariffsRoute,
|
AuthTariffsRoute: AuthTariffsRoute,
|
||||||
AuthVpsRoute: AuthVpsRoute,
|
AuthVpsRoute: AuthVpsRouteWithChildren,
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthRouteWithChildren = AuthRoute._addFileChildren(AuthRouteChildren)
|
const AuthRouteWithChildren = AuthRoute._addFileChildren(AuthRouteChildren)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
RefreshCwIcon,
|
RefreshCwIcon,
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
WalletIcon,
|
WalletIcon,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
import { snapshotQueryOptions } from '@/queries/snapshot'
|
import { snapshotQueryOptions } from '@/queries/snapshot'
|
||||||
import { api, ApiError } from '@/lib/api-client'
|
import { api, ApiError } from '@/lib/api-client'
|
||||||
@@ -21,6 +22,7 @@ import type { DataTableColumn } from '@/components/data-grid-types'
|
|||||||
import { dataGridCellStack } from '@/components/data-grid-cells'
|
import { dataGridCellStack } from '@/components/data-grid-cells'
|
||||||
import { CrudListPage } from '@/components/crud-list-page'
|
import { CrudListPage } from '@/components/crud-list-page'
|
||||||
import { RowActions } from '@/components/row-actions'
|
import { RowActions } from '@/components/row-actions'
|
||||||
|
import { HealthModeBanner } from '@/components/health-mode-banner'
|
||||||
import {
|
import {
|
||||||
ProviderAccountEditSheet,
|
ProviderAccountEditSheet,
|
||||||
providerAccountFormDefaults,
|
providerAccountFormDefaults,
|
||||||
@@ -30,14 +32,24 @@ import { accountBalanceApi, accountBalanceCurrency } from '@/lib/account'
|
|||||||
import type { ProviderAccount } from '@/types/entities'
|
import type { ProviderAccount } from '@/types/entities'
|
||||||
import { providerByIdMap, accountBillmanagerUiReady, billmanagerSyncableAccounts } from '@/lib/billmanager'
|
import { providerByIdMap, accountBillmanagerUiReady, billmanagerSyncableAccounts } from '@/lib/billmanager'
|
||||||
import { billingModeLabel, formatCurrency } from '@/lib/format'
|
import { billingModeLabel, formatCurrency } from '@/lib/format'
|
||||||
|
import {
|
||||||
|
getBalanceMismatchAccountIds,
|
||||||
|
getStaleSyncAccountIds,
|
||||||
|
} from '@/lib/inventory-health'
|
||||||
|
|
||||||
|
const accountsSearchSchema = z.object({
|
||||||
|
health: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/accounts')({
|
export const Route = createFileRoute('/_auth/accounts')({
|
||||||
|
validateSearch: (search) => accountsSearchSchema.parse(search),
|
||||||
loader: ({ context: { queryClient } }) =>
|
loader: ({ context: { queryClient } }) =>
|
||||||
queryClient.ensureQueryData(snapshotQueryOptions()),
|
queryClient.ensureQueryData(snapshotQueryOptions()),
|
||||||
component: AccountsPage,
|
component: AccountsPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
function AccountsPage() {
|
function AccountsPage() {
|
||||||
|
const { health } = Route.useSearch()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
|
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
@@ -129,6 +141,22 @@ function AccountsPage() {
|
|||||||
? billmanagerSyncableAccounts(snapshot.providerAccounts, snapshot.providers).length
|
? billmanagerSyncableAccounts(snapshot.providerAccounts, snapshot.providers).length
|
||||||
: 0
|
: 0
|
||||||
|
|
||||||
|
const filteredAccounts = useMemo(() => {
|
||||||
|
const accounts = snapshot?.providerAccounts ?? []
|
||||||
|
if (!health || !snapshot) return accounts
|
||||||
|
if (health === 'stale-sync') {
|
||||||
|
const ids = new Set(
|
||||||
|
getStaleSyncAccountIds(snapshot.providerAccounts, snapshot.providers, snapshot.syncLog ?? []),
|
||||||
|
)
|
||||||
|
return accounts.filter((a) => ids.has(a.id))
|
||||||
|
}
|
||||||
|
if (health === 'balance-mismatch') {
|
||||||
|
const ids = new Set(getBalanceMismatchAccountIds(snapshot.providerAccounts, snapshot.balanceLedger))
|
||||||
|
return accounts.filter((a) => ids.has(a.id))
|
||||||
|
}
|
||||||
|
return accounts
|
||||||
|
}, [snapshot, health])
|
||||||
|
|
||||||
const columns: DataTableColumn<ProviderAccount>[] = [
|
const columns: DataTableColumn<ProviderAccount>[] = [
|
||||||
{
|
{
|
||||||
key: 'name',
|
key: 'name',
|
||||||
@@ -250,17 +278,22 @@ function AccountsPage() {
|
|||||||
providers={snapshot.providers}
|
providers={snapshot.providers}
|
||||||
onSubmit={(values) => saveMut.mutate(values)}
|
onSubmit={(values) => saveMut.mutate(values)}
|
||||||
submitting={saveMut.isPending}
|
submitting={saveMut.isPending}
|
||||||
|
onBalanceRefreshed={() => void queryClient.invalidateQueries({ queryKey: ['snapshot'] })}
|
||||||
/>
|
/>
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{(snap) => (
|
{( ) => (
|
||||||
<DataGridCard
|
<div className="flex flex-col gap-4">
|
||||||
columns={columnDefFromDataTable(columns)}
|
{health ? <HealthModeBanner health={health} exitTo="/accounts" /> : null}
|
||||||
data={snap.providerAccounts}
|
<DataGridCard
|
||||||
rowId={(a) => a.id}
|
columns={columnDefFromDataTable(columns)}
|
||||||
pinLastColumn
|
data={filteredAccounts}
|
||||||
/>
|
rowId={(a) => a.id}
|
||||||
|
pinLastColumn
|
||||||
|
emptyTitle={health ? 'Нет аккаунтов с этой проблемой' : 'Нет записей'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</CrudListPage>
|
</CrudListPage>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { HistoryIcon } from 'lucide-react'
|
||||||
|
|
||||||
|
import { api } from '@/lib/api-client'
|
||||||
|
import { PageShell } from '@/components/page-shell'
|
||||||
|
import { PageHeader } from '@/components/page-header'
|
||||||
|
import { QueryState } from '@/components/query-state'
|
||||||
|
import { DataGridCard, columnDefFromDataTable } from '@/components/data-grid-card'
|
||||||
|
import type { DataTableColumn } from '@/components/data-grid-types'
|
||||||
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
|
import { Badge } from '@cfdm/ui/components/badge'
|
||||||
|
|
||||||
|
interface AuditRow {
|
||||||
|
id: string
|
||||||
|
entity: string
|
||||||
|
entityId: string
|
||||||
|
action: string
|
||||||
|
diff: Record<string, unknown> | null
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACTION_LABELS: Record<string, string> = {
|
||||||
|
create: 'Создание',
|
||||||
|
update: 'Изменение',
|
||||||
|
delete: 'Удаление',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_auth/audit')({
|
||||||
|
component: AuditPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
function AuditPage() {
|
||||||
|
const { data, isLoading, isError, error, refetch } = useQuery({
|
||||||
|
queryKey: ['audit'],
|
||||||
|
queryFn: () => api.fetchAuditLog(200),
|
||||||
|
})
|
||||||
|
|
||||||
|
const columns: DataTableColumn<AuditRow>[] = [
|
||||||
|
{
|
||||||
|
key: 'createdAt',
|
||||||
|
header: 'Время',
|
||||||
|
icon: HistoryIcon,
|
||||||
|
sortValue: (r) => r.createdAt,
|
||||||
|
cell: (r) => (
|
||||||
|
<span className="tabular-nums text-muted-foreground">
|
||||||
|
{new Date(r.createdAt).toLocaleString('ru-RU')}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'entity',
|
||||||
|
header: 'Сущность',
|
||||||
|
cell: (r) => <Badge variant="outline">{r.entity}</Badge>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'action',
|
||||||
|
header: 'Действие',
|
||||||
|
cell: (r) => ACTION_LABELS[r.action] ?? r.action,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'entityId',
|
||||||
|
header: 'ID',
|
||||||
|
cell: (r) =>
|
||||||
|
r.entity === 'vps' ? (
|
||||||
|
<Button variant="link" className="h-auto p-0" render={<Link to="/vps/$vpsId" params={{ vpsId: r.entityId }} />}>
|
||||||
|
{r.entityId}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<span className="font-mono text-xs">{r.entityId}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'diff',
|
||||||
|
header: 'Изменения',
|
||||||
|
sortable: false,
|
||||||
|
cell: (r) => (
|
||||||
|
<span className="max-w-md truncate text-xs text-muted-foreground">
|
||||||
|
{r.diff ? JSON.stringify(r.diff) : '—'}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageShell>
|
||||||
|
<PageHeader title="Журнал изменений" description="История ручных правок через API" />
|
||||||
|
<QueryState
|
||||||
|
data={data}
|
||||||
|
isLoading={isLoading}
|
||||||
|
isError={isError}
|
||||||
|
error={error}
|
||||||
|
onRetry={() => refetch()}
|
||||||
|
empty={!data?.length}
|
||||||
|
emptyTitle="Записей нет"
|
||||||
|
emptyDescription="Изменения VPS появятся здесь после CRUD-операций"
|
||||||
|
>
|
||||||
|
{(rows) => (
|
||||||
|
<DataGridCard
|
||||||
|
columns={columnDefFromDataTable(columns)}
|
||||||
|
data={rows as AuditRow[]}
|
||||||
|
rowId={(r) => r.id}
|
||||||
|
pageSize={25}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</QueryState>
|
||||||
|
</PageShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -33,10 +33,15 @@ function PaymentsPage() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const saveMut = useMutation({
|
const saveMut = useMutation({
|
||||||
mutationFn: (r: PaymentFormValues) =>
|
mutationFn: (r: PaymentFormValues) => {
|
||||||
r.id
|
const payload = {
|
||||||
? api.update<Payment>('payments', r.id, r as unknown as Partial<Payment>)
|
...r,
|
||||||
: api.create('payments', r as unknown as Payment),
|
vpsId: r.vpsId?.trim() || undefined,
|
||||||
|
}
|
||||||
|
return r.id
|
||||||
|
? api.update<Payment>('payments', r.id, payload as unknown as Partial<Payment>)
|
||||||
|
: api.create('payments', payload as unknown as Payment)
|
||||||
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
void queryClient.invalidateQueries({ queryKey: ['snapshot'] })
|
void queryClient.invalidateQueries({ queryKey: ['snapshot'] })
|
||||||
toast.success('Платёж сохранён')
|
toast.success('Платёж сохранён')
|
||||||
@@ -66,6 +71,7 @@ function PaymentsPage() {
|
|||||||
amount: p.amount,
|
amount: p.amount,
|
||||||
currency: p.currency,
|
currency: p.currency,
|
||||||
providerAccountId: p.providerAccountId,
|
providerAccountId: p.providerAccountId,
|
||||||
|
vpsId: (p as Payment & { vpsId?: string }).vpsId ?? '',
|
||||||
note: p.note ?? '',
|
note: p.note ?? '',
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -174,6 +180,7 @@ function PaymentsPage() {
|
|||||||
defaultValues={formDefaults}
|
defaultValues={formDefaults}
|
||||||
providerAccounts={snapshot.providerAccounts}
|
providerAccounts={snapshot.providerAccounts}
|
||||||
providers={snapshot.providers}
|
providers={snapshot.providers}
|
||||||
|
vpsRows={snapshot.vps}
|
||||||
onSubmit={(values) => saveMut.mutate(values)}
|
onSubmit={(values) => saveMut.mutate(values)}
|
||||||
submitting={saveMut.isPending}
|
submitting={saveMut.isPending}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { PlusIcon, FolderKanbanIcon } from 'lucide-react'
|
import { PlusIcon, FolderKanbanIcon } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
@@ -9,13 +9,16 @@ import { api, ApiError } from '@/lib/api-client'
|
|||||||
import { DataGridCard, columnDefFromDataTable } from '@/components/data-grid-card'
|
import { DataGridCard, columnDefFromDataTable } from '@/components/data-grid-card'
|
||||||
import type { DataTableColumn } from '@/components/data-grid-types'
|
import type { DataTableColumn } from '@/components/data-grid-types'
|
||||||
import { CrudListPage } from '@/components/crud-list-page'
|
import { CrudListPage } from '@/components/crud-list-page'
|
||||||
|
import { RowActions } from '@/components/row-actions'
|
||||||
import { Button } from '@cfdm/ui/components/button'
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
import { ProjectEditSheet } from '@/components/domain/project-edit-sheet'
|
import { Badge } from '@cfdm/ui/components/badge'
|
||||||
|
import { ProjectEditSheet, projectFormDefaults } from '@/components/domain/project-edit-sheet'
|
||||||
import type { ProjectFormValues } from '@/lib/schemas'
|
import type { ProjectFormValues } from '@/lib/schemas'
|
||||||
|
|
||||||
interface ProjectRow {
|
interface ProjectRow {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
|
color?: string | null
|
||||||
vpsCount: number
|
vpsCount: number
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,29 +32,72 @@ function ProjectsPage() {
|
|||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
|
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
const [formDefaults, setFormDefaults] = useState<ProjectFormValues>(projectFormDefaults())
|
||||||
|
|
||||||
const createMut = useMutation({
|
const saveMut = useMutation({
|
||||||
mutationFn: (values: ProjectFormValues) => api.createProject(values.name.trim()),
|
mutationFn: (values: ProjectFormValues) => {
|
||||||
|
const color = values.color?.trim() || null
|
||||||
|
if (values.id) {
|
||||||
|
return api.updateProject(values.id, { name: values.name.trim(), color })
|
||||||
|
}
|
||||||
|
return api.createProject(values.name.trim())
|
||||||
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
void queryClient.invalidateQueries({ queryKey: ['snapshot'] })
|
void queryClient.invalidateQueries({ queryKey: ['snapshot'] })
|
||||||
toast.success('Проект создан')
|
toast.success('Проект сохранён')
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
},
|
},
|
||||||
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка'),
|
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка'),
|
||||||
})
|
})
|
||||||
|
|
||||||
const rows: ProjectRow[] = (snapshot?.serverProjects ?? []).map((p) => {
|
const delMut = useMutation({
|
||||||
const row = p as { id: string; name: string }
|
mutationFn: (id: string) => api.deleteProject(id),
|
||||||
const vpsCount = (snapshot?.vps ?? []).filter((v) => v.project === row.name).length
|
onSuccess: () => {
|
||||||
return { id: row.id, name: row.name, vpsCount }
|
void queryClient.invalidateQueries({ queryKey: ['snapshot'] })
|
||||||
|
toast.success('Проект удалён')
|
||||||
|
},
|
||||||
|
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка'),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const rows: ProjectRow[] = useMemo(
|
||||||
|
() =>
|
||||||
|
(snapshot?.serverProjects ?? []).map((p) => {
|
||||||
|
const row = p as { id: string; name: string; color?: string | null }
|
||||||
|
const vpsCount = (snapshot?.vps ?? []).filter((v) => v.project === row.name).length
|
||||||
|
return { id: row.id, name: row.name, color: row.color, vpsCount }
|
||||||
|
}),
|
||||||
|
[snapshot],
|
||||||
|
)
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
setFormDefaults(projectFormDefaults())
|
||||||
|
setOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openEdit = (row: ProjectRow) => {
|
||||||
|
setFormDefaults(projectFormDefaults({ id: row.id, name: row.name, color: row.color ?? '' }))
|
||||||
|
setOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
const columns: DataTableColumn<ProjectRow>[] = [
|
const columns: DataTableColumn<ProjectRow>[] = [
|
||||||
{
|
{
|
||||||
key: 'name',
|
key: 'name',
|
||||||
header: 'Проект',
|
header: 'Проект',
|
||||||
icon: FolderKanbanIcon,
|
icon: FolderKanbanIcon,
|
||||||
cell: (row) => <span className="font-medium">{row.name}</span>,
|
cell: (row) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{row.color ? (
|
||||||
|
<span className="size-2.5 shrink-0 rounded-full" style={{ backgroundColor: row.color }} />
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
className="h-auto p-0 font-medium"
|
||||||
|
render={<Link to="/vps" search={{ project: row.name }} />}
|
||||||
|
>
|
||||||
|
{row.name}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'vps',
|
key: 'vps',
|
||||||
@@ -59,7 +105,29 @@ function ProjectsPage() {
|
|||||||
headerClassName: 'text-right',
|
headerClassName: 'text-right',
|
||||||
className: 'text-right tabular-nums',
|
className: 'text-right tabular-nums',
|
||||||
sortValue: (row) => row.vpsCount,
|
sortValue: (row) => row.vpsCount,
|
||||||
cell: (row) => row.vpsCount,
|
cell: (row) => (
|
||||||
|
<Badge variant="secondary">{row.vpsCount}</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
header: '',
|
||||||
|
sortable: false,
|
||||||
|
className: 'w-24 text-right',
|
||||||
|
cell: (row) => (
|
||||||
|
<RowActions
|
||||||
|
onEdit={() => openEdit(row)}
|
||||||
|
onDelete={() => {
|
||||||
|
if (row.vpsCount > 0) {
|
||||||
|
toast.error(`Нельзя удалить: к проекту привязано ${row.vpsCount} VPS`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
delMut.mutate(row.id)
|
||||||
|
}}
|
||||||
|
deleteTitle="Удалить проект?"
|
||||||
|
deleteDescription={`«${row.name}» будет удалён.`}
|
||||||
|
/>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -68,7 +136,7 @@ function ProjectsPage() {
|
|||||||
title="Проекты"
|
title="Проекты"
|
||||||
description="Группировка VPS по проектам"
|
description="Группировка VPS по проектам"
|
||||||
actions={
|
actions={
|
||||||
<Button onClick={() => setOpen(true)}>
|
<Button onClick={openCreate}>
|
||||||
<PlusIcon data-icon="inline-start" />
|
<PlusIcon data-icon="inline-start" />
|
||||||
Добавить
|
Добавить
|
||||||
</Button>
|
</Button>
|
||||||
@@ -82,7 +150,7 @@ function ProjectsPage() {
|
|||||||
emptyTitle="Проектов нет"
|
emptyTitle="Проектов нет"
|
||||||
emptyDescription="Создайте проект или назначьте его при редактировании VPS"
|
emptyDescription="Создайте проект или назначьте его при редактировании VPS"
|
||||||
emptyAction={
|
emptyAction={
|
||||||
<Button onClick={() => setOpen(true)}>
|
<Button onClick={openCreate}>
|
||||||
<PlusIcon data-icon="inline-start" />
|
<PlusIcon data-icon="inline-start" />
|
||||||
Создать проект
|
Создать проект
|
||||||
</Button>
|
</Button>
|
||||||
@@ -91,8 +159,9 @@ function ProjectsPage() {
|
|||||||
<ProjectEditSheet
|
<ProjectEditSheet
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={setOpen}
|
onOpenChange={setOpen}
|
||||||
onSubmit={(values) => createMut.mutate(values)}
|
defaultValues={formDefaults}
|
||||||
submitting={createMut.isPending}
|
onSubmit={(values) => saveMut.mutate(values)}
|
||||||
|
submitting={saveMut.isPending}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -101,6 +170,7 @@ function ProjectsPage() {
|
|||||||
columns={columnDefFromDataTable(columns)}
|
columns={columnDefFromDataTable(columns)}
|
||||||
data={rows}
|
data={rows}
|
||||||
rowId={(r) => r.id}
|
rowId={(r) => r.id}
|
||||||
|
pinLastColumn
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</CrudListPage>
|
</CrudListPage>
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { CalendarIcon } from 'lucide-react'
|
||||||
|
|
||||||
|
import { snapshotQueryOptions } from '@/queries/snapshot'
|
||||||
|
import { PageShell } from '@/components/page-shell'
|
||||||
|
import { PageHeader } from '@/components/page-header'
|
||||||
|
import { QueryState } from '@/components/query-state'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@cfdm/ui/components/card'
|
||||||
|
import { Badge } from '@cfdm/ui/components/badge'
|
||||||
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
|
import { SelectField } from '@/components/select-field'
|
||||||
|
import { getPaidUntilDate } from '@/lib/paid-until'
|
||||||
|
import { providerByIdMap } from '@/lib/billmanager'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_auth/renewals')({
|
||||||
|
loader: ({ context: { queryClient } }) =>
|
||||||
|
queryClient.ensureQueryData(snapshotQueryOptions()),
|
||||||
|
component: RenewalsPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
type Horizon = '7' | '30' | '90'
|
||||||
|
|
||||||
|
interface RenewalItem {
|
||||||
|
id: string
|
||||||
|
kind: 'vps'
|
||||||
|
label: string
|
||||||
|
sublabel: string
|
||||||
|
date: Date
|
||||||
|
overdue: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function RenewalsPage() {
|
||||||
|
const [horizon, setHorizon] = useState<Horizon>('30')
|
||||||
|
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
|
||||||
|
|
||||||
|
const items = useMemo(() => {
|
||||||
|
if (!snapshot) return []
|
||||||
|
const now = new Date()
|
||||||
|
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||||
|
const maxDays = Number(horizon)
|
||||||
|
const maxDate = new Date(todayStart)
|
||||||
|
maxDate.setDate(maxDate.getDate() + maxDays)
|
||||||
|
const ctx = {
|
||||||
|
vps: snapshot.vps,
|
||||||
|
providerAccounts: snapshot.providerAccounts,
|
||||||
|
payments: snapshot.payments,
|
||||||
|
balanceLedger: snapshot.balanceLedger,
|
||||||
|
now,
|
||||||
|
}
|
||||||
|
const providerById = providerByIdMap(snapshot.providers)
|
||||||
|
const list: RenewalItem[] = []
|
||||||
|
for (const v of snapshot.vps) {
|
||||||
|
if (v.status !== 'active') continue
|
||||||
|
const d = getPaidUntilDate(v, ctx)
|
||||||
|
if (!d) continue
|
||||||
|
if (d > maxDate) continue
|
||||||
|
const acc = snapshot.providerAccounts.find((a) => a.id === v.providerAccountId)
|
||||||
|
const providerName = acc ? providerById.get(acc.providerId)?.name ?? '' : ''
|
||||||
|
list.push({
|
||||||
|
id: v.id,
|
||||||
|
kind: 'vps',
|
||||||
|
label: v.ip || v.dns || v.id,
|
||||||
|
sublabel: [acc?.name, providerName].filter(Boolean).join(' · '),
|
||||||
|
date: d,
|
||||||
|
overdue: d < todayStart,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return list.sort((a, b) => a.date.getTime() - b.date.getTime())
|
||||||
|
}, [snapshot, horizon])
|
||||||
|
|
||||||
|
const grouped = useMemo(() => {
|
||||||
|
const map = new Map<string, RenewalItem[]>()
|
||||||
|
for (const item of items) {
|
||||||
|
const key = item.date.toLocaleDateString('ru-RU', { year: 'numeric', month: 'long', day: 'numeric' })
|
||||||
|
const arr = map.get(key) ?? []
|
||||||
|
arr.push(item)
|
||||||
|
map.set(key, arr)
|
||||||
|
}
|
||||||
|
return [...map.entries()]
|
||||||
|
}, [items])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageShell>
|
||||||
|
<PageHeader
|
||||||
|
title="Продления"
|
||||||
|
description="Календарь истечения оплаты VPS"
|
||||||
|
actions={
|
||||||
|
<SelectField
|
||||||
|
value={horizon}
|
||||||
|
onValueChange={(v) => setHorizon((v ?? '30') as Horizon)}
|
||||||
|
options={[
|
||||||
|
{ value: '7', label: '7 дней' },
|
||||||
|
{ value: '30', label: '30 дней' },
|
||||||
|
{ value: '90', label: '90 дней' },
|
||||||
|
]}
|
||||||
|
triggerClassName="w-36"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<QueryState
|
||||||
|
data={snapshot}
|
||||||
|
isLoading={isLoading}
|
||||||
|
isError={isError}
|
||||||
|
error={error}
|
||||||
|
onRetry={() => refetch()}
|
||||||
|
empty={items.length === 0}
|
||||||
|
emptyTitle="Нет продлений в выбранном периоде"
|
||||||
|
emptyDescription="Активные VPS с расчётной датой оплаты не найдены"
|
||||||
|
>
|
||||||
|
{() => (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{grouped.map(([weekLabel, weekItems]) => (
|
||||||
|
<Card key={weekLabel}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<CalendarIcon className="size-4" />
|
||||||
|
{weekLabel}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-2">
|
||||||
|
{weekItems.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className="flex flex-wrap items-center justify-between gap-2 rounded-md border px-3 py-2"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
className="h-auto justify-start p-0 font-medium"
|
||||||
|
render={<Link to="/vps/$vpsId" params={{ vpsId: item.id }} />}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Button>
|
||||||
|
<span className="text-xs text-muted-foreground">{item.sublabel}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{item.overdue ? <Badge variant="destructive">Просрочено</Badge> : null}
|
||||||
|
<span className="tabular-nums text-sm">{item.date.toLocaleDateString('ru-RU')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</QueryState>
|
||||||
|
</PageShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import { SectionCardsSkeleton } from '@/components/skeletons'
|
|||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@cfdm/ui/components/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@cfdm/ui/components/card'
|
||||||
import { FieldGroup } from '@cfdm/ui/components/field'
|
import { FieldGroup } from '@cfdm/ui/components/field'
|
||||||
import { Input } from '@cfdm/ui/components/input'
|
import { Input } from '@cfdm/ui/components/input'
|
||||||
|
import { Textarea } from '@cfdm/ui/components/textarea'
|
||||||
import { LoadingButton } from '@/components/loading-button'
|
import { LoadingButton } from '@/components/loading-button'
|
||||||
import { SelectField } from '@/components/select-field'
|
import { SelectField } from '@/components/select-field'
|
||||||
import { FormField } from '@/components/form-field'
|
import { FormField } from '@/components/form-field'
|
||||||
@@ -44,6 +45,15 @@ function settingsToFormValues(s: Settings): SettingsFormValues {
|
|||||||
notifyNewTariffsEnabled: s.notifyNewTariffsEnabled !== false,
|
notifyNewTariffsEnabled: s.notifyNewTariffsEnabled !== false,
|
||||||
notifyLowBalanceEnabled: s.notifyLowBalanceEnabled !== false,
|
notifyLowBalanceEnabled: s.notifyLowBalanceEnabled !== false,
|
||||||
notifySyncDigestEnabled: s.notifySyncDigestEnabled !== false,
|
notifySyncDigestEnabled: s.notifySyncDigestEnabled !== false,
|
||||||
|
notifyVpsDownEnabled: (s as Settings & { notifyVpsDownEnabled?: boolean }).notifyVpsDownEnabled !== false,
|
||||||
|
webhookUrl: (s as Settings & { webhookUrl?: string }).webhookUrl ?? '',
|
||||||
|
webhookEnabled: (s as Settings & { webhookEnabled?: boolean }).webhookEnabled === true,
|
||||||
|
customFieldsJson: JSON.stringify(
|
||||||
|
(s as Settings & { customFields?: unknown[] }).customFields ?? [],
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
telegramMessageThreadId: (s as Settings & { telegramMessageThreadId?: string }).telegramMessageThreadId ?? '',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,11 +96,20 @@ function SettingsPage() {
|
|||||||
|
|
||||||
const upsertMut = useMutation({
|
const upsertMut = useMutation({
|
||||||
mutationFn: (patch: SettingsFormValues) => {
|
mutationFn: (patch: SettingsFormValues) => {
|
||||||
if (current?.id) return api.update<Settings>('settings', current.id, patch)
|
const { customFieldsJson, ...rest } = patch
|
||||||
|
let customFields: unknown[] = []
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(customFieldsJson || '[]') as unknown
|
||||||
|
if (Array.isArray(parsed)) customFields = parsed
|
||||||
|
} catch {
|
||||||
|
throw new ApiError('Невалидный JSON в кастомных полях')
|
||||||
|
}
|
||||||
|
const payload = { ...rest, customFields }
|
||||||
|
if (current?.id) return api.update<Settings>('settings', current.id, payload)
|
||||||
return api.create<Settings>('settings', {
|
return api.create<Settings>('settings', {
|
||||||
id: 'settings-main',
|
id: 'settings-main',
|
||||||
ratesUrl: 'https://www.cbr-xml-daily.ru/latest.js',
|
ratesUrl: 'https://www.cbr-xml-daily.ru/latest.js',
|
||||||
...patch,
|
...payload,
|
||||||
} as Settings)
|
} as Settings)
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -173,6 +192,30 @@ function SettingsPage() {
|
|||||||
<UploadIcon data-icon="inline-start" />
|
<UploadIcon data-icon="inline-start" />
|
||||||
Импорт JSON
|
Импорт JSON
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
const input = document.createElement('input')
|
||||||
|
input.type = 'file'
|
||||||
|
input.accept = '.db,application/octet-stream'
|
||||||
|
input.onchange = async () => {
|
||||||
|
const file = input.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
try {
|
||||||
|
const buffer = await file.arrayBuffer()
|
||||||
|
await api.importBackupDatabase(buffer)
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['snapshot'] })
|
||||||
|
toast.success('Импорт SQLite выполнен')
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof ApiError ? e.message : 'Ошибка импорта')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
input.click()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<UploadIcon data-icon="inline-start" />
|
||||||
|
Импорт SQLite
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -259,6 +302,9 @@ function SettingsPage() {
|
|||||||
{...form.register('telegramBotToken')}
|
{...form.register('telegramBotToken')}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
<FormField label="Thread ID (топик)" htmlFor="set-tg-thread">
|
||||||
|
<Input id="set-tg-thread" placeholder="Необязательно" {...form.register('telegramMessageThreadId')} />
|
||||||
|
</FormField>
|
||||||
<LoadingButton
|
<LoadingButton
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -319,9 +365,53 @@ function SettingsPage() {
|
|||||||
<BoolSelect id="set-notify-tar" label="Новые тарифы" value={field.value ?? true} onChange={field.onChange} />
|
<BoolSelect id="set-notify-tar" label="Новые тарифы" value={field.value ?? true} onChange={field.onChange} />
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<Controller
|
||||||
|
control={form.control}
|
||||||
|
name="notifyVpsDownEnabled"
|
||||||
|
render={({ field }) => (
|
||||||
|
<BoolSelect id="set-notify-down" label="VPS недоступен" value={field.value ?? true} onChange={field.onChange} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card className="md:col-span-2">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Webhook</CardTitle>
|
||||||
|
<CardDescription>POST JSON при тех же событиях, что и Telegram</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<FieldGroup>
|
||||||
|
<Controller
|
||||||
|
control={form.control}
|
||||||
|
name="webhookEnabled"
|
||||||
|
render={({ field }) => (
|
||||||
|
<BoolSelect id="set-webhook" label="Webhook" value={field.value ?? false} onChange={field.onChange} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField label="Webhook URL" htmlFor="set-webhook-url" error={form.formState.errors.webhookUrl?.message}>
|
||||||
|
<Input id="set-webhook-url" placeholder="https://hooks.example.com/..." {...form.register('webhookUrl')} />
|
||||||
|
</FormField>
|
||||||
|
</FieldGroup>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="md:col-span-2">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Кастомные поля VPS</CardTitle>
|
||||||
|
<CardDescription>JSON-массив: {"{ \"key\", \"label\", \"type\": \"text|number|bool\" }"}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<FormField label="Схема полей" htmlFor="set-custom-fields" error={form.formState.errors.customFieldsJson?.message}>
|
||||||
|
<Textarea
|
||||||
|
id="set-custom-fields"
|
||||||
|
className="min-h-32 font-mono text-xs"
|
||||||
|
{...form.register('customFieldsJson')}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<LoadingButton
|
<LoadingButton
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { useMemo } from 'react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
import { snapshotQueryOptions } from '@/queries/snapshot'
|
import { snapshotQueryOptions } from '@/queries/snapshot'
|
||||||
import { api, ApiError } from '@/lib/api-client'
|
import { api, ApiError } from '@/lib/api-client'
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@cfdm/ui/components/alert'
|
||||||
import { Badge } from '@cfdm/ui/components/badge'
|
import { Badge } from '@cfdm/ui/components/badge'
|
||||||
import { DataGridCard, columnDefFromDataTable } from '@/components/data-grid-card'
|
import { DataGridCard, columnDefFromDataTable } from '@/components/data-grid-card'
|
||||||
import type { DataTableColumn } from '@/components/data-grid-types'
|
import type { DataTableColumn } from '@/components/data-grid-types'
|
||||||
@@ -15,6 +17,7 @@ import { ServerIcon, UserRoundIcon, CpuIcon, CoinsIcon, HardDriveIcon, RefreshCw
|
|||||||
import type { ActiveTariff } from '@/types/entities'
|
import type { ActiveTariff } from '@/types/entities'
|
||||||
import { providerByIdMap, accountSelectLabel, billmanagerSyncableAccounts } from '@/lib/billmanager'
|
import { providerByIdMap, accountSelectLabel, billmanagerSyncableAccounts } from '@/lib/billmanager'
|
||||||
import { formatCurrency } from '@/lib/format'
|
import { formatCurrency } from '@/lib/format'
|
||||||
|
import { computeTariffDiffs } from '@/lib/tariff-diff'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/tariffs')({
|
export const Route = createFileRoute('/_auth/tariffs')({
|
||||||
loader: ({ context: { queryClient } }) =>
|
loader: ({ context: { queryClient } }) =>
|
||||||
@@ -30,6 +33,11 @@ function TariffsPage() {
|
|||||||
? billmanagerSyncableAccounts(snapshot.providerAccounts, snapshot.providers).length
|
? billmanagerSyncableAccounts(snapshot.providerAccounts, snapshot.providers).length
|
||||||
: 0
|
: 0
|
||||||
|
|
||||||
|
const tariffDiffs = useMemo(
|
||||||
|
() => (snapshot ? computeTariffDiffs(snapshot.vps, snapshot.activeTariffs) : []),
|
||||||
|
[snapshot],
|
||||||
|
)
|
||||||
|
|
||||||
const syncTariffsMut = useMutation({
|
const syncTariffsMut = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
if (!snapshot) return { tariffsCount: 0 }
|
if (!snapshot) return { tariffsCount: 0 }
|
||||||
@@ -143,11 +151,29 @@ function TariffsPage() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{(snap) => (
|
{(snap) => (
|
||||||
<DataGridCard
|
<div className="flex flex-col gap-4">
|
||||||
columns={columnDefFromDataTable(columns)}
|
{tariffDiffs.length > 0 ? (
|
||||||
data={snap.activeTariffs}
|
<Alert>
|
||||||
rowId={(t) => t.id}
|
<AlertTitle>Расхождение тариф vs VPS ({tariffDiffs.length})</AlertTitle>
|
||||||
/>
|
<AlertDescription className="flex flex-col gap-1">
|
||||||
|
{tariffDiffs.slice(0, 5).map((d) => (
|
||||||
|
<span key={d.vpsId}>
|
||||||
|
<Link to="/vps/$vpsId" params={{ vpsId: d.vpsId }} className="underline">
|
||||||
|
{d.vpsLabel}
|
||||||
|
</Link>
|
||||||
|
{' '}({d.tariffName}): {d.issues.join('; ')}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{tariffDiffs.length > 5 ? <span>…и ещё {tariffDiffs.length - 5}</span> : null}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
<DataGridCard
|
||||||
|
columns={columnDefFromDataTable(columns)}
|
||||||
|
data={snap.activeTariffs}
|
||||||
|
rowId={(t) => t.id}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</CrudListPage>
|
</CrudListPage>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,249 @@
|
|||||||
|
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
import {
|
||||||
|
ArrowLeftIcon,
|
||||||
|
GlobeIcon,
|
||||||
|
CpuIcon,
|
||||||
|
CreditCardIcon,
|
||||||
|
RefreshCwIcon,
|
||||||
|
StickyNoteIcon,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
import { snapshotQueryOptions } from '@/queries/snapshot'
|
||||||
|
import { PageShell } from '@/components/page-shell'
|
||||||
|
import { PageHeader } from '@/components/page-header'
|
||||||
|
import { QueryState } from '@/components/query-state'
|
||||||
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
|
import { Badge } from '@cfdm/ui/components/badge'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@cfdm/ui/components/card'
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@cfdm/ui/components/tabs'
|
||||||
|
import { DataGridCard, columnDefFromDataTable } from '@/components/data-grid-card'
|
||||||
|
import type { DataTableColumn } from '@/components/data-grid-types'
|
||||||
|
import { getPaidUntilDate } from '@/lib/paid-until'
|
||||||
|
import {
|
||||||
|
effectiveVpsTariffCurrency,
|
||||||
|
formatCurrency,
|
||||||
|
formatInProviderCurrency,
|
||||||
|
tariffTypeLabel,
|
||||||
|
vpsStatusLabel,
|
||||||
|
paymentTypeLabel,
|
||||||
|
} from '@/lib/format'
|
||||||
|
import { providerByIdMap, accountSelectLabel } from '@/lib/billmanager'
|
||||||
|
import { VPS_SYNC_OVERRIDE_FIELDS, parseUserOverrides } from '@/lib/vps-sync-fields'
|
||||||
|
import type { Payment, Vps } from '@/types/entities'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_auth/vps/$vpsId')({
|
||||||
|
loader: ({ context: { queryClient } }) =>
|
||||||
|
queryClient.ensureQueryData(snapshotQueryOptions()),
|
||||||
|
component: VpsDetailPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-0.5 sm:flex-row sm:justify-between sm:gap-4">
|
||||||
|
<span className="text-sm text-muted-foreground">{label}</span>
|
||||||
|
<span className="text-sm font-medium">{value}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function VpsDetailPage() {
|
||||||
|
const { vpsId } = Route.useParams()
|
||||||
|
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
|
||||||
|
|
||||||
|
const vps = snapshot?.vps.find((v) => v.id === vpsId)
|
||||||
|
const providerById = snapshot ? providerByIdMap(snapshot.providers) : new Map()
|
||||||
|
const account = snapshot?.providerAccounts.find((a) => a.id === vps?.providerAccountId)
|
||||||
|
const provider = vps ? providerById.get(vps.providerId) : undefined
|
||||||
|
|
||||||
|
const paidUntil = useMemo(() => {
|
||||||
|
if (!vps || !snapshot) return null
|
||||||
|
return getPaidUntilDate(vps, {
|
||||||
|
vps: snapshot.vps,
|
||||||
|
providerAccounts: snapshot.providerAccounts,
|
||||||
|
payments: snapshot.payments,
|
||||||
|
balanceLedger: snapshot.balanceLedger,
|
||||||
|
now: new Date(),
|
||||||
|
})
|
||||||
|
}, [vps, snapshot])
|
||||||
|
|
||||||
|
const relatedPayments = useMemo(
|
||||||
|
() => (snapshot?.payments ?? []).filter((p) => p.vpsId === vpsId),
|
||||||
|
[snapshot, vpsId],
|
||||||
|
)
|
||||||
|
|
||||||
|
const overrides = vps ? parseUserOverrides((vps as Vps & { userOverrides?: unknown }).userOverrides) : []
|
||||||
|
|
||||||
|
const paymentColumns: DataTableColumn<Payment>[] = [
|
||||||
|
{ key: 'date', header: 'Дата', cell: (p) => <span className="tabular-nums">{p.date}</span> },
|
||||||
|
{ key: 'type', header: 'Тип', cell: (p) => paymentTypeLabel(p.type) },
|
||||||
|
{
|
||||||
|
key: 'amount',
|
||||||
|
header: 'Сумма',
|
||||||
|
className: 'text-right',
|
||||||
|
cell: (p) => <span className="tabular-nums">{formatCurrency(p.amount, p.currency)}</span>,
|
||||||
|
},
|
||||||
|
{ key: 'note', header: 'Заметка', cell: (p) => p.note || '—' },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageShell>
|
||||||
|
<PageHeader
|
||||||
|
title={vps ? (vps.ip || vps.dns || 'VPS') : 'VPS'}
|
||||||
|
description={account ? accountSelectLabel(account, providerById) : undefined}
|
||||||
|
actions={
|
||||||
|
<Button variant="outline" render={<Link to="/vps" search={{ edit: vpsId }} />}>
|
||||||
|
Редактировать
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button variant="ghost" size="sm" className="w-fit" render={<Link to="/vps" />}>
|
||||||
|
<ArrowLeftIcon data-icon="inline-start" />
|
||||||
|
К списку VPS
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<QueryState
|
||||||
|
data={vps}
|
||||||
|
isLoading={isLoading}
|
||||||
|
isError={isError}
|
||||||
|
error={error}
|
||||||
|
onRetry={() => refetch()}
|
||||||
|
empty={!isLoading && !vps}
|
||||||
|
emptyTitle="VPS не найден"
|
||||||
|
emptyDescription="Запись могла быть удалена"
|
||||||
|
emptyAction={
|
||||||
|
<Button render={<Link to="/vps" />}>К списку</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(row) => (
|
||||||
|
<Tabs defaultValue="overview">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="overview">Обзор</TabsTrigger>
|
||||||
|
<TabsTrigger value="finance">Финансы</TabsTrigger>
|
||||||
|
<TabsTrigger value="notes">Заметки</TabsTrigger>
|
||||||
|
<TabsTrigger value="sync">Синк</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="overview" className="flex flex-col gap-4">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Badge variant={row.status === 'active' ? 'default' : 'secondary'}>
|
||||||
|
{vpsStatusLabel(row.status)}
|
||||||
|
</Badge>
|
||||||
|
{row.project ? <Badge variant="outline">{row.project}</Badge> : null}
|
||||||
|
{row.environment ? <Badge variant="outline">{row.environment}</Badge> : null}
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<GlobeIcon className="size-4" />
|
||||||
|
Сеть
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-3">
|
||||||
|
<InfoRow label="IP" value={row.ip || '—'} />
|
||||||
|
<InfoRow label="DNS" value={row.dns || '—'} />
|
||||||
|
<InfoRow label="IPv6" value={(row as Vps & { ipv6?: string }).ipv6 || '—'} />
|
||||||
|
<InfoRow label="SSH порт" value={(row as Vps & { sshPort?: number }).sshPort ?? 22} />
|
||||||
|
<InfoRow label="Локация" value={[row.country, row.city].filter(Boolean).join(', ') || '—'} />
|
||||||
|
<InfoRow label="Дата-центр" value={row.datacenter || '—'} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<CpuIcon className="size-4" />
|
||||||
|
Ресурсы
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-3">
|
||||||
|
<InfoRow label="vCPU" value={row.vcpu} />
|
||||||
|
<InfoRow label="RAM" value={`${row.ramGb} GB`} />
|
||||||
|
<InfoRow label="Disk" value={`${row.diskGb} GB`} />
|
||||||
|
<InfoRow label="ОС" value={(row as Vps & { os?: string }).os || '—'} />
|
||||||
|
<InfoRow label="Хостер" value={provider?.name || '—'} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="finance" className="flex flex-col gap-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<CreditCardIcon className="size-4" />
|
||||||
|
Тариф
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-3">
|
||||||
|
<InfoRow label="Тип" value={tariffTypeLabel(row.tariffType)} />
|
||||||
|
<InfoRow
|
||||||
|
label="Ставка"
|
||||||
|
value={formatInProviderCurrency(
|
||||||
|
row.tariffType === 'daily' ? Number(row.dailyRate || 0) * 30 : Number(row.monthlyRate || 0),
|
||||||
|
effectiveVpsTariffCurrency(row, provider),
|
||||||
|
provider,
|
||||||
|
snapshot?.settings ?? [],
|
||||||
|
null,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<InfoRow
|
||||||
|
label="Оплачено до"
|
||||||
|
value={paidUntil ? paidUntil.toLocaleDateString('ru-RU') : '—'}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<DataGridCard
|
||||||
|
title="Связанные платежи"
|
||||||
|
columns={columnDefFromDataTable(paymentColumns)}
|
||||||
|
data={relatedPayments}
|
||||||
|
rowId={(p) => p.id}
|
||||||
|
emptyTitle="Платежей нет"
|
||||||
|
pagination={false}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="notes">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<StickyNoteIcon className="size-4" />
|
||||||
|
Заметки
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="whitespace-pre-wrap text-sm">{row.notes?.trim() || 'Нет заметок'}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="sync">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<RefreshCwIcon className="size-4" />
|
||||||
|
Защита от перезаписи при синке
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{overrides.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Все поля обновляются из BILLmanager</p>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col gap-1 text-sm">
|
||||||
|
{overrides.map((key) => {
|
||||||
|
const label = VPS_SYNC_OVERRIDE_FIELDS.find((f) => f.key === key)?.label ?? key
|
||||||
|
return <li key={key}>• {label}</li>
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
)}
|
||||||
|
</QueryState>
|
||||||
|
</PageShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
import { createFileRoute, useNavigate, Link } from '@tanstack/react-router'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useState, useMemo, useEffect } from 'react'
|
import { useState, useMemo, useEffect } from 'react'
|
||||||
import { PlusIcon, GlobeIcon, UserRoundIcon, FolderKanbanIcon, CpuIcon, CircleDotIcon, CreditCardIcon, MapPinIcon } from 'lucide-react'
|
import { PlusIcon, GlobeIcon, UserRoundIcon, FolderKanbanIcon, CpuIcon, CircleDotIcon, CreditCardIcon, MapPinIcon, CalendarIcon, ActivityIcon } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { snapshotQueryOptions, ratesQueryOptions } from '@/queries/snapshot'
|
import { snapshotQueryOptions, ratesQueryOptions } from '@/queries/snapshot'
|
||||||
import { api, ApiError } from '@/lib/api-client'
|
import { api, ApiError } from '@/lib/api-client'
|
||||||
@@ -27,17 +27,21 @@ import {
|
|||||||
type VpsFiltersState,
|
type VpsFiltersState,
|
||||||
} from '@/components/vps-filters'
|
} from '@/components/vps-filters'
|
||||||
import { VpsFiltersToolbar } from '@/components/vps-filters-toolbar'
|
import { VpsFiltersToolbar } from '@/components/vps-filters-toolbar'
|
||||||
|
import { HealthModeBanner } from '@/components/health-mode-banner'
|
||||||
|
import { VpsBulkToolbar } from '@/components/domain/vps-bulk-toolbar'
|
||||||
|
|
||||||
import type { Vps } from '@/types/entities'
|
import type { Vps } from '@/types/entities'
|
||||||
import { providerByIdMap, accountSelectLabel } from '@/lib/billmanager'
|
import { providerByIdMap, accountSelectLabel } from '@/lib/billmanager'
|
||||||
import { COUNTRIES, COUNTRY_BY_NAME_RU, buildCityOptions } from '@cfdm/shared/geo'
|
import { COUNTRIES, COUNTRY_BY_NAME_RU, buildCityOptions } from '@cfdm/shared/geo'
|
||||||
import { getPaidUntilDate } from '@/lib/paid-until'
|
import { getPaidUntilDate } from '@/lib/paid-until'
|
||||||
|
import { parseCustomFieldDefs } from '@/lib/custom-fields'
|
||||||
|
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
const vpsSearchSchema = z.object({
|
const vpsSearchSchema = z.object({
|
||||||
health: z.string().optional(),
|
health: z.string().optional(),
|
||||||
edit: z.string().optional(),
|
edit: z.string().optional(),
|
||||||
|
project: z.string().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/vps')({
|
export const Route = createFileRoute('/_auth/vps')({
|
||||||
@@ -52,18 +56,27 @@ const EMPTY_FORM = VPS_FORM_EMPTY
|
|||||||
function VpsPage() {
|
function VpsPage() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { health, edit } = Route.useSearch()
|
const { health, edit, project: projectSearch } = Route.useSearch()
|
||||||
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
|
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
|
||||||
const [sheetOpen, setSheetOpen] = useState(false)
|
const [sheetOpen, setSheetOpen] = useState(false)
|
||||||
const [editingId, setEditingId] = useState<string | null>(null)
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
const [defaultValues, setDefaultValues] = useState<VpsFormValues>(EMPTY_FORM)
|
const [defaultValues, setDefaultValues] = useState<VpsFormValues>(EMPTY_FORM)
|
||||||
const [filters, setFilters] = useState<VpsFiltersState>(buildDefaultVpsFilters())
|
const [filters, setFilters] = useState<VpsFiltersState>(buildDefaultVpsFilters())
|
||||||
|
const [selectedIds, setSelectedIds] = useState<string[]>([])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!health) return
|
if (!health) return
|
||||||
setFilters((prev) => ({ ...prev, status: prev.status.length ? prev.status : ['active'] }))
|
setFilters((prev) => ({ ...prev, status: prev.status.length ? prev.status : ['active'] }))
|
||||||
}, [health])
|
}, [health])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!projectSearch) return
|
||||||
|
setFilters((prev) => ({
|
||||||
|
...prev,
|
||||||
|
project: prev.project.length ? prev.project : [projectSearch],
|
||||||
|
}))
|
||||||
|
}, [projectSearch])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!edit || !snapshot) return
|
if (!edit || !snapshot) return
|
||||||
const row = snapshot.vps.find((v) => v.id === edit)
|
const row = snapshot.vps.find((v) => v.id === edit)
|
||||||
@@ -109,6 +122,17 @@ function VpsPage() {
|
|||||||
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка удаления'),
|
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка удаления'),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const bulkMutation = useMutation({
|
||||||
|
mutationFn: (payload: { ids: string[]; action: string; value?: unknown }) =>
|
||||||
|
api.bulkUpdateVps(payload.ids, payload.action, payload.value),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['snapshot'] })
|
||||||
|
setSelectedIds([])
|
||||||
|
toast.success('Массовое действие выполнено')
|
||||||
|
},
|
||||||
|
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка'),
|
||||||
|
})
|
||||||
|
|
||||||
const openCreate = () => {
|
const openCreate = () => {
|
||||||
setEditingId(null)
|
setEditingId(null)
|
||||||
setDefaultValues({
|
setDefaultValues({
|
||||||
@@ -124,10 +148,14 @@ function VpsPage() {
|
|||||||
setSheetOpen(true)
|
setSheetOpen(true)
|
||||||
}
|
}
|
||||||
const submit = (values: VpsFormValues) => {
|
const submit = (values: VpsFormValues) => {
|
||||||
|
const payload = {
|
||||||
|
...values,
|
||||||
|
customData: JSON.stringify(values.customData ?? {}),
|
||||||
|
} as unknown as Partial<Vps>
|
||||||
if (editingId) {
|
if (editingId) {
|
||||||
void updateMutation.mutate({ id: editingId, patch: values as unknown as Partial<Vps> })
|
void updateMutation.mutate({ id: editingId, patch: payload })
|
||||||
} else {
|
} else {
|
||||||
void createMutation.mutate(values)
|
void createMutation.mutate(payload as unknown as VpsFormValues)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,7 +270,12 @@ function VpsPage() {
|
|||||||
header: 'IP / DNS',
|
header: 'IP / DNS',
|
||||||
icon: GlobeIcon,
|
icon: GlobeIcon,
|
||||||
sortValue: (v) => v.ip || v.dns || '',
|
sortValue: (v) => v.ip || v.dns || '',
|
||||||
cell: (v) => dataGridCellStack(v.ip || '—', v.dns || undefined),
|
cell: (v) => dataGridCellStack(
|
||||||
|
<Button variant="link" className="h-auto p-0 font-normal" render={<Link to="/vps/$vpsId" params={{ vpsId: v.id }} />}>
|
||||||
|
{v.ip || '—'}
|
||||||
|
</Button>,
|
||||||
|
v.dns || undefined,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'account',
|
key: 'account',
|
||||||
@@ -318,6 +351,54 @@ function VpsPage() {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'health',
|
||||||
|
header: 'Мониторинг',
|
||||||
|
icon: ActivityIcon,
|
||||||
|
sortable: false,
|
||||||
|
cell: (v) => {
|
||||||
|
const ext = v as Vps & { lastHealthStatus?: string; monitoringEnabled?: boolean }
|
||||||
|
if (!ext.monitoringEnabled) return <span className="text-muted-foreground">—</span>
|
||||||
|
if (ext.lastHealthStatus === 'up') return <Badge variant="default">up</Badge>
|
||||||
|
if (ext.lastHealthStatus === 'down') return <Badge variant="destructive">down</Badge>
|
||||||
|
return <Badge variant="outline">—</Badge>
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'paidUntil',
|
||||||
|
header: 'Оплачено до',
|
||||||
|
icon: CalendarIcon,
|
||||||
|
sortValue: (v) => {
|
||||||
|
if (!snapshot) return ''
|
||||||
|
const d = getPaidUntilDate(v, {
|
||||||
|
vps: snapshot.vps,
|
||||||
|
providerAccounts: snapshot.providerAccounts,
|
||||||
|
payments: snapshot.payments,
|
||||||
|
balanceLedger: snapshot.balanceLedger,
|
||||||
|
now: new Date(),
|
||||||
|
})
|
||||||
|
return d?.getTime() ?? 0
|
||||||
|
},
|
||||||
|
cell: (v) => {
|
||||||
|
if (!snapshot) return '—'
|
||||||
|
const d = getPaidUntilDate(v, {
|
||||||
|
vps: snapshot.vps,
|
||||||
|
providerAccounts: snapshot.providerAccounts,
|
||||||
|
payments: snapshot.payments,
|
||||||
|
balanceLedger: snapshot.balanceLedger,
|
||||||
|
now: new Date(),
|
||||||
|
})
|
||||||
|
if (!d) return <span className="text-muted-foreground">—</span>
|
||||||
|
const todayStart = new Date()
|
||||||
|
todayStart.setHours(0, 0, 0, 0)
|
||||||
|
const overdue = d < todayStart
|
||||||
|
return (
|
||||||
|
<span className={overdue ? 'text-destructive tabular-nums' : 'tabular-nums text-muted-foreground'}>
|
||||||
|
{d.toLocaleDateString('ru-RU')}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'actions',
|
key: 'actions',
|
||||||
header: '',
|
header: '',
|
||||||
@@ -396,6 +477,15 @@ function VpsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
|
{health ? <HealthModeBanner health={health} exitTo="/vps" /> : null}
|
||||||
|
<VpsBulkToolbar
|
||||||
|
selectedCount={selectedIds.length}
|
||||||
|
projectOptions={projectNameOptions}
|
||||||
|
busy={bulkMutation.isPending}
|
||||||
|
onSetStatus={(status) => bulkMutation.mutate({ ids: selectedIds, action: 'status', value: status })}
|
||||||
|
onSetProject={(project) => bulkMutation.mutate({ ids: selectedIds, action: 'project', value: project })}
|
||||||
|
onDelete={() => bulkMutation.mutate({ ids: selectedIds, action: 'delete' })}
|
||||||
|
/>
|
||||||
<VpsFiltersToolbar
|
<VpsFiltersToolbar
|
||||||
filters={filters}
|
filters={filters}
|
||||||
onChange={setFilters}
|
onChange={setFilters}
|
||||||
@@ -415,6 +505,8 @@ function VpsPage() {
|
|||||||
rowId={(v) => v.id}
|
rowId={(v) => v.id}
|
||||||
emptyTitle="VPS не найдены"
|
emptyTitle="VPS не найдены"
|
||||||
pinLastColumn
|
pinLastColumn
|
||||||
|
enableRowSelection
|
||||||
|
onRowSelectionChange={setSelectedIds}
|
||||||
virtualization={section.items.length > 200}
|
virtualization={section.items.length > 200}
|
||||||
height={560}
|
height={560}
|
||||||
/>
|
/>
|
||||||
@@ -434,6 +526,9 @@ function VpsPage() {
|
|||||||
providerAccounts={snapshot.providerAccounts}
|
providerAccounts={snapshot.providerAccounts}
|
||||||
vpsRows={snapshot.vps}
|
vpsRows={snapshot.vps}
|
||||||
formCountryOptions={formCountryOptions}
|
formCountryOptions={formCountryOptions}
|
||||||
|
customFieldDefs={parseCustomFieldDefs(
|
||||||
|
(snapshot.settings[0] as { customFields?: unknown })?.customFields,
|
||||||
|
)}
|
||||||
onSubmit={submit}
|
onSubmit={submit}
|
||||||
submitting={createMutation.isPending || updateMutation.isPending}
|
submitting={createMutation.isPending || updateMutation.isPending}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { drizzle, type BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
|
|||||||
import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
|
import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
|
||||||
import { dirname, resolve } from 'node:path'
|
import { dirname, resolve } from 'node:path'
|
||||||
import * as schema from './schema/index.js'
|
import * as schema from './schema/index.js'
|
||||||
|
import { ensureRuntimeSchema } from './runtime-migrate.js'
|
||||||
|
|
||||||
export type Db = BetterSQLite3Database<typeof schema>
|
export type Db = BetterSQLite3Database<typeof schema>
|
||||||
|
|
||||||
@@ -29,6 +30,7 @@ function openDatabase(): void {
|
|||||||
_sqlite.pragma('journal_mode = WAL')
|
_sqlite.pragma('journal_mode = WAL')
|
||||||
_sqlite.pragma('foreign_keys = ON')
|
_sqlite.pragma('foreign_keys = ON')
|
||||||
_db = drizzle(_sqlite, { schema })
|
_db = drizzle(_sqlite, { schema })
|
||||||
|
ensureRuntimeSchema(_sqlite)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSqlite(): Database.Database {
|
export function getSqlite(): Database.Database {
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { randomUUID } from 'node:crypto'
|
||||||
|
import { and, desc, eq } from 'drizzle-orm'
|
||||||
|
import { getDb, schema } from '../index.js'
|
||||||
|
|
||||||
|
export interface AuditEntryInput {
|
||||||
|
entity: string
|
||||||
|
entityId: string
|
||||||
|
action: 'create' | 'update' | 'delete'
|
||||||
|
diff?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDiff(row: { diff: string | null }) {
|
||||||
|
if (!row.diff) return null
|
||||||
|
try {
|
||||||
|
return JSON.parse(row.diff) as Record<string, unknown>
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const auditLogRepository = {
|
||||||
|
append(input: AuditEntryInput): void {
|
||||||
|
getDb()
|
||||||
|
.insert(schema.auditLog)
|
||||||
|
.values({
|
||||||
|
id: `audit-${randomUUID()}`,
|
||||||
|
entity: input.entity,
|
||||||
|
entityId: input.entityId,
|
||||||
|
action: input.action,
|
||||||
|
diff: input.diff ? JSON.stringify(input.diff) : null,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.run()
|
||||||
|
},
|
||||||
|
|
||||||
|
list(limit = 100) {
|
||||||
|
return getDb()
|
||||||
|
.select()
|
||||||
|
.from(schema.auditLog)
|
||||||
|
.orderBy(desc(schema.auditLog.createdAt))
|
||||||
|
.limit(Math.min(500, Math.max(1, limit)))
|
||||||
|
.all()
|
||||||
|
.map((row) => ({ ...row, diff: parseDiff(row) }))
|
||||||
|
},
|
||||||
|
|
||||||
|
listForEntity(entity: string, entityId: string, limit = 50) {
|
||||||
|
return getDb()
|
||||||
|
.select()
|
||||||
|
.from(schema.auditLog)
|
||||||
|
.where(and(eq(schema.auditLog.entity, entity), eq(schema.auditLog.entityId, entityId)))
|
||||||
|
.orderBy(desc(schema.auditLog.createdAt))
|
||||||
|
.limit(limit)
|
||||||
|
.all()
|
||||||
|
.map((row) => ({ ...row, diff: parseDiff(row) }))
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import { getDb, schema } from '../index.js'
|
|||||||
|
|
||||||
type Row = typeof schema.settings.$inferSelect
|
type Row = typeof schema.settings.$inferSelect
|
||||||
|
|
||||||
export type SettingsDto = Omit<Row, 'telegramBotToken' | 'autoConvert' | 'syncEnabled' | 'notifyPaymentExpiryEnabled' | 'notifyNewTariffsEnabled' | 'notifyLowBalanceEnabled' | 'notifySyncDigestEnabled' | 'customFields'> & {
|
export type SettingsDto = Omit<Row, 'telegramBotToken' | 'autoConvert' | 'syncEnabled' | 'notifyPaymentExpiryEnabled' | 'notifyNewTariffsEnabled' | 'notifyLowBalanceEnabled' | 'notifySyncDigestEnabled' | 'notifyVpsDownEnabled' | 'webhookEnabled' | 'customFields'> & {
|
||||||
telegramBotTokenSet: boolean
|
telegramBotTokenSet: boolean
|
||||||
autoConvert: boolean
|
autoConvert: boolean
|
||||||
syncEnabled: boolean
|
syncEnabled: boolean
|
||||||
@@ -11,6 +11,8 @@ export type SettingsDto = Omit<Row, 'telegramBotToken' | 'autoConvert' | 'syncEn
|
|||||||
notifyNewTariffsEnabled: boolean
|
notifyNewTariffsEnabled: boolean
|
||||||
notifyLowBalanceEnabled: boolean
|
notifyLowBalanceEnabled: boolean
|
||||||
notifySyncDigestEnabled: boolean
|
notifySyncDigestEnabled: boolean
|
||||||
|
notifyVpsDownEnabled: boolean
|
||||||
|
webhookEnabled: boolean
|
||||||
customFields: unknown[]
|
customFields: unknown[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,6 +36,8 @@ function toDto(row: Row | undefined): SettingsDto | undefined {
|
|||||||
notifyNewTariffsEnabled: Boolean(row.notifyNewTariffsEnabled),
|
notifyNewTariffsEnabled: Boolean(row.notifyNewTariffsEnabled),
|
||||||
notifyLowBalanceEnabled: Boolean(row.notifyLowBalanceEnabled),
|
notifyLowBalanceEnabled: Boolean(row.notifyLowBalanceEnabled),
|
||||||
notifySyncDigestEnabled: Boolean(row.notifySyncDigestEnabled),
|
notifySyncDigestEnabled: Boolean(row.notifySyncDigestEnabled),
|
||||||
|
notifyVpsDownEnabled: Boolean(row.notifyVpsDownEnabled),
|
||||||
|
webhookEnabled: Boolean(row.webhookEnabled),
|
||||||
customFields: Array.isArray(customFields) ? customFields : [],
|
customFields: Array.isArray(customFields) ? customFields : [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -60,6 +64,9 @@ interface SettingsInput {
|
|||||||
notifyNewTariffsEnabled?: boolean
|
notifyNewTariffsEnabled?: boolean
|
||||||
notifyLowBalanceEnabled?: boolean
|
notifyLowBalanceEnabled?: boolean
|
||||||
notifySyncDigestEnabled?: boolean
|
notifySyncDigestEnabled?: boolean
|
||||||
|
notifyVpsDownEnabled?: boolean
|
||||||
|
webhookUrl?: string
|
||||||
|
webhookEnabled?: boolean
|
||||||
customFields?: unknown
|
customFields?: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,6 +128,17 @@ function buildValues(id: string, existing: Row | undefined, r: SettingsInput) {
|
|||||||
: existing?.notifySyncDigestEnabled
|
: existing?.notifySyncDigestEnabled
|
||||||
? 1
|
? 1
|
||||||
: 0,
|
: 0,
|
||||||
|
notifyVpsDownEnabled:
|
||||||
|
r.notifyVpsDownEnabled !== undefined
|
||||||
|
? r.notifyVpsDownEnabled
|
||||||
|
? 1
|
||||||
|
: 0
|
||||||
|
: existing?.notifyVpsDownEnabled
|
||||||
|
? 1
|
||||||
|
: 0,
|
||||||
|
webhookUrl: r.webhookUrl !== undefined ? r.webhookUrl || '' : existing?.webhookUrl ?? '',
|
||||||
|
webhookEnabled:
|
||||||
|
r.webhookEnabled !== undefined ? (r.webhookEnabled ? 1 : 0) : existing?.webhookEnabled ? 1 : 0,
|
||||||
customFields: serializeCustomFields(r.customFields ?? existing?.customFields),
|
customFields: serializeCustomFields(r.customFields ?? existing?.customFields),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ interface VpsInput {
|
|||||||
paidUntil?: string
|
paidUntil?: string
|
||||||
notes?: string
|
notes?: string
|
||||||
userOverrides?: string[] | 'clear'
|
userOverrides?: string[] | 'clear'
|
||||||
|
customData?: string | Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
function projectColumnsForSave(projectInput: unknown): { project: string; projectId: string } {
|
function projectColumnsForSave(projectInput: unknown): { project: string; projectId: string } {
|
||||||
@@ -99,6 +100,12 @@ function boolToInt(v: unknown): number {
|
|||||||
return v ? 1 : 0
|
return v ? 1 : 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function serializeCustomData(v: unknown): string | null {
|
||||||
|
if (v == null) return null
|
||||||
|
if (typeof v === 'string') return v || null
|
||||||
|
return JSON.stringify(v)
|
||||||
|
}
|
||||||
|
|
||||||
export const vpsRepository = {
|
export const vpsRepository = {
|
||||||
list(): VpsDto[] {
|
list(): VpsDto[] {
|
||||||
const rows = getDb().select().from(schema.vps).orderBy(desc(schema.vps.createdAt)).all()
|
const rows = getDb().select().from(schema.vps).orderBy(desc(schema.vps.createdAt)).all()
|
||||||
@@ -153,6 +160,7 @@ export const vpsRepository = {
|
|||||||
userOverrides: input.userOverrides && Array.isArray(input.userOverrides)
|
userOverrides: input.userOverrides && Array.isArray(input.userOverrides)
|
||||||
? JSON.stringify(input.userOverrides)
|
? JSON.stringify(input.userOverrides)
|
||||||
: '[]',
|
: '[]',
|
||||||
|
customData: serializeCustomData(input.customData),
|
||||||
})
|
})
|
||||||
.run()
|
.run()
|
||||||
return this.get(finalId)!
|
return this.get(finalId)!
|
||||||
@@ -248,6 +256,9 @@ export const vpsRepository = {
|
|||||||
paidUntil: input.paidUntil ?? '',
|
paidUntil: input.paidUntil ?? '',
|
||||||
notes: input.notes ?? '',
|
notes: input.notes ?? '',
|
||||||
userOverrides: userOverridesJson,
|
userOverrides: userOverridesJson,
|
||||||
|
...(input.customData !== undefined
|
||||||
|
? { customData: serializeCustomData(input.customData) }
|
||||||
|
: {}),
|
||||||
})
|
})
|
||||||
.where(eq(schema.vps.id, id))
|
.where(eq(schema.vps.id, id))
|
||||||
.run()
|
.run()
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import type Database from 'better-sqlite3'
|
||||||
|
|
||||||
|
const COLUMN_MIGRATIONS: string[] = [
|
||||||
|
`ALTER TABLE vps ADD COLUMN customData TEXT`,
|
||||||
|
`ALTER TABLE vps ADD COLUMN last_health_status TEXT`,
|
||||||
|
`ALTER TABLE vps ADD COLUMN last_health_checked_at TEXT`,
|
||||||
|
`ALTER TABLE settings ADD COLUMN notifyVpsDownEnabled INTEGER`,
|
||||||
|
`ALTER TABLE settings ADD COLUMN webhookUrl TEXT`,
|
||||||
|
`ALTER TABLE settings ADD COLUMN webhookEnabled INTEGER`,
|
||||||
|
]
|
||||||
|
|
||||||
|
const TABLE_MIGRATIONS: string[] = [
|
||||||
|
`CREATE TABLE IF NOT EXISTS vps_health_checks (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
vpsId TEXT NOT NULL REFERENCES vps(id),
|
||||||
|
checkedAt TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
latencyMs INTEGER,
|
||||||
|
error TEXT
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS audit_log (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
entity TEXT NOT NULL,
|
||||||
|
entityId TEXT NOT NULL,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
diff TEXT,
|
||||||
|
createdAt TEXT NOT NULL
|
||||||
|
)`,
|
||||||
|
]
|
||||||
|
|
||||||
|
let migrated = false
|
||||||
|
|
||||||
|
export function ensureRuntimeSchema(sqlite: Database.Database): void {
|
||||||
|
if (migrated) return
|
||||||
|
for (const sql of TABLE_MIGRATIONS) {
|
||||||
|
sqlite.exec(sql)
|
||||||
|
}
|
||||||
|
for (const sql of COLUMN_MIGRATIONS) {
|
||||||
|
try {
|
||||||
|
sqlite.exec(sql)
|
||||||
|
} catch {
|
||||||
|
/* column exists */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
migrated = true
|
||||||
|
}
|
||||||
@@ -78,6 +78,9 @@ export const vps = sqliteTable('vps', {
|
|||||||
paidUntil: text('paidUntil'),
|
paidUntil: text('paidUntil'),
|
||||||
notes: text('notes'),
|
notes: text('notes'),
|
||||||
userOverrides: text('userOverrides'),
|
userOverrides: text('userOverrides'),
|
||||||
|
customData: text('customData'),
|
||||||
|
lastHealthStatus: text('last_health_status'),
|
||||||
|
lastHealthCheckedAt: text('last_health_checked_at'),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const payments = sqliteTable('payments', {
|
export const payments = sqliteTable('payments', {
|
||||||
@@ -120,6 +123,29 @@ export const settings = sqliteTable('settings', {
|
|||||||
telegramMessageThreadId: text('telegramMessageThreadId'),
|
telegramMessageThreadId: text('telegramMessageThreadId'),
|
||||||
notifyLowBalanceEnabled: integer('notifyLowBalanceEnabled'),
|
notifyLowBalanceEnabled: integer('notifyLowBalanceEnabled'),
|
||||||
notifySyncDigestEnabled: integer('notifySyncDigestEnabled'),
|
notifySyncDigestEnabled: integer('notifySyncDigestEnabled'),
|
||||||
|
notifyVpsDownEnabled: integer('notifyVpsDownEnabled'),
|
||||||
|
webhookUrl: text('webhookUrl'),
|
||||||
|
webhookEnabled: integer('webhookEnabled'),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const vpsHealthChecks = sqliteTable('vps_health_checks', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
vpsId: text('vpsId')
|
||||||
|
.notNull()
|
||||||
|
.references(() => vps.id),
|
||||||
|
checkedAt: text('checkedAt').notNull(),
|
||||||
|
status: text('status').notNull(),
|
||||||
|
latencyMs: integer('latencyMs'),
|
||||||
|
error: text('error'),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const auditLog = sqliteTable('audit_log', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
entity: text('entity').notNull(),
|
||||||
|
entityId: text('entityId').notNull(),
|
||||||
|
action: text('action').notNull(),
|
||||||
|
diff: text('diff'),
|
||||||
|
createdAt: text('createdAt').notNull(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const syncLog = sqliteTable('sync_log', {
|
export const syncLog = sqliteTable('sync_log', {
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ export const settingsSchema = z.object({
|
|||||||
notifyNewTariffsEnabled: z.boolean().optional(),
|
notifyNewTariffsEnabled: z.boolean().optional(),
|
||||||
notifyLowBalanceEnabled: z.boolean().optional(),
|
notifyLowBalanceEnabled: z.boolean().optional(),
|
||||||
notifySyncDigestEnabled: z.boolean().optional(),
|
notifySyncDigestEnabled: z.boolean().optional(),
|
||||||
|
notifyVpsDownEnabled: z.boolean().optional(),
|
||||||
|
webhookUrl: z.string().url('Невалидный URL').or(z.literal('')).optional(),
|
||||||
|
webhookEnabled: z.boolean().optional(),
|
||||||
customFields: z.any().optional(),
|
customFields: z.any().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export const vpsSchema = z.object({
|
|||||||
paidUntil: z.string().optional().default(''),
|
paidUntil: z.string().optional().default(''),
|
||||||
notes: z.string().optional().default(''),
|
notes: z.string().optional().default(''),
|
||||||
userOverrides: z.union([z.array(z.string()), z.literal('clear')]).optional(),
|
userOverrides: z.union([z.array(z.string()), z.literal('clear')]).optional(),
|
||||||
|
customData: z.union([z.string(), z.record(z.unknown())]).optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export type VpsFormValues = z.infer<typeof vpsSchema>
|
export type VpsFormValues = z.infer<typeof vpsSchema>
|
||||||
|
|||||||
Reference in New Issue
Block a user