This commit is contained in:
Denozordec
2026-05-03 11:16:07 +07:00
parent ce00c4c671
commit bdb9b72fac
66 changed files with 9553 additions and 1547 deletions
+654 -43
View File
@@ -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>