Docker images / prepare-release (push) Successful in 4s
Docker images / backend-image (push) Failing after 2m36s
Docker images / frontend-image (push) Successful in 2m22s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 55s
Docker images / publish-release (push) Skipped
Подключить App Switcher, NavUser, OpsPanel и AlertDialog вместо Card-shell. Co-authored-by: Cursor <cursoragent@cursor.com>
2857 lines
126 KiB
TypeScript
2857 lines
126 KiB
TypeScript
"use client"
|
||
|
||
import { useState, useMemo, useEffect, useRef, useCallback } from "react"
|
||
import { PageHeader } from "@/components/page-header"
|
||
import { FormField, FormToggle, SegmentedControl } from "@/components/form-kit"
|
||
import { Frame, FramePanel } from "@/components/reui/frame"
|
||
import { IconTile } from "@/components/reui/icon-tile"
|
||
import { Button } from "@/components/ui/button"
|
||
import { Input } from "@/components/ui/input"
|
||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||
import { Flag } from "@/components/flag"
|
||
import { StatusDot } from "@/components/status-dot"
|
||
import { Sparkline } from "@/components/sparkline"
|
||
import { DataPageCard } from "@/components/data-page-card"
|
||
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
||
import {
|
||
UptimeResourcesDataGrid,
|
||
type UptimeResourceRow,
|
||
} from "@/components/data-grids/uptime-resources-data-grid"
|
||
import { UptimeSpeedHistoryDataGrid } from "@/components/data-grids/uptime-speed-history-data-grid"
|
||
import { PING_PROBE_WARN_RTT_MS } from "@/lib/ping-probe"
|
||
import { cn } from "@/lib/utils"
|
||
import { servers as mockServers, pingProbes as INIT_PROBES, filters, type Server, type Filter } from "@/lib/data"
|
||
import type { PingProbe } from "@/lib/data"
|
||
import { useDataSource } from "@/lib/data-source"
|
||
import { requestJson } from "@/shared/api/http-client"
|
||
import {
|
||
RefreshCwIcon, PlusIcon, SearchIcon, XIcon,
|
||
ChevronDownIcon, ChevronRightIcon,
|
||
TrashIcon, ArrowRightIcon,
|
||
CpuIcon, HardDriveIcon, ThermometerIcon, ClockIcon,
|
||
AlertCircleIcon, ServerIcon, PauseIcon, PlayIcon,
|
||
ArrowUpIcon, ArrowDownIcon, ArrowUpDownIcon,
|
||
ServerCrashIcon, ChevronUpIcon, DownloadIcon,
|
||
PencilIcon,
|
||
StarIcon,
|
||
CopyIcon,
|
||
TagIcon,
|
||
CheckIcon,
|
||
} from "lucide-react"
|
||
import {
|
||
Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetFooter,
|
||
} from "@/components/ui/sheet"
|
||
import {
|
||
Collapsible,
|
||
CollapsibleContent,
|
||
CollapsibleTrigger,
|
||
} from "@/components/ui/collapsible"
|
||
|
||
/** Звёздочка «на дашборде» для mock — общий ключ с `dashboard/page.tsx` */
|
||
const MOCK_DASH_STARS_LS = "mm:dashboard-probe-ids"
|
||
/** Сообщаем дашборду (та же вкладка) об изменении отметок / данных проб */
|
||
const UPTIME_PROBES_CHANGED = "mm:uptime-probes-changed"
|
||
|
||
function readMockDashboardStarIds(): Set<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 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
|
||
rxAvgMbps: number
|
||
status: "running" | "done" | "error"
|
||
command: string
|
||
lines: string[]
|
||
afterBtPing?: { rttMs: number | null; lossPct: number | null; error: string | null } | null
|
||
srcAddress?: string | null
|
||
dstAddress?: string | null
|
||
srcInterfaceAddress?: string | null
|
||
dstInterfaceAddress?: string | null
|
||
}
|
||
|
||
interface SpeedProbeRow {
|
||
id: string
|
||
srcServerId: string
|
||
dstServerId: string
|
||
srcInterface: string
|
||
dstInterface: string
|
||
protocol: "tcp" | "udp"
|
||
direction: "transmit" | "receive" | "both"
|
||
durationSec: string
|
||
enabled: boolean
|
||
lastRunAt?: string | null
|
||
lastTxAvgMbps?: number | null
|
||
lastRxAvgMbps?: number | null
|
||
lastStatus?: "done" | "error" | null
|
||
lastError?: string | null
|
||
lastPingRttMs?: number | null
|
||
lastPingLossPct?: number | null
|
||
lastPingAt?: string | null
|
||
lastPingError?: string | null
|
||
}
|
||
|
||
/** Сервер есть в БД speed-проб, но удалён из каталога — показываем группу без ломания списка */
|
||
function orphanSpeedSourceStub(id: string): Server {
|
||
return {
|
||
id,
|
||
name: `Нет в каталоге (#${id})`,
|
||
host: "—",
|
||
model: "—",
|
||
os: "—",
|
||
site: "—",
|
||
country: "UN",
|
||
asn: "",
|
||
type: "home-router",
|
||
enabled: false,
|
||
status: "offline",
|
||
latency: null,
|
||
sessions: 0,
|
||
}
|
||
}
|
||
|
||
function stripIpCidr(addr: string): string {
|
||
const t = addr.trim()
|
||
if (!t) return ""
|
||
return t.includes("/") ? (t.split("/")[0] ?? "").trim() : t
|
||
}
|
||
|
||
function resolveSpeedProbeDstHost(
|
||
sp: SpeedProbeRow,
|
||
servers: Server[],
|
||
ifaces: Record<string, 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 ResourcesTab({ resources, serversList, liveApi }: { resources: ServerResource[]; serversList: Server[]; liveApi?: boolean }) {
|
||
const [resSearch, setResSearch] = useState("")
|
||
const [typeFilter, setTypeFilter] = useState<ResTypeFilter>("all")
|
||
|
||
const rows = useMemo((): UptimeResourceRow[] => resources.map((r) => {
|
||
const hasData = r.hasData !== false
|
||
const ramPct = hasData && r.ramTotal > 0 ? Math.round(r.ramUsed / r.ramTotal * 100) : 0
|
||
const hddPct = hasData && r.hddTotal > 0 ? Math.round(r.hddUsed / r.hddTotal * 100) : 0
|
||
return {
|
||
...r,
|
||
hasData,
|
||
server: serversList.find(s => s.id === r.serverId)!,
|
||
ramPct,
|
||
hddPct,
|
||
}
|
||
}).filter(r => serversList.some(s => s.id === r.serverId)), [resources, serversList])
|
||
|
||
// KPI aggregates (только серверы с реальными сэмплами за окно)
|
||
const onlineWithSamples = rows.filter(r => r.server.status === "online" && r.hasData)
|
||
const avgCpu = onlineWithSamples.length ? Math.round(onlineWithSamples.reduce((s, r) => s + r.cpu, 0) / onlineWithSamples.length) : 0
|
||
const avgRam = onlineWithSamples.length ? Math.round(onlineWithSamples.reduce((s, r) => s + r.ramPct, 0) / onlineWithSamples.length) : 0
|
||
const highCpu = rows.filter(r => r.server.status === "online" && r.hasData && r.cpu >= 85).length
|
||
const highRam = rows.filter(r => r.server.status === "online" && r.hasData && r.ramPct >= 85).length
|
||
const highHdd = rows.filter(r => r.server.status === "online" && r.hasData && r.hddPct >= 85).length
|
||
|
||
// Alerts
|
||
const alerts = useMemo(() =>
|
||
rows.filter(r => r.server.status === "online" && r.hasData && (r.cpu >= 85 || r.ramPct >= 85 || r.hddPct >= 85 || (r.temp ?? 0) >= 70)),
|
||
[rows],
|
||
)
|
||
|
||
// Filtered + sorted
|
||
const visible = useMemo(() => {
|
||
let list = rows
|
||
if (typeFilter !== "all") list = list.filter(r => r.server.type === typeFilter)
|
||
if (resSearch.trim()) {
|
||
const q = resSearch.toLowerCase()
|
||
list = list.filter(r =>
|
||
r.server.name.toLowerCase().includes(q) ||
|
||
r.server.site.toLowerCase().includes(q) ||
|
||
r.boardName.toLowerCase().includes(q)
|
||
)
|
||
}
|
||
return list
|
||
}, [rows, typeFilter, resSearch])
|
||
|
||
function exportCsv() {
|
||
const header = ["Сервер", "Тип", "Площадка", "CPU %", "RAM %", "RAM использ.", "RAM всего", "HDD %", "HDD использ.", "HDD всего", "Uptime", "Температура °C", "RouterOS"]
|
||
const rowsCsv = visible.map(r => {
|
||
const s = r.server
|
||
return [s.name, s.type, s.site, r.cpu, r.ramPct, fmtMB(r.ramUsed), fmtMB(r.ramTotal),
|
||
r.hddPct, fmtMB(r.hddUsed), fmtMB(r.hddTotal), fmtUptime(r.uptimeSeconds),
|
||
r.temp ?? "", s.os].join(",")
|
||
})
|
||
const csv = [header.join(","), ...rowsCsv].join("\n")
|
||
const blob = new Blob([csv], { type: "text/csv;charset=utf-8" })
|
||
const url = URL.createObjectURL(blob)
|
||
const a = document.createElement("a")
|
||
a.href = url; a.download = `resources-${new Date().toISOString().slice(0,10)}.csv`; a.click()
|
||
URL.revokeObjectURL(url)
|
||
}
|
||
|
||
const typeOpts: { value: ResTypeFilter; label: string }[] = [
|
||
{ value: "all", label: "Все" },
|
||
{ value: "jump-host", label: "JumpHost" },
|
||
{ value: "exit-node", label: "Exit Node" },
|
||
{ value: "home-router", label: "Home Router" },
|
||
]
|
||
|
||
return (
|
||
<div className="p-6 flex flex-col gap-4">
|
||
|
||
{/* ── Alert banner ──────────────────────────────────────────────────── */}
|
||
{alerts.length > 0 && (
|
||
<Alert variant="destructive">
|
||
<ServerCrashIcon />
|
||
<AlertTitle className="text-xs mb-1">
|
||
{alerts.length} {alerts.length === 1 ? "сервер требует внимания" : "сервера требуют внимания"}
|
||
</AlertTitle>
|
||
<AlertDescription>
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{alerts.map(r => {
|
||
const s = r.server
|
||
const issues: string[] = []
|
||
if (r.cpu >= 85) issues.push(`CPU ${r.cpu}%`)
|
||
if (r.ramPct >= 85) issues.push(`RAM ${r.ramPct}%`)
|
||
if (r.hddPct >= 85) issues.push(`HDD ${r.hddPct}%`)
|
||
if ((r.temp ?? 0) >= 70) issues.push(`${r.temp}°C`)
|
||
return (
|
||
<span key={r.serverId} className="inline-flex items-center gap-1 text-[11px] font-mono rounded border border-destructive/30 bg-destructive/10 px-2 py-0.5">
|
||
<Flag code={s.country} size={10} />
|
||
{s.name} — {issues.join(", ")}
|
||
</span>
|
||
)
|
||
})}
|
||
</div>
|
||
</AlertDescription>
|
||
</Alert>
|
||
)}
|
||
|
||
{/* ── KPI summary ───────────────────────────────────────────────────── */}
|
||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
|
||
{[
|
||
{ icon: <ServerIcon className="size-4 text-muted-foreground" />, label: String(rows.length), sub: "серверов всего", color: "text-foreground" },
|
||
{ icon: <CpuIcon className="size-4" />, label: `${avgCpu}%`, sub: "средний CPU", color: resPctColor(avgCpu) },
|
||
{ icon: <HardDriveIcon className="size-4" />, label: `${avgRam}%`, sub: "средний RAM", color: resPctColor(avgRam) },
|
||
{ icon: <AlertCircleIcon className="size-4" />, label: String(highCpu), sub: "CPU > 85%", color: highCpu > 0 ? "text-red-500" : "text-muted-foreground" },
|
||
{ icon: <AlertCircleIcon className="size-4" />, label: String(highRam), sub: "RAM > 85%", color: highRam > 0 ? "text-red-500" : "text-muted-foreground" },
|
||
{ icon: <AlertCircleIcon className="size-4" />, label: String(highHdd), sub: "Диск > 85%", color: highHdd > 0 ? "text-amber-500" : "text-muted-foreground" },
|
||
].map(kpi => (
|
||
<Frame key={kpi.sub} className="h-full">
|
||
<FramePanel className="relative isolate flex h-full items-start gap-3">
|
||
<IconTile variant="elevated" aria-hidden="true" className={cn("size-10.5", kpi.color)}>
|
||
{kpi.icon}
|
||
</IconTile>
|
||
<div className="min-w-0 flex flex-col gap-0.5">
|
||
<p className={cn("text-xl leading-none font-bold tabular-nums", kpi.color)}>{kpi.label}</p>
|
||
<p className="text-[11px] text-muted-foreground">{kpi.sub}</p>
|
||
</div>
|
||
</FramePanel>
|
||
</Frame>
|
||
))}
|
||
</div>
|
||
|
||
{/* ── Table ─────────────────────────────────────────────────────────── */}
|
||
<DataPageCard>
|
||
<DataPageToolbar
|
||
segmented={{
|
||
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 ───────────────────────────────────────────────────────────────────────
|
||
|
||
export default function UptimePage() {
|
||
const { mode, backendUrl, backendStatus, checkBackend } = useDataSource()
|
||
/** При mode=live всегда ходим на backend. Нельзя требовать backendStatus===true: до ответа /health там undefined — иначе обзор/«Обновить» молчат. */
|
||
const liveApi = mode === "live"
|
||
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
||
|
||
const [allServers, setAllServers] = useState<Server[]>([])
|
||
const [tab, setTab] = useState<"probes" | "resources" | "speed">("probes")
|
||
const [probes, setProbes] = useState<PingProbe[]>([])
|
||
const [resources, setResources] = useState<ServerResource[]>([])
|
||
const [isPaused, setIsPaused] = useState(false)
|
||
const [search, setSearch] = useState("")
|
||
const [statusFilter, setStatusFilter] = useState<"all" | "up" | "warn" | "down">("all")
|
||
const [serverFilter, setServerFilter] = useState<string[]>([])
|
||
const [collapsed, setCollapsed] = useState<Set<string>>(new Set())
|
||
const [sheetOpen, setSheetOpen] = useState(false)
|
||
const [opError, setOpError] = useState<string | null>(null)
|
||
const [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 [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)
|
||
}, [liveApi])
|
||
|
||
const isPausedRef = useRef(isPaused)
|
||
useEffect(() => { isPausedRef.current = isPaused }, [isPaused])
|
||
|
||
/** Сбрасывает ответы устаревших GET /overview (гонка: ответ приходит после клика по ★ и затирает showOnDashboard). */
|
||
const overviewReqRef = useRef(0)
|
||
|
||
const loadLiveOverview = useCallback(async () => {
|
||
if (!liveApi) return
|
||
const myReq = ++overviewReqRef.current
|
||
setOpError(null)
|
||
try {
|
||
const [serverRows, data] = await Promise.all([
|
||
apiFetch<BackendServer[]>("/api/servers"),
|
||
apiFetch<{ probes: PingProbe[]; resources: ServerResource[] }>("/api/uptime/overview?range=1h"),
|
||
])
|
||
if (myReq !== overviewReqRef.current) return
|
||
setAllServers(mapBackendServersToServers(serverRows))
|
||
setProbes(data.probes)
|
||
setResources(data.resources)
|
||
} catch (e) {
|
||
if (myReq !== overviewReqRef.current) return
|
||
setOpError(e instanceof Error ? e.message : "Не удалось загрузить uptime")
|
||
}
|
||
}, [apiFetch, liveApi])
|
||
|
||
const reloadSpeedData = useCallback(async () => {
|
||
if (!liveApi) return
|
||
type RunRow = {
|
||
id: string
|
||
srcServerId: string
|
||
dstServerId: string
|
||
srcInterface: string
|
||
dstInterface: string
|
||
protocol: "tcp" | "udp"
|
||
direction: "transmit" | "receive" | "both"
|
||
durationSec: number
|
||
txAvgMbps: number
|
||
rxAvgMbps: number
|
||
status: "done" | "error"
|
||
error?: string | null
|
||
srcAddress?: string | null
|
||
dstAddress?: string | null
|
||
srcInterfaceAddress?: string | null
|
||
dstInterfaceAddress?: string | null
|
||
afterBtPing?: { rttMs: number | null; lossPct: number | null; error: string | null } | null
|
||
createdAt: string
|
||
}
|
||
try {
|
||
const [sp, runsRes] = await Promise.all([
|
||
apiFetch<{ probes: SpeedProbeRow[] }>("/api/uptime/speed-probes"),
|
||
apiFetch<{ runs: RunRow[] }>("/api/uptime/speed-test/runs"),
|
||
])
|
||
setSpeedProbes(sp.probes ?? [])
|
||
const rows = (runsRes.runs ?? []).map((r) => ({
|
||
id: r.id,
|
||
startedAt: Date.parse(r.createdAt),
|
||
srcServerId: r.srcServerId,
|
||
dstServerId: r.dstServerId,
|
||
srcInterface: r.srcInterface || undefined,
|
||
dstInterface: r.dstInterface || undefined,
|
||
protocol: r.protocol,
|
||
direction: r.direction,
|
||
durationSec: r.durationSec,
|
||
txAvgMbps: Math.round(r.txAvgMbps ?? 0),
|
||
rxAvgMbps: Math.round(r.rxAvgMbps ?? 0),
|
||
status: (r.status === "error" ? "error" : "done") as "error" | "done",
|
||
command: "",
|
||
lines: r.error ? [`status: error`, r.error] : [],
|
||
afterBtPing: r.afterBtPing ?? null,
|
||
srcAddress: r.srcAddress ?? null,
|
||
dstAddress: r.dstAddress ?? null,
|
||
srcInterfaceAddress: r.srcInterfaceAddress ?? null,
|
||
dstInterfaceAddress: r.dstInterfaceAddress ?? null,
|
||
}))
|
||
setSpeedRuns(rows)
|
||
} catch {
|
||
setSpeedProbes([])
|
||
setSpeedRuns([])
|
||
}
|
||
}, [apiFetch, liveApi])
|
||
|
||
const refreshUptimeLive = useCallback(async (opts?: { showSpinner?: boolean; pollDevices?: boolean }) => {
|
||
if (!liveApi) return
|
||
if (opts?.showSpinner) setUptimeRefreshBusy(true)
|
||
let collectErr: string | null = null
|
||
try {
|
||
void checkBackend()
|
||
if (opts?.pollDevices) {
|
||
try {
|
||
await apiFetch<{ ok: boolean; lastError?: string | null }>("/api/uptime/collect-now", { method: "POST" })
|
||
} catch (e) {
|
||
collectErr = e instanceof Error ? e.message : "Не удалось опросить устройства (collect-now)"
|
||
}
|
||
}
|
||
await Promise.all([loadLiveOverview(), reloadSpeedData()])
|
||
if (collectErr) {
|
||
setOpError((prev) => (prev ? `${prev} · ${collectErr}` : collectErr))
|
||
}
|
||
} finally {
|
||
if (opts?.showSpinner) setUptimeRefreshBusy(false)
|
||
}
|
||
}, [liveApi, loadLiveOverview, reloadSpeedData, checkBackend, apiFetch])
|
||
|
||
const refreshProbeGroupPings = useCallback(
|
||
async (srvId: string, group: { name: string; target: string; probes: PingProbe[] }) => {
|
||
if (!liveApi) return
|
||
const key = probeGroupActionKey(srvId, group)
|
||
setProbeGroupPingBusy((m) => ({ ...m, [key]: true }))
|
||
setOpError(null)
|
||
try {
|
||
await apiFetch<{ ok: boolean; polled: number }>("/api/uptime/probes/collect-group", {
|
||
method: "POST",
|
||
body: JSON.stringify({ probeIds: group.probes.map((p) => p.id) }),
|
||
})
|
||
await loadLiveOverview()
|
||
} catch (e) {
|
||
setOpError(e instanceof Error ? e.message : "Не удалось выполнить ping группы")
|
||
} finally {
|
||
setProbeGroupPingBusy((m) => {
|
||
const next = { ...m }
|
||
delete next[key]
|
||
return next
|
||
})
|
||
}
|
||
},
|
||
[liveApi, apiFetch, loadLiveOverview],
|
||
)
|
||
|
||
useEffect(() => {
|
||
if (!liveApi) return
|
||
queueMicrotask(() => { void refreshUptimeLive() })
|
||
}, [liveApi, refreshUptimeLive])
|
||
|
||
useEffect(() => {
|
||
if (!liveApi || isPaused) return
|
||
const id = setInterval(() => { void refreshUptimeLive() }, 5_000)
|
||
return () => clearInterval(id)
|
||
}, [liveApi, isPaused, refreshUptimeLive])
|
||
|
||
// add-probe form
|
||
const [newSrcId, setNewSrcId] = useState("")
|
||
const [newSrcInterface, setNewSrcInterface] = useState("")
|
||
const [newName, setNewName] = useState("")
|
||
const [newTarget, setNewTarget] = useState("")
|
||
const [newFilter, setNewFilter] = useState("—")
|
||
const [editingProbeId, setEditingProbeId] = useState<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 || speedIfaces[serverId]) return
|
||
const id = Number.parseInt(serverId, 10)
|
||
if (!Number.isFinite(id)) return
|
||
try {
|
||
const res = await apiFetch<{ interfaces: Array<{ name: string; running: boolean; disabled: boolean; addresses: string[] }> }>(`/api/uptime/speed-test/endpoints/${id}/interfaces`)
|
||
setSpeedIfaces((prev) => ({ ...prev, [serverId]: res.interfaces ?? [] }))
|
||
} catch {
|
||
setSpeedIfaces((prev) => ({ ...prev, [serverId]: [] }))
|
||
}
|
||
}, [apiFetch, liveApi, speedIfaces])
|
||
|
||
/** После загрузки списков интерфейсов сбросить выбор, если интерфейс не активен или отсутствует в списке */
|
||
useEffect(() => {
|
||
if (!speedSheetOpen) return
|
||
setSpeedDraft((prev) => {
|
||
const rawSrc = speedIfaces[prev.srcServerId]
|
||
const rawDst = speedIfaces[prev.dstServerId]
|
||
let srcInterface = prev.srcInterface
|
||
let dstInterface = prev.dstInterface
|
||
if (rawSrc !== undefined) {
|
||
const active = filterActiveInterfaces(rawSrc)
|
||
if (srcInterface && !active.some((i) => i.name === srcInterface)) srcInterface = ""
|
||
}
|
||
if (rawDst !== undefined) {
|
||
const active = filterActiveInterfaces(rawDst)
|
||
if (dstInterface && !active.some((i) => i.name === dstInterface)) dstInterface = ""
|
||
}
|
||
if (srcInterface === prev.srcInterface && dstInterface === prev.dstInterface) return prev
|
||
return { ...prev, srcInterface, dstInterface }
|
||
})
|
||
}, [speedSheetOpen, speedIfaces])
|
||
|
||
useEffect(() => {
|
||
if (!sheetOpen) return
|
||
if (!newSrcId || !selectableSources.some(s => s.id === newSrcId)) {
|
||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||
setNewSrcId(selectableSources[0]?.id ?? "")
|
||
}
|
||
}, [sheetOpen, newSrcId, selectableSources])
|
||
|
||
useEffect(() => {
|
||
if (!sheetOpen || !newSrcId) {
|
||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||
setSrcInterfaces([])
|
||
setNewSrcInterface("")
|
||
return
|
||
}
|
||
if (!liveApi) {
|
||
setSrcInterfaces([])
|
||
setNewSrcInterface("")
|
||
return
|
||
}
|
||
const serverId = Number.parseInt(newSrcId, 10)
|
||
if (!Number.isFinite(serverId)) {
|
||
setSrcInterfaces([])
|
||
setNewSrcInterface("")
|
||
return
|
||
}
|
||
setSrcInterfacesBusy(true)
|
||
void apiFetch<{ interfaces: Array<{ name: string; running: boolean; disabled: boolean }> }>(`/api/uptime/sources/${serverId}/interfaces`)
|
||
.then((data) => {
|
||
const list = filterActiveInterfaces(data.interfaces ?? [])
|
||
setSrcInterfaces(list)
|
||
setNewSrcInterface((prev) => (prev && list.some((i) => i.name === prev) ? prev : ""))
|
||
})
|
||
.catch(() => {
|
||
setSrcInterfaces([])
|
||
setNewSrcInterface("")
|
||
})
|
||
.finally(() => setSrcInterfacesBusy(false))
|
||
}, [sheetOpen, newSrcId, liveApi, apiFetch])
|
||
|
||
// servers that have at least one probe (preserve data-order)
|
||
const probedServerIds = useMemo(
|
||
() => [...new Set(probes.map(p => p.srcServerId))],
|
||
[probes],
|
||
)
|
||
const probedServers = useMemo(() => {
|
||
const inCatalog = allServers.filter((s) => probedServerIds.includes(s.id))
|
||
const orphanIds = probedServerIds.filter((id) => !inCatalog.some((s) => s.id === id))
|
||
return [...inCatalog, ...orphanIds.map(orphanSpeedSourceStub)]
|
||
}, [probedServerIds, allServers])
|
||
|
||
// live RTT tick
|
||
useEffect(() => {
|
||
const id = setInterval(() => {
|
||
if (liveApi) return
|
||
if (isPausedRef.current) return
|
||
setProbes(prev => prev.map(p => {
|
||
if (!p.enabled || p.status === "down" || p.rtt === null) return p
|
||
const newRtt = jitter(p.rtt, 0.12)
|
||
const newSeries = [...p.series.slice(1), newRtt]
|
||
return { ...p, rtt: newRtt, series: newSeries }
|
||
}))
|
||
}, 3000)
|
||
return () => clearInterval(id)
|
||
}, [liveApi])
|
||
|
||
// live resource tick
|
||
useEffect(() => {
|
||
const id = setInterval(() => {
|
||
if (liveApi) return
|
||
if (isPausedRef.current) return
|
||
setResources(prev => prev.map(r => {
|
||
const srv = allServers.find(s => s.id === r.serverId)
|
||
if (!srv || srv.status !== "online") return r
|
||
if (r.hasData === false) return r
|
||
const newCpu = Math.min(99, Math.max(1, r.cpu + Math.round((Math.random() - 0.48) * 8)))
|
||
const newRam = Math.min(r.ramTotal - 64, Math.max(256, r.ramUsed + Math.round((Math.random() - 0.5) * 128)))
|
||
const newTemp = r.temp !== undefined
|
||
? Math.min(90, Math.max(28, r.temp + Math.round((Math.random() - 0.5) * 3)))
|
||
: undefined
|
||
return {
|
||
...r,
|
||
cpu: newCpu,
|
||
cpuHistory: [...r.cpuHistory.slice(1), newCpu],
|
||
ramUsed: newRam,
|
||
uptimeSeconds: r.uptimeSeconds + 5,
|
||
temp: newTemp,
|
||
}
|
||
}))
|
||
}, 5000)
|
||
return () => clearInterval(id)
|
||
}, [liveApi, allServers])
|
||
|
||
// ── derived ──
|
||
const stats = useMemo(() => ({
|
||
total: probes.length,
|
||
up: probes.filter(p => p.status === "up").length,
|
||
warn: probes.filter(p => p.status === "warn").length,
|
||
down: probes.filter(p => p.status === "down").length,
|
||
}), [probes])
|
||
|
||
const alertCount = useMemo(() =>
|
||
resources.filter(r => {
|
||
const s = allServers.find(x => x.id === r.serverId)
|
||
if (!s || s.status !== "online" || r.hasData === false) return false
|
||
const ramPct = Math.round(r.ramUsed / r.ramTotal * 100)
|
||
const hddPct = Math.round(r.hddUsed / r.hddTotal * 100)
|
||
return r.cpu >= 85 || ramPct >= 85 || hddPct >= 85 || (r.temp ?? 0) >= 70
|
||
}).length,
|
||
[resources, allServers],
|
||
)
|
||
|
||
const filtered = useMemo(() => probes.filter(p => {
|
||
if (serverFilter.length > 0 && !serverFilter.includes(p.srcServerId)) return false
|
||
if (statusFilter !== "all" && p.status !== statusFilter) return false
|
||
if (search) {
|
||
const q = search.toLowerCase()
|
||
return (
|
||
p.name.toLowerCase().includes(q) ||
|
||
p.target.toLowerCase().includes(q) ||
|
||
p.filter.toLowerCase().includes(q)
|
||
)
|
||
}
|
||
return true
|
||
}), [probes, serverFilter, statusFilter, search])
|
||
|
||
const grouped = useMemo(() => {
|
||
const byServer = new Map<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((rows: PingProbe[]) => {
|
||
if (!liveApi) return
|
||
void apiFetch("/api/uptime/probes", {
|
||
method: "PUT",
|
||
body: JSON.stringify({
|
||
probes: rows.map((p) => ({
|
||
id: p.id,
|
||
srcServerId: p.srcServerId,
|
||
srcInterface: p.srcInterface || "",
|
||
name: p.name,
|
||
target: p.target,
|
||
filter: p.filter,
|
||
enabled: p.enabled,
|
||
showOnDashboard: p.showOnDashboard === true,
|
||
})),
|
||
}),
|
||
}).catch(() => {})
|
||
}, [apiFetch, liveApi])
|
||
|
||
const toggleDashboardStar = useCallback((id: string) => {
|
||
const cur = probes.find((p) => p.id === id)
|
||
if (!cur) return
|
||
const nextVal = !cur.showOnDashboard
|
||
if (liveApi) {
|
||
overviewReqRef.current += 1
|
||
}
|
||
setProbes((prev) => prev.map((p) => (p.id === id ? { ...p, showOnDashboard: nextVal } : p)))
|
||
if (liveApi) {
|
||
void apiFetch(`/api/uptime/probes/${encodeURIComponent(id)}`, {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ showOnDashboard: nextVal }),
|
||
})
|
||
.then(() => loadLiveOverview())
|
||
.then(() => {
|
||
if (typeof window !== "undefined") {
|
||
window.dispatchEvent(new Event(UPTIME_PROBES_CHANGED))
|
||
}
|
||
})
|
||
.catch(() => {
|
||
setProbes((prev) => prev.map((p) => (p.id === id ? { ...p, showOnDashboard: cur.showOnDashboard } : p)))
|
||
})
|
||
} else {
|
||
const s = readMockDashboardStarIds()
|
||
if (nextVal) s.add(id)
|
||
else s.delete(id)
|
||
writeMockDashboardStarIds(s)
|
||
if (typeof window !== "undefined") {
|
||
window.dispatchEvent(new Event(UPTIME_PROBES_CHANGED))
|
||
}
|
||
}
|
||
}, [probes, liveApi, apiFetch, loadLiveOverview])
|
||
|
||
const persistSpeedProbes = useCallback((rows: SpeedProbeRow[]) => {
|
||
if (!liveApi) return
|
||
void apiFetch("/api/uptime/speed-probes", {
|
||
method: "PUT",
|
||
body: JSON.stringify({
|
||
probes: rows.map((p) => ({
|
||
id: p.id,
|
||
srcServerId: p.srcServerId,
|
||
dstServerId: p.dstServerId,
|
||
srcInterface: p.srcInterface || "",
|
||
dstInterface: p.dstInterface || "",
|
||
protocol: p.protocol,
|
||
direction: p.direction,
|
||
durationSec: Number.parseInt(p.durationSec, 10) || 10,
|
||
enabled: p.enabled,
|
||
lastRunAt: p.lastRunAt ?? null,
|
||
lastTxAvgMbps: p.lastTxAvgMbps ?? null,
|
||
lastRxAvgMbps: p.lastRxAvgMbps ?? null,
|
||
lastStatus: p.lastStatus ?? null,
|
||
lastError: p.lastError ?? null,
|
||
})),
|
||
}),
|
||
}).catch(() => {})
|
||
}, [apiFetch, liveApi])
|
||
|
||
// ── actions ──
|
||
const toggleProbe = (id: string, v: boolean) =>
|
||
setProbes((prev) => {
|
||
const next = prev.map((x) => x.id === id ? { ...x, enabled: v } : x)
|
||
persistProbes(next)
|
||
return next
|
||
})
|
||
|
||
const deleteProbe = (id: string) => {
|
||
if (!liveApi) {
|
||
const s = readMockDashboardStarIds()
|
||
s.delete(id)
|
||
writeMockDashboardStarIds(s)
|
||
}
|
||
setProbes((prev) => {
|
||
const next = prev.filter((x) => x.id !== id)
|
||
persistProbes(next)
|
||
return next
|
||
})
|
||
}
|
||
|
||
const toggleCollapse = (serverId: string) =>
|
||
setCollapsed(prev => {
|
||
const next = new Set(prev)
|
||
if (next.has(serverId)) next.delete(serverId); else next.add(serverId)
|
||
return next
|
||
})
|
||
|
||
const collapseAll = useCallback(() =>
|
||
setCollapsed(new Set(grouped.map(g => g.server.id))), [grouped])
|
||
|
||
const expandAll = useCallback(() =>
|
||
setCollapsed(new Set()), [])
|
||
|
||
const allCollapsed = grouped.length > 0 && grouped.every(g => collapsed.has(g.server.id))
|
||
|
||
const toggleServerFilter = (id: string) =>
|
||
setServerFilter(prev =>
|
||
prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]
|
||
)
|
||
|
||
const cycleStatusFilter = (s: typeof statusFilter) =>
|
||
setStatusFilter(prev => prev === s ? "all" : s)
|
||
|
||
const openEditProbe = (probe: PingProbe) => {
|
||
setEditingProbeId(probe.id)
|
||
setNewSrcId(probe.srcServerId)
|
||
setNewSrcInterface(probe.srcInterface || "")
|
||
setNewName(probe.name)
|
||
setNewTarget(probe.target)
|
||
setNewFilter(probe.filter || "—")
|
||
setSheetOpen(true)
|
||
}
|
||
|
||
const openAddSheet = () => {
|
||
setEditingProbeId(null)
|
||
setNewSrcId(selectableSources[0]?.id ?? "")
|
||
setNewSrcInterface("")
|
||
setNewName(""); setNewTarget(""); setNewFilter("—")
|
||
setSheetOpen(true)
|
||
}
|
||
|
||
const handleSaveProbe = () => {
|
||
if (!newName.trim() || !newTarget.trim() || !newSrcId) return
|
||
const probeBase: PingProbe = {
|
||
id: editingProbeId ?? `p${Date.now()}`,
|
||
srcServerId: newSrcId,
|
||
srcInterface: newSrcInterface,
|
||
name: newName.trim(),
|
||
target: newTarget.trim(),
|
||
filter: newFilter || "—",
|
||
rtt: null,
|
||
loss: 0,
|
||
status: "up",
|
||
series: Array(40).fill(0),
|
||
enabled: true,
|
||
showOnDashboard: false,
|
||
}
|
||
if (editingProbeId) {
|
||
setProbes((prev) => {
|
||
const next = prev.map((p) => {
|
||
if (p.id !== editingProbeId) return p
|
||
return {
|
||
...p,
|
||
srcServerId: probeBase.srcServerId,
|
||
srcInterface: probeBase.srcInterface,
|
||
name: probeBase.name,
|
||
target: probeBase.target,
|
||
filter: probeBase.filter,
|
||
}
|
||
})
|
||
persistProbes(next)
|
||
return next
|
||
})
|
||
} else {
|
||
setProbes((prev) => {
|
||
const next = [...prev, probeBase]
|
||
persistProbes(next)
|
||
return next
|
||
})
|
||
}
|
||
setEditingProbeId(null)
|
||
setSheetOpen(false)
|
||
}
|
||
|
||
const buildSpeedCommand = useCallback((probe: SpeedProbeRow) => {
|
||
const src = allServers.find((s) => s.id === probe.srcServerId)
|
||
const dst = allServers.find((s) => s.id === probe.dstServerId)
|
||
const rawDst = probe.dstInterface
|
||
? speedIfaces[probe.dstServerId]?.find((i) => i.name === probe.dstInterface)?.addresses?.[0]
|
||
: undefined
|
||
const dstIp = rawDst ? stripIpCidr(rawDst) : (dst?.host ?? "0.0.0.0")
|
||
const dur = Math.max(3, Number.parseInt(probe.durationSec, 10) || 10)
|
||
return `[${src?.name ?? "src"}] /tool bandwidth-test address=${dstIp} user=<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 (!probe.srcServerId || !probe.dstServerId || probe.srcServerId === probe.dstServerId) return
|
||
setSpeedError(null)
|
||
setSpeedBusy(true)
|
||
const durationSec = Math.max(3, Number.parseInt(probe.durationSec, 10) || 10)
|
||
const runId = `speed-${Date.now()}`
|
||
setSpeedRuns((prev) => [{
|
||
id: runId,
|
||
startedAt: Date.now(),
|
||
srcServerId: probe.srcServerId,
|
||
dstServerId: probe.dstServerId,
|
||
srcInterface: probe.srcInterface || undefined,
|
||
dstInterface: probe.dstInterface || undefined,
|
||
protocol: probe.protocol,
|
||
direction: probe.direction,
|
||
durationSec,
|
||
txAvgMbps: 0,
|
||
rxAvgMbps: 0,
|
||
status: "running" as const,
|
||
command: buildSpeedCommand(probe),
|
||
lines: ["status: running..."],
|
||
}, ...prev].slice(0, 20))
|
||
try {
|
||
if (liveApi) {
|
||
const payload = {
|
||
runId,
|
||
probeId: probe.id,
|
||
srcServerId: Number.parseInt(probe.srcServerId, 10),
|
||
dstServerId: Number.parseInt(probe.dstServerId, 10),
|
||
srcInterface: probe.srcInterface || undefined,
|
||
dstInterface: probe.dstInterface || undefined,
|
||
protocol: probe.protocol,
|
||
direction: probe.direction,
|
||
durationSec,
|
||
}
|
||
const res = await apiFetch<{
|
||
result: {
|
||
txAvgMbps: number
|
||
rxAvgMbps: number
|
||
raw?: Array<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",
|
||
body: JSON.stringify(payload),
|
||
})
|
||
const ap = res.result.afterBtPing
|
||
const lines = (res.result.raw ?? []).flatMap((row) =>
|
||
Object.entries(row).map(([k, v]) => `${k}: ${String(v)}`),
|
||
)
|
||
const pingNote = ap
|
||
? (ap.error
|
||
? `after-bt-ping: error: ${ap.error}`
|
||
: `after-bt-ping: rtt=${ap.rttMs ?? "—"}ms loss=${ap.lossPct ?? "—"}%`)
|
||
: ""
|
||
setSpeedRuns((prev) => prev.map((r) => r.id === runId ? ({
|
||
...r,
|
||
txAvgMbps: Math.round(res.result.txAvgMbps),
|
||
rxAvgMbps: Math.round(res.result.rxAvgMbps),
|
||
status: "done",
|
||
afterBtPing: ap ?? null,
|
||
srcAddress: res.result.srcAddress ?? null,
|
||
dstAddress: res.result.dstAddress ?? null,
|
||
srcInterfaceAddress: res.result.srcInterfaceAddress ?? null,
|
||
dstInterfaceAddress: res.result.dstInterfaceAddress ?? null,
|
||
lines: [
|
||
...(lines.length ? lines : [`tx-total-average: ${Math.round(res.result.txAvgMbps)}Mbps`, `rx-total-average: ${Math.round(res.result.rxAvgMbps)}Mbps`]),
|
||
...(pingNote ? [pingNote] : []),
|
||
],
|
||
}) : r))
|
||
const ts = new Date().toISOString()
|
||
setSpeedProbes((prev) => prev.map((p) => p.id === probe.id ? ({
|
||
...p,
|
||
lastRunAt: ts,
|
||
lastTxAvgMbps: Math.round(res.result.txAvgMbps),
|
||
lastRxAvgMbps: Math.round(res.result.rxAvgMbps),
|
||
lastStatus: "done",
|
||
lastError: "",
|
||
lastPingRttMs: ap?.rttMs ?? null,
|
||
lastPingLossPct: ap?.lossPct ?? null,
|
||
lastPingAt: ts,
|
||
lastPingError: ap?.error ?? "",
|
||
}) : p))
|
||
} else {
|
||
const tx = Math.max(10, Math.round(250 + Math.random() * 500))
|
||
const rx = Math.max(10, Math.round(tx * (0.85 + Math.random() * 0.2)))
|
||
const mockPing = { rttMs: Math.round(8 + Math.random() * 35), lossPct: Math.random() < 0.15 ? Math.round(Math.random() * 25) : 0, error: null as string | null }
|
||
setSpeedRuns((prev) => prev.map((r) => r.id === runId ? ({
|
||
...r,
|
||
txAvgMbps: tx,
|
||
rxAvgMbps: rx,
|
||
status: "done",
|
||
afterBtPing: mockPing,
|
||
srcAddress: allServers.find((s) => s.id === probe.srcServerId)?.host ?? null,
|
||
dstAddress: allServers.find((s) => s.id === probe.dstServerId)?.host ?? null,
|
||
srcInterfaceAddress: null,
|
||
dstInterfaceAddress: null,
|
||
lines: [
|
||
"status: done testing",
|
||
`tx-total-average: ${tx}Mbps`,
|
||
`rx-total-average: ${rx}Mbps`,
|
||
`after-bt-ping: rtt=${mockPing.rttMs}ms loss=${mockPing.lossPct}%`,
|
||
],
|
||
}) : r))
|
||
const ts = new Date().toISOString()
|
||
setSpeedProbes((prev) => prev.map((p) => p.id === probe.id ? ({
|
||
...p,
|
||
lastRunAt: ts,
|
||
lastTxAvgMbps: tx,
|
||
lastRxAvgMbps: rx,
|
||
lastStatus: "done",
|
||
lastError: "",
|
||
lastPingRttMs: mockPing.rttMs,
|
||
lastPingLossPct: mockPing.lossPct,
|
||
lastPingAt: ts,
|
||
lastPingError: "",
|
||
}) : p))
|
||
}
|
||
} catch (e) {
|
||
const message = e instanceof Error ? e.message : "Не удалось выполнить speed test"
|
||
setSpeedRuns((prev) => prev.map((r) => r.id === runId ? ({
|
||
...r,
|
||
status: "error",
|
||
lines: [`status: error`, message],
|
||
}) : r))
|
||
setSpeedError(message)
|
||
setSpeedProbes((prev) => prev.map((p) => p.id === probe.id ? ({
|
||
...p,
|
||
lastRunAt: new Date().toISOString(),
|
||
lastStatus: "error",
|
||
lastError: message,
|
||
}) : p))
|
||
} finally {
|
||
setSpeedBusy(false)
|
||
}
|
||
}
|
||
|
||
const updateSpeedProbe = (id: string, patch: Partial<SpeedProbeRow>) => {
|
||
setSpeedProbes((prev) => {
|
||
const nextRows = prev.map((p) => {
|
||
if (p.id !== id) return p
|
||
const next = { ...p, ...patch }
|
||
if (patch.srcServerId && patch.srcServerId === next.dstServerId) {
|
||
next.dstServerId = selectableSources.find((s) => s.id !== patch.srcServerId)?.id ?? patch.srcServerId
|
||
}
|
||
return next
|
||
})
|
||
persistSpeedProbes(nextRows)
|
||
return nextRows
|
||
})
|
||
}
|
||
|
||
const openAddSpeedSheet = () => {
|
||
setEditingSpeedProbeId(null)
|
||
const src = selectableSources[0]?.id ?? ""
|
||
const dst = selectableSources.find((s) => s.id !== src)?.id ?? src
|
||
setSpeedDraft({
|
||
id: "",
|
||
srcServerId: src,
|
||
dstServerId: dst,
|
||
srcInterface: "",
|
||
dstInterface: "",
|
||
protocol: "tcp",
|
||
direction: "both",
|
||
durationSec: "10",
|
||
enabled: true,
|
||
})
|
||
setSpeedSheetOpen(true)
|
||
void loadSpeedInterfaces(src)
|
||
void loadSpeedInterfaces(dst)
|
||
}
|
||
|
||
const openEditSpeedSheet = (probe: SpeedProbeRow) => {
|
||
setEditingSpeedProbeId(probe.id)
|
||
setSpeedDraft({
|
||
...probe,
|
||
durationSec: String(probe.durationSec ?? "10"),
|
||
})
|
||
setSpeedSheetOpen(true)
|
||
void loadSpeedInterfaces(probe.srcServerId)
|
||
void loadSpeedInterfaces(probe.dstServerId)
|
||
}
|
||
|
||
const saveSpeedProbe = () => {
|
||
if (!speedDraft.srcServerId || !speedDraft.dstServerId) return
|
||
if (editingSpeedProbeId) {
|
||
setSpeedProbes((prev) => {
|
||
const next = prev.map((p) => p.id === editingSpeedProbeId
|
||
? {
|
||
...p,
|
||
...speedDraft,
|
||
id: editingSpeedProbeId,
|
||
durationSec: String(speedDraft.durationSec ?? "10"),
|
||
}
|
||
: p)
|
||
persistSpeedProbes(next)
|
||
return next
|
||
})
|
||
} else {
|
||
setSpeedProbes((prev) => {
|
||
const next = [{
|
||
...speedDraft,
|
||
id: `sp-${Date.now()}`,
|
||
}, ...prev]
|
||
persistSpeedProbes(next)
|
||
return next
|
||
})
|
||
}
|
||
setEditingSpeedProbeId(null)
|
||
setSpeedSheetOpen(false)
|
||
}
|
||
|
||
const deleteSpeedProbe = (id: string) => {
|
||
setSpeedProbes((prev) => {
|
||
const next = prev.filter((p) => p.id !== id)
|
||
persistSpeedProbes(next)
|
||
return next
|
||
})
|
||
}
|
||
|
||
const toggleSpeedCollapse = (serverId: string) => {
|
||
setSpeedCollapsed((prev) => {
|
||
const next = new Set(prev)
|
||
if (next.has(serverId)) next.delete(serverId); else next.add(serverId)
|
||
return next
|
||
})
|
||
}
|
||
|
||
// ── render ──
|
||
return (
|
||
<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={liveApi && uptimeRefreshBusy} onClick={() => {
|
||
if (liveApi) {
|
||
void refreshUptimeLive({ showSpinner: true, pollDevices: tab === "resources" || tab === "probes" })
|
||
return
|
||
}
|
||
setProbes(mockProbesWithSavedStars(INIT_PROBES))
|
||
setResources(INIT_RESOURCES)
|
||
}}>
|
||
<RefreshCwIcon className={cn("size-4", uptimeRefreshBusy && "animate-spin")} />{liveApi ? "Обновить" : "Сбросить"}
|
||
</Button>
|
||
|
||
{tab === "probes" && (
|
||
<Button size="sm" onClick={openAddSheet}>
|
||
<PlusIcon className="size-4" />Новая проба
|
||
</Button>
|
||
)}
|
||
{tab === "speed" && (
|
||
<Button size="sm" onClick={openAddSpeedSheet}>
|
||
<PlusIcon className="size-4" />Новая speed-проба
|
||
</Button>
|
||
)}
|
||
</>
|
||
}
|
||
/>
|
||
|
||
{/* ── Live indicator ── */}
|
||
{!isPaused && (
|
||
<div className="flex items-center gap-2 px-6 py-1.5 border-b 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-6 flex items-center gap-1 shrink-0">
|
||
{(["probes", "resources", "speed"] as const).map(t => (
|
||
<button
|
||
key={t}
|
||
onClick={() => setTab(t)}
|
||
className={cn(
|
||
"px-4 py-2.5 text-sm font-medium border-b-2 transition-colors -mb-px",
|
||
tab === t
|
||
? "border-foreground text-foreground"
|
||
: "border-transparent text-muted-foreground hover:text-foreground",
|
||
)}
|
||
>
|
||
{t === "probes" ? (
|
||
<span className="flex items-center gap-2">
|
||
<span>Пинг-пробы</span>
|
||
{stats.down > 0 && (
|
||
<span className="inline-flex items-center justify-center min-w-[18px] h-4.5 px-1 rounded-full
|
||
text-[10px] font-bold bg-red-500 text-white leading-none">
|
||
{stats.down}
|
||
</span>
|
||
)}
|
||
<span className="text-[10px] font-mono opacity-50">{probes.length}</span>
|
||
</span>
|
||
) : t === "resources" ? (
|
||
<span className="flex items-center gap-2">
|
||
<CpuIcon className="size-3.5" />
|
||
<span>Ресурсы</span>
|
||
{alertCount > 0 && (
|
||
<span className="inline-flex items-center justify-center min-w-[18px] h-4.5 px-1 rounded-full
|
||
text-[10px] font-bold bg-red-500 text-white leading-none">
|
||
{alertCount}
|
||
</span>
|
||
)}
|
||
<span className="text-[10px] font-mono opacity-50">{resources.length}</span>
|
||
</span>
|
||
) : (
|
||
<span className="flex items-center gap-2">
|
||
<ArrowUpDownIcon className="size-3.5" />
|
||
<span>Скорость</span>
|
||
<span className="text-[10px] font-mono opacity-50">{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">
|
||
|
||
{/* ── KPI strip ── */}
|
||
{(() => {
|
||
const doneRuns = speedRuns.filter(r => r.status === "done")
|
||
const runningCnt = speedRuns.filter(r => r.status === "running").length
|
||
const maxTx = doneRuns.length ? Math.max(...doneRuns.map(r => r.txAvgMbps)) : null
|
||
const maxRx = doneRuns.length ? Math.max(...doneRuns.map(r => r.rxAvgMbps)) : null
|
||
return (
|
||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 px-6 py-4 border-b bg-muted/10 shrink-0">
|
||
{[
|
||
{ label: "Speed-пробы", value: speedProbes.length, unit: "шт", color: "" },
|
||
{ label: "Тестов выполнено", value: doneRuns.length, unit: "run", color: "" },
|
||
{ label: "Макс TX", value: maxTx != null ? `${maxTx}` : "—", unit: maxTx != null ? "Мбит/с" : "", color: "text-[var(--chart-tx)]" },
|
||
{ label: "Макс RX", value: maxRx != null ? `${maxRx}` : "—", unit: maxRx != null ? "Мбит/с" : "", color: "text-[var(--chart-rx)]" },
|
||
].map(k => (
|
||
<Frame key={k.label} className="h-full">
|
||
<FramePanel className="flex flex-col gap-0.5">
|
||
<p className="text-muted-foreground text-sm font-medium">{k.label}</p>
|
||
<div className="flex items-baseline gap-1">
|
||
<span className={cn("text-2xl leading-none font-bold tabular-nums", k.color)}>{k.value}</span>
|
||
{k.unit && <span className="text-xs text-muted-foreground">{k.unit}</span>}
|
||
</div>
|
||
{runningCnt > 0 && k.label === "Тестов выполнено" && (
|
||
<p className="text-[11px] text-[var(--status-degraded-fg)] flex items-center gap-1 mt-0.5">
|
||
<RefreshCwIcon className="size-2.5 animate-spin" />{runningCnt} выполняется
|
||
</p>
|
||
)}
|
||
</FramePanel>
|
||
</Frame>
|
||
))}
|
||
</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 && (
|
||
<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" 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="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" ? "error" : probe.lastRunAt ? "done" : null)
|
||
const viewTx = run?.txAvgMbps ?? Math.round(probe.lastTxAvgMbps ?? 0)
|
||
const viewRx = run?.rxAvgMbps ?? Math.round(probe.lastRxAvgMbps ?? 0)
|
||
const maxVal = Math.max(viewTx, viewRx, 1)
|
||
const canRun = probe.enabled && !!probe.srcServerId && !!probe.dstServerId && probe.srcServerId !== probe.dstServerId
|
||
const viewAfterBtPing =
|
||
run?.status === "done" && run.afterBtPing
|
||
? run.afterBtPing
|
||
: probe.lastPingAt
|
||
? {
|
||
rttMs: probe.lastPingRttMs ?? null,
|
||
lossPct: probe.lastPingLossPct ?? null,
|
||
error: probe.lastPingError?.trim() ? probe.lastPingError : null,
|
||
}
|
||
: null
|
||
|
||
return (
|
||
<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 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" ? "↑" : "↓"} {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"} → {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={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"
|
||
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"
|
||
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: `${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} Мбит/с
|
||
</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>
|
||
)}
|
||
|
||
{/* ── summary + server filter ── */}
|
||
<div className="border-b bg-muted/20 px-6 py-3 flex items-center gap-3 flex-wrap">
|
||
<StatChip label="Всего" value={stats.total} />
|
||
<StatChip label="OK" value={stats.up} color="emerald" active={statusFilter === "up"} onClick={() => cycleStatusFilter("up")} />
|
||
<StatChip label="Предупреждение" value={stats.warn} color="amber" active={statusFilter === "warn"} onClick={() => cycleStatusFilter("warn")} />
|
||
<StatChip label="Недоступен" value={stats.down} color="red" active={statusFilter === "down"} onClick={() => cycleStatusFilter("down")} />
|
||
|
||
<div className="w-px h-4 bg-border mx-1 shrink-0" />
|
||
|
||
{/* server chips */}
|
||
{probedServers.map(s => {
|
||
const active = serverFilter.includes(s.id)
|
||
const sProbes = probes.filter(p => p.srcServerId === s.id)
|
||
const hasIssues = sProbes.some(p => p.status !== "up")
|
||
return (
|
||
<button key={s.id} onClick={() => toggleServerFilter(s.id)}
|
||
className={cn(
|
||
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] font-medium transition-all",
|
||
active
|
||
? "bg-foreground text-background border-foreground"
|
||
: "border-border text-muted-foreground hover:text-foreground hover:border-foreground/40",
|
||
)}>
|
||
<Flag code={s.country} size={12} />
|
||
<span className="font-mono">{s.name}</span>
|
||
<TypeChip type={s.type} />
|
||
{hasIssues && !active && (
|
||
<span className="size-1.5 rounded-full bg-amber-500 shrink-0" />
|
||
)}
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
|
||
{/* ── toolbar ── */}
|
||
<div className="px-6 py-3 flex items-center gap-3 border-b flex-wrap">
|
||
<div className="relative min-w-[200px] max-w-xs flex-1">
|
||
<SearchIcon className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" />
|
||
<Input className="pl-8 h-8 text-sm" placeholder="Имя, IP-адрес, фильтр…"
|
||
value={search} onChange={e => setSearch(e.target.value)} />
|
||
{search && (
|
||
<button onClick={() => setSearch("")}
|
||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
|
||
<XIcon className="size-3.5" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{(search || statusFilter !== "all" || serverFilter.length > 0) && (
|
||
<Button variant="ghost" size="sm" className="text-muted-foreground h-8 px-2"
|
||
onClick={() => { setSearch(""); setStatusFilter("all"); setServerFilter([]) }}>
|
||
<XIcon className="size-3.5" />Сбросить
|
||
</Button>
|
||
)}
|
||
|
||
{/* Collapse / Expand all */}
|
||
<Button variant="outline" size="sm" className="h-8 gap-1.5 ml-auto"
|
||
onClick={allCollapsed ? expandAll : collapseAll}>
|
||
{allCollapsed
|
||
? <><ChevronDownIcon className="size-3.5" />Развернуть все</>
|
||
: <><ChevronUpIcon className="size-3.5" />Свернуть все</>}
|
||
</Button>
|
||
|
||
<p className="text-xs text-muted-foreground shrink-0">
|
||
{filtered.length} из {probes.length} проб
|
||
<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-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="w-full overflow-hidden">
|
||
<FramePanel className="p-0 overflow-hidden">
|
||
|
||
{/* server header */}
|
||
<button onClick={() => toggleCollapse(srv.id)}
|
||
className="w-full flex items-center gap-2.5 px-4 py-3 hover:bg-muted/30 transition-colors text-left border-b">
|
||
{isCollapsed
|
||
? <ChevronRightIcon className="size-4 text-muted-foreground shrink-0" />
|
||
: <ChevronDownIcon className="size-4 text-muted-foreground shrink-0" />}
|
||
<StatusDot status={srv.status} pulse={srv.status === "online"} />
|
||
<Flag code={srv.country} size={16} />
|
||
<span className="font-mono text-sm font-semibold">{srv.name}</span>
|
||
<TypeChip type={srv.type} />
|
||
<span className="text-xs text-muted-foreground">{srv.site}</span>
|
||
<div className="ml-auto flex items-center gap-3 text-xs">
|
||
{downCount > 0 && (
|
||
<span className="text-red-600 dark:text-red-400 font-medium">{downCount} down</span>
|
||
)}
|
||
{warnCount > 0 && (
|
||
<span className="text-amber-600 dark:text-amber-400 font-medium">{warnCount} warn</span>
|
||
)}
|
||
{issues === 0 && (
|
||
<span className="text-emerald-600 dark:text-emerald-400 font-medium">all ok</span>
|
||
)}
|
||
<span className="text-muted-foreground">{allServerProbes.length} проб</span>
|
||
</div>
|
||
</button>
|
||
|
||
{!isCollapsed && (
|
||
<>
|
||
<div className="divide-y divide-border/60">
|
||
{probeGroups.map((group) => {
|
||
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 items-center gap-2 text-xs">
|
||
<ArrowRightIcon className="size-3.5 text-muted-foreground/60 shrink-0" />
|
||
<span className="font-medium truncate min-w-0">{group.name}</span>
|
||
<span className="font-mono text-muted-foreground shrink-0">{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={!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="grid items-center gap-3 px-4 py-1.5 bg-muted/30 border-b text-[10px] font-semibold uppercase tracking-widest text-muted-foreground"
|
||
style={{ gridTemplateColumns: "36px 16px 130px 120px 140px 70px 44px minmax(132px,1fr) 96px 36px 72px" }}>
|
||
<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} className="border-b border-border/40 last:border-b-0">
|
||
<div
|
||
className={cn(
|
||
"grid items-center gap-3 px-4 py-2.5 hover:bg-muted/20 transition-colors",
|
||
!p.enabled && "opacity-40",
|
||
)}
|
||
style={{ gridTemplateColumns: "36px 16px 130px 120px 140px 70px 44px minmax(132px,1fr) 96px 36px 72px" }}>
|
||
<FormToggle checked={p.enabled} onChange={v => toggleProbe(p.id, v)} />
|
||
<StatusDot
|
||
status={p.status === "up" ? "online" : p.status === "warn" ? "degraded" : "offline"}
|
||
pulse={p.status === "up" && p.enabled}
|
||
/>
|
||
<span className="font-mono text-xs text-muted-foreground truncate">{p.srcInterface || "auto"}</span>
|
||
<span className="text-sm font-medium truncate">{p.name}</span>
|
||
{p.filter === "—"
|
||
? <span className="text-muted-foreground/40 text-xs">—</span>
|
||
: <span className="text-[11px] font-medium text-sky-600 dark:text-sky-400 truncate">{p.filter}</span>
|
||
}
|
||
<span className={cn("font-mono text-sm font-semibold tabular-nums", rttColor(p.rtt, p.loss))}>
|
||
{p.rtt === null ? "—" : `${p.rtt}мс`}
|
||
</span>
|
||
<span className={cn("font-mono text-xs tabular-nums",
|
||
p.loss === 0 ? "text-muted-foreground" :
|
||
p.loss >= 100 ? "text-red-600 dark:text-red-400" :
|
||
p.loss > 5 ? "text-red-600 dark:text-red-400" :
|
||
"text-amber-600 dark:text-amber-400",
|
||
)}>
|
||
{p.loss}%
|
||
</span>
|
||
<div className="min-w-0 flex flex-col gap-0.5 justify-center">
|
||
{!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>
|
||
<Sparkline data={p.series} width={96} height={24} color={probeSparkColor(p.status)} filled />
|
||
<div className="flex justify-center">
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
variant="ghost"
|
||
className="size-7 p-0 text-muted-foreground hover:text-amber-500"
|
||
onClick={() => toggleDashboardStar(p.id)}
|
||
title={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="flex justify-end gap-0.5">
|
||
<Button size="sm" variant="ghost"
|
||
className="size-7 p-0 text-muted-foreground hover:text-foreground"
|
||
onClick={() => openEditProbe(p)} title="Редактировать пробу">
|
||
<PencilIcon className="size-3.5" />
|
||
</Button>
|
||
<Button size="sm" variant="ghost"
|
||
className="size-7 p-0 text-muted-foreground hover:text-destructive"
|
||
onClick={() => deleteProbe(p.id)} title="Удалить пробу">
|
||
<TrashIcon className="size-3.5" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
<Collapsible
|
||
open={probeRttChartOpen[p.id] ?? false}
|
||
onOpenChange={(open) => setProbeRttChartOpen((m) => ({ ...m, [p.id]: open }))}
|
||
>
|
||
<CollapsibleTrigger
|
||
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
|
||
onClick={() => {
|
||
setEditingProbeId(null)
|
||
setNewSrcId(srv.id)
|
||
setNewSrcInterface("")
|
||
setNewName(group.name); setNewTarget(group.target); setNewFilter("—")
|
||
setSheetOpen(true)
|
||
}}
|
||
className="w-full flex items-center gap-2 px-4 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/20 transition-colors border-t">
|
||
<PlusIcon className="size-3.5" />
|
||
Добавить интерфейс для {group.name} ({group.target})
|
||
</button>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</>
|
||
)}
|
||
</FramePanel>
|
||
</Frame>
|
||
)
|
||
})}
|
||
</div>
|
||
</>}
|
||
</div>
|
||
|
||
{/* ── add speed-probe sheet ── */}
|
||
<Sheet open={speedSheetOpen} onOpenChange={(v) => {
|
||
if (!v) {
|
||
setSpeedSheetOpen(false)
|
||
setEditingSpeedProbeId(null)
|
||
return
|
||
}
|
||
setSpeedSheetOpen(true)
|
||
}}>
|
||
<SheetContent side="right" className="sm:max-w-[420px] flex flex-col p-0" showCloseButton={false}>
|
||
<SheetHeader className="px-5 pt-5 pb-4 border-b shrink-0">
|
||
<div className="flex items-center justify-between gap-2">
|
||
<div>
|
||
<SheetTitle className="text-base">{editingSpeedProbeId ? "Редактирование speed-пробы" : "Новая speed-проба"}</SheetTitle>
|
||
<SheetDescription className="text-xs mt-0.5">
|
||
{editingSpeedProbeId ? "Измените параметры BT-пробы" : "BTTest между выбранными серверами"}
|
||
</SheetDescription>
|
||
</div>
|
||
<Button variant="ghost" size="icon-sm" onClick={() => setSpeedSheetOpen(false)}>
|
||
<XIcon className="size-4" />
|
||
</Button>
|
||
</div>
|
||
</SheetHeader>
|
||
|
||
<div className="flex-1 overflow-y-auto px-5 py-5 flex flex-col gap-5">
|
||
<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-3 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>
|
||
)}
|
||
</div>
|
||
|
||
<SheetFooter className="px-5 py-4 border-t shrink-0 gap-2">
|
||
<Button variant="outline" className="flex-1" onClick={() => { setSpeedSheetOpen(false); setEditingSpeedProbeId(null) }}>Отмена</Button>
|
||
<Button className="flex-1" onClick={saveSpeedProbe} disabled={!speedDraft.srcServerId || !speedDraft.dstServerId || speedDraft.srcServerId === speedDraft.dstServerId}>
|
||
{editingSpeedProbeId ? <PencilIcon className="size-4" /> : <PlusIcon className="size-4" />}
|
||
{editingSpeedProbeId ? "Сохранить" : "Добавить"}
|
||
</Button>
|
||
</SheetFooter>
|
||
</SheetContent>
|
||
</Sheet>
|
||
|
||
{/* ── add probe sheet ── */}
|
||
<Sheet open={sheetOpen} onOpenChange={(v) => {
|
||
if (!v) {
|
||
setSheetOpen(false)
|
||
setEditingProbeId(null)
|
||
return
|
||
}
|
||
setSheetOpen(true)
|
||
}}>
|
||
<SheetContent side="right" className="sm:max-w-[420px] flex flex-col p-0" showCloseButton={false}>
|
||
|
||
<SheetHeader className="px-5 pt-5 pb-4 border-b shrink-0">
|
||
<div className="flex items-center justify-between gap-2">
|
||
<div>
|
||
<SheetTitle className="text-base">{editingProbeId ? "Редактирование пробы" : "Новая проба"}</SheetTitle>
|
||
<SheetDescription className="text-xs mt-0.5">
|
||
Ping от выбранного сервера к целевому хосту
|
||
</SheetDescription>
|
||
</div>
|
||
<Button variant="ghost" size="icon-sm" onClick={() => setSheetOpen(false)}>
|
||
<XIcon className="size-4" />
|
||
</Button>
|
||
</div>
|
||
</SheetHeader>
|
||
|
||
<div className="flex-1 overflow-y-auto px-5 py-5 flex flex-col gap-5">
|
||
|
||
{/* from → to visual */}
|
||
<div className="flex items-center gap-2 rounded-lg bg-muted/40 border px-4 py-3">
|
||
<div className="flex-1 min-w-0">
|
||
<p className="text-[10px] text-muted-foreground uppercase tracking-widest mb-0.5">Источник</p>
|
||
{newSrcId ? (() => {
|
||
const s = allServers.find(x => x.id === newSrcId)
|
||
return s ? (
|
||
<div className="flex items-center gap-1.5">
|
||
<Flag code={s.country} size={14} />
|
||
<span className="text-sm font-mono font-medium truncate">{s.name}</span>
|
||
<TypeChip type={s.type} />
|
||
</div>
|
||
) : null
|
||
})() : <span className="text-xs text-muted-foreground">не выбран</span>}
|
||
</div>
|
||
<ArrowRightIcon className="size-4 text-muted-foreground/40 shrink-0" />
|
||
<div className="flex-1 min-w-0 text-right">
|
||
<p className="text-[10px] text-muted-foreground uppercase tracking-widest mb-0.5">Цель</p>
|
||
<p className="text-sm font-mono truncate">
|
||
{newTarget || <span className="text-muted-foreground/40">0.0.0.0</span>}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<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>
|
||
</div>
|
||
|
||
<SheetFooter className="px-5 py-4 border-t shrink-0 gap-2">
|
||
<Button variant="outline" className="flex-1" onClick={() => { setSheetOpen(false); setEditingProbeId(null) }}>
|
||
Отмена
|
||
</Button>
|
||
<Button className="flex-1"
|
||
disabled={!newName.trim() || !newTarget.trim() || !newSrcId}
|
||
onClick={handleSaveProbe}>
|
||
{editingProbeId ? <PencilIcon className="size-4" /> : <PlusIcon className="size-4" />}
|
||
{editingProbeId ? "Сохранить" : "Добавить"}
|
||
</Button>
|
||
</SheetFooter>
|
||
</SheetContent>
|
||
</Sheet>
|
||
</div>
|
||
)
|
||
}
|