"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 } from "@/lib/data" import { PlayIcon, SquareIcon, CopyIcon, Trash2Icon, PlusIcon, ActivityIcon, RouteIcon, SearchIcon, NetworkIcon, RulerIcon, ZapIcon, ClockIcon, CheckIcon, TerminalIcon, } 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 srcServerId: string srcServerName: string target: string command: string startedAt: number lines: OutputLine[] totalLines: number // final line count — reveals progressively } 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 пути" }, } // ─── 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 ────────────────────────────────────────────────────────── function buildCommand( tool: DiagTool, src: string, target: string, opts: { pingCount?: number; pingSize?: number; pingTtl?: number; traceProto?: TraceProto; traceMaxHops?: number; dnsType?: DnsType; mtuStart?: number; bwProto?: string; bwDuration?: number; bwTarget?: string }, ): string { const host = servers.find(s => s.id === src)?.host ?? "?" switch (tool) { case "ping": return `/tool ping address=${target} count=${opts.pingCount ?? 5} size=${opts.pingSize ?? 64} ttl=${opts.pingTtl ?? 64} src-address=${host}` case "traceroute": return `/tool traceroute address=${target} max-hops=${opts.traceMaxHops ?? 30} protocol=${opts.traceProto ?? "icmp"} src-address=${host}` 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=${host}` } } // ─── 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 && (
)}
) } // ─── 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 }: { rules: SchedRule[]; setRules: React.Dispatch> }) { const [showAdd, setShowAdd] = useState(false) const [addSrc, setAddSrc] = useState("srv1") const [addTun, setAddTun] = useState("gre1") const [addType, setAddType] = useState("ping") const [addMin, setAddMin] = useState(10) const addTunnels = useMemo(() => greTunnels.filter(t => t.serverId === addSrc), [addSrc]) const typeLabel: Record = { ping: "Ping", bandwidth: "BW-тест", both: "Ping + BW" } return (
{rules.length === 0 ? (

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

) : ( <>
Туннель Сервер Тип Интервал Последний / следующий
{rules.map(rule => { const src = servers.find(s => s.id === rule.srcId) const tun = greTunnels.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 ? (
Новое правило
Сервер {servers.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() { // ── tool config ── 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 [traceHops] = useState(30) const [dnsType, setDnsType] = useState("A") const [bwTunId, setBwTunId] = useState(greTunnels[0]?.id ?? "gre1") const [bwProto, setBwProto] = useState<"tcp" | "udp">("tcp") const [bwDuration, setBwDuration] = useState(10) // ── 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) const bwTunnels = useMemo(() => greTunnels.filter(t => t.serverId === srcId), [srcId]) const srcServer = useMemo(() => servers.find(s => s.id === srcId), [srcId]) // current command preview const cmdPreview = useMemo(() => buildCommand(tool, srcId, target, { pingCount, pingSize, pingTtl, traceProto, traceMaxHops: traceHops, dnsType, bwTarget: greTunnels.find(t => t.id === bwTunId)?.remoteAddress, bwProto, bwDuration, }), [tool, srcId, target, pingCount, pingSize, pingTtl, traceProto, traceHops, dnsType, bwTunId, bwProto, bwDuration]) // ── 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.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(() => { const srv = servers.find(s => s.id === srcId) if (!srv) return const id = String(nextId.current++) let lines: OutputLine[] = [] const bwTun = greTunnels.find(t => t.id === bwTunId) 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: "running", srcServerId: srcId, srcServerName: srv.name, target: tool === "bandwidth" ? (bwTun?.name ?? target) : target, command: cmdPreview, startedAt: Date.now(), lines, totalLines: 0, } setTests(p => [test, ...p.slice(0, 9)]) // keep last 10 setTab("history") }, [tool, srcId, target, pingCount, pingSize, pingTtl, traceProto, traceHops, dnsType, bwTunId, bwProto, bwDuration, cmdPreview]) const stopTest = (id: string) => setTests(p => p.map(t => t.id === id ? { ...t, status: "done", 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") return (
0 ? : undefined } />
{/* ── 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 */}
Источник {servers.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()})}
)} {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.lines.length * 0.4)).toFixed(1)}с )}
{/* terminal output */}
) })}
) )} {/* schedule */} {tab === "schedule" && }
) }