1988 lines
89 KiB
TypeScript
1988 lines
89 KiB
TypeScript
"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>
|
||
)
|
||
}
|