"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, vxRes] = 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"), apiFetch<{ tunnels?: Array<{ id: string; name: string; status: OverlayItem["status"] }> }>("/api/vxlan"), ]) 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, })) : [] const vxItems: OverlayItem[] = vxRes.status === "fulfilled" ? (vxRes.value.tunnels ?? []).map((t) => ({ id: `vx-${t.id}`, name: t.name, kind: "vxlan" as const, href: "/vxlan", status: t.status === "up" ? "up" : "down", })) : [] setOverlayItems([...greItems, ...wgItems, ...vxItems]) 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, } }