"use client" import { useCallback, useEffect, useMemo, useState } from "react" import { usePathname } from "next/navigation" import Link from "next/link" 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 as mockServers, pingProbes, systemEvents, dashLatency, traffic, serverFilterRulesets, } from "@/lib/data" import type { PingProbe, Server, ServerStatus, ServerType } from "@/lib/data" import { useDataSource } from "@/lib/data-source" import { Flag } from "@/components/flag" import { AlertCircleIcon, AlertTriangleIcon, InfoIcon, FilterIcon, DownloadIcon } from "lucide-react" import { Button, buttonVariants } from "@/components/ui/button" import { cn } from "@/lib/utils" import { requestJson } from "@/shared/api/http-client" import { buildLatencySeriesByProbeSource } from "@/lib/dashboard-latency" function makeApiFetch(backendUrl: string) { return async function apiFetch(path: string, init?: RequestInit): Promise { return requestJson(backendUrl, path, init) } } 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 (

{label}

{value} {unit && {unit}}
{delta && (

{delta}

)} {spark && spark.length > 1 && (
)}
) } function formatLossPct(loss: number): string { if (!Number.isFinite(loss)) return "—" return Number.isInteger(loss) ? `${loss}%` : `${loss.toFixed(1)}%` } function fmtIntRu(n: number): string { return n.toLocaleString("ru-RU") } interface LiveKpiSnapshot { filters: { ruleTotal: number; serversWithRules: number } | null bgp: { prefixSum: number; establishedCount: number } | null } /** Синхронизируется со страницей мониторинга (mock) */ const MOCK_DASH_STARS_LS = "mm:dashboard-probe-ids" const UPTIME_PROBES_CHANGED = "mm:uptime-probes-changed" function readMockDashboardStarIds(): Set { if (typeof window === "undefined") return new Set() try { const raw = localStorage.getItem(MOCK_DASH_STARS_LS) const arr = raw ? (JSON.parse(raw) as unknown) : [] return new Set(Array.isArray(arr) ? arr.filter((x): x is string => typeof x === "string") : []) } catch { return new Set() } } /** Совпадает с эталоном uptime / servers */ function TypeChip({ type }: { type: ServerType }) { return ( {type === "jump-host" ? "JH" : type === "home-router" ? "HR" : "EN"} ) } interface BackendServerRow { id: number name: string host: string site: string country: string asn: string type: ServerType enabled: boolean status: "online" | "offline" | null latency: number | null os: string | null model: string | null sessions?: number } function mapBackendToServer(s: BackendServerRow): Server { return { id: String(s.id), name: s.name || s.host, host: s.host, model: s.model ?? "—", os: s.os ?? "—", site: s.site || "—", country: s.country || "UN", asn: s.asn, type: s.type, enabled: s.enabled, status: (s.status ?? "offline") as ServerStatus, latency: s.latency != null ? Math.round(s.latency) : null, sessions: s.sessions ?? 0, } } function ProbeSourceCell({ probe, catalog }: { probe: PingProbe; catalog: Server[] }) { const srv = catalog.find(s => s.id === probe.srcServerId) const iface = (probe.srcInterface ?? "").trim() || "auto" if (!srv) { return (

Сервер {probe.srcServerId}

{iface}

) } return (
{srv.name}

{iface} {srv.site && srv.site !== "—" && ( · {srv.site} )}

) } export default function DashboardPage() { const pathname = usePathname() const { mode, backendUrl, backendStatus } = useDataSource() const isLive = mode === "live" && backendStatus === true const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl]) const [liveProbes, setLiveProbes] = useState(null) const [liveServersResolved, setLiveServersResolved] = useState(null) const [liveKpi, setLiveKpi] = useState(null) const [probesLoading, setProbesLoading] = useState(false) const [probesError, setProbesError] = useState(null) const probeServerCatalog = useMemo(() => { if (!isLive) return mockServers return liveServersResolved ?? [] }, [isLive, liveServersResolved]) const fetchProbes = useCallback(async (silent: boolean) => { if (!isLive) return if (!silent) setProbesLoading(true) try { const overview = await apiFetch<{ probes: PingProbe[] }>("/api/uptime/overview?range=1h") setLiveProbes(overview.probes) setProbesError(null) try { const backendServers = await apiFetch("/api/servers") setLiveServersResolved(backendServers.map(mapBackendToServer)) } catch { setLiveServersResolved([]) } const [fr, br] = await Promise.allSettled([ apiFetch<{ rulesets: Array<{ rules?: unknown[] }> }>("/api/filters/rules"), apiFetch>("/api/bgp/sessions"), ]) let filtersPart: LiveKpiSnapshot["filters"] = null if (fr.status === "fulfilled") { const rs = fr.value.rulesets ?? [] const ruleTotal = rs.reduce((n, x) => n + (Array.isArray(x.rules) ? x.rules.length : 0), 0) const serversWithRules = rs.filter((x) => Array.isArray(x.rules) && x.rules.length > 0).length filtersPart = { ruleTotal, serversWithRules } } let bgpPart: LiveKpiSnapshot["bgp"] = null if (br.status === "fulfilled") { let prefixSum = 0 let establishedCount = 0 for (const s of br.value) { const st = String(s.state ?? "") if (/established/i.test(st)) { establishedCount += 1 prefixSum += Number(s.prefixesRx ?? 0) } } bgpPart = { prefixSum, establishedCount } } setLiveKpi({ filters: filtersPart, bgp: bgpPart }) } catch (e) { const msg = e instanceof Error ? e.message : "Не удалось загрузить пробы" setProbesError(msg) setLiveKpi(null) if (!silent) { setLiveProbes(null) setLiveServersResolved(null) } } finally { if (!silent) setProbesLoading(false) } }, [apiFetch, isLive]) useEffect(() => { if (!isLive) { queueMicrotask(() => { setLiveProbes(null) setLiveServersResolved(null) setLiveKpi(null) setProbesError(null) }) return } let cancelled = false queueMicrotask(() => { if (cancelled) return void fetchProbes(false) }) return () => { cancelled = true } }, [isLive, fetchProbes]) useEffect(() => { if (!isLive) return const id = setInterval(() => { queueMicrotask(() => { void fetchProbes(true) }) }, 60_000) return () => clearInterval(id) }, [isLive, fetchProbes]) /** Live: после звезды на мониторинге данные на дашборде должны подтянуться сразу (раньше только при монтировании и раз в 60 с). */ useEffect(() => { if (!isLive) return queueMicrotask(() => { void fetchProbes(true) }) }, [pathname, isLive, fetchProbes]) const [mockDashEpoch, setMockDashEpoch] = useState(0) useEffect(() => { const bumpMock = () => setMockDashEpoch((x) => x + 1) const onStorage = (e: StorageEvent) => { if (e.key === MOCK_DASH_STARS_LS) bumpMock() } const onVis = () => { if (document.visibilityState === "visible") bumpMock() } const onUptimeChanged = () => { bumpMock() if (isLive) void fetchProbes(true) } window.addEventListener(UPTIME_PROBES_CHANGED, onUptimeChanged) window.addEventListener("storage", onStorage) document.addEventListener("visibilitychange", onVis) return () => { window.removeEventListener(UPTIME_PROBES_CHANGED, onUptimeChanged) window.removeEventListener("storage", onStorage) document.removeEventListener("visibilitychange", onVis) } }, [isLive, fetchProbes]) useEffect(() => { queueMicrotask(() => setMockDashEpoch((x) => x + 1)) }, [pathname]) const mockActiveProbes = useMemo(() => { void mockDashEpoch const stars = readMockDashboardStarIds() return pingProbes.filter(p => p.enabled && stars.has(p.id)) }, [mockDashEpoch]) const activeProbesTable = useMemo(() => { if (!isLive) return mockActiveProbes if (liveProbes === null && probesLoading) return [] if (liveProbes === null) return [] return liveProbes.filter((p) => p.enabled && p.showOnDashboard === true) }, [isLive, liveProbes, probesLoading, mockActiveProbes]) const probesSubtitle = useMemo(() => { if (!isLive) { const starred = mockActiveProbes.length return starred > 0 ? `${starred} на дашборде · мок-данные · отметьте звезды в мониторинге` : "Нет проб на дашборде · отметьте звёздочкой на странице мониторинга" } if (probesError && liveProbes === null) return probesError const n = activeProbesTable.length return n > 0 ? `${n} на дашборде · последний час (API)` : "Нет проб на дашборде · отметьте звёздочкой на странице мониторинга" }, [isLive, mockActiveProbes.length, probesError, liveProbes, activeProbesTable.length]) /** Карточка «Состояние серверов»: в Live — `/api/servers` (тот же запрос, что и для каталога проб). */ const serverStatusModel = useMemo(() => { if (!isLive) { return { kind: "mock" as const, servers: mockServers.slice(0, 5), subtitle: `${mockServers.length} узлов MikroTik · демо`, } } if (liveServersResolved === null) { if (probesLoading) { return { kind: "loading" as const, subtitle: "Загрузка…" } } return { kind: "unavailable" as const, subtitle: probesError ? "Нет данных · проверьте backend" : "Нет данных о серверах", } } const servers = [...liveServersResolved].sort((a, b) => { if (a.enabled !== b.enabled) return a.enabled ? -1 : 1 return a.name.localeCompare(b.name, "ru") }) const onlineN = servers.filter((s) => s.status === "online").length return { kind: "live" as const, servers, subtitle: `${servers.length} узлов · ${onlineN} онлайн · API`, } }, [isLive, liveServersResolved, probesLoading, probesError]) const latencyChartBlock = useMemo(() => { if (!isLive) { return { kind: "mock" as const, series: dashLatency, labels: undefined as Record | undefined, subtitle: "Последние 60 минут · ping от монитора → серверы MikroTik · демо", } } if (liveProbes === null && probesLoading) { return { kind: "loading" as const } } if (!liveProbes?.length) { return { kind: "empty" as const, message: "Нет данных проб. Откройте мониторинг и проверьте сборщик uptime.", } } const catalog = liveServersResolved ?? [] const { series, labels } = buildLatencySeriesByProbeSource( liveProbes, catalog, { maxServers: 8, points: 60 }, ) if (Object.keys(series).length === 0) { return { kind: "empty" as const, message: "Нет включённых проб с историей RTT. Включите пробы на странице «Мониторинг».", } } return { kind: "live" as const, series, labels, subtitle: "Средний RTT по источникам проб · окно 1 ч · до 8 узлов · API", } }, [isLive, liveProbes, probesLoading, liveServersResolved]) const dashboardKpi = useMemo(() => { const sparkSrv = [5, 5, 6, 6, 5, 6, 6, 6, 7, 6, 6, 6, 6, 6, 6, 6, 6] const sparkFlt = [3, 4, 4, 5, 4, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5] const sparkBgp = [7800, 7900, 8000, 8100, 8050, 8120, 8200, 8240, 8300, 8350, 8380, 8400, 8420, 8430, 8432] const sparkAlt = [1, 2, 2, 3, 3, 4, 5, 4, 4, 4, 3, 3, 4, 4, 4] if (!isLive) { const totalSrv = mockServers.length const onlineSrv = mockServers.filter((s) => s.status === "online").length const ruleTotal = serverFilterRulesets.reduce((n, rs) => n + rs.rules.length, 0) const serversWithRules = serverFilterRulesets.filter((rs) => rs.rules.length > 0).length const en = pingProbes.filter((p) => p.enabled) const down = en.filter((p) => p.status === "down").length const warn = en.filter((p) => p.status === "warn").length return { servers: { value: String(onlineSrv), unit: `/ ${totalSrv}`, delta: `${totalSrv - onlineSrv} offline`, deltaDir: onlineSrv < totalSrv ? ("down" as const) : ("up" as const), spark: sparkSrv, sparkColor: "var(--chart-line-1)", }, filters: { value: String(ruleTotal), unit: `/ ${serverFilterRulesets.length}`, delta: `${serversWithRules} серверов с правилами · демо`, deltaDir: "up" as const, spark: sparkFlt, sparkColor: "var(--chart-line-2)", }, bgp: { value: "8 432", unit: undefined as string | undefined, delta: "демо · не из API", deltaDir: "up" as const, spark: sparkBgp, sparkColor: "var(--chart-line-4)", }, alerts: { value: String(down + warn), unit: undefined as string | undefined, delta: `${down} down · ${warn} warn`, deltaDir: down + warn > 0 ? ("down" as const) : ("up" as const), spark: sparkAlt, sparkColor: "var(--chart-5)", }, } } const loadingBlock = probesLoading && liveProbes === null const srvList = liveServersResolved ?? [] const totalSrv = srvList.length const onlineSrv = srvList.filter((s) => s.status === "online").length const offlineSrv = Math.max(0, totalSrv - onlineSrv) const en = liveProbes?.filter((p) => p.enabled) ?? [] const down = en.filter((p) => p.status === "down").length const warn = en.filter((p) => p.status === "warn").length const filters = liveKpi?.filters const bgp = liveKpi?.bgp return { servers: { value: loadingBlock ? "—" : String(onlineSrv), unit: loadingBlock ? undefined : `/ ${totalSrv}`, delta: loadingBlock ? "Загрузка…" : offlineSrv > 0 ? `${offlineSrv} offline · опрос API` : totalSrv > 0 ? "Все узлы online в последнем опросе" : "Нет серверов в базе", deltaDir: offlineSrv > 0 ? ("down" as const) : ("up" as const), spark: undefined as number[] | undefined, sparkColor: "var(--chart-line-1)", }, filters: { value: loadingBlock ? "—" : filters ? String(filters.ruleTotal) : "—", unit: filters && filters.serversWithRules > 0 ? `на ${filters.serversWithRules} серв.` : undefined, delta: loadingBlock ? "Загрузка…" : filters ? `${filters.serversWithRules} серверов с правилами · /api/filters/rules` : "Не удалось загрузить правила", deltaDir: filters ? ("up" as const) : ("down" as const), spark: undefined as number[] | undefined, sparkColor: "var(--chart-line-2)", }, bgp: { value: loadingBlock ? "—" : bgp ? fmtIntRu(bgp.prefixSum) : "—", unit: undefined as string | undefined, delta: loadingBlock ? "Загрузка…" : bgp ? `Σ prefixes Rx · ${bgp.establishedCount} Established · /api/bgp/sessions` : "Не удалось загрузить BGP", deltaDir: (bgp ? "up" : "down") as "up" | "down", spark: undefined as number[] | undefined, sparkColor: "var(--chart-line-4)", }, alerts: { value: loadingBlock ? "—" : String(down + warn), unit: undefined as string | undefined, delta: loadingBlock ? "Загрузка…" : `${down} down · ${warn} warn · включённые пробы`, deltaDir: down + warn > 0 ? ("down" as const) : ("up" as const), spark: undefined as number[] | undefined, sparkColor: "var(--chart-5)", }, } }, [isLive, liveServersResolved, liveKpi, liveProbes, probesLoading]) return (
} />
{/* KPI row */}
{/* Latency chart + Events */}
Задержка до серверов

{latencyChartBlock.kind === "mock" && latencyChartBlock.subtitle} {latencyChartBlock.kind === "live" && latencyChartBlock.subtitle} {latencyChartBlock.kind === "loading" && "Загрузка…"} {latencyChartBlock.kind === "empty" && "Нет серии RTT для графика"}

{latencyChartBlock.kind === "loading" && (
)} {latencyChartBlock.kind === "empty" && (

{latencyChartBlock.message}

)} {(latencyChartBlock.kind === "mock" || latencyChartBlock.kind === "live") && ( )}
Последние события

Система и BGP-активность

{systemEvents.map((e) => (
{e.sev === "destructive" && } {e.sev === "warning" && } {e.sev === "info" && }

{e.title}

{e.message}

{e.when}
))}
{/* Bandwidth + Server status */}
Пропускная способность

Суммарный RX / TX по всем серверам

RX 318 Мбит/с TX 244 Мбит/с
Состояние серверов

{serverStatusModel.subtitle}

Управление →
{serverStatusModel.kind === "loading" && ( <> {Array.from({ length: 5 }, (_, i) => (
))} )} {serverStatusModel.kind === "unavailable" && (
{serverStatusModel.subtitle}
)} {(serverStatusModel.kind === "mock" || serverStatusModel.kind === "live") && serverStatusModel.servers.length === 0 && (
Нет серверов в базе. Добавьте узел на странице «Серверы».
)} {(serverStatusModel.kind === "mock" || serverStatusModel.kind === "live") && serverStatusModel.servers.map((s) => (

{s.name}

{s.host} · {s.site} · {s.asn}

60 ? "text-[var(--status-degraded-fg)]" : "text-muted-foreground"}`}> {s.latency == null ? "недоступен" : `${s.latency}мс`}
))}
{/* Ping probes table */}
Активные пробы

{probesSubtitle}

Открыть монитор →
{isLive && probesLoading && liveProbes === null && ( )} {!(isLive && probesLoading && liveProbes === null) && activeProbesTable.map((p) => { const sparkColor = p.status === "down" ? "hsl(0 84% 60%)" : p.status === "warn" ? "hsl(32 94% 44%)" : "hsl(142 76% 36%)" return ( ) })} {!(isLive && probesLoading && liveProbes === null) && activeProbesTable.length === 0 && ( )}
Источник Проба Цель Фильтр RTT Потери 60с Статус
{p.name} {p.target} {p.filter} {p.rtt == null ? "—" : `${p.rtt} мс`} 5 ? "text-red-500" : p.loss > 0 ? "text-amber-500" : "text-muted-foreground"}`}> {formatLossPct(p.loss)}
{isLive && probesError ? "Нет данных о пробах. Проверьте сборщик uptime и настройки проб на странице мониторинга." : "Нет проб с звездой на дашборде. Включите пробу и отметьте ★ в разделе «Мониторинг»."}
) }