"use client" import { useState, useMemo, useEffect, useRef, useCallback } from "react" import { PageHeader } from "@/components/page-header" import { Card, CardContent } from "@/components/ui/card" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Flag } from "@/components/flag" import { StatusDot } from "@/components/status-dot" import { Sparkline } from "@/components/sparkline" import { cn } from "@/lib/utils" import { servers as mockServers, pingProbes as INIT_PROBES, filters, type Server } from "@/lib/data" import type { PingProbe } from "@/lib/data" import { useDataSource } from "@/lib/data-source" import { RefreshCwIcon, PlusIcon, SearchIcon, XIcon, ChevronDownIcon, ChevronRightIcon, TrashIcon, ArrowRightIcon, CpuIcon, HardDriveIcon, ThermometerIcon, ClockIcon, AlertCircleIcon, ServerIcon, PauseIcon, PlayIcon, ArrowUpIcon, ArrowDownIcon, ArrowUpDownIcon, ServerCrashIcon, ChevronUpIcon, DownloadIcon, PencilIcon, } from "lucide-react" import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetFooter, } from "@/components/ui/sheet" function makeApiFetch(backendUrl: string) { return async function apiFetch(path: string, init?: RequestInit): Promise { const headers: HeadersInit = init?.body ? { "Content-Type": "application/json" } : {} const res = await fetch(backendUrl.replace(/\/$/, "") + path, { ...init, headers: { ...headers, ...(init?.headers ?? {}) }, }) if (!res.ok) { let message = res.statusText try { const err = await res.json() as { error?: string } message = err.error ?? message } catch { const text = await res.text().catch(() => "") if (text) message = text } throw new Error(message) } return res.json() as Promise } } // ── 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-red-600 dark:text-red-400" if (loss > 1 || rtt > 60) return "text-amber-600 dark:text-amber-400" return "text-emerald-600 dark:text-emerald-400" } function probeSparkColor(status: PingProbe["status"]): string { return status === "down" ? "hsl(0 84% 60%)" : status === "warn" ? "hsl(32 94% 44%)" : "hsl(142 76% 36%)" } // ── shared components ────────────────────────────────────────────────────────── function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) { return ( ) } 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 ( ) } function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) { return (
{children}
) } // ── Resource monitoring types + helpers ─────────────────────────────────────── interface ServerResource { serverId: string 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 } 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[] } interface SpeedProbeRow { id: string srcServerId: string dstServerId: string srcInterface: string dstInterface: string protocol: "tcp" | "udp" direction: "transmit" | "receive" | "both" durationSec: string enabled: boolean } function fmtMB(mb: number): string { if (mb >= 1024) return `${(mb / 1024).toFixed(mb >= 10240 ? 0 : 1)} ГБ` return `${mb} МБ` } 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, } }) const DEFAULT_SPEED_PROBE: SpeedProbeRow | null = (() => { const enabled = mockServers.filter((s) => s.enabled) if (!enabled.length) return null const src = enabled[0]?.id ?? "" const dst = enabled.find((s) => s.id !== src)?.id ?? src return { id: "sp-default", srcServerId: src, dstServerId: dst, srcInterface: "", dstInterface: "", protocol: "tcp", direction: "both", durationSec: "10", enabled: 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 }: { resources: ServerResource[]; serversList: Server[] }) { const [sortKey, setSortKey] = useState("name") const [sortAsc, setSortAsc] = useState(true) const [resSearch, setResSearch] = useState("") const [typeFilter, setTypeFilter] = useState("all") const rows = useMemo(() => resources.map(r => ({ ...r, server: serversList.find(s => s.id === r.serverId), ramPct: Math.round(r.ramUsed / r.ramTotal * 100), hddPct: Math.round(r.hddUsed / r.hddTotal * 100), })).filter(r => r.server !== undefined), [resources, serversList]) // KPI aggregates const online = rows.filter(r => r.server!.status === "online") const avgCpu = online.length ? Math.round(online.reduce((s, r) => s + r.cpu, 0) / online.length) : 0 const avgRam = online.length ? Math.round(online.reduce((s, r) => s + r.ramPct, 0) / online.length) : 0 const highCpu = rows.filter(r => r.server!.status === "online" && r.cpu >= 85).length const highRam = rows.filter(r => r.server!.status === "online" && r.ramPct >= 85).length const highHdd = rows.filter(r => r.server!.status === "online" && r.hddPct >= 85).length // Alerts const alerts = useMemo(() => rows.filter(r => r.server!.status === "online" && (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 (
{/* ── 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}

))}
{/* ── Toolbar ───────────────────────────────────────────────────────── */}
{/* Type filter */}
{typeOpts.map(o => ( ))}
{/* Search */}
setResSearch(e.target.value)} /> {resSearch && ( )}
{visible.length} из {rows.length} серверов {/* Export CSV */}
{/* ── Table ─────────────────────────────────────────────────────────── */}
{/* Sortable: name */} {/* Sortable: cpu */} {/* Sortable: ram */} {/* Sortable: hdd */} {/* Sortable: uptime */} {/* Sortable: temp */} {visible.length === 0 && ( )} {visible.map(r => { const srv = r.server! const offline = srv.status !== "online" const isCrit = !offline && (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 ( {/* Server */} {/* Board + ROS */} {/* CPU */} {/* RAM */} {/* HDD */} {/* Uptime */} {/* Temp */} ) })}
toggleSort("name")}> Сервер Модель · ROS toggleSort("cpu")}> CPU toggleSort("ram")}> RAM toggleSort("hdd")}> Диск toggleSort("uptime")}> Uptime toggleSort("temp")}> °C
Ничего не найдено
{isCrit && } {!isCrit && } {srv.name} {srv.site}
{r.boardName} {srv.os}
{offline ? : (
{r.cpu}%
)}
{offline ? : (
{r.ramPct}% {fmtMB(r.ramUsed)}/{fmtMB(r.ramTotal)}
)}
{offline ? : (
{r.hddPct}% {fmtMB(r.hddUsed)}/{fmtMB(r.hddTotal)}
)}
{offline ? "—" : fmtUptime(r.uptimeSeconds)} {r.temp !== undefined && !offline ? ( = 70 ? "text-red-600 dark:text-red-400" : r.temp >= 55 ? "text-amber-600 dark:text-amber-400" : "text-muted-foreground", )}> {r.temp}°C ) : ( )}

Обновление каждые 5 сек · /system/resource via RouterOS REST API · demo-режим

) } // ── page ─────────────────────────────────────────────────────────────────────── export default function UptimePage() { const [allServers, setAllServers] = useState(mockServers) const { mode, backendUrl, backendStatus } = useDataSource() const isLive = mode === "live" && backendStatus === true const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl]) const [tab, setTab] = useState<"probes" | "resources" | "speed">("probes") const [probes, setProbes] = useState(INIT_PROBES) const [resources, setResources] = useState(INIT_RESOURCES) 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 [settingsBusy, setSettingsBusy] = useState(false) const [uptimeSettings, setUptimeSettings] = useState<{ enabled: boolean intervalSec: number retentionDays: number lastCollectedAt: string | null lastDurationMs: number | null lastError: string | null } | null>(null) const [intervalDraft, setIntervalDraft] = useState("15") const [retentionDraft, setRetentionDraft] = useState("14") const [speedBusy, setSpeedBusy] = useState(false) const [speedError, setSpeedError] = useState(null) const [speedRuns, setSpeedRuns] = useState([]) const [speedProbes, setSpeedProbes] = useState(() => (DEFAULT_SPEED_PROBE ? [DEFAULT_SPEED_PROBE] : [])) const [speedIfaces, setSpeedIfaces] = useState>>({}) const [speedCollapsed, setSpeedCollapsed] = useState>(new Set()) const [speedSheetOpen, setSpeedSheetOpen] = useState(false) const [speedDraft, setSpeedDraft] = useState({ id: "", srcServerId: "", dstServerId: "", srcInterface: "", dstInterface: "", protocol: "tcp", direction: "both", durationSec: "10", enabled: true, }) useEffect(() => { if (!isLive) { // eslint-disable-next-line react-hooks/set-state-in-effect setAllServers(mockServers) return } apiFetch("/api/servers") .then((data) => { const mapped: Server[] = 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, })) setAllServers(mapped) }) .catch(() => setAllServers([])) }, [isLive, apiFetch]) const isPausedRef = useRef(isPaused) useEffect(() => { isPausedRef.current = isPaused }, [isPaused]) const loadLiveOverview = useCallback(async () => { if (!isLive) return setOpError(null) try { const data = await apiFetch<{ probes: PingProbe[]; resources: ServerResource[] }>("/api/uptime/overview?range=1h") setProbes(data.probes) setResources(data.resources) } catch (e) { setOpError(e instanceof Error ? e.message : "Не удалось загрузить uptime") } }, [apiFetch, isLive]) const loadLiveSettings = useCallback(async () => { if (!isLive) return try { const s = await apiFetch<{ enabled: boolean intervalSec: number retentionDays: number lastCollectedAt: string | null lastDurationMs: number | null lastError: string | null }>("/api/uptime/settings") setUptimeSettings(s) setIntervalDraft(String(s.intervalSec)) setRetentionDraft(String(s.retentionDays)) } catch (e) { setOpError(e instanceof Error ? e.message : "Не удалось загрузить настройки uptime") } }, [apiFetch, isLive]) useEffect(() => { if (!isLive) { // eslint-disable-next-line react-hooks/set-state-in-effect setUptimeSettings(null) return } void loadLiveOverview() void loadLiveSettings() }, [isLive, loadLiveOverview, loadLiveSettings]) // 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) const selectableSources = useMemo( () => allServers.filter(s => s.enabled), [allServers], ) const loadSpeedInterfaces = useCallback(async (serverId: string) => { if (!isLive) 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, isLive, 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 (!isLive) { 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 = data.interfaces ?? [] setSrcInterfaces(list) setNewSrcInterface((prev) => (prev && list.some((i) => i.name === prev) ? prev : "")) }) .catch(() => { setSrcInterfaces([]) setNewSrcInterface("") }) .finally(() => setSrcInterfacesBusy(false)) }, [sheetOpen, newSrcId, isLive, 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( () => allServers.filter(s => probedServerIds.includes(s.id)), [probedServerIds, allServers], ) // live RTT tick useEffect(() => { const id = setInterval(() => { if (isLive) 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) }, [isLive]) // live resource tick useEffect(() => { const id = setInterval(() => { if (isLive) 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 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) }, [isLive, 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") 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) } return selectableSources .filter((s) => map.has(s.id)) .map((s) => ({ server: s, probes: (map.get(s.id) ?? []).sort((a, b) => (a.dstServerId + a.id).localeCompare(b.dstServerId + b.id)), })) }, [speedProbes, selectableSources]) // ── actions ── const toggleProbe = (id: string, v: boolean) => setProbes(p => p.map(x => x.id === id ? { ...x, enabled: v } : x)) const deleteProbe = (id: string) => setProbes(p => p.filter(x => x.id !== id)) 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, } if (editingProbeId) { setProbes(prev => 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, } })) } else { setProbes(p => [...p, probeBase]) } 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 dstIp = probe.dstInterface ? (speedIfaces[probe.dstServerId]?.find((i) => i.name === probe.dstInterface)?.addresses[0] ?? (dst?.host ?? "0.0.0.0")) : (dst?.host ?? "0.0.0.0") return `[${src?.name ?? "src"}] /tool bandwidth-test address=${dstIp} user=${dst?.username ?? "admin"} protocol=${probe.protocol} direction=${probe.direction} duration=${Math.max(3, Number.parseInt(probe.durationSec, 10) || 10)}s` }, [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", command: buildSpeedCommand(probe), lines: ["status: running..."], }, ...prev].slice(0, 20)) try { if (isLive) { const payload = { 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> } }>("/api/uptime/speed-test", { method: "POST", body: JSON.stringify(payload), }) const lines = (res.result.raw ?? []).flatMap((row) => Object.entries(row).map(([k, v]) => `${k}: ${String(v)}`), ) setSpeedRuns((prev) => prev.map((r) => r.id === runId ? ({ ...r, txAvgMbps: Math.round(res.result.txAvgMbps), rxAvgMbps: Math.round(res.result.rxAvgMbps), status: "done", lines: lines.length ? lines : [`tx-total-average: ${Math.round(res.result.txAvgMbps)}Mbps`, `rx-total-average: ${Math.round(res.result.rxAvgMbps)}Mbps`], }) : r)) } 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))) setSpeedRuns((prev) => prev.map((r) => r.id === runId ? ({ ...r, txAvgMbps: tx, rxAvgMbps: rx, status: "done", lines: [ "status: done testing", `tx-total-average: ${tx}Mbps`, `rx-total-average: ${rx}Mbps`, ], }) : r)) } } 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) } finally { setSpeedBusy(false) } } const updateSpeedProbe = (id: string, patch: Partial) => { setSpeedProbes((prev) => 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 })) } const openAddSpeedSheet = () => { 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 addSpeedProbe = () => { if (!speedDraft.srcServerId || !speedDraft.dstServerId) return setSpeedProbes((prev) => [{ ...speedDraft, id: `sp-${Date.now()}`, }, ...prev]) setSpeedSheetOpen(false) } const deleteSpeedProbe = (id: string) => { setSpeedProbes((prev) => prev.filter((p) => p.id !== id)) } const toggleSpeedCollapse = (serverId: string) => { setSpeedCollapsed((prev) => { const next = new Set(prev) if (next.has(serverId)) next.delete(serverId); else next.add(serverId) return next }) } useEffect(() => { if (!isLive) return const t = setTimeout(() => { void apiFetch("/api/uptime/probes", { method: "PUT", body: JSON.stringify({ probes: probes.map((p) => ({ id: p.id, srcServerId: p.srcServerId, srcInterface: p.srcInterface || "", name: p.name, target: p.target, filter: p.filter, enabled: p.enabled, })), }), }).catch(() => {}) }, 250) return () => clearTimeout(t) }, [isLive, probes, apiFetch]) // ── render ── return (
{/* Live / Pause toggle */} {tab === "probes" && ( )} {tab === "speed" && ( )} } /> {/* ── Live indicator ── */} {!isPaused && (
{isLive ? `Live (backend) · сбор каждые ${uptimeSettings?.intervalSec ?? 15}с` : "Live · проба обновляется каждые 3с · ресурсы каждые 5с"}
)} {isPaused && (
Обновление приостановлено
)} {/* ── tab switcher ── */}
{(["probes", "resources", "speed"] as const).map(t => ( ))}
{isLive && uptimeSettings && (
Сбор uptime
Интервал (сек) setIntervalDraft(e.target.value)} />
Хранение (дней) setRetentionDraft(e.target.value)} />

Последний сбор: {uptimeSettings.lastCollectedAt ? new Date(uptimeSettings.lastCollectedAt).toLocaleString("ru-RU") : "—"}

Длительность: {uptimeSettings.lastDurationMs != null ? `${uptimeSettings.lastDurationMs} мс` : "—"}

{uptimeSettings.lastError &&

Ошибка: {uptimeSettings.lastError}

}
)} {/* ── resources tab ── */} {tab === "resources" && } {/* ── speed tab ── */} {tab === "speed" && (

Speed-пробы между серверами

{speedProbes.length} проб

Создание через модалку, запуск/удаление — в строках ниже.

{speedError && (

{speedError}

)}
{speedGrouped.length === 0 && ( Нет speed-проб. Нажми `Новая speed-проба`. )} {speedGrouped.map(({ server, probes: srvProbes }) => { const isCollapsed = speedCollapsed.has(server.id) return ( {!isCollapsed && ( <>
Назначение If src If dst Proto Dir Sec Команда Действия
{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, ) return (
updateSpeedProbe(probe.id, { enabled: v })} />

{dst?.name ?? probe.dstServerId}

{run && (

{run.status === "running" ? "running..." : `TX ${run.txAvgMbps} / RX ${run.rxAvgMbps} Mbps`}

)}
{probe.srcInterface || "auto"} {probe.dstInterface || "auto"} {probe.protocol.toUpperCase()} {probe.direction} {probe.durationSec}s {buildSpeedCommand(probe)}
) })}
)}
) })}
{speedRuns.length === 0 && ( )} {speedRuns.map((run) => { const src = allServers.find((s) => s.id === run.srcServerId) const dst = allServers.find((s) => s.id === run.dstServerId) return ( ) })}
Время Путь Параметры TX avg RX avg
Запусти первый BTTest
{new Date(run.startedAt).toLocaleString("ru-RU")} {src?.name ?? run.srcServerId} → {dst?.name ?? run.dstServerId} {run.protocol.toUpperCase()} · {run.direction} · {run.durationSec}s {run.txAvgMbps} Mbps {run.rxAvgMbps} Mbps
)} {/* ── 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) => (
{group.name} {group.target} {group.probes.length} интерф.
Интерфейс Проба Фильтр RTT Потери График Действия
{group.probes.map((p) => (
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}%
))}
))}
)}
) })}
}
{/* ── add speed-probe sheet ── */} setSpeedSheetOpen(v)}>
Новая speed-проба BTTest между выбранными серверами
setSpeedDraft((prev) => ({ ...prev, durationSec: e.target.value }))} />
{/* ── add probe sheet ── */} { if (!v) { setSheetOpen(false) setEditingProbeId(null) } }}>
{editingProbeId ? "Редактирование пробы" : "Новая проба"} Ping от выбранного сервера к целевому хосту
{/* from → to visual */}

Источник

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

Цель

{newTarget || 0.0.0.0}

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