Added the sonner library for toast notifications across various components, improving user feedback for actions such as saving settings, syncing rules, and handling errors. Updated the layout to include a Toaster component for consistent notification display. Refactored alert messages in the backups, gre, and filters pages to utilize the new notification system, enhancing overall user experience.
3234 lines
145 KiB
TypeScript
3234 lines
145 KiB
TypeScript
"use client"
|
||
|
||
import { useState, useMemo, useEffect, useRef, useCallback } from "react"
|
||
import { PageHeader } from "@/components/page-header"
|
||
import { Card, CardContent } from "@/components/ui/card"
|
||
import { Button } from "@/components/ui/button"
|
||
import { Input } from "@/components/ui/input"
|
||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||
import { Flag } from "@/components/flag"
|
||
import { StatusDot } from "@/components/status-dot"
|
||
import { Sparkline } from "@/components/sparkline"
|
||
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 Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
|
||
return (
|
||
<button type="button" onClick={() => onChange(!checked)}
|
||
className={cn(
|
||
"relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors cursor-pointer",
|
||
checked ? "bg-primary" : "bg-input",
|
||
)}>
|
||
<span className={cn(
|
||
"pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm transition-transform",
|
||
checked ? "translate-x-4" : "translate-x-0",
|
||
)} />
|
||
</button>
|
||
)
|
||
}
|
||
|
||
function TypeChip({ type }: { type: "jump-host" | "exit-node" | "home-router" }) {
|
||
return (
|
||
<span className={cn(
|
||
"inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-bold border shrink-0",
|
||
type === "home-router"
|
||
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
|
||
: type === "jump-host"
|
||
? "bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/20"
|
||
: "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20",
|
||
)}>
|
||
{type === "jump-host" ? "JH" : type === "home-router" ? "HR" : "EN"}
|
||
</span>
|
||
)
|
||
}
|
||
|
||
function StatChip({
|
||
label, value, color, active, onClick,
|
||
}: {
|
||
label: string; value: number; color?: "emerald" | "amber" | "red"; active?: boolean; onClick?: () => void
|
||
}) {
|
||
return (
|
||
<button
|
||
onClick={onClick}
|
||
className={cn(
|
||
"flex items-center gap-1.5 px-3 py-1.5 rounded-md border text-xs transition-all",
|
||
active
|
||
? "bg-foreground text-background border-foreground"
|
||
: "border-border hover:border-foreground/30 text-muted-foreground hover:text-foreground",
|
||
)}>
|
||
<span>{label}</span>
|
||
<span className={cn(
|
||
"font-semibold tabular-nums",
|
||
!active && color === "emerald" ? "text-[var(--status-online-fg)]" :
|
||
!active && color === "amber" ? "text-[var(--status-degraded-fg)]" :
|
||
!active && color === "red" ? "text-[var(--status-offline-fg)]" : "",
|
||
)}>{value}</span>
|
||
</button>
|
||
)
|
||
}
|
||
|
||
function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
|
||
return (
|
||
<div className="flex flex-col gap-1.5">
|
||
<label className="text-xs font-medium">
|
||
{label}
|
||
{hint && <span className="font-normal text-muted-foreground ml-1">{hint}</span>}
|
||
</label>
|
||
{children}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/** Активный интерфейс 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 SegmentedControl<T extends string>({
|
||
value,
|
||
onChange,
|
||
options,
|
||
}: {
|
||
value: T
|
||
onChange: (v: T) => void
|
||
options: Array<{ value: T; label: string }>
|
||
}) {
|
||
return (
|
||
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5 w-fit">
|
||
{options.map((option) => (
|
||
<button
|
||
key={option.value}
|
||
type="button"
|
||
onClick={() => onChange(option.value)}
|
||
className={cn(
|
||
"px-3 py-1 text-sm rounded transition-colors",
|
||
value === option.value
|
||
? "bg-background text-foreground shadow-sm"
|
||
: "text-muted-foreground hover:text-foreground",
|
||
)}
|
||
>
|
||
{option.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
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 [sortKey, setSortKey] = useState<ResSortKey>("name")
|
||
const [sortAsc, setSortAsc] = useState(true)
|
||
const [resSearch, setResSearch] = useState("")
|
||
const [typeFilter, setTypeFilter] = useState<ResTypeFilter>("all")
|
||
|
||
const rows = useMemo(() => resources.map((r) => {
|
||
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 => r.server !== undefined), [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)
|
||
)
|
||
}
|
||
list = [...list].sort((a, b) => {
|
||
let diff = 0
|
||
switch (sortKey) {
|
||
case "name": diff = a.server!.name.localeCompare(b.server!.name); break
|
||
case "cpu": diff = a.cpu - b.cpu; break
|
||
case "ram": diff = a.ramPct - b.ramPct; break
|
||
case "hdd": diff = a.hddPct - b.hddPct; break
|
||
case "uptime": diff = a.uptimeSeconds - b.uptimeSeconds; break
|
||
case "temp": diff = (a.temp ?? -1) - (b.temp ?? -1); break
|
||
}
|
||
return sortAsc ? diff : -diff
|
||
})
|
||
return list
|
||
}, [rows, typeFilter, resSearch, sortKey, sortAsc])
|
||
|
||
function toggleSort(k: ResSortKey) {
|
||
if (sortKey === k) setSortAsc(v => !v)
|
||
else { setSortKey(k); setSortAsc(false) } // default desc for metrics
|
||
}
|
||
|
||
function exportCsv() {
|
||
const header = ["Сервер", "Тип", "Площадка", "CPU %", "RAM %", "RAM использ.", "RAM всего", "HDD %", "HDD использ.", "HDD всего", "Uptime", "Температура °C", "RouterOS"]
|
||
const rowsCsv = visible.map(r => {
|
||
const s = r.server!
|
||
return [s.name, s.type, s.site, r.cpu, r.ramPct, fmtMB(r.ramUsed), fmtMB(r.ramTotal),
|
||
r.hddPct, fmtMB(r.hddUsed), fmtMB(r.hddTotal), fmtUptime(r.uptimeSeconds),
|
||
r.temp ?? "", s.os].join(",")
|
||
})
|
||
const csv = [header.join(","), ...rowsCsv].join("\n")
|
||
const blob = new Blob([csv], { type: "text/csv;charset=utf-8" })
|
||
const url = URL.createObjectURL(blob)
|
||
const a = document.createElement("a")
|
||
a.href = url; a.download = `resources-${new Date().toISOString().slice(0,10)}.csv`; a.click()
|
||
URL.revokeObjectURL(url)
|
||
}
|
||
|
||
const typeOpts: { value: ResTypeFilter; label: string }[] = [
|
||
{ value: "all", label: "Все" },
|
||
{ value: "jump-host", label: "JumpHost" },
|
||
{ value: "exit-node", label: "Exit Node" },
|
||
{ value: "home-router", label: "Home Router" },
|
||
]
|
||
|
||
return (
|
||
<div className="p-6 flex flex-col gap-4">
|
||
|
||
{/* ── Alert banner ──────────────────────────────────────────────────── */}
|
||
{alerts.length > 0 && (
|
||
<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 => (
|
||
<Card key={kpi.sub}>
|
||
<CardContent className="px-4 py-3 flex items-start gap-3">
|
||
<div className={cn("mt-0.5 shrink-0", kpi.color)}>{kpi.icon}</div>
|
||
<div className="min-w-0">
|
||
<p className={cn("text-xl font-semibold tabular-nums leading-tight", kpi.color)}>{kpi.label}</p>
|
||
<p className="text-[11px] text-muted-foreground mt-0.5">{kpi.sub}</p>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
|
||
{/* ── Toolbar ───────────────────────────────────────────────────────── */}
|
||
<div className="flex items-center gap-3 flex-wrap">
|
||
{/* Type filter */}
|
||
<div className="flex items-center gap-0.5 rounded-md border border-border bg-muted/40 p-0.5 shrink-0">
|
||
{typeOpts.map(o => (
|
||
<button key={o.value} onClick={() => setTypeFilter(o.value)}
|
||
className={cn(
|
||
"px-2.5 py-1 text-xs rounded transition-colors whitespace-nowrap",
|
||
typeFilter === o.value
|
||
? "bg-background text-foreground shadow-sm"
|
||
: "text-muted-foreground hover:text-foreground",
|
||
)}>
|
||
{o.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* Search */}
|
||
<div className="relative min-w-[180px] flex-1 max-w-xs">
|
||
<SearchIcon className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" />
|
||
<Input className="pl-8 h-8 text-sm" placeholder="Поиск по имени, площадке…"
|
||
value={resSearch} onChange={e => setResSearch(e.target.value)} />
|
||
{resSearch && (
|
||
<button onClick={() => setResSearch("")}
|
||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
|
||
<XIcon className="size-3.5" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
<span className="text-xs text-muted-foreground ml-auto shrink-0">
|
||
{visible.length} из {rows.length} серверов
|
||
</span>
|
||
|
||
{/* Export CSV */}
|
||
<Button variant="outline" size="sm" className="h-8 gap-1.5 shrink-0" onClick={exportCsv}>
|
||
<DownloadIcon className="size-3.5" />CSV
|
||
</Button>
|
||
</div>
|
||
|
||
{/* ── Table ─────────────────────────────────────────────────────────── */}
|
||
<Card className="overflow-hidden">
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-sm">
|
||
<thead>
|
||
<tr className="border-b bg-muted/30 text-xs text-muted-foreground font-medium">
|
||
|
||
{/* Sortable: name */}
|
||
<th className="text-left px-5 py-3 cursor-pointer hover:text-foreground transition-colors select-none"
|
||
onClick={() => toggleSort("name")}>
|
||
<span className="flex items-center gap-0.5">
|
||
Сервер <SortIcon k="name" sortKey={sortKey} sortAsc={sortAsc} />
|
||
</span>
|
||
</th>
|
||
|
||
<th className="text-left px-4 py-3 hidden md:table-cell whitespace-nowrap">Модель · ROS</th>
|
||
|
||
{/* Sortable: cpu */}
|
||
<th className="text-left px-4 py-3 min-w-[160px] cursor-pointer hover:text-foreground transition-colors select-none"
|
||
onClick={() => toggleSort("cpu")}>
|
||
<span className="flex items-center gap-1.5">
|
||
<CpuIcon className="size-3.5" />CPU
|
||
<SortIcon k="cpu" sortKey={sortKey} sortAsc={sortAsc} />
|
||
</span>
|
||
</th>
|
||
|
||
{/* Sortable: ram */}
|
||
<th className="text-left px-4 py-3 min-w-[175px] cursor-pointer hover:text-foreground transition-colors select-none"
|
||
onClick={() => toggleSort("ram")}>
|
||
<span className="flex items-center gap-1.5">
|
||
<HardDriveIcon className="size-3.5" />RAM
|
||
<SortIcon k="ram" sortKey={sortKey} sortAsc={sortAsc} />
|
||
</span>
|
||
</th>
|
||
|
||
{/* Sortable: hdd */}
|
||
<th className="text-left px-4 py-3 min-w-[175px] cursor-pointer hover:text-foreground transition-colors select-none"
|
||
onClick={() => toggleSort("hdd")}>
|
||
<span className="flex items-center gap-1.5">
|
||
<HardDriveIcon className="size-3.5" />Диск
|
||
<SortIcon k="hdd" sortKey={sortKey} sortAsc={sortAsc} />
|
||
</span>
|
||
</th>
|
||
|
||
{/* Sortable: uptime */}
|
||
<th className="text-left px-4 py-3 cursor-pointer hover:text-foreground transition-colors select-none"
|
||
onClick={() => toggleSort("uptime")}>
|
||
<span className="flex items-center gap-1.5">
|
||
<ClockIcon className="size-3.5" />Uptime
|
||
<SortIcon k="uptime" sortKey={sortKey} sortAsc={sortAsc} />
|
||
</span>
|
||
</th>
|
||
|
||
{/* Sortable: temp */}
|
||
<th className="text-left px-4 py-3 cursor-pointer hover:text-foreground transition-colors select-none"
|
||
onClick={() => toggleSort("temp")}>
|
||
<span className="flex items-center gap-1.5">
|
||
<ThermometerIcon className="size-3.5" />°C
|
||
<SortIcon k="temp" sortKey={sortKey} sortAsc={sortAsc} />
|
||
</span>
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-border">
|
||
{visible.length === 0 && (
|
||
<tr>
|
||
<td colSpan={7} className="text-center text-sm text-muted-foreground py-12">
|
||
<SearchIcon className="size-6 mx-auto mb-2 opacity-20" />
|
||
Ничего не найдено
|
||
</td>
|
||
</tr>
|
||
)}
|
||
{visible.map(r => {
|
||
const srv = r.server!
|
||
const offline = srv.status !== "online"
|
||
const hasSamples = r.hasData !== false
|
||
const noMetrics = offline || !hasSamples
|
||
const isCrit = !noMetrics && (r.cpu >= 85 || r.ramPct >= 85 || r.hddPct >= 85 || (r.temp ?? 0) >= 70)
|
||
const cpuColor = r.cpu >= 85 ? "hsl(0 84% 60%)" : r.cpu >= 70 ? "hsl(38 92% 50%)" : "hsl(142 76% 36%)"
|
||
return (
|
||
<tr key={r.serverId} className={cn(
|
||
"hover:bg-muted/30 transition-colors",
|
||
offline && "opacity-50",
|
||
isCrit && "bg-red-500/3",
|
||
)}>
|
||
|
||
{/* Server */}
|
||
<td className="px-5 py-3">
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
{isCrit && <AlertCircleIcon className="size-3.5 text-red-500 shrink-0" />}
|
||
{!isCrit && <StatusDot status={srv.status} pulse={!offline} />}
|
||
<Flag code={srv.country} size={16} />
|
||
<span className="font-mono font-semibold">{srv.name}</span>
|
||
<TypeChip type={srv.type} />
|
||
<span className="text-xs text-muted-foreground hidden xl:inline">{srv.site}</span>
|
||
{!offline && r.hasData === false && (
|
||
<span className="text-[10px] rounded border border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-400 px-1.5 py-0.5">
|
||
нет данных
|
||
</span>
|
||
)}
|
||
</div>
|
||
</td>
|
||
|
||
{/* Board + ROS */}
|
||
<td className="px-4 py-3 hidden md:table-cell">
|
||
<div className="flex flex-col leading-tight">
|
||
<span className="font-mono text-xs text-muted-foreground">{hasSamples ? r.boardName : "—"}</span>
|
||
<span className="text-[10px] text-muted-foreground/50">{srv.os}</span>
|
||
</div>
|
||
</td>
|
||
|
||
{/* CPU */}
|
||
<td className="px-4 py-3">
|
||
{noMetrics
|
||
? <span className="text-xs text-muted-foreground/30">{offline ? "—" : "нет опроса"}</span>
|
||
: (
|
||
<div className="flex flex-col gap-1.5 min-w-[140px]">
|
||
<div className="flex items-center gap-2">
|
||
<span className={cn("font-mono text-sm font-semibold tabular-nums w-10 shrink-0", resPctColor(r.cpu))}>
|
||
{r.cpu}%
|
||
</span>
|
||
<MiniBar pct={r.cpu} className="flex-1" />
|
||
</div>
|
||
<Sparkline data={r.cpuHistory} width={120} height={18} color={cpuColor} filled />
|
||
</div>
|
||
)}
|
||
</td>
|
||
|
||
{/* RAM */}
|
||
<td className="px-4 py-3">
|
||
{noMetrics
|
||
? <span className="text-xs text-muted-foreground/30">{offline ? "—" : "нет опроса"}</span>
|
||
: (
|
||
<div className="flex flex-col gap-1.5 min-w-[155px]">
|
||
<div className="flex items-center justify-between text-xs">
|
||
<span className={cn("font-mono font-semibold", resPctColor(r.ramPct))}>{r.ramPct}%</span>
|
||
<span className="text-muted-foreground/60 font-mono text-[10px]">
|
||
{fmtMB(r.ramUsed)}/{fmtMB(r.ramTotal)}
|
||
</span>
|
||
</div>
|
||
<MiniBar pct={r.ramPct} />
|
||
</div>
|
||
)}
|
||
</td>
|
||
|
||
{/* HDD */}
|
||
<td className="px-4 py-3">
|
||
{noMetrics
|
||
? <span className="text-xs text-muted-foreground/30">{offline ? "—" : "нет опроса"}</span>
|
||
: (
|
||
<div className="flex flex-col gap-1.5 min-w-[155px]">
|
||
<div className="flex items-center justify-between text-xs">
|
||
<span className={cn("font-mono font-semibold", resPctColor(r.hddPct))}>{r.hddPct}%</span>
|
||
<span className="text-muted-foreground/60 font-mono text-[10px]">
|
||
{fmtMB(r.hddUsed)}/{fmtMB(r.hddTotal)}
|
||
</span>
|
||
</div>
|
||
<MiniBar pct={r.hddPct} />
|
||
</div>
|
||
)}
|
||
</td>
|
||
|
||
{/* Uptime */}
|
||
<td className="px-4 py-3">
|
||
<span className="font-mono text-xs text-muted-foreground">
|
||
{noMetrics ? (offline ? "—" : "—") : fmtUptime(r.uptimeSeconds)}
|
||
</span>
|
||
</td>
|
||
|
||
{/* Temp */}
|
||
<td className="px-4 py-3">
|
||
{r.temp !== undefined && !noMetrics ? (
|
||
<span className={cn("font-mono text-sm font-semibold tabular-nums",
|
||
r.temp >= 70 ? "text-red-600 dark:text-red-400"
|
||
: r.temp >= 55 ? "text-amber-600 dark:text-amber-400"
|
||
: "text-muted-foreground",
|
||
)}>
|
||
{r.temp}°C
|
||
</span>
|
||
) : (
|
||
<span className="text-muted-foreground/30 text-xs">—</span>
|
||
)}
|
||
</td>
|
||
</tr>
|
||
)
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</Card>
|
||
|
||
<p className="text-xs text-muted-foreground/40 text-center">
|
||
{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 [allServers, setAllServers] = useState<Server[]>(mockServers)
|
||
const { mode, backendUrl, backendStatus, checkBackend } = useDataSource()
|
||
/** При mode=live всегда ходим на backend. Нельзя требовать backendStatus===true: до ответа /health там undefined — иначе обзор/«Обновить» молчат. */
|
||
const liveApi = mode === "live"
|
||
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
||
|
||
const [tab, setTab] = useState<"probes" | "resources" | "speed">("probes")
|
||
const [probes, setProbes] = useState<PingProbe[]>(INIT_PROBES)
|
||
const [resources, setResources] = useState<ServerResource[]>(INIT_RESOURCES)
|
||
const [isPaused, setIsPaused] = useState(false)
|
||
const [search, setSearch] = useState("")
|
||
const [statusFilter, setStatusFilter] = useState<"all" | "up" | "warn" | "down">("all")
|
||
const [serverFilter, setServerFilter] = useState<string[]>([])
|
||
const [collapsed, setCollapsed] = useState<Set<string>>(new Set())
|
||
const [sheetOpen, setSheetOpen] = useState(false)
|
||
const [opError, setOpError] = useState<string | null>(null)
|
||
const [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) {
|
||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||
setAllServers(mockServers)
|
||
return
|
||
}
|
||
/** В live каталог серверов подгружается вместе с overview (см. loadLiveOverview), иначе после «Серверы» строки ресурсов пропадают из-за .filter(r => r.server). */
|
||
}, [liveApi])
|
||
|
||
useEffect(() => {
|
||
if (liveApi) return
|
||
queueMicrotask(() => setProbes(mockProbesWithSavedStars(INIT_PROBES)))
|
||
}, [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 => (
|
||
<Card key={k.label}>
|
||
<CardContent className="pt-4 pb-3 px-4">
|
||
<p className="text-xs text-muted-foreground">{k.label}</p>
|
||
<div className="flex items-baseline gap-1 mt-0.5">
|
||
<span className={cn("text-2xl font-semibold 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>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
))}
|
||
</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 (
|
||
<Card key={server.id} className="overflow-hidden py-0 gap-0">
|
||
{/* 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 */}
|
||
<Toggle 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>
|
||
)}
|
||
</Card>
|
||
)
|
||
})}
|
||
|
||
{/* ── history ── */}
|
||
{speedRuns.length > 0 && (
|
||
<Card className="overflow-hidden">
|
||
<div className="flex items-center justify-between px-4 py-3 border-b">
|
||
<p className="text-sm font-medium">История тестов</p>
|
||
<span className="text-xs text-muted-foreground">{speedRuns.length} запусков</span>
|
||
</div>
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-xs">
|
||
<thead>
|
||
<tr className="border-b bg-muted/40 text-muted-foreground">
|
||
{["Время", "Маршрут", "Параметры", "Статус", "TX avg", "RX avg", "Ping после BT"].map(h => (
|
||
<th key={h} className="px-4 py-2.5 text-left font-medium whitespace-nowrap">{h}</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-border/60">
|
||
{speedRuns.map((run) => {
|
||
const src = allServers.find((s) => s.id === run.srcServerId)
|
||
const dst = allServers.find((s) => s.id === run.dstServerId)
|
||
const maxVal = Math.max(run.txAvgMbps, run.rxAvgMbps, 1)
|
||
return (
|
||
<tr key={run.id} className="hover:bg-muted/20 transition-colors">
|
||
<td className="px-4 py-2.5 text-muted-foreground whitespace-nowrap tabular-nums font-mono">
|
||
{new Date(run.startedAt).toLocaleString("ru-RU", { hour: "2-digit", minute: "2-digit", second: "2-digit", day: "2-digit", month: "2-digit" })}
|
||
</td>
|
||
<td className="px-4 py-2.5 font-mono whitespace-nowrap">
|
||
<div className="flex items-center gap-1.5">
|
||
<Flag code={src?.country ?? "UN"} size={13} />
|
||
<span>{src?.name ?? run.srcServerId}</span>
|
||
<ArrowRightIcon className="size-3 text-muted-foreground" />
|
||
<Flag code={dst?.country ?? "UN"} size={13} />
|
||
<span>{dst?.name ?? run.dstServerId}</span>
|
||
</div>
|
||
<div className="text-[10px] text-muted-foreground mt-0.5 font-mono">
|
||
{run.srcInterfaceAddress && run.dstInterfaceAddress
|
||
? `${run.srcInterfaceAddress} → ${run.dstInterfaceAddress}`
|
||
: "внутренние IP: auto/не указаны"}
|
||
</div>
|
||
</td>
|
||
<td className="px-4 py-2.5 text-muted-foreground whitespace-nowrap">
|
||
<div className="flex items-center gap-1">
|
||
<span className="inline-flex items-center rounded border px-1.5 py-0.5 text-[10px] font-semibold bg-muted/60 border-border/60">
|
||
{run.protocol.toUpperCase()}
|
||
</span>
|
||
<span className="text-muted-foreground/60">·</span>
|
||
<span>{run.direction}</span>
|
||
<span className="text-muted-foreground/60">·</span>
|
||
<span>{run.durationSec}s</span>
|
||
</div>
|
||
</td>
|
||
<td className="px-4 py-2.5">
|
||
{run.status === "running" ? (
|
||
<span className="inline-flex items-center gap-1 text-[var(--status-degraded-fg)]">
|
||
<RefreshCwIcon className="size-3 animate-spin" />running
|
||
</span>
|
||
) : run.status === "error" ? (
|
||
<span className="text-[var(--status-offline-fg)]">error</span>
|
||
) : (
|
||
<span className="text-[var(--status-online-fg)]">done</span>
|
||
)}
|
||
</td>
|
||
<td className="px-4 py-2.5">
|
||
<div className="flex items-center gap-2 min-w-[120px]">
|
||
<div className="w-16 h-1.5 rounded-full bg-muted overflow-hidden">
|
||
<div className="h-full rounded-full bg-[var(--chart-tx)]"
|
||
style={{ width: `${(run.txAvgMbps / maxVal) * 100}%` }} />
|
||
</div>
|
||
<span className="font-mono tabular-nums text-[var(--chart-tx)] font-medium whitespace-nowrap">
|
||
{run.txAvgMbps} Мбит/с
|
||
</span>
|
||
</div>
|
||
</td>
|
||
<td className="px-4 py-2.5">
|
||
<div className="flex items-center gap-2 min-w-[120px]">
|
||
<div className="w-16 h-1.5 rounded-full bg-muted overflow-hidden">
|
||
<div className="h-full rounded-full bg-[var(--chart-rx)]"
|
||
style={{ width: `${(run.rxAvgMbps / maxVal) * 100}%` }} />
|
||
</div>
|
||
<span className="font-mono tabular-nums text-[var(--chart-rx)] font-medium whitespace-nowrap">
|
||
{run.rxAvgMbps} Мбит/с
|
||
</span>
|
||
</div>
|
||
</td>
|
||
<td className="px-4 py-2.5 font-mono tabular-nums whitespace-nowrap">
|
||
{run.status !== "done" ? "—" : run.afterBtPing?.error ? (
|
||
<span className="text-[var(--status-offline-fg)]" title={run.afterBtPing.error}>
|
||
ошибка
|
||
</span>
|
||
) : run.afterBtPing?.rttMs != null ? (
|
||
<span className="text-violet-600 dark:text-violet-400">
|
||
{run.afterBtPing.rttMs} мс
|
||
{run.afterBtPing.lossPct != null && run.afterBtPing.lossPct > 0 && (
|
||
<span className="text-amber-600 dark:text-amber-400"> · {run.afterBtPing.lossPct}%</span>
|
||
)}
|
||
</span>
|
||
) : (
|
||
<span className="text-amber-600 dark:text-amber-400">
|
||
timeout
|
||
{run.afterBtPing?.lossPct != null && <span> · {run.afterBtPing.lossPct}%</span>}
|
||
</span>
|
||
)}
|
||
</td>
|
||
</tr>
|
||
)
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</Card>
|
||
)}
|
||
|
||
</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 (
|
||
<Card key={srv.id} className="overflow-hidden py-0 gap-0">
|
||
|
||
{/* server header */}
|
||
<button onClick={() => toggleCollapse(srv.id)}
|
||
className="w-full flex items-center gap-2.5 px-4 py-3 hover:bg-muted/30 transition-colors text-left border-b">
|
||
{isCollapsed
|
||
? <ChevronRightIcon className="size-4 text-muted-foreground shrink-0" />
|
||
: <ChevronDownIcon className="size-4 text-muted-foreground shrink-0" />}
|
||
<StatusDot status={srv.status} pulse={srv.status === "online"} />
|
||
<Flag code={srv.country} size={16} />
|
||
<span className="font-mono text-sm font-semibold">{srv.name}</span>
|
||
<TypeChip type={srv.type} />
|
||
<span className="text-xs text-muted-foreground">{srv.site}</span>
|
||
<div className="ml-auto flex items-center gap-3 text-xs">
|
||
{downCount > 0 && (
|
||
<span className="text-red-600 dark:text-red-400 font-medium">{downCount} down</span>
|
||
)}
|
||
{warnCount > 0 && (
|
||
<span className="text-amber-600 dark:text-amber-400 font-medium">{warnCount} warn</span>
|
||
)}
|
||
{issues === 0 && (
|
||
<span className="text-emerald-600 dark:text-emerald-400 font-medium">all ok</span>
|
||
)}
|
||
<span className="text-muted-foreground">{allServerProbes.length} проб</span>
|
||
</div>
|
||
</button>
|
||
|
||
{!isCollapsed && (
|
||
<>
|
||
<div className="divide-y divide-border/60">
|
||
{probeGroups.map((group) => {
|
||
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" }}>
|
||
<Toggle checked={p.enabled} onChange={v => toggleProbe(p.id, v)} />
|
||
<StatusDot
|
||
status={p.status === "up" ? "online" : p.status === "warn" ? "degraded" : "offline"}
|
||
pulse={p.status === "up" && p.enabled}
|
||
/>
|
||
<span className="font-mono text-xs text-muted-foreground truncate">{p.srcInterface || "auto"}</span>
|
||
<span className="text-sm font-medium truncate">{p.name}</span>
|
||
{p.filter === "—"
|
||
? <span className="text-muted-foreground/40 text-xs">—</span>
|
||
: <span className="text-[11px] font-medium text-sky-600 dark:text-sky-400 truncate">{p.filter}</span>
|
||
}
|
||
<span className={cn("font-mono text-sm font-semibold tabular-nums", rttColor(p.rtt, p.loss))}>
|
||
{p.rtt === null ? "—" : `${p.rtt}мс`}
|
||
</span>
|
||
<span className={cn("font-mono text-xs tabular-nums",
|
||
p.loss === 0 ? "text-muted-foreground" :
|
||
p.loss >= 100 ? "text-red-600 dark:text-red-400" :
|
||
p.loss > 5 ? "text-red-600 dark:text-red-400" :
|
||
"text-amber-600 dark:text-amber-400",
|
||
)}>
|
||
{p.loss}%
|
||
</span>
|
||
<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>
|
||
</>
|
||
)}
|
||
</Card>
|
||
)
|
||
})}
|
||
</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">
|
||
<Field 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)
|
||
}}
|
||
/>
|
||
</Field>
|
||
|
||
<Field label="Назначение">
|
||
<ServerPickerCards
|
||
options={selectableSources.filter((s) => s.id !== speedDraft.srcServerId)}
|
||
selectedId={speedDraft.dstServerId}
|
||
onSelect={(nextDst) => {
|
||
setSpeedDraft((prev) => ({ ...prev, dstServerId: nextDst, dstInterface: "" }))
|
||
void loadSpeedInterfaces(nextDst)
|
||
}}
|
||
/>
|
||
</Field>
|
||
|
||
<Field label="Интерфейс источника">
|
||
<InterfacePickerCards
|
||
value={speedDraft.srcInterface}
|
||
onChange={(v) => setSpeedDraft((prev) => ({ ...prev, srcInterface: v }))}
|
||
options={filterActiveInterfaces(speedIfaces[speedDraft.srcServerId] ?? [])}
|
||
autoLabel="auto"
|
||
/>
|
||
</Field>
|
||
|
||
<Field label="Интерфейс назначения">
|
||
<InterfacePickerCards
|
||
value={speedDraft.dstInterface}
|
||
onChange={(v) => setSpeedDraft((prev) => ({ ...prev, dstInterface: v }))}
|
||
options={filterActiveInterfaces(speedIfaces[speedDraft.dstServerId] ?? [])}
|
||
autoLabel="auto"
|
||
/>
|
||
</Field>
|
||
|
||
<div className="grid grid-cols-3 gap-3">
|
||
<Field label="Протокол">
|
||
<SegmentedControl
|
||
value={speedDraft.protocol}
|
||
onChange={(v) => setSpeedDraft((prev) => ({ ...prev, protocol: v }))}
|
||
options={[
|
||
{ value: "tcp", label: "TCP" },
|
||
{ value: "udp", label: "UDP" },
|
||
]}
|
||
/>
|
||
</Field>
|
||
<Field 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" },
|
||
]}
|
||
/>
|
||
</Field>
|
||
<Field label="Сек">
|
||
<Input className="h-9" value={speedDraft.durationSec} onChange={(e) => setSpeedDraft((prev) => ({ ...prev, durationSec: e.target.value }))} />
|
||
</Field>
|
||
</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>
|
||
|
||
<Field
|
||
label="Источник (кто пингует)"
|
||
hint="— весь каталог, в т.ч. выключенные в инвентаре (Home Router часто «выкл.», но доступен по LAN для ping)"
|
||
>
|
||
<ServerPickerCards
|
||
options={selectableSources}
|
||
selectedId={newSrcId}
|
||
onSelect={(id) => setNewSrcId(id)}
|
||
/>
|
||
</Field>
|
||
|
||
<Field label="Интерфейс источника" hint="(необязательно)">
|
||
<InterfacePickerCards
|
||
value={newSrcInterface}
|
||
onChange={setNewSrcInterface}
|
||
options={filterActiveInterfaces(srcInterfaces)}
|
||
autoLabel="авто (по маршруту)"
|
||
busy={srcInterfacesBusy}
|
||
/>
|
||
</Field>
|
||
|
||
<Field label="Имя пробы">
|
||
<Input className="h-9 text-sm" placeholder="youtube.com"
|
||
value={newName} onChange={e => setNewName(e.target.value)} />
|
||
</Field>
|
||
|
||
<Field label="Целевой IP / хост">
|
||
<Input className="h-9 text-sm font-mono" placeholder="142.250.74.110"
|
||
value={newTarget} onChange={e => setNewTarget(e.target.value)} />
|
||
</Field>
|
||
|
||
<Field label="Связанный фильтр" hint="(необязательно)">
|
||
<LinkedFilterPickerCards
|
||
value={newFilter}
|
||
onChange={setNewFilter}
|
||
items={filters}
|
||
/>
|
||
</Field>
|
||
</div>
|
||
|
||
<SheetFooter className="px-5 py-4 border-t shrink-0 gap-2">
|
||
<Button variant="outline" className="flex-1" onClick={() => { setSheetOpen(false); setEditingProbeId(null) }}>
|
||
Отмена
|
||
</Button>
|
||
<Button className="flex-1"
|
||
disabled={!newName.trim() || !newTarget.trim() || !newSrcId}
|
||
onClick={handleSaveProbe}>
|
||
{editingProbeId ? <PencilIcon className="size-4" /> : <PlusIcon className="size-4" />}
|
||
{editingProbeId ? "Сохранить" : "Добавить"}
|
||
</Button>
|
||
</SheetFooter>
|
||
</SheetContent>
|
||
</Sheet>
|
||
</div>
|
||
)
|
||
}
|