Added the sonner library for toast notifications across various components, improving user feedback for actions such as saving settings, syncing rules, and handling errors. Updated the layout to include a Toaster component for consistent notification display. Refactored alert messages in the backups, gre, and filters pages to utilize the new notification system, enhancing overall user experience.
902 lines
38 KiB
TypeScript
902 lines
38 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,
|
||
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 { requestJson } from "@/shared/api/http-client"
|
||
import { buildLatencySeriesByProbeSource } from "@/lib/dashboard-latency"
|
||
import { listEvents } from "@/shared/api/events"
|
||
import type { EventItem } from "@/packages/contracts/src/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
|
||
}
|
||
|
||
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 [recentEvents, setRecentEvents] = useState<EventItem[]>([])
|
||
const [eventsLoading, setEventsLoading] = useState(false)
|
||
const [eventsError, setEventsError] = 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])
|
||
|
||
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)
|
||
})
|
||
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>
|
||
|
||
{/* 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>
|
||
)
|
||
}
|