Files
MikrotikManager/app/(main)/probes/page.tsx
T
DenozordecandCursor 5f29cfaf0f
Docker images / prepare-release (push) Successful in 4s
Docker images / backend-image (push) Failing after 2m36s
Docker images / frontend-image (push) Successful in 2m22s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 55s
Docker images / publish-release (push) Skipped
fix(ui): выровнять chrome Frame и убрать Card-оболочки
Подключить App Switcher, NavUser, OpsPanel и AlertDialog вместо Card-shell.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-17 15:36:56 +07:00

1182 lines
52 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 { FormToggle } from "@/components/form-kit"
import { Frame, FramePanel } from "@/components/reui/frame"
import { OpsPanel } from "@/components/ops-panel"
import { DataPageCard } from "@/components/data-page-card"
import {
ProbesScheduleDataGrid,
type SchedRule,
type SchedType,
} from "@/components/data-grids/probes-schedule-data-grid"
import {
ProbesSpeedProbesDataGrid,
type SpeedProbeApiRow,
} from "@/components/data-grids/probes-speed-probes-data-grid"
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"
import { requestJson } from "@/shared/api/http-client"
// ─── 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"
}
// SchedRule imported from probes-schedule-data-grid
// ─── 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 пути" },
}
function makeApiFetch(backendUrl: string) {
return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
return requestJson<T>(backendUrl, path, init)
}
}
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
}
// SpeedProbeApiRow imported from probes-speed-probes-data-grid
// ─── helpers ──────────────────────────────────────────────────────────────────
function jitter(base: number, pct = 0.15) {
return Math.max(1, Math.round(base + (Math.random() - 0.5) * base * pct * 2))
}
function seededRng(seed: string) {
let h = 0
for (let i = 0; i < seed.length; i++) h = (Math.imul(31, h) + seed.charCodeAt(i)) | 0
h = Math.abs(h)
return (n = 233280) => { h = (h * 9301 + 49297) % n; return h / n }
}
function baseRtt(srcId: string, _target: string): number {
const map: Record<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 ──────────────────────────────────────────────────────────
/** 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 (
<select value={value} onChange={e => onChange(e.target.value)}
className={cn(
"h-8 min-w-0 rounded-lg border border-input bg-background px-2.5 py-1 text-sm text-foreground",
"transition-colors outline-none",
"focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50",
className,
)}>
{children}
</select>
)
}
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>
)
}
// ─── uptime speed probes (live) ───────────────────────────────────────────────
function ScheduleSpeedProbesLive({
apiFetch,
serversForName,
}: {
apiFetch: ReturnType<typeof makeApiFetch>
serversForName: Server[]
}) {
const [rows, setRows] = useState<SpeedProbeApiRow[]>([])
const [loading, setLoading] = useState(true)
const [err, setErr] = useState<string | null>(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 (
<div className="flex items-center justify-center gap-2 py-12 text-sm text-muted-foreground">
<LoaderCircleIcon className="size-5 animate-spin" />
Загрузка расписания из БД…
</div>
)
}
if (err) {
return (
<div className="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
<AlertCircleIcon className="size-4 shrink-0" />
{err}
</div>
)
}
return (
<div className="flex flex-col gap-3">
<DataPageCard>
<ProbesSpeedProbesDataGrid rows={rows} serverName={name} />
</DataPageCard>
<p className="text-[11px] text-muted-foreground">
Данные из коллектора uptime (та же БД, что и дашборд). Редактирование через настройки мониторинга / API.
</p>
</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,
serverOptions,
tunnelsForServer,
}: {
rules: SchedRule[]
setRules: React.Dispatch<React.SetStateAction<SchedRule[]>>
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<SchedType>("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 tunnelName = (srcId: string, tunnelId: string) =>
tunnelsForServer(srcId).find((t) => t.id === tunnelId)?.name
return (
<div className="flex flex-col gap-3">
<DataPageCard>
<ProbesScheduleDataGrid
rules={rules}
serverOptions={serverOptions}
tunnelName={tunnelName}
onToggleEnabled={(id, enabled) =>
setRules((p) => p.map((r) => (r.id === id ? { ...r, enabled } : r)))
}
onDelete={(id) => setRules((p) => p.filter((r) => r.id !== id))}
/>
</DataPageCard>
{showAdd ? (
<Frame dense className="w-full overflow-hidden">
<FramePanel className="p-0 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]">
{serverOptions.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>
</FramePanel>
</Frame>
) : (
<Button variant="outline" size="sm" className="w-fit" onClick={() => setShowAdd(true)}>
<PlusIcon className="size-4" />Добавить правило
</Button>
)}
</div>
)
}
// ─── page ─────────────────────────────────────────────────────────────────────
export default function ProbesPage() {
const { mode, backendUrl } = useDataSource()
const isLive = mode === "live"
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
const [liveServers, setLiveServers] = useState<Server[]>([])
const [liveLoad, setLiveLoad] = useState<"idle" | "loading" | "error">(() => (isLive ? "loading" : "idle"))
const [greByServer, setGreByServer] = useState<Record<string, GreTunnel[]>>({})
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 [traceUseDns, setTraceUseDns] = useState(false)
const [traceHops] = useState(30)
const [dnsType, setDnsType] = useState<DnsType>("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<string | null | undefined>(undefined)
useEffect(() => {
if (!isLive) {
queueMicrotask(() => setLiveLoad("idle"))
return
}
setLiveLoad("loading")
void apiFetch<BackendServerRow[]>("/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
return liveServers
}, [isLive, liveServers])
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<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)
/** Живой 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 (
<div className="flex flex-col h-full">
<PageHeader crumbs={[{ label: "Инструменты" }, { label: "Диагностика" }]} />
<div className="flex-1 flex flex-col items-center justify-center gap-3 text-muted-foreground">
<LoaderCircleIcon className="size-8 animate-spin opacity-50" />
<p className="text-sm">Загрузка серверов из бекенда…</p>
</div>
</div>
)
}
return (
<div className="flex flex-col h-full">
<PageHeader
crumbs={[{ label: "Инструменты" }, { label: "Диагностика" }]}
actions={
running.length > 0
? <Button variant="outline" size="sm" onClick={() => {
liveProbeRunRef.current?.ctrl.abort()
setTests(p => p.map(t => (t.status === "running"
? { ...t, status: "done" as const, totalLines: t.lines.length }
: t)))
}}>
<SquareIcon className="size-4" />Остановить все
</Button>
: undefined
}
/>
<div className="flex-1 overflow-y-auto p-6">
<div className="flex flex-col gap-4">
{isLive && liveLoad === "error" && (
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-2.5 text-xs text-destructive flex items-center gap-2">
<AlertCircleIcon className="size-3.5 shrink-0" />
Бекенд недоступен переключитесь в режим «Демо» в настройках источника данных или проверьте URL API.
</div>
)}
{isLive && (
<p className="text-xs text-muted-foreground">
Режим «Живые»: 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.
</p>
)}
{/* ── tool selector + config ── */}
<OpsPanel contentClassName="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]">
{allServers.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>
<div className="flex items-end gap-3 pb-0.5">
<div className="flex flex-col gap-1 min-w-0">
<OptionLabel>Имена хопов (use-dns)</OptionLabel>
<p className="text-[10px] text-muted-foreground leading-snug max-w-[220px]">
Как в RouterOS: резолвить IP промежуточных узлов в DNS-имена на самом MikroTik.
</p>
</div>
<FormToggle checked={traceUseDns} onChange={setTraceUseDns} />
</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={() => void runTest()}
disabled={runBusy || !srcServer || (tool === "bandwidth" && !bwTunnels.length)}
className="h-8 shrink-0 gap-1.5 self-end"
>
{runBusy ? <LoaderCircleIcon className="size-3.5 animate-spin" /> : <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>
</OpsPanel>
{/* ── 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 (
<Frame key={test.id} dense className="w-full overflow-hidden">
<FramePanel className="p-0 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" && test.source !== "live" && (
<span className="text-[11px] text-muted-foreground">
{((test.lines.length * 0.4)).toFixed(1)}с
</span>
)}
{test.status === "done" && test.source === "live" && (
<span className="text-[10px] font-mono text-emerald-600 dark:text-emerald-400">live</span>
)}
{test.status === "error" && (
<span className="text-[11px] text-destructive">ошибка</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>
</FramePanel>
</Frame>
)
})}
</div>
)
)}
{/* schedule */}
{tab === "schedule" && (
isLive
? <ScheduleSpeedProbesLive apiFetch={apiFetch} serversForName={allServers} />
: (
<ScheduleTab
rules={rules}
setRules={setRules}
serverOptions={allServers}
tunnelsForServer={sid => greTunnels.filter(t => t.serverId === sid)}
/>
)
)}
</div>
</div>
</div>
</div>
</div>
)
}