Files
MikrotikManager/app/(main)/uptime/page.tsx
T
Denozordec a1a9124f3d
Docker images / prepare-release (push) Successful in 5s
Docker images / backend-image (push) Successful in 2m37s
Docker images / frontend-image (push) Successful in 2m22s
Docker images / updater-image (push) Successful in 38s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 8s
fix(config): добавить новые зависимости и обновить конфигурацию компонентов
2026-06-30 21:30:40 +07:00

3172 lines
143 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 { useState, useMemo, useEffect, useRef, useCallback } from "react"
import { PageHeader } from "@/components/page-header"
import { FormField, FormToggle, SegmentedControl } from "@/components/form-kit"
import { Card, CardContent } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { Flag } from "@/components/flag"
import { StatusDot } from "@/components/status-dot"
import { Sparkline } from "@/components/sparkline"
import { PING_PROBE_WARN_RTT_MS } from "@/lib/ping-probe"
import { cn } from "@/lib/utils"
import { servers as mockServers, pingProbes as INIT_PROBES, filters, type Server, type Filter } from "@/lib/data"
import type { PingProbe } from "@/lib/data"
import { useDataSource } from "@/lib/data-source"
import { requestJson } from "@/shared/api/http-client"
import {
RefreshCwIcon, PlusIcon, SearchIcon, XIcon,
ChevronDownIcon, ChevronRightIcon,
TrashIcon, ArrowRightIcon,
CpuIcon, HardDriveIcon, ThermometerIcon, ClockIcon,
AlertCircleIcon, ServerIcon, PauseIcon, PlayIcon,
ArrowUpIcon, ArrowDownIcon, ArrowUpDownIcon,
ServerCrashIcon, ChevronUpIcon, DownloadIcon,
PencilIcon,
StarIcon,
CopyIcon,
TagIcon,
CheckIcon,
} from "lucide-react"
import {
Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetFooter,
} from "@/components/ui/sheet"
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible"
/** Звёздочка «на дашборде» для mock — общий ключ с `dashboard/page.tsx` */
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()
}
}
function writeMockDashboardStarIds(ids: Set<string>) {
localStorage.setItem(MOCK_DASH_STARS_LS, JSON.stringify([...ids]))
}
function mockProbesWithSavedStars(base: PingProbe[]): PingProbe[] {
const stars = readMockDashboardStarIds()
return base.map((p) => ({ ...p, showOnDashboard: stars.has(p.id) }))
}
function makeApiFetch(backendUrl: string) {
return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
return requestJson<T>(backendUrl, path, init)
}
}
// ── helpers ────────────────────────────────────────────────────────────────────
function jitter(base: number, pct: number) {
return Math.round(Math.max(1, base + (Math.random() - 0.5) * base * pct * 2))
}
function rttColor(rtt: number | null, loss: number): string {
if (rtt === null || loss >= 100) return "text-[var(--status-offline-fg)]"
if (loss > 1 || rtt > PING_PROBE_WARN_RTT_MS) return "text-[var(--status-degraded-fg)]"
return "text-[var(--status-online-fg)]"
}
function probeSparkColor(status: PingProbe["status"]): string {
return status === "down" ? "var(--status-offline)" : status === "warn" ? "var(--status-degraded)" : "var(--status-online)"
}
/** Развёрнутый график RTT под мини-спарклайном (та же серия `PingProbe.series`). */
function ProbePingRttDetailChart({
series,
status,
probeName,
target,
}: {
series: number[]
status: PingProbe["status"]
probeName: string
target: string
}) {
const stroke = probeSparkColor(status)
const data = series.map((v) => (v != null && Number.isFinite(v) ? Math.max(0, v) : 0))
const valid = data.filter((v) => Number.isFinite(v))
if (valid.length === 0) {
return (
<p className="text-xs text-muted-foreground">
Нет числовых точек RTT для графика (проба «{probeName}» {target}).
</p>
)
}
const chartPts = valid.length >= 2 ? data : [valid[0] ?? 0, valid[0] ?? 0]
const W = 720
const H = 168
const pad = { l: 48, r: 14, t: 14, b: 36 }
const iw = W - pad.l - pad.r
const ih = H - pad.t - pad.b
const maxVal = Math.max(...chartPts, 1)
const minVal = Math.min(...chartPts)
const span = Math.max(1, maxVal - minVal) * 1.08
const y0 = minVal - (span - (maxVal - minVal)) / 2
const y1 = y0 + span
const xAt = (i: number) => pad.l + (chartPts.length <= 1 ? iw / 2 : (i / (chartPts.length - 1)) * iw)
const yAt = (v: number) => pad.t + (1 - (v - y0) / span) * ih
const lineD = chartPts
.map((v, i) => `${i === 0 ? "M" : "L"}${xAt(i).toFixed(1)},${yAt(v).toFixed(1)}`)
.join(" ")
const areaD = `${lineD} L ${xAt(chartPts.length - 1).toFixed(1)},${pad.t + ih} L ${pad.l},${pad.t + ih} Z`
const gridVals = [0, 0.25, 0.5, 0.75, 1]
const fmt = (v: number) => `${Math.round(v)} мс`
return (
<div className="space-y-2">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<p className="text-xs text-muted-foreground">
<span className="font-medium text-foreground">{probeName}</span>
<span className="font-mono ml-1.5">{target}</span>
</p>
<p className="text-[11px] text-muted-foreground">Ось X: старые замеры слева новые справа · обзор ~1 ч</p>
</div>
<svg
viewBox={`0 0 ${W} ${H}`}
className="w-full max-w-[720px] h-[min(200px,42vw)] min-h-[140px]"
style={{ display: "block" }}
preserveAspectRatio="xMidYMid meet"
>
{gridVals.map((g, i) => {
const y = pad.t + ih * (1 - g)
return (
<g key={i}>
<line
x1={pad.l}
x2={W - pad.r}
y1={y}
y2={y}
stroke="hsl(var(--border))"
strokeDasharray={g === 0 ? "0" : "2 5"}
/>
<text
x={pad.l - 8}
y={y + 4}
textAnchor="end"
fontSize="11"
fill="hsl(var(--muted-foreground))"
fontFamily="ui-monospace, monospace"
>
{fmt(y0 + span * g)}
</text>
</g>
)
})}
<path d={areaD} style={{ fill: stroke, fillOpacity: 0.12 }} />
<path
d={lineD}
fill="none"
style={{ stroke }}
strokeWidth="2"
strokeLinejoin="round"
strokeLinecap="round"
/>
{[0, Math.floor((chartPts.length - 1) / 2), chartPts.length - 1]
.filter((i, idx, a) => a.indexOf(i) === idx)
.map((i) => (
<text
key={`x-${i}`}
x={xAt(i)}
y={H - 10}
textAnchor="middle"
fontSize="10"
fill="hsl(var(--muted-foreground))"
fontFamily="ui-monospace, monospace"
>
{i === chartPts.length - 1 ? "сейчас" : i === 0 ? "раньше" : "·"}
</text>
))}
</svg>
<div className="flex flex-wrap gap-4 text-[11px] text-muted-foreground">
<span>
min <span className="font-mono text-foreground">{Math.round(minVal)}</span> мс
</span>
<span>
max <span className="font-mono text-foreground">{Math.round(maxVal)}</span> мс
</span>
{valid.length < 2 && (
<span className="text-amber-600 dark:text-amber-400">В ряду одна точка линия для наглядности продублирована.</span>
)}
</div>
</div>
)
}
function probeGroupActionKey(srvId: string, group: { name: string; target: string }) {
return `${srvId}\t${group.name}\t${group.target}`
}
// ── shared components ──────────────────────────────────────────────────────────
function TypeChip({ type }: { type: "jump-host" | "exit-node" | "home-router" }) {
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>
)
}
function StatChip({
label, value, color, active, onClick,
}: {
label: string; value: number; color?: "emerald" | "amber" | "red"; active?: boolean; onClick?: () => void
}) {
return (
<button
onClick={onClick}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-md border text-xs transition-all",
active
? "bg-foreground text-background border-foreground"
: "border-border hover:border-foreground/30 text-muted-foreground hover:text-foreground",
)}>
<span>{label}</span>
<span className={cn(
"font-semibold tabular-nums",
!active && color === "emerald" ? "text-[var(--status-online-fg)]" :
!active && color === "amber" ? "text-[var(--status-degraded-fg)]" :
!active && color === "red" ? "text-[var(--status-offline-fg)]" : "",
)}>{value}</span>
</button>
)
}
/** Активный интерфейс RouterOS: не disabled и running */
function isActiveRouterOsInterface(i: { running?: boolean; disabled?: boolean }): boolean {
return i.running === true && i.disabled !== true
}
function filterActiveInterfaces<T extends { running?: boolean; disabled?: boolean }>(list: T[]): T[] {
return list.filter(isActiveRouterOsInterface)
}
function serverMatchesSearch(server: Server, raw: string): boolean {
const q = raw.trim().toLowerCase()
if (!q) return true
const parts = [
server.name,
server.host,
server.site,
server.country,
server.asn,
server.type.replace(/-/g, " "),
server.comment ?? "",
server.model,
]
return parts.some((p) => String(p).toLowerCase().includes(q))
}
type RouterInterfaceOption = {
name: string
running?: boolean
disabled?: boolean
addresses?: string[]
}
function interfaceOptionMatchesSearch(iface: RouterInterfaceOption, raw: string): boolean {
const q = raw.trim().toLowerCase()
if (!q) return true
if (iface.name.toLowerCase().includes(q)) return true
for (const addr of iface.addresses ?? []) {
const t = addr.trim()
const bare = t.includes("/") ? (t.split("/")[0] ?? "").trim() : t
if (bare.toLowerCase().includes(q) || t.toLowerCase().includes(q)) return true
}
return false
}
function ServerPickerCards({
options,
selectedId,
onSelect,
blockedId,
}: {
options: Server[]
selectedId: string
onSelect: (serverId: string) => void
blockedId?: string
}) {
const [query, setQuery] = useState("")
const filtered = useMemo(() => {
const q = query.trim()
const base = options.filter((s) => serverMatchesSearch(s, q))
if (!selectedId) return base
const selected = options.find((s) => s.id === selectedId)
if (!selected || base.some((s) => s.id === selectedId)) return base
return [selected, ...base.filter((s) => s.id !== selectedId)]
}, [options, query, selectedId])
return (
<div className="flex flex-col gap-2">
<div className="relative">
<SearchIcon className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" />
<Input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Поиск по имени, хосту, сайту…"
className="h-8 pl-8 pr-8 text-sm"
aria-label="Поиск сервера"
/>
{query ? (
<button
type="button"
onClick={() => setQuery("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
aria-label="Очистить поиск"
>
<XIcon className="size-3.5" />
</button>
) : null}
</div>
<div className="flex flex-col gap-1.5 max-h-[240px] overflow-y-auto overflow-x-hidden pr-1">
{filtered.length === 0 ? (
<p className="text-xs text-muted-foreground px-1 py-2">Ничего не найдено</p>
) : (
filtered.map((server) => {
const isSelected = server.id === selectedId
const isBlocked = blockedId === server.id
return (
<button
key={server.id}
type="button"
onClick={() => onSelect(server.id)}
disabled={isBlocked}
className={cn(
"text-left rounded-lg border px-3 py-2.5 transition-all",
isSelected
? "border-primary bg-primary/5 ring-1 ring-primary/30"
: "border-border hover:border-muted-foreground/40 hover:bg-muted/40",
isBlocked && "opacity-40 cursor-not-allowed",
)}
>
<div className="flex items-center gap-2">
<StatusDot status={server.status} />
<Flag code={server.country} className="shrink-0" />
<span className="font-mono text-xs font-semibold flex-1 truncate">{server.name}</span>
<TypeChip type={server.type} />
{isSelected && <StarIcon className="size-3.5 text-primary shrink-0 fill-current" />}
</div>
<div className="mt-1.5 flex items-center gap-2 text-[11px] text-muted-foreground font-mono">
<span>{server.site}</span>
<span className="text-muted-foreground/30"></span>
<span className="truncate">{server.host}</span>
</div>
{!server.enabled && (
<p className="mt-1 text-[10px] text-amber-600 dark:text-amber-400 leading-snug">
В инвентаре выключен для ping всё равно можно выбрать, если бекенд достигает REST API.
</p>
)}
</button>
)
})
)}
</div>
</div>
)
}
function InterfacePickerCards({
value,
onChange,
options,
autoLabel,
busy,
disabled,
}: {
value: string
onChange: (v: string) => void
options: RouterInterfaceOption[]
autoLabel: string
busy?: boolean
disabled?: boolean
}) {
const [query, setQuery] = useState("")
const effectiveDisabled = disabled || busy
const filtered = useMemo(() => {
const q = query.trim()
const base = options.filter((i) => interfaceOptionMatchesSearch(i, q))
if (!value) return base
const selected = options.find((i) => i.name === value)
if (!selected || base.some((i) => i.name === value)) return base
return [selected, ...base.filter((i) => i.name !== value)]
}, [options, query, value])
return (
<div className="flex flex-col gap-2">
<div className="relative">
<SearchIcon className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" />
<Input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Поиск по имени или IP…"
className="h-8 pl-8 pr-8 text-sm font-mono"
disabled={effectiveDisabled}
aria-label="Поиск интерфейса"
/>
{query ? (
<button
type="button"
onClick={() => setQuery("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
aria-label="Очистить поиск"
>
<XIcon className="size-3.5" />
</button>
) : null}
</div>
<div className="flex flex-col gap-1.5">
<button
type="button"
onClick={() => onChange("")}
disabled={effectiveDisabled}
className={cn(
"text-left rounded-lg border px-3 py-2 transition-all",
value === ""
? "border-primary bg-primary/5 ring-1 ring-primary/30"
: "border-border hover:border-muted-foreground/40 hover:bg-muted/40",
effectiveDisabled && "opacity-50 cursor-not-allowed",
)}
>
<div className="flex items-center gap-2 text-xs">
<span className={cn("size-1.5 rounded-full", value === "" ? "bg-primary" : "bg-muted-foreground/40")} />
<span className="font-mono">{autoLabel}</span>
</div>
</button>
<div className="flex flex-col gap-1.5 max-h-[180px] overflow-y-auto overflow-x-hidden pr-1">
{filtered.length === 0 ? (
<p className="text-xs text-muted-foreground px-1 py-2">Ничего не найдено</p>
) : (
filtered.map((iface) => {
const selected = value === iface.name
const addrPreview = (iface.addresses ?? [])[0]
return (
<button
key={iface.name}
type="button"
onClick={() => onChange(iface.name)}
disabled={effectiveDisabled}
className={cn(
"text-left rounded-lg border px-3 py-2 transition-all",
selected
? "border-primary bg-primary/5 ring-1 ring-primary/30"
: "border-border hover:border-muted-foreground/40 hover:bg-muted/40",
effectiveDisabled && "opacity-50 cursor-not-allowed",
)}
>
<div className="flex items-center gap-2 text-xs">
<span className="size-1.5 rounded-full bg-emerald-500 shrink-0" />
<span className="font-mono font-medium truncate">{iface.name}</span>
</div>
{addrPreview ? (
<p className="mt-1 pl-3.5 text-[10px] font-mono text-muted-foreground truncate">
{addrPreview}
</p>
) : null}
</button>
)
})
)}
</div>
</div>
</div>
)
}
const LINKED_FILTER_NONE = "—"
function LinkedFilterPickerCards({
value,
onChange,
items,
}: {
value: string
onChange: (v: string) => void
items: Filter[]
}) {
const [query, setQuery] = useState("")
const filtered = useMemo(() => {
const q = query.trim().toLowerCase()
let base = !q
? items
: items.filter((f) =>
f.name.toLowerCase().includes(q) ||
f.id.toLowerCase().includes(q) ||
f.gateway.toLowerCase().includes(q) ||
(f.communities ?? []).some((c) => c.toLowerCase().includes(q)),
)
if (value && value !== LINKED_FILTER_NONE) {
const sel = items.find((f) => f.name === value)
if (sel && !base.some((f) => f.name === value)) {
base = [sel, ...base.filter((f) => f.name !== value)]
}
}
return base
}, [items, query, value])
const isNone = value === LINKED_FILTER_NONE || !value
return (
<div className="flex flex-col gap-2">
<div className="relative">
<SearchIcon className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" />
<Input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Поиск по имени, community, gateway…"
className="h-8 pl-8 pr-8 text-sm"
aria-label="Поиск фильтра"
/>
{query ? (
<button
type="button"
onClick={() => setQuery("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
aria-label="Очистить поиск"
>
<XIcon className="size-3.5" />
</button>
) : null}
</div>
<div className="flex flex-col gap-1.5 max-h-[220px] overflow-y-auto overflow-x-hidden pr-1">
<button
type="button"
onClick={() => onChange(LINKED_FILTER_NONE)}
className={cn(
"text-left rounded-lg border px-3 py-2.5 transition-all",
isNone
? "border-primary bg-primary/5 ring-1 ring-primary/30"
: "border-border hover:border-muted-foreground/40 hover:bg-muted/40",
)}
>
<div className="flex items-center gap-2">
<span className="text-muted-foreground"><TagIcon className="size-3.5" /></span>
<span className="text-sm font-medium">Без привязки</span>
{isNone && <CheckIcon className="size-3.5 text-primary shrink-0 ml-auto" />}
</div>
<p className="mt-1 text-[11px] text-muted-foreground pl-5">Не связывать пробу с правилом фильтрации</p>
</button>
{filtered.length === 0 ? (
query.trim() ? (
<p className="text-xs text-muted-foreground px-1 py-2">Ничего не найдено</p>
) : null
) : (
filtered.map((f) => {
const selected = value === f.name
return (
<button
key={f.id}
type="button"
onClick={() => onChange(f.name)}
className={cn(
"text-left rounded-lg border px-3 py-2.5 transition-all",
selected
? "border-primary bg-primary/5 ring-1 ring-primary/30"
: "border-border hover:border-muted-foreground/40 hover:bg-muted/40",
)}
>
<div className="flex items-center gap-2">
<TagIcon className="size-3.5 text-muted-foreground shrink-0" />
<span className="font-mono text-xs font-semibold flex-1 truncate">{f.name}</span>
{selected && <CheckIcon className="size-3.5 text-primary shrink-0" />}
</div>
<div className="mt-1.5 flex flex-wrap gap-x-2 gap-y-0.5 text-[11px] text-muted-foreground font-mono pl-5">
<span>{f.domains} дом.</span>
<span className="text-muted-foreground/40">·</span>
<span>{f.ips} IP</span>
<span className="text-muted-foreground/40">·</span>
<span className="truncate max-w-[140px]" title={f.gateway}>gw {f.gateway}</span>
</div>
</button>
)
})
)}
</div>
</div>
)
}
// ── Resource monitoring types + helpers ───────────────────────────────────────
interface ServerResource {
serverId: string
/** false — в выбранном окне нет сэмплов ресурсов (не подменяем нулями «реальные» 0 %) */
hasData?: boolean
cpu: number
cpuHistory: number[]
ramUsed: number // MB
ramTotal: number // MB
hddUsed: number // MB
hddTotal: number // MB
uptimeSeconds: number
boardName: string
temp?: number // °C
}
interface BackendServer {
id: number
name: string
host: string
type: "jump-host" | "exit-node" | "home-router"
site: string
country: string
enabled: boolean
status: "online" | "offline" | null
latency: number | null
os: string | null
}
function mapBackendServersToServers(data: BackendServer[]): Server[] {
return data.map((s) => ({
id: String(s.id),
name: s.name || s.host,
host: s.host,
model: "—",
os: s.os ?? "—",
site: s.site || "—",
country: s.country || "UN",
asn: "",
type: s.type,
enabled: s.enabled,
status: (s.status ?? "offline") as Server["status"],
latency: s.latency != null ? Math.round(s.latency) : null,
sessions: 0,
}))
}
interface SpeedTestRun {
id: string
startedAt: number
srcServerId: string
dstServerId: string
srcInterface?: string
dstInterface?: string
protocol: "tcp" | "udp"
direction: "transmit" | "receive" | "both"
durationSec: number
txAvgMbps: number
rxAvgMbps: number
status: "running" | "done" | "error"
command: string
lines: string[]
afterBtPing?: { rttMs: number | null; lossPct: number | null; error: string | null } | null
srcAddress?: string | null
dstAddress?: string | null
srcInterfaceAddress?: string | null
dstInterfaceAddress?: string | null
}
interface SpeedProbeRow {
id: string
srcServerId: string
dstServerId: string
srcInterface: string
dstInterface: string
protocol: "tcp" | "udp"
direction: "transmit" | "receive" | "both"
durationSec: string
enabled: boolean
lastRunAt?: string | null
lastTxAvgMbps?: number | null
lastRxAvgMbps?: number | null
lastStatus?: "done" | "error" | null
lastError?: string | null
lastPingRttMs?: number | null
lastPingLossPct?: number | null
lastPingAt?: string | null
lastPingError?: string | null
}
/** Сервер есть в БД speed-проб, но удалён из каталога — показываем группу без ломания списка */
function orphanSpeedSourceStub(id: string): Server {
return {
id,
name: `Нет в каталоге (#${id})`,
host: "—",
model: "—",
os: "—",
site: "—",
country: "UN",
asn: "",
type: "home-router",
enabled: false,
status: "offline",
latency: null,
sessions: 0,
}
}
function stripIpCidr(addr: string): string {
const t = addr.trim()
if (!t) return ""
return t.includes("/") ? (t.split("/")[0] ?? "").trim() : t
}
function resolveSpeedProbeDstHost(
sp: SpeedProbeRow,
servers: Server[],
ifaces: Record<string, Array<{ name: string; addresses: string[] }>>,
): string {
const dst = servers.find((s) => s.id === sp.dstServerId)
if (!dst) return ""
const iface = sp.dstInterface?.trim()
if (iface) {
const row = ifaces[sp.dstServerId]?.find((i) => i.name === iface)
const raw = row?.addresses?.[0]
const ip = raw ? stripIpCidr(raw) : ""
return (ip || dst.host).trim().toLowerCase()
}
return dst.host.trim().toLowerCase()
}
function findLinkedSpeedProbe(
ping: PingProbe,
speedList: SpeedProbeRow[],
servers: Server[],
ifaces: Record<string, Array<{ name: string; addresses: string[] }>>,
): SpeedProbeRow | undefined {
const t = ping.target.trim().toLowerCase()
if (!t) return undefined
return speedList.find((sp) => {
if (sp.srcServerId !== ping.srcServerId) return false
if ((sp.srcInterface ?? "").trim() !== (ping.srcInterface ?? "").trim()) return false
const resolved = resolveSpeedProbeDstHost(sp, servers, ifaces)
const dst = servers.find((s) => s.id === sp.dstServerId)
const host = dst?.host.trim().toLowerCase() ?? ""
return (resolved.length > 0 && t === resolved) || t === host
})
}
function fmtMB(mb: number): string {
if (mb >= 1024) return `${(mb / 1024).toFixed(mb >= 10240 ? 0 : 1)} ГБ`
return `${mb.toFixed(1)} МБ`
}
function fmtUptime(sec: number): string {
const d = Math.floor(sec / 86400)
const h = Math.floor((sec % 86400) / 3600)
const m = Math.floor((sec % 3600) / 60)
if (d > 0) return `${d}д ${h}ч`
if (h > 0) return `${h}ч ${m}м`
return `${m}м`
}
function resPctColor(pct: number, warn = 70, crit = 85): string {
if (pct >= crit) return "text-red-600 dark:text-red-400"
if (pct >= warn) return "text-amber-600 dark:text-amber-400"
return "text-emerald-600 dark:text-emerald-400"
}
function resBarColor(pct: number, warn = 70, crit = 85): string {
if (pct >= crit) return "bg-red-500"
if (pct >= warn) return "bg-amber-500"
return "bg-emerald-500"
}
const BOARD_MAP: Record<string, string> = {
"jump-host": "RB5009UG+S+IN",
"exit-node": "RB4011iGS+RM",
"home-router": "hAP ax²",
}
// Deterministic pseudo-random initial resource values per server
const INIT_RESOURCES: ServerResource[] = mockServers.map(s => {
const h = s.id.split("").reduce((a, c) => a + c.charCodeAt(0), 0)
const cpu = 4 + (h % 68)
const ramTotal = s.type === "jump-host" ? 8192 : s.type === "exit-node" ? 4096 : 1024
const hddTotal = s.type === "home-router" ? 2048 : 16384
const ramPct = 12 + (h % 72)
const hddPct = 8 + (h % 75)
return {
serverId: s.id,
cpu,
cpuHistory: Array.from({ length: 40 }, (_, i) =>
Math.max(1, Math.min(99, cpu + Math.round(Math.sin(i * 0.7 + h * 0.1) * 15)))
),
ramUsed: Math.round(ramTotal * ramPct / 100),
ramTotal,
hddUsed: Math.round(hddTotal * hddPct / 100),
hddTotal,
uptimeSeconds: (1 + h % 200) * 86400 + (h % 24) * 3600 + (h % 60) * 60,
boardName: BOARD_MAP[s.type] ?? "RouterBOARD",
temp: s.type !== "home-router" ? 34 + (h % 32) : undefined,
hasData: true,
}
})
// ── MiniBar ───────────────────────────────────────────────────────────────────
function MiniBar({ pct, warn = 70, crit = 85, className }: {
pct: number; warn?: number; crit?: number; className?: string
}) {
return (
<div className={cn("h-1.5 rounded-full bg-muted overflow-hidden", className)}>
<div
className={cn("h-full rounded-full transition-all duration-700", resBarColor(pct, warn, crit))}
style={{ width: `${Math.min(100, Math.max(0, pct))}%` }}
/>
</div>
)
}
// ── Sort indicator ─────────────────────────────────────────────────────────────
type ResSortKey = "name" | "cpu" | "ram" | "hdd" | "uptime" | "temp"
function SortIcon({ k, sortKey, sortAsc }: { k: ResSortKey; sortKey: ResSortKey; sortAsc: boolean }) {
if (sortKey !== k) return <ArrowUpDownIcon className="size-3 opacity-25 ml-0.5" />
return sortAsc
? <ArrowUpIcon className="size-3 ml-0.5 text-foreground" />
: <ArrowDownIcon className="size-3 ml-0.5 text-foreground" />
}
// ── ResourcesTab ──────────────────────────────────────────────────────────────
type ResTypeFilter = "all" | "jump-host" | "exit-node" | "home-router"
function ResourcesTab({ resources, serversList, liveApi }: { resources: ServerResource[]; serversList: Server[]; liveApi?: boolean }) {
const [sortKey, setSortKey] = useState<ResSortKey>("name")
const [sortAsc, setSortAsc] = useState(true)
const [resSearch, setResSearch] = useState("")
const [typeFilter, setTypeFilter] = useState<ResTypeFilter>("all")
const rows = useMemo(() => resources.map((r) => {
const hasData = r.hasData !== false
const ramPct = hasData && r.ramTotal > 0 ? Math.round(r.ramUsed / r.ramTotal * 100) : 0
const hddPct = hasData && r.hddTotal > 0 ? Math.round(r.hddUsed / r.hddTotal * 100) : 0
return {
...r,
hasData,
server: serversList.find(s => s.id === r.serverId),
ramPct,
hddPct,
}
}).filter(r => r.server !== undefined), [resources, serversList])
// KPI aggregates (только серверы с реальными сэмплами за окно)
const onlineWithSamples = rows.filter(r => r.server!.status === "online" && r.hasData)
const avgCpu = onlineWithSamples.length ? Math.round(onlineWithSamples.reduce((s, r) => s + r.cpu, 0) / onlineWithSamples.length) : 0
const avgRam = onlineWithSamples.length ? Math.round(onlineWithSamples.reduce((s, r) => s + r.ramPct, 0) / onlineWithSamples.length) : 0
const highCpu = rows.filter(r => r.server!.status === "online" && r.hasData && r.cpu >= 85).length
const highRam = rows.filter(r => r.server!.status === "online" && r.hasData && r.ramPct >= 85).length
const highHdd = rows.filter(r => r.server!.status === "online" && r.hasData && r.hddPct >= 85).length
// Alerts
const alerts = useMemo(() =>
rows.filter(r => r.server!.status === "online" && r.hasData && (r.cpu >= 85 || r.ramPct >= 85 || r.hddPct >= 85 || (r.temp ?? 0) >= 70)),
[rows],
)
// Filtered + sorted
const visible = useMemo(() => {
let list = rows
if (typeFilter !== "all") list = list.filter(r => r.server!.type === typeFilter)
if (resSearch.trim()) {
const q = resSearch.toLowerCase()
list = list.filter(r =>
r.server!.name.toLowerCase().includes(q) ||
r.server!.site.toLowerCase().includes(q) ||
r.boardName.toLowerCase().includes(q)
)
}
list = [...list].sort((a, b) => {
let diff = 0
switch (sortKey) {
case "name": diff = a.server!.name.localeCompare(b.server!.name); break
case "cpu": diff = a.cpu - b.cpu; break
case "ram": diff = a.ramPct - b.ramPct; break
case "hdd": diff = a.hddPct - b.hddPct; break
case "uptime": diff = a.uptimeSeconds - b.uptimeSeconds; break
case "temp": diff = (a.temp ?? -1) - (b.temp ?? -1); break
}
return sortAsc ? diff : -diff
})
return list
}, [rows, typeFilter, resSearch, sortKey, sortAsc])
function toggleSort(k: ResSortKey) {
if (sortKey === k) setSortAsc(v => !v)
else { setSortKey(k); setSortAsc(false) } // default desc for metrics
}
function exportCsv() {
const header = ["Сервер", "Тип", "Площадка", "CPU %", "RAM %", "RAM использ.", "RAM всего", "HDD %", "HDD использ.", "HDD всего", "Uptime", "Температура °C", "RouterOS"]
const rowsCsv = visible.map(r => {
const s = r.server!
return [s.name, s.type, s.site, r.cpu, r.ramPct, fmtMB(r.ramUsed), fmtMB(r.ramTotal),
r.hddPct, fmtMB(r.hddUsed), fmtMB(r.hddTotal), fmtUptime(r.uptimeSeconds),
r.temp ?? "", s.os].join(",")
})
const csv = [header.join(","), ...rowsCsv].join("\n")
const blob = new Blob([csv], { type: "text/csv;charset=utf-8" })
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url; a.download = `resources-${new Date().toISOString().slice(0,10)}.csv`; a.click()
URL.revokeObjectURL(url)
}
const typeOpts: { value: ResTypeFilter; label: string }[] = [
{ value: "all", label: "Все" },
{ value: "jump-host", label: "JumpHost" },
{ value: "exit-node", label: "Exit Node" },
{ value: "home-router", label: "Home Router" },
]
return (
<div className="p-6 flex flex-col gap-4">
{/* ── Alert banner ──────────────────────────────────────────────────── */}
{alerts.length > 0 && (
<Alert variant="destructive">
<ServerCrashIcon />
<AlertTitle className="text-xs mb-1">
{alerts.length} {alerts.length === 1 ? "сервер требует внимания" : "сервера требуют внимания"}
</AlertTitle>
<AlertDescription>
<div className="flex flex-wrap gap-1.5">
{alerts.map(r => {
const s = r.server!
const issues: string[] = []
if (r.cpu >= 85) issues.push(`CPU ${r.cpu}%`)
if (r.ramPct >= 85) issues.push(`RAM ${r.ramPct}%`)
if (r.hddPct >= 85) issues.push(`HDD ${r.hddPct}%`)
if ((r.temp ?? 0) >= 70) issues.push(`${r.temp}°C`)
return (
<span key={r.serverId} className="inline-flex items-center gap-1 text-[11px] font-mono rounded border border-destructive/30 bg-destructive/10 px-2 py-0.5">
<Flag code={s.country} size={10} />
{s.name} {issues.join(", ")}
</span>
)
})}
</div>
</AlertDescription>
</Alert>
)}
{/* ── KPI summary ───────────────────────────────────────────────────── */}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
{[
{ icon: <ServerIcon className="size-4 text-muted-foreground" />, label: String(rows.length), sub: "серверов всего", color: "text-foreground" },
{ icon: <CpuIcon className="size-4" />, label: `${avgCpu}%`, sub: "средний CPU", color: resPctColor(avgCpu) },
{ icon: <HardDriveIcon className="size-4" />, label: `${avgRam}%`, sub: "средний RAM", color: resPctColor(avgRam) },
{ icon: <AlertCircleIcon className="size-4" />, label: String(highCpu), sub: "CPU > 85%", color: highCpu > 0 ? "text-red-500" : "text-muted-foreground" },
{ icon: <AlertCircleIcon className="size-4" />, label: String(highRam), sub: "RAM > 85%", color: highRam > 0 ? "text-red-500" : "text-muted-foreground" },
{ icon: <AlertCircleIcon className="size-4" />, label: String(highHdd), sub: "Диск > 85%", color: highHdd > 0 ? "text-amber-500" : "text-muted-foreground" },
].map(kpi => (
<Card key={kpi.sub}>
<CardContent className="px-4 py-3 flex items-start gap-3">
<div className={cn("mt-0.5 shrink-0", kpi.color)}>{kpi.icon}</div>
<div className="min-w-0">
<p className={cn("text-xl font-semibold tabular-nums leading-tight", kpi.color)}>{kpi.label}</p>
<p className="text-[11px] text-muted-foreground mt-0.5">{kpi.sub}</p>
</div>
</CardContent>
</Card>
))}
</div>
{/* ── Toolbar ───────────────────────────────────────────────────────── */}
<div className="flex items-center gap-3 flex-wrap">
{/* Type filter */}
<div className="flex items-center gap-0.5 rounded-md border border-border bg-muted/40 p-0.5 shrink-0">
{typeOpts.map(o => (
<button key={o.value} onClick={() => setTypeFilter(o.value)}
className={cn(
"px-2.5 py-1 text-xs rounded transition-colors whitespace-nowrap",
typeFilter === o.value
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}>
{o.label}
</button>
))}
</div>
{/* Search */}
<div className="relative min-w-[180px] flex-1 max-w-xs">
<SearchIcon className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" />
<Input className="pl-8 h-8 text-sm" placeholder="Поиск по имени, площадке…"
value={resSearch} onChange={e => setResSearch(e.target.value)} />
{resSearch && (
<button onClick={() => setResSearch("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
<XIcon className="size-3.5" />
</button>
)}
</div>
<span className="text-xs text-muted-foreground ml-auto shrink-0">
{visible.length} из {rows.length} серверов
</span>
{/* Export CSV */}
<Button variant="outline" size="sm" className="h-8 gap-1.5 shrink-0" onClick={exportCsv}>
<DownloadIcon className="size-3.5" />CSV
</Button>
</div>
{/* ── Table ─────────────────────────────────────────────────────────── */}
<Card className="overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/30 text-xs text-muted-foreground font-medium">
{/* Sortable: name */}
<th className="text-left px-5 py-3 cursor-pointer hover:text-foreground transition-colors select-none"
onClick={() => toggleSort("name")}>
<span className="flex items-center gap-0.5">
Сервер <SortIcon k="name" sortKey={sortKey} sortAsc={sortAsc} />
</span>
</th>
<th className="text-left px-4 py-3 hidden md:table-cell whitespace-nowrap">Модель · ROS</th>
{/* Sortable: cpu */}
<th className="text-left px-4 py-3 min-w-[160px] cursor-pointer hover:text-foreground transition-colors select-none"
onClick={() => toggleSort("cpu")}>
<span className="flex items-center gap-1.5">
<CpuIcon className="size-3.5" />CPU
<SortIcon k="cpu" sortKey={sortKey} sortAsc={sortAsc} />
</span>
</th>
{/* Sortable: ram */}
<th className="text-left px-4 py-3 min-w-[175px] cursor-pointer hover:text-foreground transition-colors select-none"
onClick={() => toggleSort("ram")}>
<span className="flex items-center gap-1.5">
<HardDriveIcon className="size-3.5" />RAM
<SortIcon k="ram" sortKey={sortKey} sortAsc={sortAsc} />
</span>
</th>
{/* Sortable: hdd */}
<th className="text-left px-4 py-3 min-w-[175px] cursor-pointer hover:text-foreground transition-colors select-none"
onClick={() => toggleSort("hdd")}>
<span className="flex items-center gap-1.5">
<HardDriveIcon className="size-3.5" />Диск
<SortIcon k="hdd" sortKey={sortKey} sortAsc={sortAsc} />
</span>
</th>
{/* Sortable: uptime */}
<th className="text-left px-4 py-3 cursor-pointer hover:text-foreground transition-colors select-none"
onClick={() => toggleSort("uptime")}>
<span className="flex items-center gap-1.5">
<ClockIcon className="size-3.5" />Uptime
<SortIcon k="uptime" sortKey={sortKey} sortAsc={sortAsc} />
</span>
</th>
{/* Sortable: temp */}
<th className="text-left px-4 py-3 cursor-pointer hover:text-foreground transition-colors select-none"
onClick={() => toggleSort("temp")}>
<span className="flex items-center gap-1.5">
<ThermometerIcon className="size-3.5" />°C
<SortIcon k="temp" sortKey={sortKey} sortAsc={sortAsc} />
</span>
</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{visible.length === 0 && (
<tr>
<td colSpan={7} className="text-center text-sm text-muted-foreground py-12">
<SearchIcon className="size-6 mx-auto mb-2 opacity-20" />
Ничего не найдено
</td>
</tr>
)}
{visible.map(r => {
const srv = r.server!
const offline = srv.status !== "online"
const hasSamples = r.hasData !== false
const noMetrics = offline || !hasSamples
const isCrit = !noMetrics && (r.cpu >= 85 || r.ramPct >= 85 || r.hddPct >= 85 || (r.temp ?? 0) >= 70)
const cpuColor = r.cpu >= 85 ? "hsl(0 84% 60%)" : r.cpu >= 70 ? "hsl(38 92% 50%)" : "hsl(142 76% 36%)"
return (
<tr key={r.serverId} className={cn(
"hover:bg-muted/30 transition-colors",
offline && "opacity-50",
isCrit && "bg-red-500/3",
)}>
{/* Server */}
<td className="px-5 py-3">
<div className="flex items-center gap-2 flex-wrap">
{isCrit && <AlertCircleIcon className="size-3.5 text-red-500 shrink-0" />}
{!isCrit && <StatusDot status={srv.status} pulse={!offline} />}
<Flag code={srv.country} size={16} />
<span className="font-mono font-semibold">{srv.name}</span>
<TypeChip type={srv.type} />
<span className="text-xs text-muted-foreground hidden xl:inline">{srv.site}</span>
{!offline && r.hasData === false && (
<span className="text-[10px] rounded border border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-400 px-1.5 py-0.5">
нет данных
</span>
)}
</div>
</td>
{/* Board + ROS */}
<td className="px-4 py-3 hidden md:table-cell">
<div className="flex flex-col leading-tight">
<span className="font-mono text-xs text-muted-foreground">{hasSamples ? r.boardName : "—"}</span>
<span className="text-[10px] text-muted-foreground/50">{srv.os}</span>
</div>
</td>
{/* CPU */}
<td className="px-4 py-3">
{noMetrics
? <span className="text-xs text-muted-foreground/30">{offline ? "—" : "нет опроса"}</span>
: (
<div className="flex flex-col gap-1.5 min-w-[140px]">
<div className="flex items-center gap-2">
<span className={cn("font-mono text-sm font-semibold tabular-nums w-10 shrink-0", resPctColor(r.cpu))}>
{r.cpu}%
</span>
<MiniBar pct={r.cpu} className="flex-1" />
</div>
<Sparkline data={r.cpuHistory} width={120} height={18} color={cpuColor} filled />
</div>
)}
</td>
{/* RAM */}
<td className="px-4 py-3">
{noMetrics
? <span className="text-xs text-muted-foreground/30">{offline ? "—" : "нет опроса"}</span>
: (
<div className="flex flex-col gap-1.5 min-w-[155px]">
<div className="flex items-center justify-between text-xs">
<span className={cn("font-mono font-semibold", resPctColor(r.ramPct))}>{r.ramPct}%</span>
<span className="text-muted-foreground/60 font-mono text-[10px]">
{fmtMB(r.ramUsed)}/{fmtMB(r.ramTotal)}
</span>
</div>
<MiniBar pct={r.ramPct} />
</div>
)}
</td>
{/* HDD */}
<td className="px-4 py-3">
{noMetrics
? <span className="text-xs text-muted-foreground/30">{offline ? "—" : "нет опроса"}</span>
: (
<div className="flex flex-col gap-1.5 min-w-[155px]">
<div className="flex items-center justify-between text-xs">
<span className={cn("font-mono font-semibold", resPctColor(r.hddPct))}>{r.hddPct}%</span>
<span className="text-muted-foreground/60 font-mono text-[10px]">
{fmtMB(r.hddUsed)}/{fmtMB(r.hddTotal)}
</span>
</div>
<MiniBar pct={r.hddPct} />
</div>
)}
</td>
{/* Uptime */}
<td className="px-4 py-3">
<span className="font-mono text-xs text-muted-foreground">
{noMetrics ? (offline ? "—" : "—") : fmtUptime(r.uptimeSeconds)}
</span>
</td>
{/* Temp */}
<td className="px-4 py-3">
{r.temp !== undefined && !noMetrics ? (
<span className={cn("font-mono text-sm font-semibold tabular-nums",
r.temp >= 70 ? "text-red-600 dark:text-red-400"
: r.temp >= 55 ? "text-amber-600 dark:text-amber-400"
: "text-muted-foreground",
)}>
{r.temp}°C
</span>
) : (
<span className="text-muted-foreground/30 text-xs"></span>
)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</Card>
<p className="text-xs text-muted-foreground/40 text-center">
{liveApi
? "Автообновление каждые 5 сек (и кнопка «Обновить») · /system/resource via RouterOS REST API · backend"
: "Обновление каждые 5 сек · /system/resource via RouterOS REST API · demo-режим"}
</p>
</div>
)
}
// ── page ───────────────────────────────────────────────────────────────────────
export default function UptimePage() {
const { mode, backendUrl, backendStatus, checkBackend } = useDataSource()
/** При mode=live всегда ходим на backend. Нельзя требовать backendStatus===true: до ответа /health там undefined — иначе обзор/«Обновить» молчат. */
const liveApi = mode === "live"
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
const [allServers, setAllServers] = useState<Server[]>([])
const [tab, setTab] = useState<"probes" | "resources" | "speed">("probes")
const [probes, setProbes] = useState<PingProbe[]>([])
const [resources, setResources] = useState<ServerResource[]>([])
const [isPaused, setIsPaused] = useState(false)
const [search, setSearch] = useState("")
const [statusFilter, setStatusFilter] = useState<"all" | "up" | "warn" | "down">("all")
const [serverFilter, setServerFilter] = useState<string[]>([])
const [collapsed, setCollapsed] = useState<Set<string>>(new Set())
const [sheetOpen, setSheetOpen] = useState(false)
const [opError, setOpError] = useState<string | null>(null)
const [uptimeRefreshBusy, setUptimeRefreshBusy] = useState(false)
/** Ключ — probeGroupActionKey: ручной ping группы «сервер + назначение». */
const [probeGroupPingBusy, setProbeGroupPingBusy] = useState<Record<string, boolean>>({})
/** Раскрытый подробный график RTT по id пробы */
const [probeRttChartOpen, setProbeRttChartOpen] = useState<Record<string, boolean>>({})
const [speedBusy, setSpeedBusy] = useState(false)
const [speedError, setSpeedError] = useState<string | null>(null)
const [speedRuns, setSpeedRuns] = useState<SpeedTestRun[]>([])
const [speedProbes, setSpeedProbes] = useState<SpeedProbeRow[]>([])
const [speedIfaces, setSpeedIfaces] = useState<Record<string, Array<{ name: string; running: boolean; disabled: boolean; addresses: string[] }>>>({})
const [speedCollapsed, setSpeedCollapsed] = useState<Set<string>>(new Set())
const [speedSheetOpen, setSpeedSheetOpen] = useState(false)
const [editingSpeedProbeId, setEditingSpeedProbeId] = useState<string | null>(null)
const [speedDraft, setSpeedDraft] = useState<SpeedProbeRow>({
id: "",
srcServerId: "",
dstServerId: "",
srcInterface: "",
dstInterface: "",
protocol: "tcp",
direction: "both",
durationSec: "10",
enabled: true,
})
useEffect(() => {
if (liveApi) return
// eslint-disable-next-line react-hooks/set-state-in-effect
setAllServers(mockServers)
setProbes(mockProbesWithSavedStars(INIT_PROBES))
setResources(INIT_RESOURCES)
}, [liveApi])
const isPausedRef = useRef(isPaused)
useEffect(() => { isPausedRef.current = isPaused }, [isPaused])
/** Сбрасывает ответы устаревших GET /overview (гонка: ответ приходит после клика по ★ и затирает showOnDashboard). */
const overviewReqRef = useRef(0)
const loadLiveOverview = useCallback(async () => {
if (!liveApi) return
const myReq = ++overviewReqRef.current
setOpError(null)
try {
const [serverRows, data] = await Promise.all([
apiFetch<BackendServer[]>("/api/servers"),
apiFetch<{ probes: PingProbe[]; resources: ServerResource[] }>("/api/uptime/overview?range=1h"),
])
if (myReq !== overviewReqRef.current) return
setAllServers(mapBackendServersToServers(serverRows))
setProbes(data.probes)
setResources(data.resources)
} catch (e) {
if (myReq !== overviewReqRef.current) return
setOpError(e instanceof Error ? e.message : "Не удалось загрузить uptime")
}
}, [apiFetch, liveApi])
const reloadSpeedData = useCallback(async () => {
if (!liveApi) return
type RunRow = {
id: string
srcServerId: string
dstServerId: string
srcInterface: string
dstInterface: string
protocol: "tcp" | "udp"
direction: "transmit" | "receive" | "both"
durationSec: number
txAvgMbps: number
rxAvgMbps: number
status: "done" | "error"
error?: string | null
srcAddress?: string | null
dstAddress?: string | null
srcInterfaceAddress?: string | null
dstInterfaceAddress?: string | null
afterBtPing?: { rttMs: number | null; lossPct: number | null; error: string | null } | null
createdAt: string
}
try {
const [sp, runsRes] = await Promise.all([
apiFetch<{ probes: SpeedProbeRow[] }>("/api/uptime/speed-probes"),
apiFetch<{ runs: RunRow[] }>("/api/uptime/speed-test/runs"),
])
setSpeedProbes(sp.probes ?? [])
const rows = (runsRes.runs ?? []).map((r) => ({
id: r.id,
startedAt: Date.parse(r.createdAt),
srcServerId: r.srcServerId,
dstServerId: r.dstServerId,
srcInterface: r.srcInterface || undefined,
dstInterface: r.dstInterface || undefined,
protocol: r.protocol,
direction: r.direction,
durationSec: r.durationSec,
txAvgMbps: Math.round(r.txAvgMbps ?? 0),
rxAvgMbps: Math.round(r.rxAvgMbps ?? 0),
status: (r.status === "error" ? "error" : "done") as "error" | "done",
command: "",
lines: r.error ? [`status: error`, r.error] : [],
afterBtPing: r.afterBtPing ?? null,
srcAddress: r.srcAddress ?? null,
dstAddress: r.dstAddress ?? null,
srcInterfaceAddress: r.srcInterfaceAddress ?? null,
dstInterfaceAddress: r.dstInterfaceAddress ?? null,
}))
setSpeedRuns(rows)
} catch {
setSpeedProbes([])
setSpeedRuns([])
}
}, [apiFetch, liveApi])
const refreshUptimeLive = useCallback(async (opts?: { showSpinner?: boolean; pollDevices?: boolean }) => {
if (!liveApi) return
if (opts?.showSpinner) setUptimeRefreshBusy(true)
let collectErr: string | null = null
try {
void checkBackend()
if (opts?.pollDevices) {
try {
await apiFetch<{ ok: boolean; lastError?: string | null }>("/api/uptime/collect-now", { method: "POST" })
} catch (e) {
collectErr = e instanceof Error ? e.message : "Не удалось опросить устройства (collect-now)"
}
}
await Promise.all([loadLiveOverview(), reloadSpeedData()])
if (collectErr) {
setOpError((prev) => (prev ? `${prev} · ${collectErr}` : collectErr))
}
} finally {
if (opts?.showSpinner) setUptimeRefreshBusy(false)
}
}, [liveApi, loadLiveOverview, reloadSpeedData, checkBackend, apiFetch])
const refreshProbeGroupPings = useCallback(
async (srvId: string, group: { name: string; target: string; probes: PingProbe[] }) => {
if (!liveApi) return
const key = probeGroupActionKey(srvId, group)
setProbeGroupPingBusy((m) => ({ ...m, [key]: true }))
setOpError(null)
try {
await apiFetch<{ ok: boolean; polled: number }>("/api/uptime/probes/collect-group", {
method: "POST",
body: JSON.stringify({ probeIds: group.probes.map((p) => p.id) }),
})
await loadLiveOverview()
} catch (e) {
setOpError(e instanceof Error ? e.message : "Не удалось выполнить ping группы")
} finally {
setProbeGroupPingBusy((m) => {
const next = { ...m }
delete next[key]
return next
})
}
},
[liveApi, apiFetch, loadLiveOverview],
)
useEffect(() => {
if (!liveApi) return
queueMicrotask(() => { void refreshUptimeLive() })
}, [liveApi, refreshUptimeLive])
useEffect(() => {
if (!liveApi || isPaused) return
const id = setInterval(() => { void refreshUptimeLive() }, 5_000)
return () => clearInterval(id)
}, [liveApi, isPaused, refreshUptimeLive])
// add-probe form
const [newSrcId, setNewSrcId] = useState("")
const [newSrcInterface, setNewSrcInterface] = useState("")
const [newName, setNewName] = useState("")
const [newTarget, setNewTarget] = useState("")
const [newFilter, setNewFilter] = useState("—")
const [editingProbeId, setEditingProbeId] = useState<string | null>(null)
const [srcInterfaces, setSrcInterfaces] = useState<Array<{ name: string; running: boolean; disabled: boolean }>>([])
const [srcInterfacesBusy, setSrcInterfacesBusy] = useState(false)
/** Источник для ping/speed: весь каталог (в т.ч. выключенные в inventory), иначе Home Router нельзя выбрать */
const selectableSources = useMemo(() => allServers, [allServers])
const loadSpeedInterfaces = useCallback(async (serverId: string) => {
if (!liveApi) return
if (!serverId || speedIfaces[serverId]) return
const id = Number.parseInt(serverId, 10)
if (!Number.isFinite(id)) return
try {
const res = await apiFetch<{ interfaces: Array<{ name: string; running: boolean; disabled: boolean; addresses: string[] }> }>(`/api/uptime/speed-test/endpoints/${id}/interfaces`)
setSpeedIfaces((prev) => ({ ...prev, [serverId]: res.interfaces ?? [] }))
} catch {
setSpeedIfaces((prev) => ({ ...prev, [serverId]: [] }))
}
}, [apiFetch, liveApi, speedIfaces])
/** После загрузки списков интерфейсов сбросить выбор, если интерфейс не активен или отсутствует в списке */
useEffect(() => {
if (!speedSheetOpen) return
setSpeedDraft((prev) => {
const rawSrc = speedIfaces[prev.srcServerId]
const rawDst = speedIfaces[prev.dstServerId]
let srcInterface = prev.srcInterface
let dstInterface = prev.dstInterface
if (rawSrc !== undefined) {
const active = filterActiveInterfaces(rawSrc)
if (srcInterface && !active.some((i) => i.name === srcInterface)) srcInterface = ""
}
if (rawDst !== undefined) {
const active = filterActiveInterfaces(rawDst)
if (dstInterface && !active.some((i) => i.name === dstInterface)) dstInterface = ""
}
if (srcInterface === prev.srcInterface && dstInterface === prev.dstInterface) return prev
return { ...prev, srcInterface, dstInterface }
})
}, [speedSheetOpen, speedIfaces])
useEffect(() => {
if (!sheetOpen) return
if (!newSrcId || !selectableSources.some(s => s.id === newSrcId)) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setNewSrcId(selectableSources[0]?.id ?? "")
}
}, [sheetOpen, newSrcId, selectableSources])
useEffect(() => {
if (!sheetOpen || !newSrcId) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setSrcInterfaces([])
setNewSrcInterface("")
return
}
if (!liveApi) {
setSrcInterfaces([])
setNewSrcInterface("")
return
}
const serverId = Number.parseInt(newSrcId, 10)
if (!Number.isFinite(serverId)) {
setSrcInterfaces([])
setNewSrcInterface("")
return
}
setSrcInterfacesBusy(true)
void apiFetch<{ interfaces: Array<{ name: string; running: boolean; disabled: boolean }> }>(`/api/uptime/sources/${serverId}/interfaces`)
.then((data) => {
const list = filterActiveInterfaces(data.interfaces ?? [])
setSrcInterfaces(list)
setNewSrcInterface((prev) => (prev && list.some((i) => i.name === prev) ? prev : ""))
})
.catch(() => {
setSrcInterfaces([])
setNewSrcInterface("")
})
.finally(() => setSrcInterfacesBusy(false))
}, [sheetOpen, newSrcId, liveApi, apiFetch])
// servers that have at least one probe (preserve data-order)
const probedServerIds = useMemo(
() => [...new Set(probes.map(p => p.srcServerId))],
[probes],
)
const probedServers = useMemo(() => {
const inCatalog = allServers.filter((s) => probedServerIds.includes(s.id))
const orphanIds = probedServerIds.filter((id) => !inCatalog.some((s) => s.id === id))
return [...inCatalog, ...orphanIds.map(orphanSpeedSourceStub)]
}, [probedServerIds, allServers])
// live RTT tick
useEffect(() => {
const id = setInterval(() => {
if (liveApi) return
if (isPausedRef.current) return
setProbes(prev => prev.map(p => {
if (!p.enabled || p.status === "down" || p.rtt === null) return p
const newRtt = jitter(p.rtt, 0.12)
const newSeries = [...p.series.slice(1), newRtt]
return { ...p, rtt: newRtt, series: newSeries }
}))
}, 3000)
return () => clearInterval(id)
}, [liveApi])
// live resource tick
useEffect(() => {
const id = setInterval(() => {
if (liveApi) return
if (isPausedRef.current) return
setResources(prev => prev.map(r => {
const srv = allServers.find(s => s.id === r.serverId)
if (!srv || srv.status !== "online") return r
if (r.hasData === false) return r
const newCpu = Math.min(99, Math.max(1, r.cpu + Math.round((Math.random() - 0.48) * 8)))
const newRam = Math.min(r.ramTotal - 64, Math.max(256, r.ramUsed + Math.round((Math.random() - 0.5) * 128)))
const newTemp = r.temp !== undefined
? Math.min(90, Math.max(28, r.temp + Math.round((Math.random() - 0.5) * 3)))
: undefined
return {
...r,
cpu: newCpu,
cpuHistory: [...r.cpuHistory.slice(1), newCpu],
ramUsed: newRam,
uptimeSeconds: r.uptimeSeconds + 5,
temp: newTemp,
}
}))
}, 5000)
return () => clearInterval(id)
}, [liveApi, allServers])
// ── derived ──
const stats = useMemo(() => ({
total: probes.length,
up: probes.filter(p => p.status === "up").length,
warn: probes.filter(p => p.status === "warn").length,
down: probes.filter(p => p.status === "down").length,
}), [probes])
const alertCount = useMemo(() =>
resources.filter(r => {
const s = allServers.find(x => x.id === r.serverId)
if (!s || s.status !== "online" || r.hasData === false) return false
const ramPct = Math.round(r.ramUsed / r.ramTotal * 100)
const hddPct = Math.round(r.hddUsed / r.hddTotal * 100)
return r.cpu >= 85 || ramPct >= 85 || hddPct >= 85 || (r.temp ?? 0) >= 70
}).length,
[resources, allServers],
)
const filtered = useMemo(() => probes.filter(p => {
if (serverFilter.length > 0 && !serverFilter.includes(p.srcServerId)) return false
if (statusFilter !== "all" && p.status !== statusFilter) return false
if (search) {
const q = search.toLowerCase()
return (
p.name.toLowerCase().includes(q) ||
p.target.toLowerCase().includes(q) ||
p.filter.toLowerCase().includes(q)
)
}
return true
}), [probes, serverFilter, statusFilter, search])
const grouped = useMemo(() => {
const byServer = new Map<string, Map<string, { name: string; target: string; probes: PingProbe[] }>>()
for (const p of filtered) {
if (!byServer.has(p.srcServerId)) byServer.set(p.srcServerId, new Map())
const key = `${p.name.trim().toLowerCase()}|${p.target.trim().toLowerCase()}`
const serverMap = byServer.get(p.srcServerId)!
if (!serverMap.has(key)) serverMap.set(key, { name: p.name, target: p.target, probes: [] })
serverMap.get(key)!.probes.push(p)
}
return probedServers
.map((server) => ({
server,
groups: [...(byServer.get(server.id)?.values() ?? [])]
.sort((a, b) => (a.target + a.name).localeCompare(b.target + b.name)),
}))
.filter((g) => g.groups.length > 0)
}, [filtered, probedServers])
const speedGrouped = useMemo(() => {
const map = new Map<string, SpeedProbeRow[]>()
for (const p of speedProbes) {
const arr = map.get(p.srcServerId) ?? []
arr.push(p)
map.set(p.srcServerId, arr)
}
const ids = [...map.keys()].sort((a, b) => {
const sa = allServers.find((s) => s.id === a) ?? orphanSpeedSourceStub(a)
const sb = allServers.find((s) => s.id === b) ?? orphanSpeedSourceStub(b)
return (sa.host + sa.name).localeCompare(sb.host + sb.name)
})
return ids.map((id) => ({
server: allServers.find((s) => s.id === id) ?? orphanSpeedSourceStub(id),
probes: (map.get(id) ?? []).sort((a, b) => (a.dstServerId + a.id).localeCompare(b.dstServerId + b.id)),
}))
}, [speedProbes, allServers])
const persistProbes = useCallback((rows: PingProbe[]) => {
if (!liveApi) return
void apiFetch("/api/uptime/probes", {
method: "PUT",
body: JSON.stringify({
probes: rows.map((p) => ({
id: p.id,
srcServerId: p.srcServerId,
srcInterface: p.srcInterface || "",
name: p.name,
target: p.target,
filter: p.filter,
enabled: p.enabled,
showOnDashboard: p.showOnDashboard === true,
})),
}),
}).catch(() => {})
}, [apiFetch, liveApi])
const toggleDashboardStar = useCallback((id: string) => {
const cur = probes.find((p) => p.id === id)
if (!cur) return
const nextVal = !cur.showOnDashboard
if (liveApi) {
overviewReqRef.current += 1
}
setProbes((prev) => prev.map((p) => (p.id === id ? { ...p, showOnDashboard: nextVal } : p)))
if (liveApi) {
void apiFetch(`/api/uptime/probes/${encodeURIComponent(id)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ showOnDashboard: nextVal }),
})
.then(() => loadLiveOverview())
.then(() => {
if (typeof window !== "undefined") {
window.dispatchEvent(new Event(UPTIME_PROBES_CHANGED))
}
})
.catch(() => {
setProbes((prev) => prev.map((p) => (p.id === id ? { ...p, showOnDashboard: cur.showOnDashboard } : p)))
})
} else {
const s = readMockDashboardStarIds()
if (nextVal) s.add(id)
else s.delete(id)
writeMockDashboardStarIds(s)
if (typeof window !== "undefined") {
window.dispatchEvent(new Event(UPTIME_PROBES_CHANGED))
}
}
}, [probes, liveApi, apiFetch, loadLiveOverview])
const persistSpeedProbes = useCallback((rows: SpeedProbeRow[]) => {
if (!liveApi) return
void apiFetch("/api/uptime/speed-probes", {
method: "PUT",
body: JSON.stringify({
probes: rows.map((p) => ({
id: p.id,
srcServerId: p.srcServerId,
dstServerId: p.dstServerId,
srcInterface: p.srcInterface || "",
dstInterface: p.dstInterface || "",
protocol: p.protocol,
direction: p.direction,
durationSec: Number.parseInt(p.durationSec, 10) || 10,
enabled: p.enabled,
lastRunAt: p.lastRunAt ?? null,
lastTxAvgMbps: p.lastTxAvgMbps ?? null,
lastRxAvgMbps: p.lastRxAvgMbps ?? null,
lastStatus: p.lastStatus ?? null,
lastError: p.lastError ?? null,
})),
}),
}).catch(() => {})
}, [apiFetch, liveApi])
// ── actions ──
const toggleProbe = (id: string, v: boolean) =>
setProbes((prev) => {
const next = prev.map((x) => x.id === id ? { ...x, enabled: v } : x)
persistProbes(next)
return next
})
const deleteProbe = (id: string) => {
if (!liveApi) {
const s = readMockDashboardStarIds()
s.delete(id)
writeMockDashboardStarIds(s)
}
setProbes((prev) => {
const next = prev.filter((x) => x.id !== id)
persistProbes(next)
return next
})
}
const toggleCollapse = (serverId: string) =>
setCollapsed(prev => {
const next = new Set(prev)
if (next.has(serverId)) next.delete(serverId); else next.add(serverId)
return next
})
const collapseAll = useCallback(() =>
setCollapsed(new Set(grouped.map(g => g.server.id))), [grouped])
const expandAll = useCallback(() =>
setCollapsed(new Set()), [])
const allCollapsed = grouped.length > 0 && grouped.every(g => collapsed.has(g.server.id))
const toggleServerFilter = (id: string) =>
setServerFilter(prev =>
prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]
)
const cycleStatusFilter = (s: typeof statusFilter) =>
setStatusFilter(prev => prev === s ? "all" : s)
const openEditProbe = (probe: PingProbe) => {
setEditingProbeId(probe.id)
setNewSrcId(probe.srcServerId)
setNewSrcInterface(probe.srcInterface || "")
setNewName(probe.name)
setNewTarget(probe.target)
setNewFilter(probe.filter || "—")
setSheetOpen(true)
}
const openAddSheet = () => {
setEditingProbeId(null)
setNewSrcId(selectableSources[0]?.id ?? "")
setNewSrcInterface("")
setNewName(""); setNewTarget(""); setNewFilter("—")
setSheetOpen(true)
}
const handleSaveProbe = () => {
if (!newName.trim() || !newTarget.trim() || !newSrcId) return
const probeBase: PingProbe = {
id: editingProbeId ?? `p${Date.now()}`,
srcServerId: newSrcId,
srcInterface: newSrcInterface,
name: newName.trim(),
target: newTarget.trim(),
filter: newFilter || "—",
rtt: null,
loss: 0,
status: "up",
series: Array(40).fill(0),
enabled: true,
showOnDashboard: false,
}
if (editingProbeId) {
setProbes((prev) => {
const next = prev.map((p) => {
if (p.id !== editingProbeId) return p
return {
...p,
srcServerId: probeBase.srcServerId,
srcInterface: probeBase.srcInterface,
name: probeBase.name,
target: probeBase.target,
filter: probeBase.filter,
}
})
persistProbes(next)
return next
})
} else {
setProbes((prev) => {
const next = [...prev, probeBase]
persistProbes(next)
return next
})
}
setEditingProbeId(null)
setSheetOpen(false)
}
const buildSpeedCommand = useCallback((probe: SpeedProbeRow) => {
const src = allServers.find((s) => s.id === probe.srcServerId)
const dst = allServers.find((s) => s.id === probe.dstServerId)
const rawDst = probe.dstInterface
? speedIfaces[probe.dstServerId]?.find((i) => i.name === probe.dstInterface)?.addresses?.[0]
: undefined
const dstIp = rawDst ? stripIpCidr(rawDst) : (dst?.host ?? "0.0.0.0")
const dur = Math.max(3, Number.parseInt(probe.durationSec, 10) || 10)
return `[${src?.name ?? "src"}] /tool bandwidth-test address=${dstIp} user=<API_назначения> protocol=${probe.protocol} direction=${probe.direction} duration=${dur}s`
}, [allServers, speedIfaces])
/** Те же адреса, что при POST /api/uptime/speed-test — для проверки в SSH на узле источника. */
const speedVerificationRouterOsCli = useMemo(() => {
const probe = speedDraft
const src = allServers.find((s) => s.id === probe.srcServerId)
const dst = allServers.find((s) => s.id === probe.dstServerId)
if (!src || !dst || probe.srcServerId === probe.dstServerId) return ""
const dstRaw = probe.dstInterface.trim()
? speedIfaces[probe.dstServerId]?.find((i) => i.name === probe.dstInterface)?.addresses?.[0]
: undefined
const dstIp = dstRaw ? stripIpCidr(dstRaw) : dst.host.trim()
const srcRaw = probe.srcInterface.trim()
? speedIfaces[probe.srcServerId]?.find((i) => i.name === probe.srcInterface)?.addresses?.[0]
: undefined
const srcIp = srcRaw ? stripIpCidr(srcRaw) : src.host.trim()
const dur = Math.max(3, Number.parseInt(probe.durationSec, 10) || 10)
const si = probe.srcInterface.trim()
const di = probe.dstInterface.trim()
const lines: string[] = []
lines.push(`# Проверка IP (как backend BTTest: только активные записи /ip/address — без disabled/invalid; статический адрес предпочтительнее dynamic)`)
lines.push(`# Узел источника: ${src.name}`)
lines.push(`# ожидаемый локальный адрес: ${srcIp || "?"}${si ? ` · интерфейс "${si}"` : " · interface не задан — как host/API каталога"}`)
lines.push(`# Узел назначения: ${dst.name}`)
lines.push(`# address для ping и bandwidth-test: ${dstIp || "?"}${di ? ` · интерфейс "${di}" на назначении` : " · host каталога (DNS/API)"}`)
lines.push("")
if (si) {
lines.push(`/ip address print where name="${si.replace(/"/g, '\\"')}"`)
lines.push("")
}
if (di) {
lines.push(`# Выполнить на узле назначения (${dst.name}) — адрес приёмника BT-сервера:`)
lines.push(`/ip address print where name="${di.replace(/"/g, '\\"')}"`)
lines.push("")
}
const pingLine =
`/ping address=${dstIp || "?"} count=4` +
(si ? ` interface="${si.replace(/"/g, '\\"')}"` : "")
lines.push(pingLine)
lines.push("")
lines.push(
`/tool bandwidth-test address=${dstIp || "?"} user=<логин_API_назначения> password=<пароль_API_назначения> protocol=${probe.protocol} direction=${probe.direction} duration=${dur}s`,
)
lines.push("")
lines.push(
`# Подставьте логин/пароль REST API узла «${dst.name}» из раздела «Серверы» (тот же user/password, что для MikrotikClient).`,
)
return lines.join("\n")
}, [speedDraft, allServers, speedIfaces])
const runSpeedTest = async (probe: SpeedProbeRow) => {
if (!probe.srcServerId || !probe.dstServerId || probe.srcServerId === probe.dstServerId) return
setSpeedError(null)
setSpeedBusy(true)
const durationSec = Math.max(3, Number.parseInt(probe.durationSec, 10) || 10)
const runId = `speed-${Date.now()}`
setSpeedRuns((prev) => [{
id: runId,
startedAt: Date.now(),
srcServerId: probe.srcServerId,
dstServerId: probe.dstServerId,
srcInterface: probe.srcInterface || undefined,
dstInterface: probe.dstInterface || undefined,
protocol: probe.protocol,
direction: probe.direction,
durationSec,
txAvgMbps: 0,
rxAvgMbps: 0,
status: "running" as const,
command: buildSpeedCommand(probe),
lines: ["status: running..."],
}, ...prev].slice(0, 20))
try {
if (liveApi) {
const payload = {
runId,
probeId: probe.id,
srcServerId: Number.parseInt(probe.srcServerId, 10),
dstServerId: Number.parseInt(probe.dstServerId, 10),
srcInterface: probe.srcInterface || undefined,
dstInterface: probe.dstInterface || undefined,
protocol: probe.protocol,
direction: probe.direction,
durationSec,
}
const res = await apiFetch<{
result: {
txAvgMbps: number
rxAvgMbps: number
raw?: Array<Record<string, string>>
afterBtPing?: { rttMs: number | null; lossPct: number | null; error: string | null }
srcAddress?: string | null
dstAddress?: string | null
srcInterfaceAddress?: string | null
dstInterfaceAddress?: string | null
}
}>("/api/uptime/speed-test", {
method: "POST",
body: JSON.stringify(payload),
})
const ap = res.result.afterBtPing
const lines = (res.result.raw ?? []).flatMap((row) =>
Object.entries(row).map(([k, v]) => `${k}: ${String(v)}`),
)
const pingNote = ap
? (ap.error
? `after-bt-ping: error: ${ap.error}`
: `after-bt-ping: rtt=${ap.rttMs ?? "—"}ms loss=${ap.lossPct ?? "—"}%`)
: ""
setSpeedRuns((prev) => prev.map((r) => r.id === runId ? ({
...r,
txAvgMbps: Math.round(res.result.txAvgMbps),
rxAvgMbps: Math.round(res.result.rxAvgMbps),
status: "done",
afterBtPing: ap ?? null,
srcAddress: res.result.srcAddress ?? null,
dstAddress: res.result.dstAddress ?? null,
srcInterfaceAddress: res.result.srcInterfaceAddress ?? null,
dstInterfaceAddress: res.result.dstInterfaceAddress ?? null,
lines: [
...(lines.length ? lines : [`tx-total-average: ${Math.round(res.result.txAvgMbps)}Mbps`, `rx-total-average: ${Math.round(res.result.rxAvgMbps)}Mbps`]),
...(pingNote ? [pingNote] : []),
],
}) : r))
const ts = new Date().toISOString()
setSpeedProbes((prev) => prev.map((p) => p.id === probe.id ? ({
...p,
lastRunAt: ts,
lastTxAvgMbps: Math.round(res.result.txAvgMbps),
lastRxAvgMbps: Math.round(res.result.rxAvgMbps),
lastStatus: "done",
lastError: "",
lastPingRttMs: ap?.rttMs ?? null,
lastPingLossPct: ap?.lossPct ?? null,
lastPingAt: ts,
lastPingError: ap?.error ?? "",
}) : p))
} else {
const tx = Math.max(10, Math.round(250 + Math.random() * 500))
const rx = Math.max(10, Math.round(tx * (0.85 + Math.random() * 0.2)))
const mockPing = { rttMs: Math.round(8 + Math.random() * 35), lossPct: Math.random() < 0.15 ? Math.round(Math.random() * 25) : 0, error: null as string | null }
setSpeedRuns((prev) => prev.map((r) => r.id === runId ? ({
...r,
txAvgMbps: tx,
rxAvgMbps: rx,
status: "done",
afterBtPing: mockPing,
srcAddress: allServers.find((s) => s.id === probe.srcServerId)?.host ?? null,
dstAddress: allServers.find((s) => s.id === probe.dstServerId)?.host ?? null,
srcInterfaceAddress: null,
dstInterfaceAddress: null,
lines: [
"status: done testing",
`tx-total-average: ${tx}Mbps`,
`rx-total-average: ${rx}Mbps`,
`after-bt-ping: rtt=${mockPing.rttMs}ms loss=${mockPing.lossPct}%`,
],
}) : r))
const ts = new Date().toISOString()
setSpeedProbes((prev) => prev.map((p) => p.id === probe.id ? ({
...p,
lastRunAt: ts,
lastTxAvgMbps: tx,
lastRxAvgMbps: rx,
lastStatus: "done",
lastError: "",
lastPingRttMs: mockPing.rttMs,
lastPingLossPct: mockPing.lossPct,
lastPingAt: ts,
lastPingError: "",
}) : p))
}
} catch (e) {
const message = e instanceof Error ? e.message : "Не удалось выполнить speed test"
setSpeedRuns((prev) => prev.map((r) => r.id === runId ? ({
...r,
status: "error",
lines: [`status: error`, message],
}) : r))
setSpeedError(message)
setSpeedProbes((prev) => prev.map((p) => p.id === probe.id ? ({
...p,
lastRunAt: new Date().toISOString(),
lastStatus: "error",
lastError: message,
}) : p))
} finally {
setSpeedBusy(false)
}
}
const updateSpeedProbe = (id: string, patch: Partial<SpeedProbeRow>) => {
setSpeedProbes((prev) => {
const nextRows = prev.map((p) => {
if (p.id !== id) return p
const next = { ...p, ...patch }
if (patch.srcServerId && patch.srcServerId === next.dstServerId) {
next.dstServerId = selectableSources.find((s) => s.id !== patch.srcServerId)?.id ?? patch.srcServerId
}
return next
})
persistSpeedProbes(nextRows)
return nextRows
})
}
const openAddSpeedSheet = () => {
setEditingSpeedProbeId(null)
const src = selectableSources[0]?.id ?? ""
const dst = selectableSources.find((s) => s.id !== src)?.id ?? src
setSpeedDraft({
id: "",
srcServerId: src,
dstServerId: dst,
srcInterface: "",
dstInterface: "",
protocol: "tcp",
direction: "both",
durationSec: "10",
enabled: true,
})
setSpeedSheetOpen(true)
void loadSpeedInterfaces(src)
void loadSpeedInterfaces(dst)
}
const openEditSpeedSheet = (probe: SpeedProbeRow) => {
setEditingSpeedProbeId(probe.id)
setSpeedDraft({
...probe,
durationSec: String(probe.durationSec ?? "10"),
})
setSpeedSheetOpen(true)
void loadSpeedInterfaces(probe.srcServerId)
void loadSpeedInterfaces(probe.dstServerId)
}
const saveSpeedProbe = () => {
if (!speedDraft.srcServerId || !speedDraft.dstServerId) return
if (editingSpeedProbeId) {
setSpeedProbes((prev) => {
const next = prev.map((p) => p.id === editingSpeedProbeId
? {
...p,
...speedDraft,
id: editingSpeedProbeId,
durationSec: String(speedDraft.durationSec ?? "10"),
}
: p)
persistSpeedProbes(next)
return next
})
} else {
setSpeedProbes((prev) => {
const next = [{
...speedDraft,
id: `sp-${Date.now()}`,
}, ...prev]
persistSpeedProbes(next)
return next
})
}
setEditingSpeedProbeId(null)
setSpeedSheetOpen(false)
}
const deleteSpeedProbe = (id: string) => {
setSpeedProbes((prev) => {
const next = prev.filter((p) => p.id !== id)
persistSpeedProbes(next)
return next
})
}
const toggleSpeedCollapse = (serverId: string) => {
setSpeedCollapsed((prev) => {
const next = new Set(prev)
if (next.has(serverId)) next.delete(serverId); else next.add(serverId)
return next
})
}
// ── render ──
return (
<div className="flex flex-col h-full">
<PageHeader
crumbs={[{ label: "Обзор" }, { label: "Мониторинг" }]}
actions={
<>
{/* Live / Pause toggle */}
<Button
variant="outline" size="sm"
onClick={() => setIsPaused(v => !v)}
className={cn(isPaused && "border-[var(--status-degraded)]/50 text-[var(--status-degraded-fg)]")}
>
{isPaused
? <><PlayIcon className="size-4" />Возобновить</>
: <><PauseIcon className="size-4" />Пауза</>}
</Button>
<Button variant="outline" size="sm" disabled={liveApi && uptimeRefreshBusy} onClick={() => {
if (liveApi) {
void refreshUptimeLive({ showSpinner: true, pollDevices: tab === "resources" || tab === "probes" })
return
}
setProbes(mockProbesWithSavedStars(INIT_PROBES))
setResources(INIT_RESOURCES)
}}>
<RefreshCwIcon className={cn("size-4", uptimeRefreshBusy && "animate-spin")} />{liveApi ? "Обновить" : "Сбросить"}
</Button>
{tab === "probes" && (
<Button size="sm" onClick={openAddSheet}>
<PlusIcon className="size-4" />Новая проба
</Button>
)}
{tab === "speed" && (
<Button size="sm" onClick={openAddSpeedSheet}>
<PlusIcon className="size-4" />Новая speed-проба
</Button>
)}
</>
}
/>
{/* ── Live indicator ── */}
{!isPaused && (
<div className="flex items-center gap-2 px-6 py-1.5 border-b shrink-0 text-[11px]"
style={{ background: "var(--status-online-bg)", color: "var(--status-online-fg)" }}>
<span className="size-1.5 rounded-full bg-[var(--status-online)] animate-pulse" />
{liveApi
? (backendStatus === false
? "Live: /health не ответил — проверьте URL в настройках; запросы к API выполняются"
: "Live (backend)")
: "Демо-режим · пробы/ресурсы локальные; «Обновить» сбрасывает макет"}
</div>
)}
{isPaused && (
<div className="flex items-center gap-2 px-6 py-1.5 border-b shrink-0 text-[11px]"
style={{ background: "var(--status-degraded-bg)", color: "var(--status-degraded-fg)" }}>
<PauseIcon className="size-3" />
Обновление приостановлено
</div>
)}
{/* ── tab switcher ── */}
<div className="border-b bg-muted/10 px-6 flex items-center gap-1 shrink-0">
{(["probes", "resources", "speed"] as const).map(t => (
<button
key={t}
onClick={() => setTab(t)}
className={cn(
"px-4 py-2.5 text-sm font-medium border-b-2 transition-colors -mb-px",
tab === t
? "border-foreground text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground",
)}
>
{t === "probes" ? (
<span className="flex items-center gap-2">
<span>Пинг-пробы</span>
{stats.down > 0 && (
<span className="inline-flex items-center justify-center min-w-[18px] h-4.5 px-1 rounded-full
text-[10px] font-bold bg-red-500 text-white leading-none">
{stats.down}
</span>
)}
<span className="text-[10px] font-mono opacity-50">{probes.length}</span>
</span>
) : t === "resources" ? (
<span className="flex items-center gap-2">
<CpuIcon className="size-3.5" />
<span>Ресурсы</span>
{alertCount > 0 && (
<span className="inline-flex items-center justify-center min-w-[18px] h-4.5 px-1 rounded-full
text-[10px] font-bold bg-red-500 text-white leading-none">
{alertCount}
</span>
)}
<span className="text-[10px] font-mono opacity-50">{resources.length}</span>
</span>
) : (
<span className="flex items-center gap-2">
<ArrowUpDownIcon className="size-3.5" />
<span>Скорость</span>
<span className="text-[10px] font-mono opacity-50">{speedProbes.length}</span>
</span>
)}
</button>
))}
</div>
<div className="flex-1 overflow-y-auto">
{/* ── resources tab ── */}
{tab === "resources" && <ResourcesTab resources={resources} serversList={allServers} liveApi={liveApi} />}
{/* ── speed tab ── */}
{tab === "speed" && (
<div className="flex flex-col gap-0">
{/* ── KPI strip ── */}
{(() => {
const doneRuns = speedRuns.filter(r => r.status === "done")
const runningCnt = speedRuns.filter(r => r.status === "running").length
const maxTx = doneRuns.length ? Math.max(...doneRuns.map(r => r.txAvgMbps)) : null
const maxRx = doneRuns.length ? Math.max(...doneRuns.map(r => r.rxAvgMbps)) : null
return (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 px-6 py-4 border-b bg-muted/10 shrink-0">
{[
{ label: "Speed-пробы", value: speedProbes.length, unit: "шт", color: "" },
{ label: "Тестов выполнено", value: doneRuns.length, unit: "run", color: "" },
{ label: "Макс TX", value: maxTx != null ? `${maxTx}` : "—", unit: maxTx != null ? "Мбит/с" : "", color: "text-[var(--chart-tx)]" },
{ label: "Макс RX", value: maxRx != null ? `${maxRx}` : "—", unit: maxRx != null ? "Мбит/с" : "", color: "text-[var(--chart-rx)]" },
].map(k => (
<Card key={k.label}>
<CardContent className="pt-4 pb-3 px-4">
<p className="text-xs text-muted-foreground">{k.label}</p>
<div className="flex items-baseline gap-1 mt-0.5">
<span className={cn("text-2xl font-semibold tabular-nums", k.color)}>{k.value}</span>
{k.unit && <span className="text-xs text-muted-foreground">{k.unit}</span>}
</div>
{runningCnt > 0 && k.label === "Тестов выполнено" && (
<p className="text-[11px] text-[var(--status-degraded-fg)] flex items-center gap-1 mt-0.5">
<RefreshCwIcon className="size-2.5 animate-spin" />{runningCnt} выполняется
</p>
)}
</CardContent>
</Card>
))}
</div>
)
})()}
{/* ── error ── */}
{speedError && (
<div className="mx-6 mt-4">
<Alert variant="destructive" className="py-2 text-xs">
<AlertCircleIcon />
<AlertDescription>{speedError}</AlertDescription>
</Alert>
</div>
)}
<div className="flex-1 overflow-y-auto p-6 flex flex-col gap-4">
{/* ── empty state ── */}
{speedGrouped.length === 0 && (
<div className="flex flex-col items-center justify-center py-16 gap-3 text-center">
<div className="size-12 rounded-full bg-muted flex items-center justify-center">
<ArrowUpDownIcon className="size-5 text-muted-foreground" />
</div>
<p className="text-sm font-medium">Нет speed-проб</p>
<p className="text-xs text-muted-foreground max-w-xs">
Создайте пробу через кнопку «Новая speed-проба» в шапке страницы
</p>
<Button size="sm" variant="outline" className="mt-1" onClick={openAddSpeedSheet}>
<PlusIcon className="size-4" />Новая speed-проба
</Button>
</div>
)}
{/* ── probe groups ── */}
{speedGrouped.map(({ server, probes: srvProbes }) => {
const isCollapsed = speedCollapsed.has(server.id)
return (
<Card key={server.id} className="overflow-hidden py-0 gap-0">
{/* server header */}
<button
onClick={() => toggleSpeedCollapse(server.id)}
className="w-full flex items-center gap-2.5 px-4 py-3 hover:bg-muted/30 transition-colors text-left border-b"
>
{isCollapsed
? <ChevronRightIcon className="size-4 text-muted-foreground shrink-0" />
: <ChevronDownIcon className="size-4 text-muted-foreground shrink-0" />}
<StatusDot status={server.status} pulse={server.status === "online"} />
<Flag code={server.country} size={16} />
<span className="font-mono text-sm font-semibold">{server.name}</span>
<TypeChip type={server.type} />
<span className="text-xs text-muted-foreground ml-auto">{srvProbes.length} проб</span>
</button>
{!isCollapsed && (
<div className="divide-y divide-border/60">
{srvProbes.map((probe) => {
const dst = allServers.find((s) => s.id === probe.dstServerId)
const run = speedRuns.find((r) =>
r.srcServerId === probe.srcServerId &&
r.dstServerId === probe.dstServerId &&
(r.srcInterface ?? "") === probe.srcInterface &&
(r.dstInterface ?? "") === probe.dstInterface,
)
const viewStatus = run?.status ?? (probe.lastStatus === "error" ? "error" : probe.lastRunAt ? "done" : null)
const viewTx = run?.txAvgMbps ?? Math.round(probe.lastTxAvgMbps ?? 0)
const viewRx = run?.rxAvgMbps ?? Math.round(probe.lastRxAvgMbps ?? 0)
const maxVal = Math.max(viewTx, viewRx, 1)
const canRun = probe.enabled && !!probe.srcServerId && !!probe.dstServerId && probe.srcServerId !== probe.dstServerId
const viewAfterBtPing =
run?.status === "done" && run.afterBtPing
? run.afterBtPing
: probe.lastPingAt
? {
rttMs: probe.lastPingRttMs ?? null,
lossPct: probe.lastPingLossPct ?? null,
error: probe.lastPingError?.trim() ? probe.lastPingError : null,
}
: null
return (
<div key={probe.id}
className={cn("px-4 py-3 hover:bg-muted/20 transition-colors", !probe.enabled && "opacity-50")}>
<div className="flex items-center gap-3 flex-wrap">
{/* enable toggle */}
<FormToggle checked={probe.enabled} onChange={(v) => updateSpeedProbe(probe.id, { enabled: v })} />
{/* route: src → dst */}
<div className="flex items-center gap-1.5 min-w-0 flex-1">
<span className="font-mono text-sm font-medium truncate">{server.name}</span>
<ArrowRightIcon className="size-3.5 text-muted-foreground shrink-0" />
<Flag code={dst?.country ?? "UN"} size={14} className="shrink-0" />
<span className="font-mono text-sm font-medium truncate">{dst?.name ?? probe.dstServerId}</span>
</div>
{/* param chips */}
<div className="flex items-center gap-1 shrink-0 flex-wrap">
<span className="inline-flex items-center rounded border px-1.5 py-0.5 text-[10px] font-semibold bg-muted/60 text-foreground border-border/60">
{probe.protocol.toUpperCase()}
</span>
<span className="inline-flex items-center rounded border px-1.5 py-0.5 text-[10px] font-medium bg-muted/60 text-muted-foreground border-border/60">
{probe.direction === "both" ? "↕" : probe.direction === "transmit" ? "↑" : "↓"}&nbsp;{probe.direction}
</span>
<span className="inline-flex items-center rounded border px-1.5 py-0.5 text-[10px] font-medium bg-muted/60 text-muted-foreground border-border/60">
{probe.durationSec}s
</span>
{(probe.srcInterface || probe.dstInterface) && (
<span className="inline-flex items-center rounded border px-1.5 py-0.5 text-[10px] font-mono bg-muted/60 text-muted-foreground border-border/60">
{probe.srcInterface || "auto"}&nbsp;&nbsp;{probe.dstInterface || "auto"}
</span>
)}
</div>
{/* actions */}
<div className="flex items-center gap-1 shrink-0">
<Button size="sm" variant="outline" className="h-7 gap-1.5 text-xs"
disabled={speedBusy || !canRun}
onClick={() => void runSpeedTest(probe)}>
<RefreshCwIcon className={cn("size-3", speedBusy && run?.status === "running" && "animate-spin")} />
Запустить
</Button>
<Button size="sm" variant="ghost" className="h-7 text-muted-foreground hover:text-foreground"
onClick={() => openEditSpeedSheet(probe)}
title="Редактировать speed-пробу">
<PencilIcon className="size-3.5" />
</Button>
<Button size="sm" variant="ghost" className="h-7 text-muted-foreground hover:text-destructive"
onClick={() => deleteSpeedProbe(probe.id)}>
<TrashIcon className="size-3.5" />
</Button>
</div>
</div>
{/* result row */}
{viewStatus && (
<div className="mt-2.5 ml-9">
{viewStatus === "running" ? (
<p className="text-[11px] text-[var(--status-degraded-fg)] flex items-center gap-1.5">
<RefreshCwIcon className="size-3 animate-spin" />Тест выполняется…
</p>
) : viewStatus === "error" ? (
<p className="text-[11px] text-[var(--status-offline-fg)]">
{probe.lastError || "Ошибка выполнения теста"}
</p>
) : (
<div className="flex flex-col gap-1.5 max-w-md">
<div className="flex flex-col gap-1 max-w-md">
{[
{ label: "TX", val: viewTx, color: "bg-[var(--chart-tx)]", textColor: "text-[var(--chart-tx)]" },
{ label: "RX", val: viewRx, color: "bg-[var(--chart-rx)]", textColor: "text-[var(--chart-rx)]" },
].map(r => (
<div key={r.label} className="flex items-center gap-2 text-[11px]">
<span className="w-8 font-semibold text-muted-foreground">{r.label}</span>
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
<div className={cn("h-full rounded-full", r.color)}
style={{ width: `${Math.max((r.val / maxVal) * 100, r.val > 0 ? 3 : 0)}%` }} />
</div>
<span className={cn("w-24 text-right font-mono tabular-nums font-semibold", r.textColor)}>
{r.val} Мбит/с
</span>
</div>
))}
</div>
{viewAfterBtPing && (
viewAfterBtPing.error ? (
<p className="text-[11px] text-[var(--status-offline-fg)]">
Ping после BT: {viewAfterBtPing.error}
</p>
) : (
<div className="flex items-center gap-2 text-[11px]">
<span className="w-8 font-semibold text-violet-600 dark:text-violet-400">Ping</span>
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
<div
className={cn(
"h-full rounded-full",
viewAfterBtPing.rttMs == null
? "bg-amber-500"
: "bg-violet-500",
)}
style={{
width: viewAfterBtPing.rttMs == null
? "100%"
: `${Math.max(Math.min((viewAfterBtPing.rttMs / 120) * 100, 100), 4)}%`,
}}
/>
</div>
<span className={cn(
"w-24 text-right font-mono tabular-nums font-semibold",
viewAfterBtPing.rttMs == null
? "text-amber-600 dark:text-amber-400"
: "text-violet-600 dark:text-violet-400",
)}>
{viewAfterBtPing.rttMs == null ? "timeout" : `${viewAfterBtPing.rttMs} мс`}
</span>
</div>
)
)}
</div>
)}
</div>
)}
</div>
)
})}
</div>
)}
</Card>
)
})}
{/* ── history ── */}
{speedRuns.length > 0 && (
<Card className="overflow-hidden">
<div className="flex items-center justify-between px-4 py-3 border-b">
<p className="text-sm font-medium">История тестов</p>
<span className="text-xs text-muted-foreground">{speedRuns.length} запусков</span>
</div>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b bg-muted/40 text-muted-foreground">
{["Время", "Маршрут", "Параметры", "Статус", "TX avg", "RX avg", "Ping после BT"].map(h => (
<th key={h} className="px-4 py-2.5 text-left font-medium whitespace-nowrap">{h}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border/60">
{speedRuns.map((run) => {
const src = allServers.find((s) => s.id === run.srcServerId)
const dst = allServers.find((s) => s.id === run.dstServerId)
const maxVal = Math.max(run.txAvgMbps, run.rxAvgMbps, 1)
return (
<tr key={run.id} className="hover:bg-muted/20 transition-colors">
<td className="px-4 py-2.5 text-muted-foreground whitespace-nowrap tabular-nums font-mono">
{new Date(run.startedAt).toLocaleString("ru-RU", { hour: "2-digit", minute: "2-digit", second: "2-digit", day: "2-digit", month: "2-digit" })}
</td>
<td className="px-4 py-2.5 font-mono whitespace-nowrap">
<div className="flex items-center gap-1.5">
<Flag code={src?.country ?? "UN"} size={13} />
<span>{src?.name ?? run.srcServerId}</span>
<ArrowRightIcon className="size-3 text-muted-foreground" />
<Flag code={dst?.country ?? "UN"} size={13} />
<span>{dst?.name ?? run.dstServerId}</span>
</div>
<div className="text-[10px] text-muted-foreground mt-0.5 font-mono">
{run.srcInterfaceAddress && run.dstInterfaceAddress
? `${run.srcInterfaceAddress}${run.dstInterfaceAddress}`
: "внутренние IP: auto/не указаны"}
</div>
</td>
<td className="px-4 py-2.5 text-muted-foreground whitespace-nowrap">
<div className="flex items-center gap-1">
<span className="inline-flex items-center rounded border px-1.5 py-0.5 text-[10px] font-semibold bg-muted/60 border-border/60">
{run.protocol.toUpperCase()}
</span>
<span className="text-muted-foreground/60">·</span>
<span>{run.direction}</span>
<span className="text-muted-foreground/60">·</span>
<span>{run.durationSec}s</span>
</div>
</td>
<td className="px-4 py-2.5">
{run.status === "running" ? (
<span className="inline-flex items-center gap-1 text-[var(--status-degraded-fg)]">
<RefreshCwIcon className="size-3 animate-spin" />running
</span>
) : run.status === "error" ? (
<span className="text-[var(--status-offline-fg)]">error</span>
) : (
<span className="text-[var(--status-online-fg)]">done</span>
)}
</td>
<td className="px-4 py-2.5">
<div className="flex items-center gap-2 min-w-[120px]">
<div className="w-16 h-1.5 rounded-full bg-muted overflow-hidden">
<div className="h-full rounded-full bg-[var(--chart-tx)]"
style={{ width: `${(run.txAvgMbps / maxVal) * 100}%` }} />
</div>
<span className="font-mono tabular-nums text-[var(--chart-tx)] font-medium whitespace-nowrap">
{run.txAvgMbps} Мбит/с
</span>
</div>
</td>
<td className="px-4 py-2.5">
<div className="flex items-center gap-2 min-w-[120px]">
<div className="w-16 h-1.5 rounded-full bg-muted overflow-hidden">
<div className="h-full rounded-full bg-[var(--chart-rx)]"
style={{ width: `${(run.rxAvgMbps / maxVal) * 100}%` }} />
</div>
<span className="font-mono tabular-nums text-[var(--chart-rx)] font-medium whitespace-nowrap">
{run.rxAvgMbps} Мбит/с
</span>
</div>
</td>
<td className="px-4 py-2.5 font-mono tabular-nums whitespace-nowrap">
{run.status !== "done" ? "—" : run.afterBtPing?.error ? (
<span className="text-[var(--status-offline-fg)]" title={run.afterBtPing.error}>
ошибка
</span>
) : run.afterBtPing?.rttMs != null ? (
<span className="text-violet-600 dark:text-violet-400">
{run.afterBtPing.rttMs} мс
{run.afterBtPing.lossPct != null && run.afterBtPing.lossPct > 0 && (
<span className="text-amber-600 dark:text-amber-400"> · {run.afterBtPing.lossPct}%</span>
)}
</span>
) : (
<span className="text-amber-600 dark:text-amber-400">
timeout
{run.afterBtPing?.lossPct != null && <span> · {run.afterBtPing.lossPct}%</span>}
</span>
)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</Card>
)}
</div>
</div>
)}
{/* ── probes tab ── */}
{tab === "probes" && <>
{opError && (
<div className="px-6 pt-4">
<Alert variant="destructive" className="py-2 text-xs">
<AlertCircleIcon />
<AlertDescription>{opError}</AlertDescription>
</Alert>
</div>
)}
{/* ── summary + server filter ── */}
<div className="border-b bg-muted/20 px-6 py-3 flex items-center gap-3 flex-wrap">
<StatChip label="Всего" value={stats.total} />
<StatChip label="OK" value={stats.up} color="emerald" active={statusFilter === "up"} onClick={() => cycleStatusFilter("up")} />
<StatChip label="Предупреждение" value={stats.warn} color="amber" active={statusFilter === "warn"} onClick={() => cycleStatusFilter("warn")} />
<StatChip label="Недоступен" value={stats.down} color="red" active={statusFilter === "down"} onClick={() => cycleStatusFilter("down")} />
<div className="w-px h-4 bg-border mx-1 shrink-0" />
{/* server chips */}
{probedServers.map(s => {
const active = serverFilter.includes(s.id)
const sProbes = probes.filter(p => p.srcServerId === s.id)
const hasIssues = sProbes.some(p => p.status !== "up")
return (
<button key={s.id} onClick={() => toggleServerFilter(s.id)}
className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] font-medium transition-all",
active
? "bg-foreground text-background border-foreground"
: "border-border text-muted-foreground hover:text-foreground hover:border-foreground/40",
)}>
<Flag code={s.country} size={12} />
<span className="font-mono">{s.name}</span>
<TypeChip type={s.type} />
{hasIssues && !active && (
<span className="size-1.5 rounded-full bg-amber-500 shrink-0" />
)}
</button>
)
})}
</div>
{/* ── toolbar ── */}
<div className="px-6 py-3 flex items-center gap-3 border-b flex-wrap">
<div className="relative min-w-[200px] max-w-xs flex-1">
<SearchIcon className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" />
<Input className="pl-8 h-8 text-sm" placeholder="Имя, IP-адрес, фильтр…"
value={search} onChange={e => setSearch(e.target.value)} />
{search && (
<button onClick={() => setSearch("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
<XIcon className="size-3.5" />
</button>
)}
</div>
{(search || statusFilter !== "all" || serverFilter.length > 0) && (
<Button variant="ghost" size="sm" className="text-muted-foreground h-8 px-2"
onClick={() => { setSearch(""); setStatusFilter("all"); setServerFilter([]) }}>
<XIcon className="size-3.5" />Сбросить
</Button>
)}
{/* Collapse / Expand all */}
<Button variant="outline" size="sm" className="h-8 gap-1.5 ml-auto"
onClick={allCollapsed ? expandAll : collapseAll}>
{allCollapsed
? <><ChevronDownIcon className="size-3.5" />Развернуть все</>
: <><ChevronUpIcon className="size-3.5" />Свернуть все</>}
</Button>
<p className="text-xs text-muted-foreground shrink-0">
{filtered.length} из {probes.length} проб
<span className="text-muted-foreground/70 hidden md:inline"> · </span>
<span className="text-muted-foreground/80 hidden md:inline" title="Отображение на дашборде">
звезда блок «Активные пробы»
</span>
</p>
</div>
{/* ── probe groups ── */}
<div className="p-6 flex flex-col gap-4">
{grouped.length === 0 && (
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground gap-2">
<SearchIcon className="size-8 opacity-20" />
<p className="text-sm">Пробы не найдены</p>
</div>
)}
{grouped.map(({ server: srv, groups: probeGroups }) => {
const isCollapsed = collapsed.has(srv.id)
const allServerProbes = probeGroups.flatMap((g) => g.probes)
const issues = allServerProbes.filter(p => p.status !== "up").length
const downCount = allServerProbes.filter(p => p.status === "down").length
const warnCount = allServerProbes.filter(p => p.status === "warn").length
return (
<Card key={srv.id} className="overflow-hidden py-0 gap-0">
{/* server header */}
<button onClick={() => toggleCollapse(srv.id)}
className="w-full flex items-center gap-2.5 px-4 py-3 hover:bg-muted/30 transition-colors text-left border-b">
{isCollapsed
? <ChevronRightIcon className="size-4 text-muted-foreground shrink-0" />
: <ChevronDownIcon className="size-4 text-muted-foreground shrink-0" />}
<StatusDot status={srv.status} pulse={srv.status === "online"} />
<Flag code={srv.country} size={16} />
<span className="font-mono text-sm font-semibold">{srv.name}</span>
<TypeChip type={srv.type} />
<span className="text-xs text-muted-foreground">{srv.site}</span>
<div className="ml-auto flex items-center gap-3 text-xs">
{downCount > 0 && (
<span className="text-red-600 dark:text-red-400 font-medium">{downCount} down</span>
)}
{warnCount > 0 && (
<span className="text-amber-600 dark:text-amber-400 font-medium">{warnCount} warn</span>
)}
{issues === 0 && (
<span className="text-emerald-600 dark:text-emerald-400 font-medium">all ok</span>
)}
<span className="text-muted-foreground">{allServerProbes.length} проб</span>
</div>
</button>
{!isCollapsed && (
<>
<div className="divide-y divide-border/60">
{probeGroups.map((group) => {
const groupBusyKey = probeGroupActionKey(srv.id, group)
const groupBusy = !!probeGroupPingBusy[groupBusyKey]
return (
<div key={`${group.name}|${group.target}`}>
<div className="px-4 py-2 border-b bg-muted/20 flex items-center gap-2 text-xs">
<ArrowRightIcon className="size-3.5 text-muted-foreground/60 shrink-0" />
<span className="font-medium truncate min-w-0">{group.name}</span>
<span className="font-mono text-muted-foreground shrink-0">{group.target}</span>
<div className="ml-auto flex items-center gap-2 shrink-0">
<span className="text-muted-foreground whitespace-nowrap">{group.probes.length} интерф.</span>
<Button
type="button"
variant="outline"
size="sm"
className="h-7 gap-1 px-2 text-[11px]"
disabled={!liveApi || groupBusy}
title={liveApi ? "Ping по всем интерфейсам группы и запись в БД" : "Доступно в режиме Live"}
onClick={() => { void refreshProbeGroupPings(srv.id, group) }}
>
<RefreshCwIcon className={cn("size-3", groupBusy && "animate-spin")} />
Обновить
</Button>
</div>
</div>
<div className="grid items-center gap-3 px-4 py-1.5 bg-muted/30 border-b text-[10px] font-semibold uppercase tracking-widest text-muted-foreground"
style={{ gridTemplateColumns: "36px 16px 130px 120px 140px 70px 44px minmax(132px,1fr) 96px 36px 72px" }}>
<span />
<span />
<span>Интерфейс</span>
<span>Проба</span>
<span>Фильтр</span>
<span>RTT</span>
<span>Потери</span>
<span className="truncate">BT · ping</span>
<span>График</span>
<span className="text-center" title="На дашборде"></span>
<span>Действия</span>
</div>
{group.probes.map((p) => {
const linkedSp = findLinkedSpeedProbe(p, speedProbes, allServers, speedIfaces)
return (
<div key={p.id} className="border-b border-border/40 last:border-b-0">
<div
className={cn(
"grid items-center gap-3 px-4 py-2.5 hover:bg-muted/20 transition-colors",
!p.enabled && "opacity-40",
)}
style={{ gridTemplateColumns: "36px 16px 130px 120px 140px 70px 44px minmax(132px,1fr) 96px 36px 72px" }}>
<FormToggle checked={p.enabled} onChange={v => toggleProbe(p.id, v)} />
<StatusDot
status={p.status === "up" ? "online" : p.status === "warn" ? "degraded" : "offline"}
pulse={p.status === "up" && p.enabled}
/>
<span className="font-mono text-xs text-muted-foreground truncate">{p.srcInterface || "auto"}</span>
<span className="text-sm font-medium truncate">{p.name}</span>
{p.filter === "—"
? <span className="text-muted-foreground/40 text-xs"></span>
: <span className="text-[11px] font-medium text-sky-600 dark:text-sky-400 truncate">{p.filter}</span>
}
<span className={cn("font-mono text-sm font-semibold tabular-nums", rttColor(p.rtt, p.loss))}>
{p.rtt === null ? "—" : `${p.rtt}мс`}
</span>
<span className={cn("font-mono text-xs tabular-nums",
p.loss === 0 ? "text-muted-foreground" :
p.loss >= 100 ? "text-red-600 dark:text-red-400" :
p.loss > 5 ? "text-red-600 dark:text-red-400" :
"text-amber-600 dark:text-amber-400",
)}>
{p.loss}%
</span>
<div className="min-w-0 flex flex-col gap-0.5 justify-center">
{!linkedSp?.lastRunAt ? (
<span className="text-muted-foreground/35 text-[10px]"></span>
) : linkedSp.lastStatus === "error" && linkedSp.lastError ? (
<span className="text-[10px] text-[var(--status-offline-fg)] truncate" title={linkedSp.lastError}>
BT: ошибка
</span>
) : (
<>
<span className="text-[10px] font-mono tabular-nums text-muted-foreground truncate">
{linkedSp.lastTxAvgMbps != null && linkedSp.lastRxAvgMbps != null
? `${Math.round(linkedSp.lastTxAvgMbps)}/${Math.round(linkedSp.lastRxAvgMbps)} Мбит/с`
: "—"}
</span>
{linkedSp.lastPingError?.trim() ? (
<span className="text-[10px] text-[var(--status-offline-fg)] truncate" title={linkedSp.lastPingError}>
ping: сбой
</span>
) : linkedSp.lastPingRttMs != null ? (
<span className="text-[10px] font-mono tabular-nums text-violet-600 dark:text-violet-400 truncate">
ping {linkedSp.lastPingRttMs} мс
{linkedSp.lastPingLossPct != null && linkedSp.lastPingLossPct > 0 && (
<span className="text-amber-600 dark:text-amber-400"> · {linkedSp.lastPingLossPct}%</span>
)}
</span>
) : linkedSp.lastPingAt ? (
<span className="text-[10px] text-amber-600 dark:text-amber-400 truncate">
ping timeout
{linkedSp.lastPingLossPct != null && <span> · {linkedSp.lastPingLossPct}%</span>}
</span>
) : null}
</>
)}
</div>
<Sparkline data={p.series} width={96} height={24} color={probeSparkColor(p.status)} filled />
<div className="flex justify-center">
<Button
type="button"
size="sm"
variant="ghost"
className="size-7 p-0 text-muted-foreground hover:text-amber-500"
onClick={() => toggleDashboardStar(p.id)}
title={p.showOnDashboard ? "Убрать с дашборда" : "Показать на дашборде"}
aria-pressed={p.showOnDashboard === true}
>
<StarIcon
className={cn(
"size-3.5",
p.showOnDashboard
? "fill-amber-400 text-amber-500"
: "text-muted-foreground",
)}
/>
</Button>
</div>
<div className="flex justify-end gap-0.5">
<Button size="sm" variant="ghost"
className="size-7 p-0 text-muted-foreground hover:text-foreground"
onClick={() => openEditProbe(p)} title="Редактировать пробу">
<PencilIcon className="size-3.5" />
</Button>
<Button size="sm" variant="ghost"
className="size-7 p-0 text-muted-foreground hover:text-destructive"
onClick={() => deleteProbe(p.id)} title="Удалить пробу">
<TrashIcon className="size-3.5" />
</Button>
</div>
</div>
<Collapsible
open={probeRttChartOpen[p.id] ?? false}
onOpenChange={(open) => setProbeRttChartOpen((m) => ({ ...m, [p.id]: open }))}
>
<CollapsibleTrigger
className={cn(
"flex w-full items-center gap-2 border-t border-border/50 bg-muted/15 px-4 py-1.5 text-left text-xs text-muted-foreground",
"outline-none hover:bg-muted/30 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
)}
>
<ChevronDownIcon
className={cn(
"size-3.5 shrink-0 transition-transform duration-200",
probeRttChartOpen[p.id] && "rotate-180",
)}
/>
<span>
Подробный график RTT
<span className="font-mono tabular-nums text-muted-foreground/80 ml-1">
({p.series.length} точ.)
</span>
</span>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="border-t border-border/50 bg-muted/5 px-4 py-3">
<ProbePingRttDetailChart
series={p.series}
status={p.status}
probeName={p.name}
target={group.target}
/>
</div>
</CollapsibleContent>
</Collapsible>
</div>
)
})}
<button
onClick={() => {
setEditingProbeId(null)
setNewSrcId(srv.id)
setNewSrcInterface("")
setNewName(group.name); setNewTarget(group.target); setNewFilter("—")
setSheetOpen(true)
}}
className="w-full flex items-center gap-2 px-4 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/20 transition-colors border-t">
<PlusIcon className="size-3.5" />
Добавить интерфейс для {group.name} ({group.target})
</button>
</div>
)
})}
</div>
</>
)}
</Card>
)
})}
</div>
</>}
</div>
{/* ── add speed-probe sheet ── */}
<Sheet open={speedSheetOpen} onOpenChange={(v) => {
if (!v) {
setSpeedSheetOpen(false)
setEditingSpeedProbeId(null)
return
}
setSpeedSheetOpen(true)
}}>
<SheetContent side="right" className="sm:max-w-[420px] flex flex-col p-0" showCloseButton={false}>
<SheetHeader className="px-5 pt-5 pb-4 border-b shrink-0">
<div className="flex items-center justify-between gap-2">
<div>
<SheetTitle className="text-base">{editingSpeedProbeId ? "Редактирование speed-пробы" : "Новая speed-проба"}</SheetTitle>
<SheetDescription className="text-xs mt-0.5">
{editingSpeedProbeId ? "Измените параметры BT-пробы" : "BTTest между выбранными серверами"}
</SheetDescription>
</div>
<Button variant="ghost" size="icon-sm" onClick={() => setSpeedSheetOpen(false)}>
<XIcon className="size-4" />
</Button>
</div>
</SheetHeader>
<div className="flex-1 overflow-y-auto px-5 py-5 flex flex-col gap-5">
<FormField label="Источник">
<ServerPickerCards
options={selectableSources}
selectedId={speedDraft.srcServerId}
blockedId={speedDraft.dstServerId}
onSelect={(nextSrc) => {
const nextDst = speedDraft.dstServerId === nextSrc
? (selectableSources.find((s) => s.id !== nextSrc)?.id ?? nextSrc)
: speedDraft.dstServerId
setSpeedDraft((prev) => ({ ...prev, srcServerId: nextSrc, dstServerId: nextDst, srcInterface: "" }))
void loadSpeedInterfaces(nextSrc)
}}
/>
</FormField>
<FormField label="Назначение">
<ServerPickerCards
options={selectableSources.filter((s) => s.id !== speedDraft.srcServerId)}
selectedId={speedDraft.dstServerId}
onSelect={(nextDst) => {
setSpeedDraft((prev) => ({ ...prev, dstServerId: nextDst, dstInterface: "" }))
void loadSpeedInterfaces(nextDst)
}}
/>
</FormField>
<FormField label="Интерфейс источника">
<InterfacePickerCards
value={speedDraft.srcInterface}
onChange={(v) => setSpeedDraft((prev) => ({ ...prev, srcInterface: v }))}
options={filterActiveInterfaces(speedIfaces[speedDraft.srcServerId] ?? [])}
autoLabel="auto"
/>
</FormField>
<FormField label="Интерфейс назначения">
<InterfacePickerCards
value={speedDraft.dstInterface}
onChange={(v) => setSpeedDraft((prev) => ({ ...prev, dstInterface: v }))}
options={filterActiveInterfaces(speedIfaces[speedDraft.dstServerId] ?? [])}
autoLabel="auto"
/>
</FormField>
<div className="grid grid-cols-3 gap-3">
<FormField label="Протокол">
<SegmentedControl
value={speedDraft.protocol}
onChange={(v) => setSpeedDraft((prev) => ({ ...prev, protocol: v }))}
options={[
{ value: "tcp", label: "TCP" },
{ value: "udp", label: "UDP" },
]}
/>
</FormField>
<FormField label="Direction">
<SegmentedControl
value={speedDraft.direction}
onChange={(v) => setSpeedDraft((prev) => ({ ...prev, direction: v }))}
options={[
{ value: "both", label: "both" },
{ value: "transmit", label: "tx" },
{ value: "receive", label: "rx" },
]}
/>
</FormField>
<FormField label="Сек">
<Input className="h-9" value={speedDraft.durationSec} onChange={(e) => setSpeedDraft((prev) => ({ ...prev, durationSec: e.target.value }))} />
</FormField>
</div>
{speedDraft.srcServerId &&
speedDraft.dstServerId &&
speedDraft.srcServerId !== speedDraft.dstServerId &&
speedVerificationRouterOsCli && (
<div className="rounded-lg border border-border/80 bg-muted/30 p-3 space-y-2">
<div className="flex items-center justify-between gap-2">
<p className="text-xs font-medium text-muted-foreground leading-snug">
RouterOS CLI проверка адресов
</p>
<Button
type="button"
variant="outline"
size="sm"
className="h-8 shrink-0 gap-1.5 text-xs"
onClick={() => {
void navigator.clipboard.writeText(speedVerificationRouterOsCli).then(() => {
/* feedback через title */
})
}}
title="Копировать в буфер"
>
<CopyIcon className="size-3.5" />
Копировать
</Button>
</div>
<pre className="text-[10px] font-mono leading-relaxed text-foreground/90 whitespace-pre-wrap break-all max-h-[240px] overflow-y-auto rounded-md bg-background/60 border border-border/50 p-2.5">
{speedVerificationRouterOsCli}
</pre>
<p className="text-[10px] text-muted-foreground leading-snug">
Команды выполняйте на узле источника (SSH или Terminal в MikrotikManager). Если IP показываются как «?», перезагрузите список интерфейсов (смените сервер или закройте и снова откройте форму) подтянутся адреса с RouterOS.
</p>
</div>
)}
</div>
<SheetFooter className="px-5 py-4 border-t shrink-0 gap-2">
<Button variant="outline" className="flex-1" onClick={() => { setSpeedSheetOpen(false); setEditingSpeedProbeId(null) }}>Отмена</Button>
<Button className="flex-1" onClick={saveSpeedProbe} disabled={!speedDraft.srcServerId || !speedDraft.dstServerId || speedDraft.srcServerId === speedDraft.dstServerId}>
{editingSpeedProbeId ? <PencilIcon className="size-4" /> : <PlusIcon className="size-4" />}
{editingSpeedProbeId ? "Сохранить" : "Добавить"}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
{/* ── add probe sheet ── */}
<Sheet open={sheetOpen} onOpenChange={(v) => {
if (!v) {
setSheetOpen(false)
setEditingProbeId(null)
return
}
setSheetOpen(true)
}}>
<SheetContent side="right" className="sm:max-w-[420px] flex flex-col p-0" showCloseButton={false}>
<SheetHeader className="px-5 pt-5 pb-4 border-b shrink-0">
<div className="flex items-center justify-between gap-2">
<div>
<SheetTitle className="text-base">{editingProbeId ? "Редактирование пробы" : "Новая проба"}</SheetTitle>
<SheetDescription className="text-xs mt-0.5">
Ping от выбранного сервера к целевому хосту
</SheetDescription>
</div>
<Button variant="ghost" size="icon-sm" onClick={() => setSheetOpen(false)}>
<XIcon className="size-4" />
</Button>
</div>
</SheetHeader>
<div className="flex-1 overflow-y-auto px-5 py-5 flex flex-col gap-5">
{/* from → to visual */}
<div className="flex items-center gap-2 rounded-lg bg-muted/40 border px-4 py-3">
<div className="flex-1 min-w-0">
<p className="text-[10px] text-muted-foreground uppercase tracking-widest mb-0.5">Источник</p>
{newSrcId ? (() => {
const s = allServers.find(x => x.id === newSrcId)
return s ? (
<div className="flex items-center gap-1.5">
<Flag code={s.country} size={14} />
<span className="text-sm font-mono font-medium truncate">{s.name}</span>
<TypeChip type={s.type} />
</div>
) : null
})() : <span className="text-xs text-muted-foreground">не выбран</span>}
</div>
<ArrowRightIcon className="size-4 text-muted-foreground/40 shrink-0" />
<div className="flex-1 min-w-0 text-right">
<p className="text-[10px] text-muted-foreground uppercase tracking-widest mb-0.5">Цель</p>
<p className="text-sm font-mono truncate">
{newTarget || <span className="text-muted-foreground/40">0.0.0.0</span>}
</p>
</div>
</div>
<FormField
label="Источник (кто пингует)"
hint="— весь каталог, в т.ч. выключенные в инвентаре (Home Router часто «выкл.», но доступен по LAN для ping)"
>
<ServerPickerCards
options={selectableSources}
selectedId={newSrcId}
onSelect={(id) => setNewSrcId(id)}
/>
</FormField>
<FormField label="Интерфейс источника" hint="(необязательно)">
<InterfacePickerCards
value={newSrcInterface}
onChange={setNewSrcInterface}
options={filterActiveInterfaces(srcInterfaces)}
autoLabel="авто (по маршруту)"
busy={srcInterfacesBusy}
/>
</FormField>
<FormField label="Имя пробы">
<Input className="h-9 text-sm" placeholder="youtube.com"
value={newName} onChange={e => setNewName(e.target.value)} />
</FormField>
<FormField label="Целевой IP / хост">
<Input className="h-9 text-sm font-mono" placeholder="142.250.74.110"
value={newTarget} onChange={e => setNewTarget(e.target.value)} />
</FormField>
<FormField label="Связанный фильтр" hint="(необязательно)">
<LinkedFilterPickerCards
value={newFilter}
onChange={setNewFilter}
items={filters}
/>
</FormField>
</div>
<SheetFooter className="px-5 py-4 border-t shrink-0 gap-2">
<Button variant="outline" className="flex-1" onClick={() => { setSheetOpen(false); setEditingProbeId(null) }}>
Отмена
</Button>
<Button className="flex-1"
disabled={!newName.trim() || !newTarget.trim() || !newSrcId}
onClick={handleSaveProbe}>
{editingProbeId ? <PencilIcon className="size-4" /> : <PlusIcon className="size-4" />}
{editingProbeId ? "Сохранить" : "Добавить"}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
</div>
)
}