Files
MikrotikManager/app/(main)/uptime/page.tsx
T
2026-05-02 01:17:08 +07:00

1988 lines
89 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 { 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<T>(path: string, init?: RequestInit): Promise<T> {
const headers: HeadersInit = init?.body ? { "Content-Type": "application/json" } : {}
const res = await fetch(backendUrl.replace(/\/$/, "") + path, {
...init,
headers: { ...headers, ...(init?.headers ?? {}) },
})
if (!res.ok) {
let message = res.statusText
try {
const err = await res.json() as { error?: string }
message = err.error ?? message
} catch {
const text = await res.text().catch(() => "")
if (text) message = text
}
throw new Error(message)
}
return res.json() as Promise<T>
}
}
// ── 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 (
<button type="button" onClick={() => onChange(!checked)}
className={cn(
"relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors cursor-pointer",
checked ? "bg-primary" : "bg-input",
)}>
<span className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm transition-transform",
checked ? "translate-x-4" : "translate-x-0",
)} />
</button>
)
}
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-emerald-600 dark:text-emerald-400" :
!active && color === "amber" ? "text-amber-600 dark:text-amber-400" :
!active && color === "red" ? "text-red-600 dark:text-red-400" : "",
)}>{value}</span>
</button>
)
}
function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
return (
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium">
{label}
{hint && <span className="font-normal text-muted-foreground ml-1">{hint}</span>}
</label>
{children}
</div>
)
}
// ── 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<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,
}
})
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 (
<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 }: { resources: ServerResource[]; serversList: Server[] }) {
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 => ({
...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 (
<div className="p-6 flex flex-col gap-4">
{/* ── Alert banner ──────────────────────────────────────────────────── */}
{alerts.length > 0 && (
<div className="flex items-start gap-3 rounded-lg border border-red-500/30 bg-red-500/5 px-4 py-3">
<ServerCrashIcon className="size-4 text-red-500 shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<p className="text-xs font-semibold text-red-600 dark:text-red-400 mb-1">
{alerts.length} {alerts.length === 1 ? "сервер требует внимания" : "сервера требуют внимания"}
</p>
<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-red-500/30 bg-red-500/10 px-2 py-0.5 text-red-400">
<Flag code={s.country} size={10} />
{s.name} {issues.join(", ")}
</span>
)
})}
</div>
</div>
</div>
)}
{/* ── 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 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 (
<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>
</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">{r.boardName}</span>
<span className="text-[10px] text-muted-foreground/50">{srv.os}</span>
</div>
</td>
{/* CPU */}
<td className="px-4 py-3">
{offline
? <span className="text-xs text-muted-foreground/30"></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">
{offline
? <span className="text-xs text-muted-foreground/30"></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">
{offline
? <span className="text-xs text-muted-foreground/30"></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">
{offline ? "—" : fmtUptime(r.uptimeSeconds)}
</span>
</td>
{/* Temp */}
<td className="px-4 py-3">
{r.temp !== undefined && !offline ? (
<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">
Обновление каждые 5 сек · /system/resource via RouterOS REST API · demo-режим
</p>
</div>
)
}
// ── page ───────────────────────────────────────────────────────────────────────
export default function UptimePage() {
const [allServers, setAllServers] = useState<Server[]>(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<PingProbe[]>(INIT_PROBES)
const [resources, setResources] = useState<ServerResource[]>(INIT_RESOURCES)
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 [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<string | null>(null)
const [speedRuns, setSpeedRuns] = useState<SpeedTestRun[]>([])
const [speedProbes, setSpeedProbes] = useState<SpeedProbeRow[]>(() => (DEFAULT_SPEED_PROBE ? [DEFAULT_SPEED_PROBE] : []))
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 [speedDraft, setSpeedDraft] = useState<SpeedProbeRow>({
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<BackendServer[]>("/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<string | null>(null)
const [srcInterfaces, setSrcInterfaces] = useState<Array<{ name: string; running: boolean; disabled: boolean }>>([])
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<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)
}
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<Record<string, string>> } }>("/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<SpeedProbeRow>) => {
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 (
<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-amber-500/50 text-amber-600 dark:text-amber-400")}
>
{isPaused
? <><PlayIcon className="size-4" />Возобновить</>
: <><PauseIcon className="size-4" />Пауза</>}
</Button>
<Button variant="outline" size="sm" onClick={() => {
if (isLive) {
setSettingsBusy(true)
void apiFetch("/api/uptime/collect-now", { method: "POST" })
.then(() => Promise.all([loadLiveOverview(), loadLiveSettings()]))
.catch((e) => setOpError(e instanceof Error ? e.message : "Не удалось выполнить сбор"))
.finally(() => setSettingsBusy(false))
return
}
setProbes(INIT_PROBES)
setResources(INIT_RESOURCES)
}}>
<RefreshCwIcon className={cn("size-4", settingsBusy && "animate-spin")} />{isLive ? "Собрать сейчас" : "Сбросить"}
</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 bg-emerald-500/5 text-[11px] text-emerald-600 dark:text-emerald-400 shrink-0">
<span className="size-1.5 rounded-full bg-emerald-500 animate-pulse" />
{isLive
? `Live (backend) · сбор каждые ${uptimeSettings?.intervalSec ?? 15}с`
: "Live · проба обновляется каждые 3с · ресурсы каждые 5с"}
</div>
)}
{isPaused && (
<div className="flex items-center gap-2 px-6 py-1.5 border-b bg-amber-500/5 text-[11px] text-amber-600 dark:text-amber-400 shrink-0">
<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">{speedRuns.length}</span>
</span>
)}
</button>
))}
</div>
<div className="flex-1 overflow-y-auto">
{isLive && uptimeSettings && (
<div className="px-6 pt-4">
<Card>
<CardContent className="pt-4 pb-4 px-4">
<div className="flex flex-wrap items-end gap-3">
<div className="flex flex-col gap-1">
<span className="text-[11px] text-muted-foreground">Сбор uptime</span>
<div className="flex rounded-md border border-input overflow-hidden h-8">
<button
className={cn("px-3 text-xs", uptimeSettings.enabled ? "bg-emerald-600 text-white" : "text-muted-foreground hover:bg-muted")}
onClick={async () => {
setSettingsBusy(true)
try {
await apiFetch("/api/uptime/settings", { method: "PUT", body: JSON.stringify({ enabled: true }) })
await loadLiveSettings()
} finally { setSettingsBusy(false) }
}}
disabled={settingsBusy}
>Вкл</button>
<button
className={cn("px-3 text-xs border-l border-input", !uptimeSettings.enabled ? "bg-muted-foreground text-white" : "text-muted-foreground hover:bg-muted")}
onClick={async () => {
setSettingsBusy(true)
try {
await apiFetch("/api/uptime/settings", { method: "PUT", body: JSON.stringify({ enabled: false }) })
await loadLiveSettings()
} finally { setSettingsBusy(false) }
}}
disabled={settingsBusy}
>Выкл</button>
</div>
</div>
<div className="flex flex-col gap-1">
<span className="text-[11px] text-muted-foreground">Интервал (сек)</span>
<Input className="h-8 w-24 text-sm" value={intervalDraft} onChange={(e) => setIntervalDraft(e.target.value)} />
</div>
<div className="flex flex-col gap-1">
<span className="text-[11px] text-muted-foreground">Хранение (дней)</span>
<Input className="h-8 w-24 text-sm" value={retentionDraft} onChange={(e) => setRetentionDraft(e.target.value)} />
</div>
<Button
variant="outline"
size="sm"
className="h-8"
disabled={settingsBusy}
onClick={async () => {
setSettingsBusy(true)
try {
await apiFetch("/api/uptime/settings", {
method: "PUT",
body: JSON.stringify({
intervalSec: Number.parseInt(intervalDraft, 10) || 15,
retentionDays: Number.parseInt(retentionDraft, 10) || 14,
}),
})
await loadLiveSettings()
} catch (e) {
setOpError(e instanceof Error ? e.message : "Не удалось сохранить настройки")
} finally {
setSettingsBusy(false)
}
}}
>Сохранить</Button>
<div className="ml-auto text-xs text-muted-foreground">
<p>Последний сбор: {uptimeSettings.lastCollectedAt ? new Date(uptimeSettings.lastCollectedAt).toLocaleString("ru-RU") : "—"}</p>
<p>Длительность: {uptimeSettings.lastDurationMs != null ? `${uptimeSettings.lastDurationMs} мс` : "—"}</p>
{uptimeSettings.lastError && <p className="text-destructive">Ошибка: {uptimeSettings.lastError}</p>}
</div>
</div>
</CardContent>
</Card>
</div>
)}
{/* ── resources tab ── */}
{tab === "resources" && <ResourcesTab resources={resources} serversList={allServers} />}
{/* ── speed tab ── */}
{tab === "speed" && (
<div className="p-6 flex flex-col gap-4">
<Card>
<CardContent className="pt-4 pb-4 px-4">
<div className="flex items-center justify-between gap-3 mb-3">
<p className="text-sm font-medium">Speed-пробы между серверами</p>
<p className="text-xs text-muted-foreground">{speedProbes.length} проб</p>
</div>
<p className="text-xs text-muted-foreground">Создание через модалку, запуск/удаление в строках ниже.</p>
{speedError && (
<p className="mt-3 text-xs text-destructive">{speedError}</p>
)}
</CardContent>
</Card>
{speedGrouped.length === 0 && (
<Card>
<CardContent className="py-10 text-center text-muted-foreground text-sm">
Нет speed-проб. Нажми `Новая speed-проба`.
</CardContent>
</Card>
)}
{speedGrouped.map(({ server, probes: srvProbes }) => {
const isCollapsed = speedCollapsed.has(server.id)
return (
<Card key={server.id} className="overflow-hidden py-0 gap-0">
<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">{srvProbes.length} speed-проб</span>
</button>
{!isCollapsed && (
<>
<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 1fr 120px 120px 70px 70px 60px 1fr 130px" }}>
<span />
<span>Назначение</span>
<span>If src</span>
<span>If dst</span>
<span>Proto</span>
<span>Dir</span>
<span>Sec</span>
<span>Команда</span>
<span>Действия</span>
</div>
<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,
)
return (
<div key={probe.id}
className={cn("grid items-center gap-3 px-4 py-2.5 hover:bg-muted/20 transition-colors", !probe.enabled && "opacity-40")}
style={{ gridTemplateColumns: "36px 1fr 120px 120px 70px 70px 60px 1fr 130px" }}>
<Toggle checked={probe.enabled} onChange={(v) => updateSpeedProbe(probe.id, { enabled: v })} />
<div className="min-w-0">
<p className="text-sm font-medium truncate">{dst?.name ?? probe.dstServerId}</p>
{run && (
<p className={cn(
"text-[11px] font-mono",
run.status === "error" ? "text-red-500" : run.status === "running" ? "text-amber-500" : "text-emerald-600 dark:text-emerald-400",
)}>
{run.status === "running" ? "running..." : `TX ${run.txAvgMbps} / RX ${run.rxAvgMbps} Mbps`}
</p>
)}
</div>
<span className="font-mono text-xs text-muted-foreground truncate">{probe.srcInterface || "auto"}</span>
<span className="font-mono text-xs text-muted-foreground truncate">{probe.dstInterface || "auto"}</span>
<span className="font-mono text-xs">{probe.protocol.toUpperCase()}</span>
<span className="font-mono text-xs">{probe.direction}</span>
<span className="font-mono text-xs">{probe.durationSec}s</span>
<span className="font-mono text-[10px] text-muted-foreground truncate">{buildSpeedCommand(probe)}</span>
<div className="flex items-center gap-1">
<Button size="sm" className="h-8"
disabled={speedBusy || !probe.enabled || !probe.srcServerId || !probe.dstServerId || probe.srcServerId === probe.dstServerId}
onClick={() => void runSpeedTest(probe)}>
<RefreshCwIcon className={cn("size-3.5", speedBusy && "animate-spin")} />
</Button>
<Button size="sm" variant="ghost" className="h-8 text-destructive" onClick={() => deleteSpeedProbe(probe.id)}>
<TrashIcon className="size-3.5" />
</Button>
</div>
</div>
)
})}
</div>
</>
)}
</Card>
)
})}
<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">
<th className="px-4 py-2 text-left">Время</th>
<th className="px-4 py-2 text-left">Путь</th>
<th className="px-4 py-2 text-left">Параметры</th>
<th className="px-4 py-2 text-left">TX avg</th>
<th className="px-4 py-2 text-left">RX avg</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{speedRuns.length === 0 && (
<tr>
<td colSpan={5} className="px-4 py-10 text-center text-muted-foreground">Запусти первый BTTest</td>
</tr>
)}
{speedRuns.map((run) => {
const src = allServers.find((s) => s.id === run.srcServerId)
const dst = allServers.find((s) => s.id === run.dstServerId)
return (
<tr key={run.id} className="hover:bg-muted/20">
<td className="px-4 py-2 text-xs text-muted-foreground">{new Date(run.startedAt).toLocaleString("ru-RU")}</td>
<td className="px-4 py-2 font-mono">{src?.name ?? run.srcServerId} {dst?.name ?? run.dstServerId}</td>
<td className="px-4 py-2 text-xs text-muted-foreground">{run.protocol.toUpperCase()} · {run.direction} · {run.durationSec}s</td>
<td className="px-4 py-2 font-mono text-emerald-600 dark:text-emerald-400">{run.txAvgMbps} Mbps</td>
<td className="px-4 py-2 font-mono text-sky-600 dark:text-sky-400">{run.rxAvgMbps} Mbps</td>
</tr>
)
})}
</tbody>
</table>
</div>
</Card>
</div>
)}
{/* ── probes tab ── */}
{tab === "probes" && <>
{opError && (
<div className="px-6 pt-4">
<div className="text-xs text-destructive bg-destructive/10 border border-destructive/20 rounded-md px-3 py-2">
{opError}
</div>
</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} проб
</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) => (
<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" />
<span className="font-medium truncate">{group.name}</span>
<span className="font-mono text-muted-foreground">{group.target}</span>
<span className="ml-auto text-muted-foreground">{group.probes.length} интерф.</span>
</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 96px 72px" }}>
<span />
<span />
<span>Интерфейс</span>
<span>Проба</span>
<span>Фильтр</span>
<span>RTT</span>
<span>Потери</span>
<span>График</span>
<span>Действия</span>
</div>
{group.probes.map((p) => (
<div key={p.id}
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 96px 72px" }}>
<Toggle 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>
<Sparkline data={p.series} width={96} height={24} color={probeSparkColor(p.status)} filled />
<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>
))}
<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) => setSpeedSheetOpen(v)}>
<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">Новая speed-проба</SheetTitle>
<SheetDescription className="text-xs mt-0.5">
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">
<Field label="Источник">
<select
className="w-full text-sm bg-background border border-input rounded-md px-3 h-9 focus:outline-none focus:ring-1 focus:ring-ring"
value={speedDraft.srcServerId}
onChange={(e) => {
const nextSrc = e.target.value
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)
}}>
{selectableSources.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
</Field>
<Field label="Назначение">
<select
className="w-full text-sm bg-background border border-input rounded-md px-3 h-9 focus:outline-none focus:ring-1 focus:ring-ring"
value={speedDraft.dstServerId}
onChange={(e) => {
const nextDst = e.target.value
setSpeedDraft((prev) => ({ ...prev, dstServerId: nextDst, dstInterface: "" }))
void loadSpeedInterfaces(nextDst)
}}>
{selectableSources.filter((s) => s.id !== speedDraft.srcServerId).map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
</Field>
<Field label="Интерфейс источника">
<select
className="w-full text-sm bg-background border border-input rounded-md px-3 h-9 focus:outline-none focus:ring-1 focus:ring-ring"
value={speedDraft.srcInterface}
onFocus={() => { void loadSpeedInterfaces(speedDraft.srcServerId) }}
onChange={(e) => setSpeedDraft((prev) => ({ ...prev, srcInterface: e.target.value }))}>
<option value="">auto</option>
{(speedIfaces[speedDraft.srcServerId] ?? []).map((i) => <option key={i.name} value={i.name}>{i.name}</option>)}
</select>
</Field>
<Field label="Интерфейс назначения">
<select
className="w-full text-sm bg-background border border-input rounded-md px-3 h-9 focus:outline-none focus:ring-1 focus:ring-ring"
value={speedDraft.dstInterface}
onFocus={() => { void loadSpeedInterfaces(speedDraft.dstServerId) }}
onChange={(e) => setSpeedDraft((prev) => ({ ...prev, dstInterface: e.target.value }))}>
<option value="">auto</option>
{(speedIfaces[speedDraft.dstServerId] ?? []).map((i) => <option key={i.name} value={i.name}>{i.name}</option>)}
</select>
</Field>
<div className="grid grid-cols-3 gap-3">
<Field label="Протокол">
<select className="w-full text-sm bg-background border border-input rounded-md px-3 h-9"
value={speedDraft.protocol}
onChange={(e) => setSpeedDraft((prev) => ({ ...prev, protocol: e.target.value as "tcp" | "udp" }))}>
<option value="tcp">TCP</option>
<option value="udp">UDP</option>
</select>
</Field>
<Field label="Direction">
<select className="w-full text-sm bg-background border border-input rounded-md px-3 h-9"
value={speedDraft.direction}
onChange={(e) => setSpeedDraft((prev) => ({ ...prev, direction: e.target.value as "transmit" | "receive" | "both" }))}>
<option value="both">both</option>
<option value="transmit">tx</option>
<option value="receive">rx</option>
</select>
</Field>
<Field label="Сек">
<Input className="h-9" value={speedDraft.durationSec} onChange={(e) => setSpeedDraft((prev) => ({ ...prev, durationSec: e.target.value }))} />
</Field>
</div>
</div>
<SheetFooter className="px-5 py-4 border-t shrink-0 gap-2">
<Button variant="outline" className="flex-1" onClick={() => setSpeedSheetOpen(false)}>Отмена</Button>
<Button className="flex-1" onClick={addSpeedProbe} disabled={!speedDraft.srcServerId || !speedDraft.dstServerId || speedDraft.srcServerId === speedDraft.dstServerId}>
<PlusIcon className="size-4" />Добавить
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
{/* ── add probe sheet ── */}
<Sheet open={sheetOpen} onOpenChange={v => {
if (!v) {
setSheetOpen(false)
setEditingProbeId(null)
}
}}>
<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>
<Field label="Источник (кто пингует)">
<select
className="w-full text-sm bg-background border border-input rounded-md px-3 h-9 focus:outline-none focus:ring-1 focus:ring-ring"
value={newSrcId}
onChange={e => setNewSrcId(e.target.value)}>
<optgroup label="Jump Host">
{allServers.filter(s => s.type === "jump-host" && s.enabled).map(s => (
<option key={s.id} value={s.id}>{s.name} · {s.site}</option>
))}
</optgroup>
<optgroup label="Exit Node">
{allServers.filter(s => s.type === "exit-node" && s.enabled).map(s => (
<option key={s.id} value={s.id}>{s.name} · {s.site}</option>
))}
</optgroup>
<optgroup label="Home Router">
{allServers.filter(s => s.type === "home-router" && s.enabled).map(s => (
<option key={s.id} value={s.id}>{s.name} · {s.site}</option>
))}
</optgroup>
</select>
</Field>
<Field label="Интерфейс источника" hint="(необязательно)">
<select
className="w-full text-sm bg-background border border-input rounded-md px-3 h-9 focus:outline-none focus:ring-1 focus:ring-ring"
value={newSrcInterface}
onChange={e => setNewSrcInterface(e.target.value)}
disabled={srcInterfacesBusy || (!srcInterfaces.length && isLive)}
>
<option value=""> авто (по маршруту) </option>
{srcInterfaces.map((iface) => (
<option key={iface.name} value={iface.name}>
{iface.name}{iface.disabled ? " · disabled" : iface.running ? " · running" : " · !running"}
</option>
))}
</select>
</Field>
<Field label="Имя пробы">
<Input className="h-9 text-sm" placeholder="youtube.com"
value={newName} onChange={e => setNewName(e.target.value)} />
</Field>
<Field label="Целевой IP / хост">
<Input className="h-9 text-sm font-mono" placeholder="142.250.74.110"
value={newTarget} onChange={e => setNewTarget(e.target.value)} />
</Field>
<Field label="Связанный фильтр" hint="(необязательно)">
<select
className="w-full text-sm bg-background border border-input rounded-md px-3 h-9 focus:outline-none focus:ring-1 focus:ring-ring"
value={newFilter}
onChange={e => setNewFilter(e.target.value)}>
<option value="—"> нет </option>
{filters.map(f => (
<option key={f.id} value={f.name}>{f.name}</option>
))}
</select>
</Field>
</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}>
<PlusIcon className="size-4" />{editingProbeId ? "Сохранить" : "Добавить"}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
</div>
)
}