243 lines
12 KiB
TypeScript
243 lines
12 KiB
TypeScript
import { PageHeader } from "@/components/page-header"
|
||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||
import { StatusDot } from "@/components/status-dot"
|
||
import { StatusBadge } from "@/components/status-badge"
|
||
import { Sparkline } from "@/components/sparkline"
|
||
import { LatencyChart } from "@/components/dashboard/latency-chart"
|
||
import { BandwidthChart } from "@/components/dashboard/bandwidth-chart"
|
||
import { servers, pingProbes, systemEvents, dashLatency, traffic } from "@/lib/data"
|
||
import { Flag } from "@/components/flag"
|
||
import { AlertCircleIcon, AlertTriangleIcon, InfoIcon, FilterIcon, DownloadIcon } from "lucide-react"
|
||
import { Button } from "@/components/ui/button"
|
||
|
||
function StatCard({
|
||
label, value, unit, delta, deltaDir, spark, sparkColor,
|
||
}: {
|
||
label: string; value: string; unit?: string; delta?: string
|
||
deltaDir?: "up" | "down"; spark?: number[]; sparkColor?: string
|
||
}) {
|
||
return (
|
||
<Card className="relative overflow-hidden">
|
||
<CardContent className="pt-5 pb-4 px-5">
|
||
<p className="text-sm font-medium text-muted-foreground">{label}</p>
|
||
<div className="flex items-baseline gap-1.5 mt-1">
|
||
<span className="text-3xl font-semibold tracking-tight tabular-nums">{value}</span>
|
||
{unit && <span className="text-sm text-muted-foreground">{unit}</span>}
|
||
</div>
|
||
{delta && (
|
||
<p className={`text-xs mt-1 flex items-center gap-1 ${deltaDir === "up" ? "text-emerald-600 dark:text-emerald-400" : deltaDir === "down" ? "text-red-500 dark:text-red-400" : "text-muted-foreground"}`}>
|
||
{delta}
|
||
</p>
|
||
)}
|
||
{spark && spark.length > 1 && (
|
||
<div className="absolute right-4 bottom-4 opacity-60">
|
||
<Sparkline data={spark} width={80} height={32} color={sparkColor ?? "currentColor"} filled />
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
export default function DashboardPage() {
|
||
return (
|
||
<div className="flex flex-col h-full">
|
||
<PageHeader
|
||
crumbs={[{ label: "Обзор" }, { label: "Дашборд" }]}
|
||
actions={
|
||
<>
|
||
<Button variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button>
|
||
</>
|
||
}
|
||
/>
|
||
|
||
<div className="flex-1 overflow-y-auto">
|
||
<div className="p-6 flex flex-col gap-6">
|
||
|
||
{/* KPI row */}
|
||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||
<StatCard
|
||
label="Серверы онлайн" value="6" unit="/ 7"
|
||
delta="+1 со вчерашнего" deltaDir="up"
|
||
spark={[5,5,6,6,5,6,6,6,7,6,6,6,6,6,6,6,6]}
|
||
sparkColor="var(--chart-line-1)"
|
||
/>
|
||
<StatCard
|
||
label="Активные фильтры" value="5" unit="/ 6"
|
||
delta="2.4к доменов синхронизировано" deltaDir="up"
|
||
spark={[3,4,4,5,4,5,5,5,5,5,5,5,5,5,5]}
|
||
sparkColor="var(--chart-line-2)"
|
||
/>
|
||
<StatCard
|
||
label="BGP-префиксы" value="8 432"
|
||
delta="+412 в последнем обновлении" deltaDir="up"
|
||
spark={[7800,7900,8000,8100,8050,8120,8200,8240,8300,8350,8380,8400,8420,8430,8432]}
|
||
sparkColor="var(--chart-line-4)"
|
||
/>
|
||
<StatCard
|
||
label="Активные алерты" value="4"
|
||
delta="2 критических · 2 предупреждения" deltaDir="down"
|
||
spark={[1,2,2,3,3,4,5,4,4,4,3,3,4,4,4]}
|
||
sparkColor="var(--chart-5)"
|
||
/>
|
||
</div>
|
||
|
||
{/* Latency chart + Events */}
|
||
<div className="grid grid-cols-1 lg:grid-cols-[2fr_1fr] gap-4">
|
||
<Card>
|
||
<CardHeader className="pb-2">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<CardTitle className="text-base">Задержка до серверов</CardTitle>
|
||
<p className="text-sm text-muted-foreground mt-0.5">Последние 60 минут · ping от монитора → серверы MikroTik</p>
|
||
</div>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent className="pt-0 px-3">
|
||
<LatencyChart series={dashLatency} />
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card>
|
||
<CardHeader className="pb-2">
|
||
<div className="flex items-center justify-between">
|
||
<CardTitle className="text-base">Последние события</CardTitle>
|
||
<Button variant="ghost" size="sm" className="text-xs h-7">Все →</Button>
|
||
</div>
|
||
<p className="text-sm text-muted-foreground">Система и BGP-активность</p>
|
||
</CardHeader>
|
||
<CardContent className="pt-0 px-0">
|
||
<div className="divide-y divide-border">
|
||
{systemEvents.map((e) => (
|
||
<div key={e.id} className="grid grid-cols-[20px_1fr_auto] gap-3 px-5 py-3 items-start">
|
||
<div className="mt-0.5">
|
||
{e.sev === "destructive" && <AlertCircleIcon className="size-4 text-red-500" />}
|
||
{e.sev === "warning" && <AlertTriangleIcon className="size-4 text-amber-500" />}
|
||
{e.sev === "info" && <InfoIcon className="size-4 text-blue-500" />}
|
||
</div>
|
||
<div>
|
||
<p className="text-[13px] font-medium leading-tight">{e.title}</p>
|
||
<p className="text-xs text-muted-foreground mt-0.5 leading-snug">{e.message}</p>
|
||
</div>
|
||
<span className="text-[11px] font-mono text-muted-foreground">{e.when}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
|
||
{/* Bandwidth + Server status */}
|
||
<div className="grid grid-cols-1 lg:grid-cols-[3fr_2fr] gap-4">
|
||
<Card>
|
||
<CardHeader className="pb-2">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<CardTitle className="text-base">Пропускная способность</CardTitle>
|
||
<p className="text-sm text-muted-foreground mt-0.5">Суммарный RX / TX по всем серверам</p>
|
||
</div>
|
||
<div className="flex items-center gap-3 text-xs">
|
||
<span className="flex items-center gap-1.5"><span className="w-3 h-0.5 bg-foreground/80 rounded inline-block" />RX 318 Мбит/с</span>
|
||
<span className="flex items-center gap-1.5"><span className="w-3 h-0.5 bg-blue-500 rounded inline-block" />TX 244 Мбит/с</span>
|
||
</div>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent className="pt-0 px-3">
|
||
<BandwidthChart rx={traffic.rx} tx={traffic.tx} />
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card>
|
||
<CardHeader className="pb-2">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<CardTitle className="text-base">Состояние серверов</CardTitle>
|
||
<p className="text-sm text-muted-foreground mt-0.5">7 узлов MikroTik</p>
|
||
</div>
|
||
<Button variant="outline" size="sm" className="h-7 text-xs">Управление →</Button>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent className="pt-0 px-0">
|
||
<div className="divide-y divide-border">
|
||
{servers.slice(0, 5).map((s) => (
|
||
<div key={s.id} className="grid grid-cols-[10px_1fr_auto_auto] gap-3 px-5 py-2.5 items-center">
|
||
<StatusDot status={s.status} pulse />
|
||
<div>
|
||
<p className="text-[13px] font-medium leading-tight">{s.name}</p>
|
||
<p className="text-[11px] font-mono text-muted-foreground flex items-center gap-1">
|
||
<Flag code={s.country} className="not-mono" />{s.host} · {s.site} · {s.asn}
|
||
</p>
|
||
</div>
|
||
<span className={`text-xs font-mono ${s.latency == null ? "text-red-500" : s.latency > 60 ? "text-amber-500" : "text-muted-foreground"}`}>
|
||
{s.latency == null ? "недоступен" : `${s.latency}мс`}
|
||
</span>
|
||
<StatusBadge status={s.status} />
|
||
</div>
|
||
))}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
|
||
{/* Ping probes table */}
|
||
<Card>
|
||
<CardHeader className="pb-2">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<CardTitle className="text-base">Активные пробы</CardTitle>
|
||
<p className="text-sm text-muted-foreground mt-0.5">8 ping-зондов · опрос раз в секунду</p>
|
||
</div>
|
||
<Button variant="outline" size="sm" className="h-7 text-xs">Открыть монитор →</Button>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent className="pt-0 px-0">
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-sm">
|
||
<thead>
|
||
<tr className="border-b border-border text-xs text-muted-foreground">
|
||
<th className="text-left font-medium px-5 py-2.5">Проба</th>
|
||
<th className="text-left font-medium px-4 py-2.5">Цель</th>
|
||
<th className="text-left font-medium px-4 py-2.5">Фильтр</th>
|
||
<th className="text-right font-medium px-4 py-2.5">RTT</th>
|
||
<th className="text-right font-medium px-4 py-2.5">Потери</th>
|
||
<th className="text-left font-medium px-4 py-2.5 w-36">60с</th>
|
||
<th className="text-left font-medium px-4 py-2.5">Статус</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-border">
|
||
{pingProbes.map((p) => {
|
||
const sparkColor = p.status === "down" ? "hsl(0 84% 60%)" : p.status === "warn" ? "hsl(32 94% 44%)" : "hsl(142 76% 36%)"
|
||
return (
|
||
<tr key={p.id} className="hover:bg-muted/40 transition-colors">
|
||
<td className="px-5 py-2.5 font-medium">{p.name}</td>
|
||
<td className="px-4 py-2.5 font-mono text-xs text-muted-foreground">{p.target}</td>
|
||
<td className="px-4 py-2.5">
|
||
<span className="inline-flex items-center gap-1 text-xs border border-border rounded px-2 py-0.5">
|
||
<FilterIcon className="size-3 text-muted-foreground" />{p.filter}
|
||
</span>
|
||
</td>
|
||
<td className="px-4 py-2.5 font-mono text-right">{p.rtt == null ? "—" : `${p.rtt} мс`}</td>
|
||
<td className={`px-4 py-2.5 font-mono text-right ${p.loss > 5 ? "text-red-500" : p.loss > 0 ? "text-amber-500" : "text-muted-foreground"}`}>
|
||
{p.loss}%
|
||
</td>
|
||
<td className="px-4 py-2.5">
|
||
<Sparkline data={p.series} width={120} height={24} color={sparkColor} />
|
||
</td>
|
||
<td className="px-4 py-2.5">
|
||
<StatusBadge status={p.status === "up" ? "online" : p.status === "warn" ? "degraded" : "offline"} />
|
||
</td>
|
||
</tr>
|
||
)
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|