Files
MikrotikManager/app/(main)/dashboard/page.tsx
T
Denozordec bfceb07a23
Docker images / prepare-release (push) Successful in 5s
Docker images / backend-image (push) Successful in 1m32s
Docker images / frontend-image (push) Successful in 1m39s
Docker images / updater-image (push) Successful in 37s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 6s
fix(ui): убрать проверку статуса бэкенда из компонентов
2026-05-12 18:58:38 +07:00

1064 lines
44 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 { 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, InfoIcon, FilterIcon, DownloadIcon } from "lucide-react"
import { Button, buttonVariants } from "@/components/ui/button"
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 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")
}
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()
}
}
/** Совпадает с эталоном 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
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,
}
}
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 } = useDataSource()
const isLive = 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 (!isLive) return mockServers
return liveServersResolved ?? []
}, [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 (!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>
<Link
href="/alerts"
className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "text-xs h-7")}
>
Все
</Link>
</div>
<p className="text-sm text-muted-foreground">Система и BGP-активность</p>
</CardHeader>
<CardContent className="pt-0 px-0">
<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>
</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>
<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 */}
<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>
)
}