"use client" import { useEffect, useRef, useState, useMemo, useCallback } from "react" import { PageHeader } from "@/components/page-header" import { Card, CardContent } from "@/components/ui/card" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { servers, greTunnels, type GreTunnel, type Server } from "@/lib/data" import { useDataSource } from "@/lib/data-source" import { PlayIcon, SquareIcon, CopyIcon, Trash2Icon, PlusIcon, ActivityIcon, RouteIcon, SearchIcon, NetworkIcon, RulerIcon, ZapIcon, ClockIcon, CheckIcon, TerminalIcon, LoaderCircleIcon, AlertCircleIcon, } from "lucide-react" import { cn } from "@/lib/utils" // ─── types ──────────────────────────────────────────────────────────────────── type DiagTool = "ping" | "traceroute" | "bandwidth" | "dns" | "route" | "mtu" type RunStatus = "running" | "done" | "error" 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" } type SchedType = "ping" | "bandwidth" | "both" interface SchedRule { id: string; srcId: string; tunnelId: string; type: SchedType intervalMin: number; enabled: boolean lastRun: string | null; nextRunMin: number | null } // ─── tool metadata ──────────────────────────────────────────────────────────── const TOOL_META: Record color: string description: string }> = { ping: { label: "Ping", ros: "/tool ping", Icon: ActivityIcon, color: "text-sky-500", description: "Проверка связи и RTT" }, traceroute: { label: "Traceroute", ros: "/tool traceroute", Icon: RouteIcon, color: "text-violet-500", description: "Трассировка маршрута" }, bandwidth: { label: "BW-тест", ros: "/tool bandwidth-test", Icon: ZapIcon, color: "text-emerald-500", description: "Пропускная способность" }, dns: { label: "DNS", ros: "/resolve", Icon: SearchIcon, color: "text-amber-500", description: "Разрешение DNS-имён" }, route: { label: "Маршрут", ros: "/ip route lookup", Icon: NetworkIcon, color: "text-blue-500", description: "Поиск активного маршрута" }, mtu: { label: "MTU-тест", ros: "/tool ping", Icon: RulerIcon, color: "text-orange-500", description: "Определение MTU пути" }, } function makeApiFetch(backendUrl: string) { return async function apiFetch(path: string, init?: RequestInit): Promise { const headers: HeadersInit = init?.body ? { "Content-Type": "application/json" } : {} const finalRes = await fetch(backendUrl.replace(/\/$/, "") + path, { ...init, headers: { ...headers, ...(init?.headers ?? {}) }, }) if (!finalRes.ok) { const err = await finalRes.json().catch(() => ({ error: finalRes.statusText })) as { error?: string } throw new Error(err.error ?? finalRes.statusText) } return finalRes.json() as Promise } } 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("sent=") || l.includes("received=") || l.includes("packet-loss") || 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[] { return text.split("\n").map(t => ({ text: t || " ", kind: inferProbeLineKind(t) })) } interface BackendServerRow { id: number name: string host: string site: string country: string asn: string type: Server["type"] enabled: boolean status?: Server["status"] latency?: number | null } interface SpeedProbeApiRow { id: string srcServerId: string dstServerId: string srcInterface: string dstInterface: string protocol: string direction: string durationSec: string enabled: boolean lastRunAt: string | null lastTxAvgMbps: number | null lastRxAvgMbps: number | null lastStatus: string | null lastError: string | null } // ─── 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 `/resolve ${target} type=${opts.dnsType ?? "A"} server=1.1.1.1` 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 }: { value: string; onChange: (v: string) => void; children: React.ReactNode; className?: string }) { return ( ) } function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) { return ( ) } function OptionLabel({ children }: { children: React.ReactNode }) { return

{children}

} function SegBtn({ value, current, onClick, children }: { value: T; current: T; onClick: (v: T) => void; children: React.ReactNode }) { return ( ) } // ─── terminal output ────────────────────────────────────────────────────────── function TerminalOutput({ test }: { test: DiagTest }) { const ref = useRef(null) const visible = test.lines.slice(0, test.totalLines) useEffect(() => { if (ref.current) ref.current.scrollTop = ref.current.scrollHeight }, [visible.length]) return (
{/* command line */}
[{test.srcServerName}] $ {test.command} {test.status === "running" && ( )}
{/* output */} {visible.map((line, i) => (
{line.text || " "}
))} {test.status === "running" && visible.length < test.lines.length && (
)}
) } // ─── uptime speed probes (live) ─────────────────────────────────────────────── function ScheduleSpeedProbesLive({ apiFetch, serversForName, }: { apiFetch: ReturnType serversForName: Server[] }) { const [rows, setRows] = useState([]) const [loading, setLoading] = useState(true) const [err, setErr] = useState(null) useEffect(() => { let cancelled = false setLoading(true) setErr(null) void apiFetch<{ probes: SpeedProbeApiRow[] }>("/api/uptime/speed-probes") .then(d => { if (!cancelled) setRows(d.probes ?? []) }) .catch(e => { if (!cancelled) setErr(e instanceof Error ? e.message : String(e)) }) .finally(() => { if (!cancelled) setLoading(false) }) return () => { cancelled = true } }, [apiFetch]) const name = (id: string) => serversForName.find(s => s.id === id)?.name ?? id if (loading) { return (
Загрузка расписания из БД…
) } if (err) { return (
{err}
) } return (
{rows.length === 0 ? (

Нет записей speed-test в мониторинге

Настраиваются через API PUT /api/uptime/speed-probes или связанный UI.

) : ( <>
Источник Назначение Протокол Сек Вкл Последний запуск
{rows.map(r => (
{name(r.srcServerId)}{r.srcInterface ? ` · ${r.srcInterface}` : ""} {name(r.dstServerId)}{r.dstInterface ? ` · ${r.dstInterface}` : ""} {r.protocol.toUpperCase()} {r.durationSec}s {r.enabled ? "да" : "нет"} {r.lastRunAt ?? "—"} {r.lastStatus === "done" && r.lastTxAvgMbps != null && ( TX≈{r.lastTxAvgMbps.toFixed(1)} RX≈{(r.lastRxAvgMbps ?? 0).toFixed(1)} Mb/s )} {r.lastStatus === "error" && r.lastError && ( {r.lastError} )}
))}
)}

Данные из коллектора uptime (та же БД, что и дашборд). Редактирование — через настройки мониторинга / API.

) } // ─── 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, }: { rules: SchedRule[] setRules: React.Dispatch> serverOptions: Server[] tunnelsForServer: (serverId: string) => GreTunnel[] }) { const [showAdd, setShowAdd] = useState(false) const [addSrc, setAddSrc] = useState(serverOptions[0]?.id ?? "srv1") const [addTun, setAddTun] = useState("") const [addType, setAddType] = useState("ping") const [addMin, setAddMin] = useState(10) const addTunnels = useMemo(() => tunnelsForServer(addSrc), [addSrc, tunnelsForServer]) useEffect(() => { const list = tunnelsForServer(addSrc) if (list.length && !list.some(t => t.id === addTun)) { setAddTun(list[0]?.id ?? "") } }, [addSrc, addTun, tunnelsForServer]) const typeLabel: Record = { ping: "Ping", bandwidth: "BW-тест", both: "Ping + BW" } return (
{rules.length === 0 ? (

Нет правил расписания

) : ( <>
Туннель Сервер Тип Интервал Последний / следующий
{rules.map(rule => { const src = serverOptions.find(s => s.id === rule.srcId) const tun = tunnelsForServer(rule.srcId).find(t => t.id === rule.tunnelId) return (
setRules(p => p.map(r => r.id === rule.id ? { ...r, enabled: v } : r))} /> {tun?.name ?? rule.tunnelId} {src?.name ?? rule.srcId} {typeLabel[rule.type]} каждые {rule.intervalMin} мин
{rule.lastRun && {rule.lastRun}} {rule.nextRunMin != null && rule.enabled && ( · через {rule.nextRunMin} мин )}
) })}
)}
{showAdd ? (
Новое правило
Сервер {serverOptions.filter(s => s.enabled).map(s => )}
GRE-туннель {addTunnels.map(t => )}
Тип setAddType(v as SchedType)}>
Интервал (мин) setAddMin(Math.max(1, parseInt(e.target.value) || 1))} className="w-20 font-mono text-sm" />
) : ( )}
) } // ─── page ───────────────────────────────────────────────────────────────────── export default function ProbesPage() { const { mode, backendUrl } = useDataSource() 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 [greByServer, setGreByServer] = useState>({}) const [tool, setTool] = useState("ping") const [srcId, setSrcId] = useState(servers[0]?.id ?? "srv1") 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 [bwTunId, setBwTunId] = useState("") 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 [rosSrcV4, setRosSrcV4] = useState(undefined) useEffect(() => { if (!isLive) { queueMicrotask(() => setLiveLoad("idle")) return } setLiveLoad("loading") void apiFetch("/api/servers") .then(rows => { const mapped: Server[] = rows.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 ?? "offline") as Server["status"], latency: s.latency != null ? Math.round(s.latency) : null, sessions: 0, })) setLiveServers(mapped) setLiveLoad("idle") }) .catch(() => { setLiveServers([]) setLiveLoad("error") }) }, [isLive, apiFetch]) const allServers = useMemo(() => { if (!isLive) return servers if (liveServers.length > 0) return liveServers if (liveLoad === "error") return servers return [] }, [isLive, liveServers, liveLoad]) useEffect(() => { if (!isLive) { setRosSrcV4(undefined) return } const n = Number.parseInt(srcId, 10) if (!Number.isFinite(n)) { setRosSrcV4(undefined) return } let cancelled = false setRosSrcV4(undefined) void apiFetch<{ ipv4: string | null }>(`/api/servers/${n}/ros-src-address`) .then((r) => { if (!cancelled) setRosSrcV4(r.ipv4) }) .catch(() => { if (!cancelled) setRosSrcV4(null) }) return () => { cancelled = true } }, [isLive, srcId, apiFetch]) const ensureGre = useCallback(async (serverId: string) => { if (!isLive) return try { const d = await apiFetch<{ tunnels: GreTunnel[] }>( `/api/filters/gre-tunnels?serverId=${encodeURIComponent(serverId)}`, ) setGreByServer(prev => ({ ...prev, [serverId]: d.tunnels })) } catch { setGreByServer(prev => ({ ...prev, [serverId]: [] })) } }, [isLive, apiFetch]) useEffect(() => { if (!isLive || !srcId) return void ensureGre(srcId) }, [isLive, srcId, ensureGre]) const tunnelsForSrc = useMemo(() => { if (isLive) return greByServer[srcId] ?? [] return greTunnels.filter(t => t.serverId === srcId) }, [isLive, greByServer, srcId]) useEffect(() => { const first = allServers[0]?.id if (!first) return if (!allServers.some(s => s.id === srcId)) setSrcId(first) }, [allServers, srcId]) useEffect(() => { const list = isLive ? (greByServer[srcId] ?? []) : greTunnels.filter(t => t.serverId === srcId) if (list.length && !list.some(t => t.id === bwTunId)) setBwTunId(list[0]!.id) }, [isLive, greByServer, srcId, bwTunId]) // ── run state ── const [tests, setTests] = useState([]) const [tab, setTab] = useState<"history" | "schedule">("history") const [rules, setRules] = useState(INIT_RULES) const nextId = useRef(1) const tickRef = useRef | null>(null) /** Живой POST /probes/run — отмена через fetch AbortSignal */ const liveProbeRunRef = useRef<{ testId: string; ctrl: AbortController } | null>(null) const bwTunnels = tunnelsForSrc const srcServer = useMemo(() => allServers.find(s => s.id === srcId), [allServers, srcId]) 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]) // ── progressive reveal tick ── useEffect(() => { tickRef.current = setInterval(() => { setTests(prev => { const hasRunning = prev.some(t => t.status === "running") if (!hasRunning) return prev return prev.map(t => { if (t.source === "live") return t if (t.status !== "running") return t const speed = t.tool === "dns" || t.tool === "route" ? t.lines.length : 1 const nextTotal = Math.min(t.totalLines + speed, t.lines.length) const done = nextTotal >= t.lines.length return { ...t, totalLines: nextTotal, status: done ? "done" : "running" } }) }) }, 400) return () => { if (tickRef.current) clearInterval(tickRef.current) } }, []) // ── run test ── const runTest = useCallback(async () => { const srv = allServers.find(s => s.id === srcId) if (!srv) return const id = String(nextId.current++) const numericServerId = Number.parseInt(srcId, 10) if (isLive) { if (!Number.isFinite(numericServerId)) return setRunBusy(true) const abortCtrl = new AbortController() liveProbeRunRef.current = { testId: id, ctrl: abortCtrl } 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: "Выполняется запрос к 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<{ output: string }>(`/api/servers/${numericServerId}/probes/run`, { method: "POST", body: JSON.stringify(body), signal: abortCtrl.signal, }) const lines = parseProbeOutput(res.output ?? "") const looksErr = (res.output ?? "").trim().toLowerCase().startsWith("error:") setTests(p => p.map(t => (t.id === id ? { ...t, lines, totalLines: lines.length, status: looksErr ? "error" : "done", } : t))) } catch (e) { const 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: "Запрос отменён (стоп или закрытие запроса).", kind: "dim" }], totalLines: 1, status: "done", } : 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 { if (liveProbeRunRef.current?.testId === id) liveProbeRunRef.current = null setRunBusy(false) } 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, allServers, srcId, tool, target, pingCount, pingSize, pingTtl, traceProto, traceUseDns, traceHops, dnsType, bwTun, bwTunId, bwProto, bwDuration, cmdPreview, apiFetch, ]) const stopTest = (id: string) => { if (liveProbeRunRef.current?.testId === id) liveProbeRunRef.current.ctrl.abort() setTests(p => p.map(t => (t.id === id && t.status === "running" ? { ...t, status: "done" as const, totalLines: t.lines.length } : t))) } const clearTest = (id: string) => setTests(p => p.filter(t => t.id !== id)) const running = tests.filter(t => t.status === "running") if (isLive && liveLoad === "loading") { return (

Загрузка серверов из бекенда…

) } return (
0 ? : undefined } />
{isLive && liveLoad === "error" && (
Бекенд недоступен — переключитесь в режим «Демо» в настройках источника данных или проверьте URL API.
)} {isLive && (

Режим «Живые»: ping/traceroute/route/mtu/bandwidth выполняются на выбранном MikroTik; для «Источника» поле src-address — только IPv4 (FQDN резолвится на бекенде). Traceroute через REST: у MikroTik лимит сессии ~60 с (параметры команды это не продлевают); у нас timeout в формате HH:MM:SS, count=1, max-hops при необходимости уменьшается автоматически. «Стоп» прерывает HTTP к бекенду. DNS — резолвер приложения, не MikroTik.

)} {/* ── tool selector + config ── */} {/* tool chips */}
{(Object.entries(TOOL_META) as [DiagTool, typeof TOOL_META[DiagTool]][]).map(([t, m]) => { const active = tool === t return ( ) })}
{/* main config row */}
{/* source server */}
Источник {allServers.filter(s => s.enabled).map(s => ( ))}
{/* target — all tools except bandwidth */} {tool !== "bandwidth" && (
{tool === "dns" ? "Домен" : tool === "route" ? "Destination IP" : "Цель (IP или домен)"} setTarget(e.target.value)} placeholder={tool === "dns" ? "google.com" : tool === "route" ? "8.8.8.8" : "8.8.8.8"} className="font-mono text-sm" onKeyDown={e => e.key === "Enter" && runTest()} />
)} {/* bandwidth: select GRE tunnel */} {tool === "bandwidth" && (
GRE-туннель (цель) {bwTunnels.map(t => )}
)} {/* 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", "PTR"] as DnsType[]).map(t => {t})}
)} {tool === "bandwidth" && ( <>
Протокол
{(["tcp", "udp"] as const).map(p => {p.toUpperCase()})}
Длительность
{[5, 10, 30].map(n => {n}с)}
)}
{/* RouterOS command preview */}
[{srcServer?.name}] $ {cmdPreview}
{/* ── tabs ── */}
{([ ["history", "История тестов"], ["schedule", "Расписание"], ] as const).map(([t, label]) => ( ))}
{/* history */} {tab === "history" && ( tests.length === 0 ? (

Запустите тест — результаты появятся здесь

) : (
{tests.map(test => { const { Icon, color, label } = TOOL_META[test.tool] return ( {/* header */}
{label} {test.target} ← {test.srcServerName}
{test.status === "running" && ( <> запущен )} {test.status === "done" && test.source !== "live" && ( {((test.lines.length * 0.4)).toFixed(1)}с )} {test.status === "done" && test.source === "live" && ( live )} {test.status === "error" && ( ошибка )}
{/* terminal output */}
) })}
) )} {/* schedule */} {tab === "schedule" && ( isLive ? : ( greTunnels.filter(t => t.serverId === sid)} /> ) )}
) }