854 lines
36 KiB
TypeScript
854 lines
36 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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||
import { StatusDot } from "@/components/status-dot"
|
||
import { StatusBadge } from "@/components/status-badge"
|
||
import { Sparkline } from "@/components/sparkline"
|
||
import { LatencyChart } from "@/components/dashboard/latency-chart"
|
||
import { BandwidthChart } from "@/components/dashboard/bandwidth-chart"
|
||
import {
|
||
servers as mockServers,
|
||
pingProbes,
|
||
systemEvents,
|
||
dashLatency,
|
||
traffic,
|
||
serverFilterRulesets,
|
||
} from "@/lib/data"
|
||
import type { PingProbe, Server, ServerStatus, ServerType } from "@/lib/data"
|
||
import { useDataSource } from "@/lib/data-source"
|
||
import { Flag } from "@/components/flag"
|
||
import { AlertCircleIcon, AlertTriangleIcon, InfoIcon, FilterIcon, DownloadIcon } from "lucide-react"
|
||
import { Button, buttonVariants } from "@/components/ui/button"
|
||
import { cn } from "@/lib/utils"
|
||
import { buildLatencySeriesByProbeSource } from "@/lib/dashboard-latency"
|
||
|
||
function makeApiFetch(backendUrl: string) {
|
||
return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||
const headers: HeadersInit = init?.body ? { "Content-Type": "application/json" } : {}
|
||
const res = await fetch(backendUrl.replace(/\/$/, "") + path, {
|
||
...init,
|
||
headers: { ...headers, ...(init?.headers ?? {}) },
|
||
})
|
||
if (!res.ok) {
|
||
let message = res.statusText
|
||
try {
|
||
const err = await res.json() as { error?: string }
|
||
message = err.error ?? message
|
||
} catch {
|
||
const text = await res.text().catch(() => "")
|
||
if (text) message = text
|
||
}
|
||
throw new Error(message)
|
||
}
|
||
return res.json() as Promise<T>
|
||
}
|
||
}
|
||
|
||
function StatCard({
|
||
label, value, unit, delta, deltaDir, spark, sparkColor,
|
||
}: {
|
||
label: string; value: string; unit?: string; delta?: string
|
||
deltaDir?: "up" | "down"; spark?: number[]; sparkColor?: string
|
||
}) {
|
||
return (
|
||
<Card className="relative overflow-hidden">
|
||
<CardContent className="pt-5 pb-4 px-5">
|
||
<p className="text-sm font-medium text-muted-foreground">{label}</p>
|
||
<div className="flex items-baseline gap-1.5 mt-1">
|
||
<span className="text-3xl font-semibold tracking-tight tabular-nums">{value}</span>
|
||
{unit && <span className="text-sm text-muted-foreground">{unit}</span>}
|
||
</div>
|
||
{delta && (
|
||
<p className={`text-xs mt-1 flex items-center gap-1 ${deltaDir === "up" ? "text-[var(--status-online-fg)]" : deltaDir === "down" ? "text-[var(--status-offline-fg)]" : "text-muted-foreground"}`}>
|
||
{delta}
|
||
</p>
|
||
)}
|
||
{spark && spark.length > 1 && (
|
||
<div className="absolute right-4 bottom-4 opacity-60">
|
||
<Sparkline data={spark} width={80} height={32} color={sparkColor ?? "currentColor"} filled />
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
function formatLossPct(loss: number): string {
|
||
if (!Number.isFinite(loss)) return "—"
|
||
return Number.isInteger(loss) ? `${loss}%` : `${loss.toFixed(1)}%`
|
||
}
|
||
|
||
function fmtIntRu(n: number): string {
|
||
return n.toLocaleString("ru-RU")
|
||
}
|
||
|
||
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()
|
||
}
|
||
}
|
||
|
||
/** Совпадает с эталоном uptime / servers */
|
||
function TypeChip({ type }: { type: ServerType }) {
|
||
return (
|
||
<span className={cn(
|
||
"inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-bold border shrink-0",
|
||
type === "home-router"
|
||
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
|
||
: type === "jump-host"
|
||
? "bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/20"
|
||
: "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20",
|
||
)}>
|
||
{type === "jump-host" ? "JH" : type === "home-router" ? "HR" : "EN"}
|
||
</span>
|
||
)
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
function mapBackendToServer(s: BackendServerRow): Server {
|
||
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,
|
||
}
|
||
}
|
||
|
||
function ProbeSourceCell({ probe, catalog }: { probe: PingProbe; catalog: Server[] }) {
|
||
const srv = catalog.find(s => s.id === probe.srcServerId)
|
||
const iface = (probe.srcInterface ?? "").trim() || "auto"
|
||
|
||
if (!srv) {
|
||
return (
|
||
<div className="flex items-start gap-2 min-w-0 max-w-[280px]">
|
||
<span className="mt-1 shrink-0 inline-flex">
|
||
<StatusDot status="offline" />
|
||
</span>
|
||
<div className="min-w-0">
|
||
<p className="text-[13px] font-medium text-muted-foreground truncate">
|
||
Сервер <span className="font-mono tabular-nums">{probe.srcServerId}</span>
|
||
</p>
|
||
<p className="text-[11px] font-mono text-muted-foreground truncate mt-0.5" title={iface}>
|
||
{iface}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="flex items-start gap-2 min-w-0 max-w-[280px]">
|
||
<span className="mt-1 shrink-0 inline-flex">
|
||
<StatusDot status={srv.status} pulse={srv.status === "online"} />
|
||
</span>
|
||
<div className="flex gap-2 min-w-0 flex-1">
|
||
<Flag code={srv.country} size={16} className="shrink-0 mt-0.5" />
|
||
<div className="min-w-0 flex-1">
|
||
<div className="flex items-center gap-1.5 flex-wrap">
|
||
<span className="text-[13px] font-medium leading-tight truncate">{srv.name}</span>
|
||
<TypeChip type={srv.type} />
|
||
</div>
|
||
<p className="text-[11px] text-muted-foreground mt-0.5 truncate" title={`Интерфейс: ${iface}`}>
|
||
<span className="font-mono tabular-nums">{iface}</span>
|
||
{srv.site && srv.site !== "—" && (
|
||
<span className="text-muted-foreground/90"> · {srv.site}</span>
|
||
)}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default function DashboardPage() {
|
||
const pathname = usePathname()
|
||
const { mode, backendUrl, backendStatus } = useDataSource()
|
||
const isLive = mode === "live" && backendStatus === true
|
||
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 probeServerCatalog = useMemo(() => {
|
||
if (!isLive) return mockServers
|
||
return liveServersResolved ?? []
|
||
}, [isLive, liveServersResolved])
|
||
|
||
const fetchProbes = useCallback(async (silent: boolean) => {
|
||
if (!isLive) return
|
||
if (!silent) setProbesLoading(true)
|
||
try {
|
||
const overview = await apiFetch<{ probes: PingProbe[] }>("/api/uptime/overview?range=1h")
|
||
setLiveProbes(overview.probes)
|
||
setProbesError(null)
|
||
try {
|
||
const backendServers = await apiFetch<BackendServerRow[]>("/api/servers")
|
||
setLiveServersResolved(backendServers.map(mapBackendToServer))
|
||
} catch {
|
||
setLiveServersResolved([])
|
||
}
|
||
|
||
const [fr, br] = await Promise.allSettled([
|
||
apiFetch<{ rulesets: Array<{ rules?: unknown[] }> }>("/api/filters/rules"),
|
||
apiFetch<Array<{ state?: string; prefixesRx?: number }>>("/api/bgp/sessions"),
|
||
])
|
||
|
||
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 })
|
||
} catch (e) {
|
||
const msg = e instanceof Error ? e.message : "Не удалось загрузить пробы"
|
||
setProbesError(msg)
|
||
setLiveKpi(null)
|
||
if (!silent) {
|
||
setLiveProbes(null)
|
||
setLiveServersResolved(null)
|
||
}
|
||
} finally {
|
||
if (!silent) setProbesLoading(false)
|
||
}
|
||
}, [apiFetch, isLive])
|
||
|
||
useEffect(() => {
|
||
if (!isLive) {
|
||
queueMicrotask(() => {
|
||
setLiveProbes(null)
|
||
setLiveServersResolved(null)
|
||
setLiveKpi(null)
|
||
setProbesError(null)
|
||
})
|
||
return
|
||
}
|
||
let cancelled = false
|
||
queueMicrotask(() => {
|
||
if (cancelled) return
|
||
void fetchProbes(false)
|
||
})
|
||
return () => { cancelled = true }
|
||
}, [isLive, fetchProbes])
|
||
|
||
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 (!isLive) return mockActiveProbes
|
||
if (liveProbes === null && probesLoading) return []
|
||
if (liveProbes === null) return []
|
||
return liveProbes.filter((p) => p.enabled && p.showOnDashboard === true)
|
||
}, [isLive, liveProbes, probesLoading, mockActiveProbes])
|
||
|
||
const probesSubtitle = useMemo(() => {
|
||
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)`
|
||
: "Нет проб на дашборде · отметьте звёздочкой на странице мониторинга"
|
||
}, [isLive, mockActiveProbes.length, probesError, liveProbes, activeProbesTable.length])
|
||
|
||
/** Карточка «Состояние серверов»: в Live — `/api/servers` (тот же запрос, что и для каталога проб). */
|
||
const serverStatusModel = useMemo(() => {
|
||
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`,
|
||
}
|
||
}, [isLive, liveServersResolved, probesLoading, probesError])
|
||
|
||
const latencyChartBlock = useMemo(() => {
|
||
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",
|
||
}
|
||
}, [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]
|
||
|
||
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 loadingBlock = 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)",
|
||
},
|
||
}
|
||
}, [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">
|
||
|
||
{/* KPI row */}
|
||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||
<StatCard
|
||
label="Серверы онлайн"
|
||
value={dashboardKpi.servers.value}
|
||
unit={dashboardKpi.servers.unit}
|
||
delta={dashboardKpi.servers.delta}
|
||
deltaDir={dashboardKpi.servers.deltaDir}
|
||
spark={dashboardKpi.servers.spark}
|
||
sparkColor={dashboardKpi.servers.sparkColor}
|
||
/>
|
||
<StatCard
|
||
label="Активные фильтры"
|
||
value={dashboardKpi.filters.value}
|
||
unit={dashboardKpi.filters.unit}
|
||
delta={dashboardKpi.filters.delta}
|
||
deltaDir={dashboardKpi.filters.deltaDir}
|
||
spark={dashboardKpi.filters.spark}
|
||
sparkColor={dashboardKpi.filters.sparkColor}
|
||
/>
|
||
<StatCard
|
||
label="BGP-префиксы"
|
||
value={dashboardKpi.bgp.value}
|
||
unit={dashboardKpi.bgp.unit}
|
||
delta={dashboardKpi.bgp.delta}
|
||
deltaDir={dashboardKpi.bgp.deltaDir}
|
||
spark={dashboardKpi.bgp.spark}
|
||
sparkColor={dashboardKpi.bgp.sparkColor}
|
||
/>
|
||
<StatCard
|
||
label="Активные алерты"
|
||
value={dashboardKpi.alerts.value}
|
||
unit={dashboardKpi.alerts.unit}
|
||
delta={dashboardKpi.alerts.delta}
|
||
deltaDir={dashboardKpi.alerts.deltaDir}
|
||
spark={dashboardKpi.alerts.spark}
|
||
sparkColor={dashboardKpi.alerts.sparkColor}
|
||
/>
|
||
</div>
|
||
|
||
{/* Latency chart + Events */}
|
||
<div className="grid grid-cols-1 lg:grid-cols-[2fr_1fr] gap-4">
|
||
<Card>
|
||
<CardHeader className="pb-2">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<CardTitle className="text-base">Задержка до серверов</CardTitle>
|
||
<p className="text-sm text-muted-foreground mt-0.5">
|
||
{latencyChartBlock.kind === "mock" && latencyChartBlock.subtitle}
|
||
{latencyChartBlock.kind === "live" && latencyChartBlock.subtitle}
|
||
{latencyChartBlock.kind === "loading" && "Загрузка…"}
|
||
{latencyChartBlock.kind === "empty" && "Нет серии RTT для графика"}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent className="pt-0 px-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}
|
||
/>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card>
|
||
<CardHeader className="pb-2">
|
||
<div className="flex items-center justify-between">
|
||
<CardTitle className="text-base">Последние события</CardTitle>
|
||
<Button variant="ghost" size="sm" className="text-xs h-7">Все →</Button>
|
||
</div>
|
||
<p className="text-sm text-muted-foreground">Система и BGP-активность</p>
|
||
</CardHeader>
|
||
<CardContent className="pt-0 px-0">
|
||
<div className="divide-y divide-border">
|
||
{systemEvents.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.sev === "destructive" && <AlertCircleIcon className="size-4 text-destructive" />}
|
||
{e.sev === "warning" && <AlertTriangleIcon className="size-4 text-[var(--status-degraded)]" />}
|
||
{e.sev === "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">{e.when}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
|
||
{/* Bandwidth + Server status */}
|
||
<div className="grid grid-cols-1 lg:grid-cols-[3fr_2fr] gap-4">
|
||
<Card>
|
||
<CardHeader className="pb-2">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<CardTitle className="text-base">Пропускная способность</CardTitle>
|
||
<p className="text-sm text-muted-foreground mt-0.5">Суммарный RX / TX по всем серверам</p>
|
||
</div>
|
||
<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>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent className="pt-0 px-3">
|
||
<BandwidthChart rx={traffic.rx} tx={traffic.tx} />
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card>
|
||
<CardHeader className="pb-2">
|
||
<div className="flex items-center justify-between gap-2">
|
||
<div className="min-w-0">
|
||
<CardTitle className="text-base">Состояние серверов</CardTitle>
|
||
<p className="text-sm text-muted-foreground mt-0.5 truncate" title={serverStatusModel.subtitle}>
|
||
{serverStatusModel.subtitle}
|
||
</p>
|
||
</div>
|
||
<Link
|
||
href="/servers"
|
||
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-7 text-xs shrink-0")}
|
||
>
|
||
Управление →
|
||
</Link>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent className="pt-0 px-0">
|
||
<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>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
|
||
{/* Ping probes table */}
|
||
<Card>
|
||
<CardHeader className="pb-2">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<CardTitle className="text-base">Активные пробы</CardTitle>
|
||
<p className={cn(
|
||
"text-sm mt-0.5",
|
||
probesError && isLive ? "text-destructive" : "text-muted-foreground",
|
||
)}
|
||
>
|
||
{probesSubtitle}
|
||
</p>
|
||
</div>
|
||
<Link
|
||
href="/uptime"
|
||
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-7 text-xs")}
|
||
>
|
||
Открыть монитор →
|
||
</Link>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent className="pt-0 px-0">
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-sm">
|
||
<thead>
|
||
<tr className="border-b border-border text-xs text-muted-foreground">
|
||
<th className="text-left font-medium px-5 py-2.5 w-[min(280px,32vw)]">Источник</th>
|
||
<th className="text-left font-medium px-4 py-2.5">Проба</th>
|
||
<th className="text-left font-medium px-4 py-2.5">Цель</th>
|
||
<th className="text-left font-medium px-4 py-2.5">Фильтр</th>
|
||
<th className="text-right font-medium px-4 py-2.5">RTT</th>
|
||
<th className="text-right font-medium px-4 py-2.5">Потери</th>
|
||
<th className="text-left font-medium px-4 py-2.5 w-36">60с</th>
|
||
<th className="text-left font-medium px-4 py-2.5">Статус</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-border">
|
||
{isLive && probesLoading && liveProbes === null && (
|
||
<tr>
|
||
<td colSpan={8} className="px-5 py-6">
|
||
<div className="h-10 rounded-md bg-muted/50 animate-pulse max-w-md mx-auto" />
|
||
</td>
|
||
</tr>
|
||
)}
|
||
{!(isLive && probesLoading && liveProbes === null) && activeProbesTable.map((p) => {
|
||
const sparkColor = p.status === "down" ? "hsl(0 84% 60%)" : p.status === "warn" ? "hsl(32 94% 44%)" : "hsl(142 76% 36%)"
|
||
return (
|
||
<tr key={p.id} className="hover:bg-muted/40 transition-colors">
|
||
<td className="px-5 py-2.5 align-top">
|
||
<ProbeSourceCell probe={p} catalog={probeServerCatalog} />
|
||
</td>
|
||
<td className="px-4 py-2.5 font-medium">{p.name}</td>
|
||
<td className="px-4 py-2.5 font-mono text-xs text-muted-foreground">{p.target}</td>
|
||
<td className="px-4 py-2.5">
|
||
<span className="inline-flex items-center gap-1 text-xs border border-border rounded px-2 py-0.5">
|
||
<FilterIcon className="size-3 text-muted-foreground" />{p.filter}
|
||
</span>
|
||
</td>
|
||
<td className="px-4 py-2.5 font-mono text-right">{p.rtt == null ? "—" : `${p.rtt} мс`}</td>
|
||
<td className={`px-4 py-2.5 font-mono text-right ${p.loss > 5 ? "text-red-500" : p.loss > 0 ? "text-amber-500" : "text-muted-foreground"}`}>
|
||
{formatLossPct(p.loss)}
|
||
</td>
|
||
<td className="px-4 py-2.5">
|
||
<Sparkline data={p.series} width={120} height={24} color={sparkColor} />
|
||
</td>
|
||
<td className="px-4 py-2.5">
|
||
<StatusBadge status={p.status === "up" ? "online" : p.status === "warn" ? "degraded" : "offline"} />
|
||
</td>
|
||
</tr>
|
||
)
|
||
})}
|
||
{!(isLive && probesLoading && liveProbes === null) && activeProbesTable.length === 0 && (
|
||
<tr>
|
||
<td colSpan={8} className="px-5 py-8 text-center text-sm text-muted-foreground">
|
||
{isLive && probesError
|
||
? "Нет данных о пробах. Проверьте сборщик uptime и настройки проб на странице мониторинга."
|
||
: "Нет проб с звездой на дашборде. Включите пробу и отметьте ★ в разделе «Мониторинг»."}
|
||
</td>
|
||
</tr>
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|