Init 2
This commit is contained in:
+654
-43
@@ -1,3 +1,8 @@
|
||||
"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"
|
||||
@@ -5,10 +10,43 @@ 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, pingProbes, systemEvents, dashLatency, traffic } from "@/lib/data"
|
||||
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 } from "@/components/ui/button"
|
||||
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,
|
||||
@@ -25,7 +63,7 @@ function StatCard({
|
||||
{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-emerald-600 dark:text-emerald-400" : deltaDir === "down" ? "text-red-500 dark:text-red-400" : "text-muted-foreground"}`}>
|
||||
<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>
|
||||
)}
|
||||
@@ -39,7 +77,477 @@ function StatCard({
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
@@ -57,28 +565,40 @@ export default function DashboardPage() {
|
||||
{/* KPI row */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
label="Серверы онлайн" value="6" unit="/ 7"
|
||||
delta="+1 со вчерашнего" deltaDir="up"
|
||||
spark={[5,5,6,6,5,6,6,6,7,6,6,6,6,6,6,6,6]}
|
||||
sparkColor="var(--chart-line-1)"
|
||||
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="5" unit="/ 6"
|
||||
delta="2.4к доменов синхронизировано" deltaDir="up"
|
||||
spark={[3,4,4,5,4,5,5,5,5,5,5,5,5,5,5]}
|
||||
sparkColor="var(--chart-line-2)"
|
||||
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="8 432"
|
||||
delta="+412 в последнем обновлении" deltaDir="up"
|
||||
spark={[7800,7900,8000,8100,8050,8120,8200,8240,8300,8350,8380,8400,8420,8430,8432]}
|
||||
sparkColor="var(--chart-line-4)"
|
||||
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="4"
|
||||
delta="2 критических · 2 предупреждения" deltaDir="down"
|
||||
spark={[1,2,2,3,3,4,5,4,4,4,3,3,4,4,4]}
|
||||
sparkColor="var(--chart-5)"
|
||||
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>
|
||||
|
||||
@@ -89,12 +609,33 @@ export default function DashboardPage() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-base">Задержка до серверов</CardTitle>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">Последние 60 минут · ping от монитора → серверы MikroTik</p>
|
||||
<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">
|
||||
<LatencyChart series={dashLatency} />
|
||||
{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>
|
||||
|
||||
@@ -111,9 +652,9 @@ export default function DashboardPage() {
|
||||
{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-red-500" />}
|
||||
{e.sev === "warning" && <AlertTriangleIcon className="size-4 text-amber-500" />}
|
||||
{e.sev === "info" && <InfoIcon className="size-4 text-blue-500" />}
|
||||
{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>
|
||||
@@ -138,7 +679,7 @@ export default function DashboardPage() {
|
||||
</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-blue-500 rounded inline-block" />TX 244 Мбит/с</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>
|
||||
@@ -149,26 +690,65 @@ export default function DashboardPage() {
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<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">7 узлов MikroTik</p>
|
||||
<p className="text-sm text-muted-foreground mt-0.5 truncate" title={serverStatusModel.subtitle}>
|
||||
{serverStatusModel.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs">Управление →</Button>
|
||||
<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">
|
||||
{servers.slice(0, 5).map((s) => (
|
||||
<div key={s.id} className="grid grid-cols-[10px_1fr_auto_auto] gap-3 px-5 py-2.5 items-center">
|
||||
<StatusDot status={s.status} pulse />
|
||||
<div>
|
||||
<p className="text-[13px] font-medium leading-tight">{s.name}</p>
|
||||
<p className="text-[11px] font-mono text-muted-foreground flex items-center gap-1">
|
||||
<Flag code={s.country} className="not-mono" />{s.host} · {s.site} · {s.asn}
|
||||
{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 ${s.latency == null ? "text-red-500" : s.latency > 60 ? "text-amber-500" : "text-muted-foreground"}`}>
|
||||
<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} />
|
||||
@@ -185,9 +765,20 @@ export default function DashboardPage() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-base">Активные пробы</CardTitle>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">8 ping-зондов · опрос раз в секунду</p>
|
||||
<p className={cn(
|
||||
"text-sm mt-0.5",
|
||||
probesError && isLive ? "text-destructive" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{probesSubtitle}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs">Открыть монитор →</Button>
|
||||
<Link
|
||||
href="/uptime"
|
||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-7 text-xs")}
|
||||
>
|
||||
Открыть монитор →
|
||||
</Link>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0 px-0">
|
||||
@@ -195,7 +786,8 @@ export default function DashboardPage() {
|
||||
<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">Проба</th>
|
||||
<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>
|
||||
@@ -205,11 +797,21 @@ export default function DashboardPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{pingProbes.map((p) => {
|
||||
{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 font-medium">{p.name}</td>
|
||||
<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">
|
||||
@@ -218,7 +820,7 @@ export default function DashboardPage() {
|
||||
</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"}`}>
|
||||
{p.loss}%
|
||||
{formatLossPct(p.loss)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<Sparkline data={p.series} width={120} height={24} color={sparkColor} />
|
||||
@@ -229,6 +831,15 @@ export default function DashboardPage() {
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user