Files
MikrotikManager/app/(main)/uptime/page.tsx
T

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