Init 2
This commit is contained in:
+544
-53
@@ -5,11 +5,13 @@ 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 { 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"
|
||||
|
||||
@@ -25,7 +27,7 @@ interface OutputLine { text: string; kind: "normal" | "ok" | "err" | "dim" | "he
|
||||
interface DiagTest {
|
||||
id: string
|
||||
tool: DiagTool
|
||||
status: RunStatus
|
||||
status: RunStatus | "error"
|
||||
srcServerId: string
|
||||
srcServerName: string
|
||||
target: string
|
||||
@@ -33,6 +35,8 @@ interface DiagTest {
|
||||
startedAt: number
|
||||
lines: OutputLine[]
|
||||
totalLines: number // final line count — reveals progressively
|
||||
/** demo: симуляция в браузере; live: ответ MikroTik через бекенд */
|
||||
source?: "demo" | "live"
|
||||
}
|
||||
|
||||
type SchedType = "ping" | "bandwidth" | "both"
|
||||
@@ -60,6 +64,64 @@ const TOOL_META: Record<DiagTool, {
|
||||
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> {
|
||||
const headers: HeadersInit = init?.body ? { "Content-Type": "application/json" } : {}
|
||||
const finalRes = await fetch(backendUrl.replace(/\/$/, "") + path, {
|
||||
...init,
|
||||
headers: { ...headers, ...(init?.headers ?? {}) },
|
||||
})
|
||||
if (!finalRes.ok) {
|
||||
const err = await finalRes.json().catch(() => ({ error: finalRes.statusText })) as { error?: string }
|
||||
throw new Error(err.error ?? finalRes.statusText)
|
||||
}
|
||||
return finalRes.json() as Promise<T>
|
||||
}
|
||||
}
|
||||
|
||||
function inferProbeLineKind(line: string): OutputLine["kind"] {
|
||||
const l = line.toLowerCase()
|
||||
if (l.includes("error:") || (l.includes("timeout") && l.includes("seq="))) return "err"
|
||||
if (l.includes("sent=") || l.includes("received=") || l.includes("packet-loss") || l.includes("✓")) return "ok"
|
||||
if (l.startsWith(";;") || l.trim() === "") return "dim"
|
||||
if (/ADDRESS|QUESTION|bandwidth-test|^ping |lookup |MTU discovery/i.test(line)) return "header"
|
||||
return "normal"
|
||||
}
|
||||
|
||||
function parseProbeOutput(text: string): OutputLine[] {
|
||||
return text.split("\n").map(t => ({ text: t || " ", kind: inferProbeLineKind(t) }))
|
||||
}
|
||||
|
||||
interface BackendServerRow {
|
||||
id: number
|
||||
name: string
|
||||
host: string
|
||||
site: string
|
||||
country: string
|
||||
asn: string
|
||||
type: Server["type"]
|
||||
enabled: boolean
|
||||
status?: Server["status"]
|
||||
latency?: number | null
|
||||
}
|
||||
|
||||
interface SpeedProbeApiRow {
|
||||
id: string
|
||||
srcServerId: string
|
||||
dstServerId: string
|
||||
srcInterface: string
|
||||
dstInterface: string
|
||||
protocol: string
|
||||
direction: string
|
||||
durationSec: string
|
||||
enabled: boolean
|
||||
lastRunAt: string | null
|
||||
lastTxAvgMbps: number | null
|
||||
lastRxAvgMbps: number | null
|
||||
lastStatus: string | null
|
||||
lastError: string | null
|
||||
}
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function jitter(base: number, pct = 0.15) {
|
||||
@@ -239,16 +301,37 @@ function genBwOutput(target: string, srcId: string, proto: string, duration: num
|
||||
|
||||
// ─── 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; dnsType?: DnsType; mtuStart?: number; bwProto?: string; bwDuration?: number; bwTarget?: 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 = servers.find(s => s.id === src)?.host ?? "?"
|
||||
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=${host}`
|
||||
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"} src-address=${host}`
|
||||
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":
|
||||
@@ -256,7 +339,7 @@ function buildCommand(
|
||||
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}`
|
||||
return `/tool ping address=${target} do-not-fragment count=1 size=${opts.mtuStart ?? 1500} src-address=${srcRos}`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,9 +351,9 @@ function NativeSelect({ value, onChange, children, className }: {
|
||||
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",
|
||||
"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}
|
||||
@@ -350,6 +433,111 @@ function TerminalOutput({ test }: { test: DiagTest }) {
|
||||
)
|
||||
}
|
||||
|
||||
// ─── 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">
|
||||
<Card className="overflow-hidden">
|
||||
{rows.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">Нет записей speed-test в мониторинге</p>
|
||||
<p className="text-xs text-muted-foreground/70 max-w-md text-center">
|
||||
Настраиваются через API <code className="text-[11px]">PUT /api/uptime/speed-probes</code> или связанный UI.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-[1fr_1fr_80px_90px_80px_1fr] 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">
|
||||
{rows.map(r => (
|
||||
<div key={r.id} className={cn(
|
||||
"grid grid-cols-[1fr_1fr_80px_90px_80px_1fr] gap-2 items-center px-4 py-2.5 text-xs",
|
||||
!r.enabled && "opacity-50",
|
||||
)}>
|
||||
<span className="truncate font-mono">{name(r.srcServerId)}{r.srcInterface ? ` · ${r.srcInterface}` : ""}</span>
|
||||
<span className="truncate font-mono">{name(r.dstServerId)}{r.dstInterface ? ` · ${r.dstInterface}` : ""}</span>
|
||||
<span>{r.protocol.toUpperCase()}</span>
|
||||
<span className="font-mono">{r.durationSec}s</span>
|
||||
<span>{r.enabled ? "да" : "нет"}</span>
|
||||
<span className="text-muted-foreground truncate">
|
||||
{r.lastRunAt ?? "—"}
|
||||
{r.lastStatus === "done" && r.lastTxAvgMbps != null && (
|
||||
<span className="text-emerald-600 dark:text-emerald-400 ml-1">
|
||||
TX≈{r.lastTxAvgMbps.toFixed(1)} RX≈{(r.lastRxAvgMbps ?? 0).toFixed(1)} Mb/s
|
||||
</span>
|
||||
)}
|
||||
{r.lastStatus === "error" && r.lastError && (
|
||||
<span className="text-destructive ml-1 truncate">{r.lastError}</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Данные из коллектора uptime (та же БД, что и дашборд). Редактирование — через настройки мониторинга / API.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── schedule tab ─────────────────────────────────────────────────────────────
|
||||
|
||||
const INIT_RULES: SchedRule[] = [
|
||||
@@ -358,15 +546,30 @@ const INIT_RULES: SchedRule[] = [
|
||||
{ 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[]>>
|
||||
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("srv1")
|
||||
const [addTun, setAddTun] = useState("gre1")
|
||||
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(() => greTunnels.filter(t => t.serverId === addSrc), [addSrc])
|
||||
const addTunnels = useMemo(() => tunnelsForServer(addSrc), [addSrc, tunnelsForServer])
|
||||
|
||||
useEffect(() => {
|
||||
const list = tunnelsForServer(addSrc)
|
||||
if (list.length && !list.some(t => t.id === addTun)) {
|
||||
setAddTun(list[0]?.id ?? "")
|
||||
}
|
||||
}, [addSrc, addTun, tunnelsForServer])
|
||||
|
||||
const typeLabel: Record<SchedType, string> = { ping: "Ping", bandwidth: "BW-тест", both: "Ping + BW" }
|
||||
|
||||
@@ -391,8 +594,8 @@ function ScheduleTab({ rules, setRules }: {
|
||||
</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)
|
||||
const src = serverOptions.find(s => s.id === rule.srcId)
|
||||
const tun = tunnelsForServer(rule.srcId).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",
|
||||
@@ -433,7 +636,7 @@ function ScheduleTab({ rules, setRules }: {
|
||||
<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>)}
|
||||
{serverOptions.filter(s => s.enabled).map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
<div><OptionLabel>GRE-туннель</OptionLabel>
|
||||
@@ -475,7 +678,14 @@ function ScheduleTab({ rules, setRules }: {
|
||||
// ─── page ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function ProbesPage() {
|
||||
// ── tool config ──
|
||||
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")
|
||||
@@ -483,11 +693,109 @@ export default function ProbesPage() {
|
||||
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(greTunnels[0]?.id ?? "gre1")
|
||||
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
|
||||
if (liveServers.length > 0) return liveServers
|
||||
if (liveLoad === "error") return servers
|
||||
return []
|
||||
}, [isLive, liveServers, liveLoad])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLive) {
|
||||
setRosSrcV4(undefined)
|
||||
return
|
||||
}
|
||||
const n = Number.parseInt(srcId, 10)
|
||||
if (!Number.isFinite(n)) {
|
||||
setRosSrcV4(undefined)
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
setRosSrcV4(undefined)
|
||||
void apiFetch<{ ipv4: string | null }>(`/api/servers/${n}/ros-src-address`)
|
||||
.then((r) => {
|
||||
if (!cancelled) setRosSrcV4(r.ipv4)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setRosSrcV4(null)
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [isLive, srcId, apiFetch])
|
||||
|
||||
const ensureGre = useCallback(async (serverId: string) => {
|
||||
if (!isLive) return
|
||||
try {
|
||||
const d = await apiFetch<{ tunnels: GreTunnel[] }>(
|
||||
`/api/filters/gre-tunnels?serverId=${encodeURIComponent(serverId)}`,
|
||||
)
|
||||
setGreByServer(prev => ({ ...prev, [serverId]: d.tunnels }))
|
||||
} catch {
|
||||
setGreByServer(prev => ({ ...prev, [serverId]: [] }))
|
||||
}
|
||||
}, [isLive, apiFetch])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLive || !srcId) return
|
||||
void ensureGre(srcId)
|
||||
}, [isLive, srcId, ensureGre])
|
||||
|
||||
const tunnelsForSrc = useMemo(() => {
|
||||
if (isLive) return greByServer[srcId] ?? []
|
||||
return greTunnels.filter(t => t.serverId === srcId)
|
||||
}, [isLive, greByServer, srcId])
|
||||
|
||||
useEffect(() => {
|
||||
const first = allServers[0]?.id
|
||||
if (!first) return
|
||||
if (!allServers.some(s => s.id === srcId)) setSrcId(first)
|
||||
}, [allServers, srcId])
|
||||
|
||||
useEffect(() => {
|
||||
const list = isLive ? (greByServer[srcId] ?? []) : greTunnels.filter(t => t.serverId === srcId)
|
||||
if (list.length && !list.some(t => t.id === bwTunId)) setBwTunId(list[0]!.id)
|
||||
}, [isLive, greByServer, srcId, bwTunId])
|
||||
|
||||
// ── run state ──
|
||||
const [tests, setTests] = useState<DiagTest[]>([])
|
||||
@@ -495,15 +803,18 @@ export default function ProbesPage() {
|
||||
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 = useMemo(() => greTunnels.filter(t => t.serverId === srcId), [srcId])
|
||||
const srcServer = useMemo(() => servers.find(s => s.id === srcId), [srcId])
|
||||
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(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])
|
||||
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(() => {
|
||||
@@ -512,6 +823,7 @@ export default function ProbesPage() {
|
||||
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)
|
||||
@@ -524,47 +836,180 @@ export default function ProbesPage() {
|
||||
}, [])
|
||||
|
||||
// ── run test ──
|
||||
const runTest = useCallback(() => {
|
||||
const srv = servers.find(s => s.id === srcId)
|
||||
const runTest = useCallback(async () => {
|
||||
const srv = allServers.find(s => s.id === srcId)
|
||||
if (!srv) return
|
||||
const id = String(nextId.current++)
|
||||
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[] = []
|
||||
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
|
||||
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,
|
||||
id,
|
||||
tool,
|
||||
status: "done",
|
||||
srcServerId: srcId,
|
||||
srcServerName: srv.name,
|
||||
target: tool === "bandwidth" ? (bwTun?.name ?? target) : target,
|
||||
command: cmdPreview,
|
||||
startedAt: Date.now(),
|
||||
lines,
|
||||
totalLines: 0,
|
||||
totalLines: lines.length,
|
||||
source: "demo",
|
||||
}
|
||||
setTests(p => [test, ...p.slice(0, 9)]) // keep last 10
|
||||
setTests(p => [test, ...p.slice(0, 9)])
|
||||
setTab("history")
|
||||
}, [tool, srcId, target, pingCount, pingSize, pingTtl, traceProto, traceHops, dnsType, bwTunId, bwProto, bwDuration, cmdPreview])
|
||||
}, [
|
||||
isLive,
|
||||
allServers,
|
||||
srcId,
|
||||
tool,
|
||||
target,
|
||||
pingCount,
|
||||
pingSize,
|
||||
pingTtl,
|
||||
traceProto,
|
||||
traceUseDns,
|
||||
traceHops,
|
||||
dnsType,
|
||||
bwTun,
|
||||
bwTunId,
|
||||
bwProto,
|
||||
bwDuration,
|
||||
cmdPreview,
|
||||
apiFetch,
|
||||
])
|
||||
|
||||
const stopTest = (id: string) => setTests(p => p.map(t => t.id === id ? { ...t, status: "done", totalLines: t.lines.length } : t))
|
||||
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={() => setTests(p => p.map(t => ({ ...t, status: "done" as const, totalLines: t.lines.length })))}>
|
||||
? <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
|
||||
@@ -574,6 +1019,19 @@ export default function ProbesPage() {
|
||||
<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 ── */}
|
||||
<Card>
|
||||
<CardContent className="pt-4 pb-4 px-4 flex flex-col gap-4">
|
||||
@@ -604,7 +1062,7 @@ export default function ProbesPage() {
|
||||
<div>
|
||||
<OptionLabel>Источник</OptionLabel>
|
||||
<NativeSelect value={srcId} onChange={setSrcId} className="min-w-[175px]">
|
||||
{servers.filter(s => s.enabled).map(s => (
|
||||
{allServers.filter(s => s.enabled).map(s => (
|
||||
<option key={s.id} value={s.id}>{s.name}</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
@@ -653,12 +1111,23 @@ export default function ProbesPage() {
|
||||
)}
|
||||
|
||||
{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>
|
||||
<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>
|
||||
<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>
|
||||
<Toggle checked={traceUseDns} onChange={setTraceUseDns} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tool === "dns" && (
|
||||
@@ -687,8 +1156,13 @@ export default function ProbesPage() {
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button onClick={runTest} className="h-8 shrink-0 gap-1.5 self-end">
|
||||
<PlayIcon className="size-3.5" />Запустить
|
||||
<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>
|
||||
|
||||
@@ -765,11 +1239,17 @@ export default function ProbesPage() {
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{test.status === "done" && (
|
||||
{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" />
|
||||
@@ -792,7 +1272,18 @@ export default function ProbesPage() {
|
||||
)}
|
||||
|
||||
{/* schedule */}
|
||||
{tab === "schedule" && <ScheduleTab rules={rules} setRules={setRules} />}
|
||||
{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>
|
||||
|
||||
Reference in New Issue
Block a user