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