803 lines
39 KiB
TypeScript
803 lines
39 KiB
TypeScript
"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>
|
||
)
|
||
}
|