Files
vps-tracker/apps/web/src/routes/_auth/dashboard.tsx
T
DenozordecandCursor 6fbd1a9113 refactor(repo): переход на pnpm monorepo с shadcn/ui и Fastify+Drizzle
Frontend:
- apps/web (Vite+TS, TanStack Router/Query, shadcn/ui @cfdm/ui base-nova)
- 10 страниц в routes/_auth/, Recharts через shadcn Chart, lucide-react
- формы на RHF + Zod (FormSheet/FormField)
- удалены Tabler, Chart.js, react-router-dom

Backend (параллельный трек):
- apps/api (Fastify 5 + Drizzle + better-sqlite3)
- packages/db: Drizzle-схема и repositories по сущностям
- packages/shared: Zod-контракты
- роуты с валидацией и единым форматом ошибок { error: { code, message } }
- sync/backup — заглушки 501 (billmanager-адаптеры переносятся отдельно)
- legacy Express оставлен как runtime по умолчанию (RUNTIME=express)

Infra:
- Dockerfile multi-stage под pnpm workspaces
- .dockerignore и docker-compose обновлены под monorepo

Rules:
- удалены нерелевантные правила (rust, cloudflare, server/frontend-conventions)
- project-structure.mdc и AGENTS.md переписаны под monorepo
- frontend-shadcn.mdc, shadcn-ui-production.mdc, sqlite.mdc обновлены

Co-authored-by: Cursor <[email protected]>
2026-06-26 13:42:05 +07:00

195 lines
8.2 KiB
TypeScript

import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { ServerIcon, AlertTriangleIcon, WalletIcon, TrendingUpIcon } from 'lucide-react'
import { snapshotQueryOptions, ratesQueryOptions } from '@/queries/snapshot'
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 { TableCard } from '@/components/table-card'
import { SectionCardsSkeleton } from '@/components/skeletons'
import { EmptyState } from '@/components/empty-state'
import { Button } from '@cfdm/ui/components/button'
import { Badge } from '@cfdm/ui/components/badge'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@cfdm/ui/components/table'
import { computeInventoryHealth } from '@/lib/inventory-health'
import { formatInBaseCurrency, normalizeRatesPayload } from '@/lib/format'
import { vpsStatusLabel } from '@/lib/format'
export const Route = createFileRoute('/_auth/dashboard')({
loader: ({ context: { queryClient } }) =>
queryClient.ensureQueryData(snapshotQueryOptions()),
component: DashboardPage,
})
function DashboardPage() {
const navigate = useNavigate()
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
const settings = snapshot?.settings?.[0]
const { data: rawRates } = useQuery(ratesQueryOptions(settings?.ratesUrl))
const ratesData = normalizeRatesPayload(rawRates) ?? rawRates ?? null
return (
<PageShell>
<PageHeader title="Дашборд" description="Сводка по VPS, балансам и здоровью инвентаря" />
<QueryState
data={snapshot}
isLoading={isLoading}
isError={isError}
error={error}
onRetry={() => refetch()}
skeleton={<SectionCardsSkeleton />}
>
{(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,
)
return (
<>
<SectionCards
items={[
{
label: 'Активные VPS',
value: activeVps.length,
icon: <ServerIcon className="size-4" />,
hint: `всего ${snap.vps.length}`,
},
{
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),
icon: <TrendingUpIcon className="size-4" />,
},
{
label: 'Баланс аккаунтов (API)',
value: formatInBaseCurrency(totalBalance, snap.settings[0]?.baseCurrency ?? 'RUB', snap.settings, ratesData),
icon: <WalletIcon className="size-4" />,
},
]}
/>
<TableCard
title="Здоровье инвентаря"
description="Подсказки: нет проекта, нет ставки, просрочка, устаревший синк, расхождения баланса"
actions={
issues.length > 0 ? (
<Badge variant="destructive">{issues.length}</Badge>
) : (
<Badge variant="secondary">всё ок</Badge>
)
}
>
{issues.length === 0 ? (
<div className="p-4">
<EmptyState
icon={<TrendingUpIcon className="size-8" />}
title="Проблем не найдено"
description="Все активные VPS имеют проект, ставку и актуальный синк"
/>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Проблема</TableHead>
<TableHead className="w-24 text-right">Кол-во</TableHead>
<TableHead className="w-32">Действие</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{issues.map((issue) => (
<TableRow key={issue.key}>
<TableCell>
<div className="flex items-center gap-2">
<AlertTriangleIcon className="size-4 text-destructive" />
<div className="flex flex-col">
<span>{issue.title}</span>
{issue.hint ? (
<span className="text-xs text-muted-foreground">{issue.hint}</span>
) : null}
</div>
</div>
</TableCell>
<TableCell className="text-right tabular-nums">{issue.count}</TableCell>
<TableCell>
<Button
variant="outline"
size="sm"
onClick={() => navigate({ to: issue.to })}
>
Открыть
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</TableCard>
<TableCard title="Последние VPS" description="Активные серверы">
<Table>
<TableHeader>
<TableRow>
<TableHead>IP / DNS</TableHead>
<TableHead>Проект</TableHead>
<TableHead>Статус</TableHead>
<TableHead className="text-right">Ставка/мес</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{activeVps.slice(0, 8).map((v) => (
<TableRow key={v.id}>
<TableCell className="font-medium">{v.ip || v.dns}</TableCell>
<TableCell className="text-muted-foreground">{v.project || '—'}</TableCell>
<TableCell>
<Badge variant={v.status === 'active' ? 'default' : 'secondary'}>
{vpsStatusLabel(v.status)}
</Badge>
</TableCell>
<TableCell className="text-right tabular-nums">
{formatInBaseCurrency(
v.tariffType === 'daily' ? Number(v.dailyRate || 0) * 30 : Number(v.monthlyRate || 0),
v.currency,
snap.settings,
ratesData,
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableCard>
</>
)
}}
</QueryState>
</PageShell>
)
}