"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 { DataPageCard } from "@/components/data-page-card" import { DataPageToolbar } from "@/components/data-page-toolbar" import { UptimeResourcesDataGrid, type UptimeResourceRow, } from "@/components/data-grids/uptime-resources-data-grid" import { UptimeSpeedHistoryDataGrid } from "@/components/data-grids/uptime-speed-history-data-grid" 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 { 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) { 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(path: string, init?: RequestInit): Promise { return requestJson(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 (

Нет числовых точек RTT для графика (проба «{probeName}» → {target}).

) } 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 (

{probeName} {target}

Ось X: старые замеры слева → новые справа · обзор ~1 ч

{gridVals.map((g, i) => { const y = pad.t + ih * (1 - g) return ( {fmt(y0 + span * g)} ) })} {[0, Math.floor((chartPts.length - 1) / 2), chartPts.length - 1] .filter((i, idx, a) => a.indexOf(i) === idx) .map((i) => ( {i === chartPts.length - 1 ? "сейчас" : i === 0 ? "раньше" : "·"} ))}
min {Math.round(minVal)} мс max {Math.round(maxVal)} мс {valid.length < 2 && ( В ряду одна точка — линия для наглядности продублирована. )}
) } 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 ( {type === "jump-host" ? "JH" : type === "home-router" ? "HR" : "EN"} ) } function StatChip({ label, value, color, active, onClick, }: { label: string; value: number; color?: "emerald" | "amber" | "red"; active?: boolean; onClick?: () => void }) { return ( ) } /** Активный интерфейс RouterOS: не disabled и running */ function isActiveRouterOsInterface(i: { running?: boolean; disabled?: boolean }): boolean { return i.running === true && i.disabled !== true } function filterActiveInterfaces(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 (
setQuery(e.target.value)} placeholder="Поиск по имени, хосту, сайту…" className="h-8 pl-8 pr-8 text-sm" aria-label="Поиск сервера" /> {query ? ( ) : null}
{filtered.length === 0 ? (

Ничего не найдено

) : ( filtered.map((server) => { const isSelected = server.id === selectedId const isBlocked = blockedId === server.id return ( ) }) )}
) } 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 (
setQuery(e.target.value)} placeholder="Поиск по имени или IP…" className="h-8 pl-8 pr-8 text-sm font-mono" disabled={effectiveDisabled} aria-label="Поиск интерфейса" /> {query ? ( ) : null}
{filtered.length === 0 ? (

Ничего не найдено

) : ( filtered.map((iface) => { const selected = value === iface.name const addrPreview = (iface.addresses ?? [])[0] return ( ) }) )}
) } 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 (
setQuery(e.target.value)} placeholder="Поиск по имени, community, gateway…" className="h-8 pl-8 pr-8 text-sm" aria-label="Поиск фильтра" /> {query ? ( ) : null}
{filtered.length === 0 ? ( query.trim() ? (

Ничего не найдено

) : null ) : ( filtered.map((f) => { const selected = value === f.name return ( ) }) )}
) } // ── 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 { 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>, ): 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 = { "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 (
) } // ── 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 return sortAsc ? : } // ── ResourcesTab ────────────────────────────────────────────────────────────── type ResTypeFilter = "all" | "jump-host" | "exit-node" | "home-router" function ResourcesTab({ resources, serversList, liveApi }: { resources: ServerResource[]; serversList: Server[]; liveApi?: boolean }) { const [resSearch, setResSearch] = useState("") const [typeFilter, setTypeFilter] = useState("all") const rows = useMemo((): UptimeResourceRow[] => 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 => serversList.some(s => s.id === r.serverId)), [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) ) } return list }, [rows, typeFilter, resSearch]) 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 (
{/* ── Alert banner ──────────────────────────────────────────────────── */} {alerts.length > 0 && ( {alerts.length} {alerts.length === 1 ? "сервер требует внимания" : "сервера требуют внимания"}
{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 ( {s.name} — {issues.join(", ")} ) })}
)} {/* ── KPI summary ───────────────────────────────────────────────────── */}
{[ { icon: , label: String(rows.length), sub: "серверов всего", color: "text-foreground" }, { icon: , label: `${avgCpu}%`, sub: "средний CPU", color: resPctColor(avgCpu) }, { icon: , label: `${avgRam}%`, sub: "средний RAM", color: resPctColor(avgRam) }, { icon: , label: String(highCpu), sub: "CPU > 85%", color: highCpu > 0 ? "text-red-500" : "text-muted-foreground" }, { icon: , label: String(highRam), sub: "RAM > 85%", color: highRam > 0 ? "text-red-500" : "text-muted-foreground" }, { icon: , label: String(highHdd), sub: "Диск > 85%", color: highHdd > 0 ? "text-amber-500" : "text-muted-foreground" }, ].map(kpi => (
{kpi.icon}

{kpi.label}

{kpi.sub}

))}
{/* ── Table ─────────────────────────────────────────────────────────── */} CSV } />

{liveApi ? "Автообновление каждые 5 сек (и кнопка «Обновить») · /system/resource via RouterOS REST API · backend" : "Обновление каждые 5 сек · /system/resource via RouterOS REST API · demo-режим"}

) } // ── 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([]) const [tab, setTab] = useState<"probes" | "resources" | "speed">("probes") const [probes, setProbes] = useState([]) const [resources, setResources] = useState([]) const [isPaused, setIsPaused] = useState(false) const [search, setSearch] = useState("") const [statusFilter, setStatusFilter] = useState<"all" | "up" | "warn" | "down">("all") const [serverFilter, setServerFilter] = useState([]) const [collapsed, setCollapsed] = useState>(new Set()) const [sheetOpen, setSheetOpen] = useState(false) const [opError, setOpError] = useState(null) const [uptimeRefreshBusy, setUptimeRefreshBusy] = useState(false) /** Ключ — probeGroupActionKey: ручной ping группы «сервер + назначение». */ const [probeGroupPingBusy, setProbeGroupPingBusy] = useState>({}) /** Раскрытый подробный график RTT по id пробы */ const [probeRttChartOpen, setProbeRttChartOpen] = useState>({}) const [speedBusy, setSpeedBusy] = useState(false) const [speedError, setSpeedError] = useState(null) const [speedRuns, setSpeedRuns] = useState([]) const [speedProbes, setSpeedProbes] = useState([]) const [speedIfaces, setSpeedIfaces] = useState>>({}) const [speedCollapsed, setSpeedCollapsed] = useState>(new Set()) const [speedSheetOpen, setSpeedSheetOpen] = useState(false) const [editingSpeedProbeId, setEditingSpeedProbeId] = useState(null) const [speedDraft, setSpeedDraft] = useState({ 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("/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(null) const [srcInterfaces, setSrcInterfaces] = useState>([]) 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>() 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() 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= 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> 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) => { 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 (
{/* Live / Pause toggle */} {tab === "probes" && ( )} {tab === "speed" && ( )} } /> {/* ── Live indicator ── */} {!isPaused && (
{liveApi ? (backendStatus === false ? "Live: /health не ответил — проверьте URL в настройках; запросы к API выполняются" : "Live (backend)") : "Демо-режим · пробы/ресурсы локальные; «Обновить» сбрасывает макет"}
)} {isPaused && (
Обновление приостановлено
)} {/* ── tab switcher ── */}
{(["probes", "resources", "speed"] as const).map(t => ( ))}
{/* ── resources tab ── */} {tab === "resources" && } {/* ── speed tab ── */} {tab === "speed" && (
{/* ── 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 (
{[ { 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 => (

{k.label}

{k.value} {k.unit && {k.unit}}
{runningCnt > 0 && k.label === "Тестов выполнено" && (

{runningCnt} выполняется

)}
))}
) })()} {/* ── error ── */} {speedError && (
{speedError}
)}
{/* ── empty state ── */} {speedGrouped.length === 0 && (

Нет speed-проб

Создайте пробу через кнопку «Новая speed-проба» в шапке страницы

)} {/* ── probe groups ── */} {speedGrouped.map(({ server, probes: srvProbes }) => { const isCollapsed = speedCollapsed.has(server.id) return ( {/* server header */} {!isCollapsed && (
{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 (
{/* enable toggle */} updateSpeedProbe(probe.id, { enabled: v })} /> {/* route: src → dst */}
{server.name} {dst?.name ?? probe.dstServerId}
{/* param chips */}
{probe.protocol.toUpperCase()} {probe.direction === "both" ? "↕" : probe.direction === "transmit" ? "↑" : "↓"} {probe.direction} {probe.durationSec}s {(probe.srcInterface || probe.dstInterface) && ( {probe.srcInterface || "auto"} → {probe.dstInterface || "auto"} )}
{/* actions */}
{/* result row */} {viewStatus && (
{viewStatus === "running" ? (

Тест выполняется…

) : viewStatus === "error" ? (

{probe.lastError || "Ошибка выполнения теста"}

) : (
{[ { 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 => (
{r.label}
0 ? 3 : 0)}%` }} />
{r.val} Мбит/с
))}
{viewAfterBtPing && ( viewAfterBtPing.error ? (

Ping после BT: {viewAfterBtPing.error}

) : (
Ping
{viewAfterBtPing.rttMs == null ? "timeout" : `${viewAfterBtPing.rttMs} мс`}
) )}
)}
)}
) })}
)} ) })} {/* ── history ── */} {speedRuns.length > 0 && (

История тестов

{speedRuns.length} запусков
)}
)} {/* ── probes tab ── */} {tab === "probes" && <> {opError && (
{opError}
)} {/* ── summary + server filter ── */}
cycleStatusFilter("up")} /> cycleStatusFilter("warn")} /> cycleStatusFilter("down")} />
{/* 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 ( ) })}
{/* ── toolbar ── */}
setSearch(e.target.value)} /> {search && ( )}
{(search || statusFilter !== "all" || serverFilter.length > 0) && ( )} {/* Collapse / Expand all */}

{filtered.length} из {probes.length} проб · звезда — блок «Активные пробы»

{/* ── probe groups ── */}
{grouped.length === 0 && (

Пробы не найдены

)} {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 ( {/* server header */} {!isCollapsed && ( <>
{probeGroups.map((group) => { const groupBusyKey = probeGroupActionKey(srv.id, group) const groupBusy = !!probeGroupPingBusy[groupBusyKey] return (
{group.name} {group.target}
{group.probes.length} интерф.
Интерфейс Проба Фильтр RTT Потери BT · ping График Действия
{group.probes.map((p) => { const linkedSp = findLinkedSpeedProbe(p, speedProbes, allServers, speedIfaces) return (
toggleProbe(p.id, v)} /> {p.srcInterface || "auto"} {p.name} {p.filter === "—" ? : {p.filter} } {p.rtt === null ? "—" : `${p.rtt}мс`} = 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}%
{!linkedSp?.lastRunAt ? ( ) : linkedSp.lastStatus === "error" && linkedSp.lastError ? ( BT: ошибка ) : ( <> {linkedSp.lastTxAvgMbps != null && linkedSp.lastRxAvgMbps != null ? `${Math.round(linkedSp.lastTxAvgMbps)}/${Math.round(linkedSp.lastRxAvgMbps)} Мбит/с` : "—"} {linkedSp.lastPingError?.trim() ? ( ping: сбой ) : linkedSp.lastPingRttMs != null ? ( ping {linkedSp.lastPingRttMs} мс {linkedSp.lastPingLossPct != null && linkedSp.lastPingLossPct > 0 && ( · {linkedSp.lastPingLossPct}% )} ) : linkedSp.lastPingAt ? ( ping timeout {linkedSp.lastPingLossPct != null && · {linkedSp.lastPingLossPct}%} ) : null} )}
setProbeRttChartOpen((m) => ({ ...m, [p.id]: open }))} > Подробный график RTT ({p.series.length} точ.)
) })}
) })}
)}
) })}
}
{/* ── add speed-probe sheet ── */} { if (!v) { setSpeedSheetOpen(false) setEditingSpeedProbeId(null) return } setSpeedSheetOpen(true) }}>
{editingSpeedProbeId ? "Редактирование speed-пробы" : "Новая speed-проба"} {editingSpeedProbeId ? "Измените параметры BT-пробы" : "BTTest между выбранными серверами"}
{ 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) }} /> s.id !== speedDraft.srcServerId)} selectedId={speedDraft.dstServerId} onSelect={(nextDst) => { setSpeedDraft((prev) => ({ ...prev, dstServerId: nextDst, dstInterface: "" })) void loadSpeedInterfaces(nextDst) }} /> setSpeedDraft((prev) => ({ ...prev, srcInterface: v }))} options={filterActiveInterfaces(speedIfaces[speedDraft.srcServerId] ?? [])} autoLabel="auto" /> setSpeedDraft((prev) => ({ ...prev, dstInterface: v }))} options={filterActiveInterfaces(speedIfaces[speedDraft.dstServerId] ?? [])} autoLabel="auto" />
setSpeedDraft((prev) => ({ ...prev, protocol: v }))} options={[ { value: "tcp", label: "TCP" }, { value: "udp", label: "UDP" }, ]} /> setSpeedDraft((prev) => ({ ...prev, direction: v }))} options={[ { value: "both", label: "both" }, { value: "transmit", label: "tx" }, { value: "receive", label: "rx" }, ]} /> setSpeedDraft((prev) => ({ ...prev, durationSec: e.target.value }))} />
{speedDraft.srcServerId && speedDraft.dstServerId && speedDraft.srcServerId !== speedDraft.dstServerId && speedVerificationRouterOsCli && (

RouterOS CLI — проверка адресов

                  {speedVerificationRouterOsCli}
                

Команды выполняйте на узле источника (SSH или Terminal в MikrotikManager). Если IP показываются как «?», перезагрузите список интерфейсов (смените сервер или закройте и снова откройте форму) — подтянутся адреса с RouterOS.

)}
{/* ── add probe sheet ── */} { if (!v) { setSheetOpen(false) setEditingProbeId(null) return } setSheetOpen(true) }}>
{editingProbeId ? "Редактирование пробы" : "Новая проба"} Ping от выбранного сервера к целевому хосту
{/* from → to visual */}

Источник

{newSrcId ? (() => { const s = allServers.find(x => x.id === newSrcId) return s ? (
{s.name}
) : null })() : не выбран}

Цель

{newTarget || 0.0.0.0}

setNewSrcId(id)} /> setNewName(e.target.value)} /> setNewTarget(e.target.value)} />
) }