fix(ui): улучшить доступность и мобильный вывод диагностики
This commit is contained in:
1 parent
440e10c646
commit
d28d7a2cdb
11 files changed
+149
-145
No files matched your search
+101
-144
@@ -1,5 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { toast } from "sonner"
|
||||
import { Suspense, useEffect, useRef, useState, useMemo, useCallback } from "react"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { readProbeSource } from "@/lib/probe-source"
|
||||
@@ -30,7 +31,7 @@ import { useDataSource } from "@/lib/data-source"
|
||||
import {
|
||||
PlayIcon, SquareIcon, CopyIcon, Trash2Icon, PlusIcon,
|
||||
ActivityIcon, RouteIcon, SearchIcon, NetworkIcon, RulerIcon,
|
||||
ZapIcon, ClockIcon, CheckIcon, TerminalIcon,
|
||||
ZapIcon, CheckIcon, TerminalIcon,
|
||||
LoaderCircleIcon, AlertCircleIcon,
|
||||
} from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
@@ -72,12 +73,12 @@ const TOOL_META: Record<DiagTool, {
|
||||
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: "DNS приложения", 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 пути" },
|
||||
ping: { label: "Ping", ros: "/tool ping", Icon: ActivityIcon, color: "text-info", description: "Проверка связи и RTT" },
|
||||
traceroute: { label: "Traceroute", ros: "/tool traceroute", Icon: RouteIcon, color: "text-info", description: "Трассировка маршрута" },
|
||||
bandwidth: { label: "BW-тест", ros: "/tool bandwidth-test", Icon: ZapIcon, color: "text-success", description: "Пропускная способность" },
|
||||
dns: { label: "DNS", ros: "DNS приложения", Icon: SearchIcon, color: "text-warning", description: "DNS на хосте приложения" },
|
||||
route: { label: "Маршрут", ros: "/ip route lookup", Icon: NetworkIcon, color: "text-info", description: "Поиск активного маршрута" },
|
||||
mtu: { label: "MTU-тест", ros: "/tool ping", Icon: RulerIcon, color: "text-warning", description: "Определение MTU пути" },
|
||||
}
|
||||
|
||||
function makeApiFetch(backendUrl: string) {
|
||||
@@ -86,17 +87,23 @@ function makeApiFetch(backendUrl: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function copyProbeText(text:string){
|
||||
try{await navigator.clipboard.writeText(text);toast.success("Скопировано.")}
|
||||
catch{toast.error("Не удалось скопировать. Разрешите доступ к буферу обмена или выделите текст вручную.")}
|
||||
}
|
||||
|
||||
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.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) }))
|
||||
const lines = text.split("\n").map(t => ({ text: t || " ", kind: inferProbeLineKind(t) }))
|
||||
return lines.length > 1000 ? [{ text: "Показаны последние 1000 строк; начало вывода сокращено.", kind: "dim" }, ...lines.slice(-1000)] : lines
|
||||
}
|
||||
|
||||
|
||||
@@ -325,11 +332,11 @@ function buildCommand(
|
||||
|
||||
// ─── small UI components ──────────────────────────────────────────────────────
|
||||
|
||||
function NativeSelect({ value, onChange, children, className }: {
|
||||
value: string; onChange: (v: string) => void; children: React.ReactNode; className?: string
|
||||
function NativeSelect({ value, onChange, children, className, ariaLabel }: {
|
||||
value: string; onChange: (v: string) => void; children: React.ReactNode; className?: string; ariaLabel?: string
|
||||
}) {
|
||||
return (
|
||||
<select value={value} onChange={e => onChange(e.target.value)}
|
||||
<select aria-label={ariaLabel} 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",
|
||||
@@ -341,7 +348,8 @@ function NativeSelect({ value, onChange, children, className }: {
|
||||
)
|
||||
}
|
||||
|
||||
function OptionLabel({ children }: { children: React.ReactNode }) {
|
||||
function OptionLabel({ children, htmlFor }: { children: React.ReactNode; htmlFor?: string }) {
|
||||
if (htmlFor) return <label htmlFor={htmlFor} className="block text-xs font-medium text-muted-foreground mb-1">{children}</label>
|
||||
return <p className="text-[11px] font-medium text-muted-foreground mb-1">{children}</p>
|
||||
}
|
||||
|
||||
@@ -349,9 +357,9 @@ 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)}
|
||||
<button type="button" aria-pressed={value === current} onClick={() => onClick(value)}
|
||||
className={cn(
|
||||
"px-2.5 py-1 text-xs font-mono border-r last:border-r-0 border-input transition-colors",
|
||||
"px-2.5 py-1 text-xs font-mono border-r last:border-r-0 border-input transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
value === current ? "bg-muted text-foreground font-semibold" : "text-muted-foreground hover:bg-muted/50",
|
||||
)}>
|
||||
{children}
|
||||
@@ -362,46 +370,16 @@ function SegBtn<T extends string | number>({ value, current, onClick, children }
|
||||
// ─── 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>
|
||||
)
|
||||
const ref=useRef<HTMLDivElement>(null),follow=useRef(true)
|
||||
const visible=test.lines.slice(0,test.totalLines)
|
||||
useEffect(()=>{if(ref.current&&follow.current)ref.current.scrollTop=ref.current.scrollHeight},[visible.length])
|
||||
return <div ref={ref} role="region" aria-label={"Вывод "+TOOL_META[test.tool].label+" · "+test.srcServerName} tabIndex={0}
|
||||
onScroll={()=>{const el=ref.current;if(el)follow.current=el.scrollHeight-el.scrollTop-el.clientHeight<32}}
|
||||
className="h-full min-h-0 overflow-auto rounded-lg bg-muted/30 border px-3 py-3 font-mono text-xs leading-relaxed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
<div className="mb-2 text-muted-foreground [overflow-wrap:anywhere]">{test.source==="demo"?"Демо":test.tool==="dns"?"DNS приложения":test.srcServerName} · {test.command}</div>
|
||||
{visible.map((line,i)=><div key={i} className={cn("whitespace-pre-wrap [overflow-wrap:anywhere] leading-5",line.kind==="ok"&&"text-success",line.kind==="err"&&"text-destructive",line.kind==="dim"&&"text-muted-foreground",line.kind==="header"&&"text-foreground font-semibold",line.kind==="cmd"&&"text-warning",line.kind==="normal"&&"text-foreground")}>{line.text||" "}</div>)}
|
||||
</div>
|
||||
}
|
||||
|
||||
// ─── uptime speed probes (live) ───────────────────────────────────────────────
|
||||
|
||||
function ScheduleSpeedProbesLive({
|
||||
@@ -437,7 +415,7 @@ function ScheduleSpeedProbesLive({
|
||||
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" />
|
||||
<LoaderCircleIcon aria-hidden="true" className="size-5 motion-safe:animate-spin" />
|
||||
Загрузка расписания из БД…
|
||||
</div>
|
||||
)
|
||||
@@ -445,7 +423,7 @@ function ScheduleSpeedProbesLive({
|
||||
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" />
|
||||
<AlertCircleIcon aria-hidden="true" className="size-4 shrink-0" />
|
||||
{err}
|
||||
</div>
|
||||
)
|
||||
@@ -522,21 +500,21 @@ function ScheduleTab({
|
||||
<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" />Новое правило
|
||||
<PlusIcon aria-hidden="true" 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]">
|
||||
<NativeSelect ariaLabel="Устройство расписания" 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]">
|
||||
<NativeSelect ariaLabel="Туннель расписания" 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)}>
|
||||
<NativeSelect ariaLabel="Тип теста расписания" value={addType} onChange={v => setAddType(v as SchedType)}>
|
||||
<option value="ping">Ping</option>
|
||||
<option value="bandwidth">BW-тест</option>
|
||||
<option value="both">Ping + BW</option>
|
||||
@@ -560,7 +538,7 @@ function ScheduleTab({
|
||||
</Frame>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" className="w-fit" onClick={() => setShowAdd(true)}>
|
||||
<PlusIcon className="size-4" />Добавить правило
|
||||
<PlusIcon aria-hidden="true" className="size-4" />Добавить правило
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -703,7 +681,8 @@ function ProbesContent() {
|
||||
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 targetRef = useRef<HTMLInputElement>(null)
|
||||
const runButtonRef = useRef<HTMLButtonElement>(null)
|
||||
/** Живой POST /probes/run — отмена через fetch AbortSignal */
|
||||
const liveProbeRunRef = useRef<{ testId: string; ctrl: AbortController } | null>(null)
|
||||
|
||||
@@ -741,25 +720,6 @@ function ProbesContent() {
|
||||
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)
|
||||
@@ -856,7 +816,10 @@ function ProbesContent() {
|
||||
clearTimeout(timeout)
|
||||
if (liveProbeRunRef.current?.testId === id) liveProbeRunRef.current = null
|
||||
runLock.current = false
|
||||
if (alive.current) setRunBusy(false)
|
||||
if (alive.current) {
|
||||
setRunBusy(false)
|
||||
requestAnimationFrame(() => { if (alive.current && (document.activeElement === runButtonRef.current || document.activeElement === document.body)) (targetRef.current ?? runButtonRef.current)?.focus() })
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -905,7 +868,6 @@ function ProbesContent() {
|
||||
traceHops,
|
||||
dnsType,
|
||||
bwTun,
|
||||
bwTunId,
|
||||
bwProto,
|
||||
bwDuration,
|
||||
cmdPreview,
|
||||
@@ -921,6 +883,7 @@ function ProbesContent() {
|
||||
const clearTest = (id: string) => {
|
||||
if (liveProbeRunRef.current?.testId === id) liveProbeRunRef.current.ctrl.abort()
|
||||
setTests(p => p.filter(t => t.id !== id))
|
||||
runButtonRef.current?.focus()
|
||||
}
|
||||
|
||||
const running = tests.filter(t => t.status === "running")
|
||||
@@ -932,7 +895,7 @@ function ProbesContent() {
|
||||
<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" />
|
||||
<LoaderCircleIcon aria-hidden="true" className="size-8 motion-safe:animate-spin opacity-50" />
|
||||
<p className="text-sm">Загрузка серверов из бекенда…</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -958,7 +921,7 @@ function ProbesContent() {
|
||||
? <Button variant="outline" size="sm" onClick={() => {
|
||||
running.forEach(test => stopTest(test.id))
|
||||
}}>
|
||||
<SquareIcon className="size-4" />Остановить все
|
||||
<SquareIcon aria-hidden="true" className="size-4" />Остановить все
|
||||
</Button>
|
||||
: null}
|
||||
</>
|
||||
@@ -980,10 +943,11 @@ function ProbesContent() {
|
||||
{isLive && catalogReadAt !== null && <p role="status" className="text-xs text-muted-foreground">Каталог получен: {new Date(catalogReadAt).toLocaleTimeString("ru-RU")}{liveLoad === "loading" ? " · обновляется" : ""}</p>}
|
||||
{isLive && liveLoad === "idle" && allServers.length === 0 && <p role="status">В каталоге нет устройств. Добавьте устройство в разделе «Серверы».</p>}
|
||||
|
||||
{!isLive && <p role="status" className="text-sm text-muted-foreground">Демо: запросы не отправляются; результаты синтетические.</p>}
|
||||
{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>
|
||||
<details className="text-xs text-muted-foreground"><summary className="cursor-pointer">Где выполняются тесты</summary><p className="pt-2">
|
||||
Сетевые тесты выполняются на выбранном устройстве. DNS использует резолвер приложения. Traceroute ограничен примерно минутой; сокращение числа хопов указывается в ответе. Стоп отменяет ожидание, но не подтверждает остановку на устройстве.
|
||||
</p></details>
|
||||
)}
|
||||
|
||||
{!canRun && <p role="status" className="text-sm text-muted-foreground">Для запуска диагностики требуется mm:servers:write.</p>}
|
||||
@@ -1002,26 +966,26 @@ function ProbesContent() {
|
||||
{sourceCurrent?.error ? <div role="alert" aria-label="Источник preview"><p>{sourceCurrent.error}</p><Button type="button" size="sm" variant="outline" onClick={() => setSourceRevision(n => n + 1)}>Повторить определение IPv4</Button></div>
|
||||
: !sourceCurrent || sourceCurrent.loading ? <p role="status">Определяем IPv4 для preview…</p>
|
||||
: <p role="status">{sourceCurrent.ipv4 === null ? "IPv4 для preview не определён." : "IPv4 для preview: " + sourceCurrent.ipv4}</p>}
|
||||
<p>Preview справочный. Backend повторно определяет источник при запуске; пустой результат не подтверждает исходный адрес команды.</p>
|
||||
<p>Адрес справочный: при запуске определяется заново.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── tool selector + config ── */}
|
||||
<OpsPanel contentClassName="pt-4 pb-4 px-4 flex flex-col gap-4">
|
||||
<OpsPanel contentClassName="pt-4 pb-4 px-3 md:px-4 flex min-w-0 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)}
|
||||
<button key={t} type="button" aria-pressed={active} title={m.description} 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",
|
||||
"flex items-center gap-1.5 px-3 py-1.5 rounded-full border text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
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.Icon aria-hidden="true" className={cn("size-3.5", active ? "" : m.color)} />
|
||||
{m.label}
|
||||
</button>
|
||||
)
|
||||
@@ -1034,12 +998,12 @@ function ProbesContent() {
|
||||
{/* target — all tools except bandwidth */}
|
||||
{tool !== "bandwidth" && (
|
||||
<div className="flex-1 min-w-[140px]">
|
||||
<OptionLabel>
|
||||
{tool === "dns" ? "Домен" : tool === "route" ? "Destination IP" : "Цель (IP или домен)"}
|
||||
<OptionLabel htmlFor="probe-target">
|
||||
{tool === "dns" ? "Домен" : tool === "route" ? "Адрес назначения IPv4" : "Цель (IP или домен)"}
|
||||
</OptionLabel>
|
||||
<Input value={target} onChange={e => setTarget(e.target.value)}
|
||||
<Input id="probe-target" ref={targetRef} maxLength={1024} value={target} onChange={e => setTarget(e.target.value)}
|
||||
placeholder={tool === "dns" ? "google.com" : tool === "route" ? "8.8.8.8" : "8.8.8.8"}
|
||||
aria-label={tool === "dns" ? "Домен" : "Цель диагностики"}
|
||||
aria-label={tool === "dns" ? "Домен" : tool === "route" ? "Адрес назначения IPv4" : "Цель диагностики"}
|
||||
className="font-mono text-sm"
|
||||
onKeyDown={e => e.key === "Enter" && runTest()}
|
||||
/>
|
||||
@@ -1050,7 +1014,7 @@ function ProbesContent() {
|
||||
{tool === "bandwidth" && (
|
||||
<div>
|
||||
<OptionLabel>GRE-туннель (цель)</OptionLabel>
|
||||
<NativeSelect value={bwTunId} onChange={setBwTunId} className="min-w-[180px]">
|
||||
<NativeSelect ariaLabel="GRE-туннель для BW-теста" value={bwTunId} onChange={setBwTunId} className="min-w-[180px]">
|
||||
{bwTunnels.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
@@ -1060,14 +1024,14 @@ function ProbesContent() {
|
||||
{tool === "ping" && (
|
||||
<>
|
||||
<div>
|
||||
<OptionLabel>Кол-во</OptionLabel>
|
||||
<div className="flex rounded-lg border border-input overflow-hidden h-8">
|
||||
<OptionLabel>Количество пакетов</OptionLabel>
|
||||
<div className="flex flex-wrap rounded-lg border border-input min-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">
|
||||
<div className="flex flex-wrap rounded-lg border border-input overflow-hidden min-h-8">
|
||||
{[64, 128, 512, 1472].map(n => <SegBtn key={n} value={n} current={pingSize} onClick={setPingSize}>{n}</SegBtn>)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1089,7 +1053,7 @@ function ProbesContent() {
|
||||
Как в RouterOS: резолвить IP промежуточных узлов в DNS-имена на самом MikroTik.
|
||||
</p>
|
||||
</div>
|
||||
<FormToggle checked={traceUseDns} onChange={setTraceUseDns} />
|
||||
<FormToggle ariaLabel="Разрешать имена хопов на устройстве" checked={traceUseDns} onChange={setTraceUseDns} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -1098,7 +1062,7 @@ function ProbesContent() {
|
||||
<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>)}
|
||||
{(["A", "AAAA", "MX", "NS", "TXT", "CNAME", "PTR"] as DnsType[]).map(t => <SegBtn key={t} value={t} current={dnsType} onClick={setDnsType}>{t}</SegBtn>)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -1120,25 +1084,25 @@ function ProbesContent() {
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button
|
||||
<Button ref={runButtonRef} type="button" aria-busy={runBusy}
|
||||
onClick={() => void runTest()}
|
||||
disabled={!canRun || (tool === "bandwidth" && (!bwTun?.enabled || !bwTun.remoteAddress.trim() || !greReady || (isLive && !canReadGre))) || runBusy || !srcServer?.enabled || (isLive && liveLoad !== "idle") || (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" />}
|
||||
{runBusy ? <LoaderCircleIcon aria-hidden="true" className="size-3.5 motion-safe:animate-spin" /> : <PlayIcon aria-hidden="true" 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">[{tool === "dns" ? (isLive ? "backend" : "демо") : 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" />
|
||||
<div className="flex min-w-0 items-center gap-2 rounded-lg bg-muted/30 px-3 py-2 border">
|
||||
<TerminalIcon aria-hidden="true" className="size-3 text-muted-foreground shrink-0" />
|
||||
<span className="hidden md:inline text-muted-foreground text-xs font-mono truncate max-w-24">[{tool === "dns" ? (isLive ? "backend" : "демо") : srcServer?.name}]</span>
|
||||
<span className="hidden md:inline text-success text-xs font-mono shrink-0">$</span>
|
||||
<span className="min-w-0 flex-1 text-foreground text-xs font-mono [overflow-wrap:anywhere]">{cmdPreview}</span>
|
||||
<button type="button" aria-label="Скопировать параметры диагностики" onClick={() => void copyProbeText(cmdPreview)}
|
||||
className="shrink-0 text-muted-foreground hover:text-foreground transition-colors ml-auto">
|
||||
<CopyIcon aria-hidden="true" className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
</OpsPanel>
|
||||
@@ -1150,7 +1114,7 @@ function ProbesContent() {
|
||||
["history", "История тестов"],
|
||||
["schedule", "Расписание"],
|
||||
] as const).map(([t, label]) => (
|
||||
<button key={t} onClick={() => setTab(t)}
|
||||
<button key={t} type="button" aria-pressed={tab === t} onClick={() => setTab(t)}
|
||||
className={cn(
|
||||
"px-4 py-2 text-sm font-medium -mb-px border-b-2 transition-colors",
|
||||
tab === t
|
||||
@@ -1172,7 +1136,7 @@ function ProbesContent() {
|
||||
{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" />
|
||||
<TerminalIcon aria-hidden="true" className="size-8 opacity-20" />
|
||||
<p className="text-sm">Запустите тест — результаты появятся здесь</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -1180,53 +1144,46 @@ function ProbesContent() {
|
||||
{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">
|
||||
<Frame key={test.id} dense aria-busy={test.status === "running"} className="w-full h-[60dvh] min-h-0 flex flex-col overflow-hidden">
|
||||
<FramePanel className="p-0 flex-1 min-h-0 flex flex-col 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)} />
|
||||
<div className="shrink-0 flex flex-wrap items-center justify-between gap-2 px-3 py-2.5 border-b bg-muted/20">
|
||||
<div className="flex flex-wrap items-center gap-2 min-w-0 w-full md:w-auto">
|
||||
<Icon aria-hidden="true" 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>
|
||||
<span className="min-w-0 max-w-[70%] text-xs font-mono text-muted-foreground truncate" title={test.target}>{test.target}</span>
|
||||
<span className="w-full md:w-auto max-w-full text-xs text-muted-foreground truncate" title={test.tool === "dns" ? "DNS приложения" : test.srcServerName}>{test.tool === "dns" ? "DNS приложения" : "← " + test.srcServerName}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<div className="flex flex-wrap items-center gap-2 min-w-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 role="status" className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<span className="size-1.5 rounded-full bg-info motion-safe:animate-pulse" />
|
||||
ожидаем ответ
|
||||
</span>
|
||||
<Button size="sm" variant="outline" className="h-6 px-2 text-xs"
|
||||
<Button size="sm" variant="outline" type="button" className="h-11 md:h-7 px-2 text-xs"
|
||||
onClick={() => stopTest(test.id)}>
|
||||
<SquareIcon className="size-3" />Стоп
|
||||
<SquareIcon aria-hidden="true" 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 === "done" && <span role="status" className="text-xs text-muted-foreground">{test.source === "live" ? "ответ API" : "демо"}</span>}
|
||||
{test.status === "cancelled" && <span role="status" className="text-[11px] text-muted-foreground">ожидание отменено</span>}
|
||||
{test.status === "error" && (
|
||||
<span className="text-[11px] text-destructive">ошибка</span>
|
||||
<span role="alert" className="text-xs 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 type="button" aria-label={"Скопировать показанный вывод · " + label + " · " + test.id} onClick={() => void copyProbeText(test.lines.map(l => l.text).join("\n"))}
|
||||
className="size-11 md:size-8 flex items-center justify-center rounded text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring hover:text-foreground hover:bg-muted transition-colors">
|
||||
<CopyIcon aria-hidden="true" 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 type="button" aria-label={"Удалить результат · " + label + " · " + test.id} onClick={() => clearTest(test.id)}
|
||||
className="size-11 md:size-8 flex items-center justify-center rounded text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring hover:text-destructive hover:bg-destructive/10 transition-colors">
|
||||
<Trash2Icon aria-hidden="true" className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* terminal output */}
|
||||
<div className="p-3">
|
||||
<div className="flex-1 min-h-0 p-3">
|
||||
<TerminalOutput test={test} />
|
||||
</div>
|
||||
</FramePanel>
|
||||
|
||||
@@ -199,3 +199,13 @@ REST запрос → ожидание → подтверждённый output /
|
||||
| --- | --- | --- |
|
||||
| Готовность | [PNG](ux-ui/screens/terminal-ready-desktop.png) | [390 px](ux-ui/screens/terminal-ready-mobile.png), [320 px](ux-ui/screens/terminal-ready-narrow.png) |
|
||||
| Вывод | [PNG](ux-ui/screens/terminal-output-desktop.png) | [390 px](ux-ui/screens/terminal-output-mobile.png), [320 px](ux-ui/screens/terminal-output-narrow.png) |
|
||||
|
||||
|
||||
### Диагностика: экраны 10.69
|
||||
|
||||
Референс — существующий ReUI Frame/FramePanel и List 9. Устройство → параметры → подтверждённый ответ API/ошибка → копирование или удаление. Расписание относится к 11Б.
|
||||
|
||||
| Экран | ПК | Телефон |
|
||||
| --- | --- | --- |
|
||||
| Готовность | [PNG](ux-ui/screens/probes-ready-desktop.png) | [390 px](ux-ui/screens/probes-ready-mobile.png), [320 px](ux-ui/screens/probes-ready-narrow.png) |
|
||||
| Вывод | [PNG](ux-ui/screens/probes-output-desktop.png) | [390 px](ux-ui/screens/probes-output-mobile.png), [320 px](ux-ui/screens/probes-output-narrow.png) |
|
||||
@@ -26,7 +26,7 @@
|
||||
| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже |
|
||||
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
|
||||
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
|
||||
| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.68; оптимизатор, карта и терминал приняты локально; диагностика /probes в работе |
|
||||
| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.69; оптимизатор, карта и терминал приняты локально; диагностика /probes в работе |
|
||||
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
|
||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
||||
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
||||
@@ -36,6 +36,14 @@
|
||||
|
||||
|
||||
|
||||
## Блок 10.69 — доступность и адаптивность диагностики (2026-10-01)
|
||||
|
||||
- Сохранена композиция Frame/FramePanel, OpsPanel и ServerRail; API Frame и ReUI checklist сверены. Vendor/theme не изменены. Цвета диагностики используют семантические токены, анимация учитывает reduced motion, кнопки/переключатели и область вывода имеют доступные имена и состояния.
|
||||
- Вывод ограничен последними 1000 строками с явной отметкой; переносится и прокручивается внутри панели с высотой родителя. Удаление возвращает фокус на запуск, clipboard отказ показывает ошибку и сохраняет данные. Демо явно подписано; вымышленные время и положительная окраска неизвестных счётчиков убраны.
|
||||
- На 1440/390/320 px нет горизонтального переполнения; команда на телефоне использует всю доступную ширину, адрес цели читается, DNS подписан как тест приложения. Сохранены и визуально проверены шесть снимков ready/output.
|
||||
- TypeScript, 37 data/source E2E, 4 layout/clipboard E2E и 4 context E2E прошли. Production frontend build прошёл. Lint изменённого теста чистый; в странице остаются четыре прежние set-state-in-effect ошибки (три в расписании, одна в выборе BW). Новых ошибок/предупреждений нет.
|
||||
- Далее 10.70: убрать эффект выбора BW в диагностике, дополнить проверку клавиатуры/успешного clipboard и локальную приёмку. Расписание и три его lint ошибки перенесены в 11Б; реальные RouterOS/БД/портал не приняты по мокам.
|
||||
|
||||
## Блок 10.68 — отмена backend диагностики (2026-10-01)
|
||||
|
||||
- Abort привязан к aborted HTTP запросу/незавершённому response close; нормальное завершение чтения тела не считается отменой. Listeners снимаются в finally. Перед/после зависимых await проверяется сигнал; отмена не начинает новые MTU пробы.
|
||||
|
||||
@@ -433,3 +433,12 @@ Shared контракт и backend observed исходные значения/д
|
||||
- Полный backend npm test завершился exit 0; DB-зависимые тесты явно skipped из-за ECONNREFUSED PostgreSQL 127.0.0.1:5432, не приняты по этому прогону. test:probes/build/profile lint повторены после транспортного теста. Mikrotik service сохраняет две исходные prefer-const ошибки 117/874; ESLint HEAD/current совпал, suppressions не добавлены.
|
||||
- Далее 10.69: доступность и адаптивность диагностики, семантические токены, подписи/вывод/clipboard и снимки; затем профильная регрессия и локальная приёмка /probes. Расписание в 11Б, реальные RouterOS/БД/портал остаются внешними ограничениями.
|
||||
|
||||
|
||||
|
||||
## Блок 10.69 — доступность и адаптивность диагностики (2026-10-01)
|
||||
|
||||
- Сохранена композиция Frame/FramePanel, OpsPanel и ServerRail; API Frame и ReUI checklist сверены. Vendor/theme не изменены. Цвета диагностики используют семантические токены, анимация учитывает reduced motion, кнопки/переключатели и область вывода имеют доступные имена и состояния.
|
||||
- Вывод ограничен последними 1000 строками с явной отметкой; переносится и прокручивается внутри панели с высотой родителя. Удаление возвращает фокус на запуск, clipboard отказ показывает ошибку и сохраняет данные. Демо явно подписано; вымышленные время и положительная окраска неизвестных счётчиков убраны.
|
||||
- На 1440/390/320 px нет горизонтального переполнения; команда на телефоне использует всю доступную ширину, адрес цели читается, DNS подписан как тест приложения. Сохранены и визуально проверены шесть снимков ready/output.
|
||||
- TypeScript, 37 data/source E2E, 4 layout/clipboard E2E и 4 context E2E прошли. Production frontend build прошёл. Lint изменённого теста чистый; в странице остаются четыре прежние set-state-in-effect ошибки (три в расписании, одна в выборе BW). Новых ошибок/предупреждений нет.
|
||||
- Далее 10.70: убрать эффект выбора BW в диагностике, дополнить проверку клавиатуры/успешного clipboard и локальную приёмку. Расписание и три его lint ошибки перенесены в 11Б; реальные RouterOS/БД/портал не приняты по мокам.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 133 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 48 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 114 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 51 KiB |
@@ -0,0 +1,20 @@
|
||||
import {test,expect} from "@playwright/test"
|
||||
for(const [name,width] of [["desktop",1440],["mobile",390],["narrow",320]] as const)test("Диагностика: адаптивность и длинный вывод "+name,async({page})=>{
|
||||
await page.setViewportSize({width,height:900});const device={id:1,name:"Очень длинное имя устройства диагностики для проверки мобильной кнопки",host:"192.0.2.1",country:"RU",enabled:true,status:null,type:"exit-node"}
|
||||
await page.route("**/health",r=>r.fulfill({json:{ok:true}}));await page.route("**/api/**",r=>{const p=new URL(r.request().url()).pathname;return r.fulfill({json:p==="/api/servers"?[device]:p==="/api/auth/config"?{required:false}:p.endsWith("/ros-src-address")?{host:"192.0.2.1",ipv4:"192.0.2.1"}:{tunnels:[]}})})
|
||||
const output=Array.from({length:1200},(_,i)=>"строка-"+i+" "+"x".repeat(160)).join("\n")
|
||||
await page.route("**/api/servers/1/probes/run",r=>r.fulfill({json:{output}}));await page.goto("/probes?server=1");await expect(page.getByRole("button",{name:"Запустить",exact:true})).toBeEnabled();await expect(page.getByRole("status").filter({hasText:"IPv4 для preview:"})).toBeVisible()
|
||||
await page.getByRole("button",{name:"DNS",exact:true}).click();await expect(page.getByRole("button",{name:"DNS",exact:true})).toHaveAttribute("aria-pressed","true");await page.getByRole("button",{name:"CNAME",exact:true}).click();await expect(page.getByRole("button",{name:"CNAME",exact:true})).toHaveAttribute("aria-pressed","true");await page.getByRole("button",{name:"Ping",exact:true}).click()
|
||||
expect(await page.evaluate(()=>document.documentElement.scrollWidth<=window.innerWidth)).toBe(true)
|
||||
if(width<768){const button=page.getByRole("button",{name:"Серверы",exact:true});const box=await button.boundingBox();expect(box&&box.x+box.width<=width).toBe(true);await button.click();await page.keyboard.press("Escape");await expect(button).toBeFocused()}
|
||||
await page.screenshot({path:"docs/ux-ui/screens/probes-ready-"+name+".png",fullPage:true})
|
||||
await page.getByRole("button",{name:"Запустить",exact:true}).click();const region=page.getByRole("region",{name:/^Вывод Ping/});await expect(region).toContainText("строка-1199");await expect(region).toContainText("Показаны последние 1000 строк");await expect(region).not.toContainText("строка-199 ");await region.scrollIntoViewIfNeeded();await region.focus()
|
||||
expect(await region.evaluate(el=>el.clientHeight>100&&el.scrollHeight>el.clientHeight&&el.scrollWidth<=el.clientWidth+1)).toBe(true);expect(await page.evaluate(()=>document.documentElement.scrollWidth<=window.innerWidth)).toBe(true)
|
||||
await page.screenshot({path:"docs/ux-ui/screens/probes-output-"+name+".png",fullPage:true})
|
||||
await page.getByRole("button",{name:/^Удалить результат/}).click();await expect(region).toHaveCount(0);await expect(page.getByRole("button",{name:"Запустить",exact:true})).toBeFocused()
|
||||
})
|
||||
test("Диагностика: недоступный clipboard сохраняет вывод и сообщает ошибку",async({page})=>{
|
||||
await page.route("**/health",r=>r.fulfill({json:{ok:true}}));await page.route("**/api/**",r=>r.fulfill({json:new URL(r.request().url()).pathname==="/api/servers"?[{id:1,name:"Тест",host:"192.0.2.1",enabled:true,status:null,type:"exit-node"}]:new URL(r.request().url()).pathname==="/api/auth/config"?{required:false}:{tunnels:[]}}))
|
||||
await page.addInitScript(()=>Object.defineProperty(navigator,"clipboard",{value:{writeText:()=>Promise.reject(new Error("permission denied"))},configurable:true}))
|
||||
await page.goto("/probes?server=1");await page.getByRole("button",{name:"Скопировать параметры диагностики"}).click();await expect(page.getByText(/Не удалось скопировать/)).toBeVisible();await expect(page.getByRole("textbox",{name:"Цель диагностики"})).toHaveValue("8.8.8.8")
|
||||
})
|
||||
Reference in new issue
Block a user