Files
MikrotikManager/app/(main)/probes/page.tsx
T
2026-05-02 01:17:08 +07:00

803 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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<DiagTool, {
label: string
ros: string // RouterOS tool path
Icon: React.FC<{ className?: string }>
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<string, number> = { 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 (
<select value={value} onChange={e => onChange(e.target.value)}
className={cn(
"h-8 min-w-0 rounded-lg border border-input bg-transparent 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 dark:bg-input/30",
className,
)}>
{children}
</select>
)
}
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
return (
<button onClick={() => onChange(!checked)}
className={cn("relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors",
checked ? "bg-primary" : "bg-muted-foreground/30")}>
<span className={cn("inline-block h-3.5 w-3.5 rounded-full bg-white shadow transition-transform",
checked ? "translate-x-4" : "translate-x-0.5")} />
</button>
)
}
function OptionLabel({ children }: { children: React.ReactNode }) {
return <p className="text-[11px] font-medium text-muted-foreground mb-1">{children}</p>
}
function SegBtn<T extends string | number>({ value, current, onClick, children }: {
value: T; current: T; onClick: (v: T) => void; children: React.ReactNode
}) {
return (
<button onClick={() => onClick(value)}
className={cn(
"px-2.5 py-1 text-xs font-mono border-r last:border-r-0 border-input transition-colors",
value === current ? "bg-muted text-foreground font-semibold" : "text-muted-foreground hover:bg-muted/50",
)}>
{children}
</button>
)
}
// ─── terminal output ──────────────────────────────────────────────────────────
function TerminalOutput({ test }: { test: DiagTest }) {
const ref = useRef<HTMLDivElement>(null)
const visible = test.lines.slice(0, test.totalLines)
useEffect(() => {
if (ref.current) ref.current.scrollTop = ref.current.scrollHeight
}, [visible.length])
return (
<div ref={ref}
className="h-72 overflow-y-auto rounded-lg bg-zinc-950 dark:bg-zinc-900 border border-zinc-800 px-4 py-3 font-mono text-xs leading-relaxed">
{/* command line */}
<div className="mb-2 flex items-center gap-2">
<span className="text-zinc-500">[{test.srcServerName}]</span>
<span className="text-emerald-400">$</span>
<span className="text-zinc-300">{test.command}</span>
{test.status === "running" && (
<span className="inline-block size-1.5 rounded-full bg-emerald-400 animate-pulse ml-1" />
)}
</div>
{/* output */}
{visible.map((line, i) => (
<div key={i} className={cn(
"whitespace-pre leading-5",
line.kind === "ok" && "text-emerald-400",
line.kind === "err" && "text-red-400",
line.kind === "dim" && "text-zinc-500",
line.kind === "header" && "text-zinc-400 font-semibold",
line.kind === "cmd" && "text-amber-400",
line.kind === "normal" && "text-zinc-300",
)}>
{line.text || " "}
</div>
))}
{test.status === "running" && visible.length < test.lines.length && (
<div className="text-zinc-600 animate-pulse"></div>
)}
</div>
)
}
// ─── 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<React.SetStateAction<SchedRule[]>>
}) {
const [showAdd, setShowAdd] = useState(false)
const [addSrc, setAddSrc] = useState("srv1")
const [addTun, setAddTun] = useState("gre1")
const [addType, setAddType] = useState<SchedType>("ping")
const [addMin, setAddMin] = useState(10)
const addTunnels = useMemo(() => greTunnels.filter(t => t.serverId === addSrc), [addSrc])
const typeLabel: Record<SchedType, string> = { ping: "Ping", bandwidth: "BW-тест", both: "Ping + BW" }
return (
<div className="flex flex-col gap-3">
<Card className="overflow-hidden">
{rules.length === 0 ? (
<div className="flex flex-col items-center justify-center py-10 gap-2 text-muted-foreground">
<ClockIcon className="size-7 opacity-20" />
<p className="text-sm">Нет правил расписания</p>
</div>
) : (
<>
<div className="grid grid-cols-[40px_1fr_140px_80px_100px_1fr_auto] gap-2 items-center px-4 py-2 bg-muted/30 border-b text-[10px] font-semibold text-muted-foreground uppercase tracking-wide">
<span />
<span>Туннель</span>
<span>Сервер</span>
<span>Тип</span>
<span>Интервал</span>
<span>Последний / следующий</span>
<span />
</div>
<div className="divide-y divide-border/60">
{rules.map(rule => {
const src = servers.find(s => s.id === rule.srcId)
const tun = greTunnels.find(t => t.id === rule.tunnelId)
return (
<div key={rule.id} className={cn(
"grid grid-cols-[40px_1fr_140px_80px_100px_1fr_auto] gap-2 items-center px-4 py-2.5 hover:bg-muted/20 transition-colors",
!rule.enabled && "opacity-50",
)}>
<Toggle checked={rule.enabled}
onChange={v => setRules(p => p.map(r => r.id === rule.id ? { ...r, enabled: v } : r))} />
<code className="font-mono text-xs truncate">{tun?.name ?? rule.tunnelId}</code>
<span className="text-xs text-muted-foreground truncate">{src?.name ?? rule.srcId}</span>
<span className={cn("text-[10px] px-1.5 py-0.5 rounded border font-medium w-fit",
rule.type === "ping" ? "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20"
: rule.type === "bandwidth" ? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
: "bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/20",
)}>{typeLabel[rule.type]}</span>
<span className="text-xs text-muted-foreground">каждые {rule.intervalMin} мин</span>
<div className="text-xs text-muted-foreground flex items-center gap-2 min-w-0">
{rule.lastRun && <span className="truncate">{rule.lastRun}</span>}
{rule.nextRunMin != null && rule.enabled && (
<span className="text-sky-600 dark:text-sky-400 shrink-0">· через {rule.nextRunMin} мин</span>
)}
</div>
<button onClick={() => setRules(p => p.filter(r => r.id !== rule.id))}
className="size-6 flex items-center justify-center rounded text-muted-foreground/40 hover:text-red-500 hover:bg-red-500/10 transition-colors">
<Trash2Icon className="size-3.5" />
</button>
</div>
)
})}
</div>
</>
)}
</Card>
{showAdd ? (
<Card className="overflow-hidden">
<div className="px-4 py-3 border-b flex items-center gap-2 text-sm font-medium">
<PlusIcon className="size-4 text-muted-foreground" />Новое правило
</div>
<div className="px-4 py-4 flex flex-wrap items-end gap-3">
<div><OptionLabel>Сервер</OptionLabel>
<NativeSelect value={addSrc} onChange={setAddSrc} className="min-w-[160px]">
{servers.filter(s => s.enabled).map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
</NativeSelect>
</div>
<div><OptionLabel>GRE-туннель</OptionLabel>
<NativeSelect value={addTun} onChange={setAddTun} className="min-w-[150px]">
{addTunnels.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
</NativeSelect>
</div>
<div><OptionLabel>Тип</OptionLabel>
<NativeSelect value={addType} onChange={v => setAddType(v as SchedType)}>
<option value="ping">Ping</option>
<option value="bandwidth">BW-тест</option>
<option value="both">Ping + BW</option>
</NativeSelect>
</div>
<div><OptionLabel>Интервал (мин)</OptionLabel>
<Input type="number" min={1} max={1440} value={addMin}
onChange={e => setAddMin(Math.max(1, parseInt(e.target.value) || 1))}
className="w-20 font-mono text-sm" />
</div>
<Button size="sm" disabled={!addTunnels.length}
onClick={() => {
setRules(p => [...p, { id: `r${Date.now()}`, srcId: addSrc, tunnelId: addTun, type: addType, intervalMin: addMin, enabled: true, lastRun: null, nextRunMin: addMin }])
setShowAdd(false)
}}>
<CheckIcon className="size-4" />Добавить
</Button>
<Button variant="outline" size="sm" onClick={() => setShowAdd(false)}>Отмена</Button>
</div>
</Card>
) : (
<Button variant="outline" size="sm" className="w-fit" onClick={() => setShowAdd(true)}>
<PlusIcon className="size-4" />Добавить правило
</Button>
)}
</div>
)
}
// ─── page ─────────────────────────────────────────────────────────────────────
export default function ProbesPage() {
// ── tool config ──
const [tool, setTool] = useState<DiagTool>("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<TraceProto>("icmp")
const [traceHops] = useState(30)
const [dnsType, setDnsType] = useState<DnsType>("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<DiagTest[]>([])
const [tab, setTab] = useState<"history" | "schedule">("history")
const [rules, setRules] = useState<SchedRule[]>(INIT_RULES)
const nextId = useRef(1)
const tickRef = useRef<ReturnType<typeof setInterval> | 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 (
<div className="flex flex-col h-full">
<PageHeader
crumbs={[{ label: "Инструменты" }, { label: "Диагностика" }]}
actions={
running.length > 0
? <Button variant="outline" size="sm" onClick={() => setTests(p => p.map(t => ({ ...t, status: "done" as const, totalLines: t.lines.length })))}>
<SquareIcon className="size-4" />Остановить все
</Button>
: undefined
}
/>
<div className="flex-1 overflow-y-auto p-6">
<div className="flex flex-col gap-4">
{/* ── tool selector + config ── */}
<Card>
<CardContent className="pt-4 pb-4 px-4 flex flex-col gap-4">
{/* tool chips */}
<div className="flex gap-2 flex-wrap">
{(Object.entries(TOOL_META) as [DiagTool, typeof TOOL_META[DiagTool]][]).map(([t, m]) => {
const active = tool === t
return (
<button key={t} onClick={() => setTool(t)}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-full border text-xs font-medium transition-colors",
active
? "border-foreground bg-foreground text-background"
: "border-border text-muted-foreground hover:text-foreground hover:border-foreground/40",
)}>
<m.Icon className={cn("size-3.5", active ? "" : m.color)} />
{m.label}
</button>
)
})}
</div>
{/* main config row */}
<div className="flex flex-wrap items-end gap-3">
{/* source server */}
<div>
<OptionLabel>Источник</OptionLabel>
<NativeSelect value={srcId} onChange={setSrcId} className="min-w-[175px]">
{servers.filter(s => s.enabled).map(s => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</NativeSelect>
</div>
{/* target — all tools except bandwidth */}
{tool !== "bandwidth" && (
<div className="flex-1 min-w-[140px]">
<OptionLabel>
{tool === "dns" ? "Домен" : tool === "route" ? "Destination IP" : "Цель (IP или домен)"}
</OptionLabel>
<Input value={target} onChange={e => 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()}
/>
</div>
)}
{/* bandwidth: select GRE tunnel */}
{tool === "bandwidth" && (
<div>
<OptionLabel>GRE-туннель (цель)</OptionLabel>
<NativeSelect value={bwTunId} onChange={setBwTunId} className="min-w-[180px]">
{bwTunnels.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
</NativeSelect>
</div>
)}
{/* inline quick options */}
{tool === "ping" && (
<>
<div>
<OptionLabel>Кол-во</OptionLabel>
<div className="flex rounded-lg border border-input overflow-hidden h-8">
{[5, 10, 25, 100].map(n => <SegBtn key={n} value={n} current={pingCount} onClick={setPingCount}>{n}</SegBtn>)}
</div>
</div>
<div>
<OptionLabel>Размер (байт)</OptionLabel>
<div className="flex rounded-lg border border-input overflow-hidden h-8">
{[64, 128, 512, 1472].map(n => <SegBtn key={n} value={n} current={pingSize} onClick={setPingSize}>{n}</SegBtn>)}
</div>
</div>
</>
)}
{tool === "traceroute" && (
<div>
<OptionLabel>Протокол</OptionLabel>
<div className="flex rounded-lg border border-input overflow-hidden h-8">
{(["icmp", "udp", "tcp"] as TraceProto[]).map(p => <SegBtn key={p} value={p} current={traceProto} onClick={setTraceProto}>{p.toUpperCase()}</SegBtn>)}
</div>
</div>
)}
{tool === "dns" && (
<div>
<OptionLabel>Тип записи</OptionLabel>
<div className="flex rounded-lg border border-input overflow-hidden h-8">
{(["A", "AAAA", "MX", "NS", "TXT", "PTR"] as DnsType[]).map(t => <SegBtn key={t} value={t} current={dnsType} onClick={setDnsType}>{t}</SegBtn>)}
</div>
</div>
)}
{tool === "bandwidth" && (
<>
<div>
<OptionLabel>Протокол</OptionLabel>
<div className="flex rounded-lg border border-input overflow-hidden h-8">
{(["tcp", "udp"] as const).map(p => <SegBtn key={p} value={p} current={bwProto} onClick={setBwProto}>{p.toUpperCase()}</SegBtn>)}
</div>
</div>
<div>
<OptionLabel>Длительность</OptionLabel>
<div className="flex rounded-lg border border-input overflow-hidden h-8">
{[5, 10, 30].map(n => <SegBtn key={n} value={n} current={bwDuration} onClick={setBwDuration}>{n}с</SegBtn>)}
</div>
</div>
</>
)}
<Button onClick={runTest} className="h-8 shrink-0 gap-1.5 self-end">
<PlayIcon className="size-3.5" />Запустить
</Button>
</div>
{/* RouterOS command preview */}
<div className="flex items-center gap-2 rounded-lg bg-zinc-950 dark:bg-zinc-900 px-3 py-2 border border-zinc-800">
<TerminalIcon className="size-3 text-zinc-500 shrink-0" />
<span className="text-zinc-500 text-xs font-mono shrink-0">[{srcServer?.name}]</span>
<span className="text-emerald-400 text-xs font-mono shrink-0">$</span>
<span className="text-zinc-300 text-xs font-mono truncate">{cmdPreview}</span>
<button onClick={() => navigator.clipboard.writeText(cmdPreview)}
className="shrink-0 text-zinc-600 hover:text-zinc-300 transition-colors ml-auto">
<CopyIcon className="size-3" />
</button>
</div>
</CardContent>
</Card>
{/* ── tabs ── */}
<div>
<div className="flex items-center gap-0 border-b">
{([
["history", "История тестов"],
["schedule", "Расписание"],
] as const).map(([t, label]) => (
<button key={t} onClick={() => 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 && (
<span className="ml-1.5 text-[10px] font-mono px-1.5 py-0.5 rounded-full bg-muted text-muted-foreground">
{tests.length}
</span>
)}
</button>
))}
</div>
<div className="mt-4">
{/* history */}
{tab === "history" && (
tests.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground gap-2">
<TerminalIcon className="size-8 opacity-20" />
<p className="text-sm">Запустите тест результаты появятся здесь</p>
</div>
) : (
<div className="flex flex-col gap-4">
{tests.map(test => {
const { Icon, color, label } = TOOL_META[test.tool]
return (
<Card key={test.id} className="overflow-hidden">
{/* header */}
<div className="flex items-center justify-between gap-3 px-4 py-2.5 border-b bg-muted/20">
<div className="flex items-center gap-2 min-w-0">
<Icon className={cn("size-4 shrink-0", color)} />
<span className="text-sm font-medium">{label}</span>
<span className="text-xs font-mono text-muted-foreground truncate">{test.target}</span>
<span className="text-[10px] text-muted-foreground"> {test.srcServerName}</span>
</div>
<div className="flex items-center gap-2 shrink-0">
{test.status === "running" && (
<>
<span className="flex items-center gap-1 text-[11px] text-muted-foreground">
<span className="size-1.5 rounded-full bg-emerald-400 animate-pulse" />
запущен
</span>
<Button size="sm" variant="outline" className="h-6 px-2 text-xs"
onClick={() => stopTest(test.id)}>
<SquareIcon className="size-3" />Стоп
</Button>
</>
)}
{test.status === "done" && (
<span className="text-[11px] text-muted-foreground">
{((test.lines.length * 0.4)).toFixed(1)}с
</span>
)}
<button onClick={() => navigator.clipboard.writeText(test.lines.map(l => l.text).join("\n"))}
className="size-6 flex items-center justify-center rounded text-muted-foreground/40 hover:text-foreground hover:bg-muted transition-colors">
<CopyIcon className="size-3.5" />
</button>
<button onClick={() => clearTest(test.id)}
className="size-6 flex items-center justify-center rounded text-muted-foreground/40 hover:text-red-500 hover:bg-red-500/10 transition-colors">
<Trash2Icon className="size-3.5" />
</button>
</div>
</div>
{/* terminal output */}
<div className="p-3">
<TerminalOutput test={test} />
</div>
</Card>
)
})}
</div>
)
)}
{/* schedule */}
{tab === "schedule" && <ScheduleTab rules={rules} setRules={setRules} />}
</div>
</div>
</div>
</div>
</div>
)
}