From 36c5305db77cab93c38fc21545cfa01ef89c93e9 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Tue, 8 Sep 2026 15:13:27 +0700 Subject: [PATCH] =?UTF-8?q?feat(dashboard):=20=D1=81=D0=BE=D0=B1=D1=80?= =?UTF-8?q?=D0=B0=D1=82=D1=8C=20ops-=D0=B4=D0=B0=D1=88=D0=B1=D0=BE=D1=80?= =?UTF-8?q?=D0=B4=20=D0=BD=D0=B0=20ReUI=20Frame=20=D1=81=20=D0=B6=D0=B8?= =?UTF-8?q?=D0=B2=D1=8B=D0=BC=D0=B8=20=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D0=BC?= =?UTF-8?q?=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- app/(main)/dashboard/page.tsx | 1111 ++++------------- .../dashboard/dashboard-events-timeline.tsx | 90 ++ components/dashboard/internet-path-map.tsx | 23 +- components/dashboard/internet-path-panel.tsx | 97 ++ .../dashboard/internet-path-summary.tsx | 130 ++ components/reui-kit/attention-queue.tsx | 93 ++ components/reui-kit/index.ts | 4 + components/reui-kit/quick-action-grid.tsx | 161 +++ components/reui-kit/traffic-rx-tx-chart.tsx | 101 +- hooks/use-dashboard-live.ts | 687 ++++++++++ 10 files changed, 1580 insertions(+), 917 deletions(-) create mode 100644 components/dashboard/dashboard-events-timeline.tsx create mode 100644 components/dashboard/internet-path-panel.tsx create mode 100644 components/dashboard/internet-path-summary.tsx create mode 100644 components/reui-kit/attention-queue.tsx create mode 100644 components/reui-kit/quick-action-grid.tsx create mode 100644 hooks/use-dashboard-live.ts diff --git a/app/(main)/dashboard/page.tsx b/app/(main)/dashboard/page.tsx index 00aca32..b234cb3 100644 --- a/app/(main)/dashboard/page.tsx +++ b/app/(main)/dashboard/page.tsx @@ -1,932 +1,341 @@ "use client" -import { useCallback, useEffect, useMemo, useState } from "react" -import { usePathname } from "next/navigation" import Link from "next/link" +import { + ActivityIcon, + BellIcon, + CableIcon, + FilterIcon, + GitMergeIcon, + HardDriveIcon, + HeartPulseIcon, + MapIcon, + ServerIcon, + ShieldIcon, +} from "lucide-react" import { PageHeader } from "@/components/page-header" import { OpsPanel } from "@/components/ops-panel" import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid" -import { StatusDot } from "@/components/status-dot" -import { StatusBadge } from "@/components/status-badge" +import { QuickActionGrid } from "@/components/reui-kit/quick-action-grid" +import { AttentionQueue } from "@/components/reui-kit/attention-queue" +import { TrafficRxTxChart } from "@/components/reui-kit/traffic-rx-tx-chart" 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 { DashboardEventsTimeline } from "@/components/dashboard/dashboard-events-timeline" +import { InternetPathPanel } from "@/components/dashboard/internet-path-panel" import { DashboardActiveProbesDataGrid } from "@/components/data-grids/dashboard-active-probes-data-grid" +import { EmptyState } from "@/components/empty-state" +import { Alert, AlertDescription, AlertTitle } from "@/components/reui/alert" +import { Badge } from "@/components/reui/badge" +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" -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) - } -} +import { fmtRate } from "@/lib/fmt-rate" +import { useDashboardLive, type AttentionRow } from "@/hooks/use-dashboard-live" 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, - } +function AttentionRows({ rows }: { rows: AttentionRow[] }) { + return ( +
+ {rows.map((row) => ( + + {row.title} + + {row.hint} + + + ))} +
+ ) } 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]) + const dash = useDashboardLive() + const offline = Math.max(0, dash.totalServers - dash.onlineCount) + const health = dash.probeDown + dash.probeWarn return ( -
- - - - } - /> +
+
-
+
+ {dash.error && !dash.loading ? ( + + Не удалось обновить дашборд + + {dash.error} + + + + ) : null} , + id: "fleet", + label: "Флот", + value: `${dash.onlineCount} / ${dash.totalServers}`, + hint: !dash.isLive + ? "демо" + : offline > 0 + ? `${offline} offline` + : dash.totalServers > 0 + ? "все online" + : "нет узлов", + href: "/servers", + icon: , iconClassName: "text-muted-foreground", - variant: dashboardKpi.servers.deltaDir === "down" ? "warning" : "default", + variant: offline > 0 ? "warning" : "default", }, { - id: "filters", - label: "Активные фильтры", - value: dashboardKpi.filters.unit - ? `${dashboardKpi.filters.value} ${dashboardKpi.filters.unit}` - : dashboardKpi.filters.value, - hint: dashboardKpi.filters.delta, - icon: , + id: "overlay", + label: "Оверлей", + value: String(dash.overlay.up), + hint: dash.overlay.total > 0 ? `${dash.overlay.down} down` : "нет туннелей", + href: "/gre", + icon: , iconClassName: "text-info", + variant: dash.overlay.down > 0 ? "warning" : "default", }, { id: "bgp", - label: "BGP-префиксы", - value: dashboardKpi.bgp.unit - ? `${dashboardKpi.bgp.value} ${dashboardKpi.bgp.unit}` - : dashboardKpi.bgp.value, - hint: dashboardKpi.bgp.delta, - icon: , + label: "BGP Rx", + value: dash.bgp ? fmtIntRu(dash.bgp.prefixSum) : "—", + hint: dash.bgp ? `${dash.bgp.establishedCount} Established` : "нет данных", + href: "/bgp", + icon: , iconClassName: "text-primary", }, { - id: "alerts", - label: "Активные алерты", - value: dashboardKpi.alerts.unit - ? `${dashboardKpi.alerts.value} ${dashboardKpi.alerts.unit}` - : dashboardKpi.alerts.value, - hint: dashboardKpi.alerts.delta, - icon: , - iconClassName: dashboardKpi.alerts.deltaDir === "down" ? "text-destructive" : "text-muted-foreground", - variant: dashboardKpi.alerts.deltaDir === "down" ? "destructive" : "default", + id: "health", + label: "Здоровье", + value: String(health), + hint: `${dash.probeDown} down · ${dash.probeWarn} warn`, + href: "/uptime", + icon: , + iconClassName: health > 0 ? "text-destructive" : "text-muted-foreground", + variant: health > 0 ? "destructive" : "default", }, ]} /> - {/* Latency chart + Events */} -
+ , + iconClassName: "text-info", + }, + { + id: "map", + title: "Карта сети", + description: "Топология GRE и WAN", + href: "/network-map", + icon: , + iconClassName: "text-primary", + }, + { + id: "filters", + title: "Фильтры", + description: "Правила обхода и списки", + href: "/filters", + icon: , + iconClassName: "text-info", + }, + { + id: "firewall", + title: "Firewall", + description: "Цепочки filter / nat", + href: "/firewall", + icon: , + iconClassName: "text-warning", + }, + { + id: "backups", + title: "Бэкапы", + description: "Снапшоты и расписание", + href: "/backups", + icon: , + iconClassName: "text-muted-foreground", + }, + { + id: "bgp", + title: "BGP", + description: "Сессии и префиксы", + href: "/bgp", + icon: , + iconClassName: "text-primary", + }, + ]} + /> + +
- {latencyChartBlock.kind === "loading" && ( -
- )} - {latencyChartBlock.kind === "empty" && ( -

- {latencyChartBlock.message} -

- )} - {(latencyChartBlock.kind === "mock" || latencyChartBlock.kind === "live") && ( - - )} + {dash.latency.kind === "loading" && ( +
+ )} + {dash.latency.kind === "empty" && ( +

{dash.latency.message}

+ )} + {(dash.latency.kind === "mock" || dash.latency.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 Мбит/с + {dash.traffic.demo ? демо : null} + RX {fmtRate(dash.traffic.rxNow)} + TX {fmtRate(dash.traffic.txNow)} + + Трафик → +
+ ) : null } contentClassName="px-3 pb-3" > - - - - - {serverStatusModel.subtitle} - - } - headerRight={ - - Управление → + {dash.loading && !dash.traffic ? ( +
+ ) : !dash.traffic ? ( +

+ Нет данных трафика за последний час.{" "} + + Открыть трафик - } - > -

- {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}мс`} - - -
- ))} -
+

+ ) : ( + + )} + + , + }, + { + id: "overlay", + title: "Оверлей", + icon: CableIcon, + iconClassName: "text-info", + count: dash.attentionOverlay.length, + countVariant: "destructive-light", + emptyTitle: "Туннели up", + emptyDescription: "GRE и WireGuard в норме.", + children: , + }, + { + id: "probes", + title: "Пробы", + icon: HeartPulseIcon, + iconClassName: "text-destructive", + count: dash.attentionProbes.length, + countVariant: "destructive-light", + emptyTitle: "Пробы up", + emptyDescription: "Нет down или warn.", + children: , + }, + ]} + />
-
- {internetPathError && isLive && ( -
{internetPathError}
- )} - {internetPathLoading && isLive && !internetPath && ( -
- )} - {(!isLive || internetPath || !internetPathLoading) && ( - - )} -
+ - {/* Ping probes table */} - {probesSubtitle} - + {dash.probesSubtitle} } headerRight={ - - Открыть монитор → - + + Мониторинг → + } + contentClassName="px-0 pb-0" > - - - + {dash.activeProbes.length === 0 && !dash.probesLoading ? ( + + Открыть мониторинг + + } + /> + ) : ( + + )} -
diff --git a/components/dashboard/dashboard-events-timeline.tsx b/components/dashboard/dashboard-events-timeline.tsx new file mode 100644 index 0000000..42b8d1f --- /dev/null +++ b/components/dashboard/dashboard-events-timeline.tsx @@ -0,0 +1,90 @@ +"use client" + +import Link from "next/link" +import { + Timeline, + TimelineContent, + TimelineDate, + TimelineHeader, + TimelineIndicator, + TimelineItem, + TimelineSeparator, + TimelineTitle, +} from "@/components/reui/timeline" +import { Alert, AlertDescription, AlertTitle } from "@/components/reui/alert" +import { cn } from "@/lib/utils" +import type { EventItem } from "@mmapp/contracts/events" + +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}д` +} + +const LEVEL_DOT: Record = { + critical: "border-destructive bg-destructive/20 group-data-completed/timeline-item:border-destructive", + warning: "border-warning bg-warning/20 group-data-completed/timeline-item:border-warning", + info: "border-info bg-info/20 group-data-completed/timeline-item:border-info", +} + +/** + * Compact activity timeline. + * Preview: https://reui.io/preview/base/timeline-3 + * Docs: https://reui.io/docs/components/base/timeline + */ +export function DashboardEventsTimeline({ + events, + loading, + error, +}: { + events: EventItem[] + loading?: boolean + error?: string | null +}) { + if (loading && events.length === 0) { + return

Загрузка событий…

+ } + if (error && events.length === 0) { + return ( +
+ + События недоступны + {error} + +
+ ) + } + if (events.length === 0) { + return ( +

+ Событий пока нет.{" "} + + Оповещения + +

+ ) + } + + return ( + + {events.map((event, index) => ( + + + + {formatEventAge(event.createdAt)} + {event.title} + + + {event.message} + + ))} + + ) +} diff --git a/components/dashboard/internet-path-map.tsx b/components/dashboard/internet-path-map.tsx index 352dda6..43961f4 100644 --- a/components/dashboard/internet-path-map.tsx +++ b/components/dashboard/internet-path-map.tsx @@ -2,8 +2,6 @@ import { useMemo, useState } from "react" import { Flag } from "@/components/flag" -import { OpsPanel } from "@/components/ops-panel" -import { StatusBadge } from "@/components/status-badge" import { cn } from "@/lib/utils" import type { InternetPathViewModel } from "@/lib/dashboard-internet-path" import { Maximize2Icon, ZoomInIcon, ZoomOutIcon } from "lucide-react" @@ -167,7 +165,7 @@ function ServerNode({ ) } -export function InternetPathMapCard({ model }: { model: InternetPathViewModel | null }) { +export function InternetPathMapCanvas({ model }: { model: InternetPathViewModel | null }) { const [zoom, setZoom] = useState(1) const [pan, setPan] = useState({ x: 0, y: 0 }) const [isDragging, setIsDragging] = useState(false) @@ -226,22 +224,7 @@ export function InternetPathMapCard({ model }: { model: InternetPathViewModel | } return ( - - } - contentClassName="px-5 pb-4" - > +
{!model && (
Недостаточно данных для построения маршрута @@ -412,6 +395,6 @@ export function InternetPathMapCard({ model }: { model: InternetPathViewModel | )}
)} - +
) } diff --git a/components/dashboard/internet-path-panel.tsx b/components/dashboard/internet-path-panel.tsx new file mode 100644 index 0000000..364b949 --- /dev/null +++ b/components/dashboard/internet-path-panel.tsx @@ -0,0 +1,97 @@ +"use client" + +import { useEffect, useState } from "react" +import Link from "next/link" +import { ChevronDownIcon } from "lucide-react" +import { OpsPanel } from "@/components/ops-panel" +import { Alert, AlertDescription, AlertTitle } from "@/components/reui/alert" +import { buttonVariants } from "@/components/ui/button" +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible" +import { cn } from "@/lib/utils" +import type { InternetPathViewModel } from "@/lib/dashboard-internet-path" +import { InternetPathMapCanvas } from "@/components/dashboard/internet-path-map" +import { InternetPathSummary } from "@/components/dashboard/internet-path-summary" + +const PATH_MAP_OPEN_LS = "mm:dashboard-path-map-open" + +function readMapOpen(): boolean { + if (typeof window === "undefined") return true + try { + const raw = localStorage.getItem(PATH_MAP_OPEN_LS) + if (raw === "0") return false + if (raw === "1") return true + } catch { + /* ignore */ + } + return true +} + +export function InternetPathPanel({ + model, + loading, + error, +}: { + model: InternetPathViewModel | null + loading?: boolean + error?: string | null +}) { + const [open, setOpen] = useState(true) + const [hydrated, setHydrated] = useState(false) + + useEffect(() => { + queueMicrotask(() => { + setOpen(readMapOpen()) + setHydrated(true) + }) + }, []) + + function handleOpenChange(next: boolean) { + setOpen(next) + try { + localStorage.setItem(PATH_MAP_OPEN_LS, next ? "1" : "0") + } catch { + /* ignore */ + } + } + + return ( + + Карта сети → + + } + contentClassName="flex flex-col gap-3 px-5 pb-4" + > + {error ? ( + + Не удалось загрузить путь + {error} + + ) : null} + {loading && !model ? ( +
+ ) : ( + + )} + + + + + {open ? "Скрыть карту" : "Показать карту"} + + + + + + + ) +} diff --git a/components/dashboard/internet-path-summary.tsx b/components/dashboard/internet-path-summary.tsx new file mode 100644 index 0000000..d709a4e --- /dev/null +++ b/components/dashboard/internet-path-summary.tsx @@ -0,0 +1,130 @@ +"use client" + +import type { ReactNode } from "react" +import Link from "next/link" +import { Badge } from "@/components/reui/badge" +import { IconTile } from "@/components/reui/icon-tile" +import { StatusBadge } from "@/components/status-badge" +import { Flag } from "@/components/flag" +import type { InternetPathViewModel } from "@/lib/dashboard-internet-path" +import type { ServerStatus } from "@/lib/data" +import { cn } from "@/lib/utils" +import { ArrowRightIcon, HomeIcon, RadioIcon, ServerIcon, GlobeIcon } from "lucide-react" + +function pathStateToServerStatus(state: InternetPathViewModel["pathState"]): ServerStatus { + if (state === "healthy") return "online" + if (state === "failover" || state === "degraded") return "degraded" + return "offline" +} + +function HopChip({ + label, + name, + country, + icon, + iconClassName, +}: { + label: string + name: string + country?: string + icon: ReactNode + iconClassName?: string +}) { + return ( +
+ +
+

{label}

+

+ {country ? : null} + {name} +

+
+
+ ) +} + +/** + * Compact live path strip (Frame-friendly). Canvas lives separately. + * Preview: https://reui.io/preview/base/stats-12 + * Docs: https://reui.io/docs/components/base/icon-tile + */ +export function InternetPathSummary({ model }: { model: InternetPathViewModel | null }) { + if (!model) { + return ( +

+ Недостаточно данных для пути. Добавьте home-router и проверьте{" "} + + карту сети + + . +

+ ) + } + + const hop = model.currentHop ?? model.primaryHop + const wanName = hop?.wan.name ?? model.activeWanUplink?.name ?? "WAN" + const wanIsp = hop?.wan.isp ?? model.activeWanUplink?.isp ?? "—" + const ping = hop?.wanJhMetrics.pingMs + const dl = hop?.wanJhMetrics.dlMbps + + return ( +
+
+ + {model.pathState === "failover" ? ( + failover + ) : null} + {ping != null ? ( + + {ping} мс + + ) : null} + {dl != null ? ( + + {Math.round(dl)} ↓ Мбит/с + + ) : null} +
+ +
+ } + iconClassName="text-success" + /> +
+ +

+ {model.currentPath?.reason ?? model.primaryPath?.reason ?? "Текущий путь не определён"} +

+
+ ) +} diff --git a/components/reui-kit/attention-queue.tsx b/components/reui-kit/attention-queue.tsx new file mode 100644 index 0000000..0f9602d --- /dev/null +++ b/components/reui-kit/attention-queue.tsx @@ -0,0 +1,93 @@ +"use client" + +import type { ReactNode } from "react" +import type { LucideIcon } from "lucide-react" +import { Badge } from "@/components/reui/badge" +import { + Frame, + FrameHeader, + FramePanel, + FrameTitle, +} from "@/components/reui/frame" +import { IconTile } from "@/components/reui/icon-tile" +import { cn } from "@/lib/utils" + +/** + * Sibling Frame columns for dashboard attention queue. + * Preview: https://reui.io/preview/base/dashboard-1 · https://reui.io/preview/base/stats-12 + * Docs: https://reui.io/docs/components/base/frame · https://reui.io/docs/components/base/icon-tile · https://reui.io/docs/components/base/badge + */ +export interface AttentionQueueColumn { + id: string + title: string + icon: LucideIcon + iconClassName?: string + count: number + countVariant?: "destructive" | "warning" | "secondary" | "destructive-light" | "warning-light" + emptyTitle: string + emptyDescription: string + emptyAction?: ReactNode + children: ReactNode +} + +interface AttentionQueueProps { + columns: AttentionQueueColumn[] + className?: string +} + +const DEFAULT_ICON_CLASS = "text-muted-foreground [&_svg]:text-current" + +export function AttentionQueue({ columns, className }: AttentionQueueProps) { + return ( +
+ {columns.map((column) => { + const Icon = column.icon + const isEmpty = column.count === 0 + return ( + + +
+ + {column.title} + {column.count > 0 ? ( + + {column.count} + + ) : null} +
+
+ + {isEmpty ? ( +
+

{column.emptyTitle}

+

+ {column.emptyDescription} +

+ {column.emptyAction ?
{column.emptyAction}
: null} +
+ ) : ( + column.children + )} +
+ + ) + })} +
+ ) +} diff --git a/components/reui-kit/index.ts b/components/reui-kit/index.ts index 36aff5e..1e98698 100644 --- a/components/reui-kit/index.ts +++ b/components/reui-kit/index.ts @@ -3,3 +3,7 @@ export type { CodeExportFormat, CodeExportSheetProps } from "./code-export-sheet export { KpiStatGrid, KpiStatCardTile, kpiStatItemKey } from "./kpi-stat-grid" export type { KpiStatItem, KpiStatCardData, KpiStatVariant } from "./kpi-stat-grid" export { kpiCols } from "./kpi-cols" +export { QuickActionGrid } from "./quick-action-grid" +export type { QuickActionItem } from "./quick-action-grid" +export { AttentionQueue } from "./attention-queue" +export type { AttentionQueueColumn } from "./attention-queue" diff --git a/components/reui-kit/quick-action-grid.tsx b/components/reui-kit/quick-action-grid.tsx new file mode 100644 index 0000000..99d6539 --- /dev/null +++ b/components/reui-kit/quick-action-grid.tsx @@ -0,0 +1,161 @@ +"use client" + +import type { KeyboardEvent, ReactNode } from "react" +import Link from "next/link" +import { + Frame, + FrameDescription, + FrameHeader, + FramePanel, + FrameTitle, +} from "@/components/reui/frame" +import { Badge } from "@/components/reui/badge" +import { IconTile } from "@/components/reui/icon-tile" +import { cn } from "@/lib/utils" +import { kpiCols } from "./kpi-cols" + +type QuickActionBase = { + id: string + title: string + description: string + icon?: ReactNode + iconClassName?: string + badge?: string + disabled?: boolean +} + +export type QuickActionItem = QuickActionBase & + ( + | { href: string; onClick?: never } + | { onClick: () => void; href?: never } + ) + +interface QuickActionGridProps { + actions: QuickActionItem[] + title?: string + description?: string + className?: string +} + +const DEFAULT_ICON_CLASS = "text-muted-foreground [&_svg]:text-current" + +function resolveBadge(action: QuickActionItem): string { + if (action.badge) return action.badge + return action.onClick ? "Выполнить" : "Перейти" +} + +function handleActionKeyDown(onActivate: () => void, event: KeyboardEvent) { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault() + onActivate() + } +} + +function QuickActionBody({ action }: { action: QuickActionItem }) { + return ( +
+ {action.icon ? ( + + ) : null} + +
+
+ {action.title} + + {resolveBadge(action)} + +
+

+ {action.description} +

+
+
+ ) +} + +function panelClassName(disabled?: boolean) { + return cn( + "relative isolate flex h-full flex-col transition-colors", + disabled + ? "cursor-not-allowed opacity-60" + : "hover:bg-muted/40 focus-within:ring-ring cursor-pointer focus-within:ring-2", + ) +} + +/** + * KPI-like quick actions strip (horizontal Frame tiles). + * Preview: https://reui.io/preview/base/stats-12 · https://reui.io/preview/base/card-12 + * Docs: https://reui.io/docs/components/base/frame · https://reui.io/docs/components/base/icon-tile + */ +export function QuickActionGrid({ + actions, + title = "Быстрые действия", + description, + className, +}: QuickActionGridProps) { + if (actions.length === 0) return null + + return ( + + {(title || description) && ( + + {title ? {title} : null} + {description ? {description} : null} + + )} +
+ {actions.map((action) => { + const label = `${action.title}: ${action.description}` + + if ("href" in action && action.href) { + return ( + + {action.disabled ? ( +
+ +
+ ) : ( + <> + + + + )} +
+ ) + } + + const onClick = action.onClick + const onActivate = () => { + if (action.disabled || !onClick) return + onClick() + } + + return ( + handleActionKeyDown(onActivate, e)} + > + + + ) + })} +
+ + ) +} diff --git a/components/reui-kit/traffic-rx-tx-chart.tsx b/components/reui-kit/traffic-rx-tx-chart.tsx index 6c72cc6..8ed4d62 100644 --- a/components/reui-kit/traffic-rx-tx-chart.tsx +++ b/components/reui-kit/traffic-rx-tx-chart.tsx @@ -70,63 +70,72 @@ export function TrafficRxTxChart({ rx, tx, range = "1h", + embedded = false, }: { rx: number[] tx: number[] range?: string + /** Skip outer Frame when already inside OpsPanel / Frame. */ + embedded?: boolean }) { const rangeMinutes = TRAFFIC_RANGE_MINUTES[range] ?? 60 const data = toChartData(rx, tx, rangeMinutes) const tickEvery = Math.max(1, Math.ceil(data.length / 6)) + const chart = ( +
+ + + + + fmtRate(Number(v))} + tickMargin={8} + width={72} + /> + } /> + + + + +
+ + +
+
+ ) + + if (embedded) return chart + return ( - - - - - - fmtRate(Number(v))} - tickMargin={8} - width={72} - /> - } /> - - - - -
- - -
-
+ {chart} ) } diff --git a/hooks/use-dashboard-live.ts b/hooks/use-dashboard-live.ts new file mode 100644 index 0000000..06c4315 --- /dev/null +++ b/hooks/use-dashboard-live.ts @@ -0,0 +1,687 @@ +"use client" + +import { useCallback, useEffect, useMemo, useState } from "react" +import { usePathname } from "next/navigation" +import { + dashLatency, + greTunnels as mockGreTunnels, + pingProbes, + servers as mockServers, + traffic as mockTraffic, + vxlanTunnels as mockVxlan, + type GreTunnel, + type PingProbe, + type Server, + type ServerStatus, + type ServerType, +} from "@/lib/data" +import { useDataSource } from "@/lib/data-source" +import { requestJson } from "@/shared/api/http-client" +import { listEvents } from "@/shared/api/events" +import { listWireGuard } from "@/shared/api/wireguard" +import type { EventItem } from "@mmapp/contracts/events" +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" + +const MOCK_DASH_STARS_LS = "mm:dashboard-probe-ids" +const UPTIME_PROBES_CHANGED = "mm:uptime-probes-changed" + +function makeApiFetch(backendUrl: string) { + return async function apiFetch(path: string, init?: RequestInit): Promise { + return requestJson(backendUrl, path, init) + } +} + +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 +} + +interface TrafficServerRow { + id: string + rxNow: number + txNow: number + rxSeries: number[] + txSeries: number[] +} + +export type OverlayKind = "gre" | "wg" | "vxlan" + +export interface OverlayItem { + id: string + name: string + kind: OverlayKind + href: string + status: "up" | "down" | "degraded" +} + +export interface AttentionRow { + id: string + title: string + hint: string + href: string + tone: "destructive" | "warning" +} + +export type LatencyBlock = + | { kind: "loading" } + | { kind: "empty"; message: string } + | { kind: "mock"; series: Record; labels?: Record; subtitle: string } + | { kind: "live"; series: Record; labels?: Record; subtitle: string } + +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, + } +} + +function sumSeries(rows: TrafficServerRow[], key: "rxSeries" | "txSeries"): number[] { + const len = Math.max(60, ...rows.map((r) => r[key].length), 0) + const out = Array.from({ length: len }, () => 0) + for (const row of rows) { + const series = row[key] + for (let i = 0; i < len; i++) { + out[i] += series[i] ?? 0 + } + } + return out +} + +function mockOverlayItems(): OverlayItem[] { + const gre: OverlayItem[] = mockGreTunnels.map((t) => ({ + id: `gre-${t.id}`, + name: t.name, + kind: "gre", + href: "/gre", + status: t.status, + })) + const wg: OverlayItem[] = mockServers.flatMap((s) => + (s.wireGuardIfaces ?? []).map((iface) => ({ + id: `wg-${iface.id}`, + name: iface.name, + kind: "wg" as const, + href: "/wireguard", + status: iface.status, + })), + ) + const vx: OverlayItem[] = mockVxlan.map((t) => ({ + id: `vx-${t.id}`, + name: t.name, + kind: "vxlan", + href: "/vxlan", + status: t.status, + })) + return [...gre, ...wg, ...vx] +} + +export function useDashboardLive() { + 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 [liveServers, setLiveServers] = useState(null) + const [overlayItems, setOverlayItems] = useState(null) + const [bgp, setBgp] = useState<{ prefixSum: number; establishedCount: number } | null>(null) + const [trafficSeries, setTrafficSeries] = useState<{ + rx: number[] + tx: number[] + rxNow: number + txNow: number + } | null>(null) + const [recentEvents, setRecentEvents] = useState([]) + const [eventsError, setEventsError] = useState(null) + const [eventsLoading, setEventsLoading] = useState(false) + const [internetPath, setInternetPath] = useState(null) + const [internetPathLoading, setInternetPathLoading] = useState(false) + const [internetPathError, setInternetPathError] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [mockDashEpoch, setMockDashEpoch] = useState(0) + + const fetchSnapshot = useCallback(async (silent: boolean) => { + if (!isLive) return + if (!silent) { + setLoading(true) + setInternetPathLoading(true) + } + try { + const [overviewRes, serversRes, fr, br, ipRes, greRes, wgRes, trafficRes] = await Promise.allSettled([ + apiFetch<{ probes: PingProbe[] }>("/api/uptime/overview?range=1h"), + apiFetch("/api/servers"), + apiFetch<{ rulesets: Array<{ rules?: unknown[] }> }>("/api/filters/rules"), + apiFetch>("/api/bgp/sessions"), + apiFetch<{ snapshot: InternetPathSnapshotPayload | null }>("/api/internet-path/latest"), + apiFetch<{ tunnels?: ApiGreTunnelRow[] }>("/api/filters/gre-tunnels"), + listWireGuard(backendUrl), + apiFetch<{ servers?: TrafficServerRow[] }>("/api/traffic/servers?range=1h"), + ]) + + const hardFail = overviewRes.status === "rejected" && serversRes.status === "rejected" + if (hardFail) { + const reason = overviewRes.reason + setError(reason instanceof Error ? reason.message : "Не удалось загрузить дашборд") + } else { + setError(null) + } + setInternetPathError(null) + + if (overviewRes.status === "fulfilled") { + setLiveProbes(overviewRes.value.probes) + } else { + setLiveProbes([]) + } + + let serversMapped: Server[] = [] + if (serversRes.status === "fulfilled") { + serversMapped = serversRes.value.map(mapBackendToServer) + setLiveServers(serversMapped) + } else { + setLiveServers([]) + } + + 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) + } + } + setBgp({ prefixSum, establishedCount }) + } else { + setBgp(null) + } + + const greItems: OverlayItem[] = + greRes.status === "fulfilled" + ? (greRes.value.tunnels ?? []).map((t) => ({ + id: `gre-${t.id}`, + name: t.name, + kind: "gre" as const, + href: "/gre", + status: t.status, + })) + : [] + const wgItems: OverlayItem[] = + wgRes.status === "fulfilled" + ? wgRes.value.interfaces.map((iface) => ({ + id: `wg-${iface.id}`, + name: iface.name, + kind: "wg" as const, + href: "/wireguard", + status: iface.status, + })) + : [] + setOverlayItems([...greItems, ...wgItems]) + + if (trafficRes.status === "fulfilled") { + const rows = trafficRes.value.servers ?? [] + setTrafficSeries({ + rx: sumSeries(rows, "rxSeries"), + tx: sumSeries(rows, "txSeries"), + rxNow: rows.reduce((n, r) => n + (r.rxNow ?? 0), 0), + txNow: rows.reduce((n, r) => n + (r.txNow ?? 0), 0), + }) + } else { + setTrafficSeries(null) + } + + const greMapped = + greRes.status === "fulfilled" ? (greRes.value.tunnels ?? []).map(apiGreToGreTunnel) : [] + + if (serversMapped.length > 0) { + const snap = ipRes.status === "fulfilled" ? ipRes.value.snapshot : null + if (snap) { + setInternetPath( + buildDashboardInternetPath({ + servers: snap.servers.map(mapBackendToServer), + 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 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: greMapped, + probes: speedRes.probes ?? [], + filtersRulesets: filterRulesets, + routeLookupByServerId: lookupById, + wanRuntimeByHomeId: wanRuntimeById, + }), + ) + } + } else { + setInternetPath(null) + } + } catch (e) { + const msg = e instanceof Error ? e.message : "Не удалось загрузить дашборд" + setInternetPathError(msg) + setInternetPath(null) + } finally { + if (!silent) { + setLoading(false) + setInternetPathLoading(false) + } + } + }, [apiFetch, backendUrl, isLive]) + + const fetchRecentEvents = useCallback(async (silent: boolean) => { + if (!isLive) { + setRecentEvents([]) + setEventsError(null) + return + } + if (!silent) setEventsLoading(true) + try { + const rows = await listEvents(backendUrl, { limit: 6 }) + setRecentEvents(rows) + setEventsError(null) + } catch (err) { + setRecentEvents([]) + setEventsError(err instanceof Error ? err.message : "Не удалось загрузить события") + } finally { + if (!silent) setEventsLoading(false) + } + }, [backendUrl, isLive]) + + useEffect(() => { + if (!isLive) { + queueMicrotask(() => { + setLiveProbes(null) + setLiveServers(null) + setOverlayItems(null) + setBgp(null) + setTrafficSeries(null) + setError(null) + setInternetPath(null) + setInternetPathError(null) + }) + return + } + let cancelled = false + queueMicrotask(() => { + if (cancelled) return + void fetchSnapshot(false) + }) + return () => { + cancelled = true + } + }, [isLive, fetchSnapshot]) + + 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 fetchSnapshot(true) + }) + }, 60_000) + return () => clearInterval(id) + }, [isLive, fetchSnapshot]) + + useEffect(() => { + if (!isLive) return + queueMicrotask(() => { + void fetchSnapshot(true) + }) + }, [pathname, isLive, fetchSnapshot]) + + 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 fetchSnapshot(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, fetchSnapshot]) + + useEffect(() => { + queueMicrotask(() => setMockDashEpoch((x) => x + 1)) + }, [pathname]) + + const servers = useMemo(() => { + if (!prefsHydrated) return [] + if (!isLive) return mockServers + return liveServers ?? [] + }, [prefsHydrated, isLive, liveServers]) + + const mockActiveProbes = useMemo(() => { + void mockDashEpoch + const stars = readMockDashboardStarIds() + return pingProbes.filter((p) => p.enabled && stars.has(p.id)) + }, [mockDashEpoch]) + + const activeProbes = useMemo(() => { + if (!prefsHydrated) return [] + if (!isLive) return mockActiveProbes + if (liveProbes === null) return [] + return liveProbes.filter((p) => p.enabled && p.showOnDashboard === true) + }, [prefsHydrated, isLive, liveProbes, mockActiveProbes]) + + const enabledProbes = useMemo(() => { + if (!prefsHydrated) return [] + if (!isLive) return pingProbes.filter((p) => p.enabled) + return liveProbes?.filter((p) => p.enabled) ?? [] + }, [prefsHydrated, isLive, liveProbes]) + + const overlay = useMemo(() => { + const items = !prefsHydrated ? [] : isLive ? (overlayItems ?? []) : mockOverlayItems() + const up = items.filter((i) => i.status === "up").length + const down = items.filter((i) => i.status !== "up").length + return { items, total: items.length, up, down } + }, [prefsHydrated, isLive, overlayItems]) + + const traffic = useMemo(() => { + if (!prefsHydrated) return null + if (!isLive) { + return { + rx: mockTraffic.rx, + tx: mockTraffic.tx, + rxNow: mockTraffic.rx[mockTraffic.rx.length - 1] ?? 0, + txNow: mockTraffic.tx[mockTraffic.tx.length - 1] ?? 0, + demo: true, + } + } + if (!trafficSeries) return null + return { ...trafficSeries, demo: false } + }, [prefsHydrated, isLive, trafficSeries]) + + const latency: LatencyBlock = useMemo(() => { + if (!prefsHydrated) return { kind: "loading" } + if (!isLive) { + return { + kind: "mock", + series: dashLatency, + subtitle: "Последние 60 минут · демо", + } + } + if (liveProbes === null && loading) return { kind: "loading" } + if (!liveProbes?.length) { + return { + kind: "empty", + message: "Нет данных проб. Откройте мониторинг и проверьте сборщик uptime.", + } + } + const { series, labels } = buildLatencySeriesByProbeSource(liveProbes, liveServers ?? [], { + maxServers: 8, + points: 60, + }) + if (Object.keys(series).length === 0) { + return { + kind: "empty", + message: "Нет включённых проб с историей RTT. Включите пробы на странице «Мониторинг».", + } + } + return { + kind: "live", + series, + labels, + subtitle: "Средний RTT · 1 ч · до 8 узлов", + } + }, [prefsHydrated, isLive, liveProbes, loading, liveServers]) + + const attentionServers: AttentionRow[] = useMemo(() => { + return servers + .filter((s) => s.enabled && s.status !== "online") + .slice(0, 5) + .map((s) => ({ + id: s.id, + title: s.name, + hint: s.status === "degraded" ? "degraded" : "offline", + href: "/servers", + tone: s.status === "degraded" ? ("warning" as const) : ("destructive" as const), + })) + }, [servers]) + + const attentionOverlay: AttentionRow[] = useMemo(() => { + return overlay.items + .filter((i) => i.status !== "up") + .slice(0, 5) + .map((i) => ({ + id: i.id, + title: i.name, + hint: i.status === "degraded" ? "degraded" : "down", + href: i.href, + tone: i.status === "degraded" ? ("warning" as const) : ("destructive" as const), + })) + }, [overlay.items]) + + const attentionProbes: AttentionRow[] = useMemo(() => { + return enabledProbes + .filter((p) => p.status === "down" || p.status === "warn") + .slice(0, 5) + .map((p) => ({ + id: p.id, + title: p.name, + hint: p.status === "warn" ? "warn" : "down", + href: "/uptime", + tone: p.status === "warn" ? ("warning" as const) : ("destructive" as const), + })) + }, [enabledProbes]) + + const onlineCount = servers.filter((s) => s.status === "online").length + const probeDown = enabledProbes.filter((p) => p.status === "down").length + const probeWarn = enabledProbes.filter((p) => p.status === "warn").length + const dataPending = isLive && !error && (liveServers === null || liveProbes === null) + const kpiLoading = !prefsHydrated || dataPending + + const probesSubtitle = !prefsHydrated + ? "Загрузка…" + : !isLive + ? mockActiveProbes.length > 0 + ? `${mockActiveProbes.length} на дашборде · демо` + : "Нет проб на дашборде · отметьте ★ в мониторинге" + : error && liveProbes === null + ? error + : activeProbes.length > 0 + ? `${activeProbes.length} на дашборде · 1 ч` + : "Нет проб на дашборде · отметьте ★ в мониторинге" + + return { + prefsHydrated, + isLive, + loading: kpiLoading, + error, + retry: () => { + void fetchSnapshot(false) + void fetchRecentEvents(false) + }, + servers, + activeProbes, + overlay, + bgp: isLive ? bgp : { prefixSum: 8432, establishedCount: 3 }, + traffic, + onlineCount, + totalServers: servers.length, + probeDown, + probeWarn, + latency, + recentEvents, + eventsLoading, + eventsError, + internetPath, + internetPathLoading: isLive && internetPathLoading && !internetPath, + internetPathError, + attentionServers, + attentionOverlay, + attentionProbes, + probesSubtitle, + probesLoading: isLive && loading && liveProbes === null, + } +}