feat(web): доработка UX/UI — дашборд, навигация и data foundation
Docker / build (push) Has been cancelled
Docker / build (push) Has been cancelled
Добавлены syncLog в snapshot, API статистики дашборда и маппинг цен тарифов; переработаны shell, главная страница, empty states и новые экраны журнала синка и проектов. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -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)) {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user