"use client" import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react" import { usePathname } from "next/navigation" import Link from "next/link" import { PageHeader } from "@/components/page-header" import { OpsPanel } from "@/components/ops-panel" import { Frame, FramePanel } from "@/components/reui/frame" import { IconTile } from "@/components/reui/icon-tile" 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 { InternetPathMapCard } from "@/components/dashboard/internet-path-map" import { servers as mockServers, pingProbes, dashLatency, traffic, serverFilterRulesets, } from "@/lib/data" import type { PingProbe, Server, ServerStatus, ServerType } from "@/lib/data" import type { GreTunnel } from "@/lib/data" import { useDataSource } from "@/lib/data-source" import { Flag } from "@/components/flag" import { AlertCircleIcon, AlertTriangleIcon, BellIcon, FilterIcon, GitMergeIcon, InfoIcon, DownloadIcon, ServerIcon } from "lucide-react" import { Button, buttonVariants } from "@/components/ui/button" import { DataPageCard } from "@/components/data-page-card" import { DashboardActiveProbesDataGrid } from "@/components/data-grids/dashboard-active-probes-data-grid" import { cn } from "@/lib/utils" import { requestJson } from "@/shared/api/http-client" import { buildLatencySeriesByProbeSource } from "@/lib/dashboard-latency" import { buildDashboardInternetPath, type HomeWanRuntime, resolveDefaultRouteLookup, type InternetPathViewModel, } from "@/lib/dashboard-internet-path" import type { FiltersRulesetRow, RouteOptimizerSpeedProbe } from "@/lib/route-optimizer-data" import { listEvents } from "@/shared/api/events" import type { EventItem } from "@mmapp/contracts/events" 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, icon, }: { label: string; value: string; unit?: string; delta?: string deltaDir?: "up" | "down"; spark?: number[]; sparkColor?: string icon?: ReactNode }) { return (
{icon ? ( ) : null}

{label}

{value} {unit ? {unit} : null}
{delta ? (

{delta}

) : null}
{spark && spark.length > 1 ? (
) : null}
) } function fmtIntRu(n: number): string { return n.toLocaleString("ru-RU") } function formatEventAge(iso: string): string { const ts = Date.parse(iso) if (!Number.isFinite(ts)) return "—" const diffMs = Math.max(0, Date.now() - ts) const minutes = Math.floor(diffMs / 60_000) if (minutes < 1) return "сейчас" if (minutes < 60) return `${minutes}м` const hours = Math.floor(minutes / 60) if (hours < 24) return `${hours}ч` const days = Math.floor(hours / 24) return `${days}д` } 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() } } 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 wanUplinks?: Array<{ id: string name: string isp: string iface: string ip: string maxDl: number maxUl: number }> } interface ApiGreTunnelRow { id: string name: string serverId: string localAddress: string remoteAddress: string localInnerIp: string remoteInnerIp: string poolId: string ipsec: null mtu: number keepaliveInterval: number keepaliveRetries: number dscp: "inherit" | number clampTcpMss: boolean allowFastPath: boolean comment: string enabled: boolean status: "up" | "down" | "degraded" } interface InternetPathSnapshotPayload { sampledAt: string servers: BackendServerRow[] greTunnels: ApiGreTunnelRow[] filtersRulesets: FiltersRulesetRow[] speedProbes: RouteOptimizerSpeedProbe[] routeLookupByServerId: Record wanRuntimeByHomeId: Record } function apiGreToGreTunnel(t: ApiGreTunnelRow): GreTunnel { return { id: t.id, name: t.name, serverId: String(t.serverId), localAddress: t.localAddress, remoteAddress: t.remoteAddress, localInnerIp: t.localInnerIp, remoteInnerIp: t.remoteInnerIp, poolId: t.poolId || "live", ipsec: null, mtu: t.mtu, keepaliveInterval: t.keepaliveInterval, keepaliveRetries: t.keepaliveRetries, dscp: t.dscp, clampTcpMss: t.clampTcpMss, allowFastPath: t.allowFastPath, comment: t.comment, enabled: t.enabled, status: t.status, } } function mapBackendToServer(s: BackendServerRow): Server { const wanUplinks = Array.isArray(s.wanUplinks) ? s.wanUplinks .filter((w) => typeof w === "object" && w != null) .map((w, idx) => ({ id: String(w.id || `wan-${s.id}-${idx + 1}`), name: String(w.name || `WAN${idx + 1}`), isp: String(w.isp || "—"), iface: String(w.iface || ""), ip: String(w.ip || ""), maxDl: Math.max(1, Math.round(Number(w.maxDl) || 100)), maxUl: Math.max(1, Math.round(Number(w.maxUl) || 100)), })) : [] 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, wanUplinks, } } export default function DashboardPage() { const pathname = usePathname() const { mode, backendUrl, prefsHydrated } = useDataSource() const isLive = prefsHydrated && mode === "live" 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 [recentEvents, setRecentEvents] = useState([]) const [eventsLoading, setEventsLoading] = useState(false) const [eventsError, setEventsError] = useState(null) const [internetPath, setInternetPath] = useState(null) const [internetPathLoading, setInternetPathLoading] = useState(false) const [internetPathError, setInternetPathError] = useState(null) const probeServerCatalog = useMemo(() => { if (!prefsHydrated) return [] if (!isLive) return mockServers return liveServersResolved ?? [] }, [prefsHydrated, isLive, liveServersResolved]) const fetchProbes = useCallback(async (silent: boolean) => { if (!isLive) return if (!silent) setProbesLoading(true) if (!silent) setInternetPathLoading(true) try { const overview = await apiFetch<{ probes: PingProbe[] }>("/api/uptime/overview?range=1h") setLiveProbes(overview.probes) setProbesError(null) setInternetPathError(null) let serversMapped: Server[] = [] try { const backendServers = await apiFetch("/api/servers") serversMapped = backendServers.map(mapBackendToServer) setLiveServersResolved(serversMapped) } catch { serversMapped = [] setLiveServersResolved([]) } const [fr, br, ipRes] = await Promise.allSettled([ apiFetch<{ rulesets: Array<{ rules?: unknown[] }> }>("/api/filters/rules"), apiFetch>("/api/bgp/sessions"), apiFetch<{ snapshot: InternetPathSnapshotPayload | null }>("/api/internet-path/latest"), ]) 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 }) if (serversMapped.length > 0) { const snap = ipRes.status === "fulfilled" ? ipRes.value.snapshot : null if (snap) { const snapshotServers = snap.servers.map(mapBackendToServer) setInternetPath(buildDashboardInternetPath({ servers: snapshotServers, greTunnels: (snap.greTunnels ?? []).map(apiGreToGreTunnel), probes: snap.speedProbes ?? [], filtersRulesets: snap.filtersRulesets ?? [], routeLookupByServerId: snap.routeLookupByServerId ?? {}, wanRuntimeByHomeId: snap.wanRuntimeByHomeId ?? {}, })) } else { const filterRulesets: FiltersRulesetRow[] = fr.status === "fulfilled" ? (fr.value.rulesets as FiltersRulesetRow[] ?? []) : [] const homes = serversMapped.filter((s) => s.type === "home-router") const lookups = await Promise.all( homes.map(async (h) => ({ id: h.id, lookup: await resolveDefaultRouteLookup(apiFetch, h.id), })), ) const wanRuntimeRows = await Promise.all( homes.map(async (h) => { try { const rt = await apiFetch(`/api/servers/${h.id}/wan-runtime`) return { id: h.id, runtime: rt } } catch { return { id: h.id, runtime: null } } }), ) const speedRes = await apiFetch<{ probes?: RouteOptimizerSpeedProbe[] }>("/api/uptime/speed-probes").catch(() => ({ probes: [] })) const greRes = await apiFetch<{ tunnels?: ApiGreTunnelRow[] }>("/api/filters/gre-tunnels").catch(() => ({ tunnels: [] })) const lookupById = Object.fromEntries(lookups.map((x) => [x.id, x.lookup])) const wanRuntimeById = Object.fromEntries(wanRuntimeRows.map((x) => [x.id, x.runtime])) setInternetPath(buildDashboardInternetPath({ servers: serversMapped, greTunnels: (greRes.tunnels ?? []).map(apiGreToGreTunnel), probes: speedRes.probes ?? [], filtersRulesets: filterRulesets, routeLookupByServerId: lookupById, wanRuntimeByHomeId: wanRuntimeById, })) } } else { setInternetPath(null) } } catch (e) { const msg = e instanceof Error ? e.message : "Не удалось загрузить пробы" setProbesError(msg) setLiveKpi(null) setInternetPathError(msg) if (!silent) { setLiveProbes(null) setLiveServersResolved(null) setInternetPath(null) } } finally { if (!silent) setProbesLoading(false) if (!silent) setInternetPathLoading(false) } }, [apiFetch, isLive]) const fetchRecentEvents = useCallback(async (silent: boolean) => { if (!isLive) { setRecentEvents([]) setEventsError(null) return } if (!silent) setEventsLoading(true) try { const rows = await listEvents(backendUrl, { limit: 8 }) setRecentEvents(rows) setEventsError(null) } catch (error) { setRecentEvents([]) setEventsError(error instanceof Error ? error.message : "Не удалось загрузить события") } finally { if (!silent) setEventsLoading(false) } }, [backendUrl, isLive]) useEffect(() => { if (!isLive) { queueMicrotask(() => { setLiveProbes(null) setLiveServersResolved(null) setLiveKpi(null) setProbesError(null) setInternetPath(null) setInternetPathError(null) }) return } let cancelled = false queueMicrotask(() => { if (cancelled) return void fetchProbes(false) }) return () => { cancelled = true } }, [isLive, fetchProbes]) useEffect(() => { queueMicrotask(() => { void fetchRecentEvents(false) }) }, [fetchRecentEvents]) useEffect(() => { const id = setInterval(() => { queueMicrotask(() => { void fetchRecentEvents(true) }) }, 20_000) return () => clearInterval(id) }, [fetchRecentEvents]) 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 (!prefsHydrated) return [] if (!isLive) return mockActiveProbes if (liveProbes === null && probesLoading) return [] if (liveProbes === null) return [] return liveProbes.filter((p) => p.enabled && p.showOnDashboard === true) }, [prefsHydrated, isLive, liveProbes, probesLoading, mockActiveProbes]) const probesSubtitle = useMemo(() => { if (!prefsHydrated) return "Загрузка…" 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)` : "Нет проб на дашборде · отметьте звёздочкой на странице мониторинга" }, [prefsHydrated, isLive, mockActiveProbes.length, probesError, liveProbes, activeProbesTable.length]) /** Карточка «Состояние серверов»: в Live — `/api/servers` (тот же запрос, что и для каталога проб). */ const serverStatusModel = useMemo(() => { if (!prefsHydrated) { return { kind: "loading" as const, subtitle: "Загрузка…" } } 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`, } }, [prefsHydrated, isLive, liveServersResolved, probesLoading, probesError]) const latencyChartBlock = useMemo(() => { if (!prefsHydrated) { return { kind: "loading" as const } } 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", } }, [prefsHydrated, 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] const loadingKpi = (sparkColor: string) => ({ value: "—", unit: undefined as string | undefined, delta: "Загрузка…", deltaDir: "up" as const, spark: undefined as number[] | undefined, sparkColor, }) if (!prefsHydrated) { return { servers: loadingKpi("var(--chart-line-1)"), filters: loadingKpi("var(--chart-line-2)"), bgp: loadingKpi("var(--chart-line-4)"), alerts: loadingKpi("var(--chart-5)"), } } 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 liveDataPending = liveServersResolved === null || liveProbes === null const loadingBlock = liveDataPending || (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)", }, } }, [prefsHydrated, isLive, liveServersResolved, liveKpi, liveProbes, probesLoading]) return (
} />
{/* KPI row */}
} /> } /> } /> } />
{/* Latency chart + Events */}
{latencyChartBlock.kind === "loading" && (
)} {latencyChartBlock.kind === "empty" && (

{latencyChartBlock.message}

)} {(latencyChartBlock.kind === "mock" || latencyChartBlock.kind === "live") && ( )} Все → } >
{eventsLoading && recentEvents.length === 0 && (
Загрузка событий...
)} {eventsError && recentEvents.length === 0 && (
{eventsError}
)} {!eventsLoading && !eventsError && recentEvents.length === 0 && (
Событий пока нет.
)} {recentEvents.map((e) => (
{e.level === "critical" && } {e.level === "warning" && } {e.level === "info" && }

{e.title}

{e.message}

{formatEventAge(e.createdAt)}
))}
{/* Bandwidth + Server status */}
RX 318 Мбит/с TX 244 Мбит/с
} contentClassName="px-3 pb-3" >
{serverStatusModel.subtitle} } headerRight={ Управление → } >
{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}мс`}
))}
{internetPathError && isLive && (
{internetPathError}
)} {internetPathLoading && isLive && !internetPath && (
)} {(!isLive || internetPath || !internetPathLoading) && ( )}
{/* Ping probes table */} {probesSubtitle} } headerRight={ Открыть монитор → } >
) }