Docker images / prepare-release (push) Successful in 9s
Docker images / backend-image (push) Successful in 1m43s
Docker images / frontend-image (push) Successful in 2m58s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 39s
Docker images / publish-release (push) Successful in 8s
Replaced existing KPI display implementations across multiple pages with the new KpiStatGrid component for a more consistent and visually appealing presentation of statistics. Updated the Backups, BGP, Certificates, Communities, Containers, Dashboard, and Data Collection pages to utilize the KpiStatGrid, improving the overall user experience and maintainability of the codebase. Additionally, added new dependencies in package.json for required libraries.
935 lines
37 KiB
TypeScript
935 lines
37 KiB
TypeScript
"use client"
|
||
|
||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||
import { usePathname } from "next/navigation"
|
||
import Link from "next/link"
|
||
import { PageHeader } from "@/components/page-header"
|
||
import { OpsPanel } from "@/components/ops-panel"
|
||
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||
import { StatusDot } from "@/components/status-dot"
|
||
import { StatusBadge } from "@/components/status-badge"
|
||
import { LatencyChart } from "@/components/dashboard/latency-chart"
|
||
import { BandwidthChart } from "@/components/dashboard/bandwidth-chart"
|
||
import { InternetPathMapCard } from "@/components/dashboard/internet-path-map"
|
||
import {
|
||
servers as mockServers,
|
||
pingProbes,
|
||
dashLatency,
|
||
traffic,
|
||
serverFilterRulesets,
|
||
} from "@/lib/data"
|
||
import type { PingProbe, Server, ServerStatus, ServerType } from "@/lib/data"
|
||
import type { GreTunnel } from "@/lib/data"
|
||
import { useDataSource } from "@/lib/data-source"
|
||
import { Flag } from "@/components/flag"
|
||
import { AlertCircleIcon, AlertTriangleIcon, BellIcon, FilterIcon, GitMergeIcon, InfoIcon, DownloadIcon, ServerIcon } from "lucide-react"
|
||
import { Button, buttonVariants } from "@/components/ui/button"
|
||
import { DataPageCard } from "@/components/data-page-card"
|
||
import { DashboardActiveProbesDataGrid } from "@/components/data-grids/dashboard-active-probes-data-grid"
|
||
import { cn } from "@/lib/utils"
|
||
import { requestJson } from "@/shared/api/http-client"
|
||
import { buildLatencySeriesByProbeSource } from "@/lib/dashboard-latency"
|
||
import {
|
||
buildDashboardInternetPath,
|
||
type HomeWanRuntime,
|
||
resolveDefaultRouteLookup,
|
||
type InternetPathViewModel,
|
||
} from "@/lib/dashboard-internet-path"
|
||
import type { FiltersRulesetRow, RouteOptimizerSpeedProbe } from "@/lib/route-optimizer-data"
|
||
import { listEvents } from "@/shared/api/events"
|
||
import type { EventItem } from "@mmapp/contracts/events"
|
||
|
||
function makeApiFetch(backendUrl: string) {
|
||
return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||
return requestJson<T>(backendUrl, path, init)
|
||
}
|
||
}
|
||
|
||
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<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>
|
||
}
|
||
|
||
function apiGreToGreTunnel(t: ApiGreTunnelRow): GreTunnel {
|
||
return {
|
||
id: t.id,
|
||
name: t.name,
|
||
serverId: String(t.serverId),
|
||
localAddress: t.localAddress,
|
||
remoteAddress: t.remoteAddress,
|
||
localInnerIp: t.localInnerIp,
|
||
remoteInnerIp: t.remoteInnerIp,
|
||
poolId: t.poolId || "live",
|
||
ipsec: null,
|
||
mtu: t.mtu,
|
||
keepaliveInterval: t.keepaliveInterval,
|
||
keepaliveRetries: t.keepaliveRetries,
|
||
dscp: t.dscp,
|
||
clampTcpMss: t.clampTcpMss,
|
||
allowFastPath: t.allowFastPath,
|
||
comment: t.comment,
|
||
enabled: t.enabled,
|
||
status: t.status,
|
||
}
|
||
}
|
||
|
||
function mapBackendToServer(s: BackendServerRow): Server {
|
||
const wanUplinks = Array.isArray(s.wanUplinks)
|
||
? s.wanUplinks
|
||
.filter((w) => typeof w === "object" && w != null)
|
||
.map((w, idx) => ({
|
||
id: String(w.id || `wan-${s.id}-${idx + 1}`),
|
||
name: String(w.name || `WAN${idx + 1}`),
|
||
isp: String(w.isp || "—"),
|
||
iface: String(w.iface || ""),
|
||
ip: String(w.ip || ""),
|
||
maxDl: Math.max(1, Math.round(Number(w.maxDl) || 100)),
|
||
maxUl: Math.max(1, Math.round(Number(w.maxUl) || 100)),
|
||
}))
|
||
: []
|
||
return {
|
||
id: String(s.id),
|
||
name: s.name || s.host,
|
||
host: s.host,
|
||
model: s.model ?? "—",
|
||
os: s.os ?? "—",
|
||
site: s.site || "—",
|
||
country: s.country || "UN",
|
||
asn: s.asn,
|
||
type: s.type,
|
||
enabled: s.enabled,
|
||
status: (s.status ?? "offline") as ServerStatus,
|
||
latency: s.latency != null ? Math.round(s.latency) : null,
|
||
sessions: s.sessions ?? 0,
|
||
wanUplinks,
|
||
}
|
||
}
|
||
|
||
export default function DashboardPage() {
|
||
const pathname = usePathname()
|
||
const { mode, backendUrl, prefsHydrated } = useDataSource()
|
||
const isLive = prefsHydrated && mode === "live"
|
||
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
||
|
||
const [liveProbes, setLiveProbes] = useState<PingProbe[] | null>(null)
|
||
const [liveServersResolved, setLiveServersResolved] = useState<Server[] | null>(null)
|
||
const [liveKpi, setLiveKpi] = useState<LiveKpiSnapshot | null>(null)
|
||
const [probesLoading, setProbesLoading] = useState(false)
|
||
const [probesError, setProbesError] = useState<string | null>(null)
|
||
const [recentEvents, setRecentEvents] = useState<EventItem[]>([])
|
||
const [eventsLoading, setEventsLoading] = useState(false)
|
||
const [eventsError, setEventsError] = useState<string | null>(null)
|
||
const [internetPath, setInternetPath] = useState<InternetPathViewModel | null>(null)
|
||
const [internetPathLoading, setInternetPathLoading] = useState(false)
|
||
const [internetPathError, setInternetPathError] = useState<string | null>(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<BackendServerRow[]>("/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<Array<{ state?: string; prefixesRx?: number }>>("/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<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 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<string, string> | undefined,
|
||
subtitle:
|
||
"Последние 60 минут · ping от монитора → серверы MikroTik · демо",
|
||
}
|
||
}
|
||
if (liveProbes === null && probesLoading) {
|
||
return { kind: "loading" as const }
|
||
}
|
||
if (!liveProbes?.length) {
|
||
return {
|
||
kind: "empty" as const,
|
||
message: "Нет данных проб. Откройте мониторинг и проверьте сборщик uptime.",
|
||
}
|
||
}
|
||
const catalog = liveServersResolved ?? []
|
||
const { series, labels } = buildLatencySeriesByProbeSource(
|
||
liveProbes,
|
||
catalog,
|
||
{ maxServers: 8, points: 60 },
|
||
)
|
||
if (Object.keys(series).length === 0) {
|
||
return {
|
||
kind: "empty" as const,
|
||
message:
|
||
"Нет включённых проб с историей RTT. Включите пробы на странице «Мониторинг».",
|
||
}
|
||
}
|
||
return {
|
||
kind: "live" as const,
|
||
series,
|
||
labels,
|
||
subtitle:
|
||
"Средний RTT по источникам проб · окно 1 ч · до 8 узлов · API",
|
||
}
|
||
}, [prefsHydrated, isLive, liveProbes, probesLoading, liveServersResolved])
|
||
|
||
const dashboardKpi = useMemo(() => {
|
||
const sparkSrv = [5, 5, 6, 6, 5, 6, 6, 6, 7, 6, 6, 6, 6, 6, 6, 6, 6]
|
||
const sparkFlt = [3, 4, 4, 5, 4, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5]
|
||
const sparkBgp = [7800, 7900, 8000, 8100, 8050, 8120, 8200, 8240, 8300, 8350, 8380, 8400, 8420, 8430, 8432]
|
||
const sparkAlt = [1, 2, 2, 3, 3, 4, 5, 4, 4, 4, 3, 3, 4, 4, 4]
|
||
|
||
const loadingKpi = (sparkColor: string) => ({
|
||
value: "—",
|
||
unit: undefined as string | undefined,
|
||
delta: "Загрузка…",
|
||
deltaDir: "up" as const,
|
||
spark: undefined as number[] | undefined,
|
||
sparkColor,
|
||
})
|
||
|
||
if (!prefsHydrated) {
|
||
return {
|
||
servers: loadingKpi("var(--chart-line-1)"),
|
||
filters: loadingKpi("var(--chart-line-2)"),
|
||
bgp: loadingKpi("var(--chart-line-4)"),
|
||
alerts: loadingKpi("var(--chart-5)"),
|
||
}
|
||
}
|
||
|
||
if (!isLive) {
|
||
const totalSrv = mockServers.length
|
||
const onlineSrv = mockServers.filter((s) => s.status === "online").length
|
||
const ruleTotal = serverFilterRulesets.reduce((n, rs) => n + rs.rules.length, 0)
|
||
const serversWithRules = serverFilterRulesets.filter((rs) => rs.rules.length > 0).length
|
||
const en = pingProbes.filter((p) => p.enabled)
|
||
const down = en.filter((p) => p.status === "down").length
|
||
const warn = en.filter((p) => p.status === "warn").length
|
||
return {
|
||
servers: {
|
||
value: String(onlineSrv),
|
||
unit: `/ ${totalSrv}`,
|
||
delta: `${totalSrv - onlineSrv} offline`,
|
||
deltaDir: onlineSrv < totalSrv ? ("down" as const) : ("up" as const),
|
||
spark: sparkSrv,
|
||
sparkColor: "var(--chart-line-1)",
|
||
},
|
||
filters: {
|
||
value: String(ruleTotal),
|
||
unit: `/ ${serverFilterRulesets.length}`,
|
||
delta: `${serversWithRules} серверов с правилами · демо`,
|
||
deltaDir: "up" as const,
|
||
spark: sparkFlt,
|
||
sparkColor: "var(--chart-line-2)",
|
||
},
|
||
bgp: {
|
||
value: "8 432",
|
||
unit: undefined as string | undefined,
|
||
delta: "демо · не из API",
|
||
deltaDir: "up" as const,
|
||
spark: sparkBgp,
|
||
sparkColor: "var(--chart-line-4)",
|
||
},
|
||
alerts: {
|
||
value: String(down + warn),
|
||
unit: undefined as string | undefined,
|
||
delta: `${down} down · ${warn} warn`,
|
||
deltaDir: down + warn > 0 ? ("down" as const) : ("up" as const),
|
||
spark: sparkAlt,
|
||
sparkColor: "var(--chart-5)",
|
||
},
|
||
}
|
||
}
|
||
|
||
const liveDataPending = liveServersResolved === null || liveProbes === null
|
||
const loadingBlock = liveDataPending || (probesLoading && liveProbes === null)
|
||
const srvList = liveServersResolved ?? []
|
||
const totalSrv = srvList.length
|
||
const onlineSrv = srvList.filter((s) => s.status === "online").length
|
||
const offlineSrv = Math.max(0, totalSrv - onlineSrv)
|
||
|
||
const en = liveProbes?.filter((p) => p.enabled) ?? []
|
||
const down = en.filter((p) => p.status === "down").length
|
||
const warn = en.filter((p) => p.status === "warn").length
|
||
|
||
const filters = liveKpi?.filters
|
||
const bgp = liveKpi?.bgp
|
||
|
||
return {
|
||
servers: {
|
||
value: loadingBlock ? "—" : String(onlineSrv),
|
||
unit: loadingBlock ? undefined : `/ ${totalSrv}`,
|
||
delta: loadingBlock
|
||
? "Загрузка…"
|
||
: offlineSrv > 0
|
||
? `${offlineSrv} offline · опрос API`
|
||
: totalSrv > 0
|
||
? "Все узлы online в последнем опросе"
|
||
: "Нет серверов в базе",
|
||
deltaDir: offlineSrv > 0 ? ("down" as const) : ("up" as const),
|
||
spark: undefined as number[] | undefined,
|
||
sparkColor: "var(--chart-line-1)",
|
||
},
|
||
filters: {
|
||
value: loadingBlock ? "—" : filters ? String(filters.ruleTotal) : "—",
|
||
unit:
|
||
filters && filters.serversWithRules > 0
|
||
? `на ${filters.serversWithRules} серв.`
|
||
: undefined,
|
||
delta: loadingBlock
|
||
? "Загрузка…"
|
||
: filters
|
||
? `${filters.serversWithRules} серверов с правилами · /api/filters/rules`
|
||
: "Не удалось загрузить правила",
|
||
deltaDir: filters ? ("up" as const) : ("down" as const),
|
||
spark: undefined as number[] | undefined,
|
||
sparkColor: "var(--chart-line-2)",
|
||
},
|
||
bgp: {
|
||
value: loadingBlock ? "—" : bgp ? fmtIntRu(bgp.prefixSum) : "—",
|
||
unit: undefined as string | undefined,
|
||
delta: loadingBlock
|
||
? "Загрузка…"
|
||
: bgp
|
||
? `Σ prefixes Rx · ${bgp.establishedCount} Established · /api/bgp/sessions`
|
||
: "Не удалось загрузить BGP",
|
||
deltaDir: (bgp ? "up" : "down") as "up" | "down",
|
||
spark: undefined as number[] | undefined,
|
||
sparkColor: "var(--chart-line-4)",
|
||
},
|
||
alerts: {
|
||
value: loadingBlock ? "—" : String(down + warn),
|
||
unit: undefined as string | undefined,
|
||
delta: loadingBlock
|
||
? "Загрузка…"
|
||
: `${down} down · ${warn} warn · включённые пробы`,
|
||
deltaDir: down + warn > 0 ? ("down" as const) : ("up" as const),
|
||
spark: undefined as number[] | undefined,
|
||
sparkColor: "var(--chart-5)",
|
||
},
|
||
}
|
||
}, [prefsHydrated, isLive, liveServersResolved, liveKpi, liveProbes, probesLoading])
|
||
|
||
return (
|
||
<div className="flex flex-col h-full">
|
||
<PageHeader
|
||
crumbs={[{ label: "Обзор" }, { label: "Дашборд" }]}
|
||
actions={
|
||
<>
|
||
<Button variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button>
|
||
</>
|
||
}
|
||
/>
|
||
|
||
<div className="flex-1 overflow-y-auto">
|
||
<div className="p-6 flex flex-col gap-6">
|
||
|
||
<KpiStatGrid
|
||
aria-label="Сводка дашборда"
|
||
items={[
|
||
{
|
||
id: "servers",
|
||
label: "Серверы онлайн",
|
||
value: dashboardKpi.servers.unit
|
||
? `${dashboardKpi.servers.value} ${dashboardKpi.servers.unit}`
|
||
: dashboardKpi.servers.value,
|
||
hint: dashboardKpi.servers.delta,
|
||
icon: <ServerIcon className="size-4" />,
|
||
iconClassName: "text-muted-foreground",
|
||
variant: dashboardKpi.servers.deltaDir === "down" ? "warning" : "default",
|
||
},
|
||
{
|
||
id: "filters",
|
||
label: "Активные фильтры",
|
||
value: dashboardKpi.filters.unit
|
||
? `${dashboardKpi.filters.value} ${dashboardKpi.filters.unit}`
|
||
: dashboardKpi.filters.value,
|
||
hint: dashboardKpi.filters.delta,
|
||
icon: <FilterIcon className="size-4" />,
|
||
iconClassName: "text-info",
|
||
},
|
||
{
|
||
id: "bgp",
|
||
label: "BGP-префиксы",
|
||
value: dashboardKpi.bgp.unit
|
||
? `${dashboardKpi.bgp.value} ${dashboardKpi.bgp.unit}`
|
||
: dashboardKpi.bgp.value,
|
||
hint: dashboardKpi.bgp.delta,
|
||
icon: <GitMergeIcon className="size-4" />,
|
||
iconClassName: "text-primary",
|
||
},
|
||
{
|
||
id: "alerts",
|
||
label: "Активные алерты",
|
||
value: dashboardKpi.alerts.unit
|
||
? `${dashboardKpi.alerts.value} ${dashboardKpi.alerts.unit}`
|
||
: dashboardKpi.alerts.value,
|
||
hint: dashboardKpi.alerts.delta,
|
||
icon: <BellIcon className="size-4" />,
|
||
iconClassName: dashboardKpi.alerts.deltaDir === "down" ? "text-destructive" : "text-muted-foreground",
|
||
variant: dashboardKpi.alerts.deltaDir === "down" ? "destructive" : "default",
|
||
},
|
||
]}
|
||
/>
|
||
|
||
{/* Latency chart + Events */}
|
||
<div className="grid grid-cols-1 lg:grid-cols-[2fr_1fr] gap-4">
|
||
<OpsPanel
|
||
title="Задержка до серверов"
|
||
description={
|
||
latencyChartBlock.kind === "mock" || latencyChartBlock.kind === "live"
|
||
? latencyChartBlock.subtitle
|
||
: latencyChartBlock.kind === "loading"
|
||
? "Загрузка…"
|
||
: "Нет серии RTT для графика"
|
||
}
|
||
contentClassName="px-3 pb-3"
|
||
>
|
||
{latencyChartBlock.kind === "loading" && (
|
||
<div
|
||
className="w-full rounded-md bg-muted/50 animate-pulse"
|
||
style={{ height: 220 }}
|
||
/>
|
||
)}
|
||
{latencyChartBlock.kind === "empty" && (
|
||
<p className="text-sm text-muted-foreground py-10 text-center px-4">
|
||
{latencyChartBlock.message}
|
||
</p>
|
||
)}
|
||
{(latencyChartBlock.kind === "mock" || latencyChartBlock.kind === "live") && (
|
||
<LatencyChart
|
||
series={latencyChartBlock.series}
|
||
labels={latencyChartBlock.labels}
|
||
/>
|
||
)}
|
||
</OpsPanel>
|
||
|
||
<OpsPanel
|
||
title="Последние события"
|
||
description="Система и BGP-активность"
|
||
headerRight={
|
||
<Link
|
||
href="/alerts"
|
||
className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "text-xs h-7")}
|
||
>
|
||
Все →
|
||
</Link>
|
||
}
|
||
>
|
||
<div className="divide-y divide-border">
|
||
{eventsLoading && recentEvents.length === 0 && (
|
||
<div className="px-5 py-6 text-sm text-muted-foreground">Загрузка событий...</div>
|
||
)}
|
||
{eventsError && recentEvents.length === 0 && (
|
||
<div className="px-5 py-6 text-sm text-destructive">{eventsError}</div>
|
||
)}
|
||
{!eventsLoading && !eventsError && recentEvents.length === 0 && (
|
||
<div className="px-5 py-6 text-sm text-muted-foreground">Событий пока нет.</div>
|
||
)}
|
||
{recentEvents.map((e) => (
|
||
<div key={e.id} className="grid grid-cols-[20px_1fr_auto] gap-3 px-5 py-3 items-start">
|
||
<div className="mt-0.5">
|
||
{e.level === "critical" && <AlertCircleIcon className="size-4 text-destructive" />}
|
||
{e.level === "warning" && <AlertTriangleIcon className="size-4 text-[var(--status-degraded)]" />}
|
||
{e.level === "info" && <InfoIcon className="size-4 text-[var(--chart-1)]" />}
|
||
</div>
|
||
<div>
|
||
<p className="text-[13px] font-medium leading-tight">{e.title}</p>
|
||
<p className="text-xs text-muted-foreground mt-0.5 leading-snug">{e.message}</p>
|
||
</div>
|
||
<span className="text-[11px] font-mono text-muted-foreground">{formatEventAge(e.createdAt)}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</OpsPanel>
|
||
</div>
|
||
|
||
{/* Bandwidth + Server status */}
|
||
<div className="grid grid-cols-1 lg:grid-cols-[3fr_2fr] gap-4">
|
||
<OpsPanel
|
||
title="Пропускная способность"
|
||
description="Суммарный RX / TX по всем серверам"
|
||
headerRight={
|
||
<div className="flex items-center gap-3 text-xs">
|
||
<span className="flex items-center gap-1.5"><span className="w-3 h-0.5 bg-foreground/80 rounded inline-block" />RX 318 Мбит/с</span>
|
||
<span className="flex items-center gap-1.5"><span className="w-3 h-0.5 bg-[var(--chart-tx)] rounded inline-block" />TX 244 Мбит/с</span>
|
||
</div>
|
||
}
|
||
contentClassName="px-3 pb-3"
|
||
>
|
||
<BandwidthChart rx={traffic.rx} tx={traffic.tx} />
|
||
</OpsPanel>
|
||
|
||
<OpsPanel
|
||
title="Состояние серверов"
|
||
description={
|
||
<span className="truncate" title={serverStatusModel.subtitle}>
|
||
{serverStatusModel.subtitle}
|
||
</span>
|
||
}
|
||
headerRight={
|
||
<Link
|
||
href="/servers"
|
||
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-7 text-xs shrink-0")}
|
||
>
|
||
Управление →
|
||
</Link>
|
||
}
|
||
>
|
||
<div className="divide-y divide-border">
|
||
{serverStatusModel.kind === "loading" && (
|
||
<>
|
||
{Array.from({ length: 5 }, (_, i) => (
|
||
<div key={i} className="grid grid-cols-[10px_1fr_auto_auto] gap-3 px-5 py-2.5 items-center">
|
||
<div className="size-2 rounded-full bg-muted animate-pulse" />
|
||
<div className="space-y-2 min-w-0">
|
||
<div className="h-4 rounded bg-muted/80 animate-pulse max-w-[180px]" />
|
||
<div className="h-3 rounded bg-muted/60 animate-pulse max-w-[240px]" />
|
||
</div>
|
||
<div className="h-4 w-12 rounded bg-muted/60 animate-pulse" />
|
||
<div className="h-5 w-16 rounded bg-muted/60 animate-pulse justify-self-end" />
|
||
</div>
|
||
))}
|
||
</>
|
||
)}
|
||
{serverStatusModel.kind === "unavailable" && (
|
||
<div className="px-5 py-8 text-center text-sm text-muted-foreground">
|
||
{serverStatusModel.subtitle}
|
||
</div>
|
||
)}
|
||
{(serverStatusModel.kind === "mock" || serverStatusModel.kind === "live") && serverStatusModel.servers.length === 0 && (
|
||
<div className="px-5 py-8 text-center text-sm text-muted-foreground">
|
||
Нет серверов в базе. Добавьте узел на странице «Серверы».
|
||
</div>
|
||
)}
|
||
{(serverStatusModel.kind === "mock" || serverStatusModel.kind === "live") && serverStatusModel.servers.map((s) => (
|
||
<div
|
||
key={s.id}
|
||
className={cn(
|
||
"grid grid-cols-[10px_1fr_auto_auto] gap-3 px-5 py-2.5 items-center",
|
||
!s.enabled && "opacity-60",
|
||
)}
|
||
>
|
||
<StatusDot status={s.status} pulse={s.status === "online"} />
|
||
<div className="min-w-0">
|
||
<p className="text-[13px] font-medium leading-tight truncate">{s.name}</p>
|
||
<p className="text-[11px] font-mono text-muted-foreground flex items-center gap-1 truncate">
|
||
<Flag code={s.country} className="not-mono shrink-0" />
|
||
<span className="truncate">{s.host} · {s.site} · {s.asn}</span>
|
||
</p>
|
||
</div>
|
||
<span className={`text-xs font-mono tabular-nums shrink-0 ${s.latency == null ? "text-[var(--status-offline-fg)]" : s.latency > 60 ? "text-[var(--status-degraded-fg)]" : "text-muted-foreground"}`}>
|
||
{s.latency == null ? "недоступен" : `${s.latency}мс`}
|
||
</span>
|
||
<StatusBadge status={s.status} />
|
||
</div>
|
||
))}
|
||
</div>
|
||
</OpsPanel>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 gap-4">
|
||
{internetPathError && isLive && (
|
||
<div className="text-sm text-destructive">{internetPathError}</div>
|
||
)}
|
||
{internetPathLoading && isLive && !internetPath && (
|
||
<div className="h-24 rounded-md bg-muted/40 animate-pulse" />
|
||
)}
|
||
{(!isLive || internetPath || !internetPathLoading) && (
|
||
<InternetPathMapCard model={internetPath} />
|
||
)}
|
||
</div>
|
||
|
||
{/* Ping probes table */}
|
||
<OpsPanel
|
||
title="Активные пробы"
|
||
description={
|
||
<span className={probesError && isLive ? "text-destructive" : undefined}>
|
||
{probesSubtitle}
|
||
</span>
|
||
}
|
||
headerRight={
|
||
<Link
|
||
href="/uptime"
|
||
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-7 text-xs")}
|
||
>
|
||
Открыть монитор →
|
||
</Link>
|
||
}
|
||
>
|
||
<DataPageCard className="rounded-none border-0 shadow-none">
|
||
<DashboardActiveProbesDataGrid
|
||
probes={activeProbesTable}
|
||
catalog={probeServerCatalog}
|
||
isLoading={isLive && probesLoading && liveProbes === null}
|
||
emptyDescription={
|
||
isLive && probesError
|
||
? "Нет данных о пробах. Проверьте сборщик uptime и настройки проб на странице мониторинга."
|
||
: "Нет проб с звездой на дашборде. Включите пробу и отметьте ★ в разделе «Мониторинг»."
|
||
}
|
||
/>
|
||
</DataPageCard>
|
||
</OpsPanel>
|
||
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|