Docker images / prepare-release (push) Successful in 10s
Docker images / backend-test (push) Successful in 2m13s
Docker images / frontend-image (push) Successful in 3m14s
Docker images / updater-image (push) Successful in 47s
Docker images / backend-image (push) Successful in 2m44s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 14s
Co-authored-by: Cursor <[email protected]>
688 lines
21 KiB
TypeScript
688 lines
21 KiB
TypeScript
"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<T>(path: string, init?: RequestInit): Promise<T> {
|
|
return requestJson<T>(backendUrl, path, init)
|
|
}
|
|
}
|
|
|
|
function readMockDashboardStarIds(): Set<string> {
|
|
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<string, { gateway: string | null; routingMark: string | null } | null>
|
|
wanRuntimeByHomeId: Record<string, HomeWanRuntime | null>
|
|
}
|
|
|
|
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<string, number[]>; labels?: Record<string, string>; subtitle: string }
|
|
| { kind: "live"; series: Record<string, number[]>; labels?: Record<string, string>; 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<PingProbe[] | null>(null)
|
|
const [liveServers, setLiveServers] = useState<Server[] | null>(null)
|
|
const [overlayItems, setOverlayItems] = useState<OverlayItem[] | null>(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<EventItem[]>([])
|
|
const [eventsError, setEventsError] = useState<string | null>(null)
|
|
const [eventsLoading, setEventsLoading] = useState(false)
|
|
const [internetPath, setInternetPath] = useState<InternetPathViewModel | null>(null)
|
|
const [internetPathLoading, setInternetPathLoading] = useState(false)
|
|
const [internetPathError, setInternetPathError] = useState<string | null>(null)
|
|
const [loading, setLoading] = useState(false)
|
|
const [error, setError] = useState<string | null>(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<BackendServerRow[]>("/api/servers"),
|
|
apiFetch<{ rulesets: Array<{ rules?: unknown[] }> }>("/api/filters/rules"),
|
|
apiFetch<Array<{ state?: string; prefixesRx?: number }>>("/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<HomeWanRuntime>(`/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,
|
|
}
|
|
}
|