feat(web): доработка UX/UI — дашборд, навигация и data foundation
Docker / build (push) Has been cancelled

Добавлены syncLog в snapshot, API статистики дашборда и маппинг цен тарифов; переработаны shell, главная страница, empty states и новые экраны журнала синка и проектов.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-06-28 00:27:09 +07:00
co-authored by Cursor
parent 9df91bf2cc
commit 1e291b3759
35 changed files with 1527 additions and 264 deletions
+2
View File
@@ -20,6 +20,7 @@ import { projectsRoutes } from './routes/projects.js'
import { backupRoutes } from './routes/backup.js'
import { ratesProxyRoutes } from './routes/rates-proxy.js'
import { migrateRoutes } from './routes/migrate.js'
import { dashboardRoutes } from './routes/dashboard.js'
import { startScheduler } from './services/scheduler.js'
const __dirname = dirname(fileURLToPath(import.meta.url))
@@ -52,6 +53,7 @@ export async function buildApp(opts: BuildAppOptions = {}) {
await app.register(backupRoutes)
await app.register(ratesProxyRoutes)
await app.register(migrateRoutes)
await app.register(dashboardRoutes)
const staticDir = opts.staticDir ?? join(__dirname, '..', '..', 'web', 'dist')
if (existsSync(staticDir)) {
+6
View File
@@ -0,0 +1,6 @@
import type { FastifyPluginAsync } from 'fastify'
import { computeDashboardStats } from '../services/dashboard-stats.js'
export const dashboardRoutes: FastifyPluginAsync = async (app) => {
app.get('/api/dashboard/stats', async () => computeDashboardStats())
}
+136
View File
@@ -0,0 +1,136 @@
import { getSnapshot } from '@cfdm/db/repositories/snapshot'
const STALE_SYNC_HOURS = 48
function vpsBurnRate(v: {
status: string
tariffType: string
dailyRate: number | null
monthlyRate: number | null
}): number {
if (v.status !== 'active') return 0
const monthly = Number(v.monthlyRate || 0)
const daily = Number(v.dailyRate || 0)
const burn = v.tariffType === 'daily' ? daily * 30 : monthly
return Number.isFinite(burn) ? burn : 0
}
function lastOkSyncAt(accountId: string, syncLog: { accountId: string; status: string | null; finishedAt: string | null }[]): number | null {
let best: number | null = null
for (const r of syncLog) {
if (r.accountId !== accountId || r.status !== 'ok' || !r.finishedAt) continue
const t = new Date(r.finishedAt).getTime()
if (!Number.isNaN(t) && (best == null || t > best)) best = t
}
return best
}
export interface DashboardStats {
activeVpsCount: number
totalVpsCount: number
providerCount: number
accountCount: number
monthlyBurnEstimate: number
totalBalanceApi: number
minRunwayDays: number | null
expiringWithin7Days: number
issuesCount: number
lastGlobalSyncAt: string | null
staleSyncAccountCount: number
lowBalanceAccountCount: number
}
export function computeDashboardStats(): DashboardStats {
const snap = getSnapshot()
const now = new Date()
const in7Days = new Date(now)
in7Days.setDate(in7Days.getDate() + 7)
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const activeVps = snap.vps.filter((v) => v.status === 'active')
const monthlyBurnEstimate = activeVps.reduce((acc, v) => acc + vpsBurnRate(v), 0)
const totalBalanceApi = snap.providerAccounts.reduce(
(acc, a) => acc + (Number(a.balanceApi ?? 0) || 0),
0,
)
const burnByAccount = new Map<string, number>()
for (const v of activeVps) {
const burn = vpsBurnRate(v)
burnByAccount.set(v.providerAccountId, (burnByAccount.get(v.providerAccountId) ?? 0) + burn)
}
let minRunwayDays: number | null = null
for (const account of snap.providerAccounts) {
const balance = Number(account.balanceApi ?? 0)
const burn = burnByAccount.get(account.id) ?? 0
if (balance <= 0 || burn <= 0) continue
const days = Math.floor((balance / burn) * 30)
if (minRunwayDays == null || days < minRunwayDays) minRunwayDays = days
}
const expiringWithin7Days = activeVps.filter((v) => {
if (!v.paidUntil) return false
const d = new Date(v.paidUntil)
if (Number.isNaN(d.getTime())) return false
return d >= todayStart && d <= in7Days
}).length
const providerById = new Map(snap.providers.map((p) => [p.id, p]))
const staleMs = STALE_SYNC_HOURS * 60 * 60 * 1000
const bmAccounts = snap.providerAccounts.filter((a) => {
const p = providerById.get(a.providerId)
return p?.apiType === 'billmanager' && Boolean((p.apiBaseUrl || '').trim()) && a.apiCredentialsSet
})
const staleSyncAccountCount = bmAccounts.filter((a) => {
const t = lastOkSyncAt(a.id, snap.syncLog)
if (t == null) return true
return now.getTime() - t > staleMs
}).length
const lowBalanceAccountCount = snap.providerAccounts.filter((a) => {
const threshold = Number(a.balanceAlertBelow ?? 0)
if (!Number.isFinite(threshold) || threshold <= 0) return false
const balance = Number(a.balanceApi ?? 0)
return balance < threshold
}).length
let issuesCount = 0
if (activeVps.some((v) => !(v.project || '').trim())) issuesCount++
if (
activeVps.some((v) => {
const dr = Number(v.dailyRate || 0)
const mr = Number(v.monthlyRate || 0)
const noMoney = (!Number.isFinite(dr) || dr <= 0) && (!Number.isFinite(mr) || mr <= 0)
const noCur = !(v.currency || '').trim()
return noMoney || noCur
})
) {
issuesCount++
}
if (expiringWithin7Days > 0) issuesCount++
if (staleSyncAccountCount > 0) issuesCount++
if (lowBalanceAccountCount > 0) issuesCount++
let lastGlobalSyncAt: string | null = null
for (const row of snap.syncLog) {
if (row.status === 'ok' && row.finishedAt) {
if (!lastGlobalSyncAt || row.finishedAt > lastGlobalSyncAt) lastGlobalSyncAt = row.finishedAt
}
}
return {
activeVpsCount: activeVps.length,
totalVpsCount: snap.vps.length,
providerCount: snap.providers.length,
accountCount: snap.providerAccounts.length,
monthlyBurnEstimate,
totalBalanceApi,
minRunwayDays,
expiringWithin7Days,
issuesCount,
lastGlobalSyncAt,
staleSyncAccountCount,
lowBalanceAccountCount,
}
}
+13
View File
@@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest'
import { parseTariffPrice } from '@cfdm/db/repositories/tariffs'
describe('parseTariffPrice', () => {
it('parses amount and currency', () => {
expect(parseTariffPrice('100.50 RUB')).toEqual({ monthlyRate: 100.5, currency: 'RUB' })
expect(parseTariffPrice('12 USD')).toEqual({ monthlyRate: 12, currency: 'USD' })
})
it('handles empty', () => {
expect(parseTariffPrice('')).toEqual({ monthlyRate: null, currency: null })
})
})