"use client" import { useCallback, useEffect, useMemo, useRef, 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 Server, } from "@/lib/data" import { useDataSource } from "@/lib/data-source" import { requestJson } from "@/shared/api/http-client" import { listEvents } from "@/shared/api/events" import type { EventItem } from "@mmapp/contracts/events" import { buildLatencySeriesByProbeSource } from "@/lib/dashboard-latency" import { buildDashboardInternetPath, resolveDefaultRouteLookup, type InternetPathViewModel, } from "@/lib/dashboard-internet-path" import { updateDashboardSource, type DashboardSource, type DashboardSources } from "@/lib/dashboard-source-state" import {can,ensureAuthConfig} from "@/lib/auth" import {parseDashboardPath,parseDashboardFilters,parseDashboardSpeedProbes,parseDashboardWanRuntime} from "@/lib/dashboard-path-response" import {parseDashboardGre,parseDashboardWireGuard,parseDashboardVxlan,type DashboardGreTunnel} from "@/lib/dashboard-overlays" import type {DashboardProbe} from "@/lib/dashboard-probes" import {parseDashboardServers,parseDashboardProbes,type DashboardServerRow} from "@/lib/dashboard-readers" import {parseDashboardTraffic,type DashboardTraffic} from "@/lib/dashboard-traffic" import {parseDashboardBgp,type DashboardBgp} from "@/lib/dashboard-bgp" import {dashboardRequestPermission} from "@/lib/dashboard-permissions" async function dashboardRequest(path:string,work:()=>Promise,method="GET"):Promise{ await ensureAuthConfig();const permission=dashboardRequestPermission(method,path) if(!can(permission))throw Error("Нет доступа к источнику: "+permission) const result=await work();if(!can(permission))throw Error("Доступ к источнику отозван: "+permission) return result } 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 dashboardRequest(path,()=>requestJson(backendUrl,path,init),init?.method??"GET") } } 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() } } type BackendServerRow=DashboardServerRow type ApiGreTunnelRow=DashboardGreTunnel 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 ?? "unknown", latency: s.latency != null ? Math.round(s.latency) : null, sessions: s.sessions, wanUplinks, } } function mockOverlayItems(): OverlayItem[] { const gre: OverlayItem[] = mockGreTunnels.map((t) => ({ id: JSON.stringify(["gre", t.serverId, t.id]), name: t.name, kind: "gre", href: "/gre?server=" + encodeURIComponent(t.serverId) + "&tunnel=" + encodeURIComponent(t.id), status: t.status, })) const wg: OverlayItem[] = mockServers.flatMap((s) => (s.wireGuardIfaces ?? []).map((iface) => ({ id: JSON.stringify(["wg", s.id, iface.id]), name: iface.name, kind: "wg" as const, href: "/wireguard?server=" + encodeURIComponent(s.id) + "&interface=" + encodeURIComponent(iface.id), status: iface.status, })), ) const vx: OverlayItem[] = mockVxlan.map((t) => ({ id: JSON.stringify(["vxlan", t.serverId, t.id]), name: t.name, kind: "vxlan", href: "/vxlan?server=" + encodeURIComponent(t.serverId) + "&tunnel=" + encodeURIComponent(t.id), 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(null) const [trafficSeries, setTrafficSeries] = useState(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 [sources, setSources] = useState({}) const snapshotRequest = useRef(0) const eventsRequest = useRef(0) const recordSource = useCallback((source: DashboardSource, error: string | null, sampledAt?: string) => { setSources(previous => updateDashboardSource(previous, source, error, Date.now(), sampledAt)) }, []) const fetchSnapshot = useCallback(async (_silent: boolean) => { if (!isLive) return const request = ++snapshotRequest.current const current = () => request === snapshotRequest.current setLoading(true) setInternetPathLoading(true) try { await ensureAuthConfig();if(!current())return if(!can("mm:servers:read"))setLiveServers(null) if(!can("mm:uptime:read"))setLiveProbes(null) if(!can("mm:bgp:read"))setBgp(null) if(!can("mm:traffic:read"))setTrafficSeries(null) if(!can("mm:network:read"))setInternetPath(null) setOverlayItems(previous=>previous?.filter(item=>can(item.kind==="gre"?"mm:filters:read":item.kind==="wg"?"mm:network:read":"mm:dashboard:read"))??null) const [overviewRes, serversRes, fr, br, ipRes, greRes, wgRes, trafficRes, vxRes] = await Promise.allSettled([ apiFetch("/api/uptime/overview?range=1h").then(parseDashboardProbes), apiFetch("/api/servers").then(parseDashboardServers), apiFetch("/api/filters/rules").then(parseDashboardFilters), apiFetch("/api/bgp/catalog").then(parseDashboardBgp), apiFetch("/api/internet-path/latest").then(parseDashboardPath), apiFetch("/api/filters/gre-tunnels").then(parseDashboardGre), apiFetch("/api/wireguard").then(parseDashboardWireGuard), apiFetch("/api/traffic/servers?range=1h").then(parseDashboardTraffic), apiFetch("/api/vxlan").then(parseDashboardVxlan), ]) if (!current()) return const results = { probes: overviewRes, servers: serversRes, bgp: br, gre: greRes, wg: wgRes, traffic: trafficRes, vxlan: vxRes } for (const [key, result] of Object.entries(results)) { recordSource(key as DashboardSource, result.status === "rejected" ? result.reason instanceof Error ? result.reason.message : "Источник недоступен" : null,key==="traffic"&&trafficRes.status==="fulfilled"?trafficRes.value.sampledAt??undefined:key==="probes"&&overviewRes.status==="fulfilled"?overviewRes.value.sampledAt??undefined:undefined) } const failures = Object.values(results).filter(result => result.status === "rejected").length setError(failures ? "Не все источники обновлены. Последние полученные данные сохранены." : null) if (overviewRes.status === "fulfilled") { setLiveProbes(overviewRes.value.probes) } let serversMapped: Server[] = [] if (serversRes.status === "fulfilled") { serversMapped = serversRes.value.map(mapBackendToServer) setLiveServers(serversMapped) } if (br.status === "fulfilled") setBgp(br.value) const greItems: OverlayItem[] = greRes.status === "fulfilled" ? (greRes.value.tunnels ?? []).map((t) => ({ id: JSON.stringify(["gre", t.serverId, t.id]), name: t.name, kind: "gre" as const, href: "/gre?server=" + encodeURIComponent(t.serverId) + "&tunnel=" + encodeURIComponent(t.id), status: t.status, })) : [] const wgItems: OverlayItem[] = wgRes.status === "fulfilled" ? wgRes.value.interfaces.map((iface) => ({ id: JSON.stringify(["wg", iface.serverId, iface.id]), name: iface.name, kind: "wg" as const, href: "/wireguard?server=" + encodeURIComponent(iface.serverId) + "&interface=" + encodeURIComponent(iface.id), status: iface.status, })) : [] const vxItems: OverlayItem[] = vxRes.status === "fulfilled" ? (vxRes.value.tunnels ?? []).map((t) => ({ id: JSON.stringify(["vxlan", t.serverId, t.id]), name: t.name, kind: "vxlan" as const, href: "/vxlan?server=" + encodeURIComponent(t.serverId) + "&tunnel=" + encodeURIComponent(t.id), status: t.status, })) : [] setOverlayItems(previous => [ ...(greRes.status === "fulfilled" ? greItems : previous?.filter(item => item.kind === "gre") ?? []), ...(wgRes.status === "fulfilled" ? wgItems : previous?.filter(item => item.kind === "wg") ?? []), ...(vxRes.status === "fulfilled" ? vxItems : previous?.filter(item => item.kind === "vxlan") ?? []), ]) if (trafficRes.status === "fulfilled") setTrafficSeries(trafficRes.value) const greMapped = greRes.status === "fulfilled" ? (greRes.value.tunnels ?? []).map(apiGreToGreTunnel) : [] if (ipRes.status === "rejected") throw ipRes.reason const snap = ipRes.value.snapshot if (snap || serversMapped.length > 0) { 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 { if (serversRes.status === "rejected" || fr.status === "rejected" || greRes.status === "rejected") { throw new Error("Не удалось получить исходные данные Internet path") } let lookupFailed = false const checkedFetch: typeof apiFetch = async (path, init) => { if (!current()) throw new Error("Обновление заменено новым запросом") try { return await apiFetch(path, init) } catch (error) { lookupFailed = true; throw error } } const filterRulesets = fr.status === "fulfilled" ? fr.value.rulesets : [] const homes = serversMapped.filter((s) => s.type === "home-router" && s.enabled) const lookups = await Promise.all( homes.map(async (h) => ({ id: h.id, lookup: await resolveDefaultRouteLookup(checkedFetch, h.id), })), ) if (!current()) return const wanRuntimeRows = await Promise.all( homes.map(async (h) => { try { const rt = await checkedFetch(`/api/servers/${h.id}/wan-runtime`).then(parseDashboardWanRuntime) return { id: h.id, runtime: rt } } catch { lookupFailed=true return { id: h.id, runtime: null } } }), ) if (!current()) return const speedRes = await checkedFetch("/api/uptime/speed-probes").then(parseDashboardSpeedProbes).catch(error=>{lookupFailed=true;throw Error("Не удалось обновить все данные Internet path: "+(error instanceof Error?error.message:String(error)))}) if (!current()) return if (lookupFailed) throw new Error("Не удалось обновить все данные Internet path") 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 { if (serversRes.status === "rejected") throw serversRes.reason setInternetPath(null) } recordSource("internetPath", null, snap?.sampledAt) setInternetPathError(null) } catch (e) { if (!current()) return const msg = e instanceof Error ? e.message : "Не удалось загрузить дашборд" setInternetPathError(msg) recordSource("internetPath", msg) } finally { if (current()) { setLoading(false) setInternetPathLoading(false) } } }, [apiFetch, isLive, recordSource]) const fetchRecentEvents = useCallback(async (_silent: boolean) => { if (!isLive) { setRecentEvents([]) setEventsError(null) return } const request = ++eventsRequest.current const current = () => request === eventsRequest.current setEventsLoading(true) try { const rows = await dashboardRequest("/api/events",()=>listEvents(backendUrl, { limit: 6 })) if (!current()) return setRecentEvents(rows) recordSource("events", null) setEventsError(null) } catch (err) { if (!current()) return const message = err instanceof Error ? err.message : "Не удалось загрузить события" recordSource("events", message) setEventsError(message) } finally { if (current()) setEventsLoading(false) } }, [backendUrl, isLive, recordSource]) useEffect(() => { let cancelled = false queueMicrotask(() => { if (cancelled) return setLiveProbes(null) setLiveServers(null) setOverlayItems(null) setBgp(null) setTrafficSeries(null) setError(null) setInternetPath(null) setInternetPathError(null) setRecentEvents([]) setEventsError(null) setSources({}) setLoading(false) setEventsLoading(false) setInternetPathLoading(false) void fetchSnapshot(false) void fetchRecentEvents(false) }) return () => { cancelled = true snapshotRequest.current += 1 eventsRequest.current += 1 } }, [fetchSnapshot, 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(() => { 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,observationsKnown:true,sampledAt:null, } } 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") .map((s) => ({ id: s.id, title: s.name, hint: s.status === "unknown" ? "Статус неизвестен" : s.status === "degraded" ? "Ухудшение" : "Не в сети", href: "/servers?server=" + encodeURIComponent(s.id), tone: s.status === "degraded" || s.status === "unknown" ? ("warning" as const) : ("destructive" as const), })) }, [servers]) const attentionOverlay: AttentionRow[] = useMemo(() => { return overlay.items .filter((i) => i.status !== "up") .map((i) => ({ id: i.id, title: i.name, hint: i.status === "degraded" ? "Ухудшение" : "Не работает", 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" || p.status === "unknown") .map((p) => ({ id: p.id, title: p.name, hint: p.status === "unknown" ? "Статус неизвестен" : p.status === "warn" ? "Предупреждение" : "Ошибка", href: "/uptime?probe=" + encodeURIComponent(p.id), tone: p.status === "warn" || p.status === "unknown" ? ("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 && loading && liveServers === null && liveProbes === null const kpiLoading = !prefsHydrated || dataPending const probesSubtitle = !prefsHydrated ? "Загрузка…" : !isLive ? mockActiveProbes.length > 0 ? `${mockActiveProbes.length} на дашборде · демо` : "Нет проб на дашборде · отметьте ★ в мониторинге" : sources.probes?.error && liveProbes === null ? "Данные проб недоступны" : activeProbes.length > 0 ? `${activeProbes.length} на дашборде · 1 ч` : "Нет проб на дашборде · отметьте ★ в мониторинге" return { prefsHydrated, isLive, loading: kpiLoading, error, sources, refreshing: isLive && (loading || eventsLoading), availability: { servers: !isLive || liveServers !== null, probes: !isLive || liveProbes !== null, overlay: !isLive || ["gre", "wg", "vxlan"].every(key => sources[key as DashboardSource]?.receivedAt != null), }, reliable: { servers: !isLive || (liveServers !== null && !sources.servers?.error && liveServers.every(s=>s.status!=="unknown")), probes: !isLive || (liveProbes !== null && !sources.probes?.error && liveProbes.filter(p=>p.enabled).every(p=>p.status!=="unknown")), overlay: !isLive || ["gre", "wg", "vxlan"].every(key => sources[key as DashboardSource]?.receivedAt != null && !sources[key as DashboardSource]?.error), }, retry: () => { void fetchSnapshot(false) void fetchRecentEvents(false) }, servers, activeProbes, overlay, bgp: isLive ? bgp : { prefixSum: 8432, establishedCount: 3 }, traffic, onlineCount, offlineCount:servers.filter(s=>s.status==="offline").length, degradedServerCount:servers.filter(s=>s.status==="degraded").length, unknownServerCount:servers.filter(s=>s.status==="unknown").length, totalServers: servers.length, probeDown, probeWarn, probeCount: enabledProbes.length, unknownProbeCount:enabledProbes.filter(p=>p.status==="unknown").length, latency, recentEvents, eventsLoading, eventsError, internetPath, internetPathLoading: isLive && internetPathLoading && !internetPath, internetPathError, attentionServers, attentionOverlay, attentionProbes, probesSubtitle, probesLoading: isLive && loading && liveProbes === null, } }