"use client"
import { toast } from "sonner"
import { Suspense, useEffect, useRef, useState, useMemo, useCallback } from "react"
import { useSearchParams } from "next/navigation"
import { readProbeSource } from "@/lib/probe-source"
import { readProbeUrl, patchProbeUrl } from "@/lib/probe-url"
import { PageHeader } from "@/components/page-header"
import { FormToggle } from "@/components/form-kit"
import { Frame, FramePanel } from "@/components/reui/frame"
import { OpsPanel } from "@/components/ops-panel"
import { DataPageCard } from "@/components/data-page-card"
import {
ProbesScheduleDataGrid,
type SchedRule,
type SchedType,
} from "@/components/data-grids/probes-schedule-data-grid"
import {ProbesSpeedSchedule} from "@/components/probes-speed-schedule"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
import { type ServerTileItem } from "@/components/server-tile-rail"
import { servers, greTunnels, type GreTunnel, type Server } from "@/lib/data"
import { filtersGreResponseSchema } from "@/lib/filters-response"
import { can } from "@/lib/auth"
import { readProbeOutput, probeServersSchema } from "@/lib/probe-response"
import { useDataSource } from "@/lib/data-source"
import {
PlayIcon, SquareIcon, CopyIcon, Trash2Icon, PlusIcon,
ActivityIcon, RouteIcon, SearchIcon, NetworkIcon, RulerIcon,
ZapIcon, CheckIcon, TerminalIcon,
LoaderCircleIcon,
} from "lucide-react"
import { cn } from "@/lib/utils"
import { requestJson } from "@/shared/api/http-client"
// ─── types ────────────────────────────────────────────────────────────────────
type DiagTool = "ping" | "traceroute" | "bandwidth" | "dns" | "route" | "mtu"
type RunStatus = "running" | "done" | "error" | "cancelled"
type TraceProto = "icmp" | "udp" | "tcp"
type DnsType = "A" | "AAAA" | "MX" | "NS" | "TXT" | "CNAME" | "PTR"
interface OutputLine { text: string; kind: "normal" | "ok" | "err" | "dim" | "header" | "cmd" }
interface DiagTest {
id: string
tool: DiagTool
status: RunStatus | "error"
srcServerId: string
srcServerName: string
target: string
command: string
startedAt: number
lines: OutputLine[]
totalLines: number // final line count — reveals progressively
/** demo: симуляция в браузере; live: ответ MikroTik через бекенд */
source?: "demo" | "live"
}
// SchedRule imported from probes-schedule-data-grid
// ─── tool metadata ────────────────────────────────────────────────────────────
const TOOL_META: Record
color: string
description: string
}> = {
ping: { label: "Ping", ros: "/tool ping", Icon: ActivityIcon, color: "text-info", description: "Проверка связи и RTT" },
traceroute: { label: "Traceroute", ros: "/tool traceroute", Icon: RouteIcon, color: "text-info", description: "Трассировка маршрута" },
bandwidth: { label: "BW-тест", ros: "/tool bandwidth-test", Icon: ZapIcon, color: "text-success", description: "Пропускная способность" },
dns: { label: "DNS", ros: "DNS приложения", Icon: SearchIcon, color: "text-warning", description: "DNS на хосте приложения" },
route: { label: "Маршрут", ros: "/ip route lookup", Icon: NetworkIcon, color: "text-info", description: "Поиск активного маршрута" },
mtu: { label: "MTU-тест", ros: "/tool ping", Icon: RulerIcon, color: "text-warning", description: "Определение MTU пути" },
}
function makeApiFetch(backendUrl: string) {
return async function apiFetch(path: string, init?: RequestInit): Promise {
return requestJson(backendUrl, path, init)
}
}
async function copyProbeText(text:string){
try{await navigator.clipboard.writeText(text);toast.success("Скопировано.")}
catch{toast.error("Не удалось скопировать. Разрешите доступ к буферу обмена или выделите текст вручную.")}
}
function inferProbeLineKind(line: string): OutputLine["kind"] {
const l = line.toLowerCase()
if (l.includes("error:") || (l.includes("timeout") && l.includes("seq="))) return "err"
if (l.includes("✓")) return "ok"
if (l.startsWith(";;") || l.trim() === "") return "dim"
if (/ADDRESS|QUESTION|bandwidth-test|^ping |lookup |MTU discovery/i.test(line)) return "header"
return "normal"
}
function parseProbeOutput(text: string): OutputLine[] {
const lines = text.split("\n").map(t => ({ text: t || " ", kind: inferProbeLineKind(t) }))
return lines.length > 1000 ? [{ text: "Показаны последние 1000 строк; начало вывода сокращено.", kind: "dim" }, ...lines.slice(-1000)] : lines
}
// SpeedProbeApiRow imported from probes-speed-probes-data-grid
// ─── helpers ──────────────────────────────────────────────────────────────────
function jitter(base: number, pct = 0.15) {
return Math.max(1, Math.round(base + (Math.random() - 0.5) * base * pct * 2))
}
function seededRng(seed: string) {
let h = 0
for (let i = 0; i < seed.length; i++) h = (Math.imul(31, h) + seed.charCodeAt(i)) | 0
h = Math.abs(h)
return (n = 233280) => { h = (h * 9301 + 49297) % n; return h / n }
}
function baseRtt(srcId: string, _target: string): number {
const map: Record = { srv1: 13, srv2: 11, srv3: 5, srv4: 8, srv5: 80, srv6: 8, srv7: 14 }
return map[srcId] ?? 20
}
function randomIp(rng: () => number, base = "10.") {
return `${base}${Math.floor(rng() * 255)}.${Math.floor(rng() * 255)}.${Math.floor(rng() * 255)}`
}
// ─── output generators ────────────────────────────────────────────────────────
function genPingOutput(target: string, srcId: string, count: number, size: number, _ttl: number): OutputLine[] {
const rng = seededRng(target + srcId)
const base = baseRtt(srcId, target)
const rtts: (number | null)[] = Array.from({ length: count }, () =>
rng() < 0.04 ? null : jitter(base, 0.2),
)
const lines: OutputLine[] = [
{ text: ` SEQ HOST SIZE TTL TIME STATUS`, kind: "header" },
]
rtts.forEach((rtt, i) => {
lines.push({
text: ` ${String(i).padStart(3)} ${target.padEnd(40)} ${String(size).padStart(4)} ${String(rtt ? 118 : 0).padStart(3)} ${rtt ? `${rtt}ms`.padStart(6) : " "} ${rtt ? "" : "timeout"}`,
kind: rtt ? "normal" : "err",
})
})
const recv = rtts.filter(r => r !== null) as number[]
const loss = Math.round((count - recv.length) / count * 100)
lines.push({ text: "", kind: "dim" })
lines.push({
text: ` sent=${count} received=${recv.length} packet-loss=${loss}% min-rtt=${Math.min(...recv)}ms avg-rtt=${Math.round(recv.reduce((a, b) => a + b, 0) / recv.length)}ms max-rtt=${Math.max(...recv)}ms`,
kind: loss === 0 ? "ok" : "err",
})
return lines
}
function genTraceOutput(target: string, srcId: string, proto: TraceProto, maxHops: number): OutputLine[] {
const rng = seededRng(target + srcId + proto)
const base = baseRtt(srcId, target)
const hopCount = Math.min(maxHops, 4 + Math.floor(rng() * 3))
const lines: OutputLine[] = [
{ text: ` # ADDRESS LOSS SENT LAST AVG BEST WORST`, kind: "header" },
]
for (let i = 1; i <= hopCount; i++) {
const addr = i === 1 ? "10.200.0.2" : i === hopCount ? target : randomIp(rng, "95.213.")
const frac = i / hopCount
const rtt1 = Math.round(base * frac * jitter(1, 0.1))
const rtt2 = Math.round(base * frac * jitter(1, 0.08))
const rtt3 = Math.round(base * frac * jitter(1, 0.12))
const avg = Math.round((rtt1 + rtt2 + rtt3) / 3)
lines.push({
text: ` ${String(i).padStart(2)} ${addr.padEnd(40)} 0% 3 ${`${rtt1}ms`.padStart(6)} ${`${avg}ms`.padStart(6)} ${`${Math.min(rtt1,rtt2,rtt3)}ms`.padStart(6)} ${`${Math.max(rtt1,rtt2,rtt3)}ms`.padStart(6)}`,
kind: i === hopCount ? "ok" : "normal",
})
}
return lines
}
function genDnsOutput(target: string, srcId: string, type: DnsType): OutputLine[] {
const rng = seededRng(target + srcId + type)
const ttl = 300 - Math.floor(rng() * 200)
const time = 5 + Math.floor(rng() * 20)
const lines: OutputLine[] = [
{ text: `;; QUESTION SECTION:`, kind: "dim" },
{ text: `;; ${target}. IN ${type}`, kind: "dim" },
{ text: "", kind: "dim" },
{ text: `;; ANSWER SECTION:`, kind: "header" },
]
const count = type === "A" ? 2 + Math.floor(rng() * 2) : 1
for (let i = 0; i < count; i++) {
let value = ""
if (type === "A") value = `142.250.${74 + Math.floor(rng() * 10)}.${Math.floor(rng() * 200)}`
if (type === "AAAA") value = `2001:db8::${Math.floor(rng() * 0xffff).toString(16)}`
if (type === "MX") value = `10 mail.${target}.`
if (type === "NS") value = `ns${i + 1}.${target}.`
if (type === "TXT") value = `"v=spf1 include:${target} ~all"`
if (type === "CNAME") value = `${target}.cdn.example.com.`
if (type === "PTR") value = `${target}.in-addr.arpa.`
lines.push({ text: `${target.padEnd(24)} ${String(ttl).padStart(5)} IN ${type.padEnd(5)} ${value}`, kind: "ok" })
}
lines.push({ text: "", kind: "dim" })
lines.push({ text: `;; Query time: ${time} msec`, kind: "dim" })
lines.push({ text: `;; SERVER: 1.1.1.1`, kind: "dim" })
return lines
}
function genRouteOutput(target: string, srcId: string): OutputLine[] {
const rng = seededRng(target + srcId)
const gw = `10.200.0.${2 + Math.floor(rng() * 14)}`
const iface = ["gre-msk-fra", "gre-msk-ams", "gre-msk-spb"][Math.floor(rng() * 3)]
const prefix = target.split(".").slice(0, 3).join(".") + ".0/24"
const asn = [15169, 32934, 20940][Math.floor(rng() * 3)]
const comm = `65001:${100 + Math.floor(rng() * 4) * 100}`
return [
{ text: `Flags: X - disabled, A - active, B - blackhole, U - unreachable`, kind: "dim" },
{ text: "", kind: "dim" },
{ text: ` # DST-ADDRESS PREF-SRC GATEWAY DISTANCE SCOPE TARGET-SCOPE`, kind: "header" },
{ text: ` 0 A ${prefix.padEnd(18)} ${gw.padEnd(15)} 200 30 10`, kind: "ok" },
{ text: ` routing-table: main`, kind: "dim" },
{ text: ` bgp-as-path: ${asn}`, kind: "normal" },
{ text: ` bgp-communities: ${comm} ${asn}:5003`, kind: "normal" },
{ text: ` bgp-local-pref: 100`, kind: "dim" },
{ text: ` bgp-med: 0`, kind: "dim" },
{ text: ` bgp-origin: igp`, kind: "dim" },
{ text: ` bgp-nexthop: ${gw}`, kind: "dim" },
{ text: ` bgp-ext-communities: (nothing)`, kind: "dim" },
{ text: ` interface: ${iface}`, kind: "normal" },
{ text: ` gateway: ${gw}`, kind: "normal" },
{ text: "", kind: "dim" },
{ text: `1 route found`, kind: "ok" },
]
}
function genMtuOutput(target: string, srcId: string): OutputLine[] {
const base = baseRtt(srcId, target)
const mtu = [1476, 1472, 1468, 1400][Math.floor(Math.random() * 2)] // common GRE MTUs
const sizes = [1500, 1492, mtu + 8, mtu + 4, mtu, mtu - 4]
const lines: OutputLine[] = [
{ text: `MTU path discovery → ${target} (do-not-fragment ping)`, kind: "header" },
{ text: "", kind: "dim" },
{ text: ` SIZE RESULT RTT`, kind: "dim" },
]
sizes.forEach(sz => {
const pass = sz <= mtu
const rtt = pass ? jitter(base, 0.1) : null
lines.push({
text: ` ${String(sz).padStart(4)} ${pass ? "✓ PASS" : "✗ FAIL (frag needed)"} ${pass ? `${rtt}ms` : "—"}`,
kind: pass ? "ok" : "err",
})
})
lines.push({ text: "", kind: "dim" })
lines.push({ text: `MTU discovered: ${mtu} bytes`, kind: "ok" })
const overhead = 1500 - mtu
lines.push({ text: `Overhead: ${overhead} bytes (GRE ${overhead >= 24 ? "+IPsec" : "no IPsec"})`, kind: "dim" })
return lines
}
function genBwOutput(target: string, srcId: string, proto: string, duration: number): OutputLine[] {
const base = [410, 580, 220, 680][Math.floor(Math.random() * 4)]
seededRng(target + srcId)
const lines: OutputLine[] = [
{ text: ` status: running`, kind: "dim" },
{ text: ` direction: both`, kind: "dim" },
{ text: ` protocol: ${proto.toUpperCase()}`, kind: "dim" },
{ text: ` duration: ${duration}s`, kind: "dim" },
{ text: "", kind: "dim" },
]
for (let t = 2; t <= duration; t += 2) {
const tx = jitter(base * 0.98, 0.08)
const rx = jitter(base * 0.95, 0.09)
lines.push({
text: ` [${String(t).padStart(2)}s] tx-current: ${tx}Mbps rx-current: ${rx}Mbps`,
kind: "normal",
})
}
const txAvg = jitter(base * 0.97, 0.04)
const rxAvg = jitter(base * 0.94, 0.04)
lines.push({ text: "", kind: "dim" })
lines.push({ text: ` status: done`, kind: "ok" })
lines.push({ text: ` tx-total-average: ${txAvg}Mbps`, kind: "ok" })
lines.push({ text: ` rx-total-average: ${rxAvg}Mbps`, kind: "ok" })
return lines
}
// ─── command builder ──────────────────────────────────────────────────────────
/** RouterOS принимает в src-address только IPv4; FQDN подставлять нельзя. */
function isIpv4Literal(s: string): boolean {
const t = s.trim()
if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(t)) return false
return t.split(".").every(p => {
const n = Number.parseInt(p, 10)
return Number.isFinite(n) && n >= 0 && n <= 255
})
}
/** Превью: IPv4 из API, иначе литерал host если уже IP, иначе плейсхолдер пока DNS не готов. */
function pickSrcAddressRos(host: string, resolvedLiveIpv4: string | null | undefined): string {
if (isIpv4Literal(host)) return host.trim()
if (resolvedLiveIpv4 === undefined) return "…"
if (resolvedLiveIpv4 === null) return "?"
return resolvedLiveIpv4
}
function buildCommand(
serversList: Server[],
tool: DiagTool, src: string, target: string,
opts: { pingCount?: number; pingSize?: number; pingTtl?: number; traceProto?: TraceProto; traceMaxHops?: number; traceUseDns?: boolean; dnsType?: DnsType; mtuStart?: number; bwProto?: string; bwDuration?: number; bwTarget?: string },
resolvedSrcIpv4?: string | null,
): string {
const host = serversList.find(s => s.id === src)?.host ?? "?"
const srcRos = pickSrcAddressRos(host, resolvedSrcIpv4)
switch (tool) {
case "ping":
return `/tool ping address=${target} count=${opts.pingCount ?? 5} size=${opts.pingSize ?? 64} ttl=${opts.pingTtl ?? 64} src-address=${srcRos}`
case "traceroute":
return `/tool traceroute address=${target} max-hops=${opts.traceMaxHops ?? 30} protocol=${opts.traceProto ?? "icmp"} timeout=00:00:01 count=1 use-dns=${opts.traceUseDns ? "yes" : "no"} src-address=${srcRos}`
case "bandwidth":
return `/tool bandwidth-test address=${opts.bwTarget ?? target} duration=${opts.bwDuration ?? 10}s protocol=${opts.bwProto ?? "tcp"} direction=both`
case "dns":
return "DNS приложения: " + target + " · " + (opts.dnsType ?? "A") + " · резолвер хоста backend"
case "route":
return `/ip route lookup ip=${target}`
case "mtu":
return `/tool ping address=${target} do-not-fragment count=1 size=${opts.mtuStart ?? 1500} src-address=${srcRos}`
}
}
// ─── small UI components ──────────────────────────────────────────────────────
function NativeSelect({ value, onChange, children, className, ariaLabel }: {
value: string; onChange: (v: string) => void; children: React.ReactNode; className?: string; ariaLabel?: string
}) {
return (
onChange(e.target.value)}
className={cn(
"h-8 min-w-0 rounded-lg border border-input bg-background px-2.5 py-1 text-sm text-foreground",
"transition-colors outline-none",
"focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50",
className,
)}>
{children}
)
}
function OptionLabel({ children, htmlFor }: { children: React.ReactNode; htmlFor?: string }) {
if (htmlFor) return {children}
return {children}
}
function SegBtn({ value, current, onClick, children }: {
value: T; current: T; onClick: (v: T) => void; children: React.ReactNode
}) {
return (
onClick(value)}
className={cn(
"px-2.5 py-1 text-xs font-mono border-r last:border-r-0 border-input transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
value === current ? "bg-muted text-foreground font-semibold" : "text-muted-foreground hover:bg-muted/50",
)}>
{children}
)
}
// ─── terminal output ──────────────────────────────────────────────────────────
function TerminalOutput({ test }: { test: DiagTest }) {
const ref=useRef(null),follow=useRef(true)
const visible=test.lines.slice(0,test.totalLines)
useEffect(()=>{if(ref.current&&follow.current)ref.current.scrollTop=ref.current.scrollHeight},[visible.length])
return {const el=ref.current;if(el)follow.current=el.scrollHeight-el.scrollTop-el.clientHeight<32}}
className="h-full min-h-0 overflow-auto rounded-lg bg-muted/30 border px-3 py-3 font-mono text-xs leading-relaxed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
{test.source==="demo"?"Демо":test.tool==="dns"?"DNS приложения":test.srcServerName} · {test.command}
{visible.map((line,i)=>
{line.text||" "}
)}
}
// ─── uptime speed probes (live) ───────────────────────────────────────────────
// ─── schedule tab ─────────────────────────────────────────────────────────────
const INIT_RULES: SchedRule[] = [
{ id: "r1", srcId: "srv1", tunnelId: "gre1", type: "both", intervalMin: 15, enabled: true, lastRun: "3 мин назад", nextRunMin: 12 },
{ id: "r2", srcId: "srv1", tunnelId: "gre2", type: "ping", intervalMin: 5, enabled: true, lastRun: "1 мин назад", nextRunMin: 4 },
{ id: "r3", srcId: "srv7", tunnelId: "gre5", type: "bandwidth", intervalMin: 60, enabled: false, lastRun: "2ч назад", nextRunMin: null },
]
function ScheduleTab({
rules,
setRules,
serverOptions,
tunnelsForServer,
defaultSrc,
}: {
rules: SchedRule[]
setRules: React.Dispatch>
serverOptions: Server[]
tunnelsForServer: (serverId: string) => GreTunnel[]
defaultSrc?: string
}) {
const [showAdd, setShowAdd] = useState(false)
const [addSrc, setAddSrc] = useState(defaultSrc ?? serverOptions[0]?.id ?? "srv1")
const [selectedTunnel, setAddTun] = useState("")
const [addType, setAddType] = useState("ping")
const [addMin, setAddMin] = useState(10)
const addTunnels = useMemo(() => tunnelsForServer(addSrc), [addSrc, tunnelsForServer])
const addTun = addTunnels.some(t=>t.id===selectedTunnel) ? selectedTunnel : addTunnels[0]?.id ?? ""
const tunnelName = (srcId: string, tunnelId: string) =>
tunnelsForServer(srcId).find((t) => t.id === tunnelId)?.name
return (
setRules((p) => p.map((r) => (r.id === id ? { ...r, enabled } : r)))
}
onDelete={(id) => setRules((p) => p.filter((r) => r.id !== id))}
/>
{showAdd ? (
Сервер
{setAddSrc(value);setAddTun("")}} className="min-w-[160px]">
{serverOptions.filter(s => s.enabled).map(s => {s.name} )}
GRE-туннель
{addTunnels.map(t => {t.name} )}
Тип
setAddType(v as SchedType)}>
Ping
BW-тест
Ping + BW
Интервал (мин)
setAddMin(Math.max(1, parseInt(e.target.value) || 1))}
className="w-20 font-mono text-sm" />
{
setRules(p => [...p, { id: `r${Date.now()}`, srcId: addSrc, tunnelId: addTun, type: addType, intervalMin: addMin, enabled: true, lastRun: null, nextRunMin: addMin }])
setShowAdd(false)
}}>
Добавить
setShowAdd(false)}>Отмена
) : (
setShowAdd(true)}>
Добавить правило
)}
)
}
// ─── page ─────────────────────────────────────────────────────────────────────
export default function ProbesPage() {
return Загрузка диагностики…
}>
}
function ProbesRoute() {
const params = useSearchParams()
const url = readProbeUrl(new URLSearchParams(params.toString()))
const { mode, backendUrl, prefsHydrated } = useDataSource()
if (url.error) return {url.error}
window.history.replaceState(null, "", patchProbeUrl(new URLSearchParams(params.toString()), null) + window.location.hash)}>Сбросить контекст диагностики
if (!prefsHydrated) return Загрузка настроек диагностики…
return
}
function ProbesContent() {
const params = useSearchParams()
const { server } = readProbeUrl(new URLSearchParams(params.toString()))
const srcId = server ?? ""
const selectServer = (id: string) => window.history.pushState(null, "", patchProbeUrl(new URLSearchParams(params.toString()), id || null) + window.location.hash)
const { mode, backendUrl } = useDataSource()
const canRead = can("mm:servers:read")
const canRun = can("mm:servers:write")
const canReadGre = can("mm:filters:read")
const isLive = mode === "live"
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
const [liveServers, setLiveServers] = useState([])
const [liveLoad, setLiveLoad] = useState<"idle" | "loading" | "error">(() => (isLive ? "loading" : "idle"))
const [catalogError, setCatalogError] = useState(null)
const [catalogRevision, setCatalogRevision] = useState(0)
const [catalogReadAt, setCatalogReadAt] = useState(null)
const [greState, setGreState] = useState<{ id: string; state: "loading" | "ready" | "error"; error?: string } | null>(null)
const [greRevision, setGreRevision] = useState(0)
const [greByServer, setGreByServer] = useState>({})
const [tool, setTool] = useState("ping")
const [target, setTarget] = useState("8.8.8.8")
const [pingCount, setPingCount] = useState(5)
const [pingSize, setPingSize] = useState(64)
const [pingTtl] = useState(64)
const [traceProto, setTraceProto] = useState("icmp")
const [traceUseDns, setTraceUseDns] = useState(false)
const [traceHops] = useState(30)
const [dnsType, setDnsType] = useState("A")
const [bwChoice, setBwChoice] = useState<{ serverId: string; id: string } | null>(null)
const [bwProto, setBwProto] = useState<"tcp" | "udp">("tcp")
const [bwDuration, setBwDuration] = useState(10)
const [runBusy, setRunBusy] = useState(false)
/** В live: IPv4 для src-address (DNS A-запись к host API); undefined = грузим, null = не удалось */
const [source, setSource] = useState<{ key: string; ipv4?: string | null; error?: string; loading: boolean } | null>(null)
const [sourceRevision, setSourceRevision] = useState(0)
useEffect(() => {
if (!isLive || !canRead) return
let cancelled = false
const ctrl = new AbortController()
queueMicrotask(() => { if (!cancelled) { setLiveLoad("loading"); setCatalogError(null) } })
void apiFetch("/api/servers", { signal: ctrl.signal })
.then(raw => {
const result = probeServersSchema.safeParse(raw)
if (!result.success) throw new Error("Некорректный ответ API: каталог устройств не подтверждён.")
if (cancelled) return
setLiveServers(result.data.map(s => ({
id: String(s.id), name: s.name || s.host, host: s.host, model: "—", os: "—",
site: s.site ?? "", country: s.country ?? "UN", asn: s.asn ?? "", type: s.type,
enabled: s.enabled, status: s.status ?? "unknown",
latency: s.latency != null ? Math.round(s.latency) : null, sessions: 0,
})))
setCatalogReadAt(Date.now())
setLiveLoad("idle")
})
.catch(error => {
if (cancelled) return
setCatalogError(error instanceof Error ? error.message : "Не удалось прочитать устройства.")
setLiveLoad("error")
})
return () => { cancelled = true; ctrl.abort() }
}, [isLive, canRead, apiFetch, catalogRevision])
const allServers = useMemo(() => {
if (!isLive) return servers
return liveServers
}, [isLive, liveServers])
const probeRailItems = useMemo(() => (
allServers.map((s) => ({
id: s.id,
name: s.name,
host: s.host,
site: s.site,
country: s.country,
status: s.status,
type: s.type,
enabled: s.enabled,
selectable: s.enabled,
}))
), [allServers])
useEffect(() => {
if (!isLive || !canReadGre || liveLoad !== "idle" || !allServers.some(s => s.id === srcId && s.enabled)) return
let cancelled = false
const ctrl = new AbortController()
queueMicrotask(() => { if (!cancelled) setGreState({ id: srcId, state: "loading" }) })
void apiFetch("/api/filters/gre-tunnels?serverId=" + encodeURIComponent(srcId), { signal: ctrl.signal })
.then(raw => {
const result = filtersGreResponseSchema.safeParse(raw)
if (!result.success || result.data.tunnels.some(tunnel => tunnel.serverId !== srcId)) throw new Error("Некорректный ответ API: GRE выбранного устройства не подтверждены.")
if (cancelled) return
setGreByServer(prev => ({ ...prev, [srcId]: result.data.tunnels }))
setGreState({ id: srcId, state: "ready" })
})
.catch(error => {
if (cancelled) return
setGreState({ id: srcId, state: "error", error: error instanceof Error ? error.message : "Не удалось прочитать GRE." })
})
return () => { cancelled = true; ctrl.abort() }
}, [isLive, canReadGre, liveLoad, allServers, srcId, apiFetch, greRevision])
const tunnelsForSrc = useMemo(() => {
if (isLive) return greByServer[srcId] ?? []
return greTunnels.filter(t => t.serverId === srcId)
}, [isLive, greByServer, srcId])
// ── run state ──
const [tests, setTests] = useState([])
const [tab, setTab] = useState<"history" | "schedule">("history")
const [rules, setRules] = useState(INIT_RULES)
const nextId = useRef(1)
const targetRef = useRef(null)
const runButtonRef = useRef(null)
/** Живой POST /probes/run — отмена через fetch AbortSignal */
const liveProbeRunRef = useRef<{ testId: string; ctrl: AbortController } | null>(null)
const runLock = useRef(false)
const alive = useRef(true)
useEffect(() => {
alive.current = true
return () => { alive.current = false; liveProbeRunRef.current?.ctrl.abort() }
}, [])
const bwTunnels = tunnelsForSrc
const bwTunId = bwChoice?.serverId === srcId ? bwChoice.id : (bwTunnels[0]?.id ?? "")
const setBwTunId = (id: string) => setBwChoice({ serverId: srcId, id })
const srcServer = useMemo(() => allServers.find(s => s.id === srcId), [allServers, srcId])
const sourceKey = JSON.stringify([srcId, srcServer?.host])
const sourceRequired = tool === "ping" || tool === "traceroute" || tool === "mtu"
const sourceCurrent = source?.key === sourceKey ? source : null
const rosSrcV4 = sourceCurrent?.ipv4
useEffect(() => {
if (!isLive || !canRead || liveLoad !== "idle" || !srcServer?.enabled || !sourceRequired) return
const host = srcServer.host, id = srcServer.id
if (!/^[1-9][0-9]*$/.test(id) || !Number.isSafeInteger(Number(id))) return
let cancelled = false
const ctrl = new AbortController()
queueMicrotask(() => { if (!cancelled) setSource({ key: sourceKey, loading: true }) })
void apiFetch("/api/servers/" + id + "/ros-src-address", { signal: ctrl.signal })
.then(raw => { const ipv4 = readProbeSource(raw, host); if (!cancelled) setSource({ key: sourceKey, loading: false, ipv4 }) })
.catch(error => { if (!cancelled) setSource({ key: sourceKey, loading: false, error: error instanceof Error ? error.message : "Источник preview неизвестен." }) })
return () => { cancelled = true; ctrl.abort() }
}, [isLive, canRead, liveLoad, srcServer, sourceRequired, sourceKey, apiFetch, sourceRevision])
const greReady = !isLive || (greState?.id === srcId && greState.state === "ready")
const bwTun = useMemo(() => bwTunnels.find(t => t.id === bwTunId), [bwTunnels, bwTunId])
// current command preview
const cmdPreview = useMemo(() => buildCommand(allServers, tool, srcId, target, {
pingCount, pingSize, pingTtl, traceProto, traceMaxHops: traceHops, traceUseDns, dnsType,
bwTarget: bwTun?.remoteAddress, bwProto, bwDuration,
}, isLive ? rosSrcV4 : undefined), [allServers, tool, srcId, target, pingCount, pingSize, pingTtl, traceProto, traceHops, traceUseDns, dnsType, bwTun, bwProto, bwDuration, isLive, rosSrcV4])
// ── run test ──
const runTest = useCallback(async () => {
const srv = allServers.find(s => s.id === srcId)
if (!canRun || (tool === "bandwidth" && (!bwTun?.enabled || !bwTun.remoteAddress.trim() || !greReady || (isLive && !canReadGre))) || !srv || !srv.enabled || runLock.current || (isLive && liveLoad !== "idle")) return
const id = String(nextId.current++)
const numericServerId = Number.parseInt(srcId, 10)
if (isLive) {
if (!Number.isFinite(numericServerId)) return
runLock.current = true
setRunBusy(true)
const abortCtrl = new AbortController()
liveProbeRunRef.current = { testId: id, ctrl: abortCtrl }
let timedOut = false
const timeout = setTimeout(() => { timedOut = true; abortCtrl.abort() }, 60_000)
setTests(p => [{
id,
tool,
status: "running",
srcServerId: srcId,
srcServerName: srv.name,
target: tool === "bandwidth" ? (bwTun?.name ?? target) : target,
command: cmdPreview,
startedAt: Date.now(),
lines: [{ text: tool === "dns" ? "Выполняется DNS-запрос приложения…" : "Выполняется запрос к MikroTik через API…", kind: "dim" }],
totalLines: 1,
source: "live",
}, ...p.slice(0, 9)])
setTab("history")
try {
const peer =
bwTun?.remoteAddress
? allServers.find(s => s.host.trim().toLowerCase() === bwTun.remoteAddress.trim().toLowerCase())
: undefined
const body = {
tool,
target: tool === "bandwidth" ? undefined : target.trim(),
pingCount,
pingSize,
pingTtl,
traceProto,
traceMaxHops: traceHops,
traceHopTimeout: tool === "traceroute" ? "1s" : undefined,
traceProbeCount: tool === "traceroute" ? 1 : undefined,
traceUseDns: tool === "traceroute" ? traceUseDns : undefined,
dnsType,
bwRemoteAddress: tool === "bandwidth" ? bwTun?.remoteAddress : undefined,
dstServerId: peer ? Number.parseInt(peer.id, 10) : undefined,
bwProto,
bwDuration,
}
const res = await apiFetch(`/api/servers/${numericServerId}/probes/run`, {
method: "POST",
body: JSON.stringify(body),
signal: abortCtrl.signal,
})
if (!alive.current || abortCtrl.signal.aborted) return
const output = readProbeOutput(res)
const lines = parseProbeOutput(output || "Запрос выполнен: API вернул пустой вывод.")
setTests(p => p.map(t => (t.id === id
? {
...t,
lines,
totalLines: lines.length,
status: "done",
}
: t)))
} catch (e) {
if (!alive.current) return
const aborted = abortCtrl.signal.aborted ||
(typeof DOMException !== "undefined" && e instanceof DOMException && e.name === "AbortError")
|| (e instanceof Error && e.name === "AbortError")
if (aborted) {
setTests(p => p.map(t => (t.id === id
? {
...t,
lines: [{ text: timedOut ? "Время ожидания истекло. Результат на устройстве не подтверждён; повтор выполняется вручную." : "Ожидание отменено. Остановка на устройстве не подтверждена.", kind: timedOut ? "err" : "dim" }],
totalLines: 1,
status: timedOut ? "error" : "cancelled",
}
: t)))
} else {
const msg = e instanceof Error ? e.message : String(e)
setTests(p => p.map(t => (t.id === id
? {
...t,
lines: [{ text: msg, kind: "err" }],
totalLines: 1,
status: "error",
}
: t)))
}
} finally {
clearTimeout(timeout)
if (liveProbeRunRef.current?.testId === id) liveProbeRunRef.current = null
runLock.current = false
if (alive.current) {
setRunBusy(false)
requestAnimationFrame(() => { if (alive.current && (document.activeElement === runButtonRef.current || document.activeElement === document.body)) (targetRef.current ?? runButtonRef.current)?.focus() })
}
}
return
}
let lines: OutputLine[] = []
switch (tool) {
case "ping": lines = genPingOutput(target, srcId, pingCount, pingSize, pingTtl); break
case "traceroute": lines = genTraceOutput(target, srcId, traceProto, traceHops); break
case "dns": lines = genDnsOutput(target, srcId, dnsType); break
case "route": lines = genRouteOutput(target, srcId); break
case "mtu": lines = genMtuOutput(target, srcId); break
case "bandwidth": lines = genBwOutput(bwTun?.remoteAddress ?? target, srcId, bwProto, bwDuration); break
}
const test: DiagTest = {
id,
tool,
status: "done",
srcServerId: srcId,
srcServerName: srv.name,
target: tool === "bandwidth" ? (bwTun?.name ?? target) : target,
command: cmdPreview,
startedAt: Date.now(),
lines,
totalLines: lines.length,
source: "demo",
}
setTests(p => [test, ...p.slice(0, 9)])
setTab("history")
}, [
isLive,
liveLoad,
canRun,
canReadGre,
greReady,
allServers,
srcId,
tool,
target,
pingCount,
pingSize,
pingTtl,
traceProto,
traceUseDns,
traceHops,
dnsType,
bwTun,
bwProto,
bwDuration,
cmdPreview,
apiFetch,
])
const stopTest = (id: string) => {
if (liveProbeRunRef.current?.testId === id) { liveProbeRunRef.current.ctrl.abort(); return }
setTests(p => p.map(t => (t.id === id && t.status === "running"
? { ...t, status: "cancelled" as const, totalLines: t.lines.length }
: t)))
}
const clearTest = (id: string) => {
if (liveProbeRunRef.current?.testId === id) liveProbeRunRef.current.ctrl.abort()
setTests(p => p.filter(t => t.id !== id))
runButtonRef.current?.focus()
}
const running = tests.filter(t => t.status === "running")
if (!canRead) return Для просмотра устройств требуется mm:servers:read.
if (isLive && liveLoad === "loading" && catalogReadAt === null) {
return (
Загрузка серверов из бекенда…
)
}
return (
{isLive && setCatalogRevision(n => n + 1)}>Обновить }
{running.length > 0
? {
running.forEach(test => stopTest(test.id))
}}>
Остановить все
: null}
>
}
/>
}
>
{!server &&
Выберите устройство для диагностики. Выбор сохраняется в URL.
}
{server && (!isLive || catalogReadAt !== null) && !srcServer &&
Указанное устройство отсутствует в текущем каталоге. Другое устройство автоматически не выбирается.
selectServer("")}>Сбросить выбор устройства }
{srcServer && !srcServer.enabled &&
Выбранное устройство выключено. Диагностика недоступна.
}
{isLive && liveLoad === "error" && (
{catalogReadAt === null ? "Устройства пока неизвестны." : "Не удалось обновить устройства. Последний список сохранён; запуск приостановлен."}
{catalogError}
)}
{isLive && catalogReadAt !== null &&
Каталог получен: {new Date(catalogReadAt).toLocaleTimeString("ru-RU")}{liveLoad === "loading" ? " · обновляется" : ""}
}
{isLive && liveLoad === "idle" && allServers.length === 0 &&
В каталоге нет устройств. Добавьте устройство в разделе «Серверы».
}
{!isLive &&
Демо: запросы не отправляются; результаты синтетические.
}
{isLive && (
Где выполняются тесты
Сетевые тесты выполняются на выбранном устройстве. DNS использует резолвер приложения. Traceroute ограничен примерно минутой; сокращение числа хопов указывается в ответе. Стоп отменяет ожидание, но не подтверждает остановку на устройстве.
)}
{!canRun &&
Для запуска диагностики требуется mm:servers:write.
}
{isLive && tool === "bandwidth" && !canReadGre &&
Для выбора GRE требуется mm:filters:read.
}
{isLive && tool === "bandwidth" && canReadGre && (
{greState?.id === srcId && greState.state === "error" ?
GRE недоступны. Последний список сохранён; BW-тест приостановлен. {greState.error}
setGreRevision(n => n + 1)}>Повторить GRE
: !greReady ?
GRE выбранного устройства загружаются…
: !bwTunnels.length ?
У выбранного устройства нет GRE-туннелей.
: !bwTun?.enabled || !bwTun.remoteAddress.trim() ?
Выберите включённый GRE с адресом назначения.
: null}
)}
{isLive && srcServer?.enabled && sourceRequired && (
{sourceCurrent?.error ?
{sourceCurrent.error}
setSourceRevision(n => n + 1)}>Повторить определение IPv4
: !sourceCurrent || sourceCurrent.loading ?
Определяем IPv4 для preview…
:
{sourceCurrent.ipv4 === null ? "IPv4 для preview не определён." : "IPv4 для preview: " + sourceCurrent.ipv4}
}
Адрес справочный: при запуске определяется заново.
)}
{/* ── tool selector + config ── */}
{/* tool chips */}
{(Object.entries(TOOL_META) as [DiagTool, typeof TOOL_META[DiagTool]][]).map(([t, m]) => {
const active = tool === t
return (
setTool(t)}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-full border text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
active
? "border-foreground bg-foreground text-background"
: "border-border text-muted-foreground hover:text-foreground hover:border-foreground/40",
)}>
{m.label}
)
})}
{/* main config row */}
{/* target — all tools except bandwidth */}
{tool !== "bandwidth" && (
{tool === "dns" ? "Домен" : tool === "route" ? "Адрес назначения IPv4" : "Цель (IP или домен)"}
setTarget(e.target.value)}
placeholder={tool === "dns" ? "google.com" : tool === "route" ? "8.8.8.8" : "8.8.8.8"}
aria-label={tool === "dns" ? "Домен" : tool === "route" ? "Адрес назначения IPv4" : "Цель диагностики"}
className="font-mono text-sm"
onKeyDown={e => e.key === "Enter" && runTest()}
/>
)}
{/* bandwidth: select GRE tunnel */}
{tool === "bandwidth" && (
GRE-туннель (цель)
t.id === bwTunId) ? bwTunId : ""} onChange={setBwTunId} className="min-w-[180px]">
{!bwTunnels.some(t => t.id === bwTunId) && Выберите GRE-туннель }
{bwTunnels.map(t => {t.name} )}
)}
{/* inline quick options */}
{tool === "ping" && (
<>
Количество пакетов
{[5, 10, 25, 100].map(n => {n} )}
Размер (байт)
{[64, 128, 512, 1472].map(n => {n} )}
>
)}
{tool === "traceroute" && (
<>
Протокол
{(["icmp", "udp", "tcp"] as TraceProto[]).map(p => {p.toUpperCase()} )}
Имена хопов (use-dns)
Как в RouterOS: резолвить IP промежуточных узлов в DNS-имена на самом MikroTik.
>
)}
{tool === "dns" && (
Тип записи
{(["A", "AAAA", "MX", "NS", "TXT", "CNAME", "PTR"] as DnsType[]).map(t => {t} )}
)}
{tool === "bandwidth" && (
<>
Протокол
{(["tcp", "udp"] as const).map(p => {p.toUpperCase()} )}
Длительность
{[5, 10, 30].map(n => {n}с )}
>
)}
void runTest()}
disabled={!canRun || (tool === "bandwidth" && (!bwTun?.enabled || !bwTun.remoteAddress.trim() || !greReady || (isLive && !canReadGre))) || runBusy || !srcServer?.enabled || (isLive && liveLoad !== "idle") || (tool === "bandwidth" && !bwTunnels.length)}
className="h-8 shrink-0 gap-1.5 self-end"
>
{runBusy ? : }
Запустить
{/* RouterOS command preview */}
[{tool === "dns" ? (isLive ? "backend" : "демо") : srcServer?.name}]
$
{cmdPreview}
void copyProbeText(cmdPreview)}
className="shrink-0 text-muted-foreground hover:text-foreground transition-colors ml-auto">
{/* ── tabs ── */}
{([
["history", "История тестов"],
["schedule", "Расписание"],
] as const).map(([t, label]) => (
setTab(t)}
className={cn(
"px-4 py-2 text-sm font-medium -mb-px border-b-2 transition-colors",
tab === t
? "border-foreground text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground",
)}>
{label}
{t === "history" && tests.length > 0 && (
{tests.length}
)}
))}
{/* history */}
{tab === "history" && (
tests.length === 0 ? (
Запустите тест — результаты появятся здесь
) : (
{tests.map(test => {
const { Icon, color, label } = TOOL_META[test.tool]
return (
{/* header */}
{label}
{test.target}
{test.tool === "dns" ? "DNS приложения" : "← " + test.srcServerName}
{test.status === "running" && (
<>
ожидаем ответ
stopTest(test.id)}>
Стоп
>
)}
{test.status === "done" && {test.source === "live" ? "ответ API" : "демо"} }
{test.status === "cancelled" && ожидание отменено }
{test.status === "error" && (
ошибка
)}
void copyProbeText(test.lines.map(l => l.text).join("\n"))}
className="size-11 md:size-8 flex items-center justify-center rounded text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring hover:text-foreground hover:bg-muted transition-colors">
clearTest(test.id)}
className="size-11 md:size-8 flex items-center justify-center rounded text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring hover:text-destructive hover:bg-destructive/10 transition-colors">
{/* terminal output */}
)
})}
)
)}
{/* schedule */}
{tab === "schedule" && (
isLive
?
: (
greTunnels.filter(t => t.serverId === sid)}
defaultSrc={srcId}
/>
)
)}
)
}