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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
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 })
})
})
+3 -3
View File
@@ -21,7 +21,7 @@ import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { EmptyState } from './empty-state'
import type { DataTableColumn } from './data-table-card'
import type { DataTableColumn } from './data-grid-types'
const PAGINATION_LABELS = {
rowsPerPageLabel: 'Строк на странице',
@@ -228,8 +228,8 @@ export function DataGridCard<TData extends object>({
return (
<Card className={cn('ring-0 shadow-none', className)}>
<CardHeader className="flex flex-row items-center justify-between gap-2 border-b border-border/50 pb-4">
<div className="space-y-1">
<CardHeader className="flex flex-row items-center justify-between gap-2 border-b border-border/50 pb-4">
<div className="flex flex-col gap-1">
{title ? <CardTitle>{title}</CardTitle> : null}
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
</div>
@@ -0,0 +1,14 @@
import type { ReactNode } from 'react'
import type { LucideIcon } from 'lucide-react'
export interface DataTableColumn<T> {
key: string
header: ReactNode
cell: (row: T, index: number) => ReactNode
icon?: LucideIcon
sortable?: boolean
sortValue?: (row: T) => string | number
headerTitle?: string
className?: string
headerClassName?: string
}
+2 -87
View File
@@ -1,87 +1,2 @@
import type { ReactNode } from 'react'
import type { LucideIcon } from 'lucide-react'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@cfdm/ui/components/table'
import { TableCard } from './table-card'
import { EmptyState } from './empty-state'
export interface DataTableColumn<T> {
key: string
header: ReactNode
cell: (row: T, index: number) => ReactNode
icon?: LucideIcon
sortable?: boolean
sortValue?: (row: T) => string | number
headerTitle?: string
className?: string
headerClassName?: string
}
interface DataTableCardProps<T> {
title?: ReactNode
description?: ReactNode
actions?: ReactNode
columns: DataTableColumn<T>[]
data: T[]
rowKey: (row: T, index: number) => string
emptyTitle?: string
emptyDescription?: string
emptyAction?: ReactNode
onRowClick?: (row: T) => void
}
export function DataTableCard<T>({
title,
description,
actions,
columns,
data,
rowKey,
emptyTitle = 'Нет записей',
emptyDescription,
emptyAction,
onRowClick,
}: DataTableCardProps<T>) {
return (
<TableCard title={title} description={description} actions={actions}>
{data.length === 0 ? (
<div className="p-4">
<EmptyState title={emptyTitle} description={emptyDescription} action={emptyAction} />
</div>
) : (
<Table>
<TableHeader>
<TableRow>
{columns.map((col) => (
<TableHead key={col.key} className={col.headerClassName}>
{col.header}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{data.map((row, index) => (
<TableRow
key={rowKey(row, index)}
onClick={onRowClick ? () => onRowClick(row) : undefined}
className={onRowClick ? 'cursor-pointer' : undefined}
>
{columns.map((col) => (
<TableCell key={col.key} className={col.className}>
{col.cell(row, index)}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
)}
</TableCard>
)
}
/** @deprecated Используйте DataGridCard. Тип колонок — data-grid-types. */
export type { DataTableColumn } from './data-grid-types'
+21 -1
View File
@@ -20,6 +20,11 @@ import type { ReactNode } from 'react'
import type { Vps, Provider, Payment, Settings, RatesData } from '@/types/entities'
import { convertCurrency, formatCurrency, monthKey, toIsoCurrency } from '@/lib/format'
import { providerByIdMap } from '@/lib/billmanager'
import { EmptyState } from '@/components/empty-state'
function ChartEmpty({ message }: { message: string }) {
return <EmptyState title={message} className="h-72 border-none" />
}
const EXPENSE_CONFIG: ChartConfig = {
expense: { label: 'Расход', color: 'var(--chart-1)' },
@@ -28,18 +33,21 @@ const EXPENSE_CONFIG: ChartConfig = {
export function MonthlyExpenseChart({
vps,
providers,
providerAccounts,
settings,
ratesData,
className,
}: {
vps: Vps[]
providers: Provider[]
providerAccounts?: { id: string; name: string; providerId: string }[]
settings: Settings[]
ratesData: RatesData | null
className?: string
}) {
const baseCurrency = (settings[0]?.baseCurrency ?? 'RUB').toUpperCase()
const providerById = providerByIdMap(providers)
const accountById = new Map((providerAccounts ?? []).map((a) => [a.id, a]))
const monthlyByAccount = new Map<string, number>()
for (const v of vps) {
@@ -56,7 +64,7 @@ export function MonthlyExpenseChart({
const data = Array.from(monthlyByAccount.entries())
.map(([accountId, value]) => ({
accountId,
name: providerById.get(accountId)?.name ?? accountId,
name: accountById.get(accountId)?.name ?? accountId,
expense: Math.round(value),
}))
.sort((a, b) => b.expense - a.expense)
@@ -69,6 +77,9 @@ export function MonthlyExpenseChart({
<CardDescription>Топ-10 по monthly rate, в {baseCurrency}</CardDescription>
</CardHeader>
<CardContent>
{data.length === 0 ? (
<ChartEmpty message="Нет данных для графика" />
) : (
<ChartContainer config={EXPENSE_CONFIG} className="h-72 w-full">
<BarChart data={data} margin={{ top: 8, right: 8, bottom: 8, left: 8 }}>
<CartesianGrid vertical={false} strokeDasharray="3 3" />
@@ -78,6 +89,7 @@ export function MonthlyExpenseChart({
<Bar dataKey="expense" fill="var(--color-expense)" radius={4} />
</BarChart>
</ChartContainer>
)}
</CardContent>
</Card>
)
@@ -115,6 +127,9 @@ export function PaymentsPieChart({
<CardDescription>Структура в {baseCurrency}</CardDescription>
</CardHeader>
<CardContent>
{data.length === 0 ? (
<ChartEmpty message="Нет данных о платежах" />
) : (
<ChartContainer config={PAYMENTS_CONFIG} className="mx-auto h-72 w-full">
<PieChart>
<RechartsTooltip content={<ChartTooltipContent nameKey="type" formatter={(v) => formatCurrency(Number(v), baseCurrency)} />} />
@@ -125,6 +140,7 @@ export function PaymentsPieChart({
</Pie>
</PieChart>
</ChartContainer>
)}
</CardContent>
</Card>
)
@@ -163,6 +179,9 @@ export function MonthlyTrendChart({
<CardDescription>Последние 12 месяцев, {baseCurrency}</CardDescription>
</CardHeader>
<CardContent>
{data.length === 0 ? (
<ChartEmpty message="Нет данных за выбранный период" />
) : (
<ChartContainer config={trendConfig} className="h-72 w-full">
<BarChart data={data} margin={{ top: 8, right: 8, bottom: 8, left: 8 }}>
<CartesianGrid vertical={false} strokeDasharray="3 3" />
@@ -172,6 +191,7 @@ export function MonthlyTrendChart({
<Bar dataKey="amount" fill="var(--color-amount)" radius={4} />
</BarChart>
</ChartContainer>
)}
</CardContent>
</Card>
)
+1 -1
View File
@@ -18,7 +18,7 @@ export function EmptyState({ title, description, icon, action, className }: Empt
)}
>
{icon ? <div className="text-muted-foreground">{icon}</div> : null}
<div className="space-y-1">
<div className="flex flex-col gap-1">
<p className="text-sm font-medium">{title}</p>
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
</div>
+145 -39
View File
@@ -9,6 +9,9 @@ import {
ChartColumnBig,
ChartBar,
Settings,
RefreshCwIcon,
FolderKanbanIcon,
HistoryIcon,
} from 'lucide-react'
import {
@@ -29,42 +32,108 @@ import {
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from '@cfdm/ui/components/breadcrumb'
import { Separator } from '@cfdm/ui/components/separator'
import { Badge } from '@cfdm/ui/components/badge'
import { Link, useRouterState } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import type { ReactNode } from 'react'
import { ModeToggle } from '@/components/mode-toggle'
import { dashboardStatsQueryOptions } from '@/queries/dashboard'
import { formatRelativeSyncTime } from '@/lib/sync-format'
interface NavItem {
to: string
label: string
icon: typeof LayoutDashboard
badge?: number
}
const NAV_ITEMS: NavItem[] = [
{ to: '/dashboard', label: 'Дашборд', icon: LayoutDashboard },
{ to: '/vps', label: 'VPS', icon: Server },
{ to: '/tariffs', label: 'Активные тарифы', icon: ServerCog },
{ to: '/providers', label: 'Хостеры', icon: Building2 },
{ to: '/accounts', label: 'Аккаунты хостеров', icon: Wallet },
{ to: '/payments', label: 'Платежи', icon: CreditCard },
{ to: '/balance', label: 'Баланс и списания', icon: Coins },
{ to: '/reports', label: 'Отчёты', icon: ChartColumnBig },
{ to: '/resources', label: 'Ресурсы', icon: ChartBar },
{ to: '/settings', label: 'Настройки', icon: Settings },
interface NavGroup {
label: string
items: NavItem[]
}
const NAV_GROUPS: NavGroup[] = [
{
label: 'Обзор',
items: [{ to: '/dashboard', label: 'Дашборд', icon: LayoutDashboard }],
},
{
label: 'Инфраструктура',
items: [
{ to: '/vps', label: 'VPS', icon: Server },
{ to: '/tariffs', label: 'Активные тарифы', icon: ServerCog },
{ to: '/providers', label: 'Хостеры', icon: Building2 },
{ to: '/accounts', label: 'Аккаунты хостеров', icon: Wallet },
{ to: '/projects', label: 'Проекты', icon: FolderKanbanIcon },
],
},
{
label: 'Финансы',
items: [
{ to: '/payments', label: 'Платежи', icon: CreditCard },
{ to: '/balance', label: 'Баланс и списания', icon: Coins },
],
},
{
label: 'Аналитика',
items: [
{ to: '/reports', label: 'Отчёты', icon: ChartColumnBig },
{ to: '/resources', label: 'Ресурсы', icon: ChartBar },
],
},
{
label: 'Система',
items: [
{ to: '/sync-journal', label: 'Журнал синка', icon: HistoryIcon },
{ to: '/settings', label: 'Настройки', icon: Settings },
],
},
]
const ALL_NAV_ITEMS = NAV_GROUPS.flatMap((g) => g.items)
const ROUTE_LABELS: Record<string, string> = Object.fromEntries(
NAV_ITEMS.map((i) => [i.to, i.label]),
ALL_NAV_ITEMS.map((i) => [i.to, i.label]),
)
const PARENT_ROUTE: Record<string, string> = {
'/vps': '/dashboard',
'/tariffs': '/dashboard',
'/providers': '/dashboard',
'/accounts': '/dashboard',
'/projects': '/dashboard',
'/payments': '/dashboard',
'/balance': '/dashboard',
'/reports': '/dashboard',
'/resources': '/dashboard',
'/sync-journal': '/settings',
}
export function AppShell({ children }: { children: ReactNode }) {
const pathname = useRouterState({ select: (s) => s.location.pathname })
const activeItem = NAV_ITEMS.find((i) => pathname.startsWith(i.to)) ?? 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 parentLabel = parentTo ? ROUTE_LABELS[parentTo] : null
const { data: stats } = useQuery(dashboardStatsQueryOptions())
const navGroups: NavGroup[] = NAV_GROUPS.map((group) => ({
...group,
items: group.items.map((item) => {
if (item.to === '/dashboard' && stats?.issuesCount) {
return { ...item, badge: stats.issuesCount }
}
return item
}),
}))
return (
<SidebarProvider>
@@ -88,44 +157,81 @@ export function AppShell({ children }: { children: ReactNode }) {
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>Меню</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{NAV_ITEMS.map((item) => {
const Icon = item.icon
const isActive = pathname === item.to || pathname.startsWith(`${item.to}/`)
return (
<SidebarMenuItem key={item.to}>
<SidebarMenuButton
render={<Link to={item.to} />}
isActive={isActive}
tooltip={item.label}
>
<Icon />
<span>{item.label}</span>
</SidebarMenuButton>
</SidebarMenuItem>
)
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
{navGroups.map((group) => (
<SidebarGroup key={group.label}>
<SidebarGroupLabel>{group.label}</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{group.items.map((item) => {
const Icon = item.icon
const isActive = pathname === item.to || pathname.startsWith(`${item.to}/`)
return (
<SidebarMenuItem key={item.to}>
<SidebarMenuButton
render={<Link to={item.to} />}
isActive={isActive}
tooltip={item.label}
>
<Icon />
<span>{item.label}</span>
{item.badge ? (
<Badge variant="destructive" className="ml-auto size-5 justify-center p-0 text-xs">
{item.badge}
</Badge>
) : null}
</SidebarMenuButton>
</SidebarMenuItem>
)
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
))}
</SidebarContent>
<SidebarFooter />
<SidebarFooter>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton render={<Link to="/settings" />} tooltip="Настройки">
<Settings />
<span className="truncate text-xs text-muted-foreground">
Синк: {formatRelativeSyncTime(stats?.lastGlobalSyncAt)}
</span>
{stats?.staleSyncAccountCount ? (
<Badge variant="outline" className="ml-auto text-xs">
<RefreshCwIcon className="size-3" />
{stats.staleSyncAccountCount}
</Badge>
) : null}
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
</Sidebar>
<SidebarInset>
<header className="sticky top-0 flex h-16 shrink-0 items-center gap-2 border-b bg-background/95 px-4 backdrop-supports">
<header className="sticky top-0 z-10 flex h-16 shrink-0 items-center gap-2 border-b bg-background/95 px-4 backdrop-blur supports-[backdrop-filter]:bg-background/80">
<SidebarTrigger />
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
<Breadcrumb>
<BreadcrumbList>
{parentLabel && parentTo ? (
<>
<BreadcrumbItem className="hidden md:block">
<BreadcrumbLink render={<Link to={parentTo} />}>{parentLabel}</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator className="hidden md:block" />
</>
) : null}
<BreadcrumbItem>
<BreadcrumbPage>{ROUTE_LABELS[activeItem.to] ?? ''}</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<div className="ml-auto">
<div className="ml-auto flex items-center gap-2">
{stats?.issuesCount ? (
<Badge variant="destructive" className="hidden sm:inline-flex">
{stats.issuesCount} проблем
</Badge>
) : null}
<ModeToggle />
</div>
</header>
+1 -1
View File
@@ -9,7 +9,7 @@ interface PageHeaderProps {
export function PageHeader({ title, description, actions }: PageHeaderProps) {
return (
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div className="space-y-1">
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
</div>
+35 -5
View File
@@ -7,13 +7,22 @@ export interface SectionCardItem {
value: string | number | ReactElement
hint?: ReactNode
icon?: ReactNode
variant?: 'default' | 'warning' | 'destructive'
onClick?: () => void
}
const VARIANT_CLASS: Record<NonNullable<SectionCardItem['variant']>, string> = {
default: '',
warning: 'border-amber-500/50',
destructive: 'border-destructive/50',
}
export function SectionCards({ items, className }: { items: SectionCardItem[]; className?: string }) {
return (
<div className={cn('grid gap-4 sm:grid-cols-2 lg:grid-cols-4', className)}>
{items.map((item, idx) => (
<Card key={typeof item.label === 'string' ? item.label : idx} className="gap-0">
<div className={cn('grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6', className)}>
{items.map((item, idx) => {
const clickable = Boolean(item.onClick)
const content = (
<CardContent className="flex flex-col gap-1 p-4">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">{item.label}</span>
@@ -22,8 +31,29 @@ export function SectionCards({ items, className }: { items: SectionCardItem[]; c
<span className="text-2xl font-semibold tabular-nums">{item.value}</span>
{item.hint ? <span className="text-xs text-muted-foreground">{item.hint}</span> : null}
</CardContent>
</Card>
))}
)
return (
<Card
key={typeof item.label === 'string' ? item.label : idx}
className={cn('gap-0', VARIANT_CLASS[item.variant ?? 'default'], clickable && 'cursor-pointer transition-colors hover:bg-muted/40')}
onClick={item.onClick}
role={clickable ? 'button' : undefined}
tabIndex={clickable ? 0 : undefined}
onKeyDown={
clickable
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
item.onClick?.()
}
}
: undefined
}
>
{content}
</Card>
)
})}
</div>
)
}
+4
View File
@@ -236,6 +236,10 @@ export function countActiveFilters(filters: VpsFiltersState): number {
return n
}
export function hasActiveVpsFilters(filters: VpsFiltersState): boolean {
return countActiveFilters(filters) > 0 || filters.groupByProject || filters.tableCompact
}
export interface VpsFilterPreset {
name: string
filters: VpsFiltersState
+23
View File
@@ -0,0 +1,23 @@
/** Баланс API аккаунта (поддержка camelCase из API и snake_case в типах). */
export function accountBalanceApi(account: {
balance_api?: number | null
balanceApi?: number | null
}): number | null {
const raw = account.balance_api ?? account.balanceApi
if (raw == null) return null
const n = Number(raw)
return Number.isFinite(n) ? n : null
}
export function accountBalanceCurrency(account: {
balance_currency?: string
balanceCurrency?: string
currency?: string
}): string {
return (
account.balance_currency ??
account.balanceCurrency ??
account.currency ??
'RUB'
)
}
+5
View File
@@ -139,6 +139,11 @@ export const api = {
if (!res.ok) throw new ApiError(res.statusText || 'Ошибка восстановления', res.status)
return res.json()
},
fetchDashboardStats: () =>
fetchApi<import('@/queries/dashboard').DashboardStats>('/api/dashboard/stats'),
fetchProjects: () => fetchApi<{ id: string; name: string }[]>('/api/projects'),
}
export type {
+4 -2
View File
@@ -31,16 +31,18 @@ function ledgerBalanceInCurrency(
return credits - debits
}
import { accountBalanceApi } from '@/lib/account'
export function accountHasApiLedgerMismatch(
account: ProviderAccount,
balanceLedger: BalanceLedgerRow[],
): boolean {
if (account.balance_api == null || !Number.isFinite(Number(account.balance_api))) return false
const apiBalance = accountBalanceApi(account)
if (apiBalance == null) return false
const rows = ledgerRowsInAccountCurrency(account, balanceLedger)
if (rows.length === 0) return false
const ledger = ledgerBalanceInCurrency(account, balanceLedger)
if (!Number.isFinite(ledger)) return false
const apiBalance = Number(account.balance_api)
const diff = Math.abs(apiBalance - ledger)
const tol = Math.max(10, Math.abs(apiBalance) * 0.05)
return diff > tol
+13
View File
@@ -0,0 +1,13 @@
export function formatRelativeSyncTime(iso: string | null | undefined): string {
if (!iso) return 'нет данных'
const t = new Date(iso).getTime()
if (Number.isNaN(t)) return 'нет данных'
const diffMs = Date.now() - t
const mins = Math.floor(diffMs / 60_000)
if (mins < 1) return 'только что'
if (mins < 60) return `${mins} мин назад`
const hours = Math.floor(mins / 60)
if (hours < 48) return `${hours} ч назад`
const days = Math.floor(hours / 24)
return `${days} дн назад`
}
+26
View File
@@ -0,0 +1,26 @@
import { api } from '@/lib/api-client'
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 const dashboardKeys = {
stats: ['dashboard', 'stats'] as const,
}
export const dashboardStatsQueryOptions = () => ({
queryKey: dashboardKeys.stats,
queryFn: () => api.fetchDashboardStats(),
staleTime: 30_000,
})
+42
View File
@@ -13,10 +13,12 @@ import { Route as AuthRouteImport } from './routes/_auth'
import { Route as IndexRouteImport } from './routes/index'
import { Route as AuthVpsRouteImport } from './routes/_auth/vps'
import { Route as AuthTariffsRouteImport } from './routes/_auth/tariffs'
import { Route as AuthSyncJournalRouteImport } from './routes/_auth/sync-journal'
import { Route as AuthSettingsRouteImport } from './routes/_auth/settings'
import { Route as AuthResourcesRouteImport } from './routes/_auth/resources'
import { Route as AuthReportsRouteImport } from './routes/_auth/reports'
import { Route as AuthProvidersRouteImport } from './routes/_auth/providers'
import { Route as AuthProjectsRouteImport } from './routes/_auth/projects'
import { Route as AuthPaymentsRouteImport } from './routes/_auth/payments'
import { Route as AuthDashboardRouteImport } from './routes/_auth/dashboard'
import { Route as AuthBalanceRouteImport } from './routes/_auth/balance'
@@ -41,6 +43,11 @@ const AuthTariffsRoute = AuthTariffsRouteImport.update({
path: '/tariffs',
getParentRoute: () => AuthRoute,
} as any)
const AuthSyncJournalRoute = AuthSyncJournalRouteImport.update({
id: '/sync-journal',
path: '/sync-journal',
getParentRoute: () => AuthRoute,
} as any)
const AuthSettingsRoute = AuthSettingsRouteImport.update({
id: '/settings',
path: '/settings',
@@ -61,6 +68,11 @@ const AuthProvidersRoute = AuthProvidersRouteImport.update({
path: '/providers',
getParentRoute: () => AuthRoute,
} as any)
const AuthProjectsRoute = AuthProjectsRouteImport.update({
id: '/projects',
path: '/projects',
getParentRoute: () => AuthRoute,
} as any)
const AuthPaymentsRoute = AuthPaymentsRouteImport.update({
id: '/payments',
path: '/payments',
@@ -88,10 +100,12 @@ export interface FileRoutesByFullPath {
'/balance': typeof AuthBalanceRoute
'/dashboard': typeof AuthDashboardRoute
'/payments': typeof AuthPaymentsRoute
'/projects': typeof AuthProjectsRoute
'/providers': typeof AuthProvidersRoute
'/reports': typeof AuthReportsRoute
'/resources': typeof AuthResourcesRoute
'/settings': typeof AuthSettingsRoute
'/sync-journal': typeof AuthSyncJournalRoute
'/tariffs': typeof AuthTariffsRoute
'/vps': typeof AuthVpsRoute
}
@@ -101,10 +115,12 @@ export interface FileRoutesByTo {
'/balance': typeof AuthBalanceRoute
'/dashboard': typeof AuthDashboardRoute
'/payments': typeof AuthPaymentsRoute
'/projects': typeof AuthProjectsRoute
'/providers': typeof AuthProvidersRoute
'/reports': typeof AuthReportsRoute
'/resources': typeof AuthResourcesRoute
'/settings': typeof AuthSettingsRoute
'/sync-journal': typeof AuthSyncJournalRoute
'/tariffs': typeof AuthTariffsRoute
'/vps': typeof AuthVpsRoute
}
@@ -116,10 +132,12 @@ export interface FileRoutesById {
'/_auth/balance': typeof AuthBalanceRoute
'/_auth/dashboard': typeof AuthDashboardRoute
'/_auth/payments': typeof AuthPaymentsRoute
'/_auth/projects': typeof AuthProjectsRoute
'/_auth/providers': typeof AuthProvidersRoute
'/_auth/reports': typeof AuthReportsRoute
'/_auth/resources': typeof AuthResourcesRoute
'/_auth/settings': typeof AuthSettingsRoute
'/_auth/sync-journal': typeof AuthSyncJournalRoute
'/_auth/tariffs': typeof AuthTariffsRoute
'/_auth/vps': typeof AuthVpsRoute
}
@@ -131,10 +149,12 @@ export interface FileRouteTypes {
| '/balance'
| '/dashboard'
| '/payments'
| '/projects'
| '/providers'
| '/reports'
| '/resources'
| '/settings'
| '/sync-journal'
| '/tariffs'
| '/vps'
fileRoutesByTo: FileRoutesByTo
@@ -144,10 +164,12 @@ export interface FileRouteTypes {
| '/balance'
| '/dashboard'
| '/payments'
| '/projects'
| '/providers'
| '/reports'
| '/resources'
| '/settings'
| '/sync-journal'
| '/tariffs'
| '/vps'
id:
@@ -158,10 +180,12 @@ export interface FileRouteTypes {
| '/_auth/balance'
| '/_auth/dashboard'
| '/_auth/payments'
| '/_auth/projects'
| '/_auth/providers'
| '/_auth/reports'
| '/_auth/resources'
| '/_auth/settings'
| '/_auth/sync-journal'
| '/_auth/tariffs'
| '/_auth/vps'
fileRoutesById: FileRoutesById
@@ -201,6 +225,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthTariffsRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/sync-journal': {
id: '/_auth/sync-journal'
path: '/sync-journal'
fullPath: '/sync-journal'
preLoaderRoute: typeof AuthSyncJournalRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/settings': {
id: '/_auth/settings'
path: '/settings'
@@ -229,6 +260,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthProvidersRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/projects': {
id: '/_auth/projects'
path: '/projects'
fullPath: '/projects'
preLoaderRoute: typeof AuthProjectsRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/payments': {
id: '/_auth/payments'
path: '/payments'
@@ -265,10 +303,12 @@ interface AuthRouteChildren {
AuthBalanceRoute: typeof AuthBalanceRoute
AuthDashboardRoute: typeof AuthDashboardRoute
AuthPaymentsRoute: typeof AuthPaymentsRoute
AuthProjectsRoute: typeof AuthProjectsRoute
AuthProvidersRoute: typeof AuthProvidersRoute
AuthReportsRoute: typeof AuthReportsRoute
AuthResourcesRoute: typeof AuthResourcesRoute
AuthSettingsRoute: typeof AuthSettingsRoute
AuthSyncJournalRoute: typeof AuthSyncJournalRoute
AuthTariffsRoute: typeof AuthTariffsRoute
AuthVpsRoute: typeof AuthVpsRoute
}
@@ -278,10 +318,12 @@ const AuthRouteChildren: AuthRouteChildren = {
AuthBalanceRoute: AuthBalanceRoute,
AuthDashboardRoute: AuthDashboardRoute,
AuthPaymentsRoute: AuthPaymentsRoute,
AuthProjectsRoute: AuthProjectsRoute,
AuthProvidersRoute: AuthProvidersRoute,
AuthReportsRoute: AuthReportsRoute,
AuthResourcesRoute: AuthResourcesRoute,
AuthSettingsRoute: AuthSettingsRoute,
AuthSyncJournalRoute: AuthSyncJournalRoute,
AuthTariffsRoute: AuthTariffsRoute,
AuthVpsRoute: AuthVpsRoute,
}
+91 -33
View File
@@ -1,7 +1,7 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { PlusIcon, PencilIcon, Trash2Icon, RefreshCwIcon, UserRoundIcon, KeyRoundIcon, PlugIcon, ReceiptIcon, WalletIcon } from 'lucide-react'
import { PlusIcon, PencilIcon, Trash2Icon, RefreshCwIcon, UserRoundIcon, KeyRoundIcon, PlugIcon, ReceiptIcon, WalletIcon, MoreHorizontalIcon } from 'lucide-react'
import { toast } from 'sonner'
import { snapshotQueryOptions } from '@/queries/snapshot'
@@ -11,7 +11,7 @@ import { PageHeader } from '@/components/page-header'
import { Button } from '@cfdm/ui/components/button'
import { Badge } from '@cfdm/ui/components/badge'
import { DataGridCard, columnDefFromDataTable } from '@/components/data-grid-card'
import type { DataTableColumn } from '@/components/data-table-card'
import type { DataTableColumn } from '@/components/data-grid-types'
import { dataGridCellStack } from '@/components/data-grid-cells'
import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons'
@@ -21,7 +21,14 @@ import { FormField } from '@/components/form-field'
import { Input } from '@cfdm/ui/components/input'
import { Textarea } from '@cfdm/ui/components/textarea'
import { SelectField } from '@/components/select-field'
import { LoadingButton } from '@/components/loading-button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@cfdm/ui/components/dropdown-menu'
import { accountBalanceApi, accountBalanceCurrency } from '@/lib/account'
import type { ProviderAccount, BillingMode } from '@/types/entities'
import { providerByIdMap, accountBillmanagerUiReady } from '@/lib/billmanager'
@@ -40,10 +47,19 @@ interface FormState {
login: string
apiCredentials: string
billingMode: BillingMode
balanceAlertBelow: string
notes: string
}
const EMPTY: FormState = { providerId: '', name: '', login: '', apiCredentials: '', billingMode: 'monthly', notes: '' }
const EMPTY: FormState = {
providerId: '',
name: '',
login: '',
apiCredentials: '',
billingMode: 'monthly',
balanceAlertBelow: '',
notes: '',
}
function AccountsPage() {
const queryClient = useQueryClient()
@@ -53,8 +69,14 @@ function AccountsPage() {
const saveMut = useMutation({
mutationFn: (r: FormState) => {
const { apiCredentials, ...rest } = r
const payload = apiCredentials ? { ...rest, apiCredentials } : rest
const { apiCredentials, balanceAlertBelow, ...rest } = r
const alertRaw = balanceAlertBelow.trim()
const alertNum = alertRaw ? Number(alertRaw) : null
const base = {
...rest,
balanceAlertBelow: Number.isFinite(alertNum) ? alertNum : null,
}
const payload = apiCredentials ? { ...base, apiCredentials } : base
return r.id
? api.update<ProviderAccount>('providerAccounts', r.id, payload as unknown as Partial<ProviderAccount>)
: api.create('providerAccounts', payload as unknown as ProviderAccount)
@@ -85,9 +107,16 @@ function AccountsPage() {
const openCreate = () => { setForm({ ...EMPTY, providerId: snapshot?.providers[0]?.id ?? '' }); setOpen(true) }
const openEdit = (a: ProviderAccount) => {
const ext = a as ProviderAccount & { balanceAlertBelow?: number | null }
setForm({
id: a.id, providerId: a.providerId, name: a.name, login: a.login ?? '',
apiCredentials: '', billingMode: a.billingMode ?? 'monthly', notes: a.notes ?? '',
id: a.id,
providerId: a.providerId,
name: a.name,
login: a.login ?? '',
apiCredentials: '',
billingMode: a.billingMode ?? 'monthly',
balanceAlertBelow: ext.balanceAlertBelow != null ? String(ext.balanceAlertBelow) : '',
notes: a.notes ?? '',
})
setOpen(true)
}
@@ -125,12 +154,16 @@ function AccountsPage() {
icon: WalletIcon,
headerClassName: 'text-right',
className: 'text-right',
sortValue: (a) => Number(a.balance_api ?? 0),
sortValue: (a) => accountBalanceApi(a) ?? 0,
cell: (a) => {
const provider = providerById.get(a.providerId)
if (!accountBillmanagerUiReady(a, provider)) return <span className="text-muted-foreground"></span>
const cur = a.balance_currency || a.currency || provider?.baseCurrency || 'USD'
return <span className="tabular-nums font-medium">{formatCurrency(Number(a.balance_api ?? 0), cur)}</span>
const cur = accountBalanceCurrency(a)
const ext = a as ProviderAccount & { enoughmoneyto?: string }
return dataGridCellStack(
formatCurrency(accountBalanceApi(a) ?? 0, cur),
ext.enoughmoneyto ? `до ${ext.enoughmoneyto}` : undefined,
)
},
},
{
@@ -142,28 +175,43 @@ function AccountsPage() {
const provider = providerById.get(a.providerId)
const canSync = accountBillmanagerUiReady(a, provider)
return (
<div className="flex justify-end gap-1">
<LoadingButton
variant="outline"
size="sm"
loading={syncMut.isPending && syncMut.variables === a.id}
disabled={!canSync}
onClick={() => syncMut.mutate(a.id)}
>
<RefreshCwIcon data-icon="inline-start" />
Синк
</LoadingButton>
<Button variant="ghost" size="icon-sm" onClick={() => openEdit(a)} aria-label="Редактировать">
<PencilIcon />
</Button>
<ConfirmDialog
trigger={<Button variant="ghost" size="icon-sm" aria-label="Удалить"><Trash2Icon /></Button>}
title="Удалить аккаунт?"
description={`«${a.name}» будет удалён.`}
destructive
confirmLabel="Удалить"
onConfirm={() => delMut.mutate(a.id)}
/>
<div className="flex justify-end">
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="ghost" size="icon-sm" aria-label="Действия">
<MoreHorizontalIcon />
</Button>
}
/>
<DropdownMenuContent align="end">
<DropdownMenuItem
disabled={!canSync || (syncMut.isPending && syncMut.variables === a.id)}
onClick={() => syncMut.mutate(a.id)}
>
<RefreshCwIcon />
Синхронизировать
</DropdownMenuItem>
<DropdownMenuItem onClick={() => openEdit(a)}>
<PencilIcon />
Редактировать
</DropdownMenuItem>
<DropdownMenuSeparator />
<ConfirmDialog
trigger={
<DropdownMenuItem variant="destructive" onSelect={(e) => e.preventDefault()}>
<Trash2Icon />
Удалить
</DropdownMenuItem>
}
title="Удалить аккаунт?"
description={`«${a.name}» будет удалён.`}
destructive
confirmLabel="Удалить"
onConfirm={() => delMut.mutate(a.id)}
/>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
},
@@ -233,6 +281,16 @@ function AccountsPage() {
]}
/>
</FormField>
<FormField label="Порог низкого баланса" htmlFor="acc-alert" description="Уведомление на дашборде, если баланс API ниже">
<Input
id="acc-alert"
type="number"
min={0}
value={form.balanceAlertBelow}
onChange={(e) => setForm({ ...form, balanceAlertBelow: e.target.value })}
placeholder="Не задан"
/>
</FormField>
<FormField label="Заметки" htmlFor="acc-notes">
<Textarea id="acc-notes" value={form.notes} onChange={(e) => setForm({ ...form, notes: e.target.value })} />
</FormField>
+1 -1
View File
@@ -11,7 +11,7 @@ import { PageHeader } from '@/components/page-header'
import { Button } from '@cfdm/ui/components/button'
import { Badge } from '@cfdm/ui/components/badge'
import { DataGridCard, columnDefFromDataTable } from '@/components/data-grid-card'
import type { DataTableColumn } from '@/components/data-table-card'
import type { DataTableColumn } from '@/components/data-grid-types'
import { dataGridCellStack } from '@/components/data-grid-cells'
import { QueryState } from '@/components/query-state'
import { SectionCardsSkeleton } from '@/components/skeletons'
+245 -71
View File
@@ -1,4 +1,4 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import {
ServerIcon,
@@ -9,45 +9,99 @@ import {
ExternalLinkIcon,
GlobeIcon,
FolderKanbanIcon,
CircleDotIcon,
CoinsIcon,
ClockIcon,
RefreshCwIcon,
BarChart3Icon,
DownloadIcon,
} from 'lucide-react'
import { snapshotQueryOptions, ratesQueryOptions } from '@/queries/snapshot'
import { dashboardStatsQueryOptions } from '@/queries/dashboard'
import { PageShell } from '@/components/page-shell'
import { PageHeader } from '@/components/page-header'
import { SectionCards } from '@/components/section-cards'
import { QueryState } from '@/components/query-state'
import { DataGridCard, columnDefFromDataTable } from '@/components/data-grid-card'
import type { DataTableColumn } from '@/components/data-table-card'
import type { DataTableColumn } from '@/components/data-grid-types'
import { dataGridCellStack } from '@/components/data-grid-cells'
import { SectionCardsSkeleton } from '@/components/skeletons'
import { SectionCardsSkeleton, TableSkeleton } from '@/components/skeletons'
import { Button } from '@cfdm/ui/components/button'
import { Badge } from '@cfdm/ui/components/badge'
import { Alert, AlertDescription, AlertTitle } from '@cfdm/ui/components/alert'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@cfdm/ui/components/tabs'
import { StatusBadge } from '@/components/status-badge'
import { computeInventoryHealth } from '@/lib/inventory-health'
import { computeInventoryHealth, getStaleSyncAccountIds } from '@/lib/inventory-health'
import { formatInBaseCurrency, normalizeRatesPayload, vpsStatusLabel } from '@/lib/format'
import { accountBalanceApi } from '@/lib/account'
import { MonthlyTrendChart, MonthlyExpenseChart } from '@/components/domain/charts'
import type { Vps } from '@/types/entities'
import type { Vps, ProviderAccount, Provider, SyncLogRow } from '@/types/entities'
export const Route = createFileRoute('/_auth/dashboard')({
loader: ({ context: { queryClient } }) =>
queryClient.ensureQueryData(snapshotQueryOptions()),
Promise.all([
queryClient.ensureQueryData(snapshotQueryOptions()),
queryClient.ensureQueryData(dashboardStatsQueryOptions()),
]),
component: DashboardPage,
})
type InventoryIssue = { key: string; title: string; count: number; to: string; hint?: string }
interface AtRiskAccount {
id: string
name: string
reason: string
severity: 'warning' | 'destructive'
}
function buildAtRiskAccounts(
accounts: ProviderAccount[],
providers: Provider[],
syncLog: SyncLogRow[] = [],
): AtRiskAccount[] {
const staleIds = new Set(getStaleSyncAccountIds(accounts, providers, syncLog))
const rows: AtRiskAccount[] = []
for (const a of accounts) {
const ext = a as ProviderAccount & { balanceAlertBelow?: number | null }
const threshold = Number(ext.balanceAlertBelow ?? 0)
const balance = accountBalanceApi(a)
if (Number.isFinite(threshold) && threshold > 0 && balance != null && balance < threshold) {
rows.push({ id: a.id, name: a.name, reason: 'Низкий баланс', severity: 'destructive' })
} else if (staleIds.has(a.id)) {
rows.push({ id: a.id, name: a.name, reason: 'Устаревший синк', severity: 'warning' })
}
}
return rows
}
function DashboardPage() {
const navigate = useNavigate()
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
const { data: stats } = useQuery(dashboardStatsQueryOptions())
const settings = snapshot?.settings?.[0]
const { data: rawRates } = useQuery(ratesQueryOptions(settings?.ratesUrl))
const ratesData = normalizeRatesPayload(rawRates) ?? rawRates ?? null
return (
<PageShell>
<PageHeader title="Дашборд" description="Сводка по VPS, балансам и здоровью инвентаря" />
<PageHeader
title="Дашборд"
description="Сводка по VPS, балансам и здоровью инвентаря"
actions={
<div className="flex flex-wrap gap-2">
<Button variant="outline" render={<Link to="/reports" />}>
<BarChart3Icon data-icon="inline-start" />
Отчёты
</Button>
<Button variant="outline" render={<Link to="/accounts" />}>
<RefreshCwIcon data-icon="inline-start" />
Синхронизация
</Button>
</div>
}
/>
<QueryState
data={snapshot}
@@ -55,21 +109,18 @@ function DashboardPage() {
isError={isError}
error={error}
onRetry={() => refetch()}
skeleton={<SectionCardsSkeleton />}
skeleton={
<div className="flex flex-col gap-4">
<SectionCardsSkeleton count={6} />
<TableSkeleton />
</div>
}
>
{(snap) => {
const activeVps = snap.vps.filter((v) => v.status === 'active')
const monthlyTotal = activeVps.reduce((acc, v) => {
const monthly = Number(v.monthlyRate || 0)
const daily = Number(v.dailyRate || 0)
const burn = v.tariffType === 'daily' ? daily * 30 : monthly
return acc + (Number.isFinite(burn) ? burn : 0)
}, 0)
const issues = computeInventoryHealth(snap)
const totalBalance = snap.providerAccounts.reduce(
(acc, a) => acc + (Number(a.balance_api ?? 0) || 0),
0,
)
const issues = computeInventoryHealth({ ...snap, syncLog: snap.syncLog ?? [] })
const atRisk = buildAtRiskAccounts(snap.providerAccounts, snap.providers, snap.syncLog ?? [])
const baseCur = snap.settings[0]?.baseCurrency ?? 'RUB'
const issueColumns: DataTableColumn<InventoryIssue>[] = [
{
@@ -121,12 +172,7 @@ function DashboardPage() {
{
key: 'status',
header: 'Статус',
icon: CircleDotIcon,
cell: (v) => (
<Badge variant={v.status === 'active' ? 'default' : 'secondary'}>
{vpsStatusLabel(v.status)}
</Badge>
),
cell: (v) => <StatusBadge status={v.status} label={vpsStatusLabel(v.status)} />,
},
{
key: 'rate',
@@ -135,15 +181,11 @@ function DashboardPage() {
headerClassName: 'text-right',
className: 'text-right',
sortValue: (v) =>
v.tariffType === 'daily'
? Number(v.dailyRate || 0) * 30
: Number(v.monthlyRate || 0),
v.tariffType === 'daily' ? Number(v.dailyRate || 0) * 30 : Number(v.monthlyRate || 0),
cell: (v) => (
<span className="tabular-nums font-medium">
{formatInBaseCurrency(
v.tariffType === 'daily'
? Number(v.dailyRate || 0) * 30
: Number(v.monthlyRate || 0),
v.tariffType === 'daily' ? Number(v.dailyRate || 0) * 30 : Number(v.monthlyRate || 0),
v.currency,
snap.settings,
ratesData,
@@ -153,61 +195,193 @@ function DashboardPage() {
},
]
const riskColumns: DataTableColumn<AtRiskAccount>[] = [
{
key: 'name',
header: 'Аккаунт',
cell: (row) => <span className="font-medium">{row.name}</span>,
},
{
key: 'reason',
header: 'Причина',
cell: (row) => (
<StatusBadge
status={row.severity === 'destructive' ? 'error' : 'stale'}
label={row.reason}
/>
),
},
{
key: 'action',
header: '',
sortable: false,
cell: () => (
<Button variant="outline" size="sm" onClick={() => navigate({ to: '/accounts' })}>
Открыть
</Button>
),
},
]
return (
<>
<div className="flex flex-col gap-4 md:gap-6">
<SectionCards
items={[
{
label: 'Активные VPS',
value: activeVps.length,
value: stats?.activeVpsCount ?? activeVps.length,
icon: <ServerIcon className="size-4" />,
hint: `всего ${snap.vps.length}`,
hint: `всего ${stats?.totalVpsCount ?? snap.vps.length}`,
onClick: () => navigate({ to: '/vps' }),
},
{
label: 'Хостеры',
value: snap.providers.length,
icon: <WalletIcon className="size-4" />,
hint: `${snap.providerAccounts.length} аккаунтов`,
},
{
label: 'Расход/мес (оценка)',
value: formatInBaseCurrency(monthlyTotal, snap.vps[0]?.currency ?? 'USD', snap.settings, ratesData),
label: 'Расход/мес',
value: formatInBaseCurrency(
stats?.monthlyBurnEstimate ?? 0,
baseCur,
snap.settings,
ratesData,
),
icon: <TrendingUpIcon className="size-4" />,
onClick: () => navigate({ to: '/reports' }),
},
{
label: 'Баланс аккаунтов (API)',
value: formatInBaseCurrency(totalBalance, snap.settings[0]?.baseCurrency ?? 'RUB', snap.settings, ratesData),
label: 'Баланс API',
value: formatInBaseCurrency(
stats?.totalBalanceApi ?? 0,
baseCur,
snap.settings,
ratesData,
),
icon: <WalletIcon className="size-4" />,
onClick: () => navigate({ to: '/accounts' }),
},
{
label: 'Runway (мин.)',
value: stats?.minRunwayDays != null ? `${stats.minRunwayDays} дн` : '—',
icon: <ClockIcon className="size-4" />,
variant: stats?.minRunwayDays != null && stats.minRunwayDays < 14 ? 'warning' : 'default',
onClick: () => navigate({ to: '/accounts' }),
},
{
label: 'Истекает 7 дн',
value: stats?.expiringWithin7Days ?? 0,
icon: <AlertTriangleIcon className="size-4" />,
variant: (stats?.expiringWithin7Days ?? 0) > 0 ? 'warning' : 'default',
onClick: () => navigate({ to: '/vps', search: { health: 'paid-overdue' } }),
},
{
label: 'Проблемы',
value: stats?.issuesCount ?? issues.length,
icon: <HashIcon className="size-4" />,
variant: (stats?.issuesCount ?? issues.length) > 0 ? 'destructive' : 'default',
},
]}
/>
<DataGridCard
title="Здоровье инвентаря"
description="Подсказки: нет проекта, нет ставки, просрочка, устаревший синк, расхождения баланса"
actions={
issues.length > 0 ? (
<Badge variant="destructive">{issues.length}</Badge>
) : (
<Badge variant="secondary">всё ок</Badge>
)
}
columns={columnDefFromDataTable(issueColumns)}
data={issues}
rowId={(i) => i.key}
emptyTitle="Проблем не найдено"
emptyDescription="Все активные VPS имеют проект, ставку и актуальный синк"
/>
{issues.length > 0 ? (
<Alert variant="destructive">
<AlertTriangleIcon />
<AlertTitle>Требует внимания</AlertTitle>
<AlertDescription>
Обнаружено {issues.length} категорий проблем в инвентаре. Проверьте вкладку «Проблемы».
</AlertDescription>
</Alert>
) : null}
<DataGridCard
title="Последние VPS"
description="Активные серверы"
columns={columnDefFromDataTable(vpsColumns)}
data={activeVps.slice(0, 8)}
rowId={(v) => v.id}
pagination={false}
/>
</>
<div className="grid gap-4 lg:grid-cols-2">
<MonthlyTrendChart
payments={snap.payments}
settings={snap.settings}
ratesData={ratesData}
className="h-full"
/>
<MonthlyExpenseChart
vps={snap.vps}
providers={snap.providers}
providerAccounts={snap.providerAccounts}
settings={snap.settings}
ratesData={ratesData}
className="h-full"
/>
</div>
<Tabs defaultValue="issues">
<TabsList>
<TabsTrigger value="issues">Проблемы ({issues.length})</TabsTrigger>
<TabsTrigger value="recent">Последние VPS</TabsTrigger>
<TabsTrigger value="risk">Аккаунты ({atRisk.length})</TabsTrigger>
</TabsList>
<TabsContent value="issues" className="mt-4">
<DataGridCard
title="Здоровье инвентаря"
description="Нет проекта, ставки, просрочка, устаревший синк, расхождения баланса"
columns={columnDefFromDataTable(issueColumns)}
data={issues}
rowId={(i) => i.key}
emptyTitle="Проблем не найдено"
emptyDescription="Все активные VPS имеют проект, ставку и актуальный синк"
pagination={false}
/>
</TabsContent>
<TabsContent value="recent" className="mt-4">
<DataGridCard
title="Последние VPS"
description="Активные серверы"
actions={
<Button variant="outline" size="sm" render={<Link to="/vps" />}>
Все VPS
</Button>
}
columns={columnDefFromDataTable(vpsColumns)}
data={activeVps.slice(0, 8)}
rowId={(v) => v.id}
pagination={false}
onRowClick={(v) => navigate({ to: '/vps', search: { edit: v.id } })}
/>
</TabsContent>
<TabsContent value="risk" className="mt-4">
<DataGridCard
title="Аккаунты под риском"
description="Низкий баланс или устаревший синк BILLmanager"
columns={columnDefFromDataTable(riskColumns)}
data={atRisk}
rowId={(r) => r.id}
emptyTitle="Рисков нет"
emptyDescription="Балансы и синхронизация в норме"
pagination={false}
/>
</TabsContent>
</Tabs>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" render={<Link to="/resources" />}>
Ресурсы
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
const csv = ['ip,status,project,currency,monthlyRate']
for (const v of activeVps) {
csv.push(
[v.ip, v.status, v.project ?? '', v.currency, v.monthlyRate ?? ''].join(','),
)
}
const blob = new Blob([csv.join('\n')], { type: 'text/csv' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'vps-export.csv'
a.click()
URL.revokeObjectURL(url)
}}
>
<DownloadIcon data-icon="inline-start" />
Экспорт CSV
</Button>
</div>
</div>
)
}}
</QueryState>
+1 -1
View File
@@ -10,7 +10,7 @@ import { PageShell } from '@/components/page-shell'
import { PageHeader } from '@/components/page-header'
import { Button } from '@cfdm/ui/components/button'
import { DataGridCard, columnDefFromDataTable } from '@/components/data-grid-card'
import type { DataTableColumn } from '@/components/data-table-card'
import type { DataTableColumn } from '@/components/data-grid-types'
import { dataGridCellStack } from '@/components/data-grid-cells'
import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons'
+135
View File
@@ -0,0 +1,135 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { PlusIcon, FolderKanbanIcon } from 'lucide-react'
import { toast } from 'sonner'
import { snapshotQueryOptions } from '@/queries/snapshot'
import { ApiError } from '@/lib/api-client'
import { PageShell } from '@/components/page-shell'
import { PageHeader } from '@/components/page-header'
import { DataGridCard, columnDefFromDataTable } from '@/components/data-grid-card'
import type { DataTableColumn } from '@/components/data-grid-types'
import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons'
import { Button } from '@cfdm/ui/components/button'
import { FormSheet } from '@/components/form-sheet'
import { FormField } from '@/components/form-field'
import { Input } from '@cfdm/ui/components/input'
interface ProjectRow {
id: string
name: string
vpsCount: number
}
export const Route = createFileRoute('/_auth/projects')({
loader: ({ context: { queryClient } }) =>
queryClient.ensureQueryData(snapshotQueryOptions()),
component: ProjectsPage,
})
function ProjectsPage() {
const queryClient = useQueryClient()
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
const [open, setOpen] = useState(false)
const [name, setName] = useState('')
const createMut = useMutation({
mutationFn: (projectName: string) =>
fetch(`${import.meta.env.VITE_API_URL ?? ''}/api/projects`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: projectName }),
}).then(async (res) => {
if (!res.ok) throw new ApiError(await res.text(), res.status)
return res.json()
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['snapshot'] })
toast.success('Проект создан')
setOpen(false)
setName('')
},
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка'),
})
const rows: ProjectRow[] = (snapshot?.serverProjects ?? []).map((p) => {
const row = p as { id: string; name: string }
const vpsCount = (snapshot?.vps ?? []).filter((v) => v.project === row.name).length
return { id: row.id, name: row.name, vpsCount }
})
const columns: DataTableColumn<ProjectRow>[] = [
{
key: 'name',
header: 'Проект',
icon: FolderKanbanIcon,
cell: (row) => <span className="font-medium">{row.name}</span>,
},
{
key: 'vps',
header: 'VPS',
headerClassName: 'text-right',
className: 'text-right tabular-nums',
sortValue: (row) => row.vpsCount,
cell: (row) => row.vpsCount,
},
]
return (
<PageShell>
<PageHeader
title="Проекты"
description="Группировка VPS по проектам"
actions={
<Button onClick={() => setOpen(true)}>
<PlusIcon data-icon="inline-start" />
Добавить
</Button>
}
/>
<QueryState
data={snapshot}
isLoading={isLoading}
isError={isError}
error={error}
onRetry={() => refetch()}
skeleton={<TableSkeleton />}
empty={rows.length === 0}
emptyTitle="Проектов нет"
emptyDescription="Создайте проект или назначьте его при редактировании VPS"
emptyAction={
<Button onClick={() => setOpen(true)}>
<PlusIcon data-icon="inline-start" />
Создать проект
</Button>
}
>
{() => (
<DataGridCard
columns={columnDefFromDataTable(columns)}
data={rows}
rowId={(r) => r.id}
dense
/>
)}
</QueryState>
<FormSheet
open={open}
onOpenChange={setOpen}
trigger={null}
title="Новый проект"
description="Имя будет доступно в автодополнении на форме VPS"
onSubmit={() => createMut.mutate(name.trim())}
submitting={createMut.isPending}
submitDisabled={!name.trim()}
>
<FormField label="Название" htmlFor="project-name">
<Input id="project-name" value={name} onChange={(e) => setName(e.target.value)} />
</FormField>
</FormSheet>
</PageShell>
)
}
+1 -1
View File
@@ -11,7 +11,7 @@ import { PageHeader } from '@/components/page-header'
import { Button } from '@cfdm/ui/components/button'
import { Badge } from '@cfdm/ui/components/badge'
import { DataGridCard, columnDefFromDataTable } from '@/components/data-grid-card'
import type { DataTableColumn } from '@/components/data-table-card'
import type { DataTableColumn } from '@/components/data-grid-types'
import { dataGridCellWithIcon } from '@/components/data-grid-cells'
import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons'
+7 -1
View File
@@ -69,7 +69,13 @@ function ReportsPage() {
]}
/>
<ChartsGrid>
<MonthlyExpenseChart vps={snap.vps} providers={snap.providers} settings={snap.settings} ratesData={ratesData} />
<MonthlyExpenseChart
vps={snap.vps}
providers={snap.providers}
providerAccounts={snap.providerAccounts}
settings={snap.settings}
ratesData={ratesData}
/>
<PaymentsPieChart payments={snap.payments} settings={snap.settings} ratesData={ratesData} />
<MonthlyTrendChart payments={snap.payments} settings={snap.settings} ratesData={ratesData} className="lg:col-span-2" />
</ChartsGrid>
+153
View File
@@ -16,6 +16,33 @@ import { SelectField } from '@/components/select-field'
import type { Settings } from '@/types/entities'
import { useState } from 'react'
import { Button } from '@cfdm/ui/components/button'
import { DownloadIcon, UploadIcon } from 'lucide-react'
function boolSelect(
draft: Partial<Settings>,
setForm: (v: Partial<Settings>) => void,
key: keyof Settings,
id: string,
label: string,
) {
const val = draft[key] === false ? 'off' : 'on'
return (
<Field orientation="horizontal">
<FieldLabel htmlFor={id}>{label}</FieldLabel>
<SelectField
triggerId={id}
triggerClassName="w-32"
value={val}
onValueChange={(v) => setForm({ ...draft, [key]: (v ?? 'on') === 'on' })}
options={[
{ value: 'on', label: 'Вкл' },
{ value: 'off', label: 'Выкл' },
]}
/>
</Field>
)
}
export const Route = createFileRoute('/_auth/settings')({
loader: ({ context: { queryClient } }) =>
@@ -157,6 +184,132 @@ function SettingsPage() {
</FieldGroup>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Синхронизация</CardTitle>
<CardDescription>Автосинк BILLmanager и интервалы</CardDescription>
</CardHeader>
<CardContent>
<FieldGroup>
{boolSelect(draft, (v) => setForm(v), 'syncEnabled', 'set-sync', 'Автосинк')}
<Field>
<FieldLabel htmlFor="set-sync-int">Интервал синка (мин)</FieldLabel>
<Input
id="set-sync-int"
type="number"
min={15}
value={draft.syncIntervalMinutes ?? 60}
onChange={(e) =>
setForm({ ...draft, syncIntervalMinutes: Number(e.target.value) || 60 })
}
/>
</Field>
<Field>
<FieldLabel htmlFor="set-tariff-int">Интервал тарифов (мин)</FieldLabel>
<Input
id="set-tariff-int"
type="number"
min={60}
value={draft.syncTariffsIntervalMinutes ?? 1440}
onChange={(e) =>
setForm({
...draft,
syncTariffsIntervalMinutes: Number(e.target.value) || 1440,
})
}
/>
</Field>
{boolSelect(draft, (v) => setForm(v), 'notifyLowBalanceEnabled', 'set-notify-bal', 'Низкий баланс')}
{boolSelect(draft, (v) => setForm(v), 'notifySyncDigestEnabled', 'set-notify-sync', 'Дайджест синка')}
{boolSelect(draft, (v) => setForm(v), 'notifyPaymentExpiryEnabled', 'set-notify-pay', 'Истечение оплаты')}
{boolSelect(draft, (v) => setForm(v), 'notifyNewTariffsEnabled', 'set-notify-tar', 'Новые тарифы')}
<LoadingButton
className="w-fit"
onClick={() => upsertMut.mutate(draft)}
loading={upsertMut.isPending}
disabled={!form}
>
Сохранить
</LoadingButton>
</FieldGroup>
</CardContent>
</Card>
<Card className="md:col-span-2">
<CardHeader>
<CardTitle>Резервное копирование</CardTitle>
<CardDescription>Экспорт и импорт базы данных</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
onClick={async () => {
try {
const blob = await api.downloadBackupJson()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `vps-tracker-backup-${new Date().toISOString().slice(0, 10)}.json`
a.click()
URL.revokeObjectURL(url)
toast.success('JSON выгружен')
} catch (e) {
toast.error(e instanceof ApiError ? e.message : 'Ошибка выгрузки')
}
}}
>
<DownloadIcon data-icon="inline-start" />
JSON
</Button>
<Button
variant="outline"
onClick={async () => {
try {
const blob = await api.downloadBackupDatabase()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `vps-tracker-${new Date().toISOString().slice(0, 10)}.db`
a.click()
URL.revokeObjectURL(url)
toast.success('База выгружена')
} catch (e) {
toast.error(e instanceof ApiError ? e.message : 'Ошибка выгрузки')
}
}}
>
<DownloadIcon data-icon="inline-start" />
SQLite
</Button>
<Button
variant="outline"
onClick={() => {
const input = document.createElement('input')
input.type = 'file'
input.accept = 'application/json,.json'
input.onchange = async () => {
const file = input.files?.[0]
if (!file) return
try {
const text = await file.text()
await api.importBackupJson(JSON.parse(text))
void queryClient.invalidateQueries({ queryKey: ['snapshot'] })
toast.success('Импорт JSON выполнен')
} catch (e) {
toast.error(e instanceof ApiError ? e.message : 'Ошибка импорта')
}
}
input.click()
}}
>
<UploadIcon data-icon="inline-start" />
Импорт JSON
</Button>
</div>
</CardContent>
</Card>
</div>
)}
</QueryState>
+108
View File
@@ -0,0 +1,108 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { HistoryIcon, UserRoundIcon, CheckCircle2Icon, XCircleIcon, LoaderIcon } from 'lucide-react'
import { snapshotQueryOptions } from '@/queries/snapshot'
import { PageShell } from '@/components/page-shell'
import { PageHeader } from '@/components/page-header'
import { DataGridCard, columnDefFromDataTable } from '@/components/data-grid-card'
import type { DataTableColumn } from '@/components/data-grid-types'
import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons'
import { StatusBadge } from '@/components/status-badge'
import { dataGridCellStack } from '@/components/data-grid-cells'
import { formatSyncSummaryLine } from '@/lib/inventory-health'
import { formatRelativeSyncTime } from '@/lib/sync-format'
import type { SyncLogRow } from '@/types/entities'
export const Route = createFileRoute('/_auth/sync-journal')({
loader: ({ context: { queryClient } }) =>
queryClient.ensureQueryData(snapshotQueryOptions()),
component: SyncJournalPage,
})
function statusIcon(status: SyncLogRow['status']) {
if (status === 'ok') return <CheckCircle2Icon className="size-4 text-primary" />
if (status === 'error') return <XCircleIcon className="size-4 text-destructive" />
return <LoaderIcon className="size-4 animate-spin text-muted-foreground" />
}
function SyncJournalPage() {
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
const columns: DataTableColumn<SyncLogRow>[] = [
{
key: 'started',
header: 'Запуск',
icon: HistoryIcon,
sortValue: (r) => r.startedAt ?? '',
cell: (r) => dataGridCellStack(
r.startedAt ? new Date(r.startedAt).toLocaleString('ru-RU') : '—',
r.finishedAt ? `завершён ${formatRelativeSyncTime(r.finishedAt)}` : undefined,
),
},
{
key: 'account',
header: 'Аккаунт',
icon: UserRoundIcon,
cell: (r) => {
const acc = snapshot?.providerAccounts.find((a) => a.id === r.accountId)
return acc?.name ?? r.accountId
},
},
{
key: 'status',
header: 'Статус',
cell: (r) => (
<div className="flex items-center gap-2">
{statusIcon(r.status)}
<StatusBadge status={r.status} label={r.status === 'ok' ? 'OK' : r.status === 'error' ? 'Ошибка' : 'Выполняется'} />
</div>
),
},
{
key: 'summary',
header: 'Итог',
cell: (r) => (
<span className="text-sm text-muted-foreground">
{r.error || formatSyncSummaryLine(r.summary as never) || '—'}
</span>
),
},
]
return (
<PageShell>
<PageHeader
title="Журнал синка"
description="История синхронизаций BILLmanager по аккаунтам"
actions={
<Link to="/accounts" className="text-sm text-muted-foreground underline-offset-4 hover:underline">
Управление аккаунтами
</Link>
}
/>
<QueryState
data={snapshot}
isLoading={isLoading}
isError={isError}
error={error}
onRetry={() => refetch()}
skeleton={<TableSkeleton />}
empty={!snapshot?.syncLog?.length}
emptyTitle="Записей синка нет"
emptyDescription="Запустите синхронизацию на странице аккаунтов"
>
{(snap) => (
<DataGridCard
columns={columnDefFromDataTable(columns)}
data={snap.syncLog ?? []}
rowId={(r) => r.id}
dense
/>
)}
</QueryState>
</PageShell>
)
}
+7 -5
View File
@@ -1,4 +1,4 @@
import { createFileRoute } from '@tanstack/react-router'
import { createFileRoute, Link } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { snapshotQueryOptions } from '@/queries/snapshot'
@@ -6,12 +6,12 @@ import { PageShell } from '@/components/page-shell'
import { PageHeader } from '@/components/page-header'
import { Badge } from '@cfdm/ui/components/badge'
import { DataGridCard, columnDefFromDataTable } from '@/components/data-grid-card'
import type { DataTableColumn } from '@/components/data-table-card'
import type { DataTableColumn } from '@/components/data-grid-types'
import { dataGridCellStack } from '@/components/data-grid-cells'
import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons'
import { EmptyState } from '@/components/empty-state'
import { ServerCogIcon, ServerIcon, UserRoundIcon, CpuIcon, CoinsIcon, HardDriveIcon } from 'lucide-react'
import { Button } from '@cfdm/ui/components/button'
import { ServerIcon, UserRoundIcon, CpuIcon, CoinsIcon, HardDriveIcon } from 'lucide-react'
import type { ActiveTariff } from '@/types/entities'
import { providerByIdMap, accountSelectLabel } from '@/lib/billmanager'
@@ -90,7 +90,9 @@ function TariffsPage() {
empty={snapshot?.activeTariffs.length === 0}
emptyTitle="Тарифы не загружены"
emptyDescription="Выполните синхронизацию аккаунта BILLmanager, чтобы загрузить тарифы"
emptyAction={<EmptyState icon={<ServerCogIcon className="size-8" />} title="Нет тарифов" />}
emptyAction={
<Button render={<Link to="/accounts" />}>Перейти к аккаунтам</Button>
}
>
{(snap) => <DataGridCard columns={columnDefFromDataTable(columns)} data={snap.activeTariffs} rowId={(t) => t.id} />}
</QueryState>
+116 -9
View File
@@ -1,6 +1,6 @@
import { createFileRoute } from '@tanstack/react-router'
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useState, useMemo } from 'react'
import { useState, useMemo, useEffect } from 'react'
import { Controller } from 'react-hook-form'
import { PlusIcon, PencilIcon, Trash2Icon, GlobeIcon, UserRoundIcon, FolderKanbanIcon, CpuIcon, CircleDotIcon, CreditCardIcon, MapPinIcon } from 'lucide-react'
import { toast } from 'sonner'
@@ -13,10 +13,11 @@ import { PageHeader } from '@/components/page-header'
import { Button } from '@cfdm/ui/components/button'
import { Badge } from '@cfdm/ui/components/badge'
import { DataGridCard, columnDefFromDataTable } from '@/components/data-grid-card'
import type { DataTableColumn } from '@/components/data-table-card'
import type { DataTableColumn } from '@/components/data-grid-types'
import { dataGridCellStack, dataGridCellWithFlag } from '@/components/data-grid-cells'
import { CountryFlag } from '@/components/country-flag'
import { QueryState } from '@/components/query-state'
import { EmptyState } from '@/components/empty-state'
import { TableSkeleton } from '@/components/skeletons'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { FormSheetRhf } from '@/components/form-sheet-rhf'
@@ -36,6 +37,7 @@ import { FormDatePicker } from '@/components/form-date-picker'
import {
applyVpsFilters,
buildDefaultVpsFilters,
hasActiveVpsFilters,
type VpsFiltersState,
} from '@/components/vps-filters'
import { VpsFiltersToolbar } from '@/components/vps-filters-toolbar'
@@ -44,8 +46,17 @@ import type { Vps } from '@/types/entities'
import { vpsStatusLabel, tariffTypeLabel } from '@/lib/format'
import { providerByIdMap, accountSelectLabel } from '@/lib/billmanager'
import { COUNTRIES, COUNTRY_BY_NAME_RU, buildCityOptions, cityMatchesCountry, resolveCountryForCityFromRows } from '@cfdm/shared/geo'
import { getPaidUntilDate } from '@/lib/paid-until'
import { z } from 'zod'
const vpsSearchSchema = z.object({
health: z.string().optional(),
edit: z.string().optional(),
})
export const Route = createFileRoute('/_auth/vps')({
validateSearch: (search) => vpsSearchSchema.parse(search),
loader: ({ context: { queryClient } }) =>
queryClient.ensureQueryData(snapshotQueryOptions()),
component: VpsPage,
@@ -60,12 +71,49 @@ const EMPTY_FORM: VpsFormValues = {
function VpsPage() {
const queryClient = useQueryClient()
const navigate = useNavigate()
const { health, edit } = Route.useSearch()
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
const [sheetOpen, setSheetOpen] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const [defaultValues, setDefaultValues] = useState<VpsFormValues>(EMPTY_FORM)
const [filters, setFilters] = useState<VpsFiltersState>(buildDefaultVpsFilters())
useEffect(() => {
if (!health) return
setFilters((prev) => ({ ...prev, status: prev.status.length ? prev.status : ['active'] }))
}, [health])
useEffect(() => {
if (!edit || !snapshot) return
const row = snapshot.vps.find((v) => v.id === edit)
if (row) {
setEditingId(row.id)
setDefaultValues({
ip: row.ip,
dns: row.dns ?? '',
providerId: row.providerId,
providerAccountId: row.providerAccountId,
country: row.country ?? '',
city: row.city ?? '',
datacenter: row.datacenter ?? '',
vcpu: row.vcpu,
ramGb: row.ramGb,
diskGb: row.diskGb,
status: row.status,
tariffType: row.tariffType,
currency: row.currency,
monthlyRate: Number(row.monthlyRate || 0),
dailyRate: Number(row.dailyRate || 0),
paidUntil: row.paidUntil ?? '',
project: row.project ?? '',
notes: row.notes ?? '',
})
setSheetOpen(true)
void navigate({ to: '/vps', search: { edit: undefined }, replace: true })
}
}, [edit, snapshot, navigate])
const settings = snapshot?.settings[0]
const { data: rawRates } = useQuery(ratesQueryOptions(settings?.ratesUrl))
const ratesData = normalizeRatesPayload(rawRates) ?? rawRates ?? null
@@ -143,10 +191,38 @@ function VpsPage() {
return [...names].sort((a, b) => a.localeCompare(b, 'ru'))
}, [snapshot])
const filteredVps = useMemo(
() => applyVpsFilters(snapshot?.vps ?? [], filters),
[snapshot?.vps, filters],
)
const filteredVps = useMemo(() => {
let rows = applyVpsFilters(snapshot?.vps ?? [], filters)
if (!health || !snapshot) return rows
const now = new Date()
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const ctx = {
vps: snapshot.vps,
providerAccounts: snapshot.providerAccounts,
payments: snapshot.payments,
balanceLedger: snapshot.balanceLedger,
now,
}
if (health === 'no-project') {
rows = rows.filter((v) => v.status === 'active' && !(v.project || '').trim())
} else if (health === 'no-rate') {
rows = rows.filter((v) => {
if (v.status !== 'active') return false
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
})
} else if (health === 'paid-overdue') {
rows = rows.filter((v) => {
if (v.status !== 'active') return false
const d = getPaidUntilDate(v, ctx)
return d != null && d < todayStart
})
}
return rows
}, [snapshot, filters, health])
const countryOptions = useMemo(() => {
const names = new Set<string>()
@@ -335,7 +411,37 @@ function VpsPage() {
</Button>
}
>
{(snap) => (
{(snap) => {
const filtersActive = hasActiveVpsFilters(filters)
const zeroResults = snap.vps.length > 0 && filteredVps.length === 0 && filtersActive
if (zeroResults) {
return (
<div className="flex flex-col gap-4">
<VpsFiltersToolbar
filters={filters}
onChange={setFilters}
providers={snap.providers}
providerAccounts={snap.providerAccounts}
vps={snap.vps}
projectNameOptions={projectNameOptions}
countryOptions={countryOptions}
cityOptions={cityOptions}
/>
<EmptyState
title="Ничего не найдено"
description="По текущим фильтрам VPS не найдены"
action={
<Button variant="outline" onClick={() => setFilters(buildDefaultVpsFilters())}>
Сбросить фильтры
</Button>
}
/>
</div>
)
}
return (
<div className="flex flex-col gap-4">
<VpsFiltersToolbar
filters={filters}
@@ -361,7 +467,8 @@ function VpsPage() {
/>
))}
</div>
)}
)
}}
</QueryState>
<FormSheetRhf
+12 -1
View File
@@ -31,8 +31,13 @@ export interface ProviderAccount {
apiCredentialsSet?: boolean
billingMode?: BillingMode
balance_api?: number | null
balanceApi?: number | null
balance_currency?: string
balanceCurrency?: string
currency?: string
balanceAlertBelow?: number | null
enoughmoneyto?: string
balanceUpdatedAt?: string
notes?: string
}
@@ -100,6 +105,12 @@ export interface Settings {
ratesUrl?: string
autoConvert?: boolean
syncEnabled?: boolean
syncIntervalMinutes?: number
syncTariffsIntervalMinutes?: number
notifyLowBalanceEnabled?: boolean
notifySyncDigestEnabled?: boolean
notifyPaymentExpiryEnabled?: boolean
notifyNewTariffsEnabled?: boolean
telegramChatId?: string
telegramBotToken?: string
}
@@ -158,5 +169,5 @@ export interface DataSnapshot {
activeTariffs: ActiveTariff[]
tariffSyncOptions?: unknown[]
serverProjects?: unknown[]
syncLog?: SyncLogRow[]
syncLog: SyncLogRow[]
}
+4
View File
@@ -6,6 +6,7 @@ import { balanceLedgerRepository } from './balance-ledger.js'
import { settingsRepository } from './settings.js'
import { activeTariffsRepository, tariffSyncOptionsRepository } from './tariffs.js'
import { projectsRepository } from './projects.js'
import { syncLogRepository } from './sync-log.js'
export interface Snapshot {
vps: ReturnType<typeof vpsRepository.list>
@@ -17,6 +18,7 @@ export interface Snapshot {
settings: ReturnType<typeof settingsRepository.list>
activeTariffs: ReturnType<typeof activeTariffsRepository.list>
tariffSyncOptions: ReturnType<typeof tariffSyncOptionsRepository.list>
syncLog: ReturnType<typeof syncLogRepository.listRecent>
}
export function getSnapshot(): Snapshot {
@@ -30,6 +32,7 @@ export function getSnapshot(): Snapshot {
settings: settingsRepository.list(),
activeTariffs: activeTariffsRepository.list(),
tariffSyncOptions: tariffSyncOptionsRepository.list(),
syncLog: syncLogRepository.listRecent(50),
}
}
@@ -43,4 +46,5 @@ export {
activeTariffsRepository,
tariffSyncOptionsRepository,
projectsRepository,
syncLogRepository,
}
+49
View File
@@ -0,0 +1,49 @@
import { desc } from 'drizzle-orm'
import { getDb, schema } from '../index.js'
export interface SyncLogDto {
id: string
accountId: string
status: 'ok' | 'error' | 'running' | string | null
startedAt: string
finishedAt: string | null
vpsCount: number | null
paymentsCount: number | null
error: string | null
summary: Record<string, unknown> | null
}
function toDto(row: typeof schema.syncLog.$inferSelect): SyncLogDto {
let summary: Record<string, unknown> | null = null
if (row.summary) {
try {
const parsed = JSON.parse(row.summary) as unknown
summary = parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : null
} catch {
summary = null
}
}
return {
id: row.id,
accountId: row.accountId,
status: row.status,
startedAt: row.startedAt,
finishedAt: row.finishedAt,
vpsCount: row.vpsCount,
paymentsCount: row.paymentsCount,
error: row.error,
summary,
}
}
export const syncLogRepository = {
listRecent(limit = 50): SyncLogDto[] {
const rows = getDb()
.select()
.from(schema.syncLog)
.orderBy(desc(schema.syncLog.startedAt))
.limit(limit)
.all()
return rows.map(toDto)
},
}
+25 -2
View File
@@ -3,17 +3,40 @@ import { getDb, schema } from '../index.js'
type Row = typeof schema.activeTariffs.$inferSelect
export type ActiveTariffDto = Omit<Row, 'orderAvailable' | 'ramGb'> & {
export type ActiveTariffDto = Omit<Row, 'orderAvailable' | 'ramGb' | 'price'> & {
orderAvailable: boolean
ramGb: number
monthlyRate: number | null
currency: string | null
}
/** Парсит строку цены BILLmanager: «100.50 RUB», «€12», «12 USD». */
export function parseTariffPrice(price: string | null | undefined): {
monthlyRate: number | null
currency: string | null
} {
const raw = String(price ?? '').trim()
if (!raw) return { monthlyRate: null, currency: null }
const match = raw.match(/([\d.,]+)\s*([A-Za-z]{3})?/)
if (!match) return { monthlyRate: null, currency: null }
const monthlyRate = Number.parseFloat(match[1].replace(',', '.'))
const currency = match[2]?.toUpperCase() ?? null
return {
monthlyRate: Number.isFinite(monthlyRate) ? monthlyRate : null,
currency,
}
}
function toDto(row: Row | undefined): ActiveTariffDto | undefined {
if (!row) return undefined
const { monthlyRate, currency } = parseTariffPrice(row.price)
const { price: _price, ...rest } = row
return {
...row,
...rest,
orderAvailable: Boolean(row.orderAvailable),
ramGb: row.ramGb != null ? Number(row.ramGb) : 0,
monthlyRate,
currency,
}
}
+76
View File
@@ -0,0 +1,76 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@cfdm/ui/lib/utils"
const alertVariants = cva(
"group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-sm text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
className
)}
{...props}
/>
)
}
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-action"
className={cn("absolute top-2 right-2", className)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription, AlertAction }