fix(ui): завершить адаптивность и приёмку OSPF

This commit is contained in:
Denozordec committed 2026-09-30 20:43:10 +07:00
1 parent 259c5c3014
commit 79a8d95e7d
16 files changed
+198 -121

No files matched your search

+116 -116
View File
@@ -1,5 +1,6 @@
"use client"
import { usePrefersReducedMotion } from "@/hooks/use-prefers-reduced-motion"
import { deriveOspfRouterIdentity } from "@/lib/ospf-router-identity"
import { buildOspfCostIndex, ospfCostKey } from "@/lib/ospf-cost"
import { normalizeOspfState, normalizeBfdState, ospfNodeState, type OspfNeighborState, type BfdState } from "@/lib/ospf-state"
@@ -265,19 +266,20 @@ const TOPO_H = 360
const NODE_R = 34
const NODE_THEME = {
Pending: { fill: "#2d1e06", stroke: "#fbbf24", glow: "transparent" },
Unknown: { fill: "#111827", stroke: "#9ca3af", glow: "transparent" },
Full: { fill: "#0f2d1f", stroke: "#4ade80", glow: "rgba(74,222,128,0.18)" },
"2-Way":{ fill: "#2d1e06", stroke: "#fbbf24", glow: "rgba(251,191,36,0.14)" },
Down: { fill: "#2d0f0f", stroke: "#f87171", glow: "transparent" },
none: { fill: "#111827", stroke: "#374151", glow: "transparent" },
Pending: { fill: "var(--status-degraded-bg)", stroke: "var(--status-degraded-fg)", glow: "transparent" },
Unknown: { fill: "var(--muted)", stroke: "var(--muted-foreground)", glow: "transparent" },
Full: { fill: "var(--status-online-bg)", stroke: "var(--status-online-fg)", glow: "var(--status-online-bg)" },
"2-Way":{ fill: "var(--status-degraded-bg)", stroke: "var(--status-degraded-fg)", glow: "var(--status-degraded-bg)" },
Down: { fill: "var(--status-offline-bg)", stroke: "var(--status-offline-fg)", glow: "transparent" },
none: { fill: "var(--muted)", stroke: "var(--muted-foreground)", glow: "transparent" },
}
const EDGE_COLOR: Record<string, string> = {
Attempt: "#fbbf24", Init: "#fbbf24", ExStart: "#fbbf24", Exchange: "#fbbf24", Loading: "#fbbf24", Unknown: "#9ca3af",
Full: "#4ade80",
"2-Way":"#fbbf24",
Down: "#f87171",
Pending: "var(--status-degraded-fg)",
Attempt: "var(--status-degraded-fg)", Init: "var(--status-degraded-fg)", ExStart: "var(--status-degraded-fg)", Exchange: "var(--status-degraded-fg)", Loading: "var(--status-degraded-fg)", Unknown: "var(--muted-foreground)",
Full: "var(--status-online-fg)",
"2-Way":"var(--status-degraded-fg)",
Down: "var(--status-offline-fg)",
}
interface GraphNode { id: string; label: string; short: string; site: string; x: number; y: number }
@@ -361,14 +363,6 @@ function moveInArray<T>(arr: T[], from: number, to: number): T[] {
return copy
}
function stateClass(state: OspfNeighbor["state"] | BfdSession["state"]) {
if (state === "Full" || state === "Up")
return "bg-[var(--status-online-bg)] text-[var(--status-online-fg)] border-current/25"
if (state === "2-Way" || state === "Init")
return "bg-[var(--status-degraded-bg)] text-[var(--status-degraded-fg)] border-current/25"
return "bg-[var(--status-offline-bg)] text-[var(--status-offline-fg)] border-current/25"
}
function Chip({ children, color }: { children: React.ReactNode; color?: string }) {
return (
<span className={cn(
@@ -394,6 +388,20 @@ interface TopologyGraphProps {
}
function TopologyGraph({ graphNodes, graphEdges, highlightId, selectedId, showDots, onNodeClick }: TopologyGraphProps) {
const svgRef = useRef<SVGSVGElement>(null)
useEffect(() => {
const svg = svgRef.current, viewport = svg?.parentElement
if (!svg || !viewport) return
const center = () => {
const node = graphNodes.find(node => node.id === selectedId)
const point = svg.createSVGPoint(); point.x = node?.x ?? TOPO_W / 2
const matrix = svg.getScreenCTM()
if (matrix) viewport.scrollLeft += point.matrixTransform(matrix).x - viewport.getBoundingClientRect().left - viewport.clientWidth / 2
}
center()
const observer = new ResizeObserver(center); observer.observe(viewport)
return () => observer.disconnect()
}, [graphNodes, selectedId])
const nodeMap = Object.fromEntries(graphNodes.map(n => [n.id, n]))
function nodeState(id: string): keyof typeof NODE_THEME {
@@ -402,9 +410,9 @@ function TopologyGraph({ graphNodes, graphEdges, highlightId, selectedId, showDo
}
return (
<svg
<svg ref={svgRef} role="group" aria-label="Топология OSPF"
viewBox={`0 0 ${TOPO_W} ${TOPO_H}`}
style={{ width: "100%", height: TOPO_H, display: "block", userSelect: "none", cursor: "default" }}
style={{ width: "100%", minWidth: 680, height: TOPO_H, display: "block", userSelect: "none", cursor: "default" }}
onClick={() => onNodeClick(null)}
>
<defs>
@@ -412,14 +420,14 @@ function TopologyGraph({ graphNodes, graphEdges, highlightId, selectedId, showDo
<circle cx="1" cy="1" r="0.8" fill="rgba(148,163,184,0.08)" />
</pattern>
</defs>
<rect width={TOPO_W} height={TOPO_H} fill="#060d1a" />
<rect width={TOPO_W} height={TOPO_H} fill="var(--background)" />
<rect width={TOPO_W} height={TOPO_H} fill="url(#ospf-dots)" />
{graphEdges.map((e, i) => {
const a = nodeMap[e.from]
const b = nodeMap[e.to]
if (!a || !b) return null
const color = EDGE_COLOR[e.state] ?? "#64748b"
const color = EDGE_COLOR[e.state] ?? "var(--muted-foreground)"
const isDashed = e.state !== "Full"
const active = highlightId === e.from || highlightId === e.to
|| selectedId === e.from || selectedId === e.to
@@ -428,7 +436,7 @@ function TopologyGraph({ graphNodes, graphEdges, highlightId, selectedId, showDo
const mx = (a.x + b.x) / 2
const my = (a.y + b.y) / 2
return (
<g key={i} style={{ transition: "opacity 0.2s" }} opacity={opacity}>
<g key={i} style={{ transition: showDots ? "opacity 0.2s" : "none" }} opacity={opacity}>
<line x1={a.x} y1={a.y} x2={b.x} y2={b.y}
stroke={color} strokeWidth={strokeW}
strokeDasharray={isDashed ? "7 4" : "none"} />
@@ -440,8 +448,8 @@ function TopologyGraph({ graphNodes, graphEdges, highlightId, selectedId, showDo
)}
<g transform={`translate(${mx},${my})`}>
<rect x="-15" y="-10" width="30" height="18" rx="4"
fill="#060d1a" stroke={color} strokeWidth="0.8" opacity="0.95" />
<text textAnchor="middle" y="4" fontSize="8.5" fontWeight="700"
fill="var(--background)" stroke={color} strokeWidth="0.8" opacity="0.95" />
<text textAnchor="middle" y="4" fontSize="12" fontWeight="700"
fill={color} fontFamily="ui-monospace,monospace">{e.cost ?? "—"}</text>
</g>
</g>
@@ -461,68 +469,46 @@ function TopologyGraph({ graphNodes, graphEdges, highlightId, selectedId, showDo
(e.from === highlightId && e.to === node.id) ||
(e.to === highlightId && e.from === node.id))
return (
<g data-ospf-node={node.id} key={node.id} transform={`translate(${node.x},${node.y})`}
style={{ cursor: "pointer", transition: "opacity 0.2s" }}
<g role="button" tabIndex={0} aria-label={`Открыть узел ${node.label}`} aria-expanded={isSel} className="focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring" data-ospf-node={node.id} key={node.id} transform={`translate(${node.x},${node.y})`}
style={{ cursor: "pointer", transition: showDots ? "opacity 0.2s" : "none" }}
opacity={dimmed ? 0.25 : 1}
onClick={e => { e.stopPropagation(); onNodeClick(isSel ? null : node.id) }}>
{state === "Full" && !dimmed && (
onClick={e => { e.stopPropagation(); onNodeClick(isSel ? null : node.id) }}
onKeyDown={e => {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); e.stopPropagation(); onNodeClick(isSel ? null : node.id) }
if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); onNodeClick(null) }
}}>
{showDots && state === "Full" && !dimmed && (
<circle r={NODE_R + 14} fill={theme.glow} opacity="0.7">
<animate attributeName="r" values={`${NODE_R+9};${NODE_R+17};${NODE_R+9}`} dur="3s" repeatCount="indefinite" />
<animate attributeName="opacity" values="0.8;0.18;0.8" dur="3s" repeatCount="indefinite" />
</circle>
)}
{state === "2-Way" && !dimmed && <circle r={NODE_R + 10} fill={theme.glow} />}
{isSel && <circle r={NODE_R+11} fill="none" stroke="rgba(255,255,255,0.65)" strokeWidth="2" />}
{isHov && !isSel && <circle r={NODE_R+10} fill="none" stroke="rgba(255,255,255,0.38)" strokeWidth="1.5" strokeDasharray="4 3" />}
{isSel && <circle r={NODE_R+11} fill="none" stroke="var(--ring)" strokeWidth="2" />}
{isHov && !isSel && <circle r={NODE_R+10} fill="none" stroke="var(--ring)" strokeWidth="1.5" strokeDasharray="4 3" />}
<circle r={NODE_R} fill={theme.fill}
stroke={isSel ? "#fff" : theme.stroke} strokeWidth={isSel ? 2.8 : 1.8} />
stroke={isSel ? "var(--foreground)" : theme.stroke} strokeWidth={isSel ? 2.8 : 1.8} />
{node.site !== "—" && (
<g transform={`translate(${NODE_R-10},${-(NODE_R-10)})`}>
<circle r="10" fill={
node.site === "MSK" ? "#7c3aed" : node.site === "SPB" ? "#0369a1" :
node.site === "FRA" ? "#b45309" : "#065f46"} />
<text textAnchor="middle" y="4" fontSize="6" fontWeight="700" fill="#fff" fontFamily="system-ui,sans-serif">
node.site === "MSK" ? "var(--primary)" : node.site === "SPB" ? "var(--info)" :
node.site === "FRA" ? "var(--warning)" : "var(--success)"} />
<text textAnchor="middle" y="4" fontSize="6" fontWeight="700" fill="var(--foreground)" fontFamily="system-ui,sans-serif">
{node.site.slice(0,3)}
</text>
</g>
)}
<text textAnchor="middle" y="-5" fontSize="11" fontWeight="700" fill="#f1f5f9" fontFamily="ui-monospace,monospace">
<text textAnchor="middle" y="-5" fontSize="11" fontWeight="700" fill="var(--foreground)" fontFamily="ui-monospace,monospace">
{node.site !== "—" ? node.site : "?"}
</text>
<text textAnchor="middle" y="10" fontSize="7.5" fill={isSel ? "#fff" : theme.stroke}
<text textAnchor="middle" y="10" fontSize="12" fill={isSel ? "var(--foreground)" : theme.stroke}
fontFamily="ui-monospace,monospace" fontWeight="500">{node.short}</text>
<text textAnchor="middle" y={NODE_R+18} fontSize="8" fill="#94a3b8" fontFamily="ui-monospace,monospace">
<text textAnchor="middle" y={NODE_R+20} fontSize="14" fill="var(--muted-foreground)" fontFamily="ui-monospace,monospace">
{node.label.replace(/^mt-/, "")}
</text>
</g>
)
})}
{/* legend */}
<g transform="translate(14, 14)">
<rect width="152" height="118" rx="8" fill="rgba(6,13,26,0.90)" stroke="rgba(255,255,255,0.07)" strokeWidth="1" />
<text x="10" y="22" fontSize="7.5" fontWeight="700" fill="#64748b" fontFamily="system-ui" letterSpacing="0.1em">ЛЕГЕНДА OSPF</text>
{([
{ state: "Full", label: "Full adjacency" },
{ state: "2-Way", label: "2-Way partial" },
{ state: "Down", label: "Down" },
] as const).map(({ state, label }, i) => {
const c = EDGE_COLOR[state] ?? "#64748b"
return (
<g key={state} transform={`translate(10, ${34 + i * 20})`}>
<circle cx="5" cy="0" r="4.5" fill={c} opacity="0.85" />
<line x1="16" y1="0" x2="44" y2="0" stroke={c}
strokeWidth={state === "Full" ? 2 : 1.2}
strokeDasharray={state !== "Full" ? "5 3" : "none"} opacity="0.8" />
<text x="52" y="4" fontSize="8" fill="#cbd5e1" fontFamily="system-ui">{label}</text>
</g>
)
})}
<line x1="10" y1="100" x2="142" y2="100" stroke="rgba(255,255,255,0.07)" strokeWidth="1" />
<text x="10" y="113" fontSize="7" fill="#475569" fontFamily="ui-monospace,monospace">
клик по узлу — подробности · цифра = cost
</text>
</g>
</svg>
)
}
@@ -554,29 +540,30 @@ function NodeDetailPanel({
const theme = NODE_THEME[panelState]
const siteFill =
node.site === "MSK" ? "#7c3aed" : node.site === "SPB" ? "#0369a1" :
node.site === "FRA" ? "#b45309" : "#065f46"
node.site === "—" ? "var(--muted)" : node.site === "MSK" ? "var(--primary)" : node.site === "SPB" ? "var(--info)" :
node.site === "FRA" ? "var(--warning)" : "var(--success)"
return (
<div role="region" aria-label="Детали узла OSPF" className="w-72 shrink-0 flex flex-col overflow-y-auto border-l border-white/10"
style={{ background: "#060d1a", maxHeight: TOPO_H }}>
<div className="flex items-start gap-2 px-4 py-3 border-b border-white/10 shrink-0">
<div role="region" aria-label="Детали узла OSPF" className="w-full min-h-0 flex flex-col border-t border-border lg:w-72 lg:shrink-0 lg:border-t-0 lg:border-l lg:overflow-y-auto"
style={{ background: "var(--background)" }}
onKeyDown={event => { if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); onClose() } }}>
<div className="flex items-start gap-2 px-4 py-3 border-b border-border shrink-0">
<div className="size-8 rounded-full flex items-center justify-center shrink-0 mt-0.5 text-[10px] font-bold text-white"
style={{ background: siteFill }}>
style={{ background: siteFill, color: node.site === "—" ? "var(--muted-foreground)" : "var(--primary-foreground)" }}>
{node.site !== "—" ? node.site.slice(0,3) : "?"}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-mono font-semibold text-[#f1f5f9] truncate leading-tight">{node.label}</p>
<p className="text-[10px] font-mono text-[#64748b] mt-0.5">Router-ID: {routerId}</p>
<p className="text-sm font-mono font-semibold text-foreground truncate leading-tight">{node.label}</p>
<p className="text-[10px] font-mono text-muted-foreground mt-0.5">Router-ID: {routerId}</p>
</div>
<button type="button" aria-label="Закрыть" onClick={onClose} className="text-white/25 hover:text-white/70 transition-colors mt-0.5 shrink-0">
<button type="button" aria-label="Закрыть" onClick={onClose} className="text-muted-foreground hover:text-foreground transition-colors mt-0.5 shrink-0">
<XIcon className="size-3.5" />
</button>
</div>
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-white/10 shrink-0">
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-border shrink-0">
<span className="size-2 rounded-full shrink-0" style={{ background: theme.stroke }} />
<span className="text-xs font-mono font-semibold" style={{ color: theme.stroke }}>{panelState}</span>
<span className="text-xs font-mono font-semibold" style={{ color: theme.stroke }}>{panelState === "Pending" ? "Согласование" : panelState === "Unknown" ? "Неизвестно" : panelState}</span>
<div className="flex gap-1.5 ml-auto">
{fullCnt > 0 && <span className="text-[10px] px-1.5 py-0.5 rounded-full border border-current/25 font-medium"
style={{ background: "var(--status-online-bg)", color: "var(--status-online-fg)" }}>Full ×{fullCnt}</span>}
@@ -585,19 +572,19 @@ function NodeDetailPanel({
</div>
</div>
<div className="px-4 py-3 border-b border-white/10">
<p className="text-[10px] font-semibold text-[#475569] uppercase tracking-wider mb-2">Соседи ({adjacencies.length})</p>
{adjacencies.length === 0 && <p className="text-[11px] text-[#475569]">Нет активных adjacency</p>}
<div className="flex flex-col gap-0 divide-y divide-white/[0.04]">
<div className="px-4 py-3 border-b border-border">
<p className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">Соседи ({adjacencies.length})</p>
{adjacencies.length === 0 && <p className="text-[11px] text-muted-foreground">Нет активных adjacency</p>}
<div className="flex flex-col gap-0 divide-y divide-border">
{adjacencies.map((adj, i) => {
const ec = EDGE_COLOR[adj.state] ?? "#64748b"
const ec = EDGE_COLOR[adj.state] ?? "var(--muted-foreground)"
return (
<div key={i} className="flex items-start gap-2.5 py-2.5">
<span className="size-1.5 rounded-full shrink-0 mt-1.5" style={{ background: ec }} />
<div className="flex-1 min-w-0">
<p className="text-xs font-mono font-medium text-[#f1f5f9] truncate leading-tight">{adj.peerLabel}</p>
<p className="text-[10px] font-mono text-[#64748b] mt-0.5">{adj.iface} · cost {adj.cost ?? "—"}</p>
<p className="text-[10px] text-[#475569] mt-0.5 tabular-nums">{adj.uptime}</p>
<p className="text-xs font-mono font-medium text-foreground truncate leading-tight">{adj.peerLabel}</p>
<p className="text-[10px] font-mono text-muted-foreground mt-0.5">{adj.iface} · cost {adj.cost ?? "—"}</p>
<p className="text-[10px] text-muted-foreground mt-0.5 tabular-nums">{adj.uptime}</p>
</div>
<span className="text-[10px] font-mono font-semibold shrink-0 mt-0.5" style={{ color: ec }}>{adj.state}</span>
</div>
@@ -607,19 +594,19 @@ function NodeDetailPanel({
</div>
<div className="px-4 py-3">
<p className="text-[10px] font-semibold text-[#475569] uppercase tracking-wider mb-2">
Интерфейсы OSPF{ifaces.length > 0 && <span className="ml-1 text-[#374151]">({ifaces.length})</span>}
<p className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">
Интерфейсы OSPF{ifaces.length > 0 && <span className="ml-1 text-muted-foreground">({ifaces.length})</span>}
</p>
{ifaces.length === 0 && <p className="text-[11px] text-[#475569]">Нет данных</p>}
<div className="flex flex-col gap-0 divide-y divide-white/[0.04]">
{ifaces.length === 0 && <p className="text-[11px] text-muted-foreground">Нет данных</p>}
<div className="flex flex-col gap-0 divide-y divide-border">
{ifaces.map(iface => (
<div key={iface.key} className="flex items-center gap-2 py-2">
<span className={cn("size-1.5 rounded-full shrink-0", iface.active ? "bg-emerald-500" : "bg-[#374151]")} />
<code className="text-[11px] font-mono flex-1 min-w-0 truncate" style={{ color: "#94a3b8" }}>{iface.interfaceName}</code>
<span className="text-[10px] font-mono text-[#475569] shrink-0">
<span className={cn("size-1.5 rounded-full shrink-0", iface.active ? "bg-emerald-500" : "bg-muted-foreground/40")} />
<code className="text-[11px] font-mono flex-1 min-w-0 truncate" style={{ color: "var(--muted-foreground)" }}>{iface.interfaceName}</code>
<span className="text-[10px] font-mono text-muted-foreground shrink-0">
{iface.area === "0.0.0.0" ? "backbone" : `area ${iface.area}`}
</span>
<span className="text-[10px] font-mono shrink-0" style={{ color: "#0ea5e9" }}>cost {iface.cost ?? "—"}</span>
<span className="text-[10px] font-mono shrink-0" style={{ color: "var(--info)" }}>cost {iface.cost ?? "—"}</span>
</div>
))}
</div>
@@ -780,7 +767,7 @@ function InterfacesTab({
)}
{isLive && filterServerId !== ALL_SERVERS_ID && (
<Button variant="outline" size="sm" onClick={handleLiveOptimize} disabled={optimizing || !dataReady || !can("mm:servers:write")}>
<WandSparklesIcon className={cn("size-4", optimizing && "animate-spin")} />
<WandSparklesIcon className={cn("size-4", optimizing && "animate-spin motion-reduce:animate-none")} />
{optimizing ? "Оптимизация OSPF…" : "Оптимизировать OSPF"}
</Button>
)}
@@ -830,7 +817,7 @@ function InterfacesTab({
onDragOver={e => { e.preventDefault(); setDragOver(item.key) }}
onDrop={() => !isLive && onDrop(router.routerKey, ag.area, item.key)}
className={cn(
"flex items-center gap-3 px-4 py-2.5 select-none transition-colors",
"flex flex-wrap items-center gap-3 px-4 py-2.5 select-none transition-colors sm:flex-nowrap",
!isLive && "cursor-grab",
dragging === item.key && "opacity-40",
dragOver === item.key && dragging !== item.key && "bg-primary/5",
@@ -906,6 +893,11 @@ function NeighborsTab({
hasSearch: boolean
}) {
const [highlightId, setHighlightId] = useState<string | null>(null)
const reducedMotion = usePrefersReducedMotion()
function closeNode() {
const previous = selectedId; onSelect(null)
if (previous) requestAnimationFrame(() => document.querySelector<SVGGElement>(`[data-ospf-node="${CSS.escape(previous)}"]`)?.focus())
}
const [showDots, setShowDots] = useState(true)
dataIncomplete = dataIncomplete || neighbors.some(n => n.state === "Unknown")
@@ -914,11 +906,12 @@ function NeighborsTab({
return (
<div className="flex flex-col gap-5">
<KpiStatGrid
gridClassName="grid-cols-2 md:grid-cols-3"
aria-label="Сводка соседей OSPF"
items={[
{
id: "neighbors",
label: "Всего соседей",
label: "Соседи",
value: dataIncomplete ? "—" : neighbors.length,
icon: <UsersIcon className="size-4" />,
iconClassName: "text-muted-foreground",
@@ -944,8 +937,8 @@ function NeighborsTab({
{selectedId && !graphUnknown && !graphNodes.some(node => node.id === selectedId) && <Alert variant="warning"><AlertTitle>Узел OSPF недоступен</AlertTitle><AlertDescription>Узел из ссылки отсутствует в текущем срезе.<Button type="button" size="sm" variant="outline" onClick={() => onSelect(null)}>Закрыть узел OSPF</Button></AlertDescription></Alert>}
{graphUnknown && <p role="status" className="text-sm text-muted-foreground">Топология OSPF неизвестна до успешного опроса всех устройств.</p>}
{!graphUnknown && graphNodes.length > 0 && (
<div className="flex rounded-xl overflow-hidden border border-white/[0.06]">
<div className="flex-1 relative" style={{ background: "#060d1a", minWidth: 0 }}>
<div className="flex min-w-0 flex-col rounded-xl overflow-hidden border border-border lg:h-[360px] lg:flex-row">
<div className="relative min-w-0 overflow-x-auto lg:flex-1" style={{ background: "var(--background)", minWidth: 0 }}>
<TopologyGraph
graphNodes={graphNodes}
graphEdges={graphEdges}
@@ -953,22 +946,22 @@ function NeighborsTab({
neighbors={neighbors}
highlightId={highlightId}
selectedId={selectedId}
showDots={showDots}
showDots={showDots && !reducedMotion}
onNodeClick={onSelect}
/>
<button
<button type="button" aria-pressed={showDots && !reducedMotion} disabled={reducedMotion} title={reducedMotion ? "Анимация отключена настройками устройства" : undefined}
onClick={() => setShowDots(v => !v)}
className={cn(
"absolute top-3 right-3 flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border text-[11px] font-medium transition-colors backdrop-blur-sm",
"absolute top-3 right-3 flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border text-[11px] font-medium transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring disabled:opacity-50",
showDots
? "bg-black/60 border-white/15 text-white/70 hover:text-white"
: "bg-black/60 border-white/10 text-white/30 hover:text-white/60",
? "bg-background border-border text-foreground hover:bg-muted"
: "bg-background border-border text-muted-foreground hover:bg-muted",
)}>
<span className={cn("size-1.5 rounded-full", showDots ? "bg-[var(--status-online)]" : "bg-white/20")} />
<span className={cn("size-1.5 rounded-full", showDots && !reducedMotion ? "bg-[var(--status-online)]" : "bg-muted-foreground/40")} />
Анимация
</button>
{!selectedId && (
<div className="absolute bottom-3 right-3 text-[10px] font-mono text-white/20 pointer-events-none">
<div className="absolute bottom-3 right-3 text-[10px] font-mono text-muted-foreground pointer-events-none">
кликните по узлу для деталей
</div>
)}
@@ -980,12 +973,17 @@ function NeighborsTab({
routerIds={routerIds}
neighbors={neighbors}
items={items}
onClose={() => onSelect(null)}
onClose={closeNode}
/>
)}
</div>
)}
{!graphUnknown && graphNodes.length > 0 && <div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-xs text-muted-foreground" aria-label="Легенда OSPF">
{(["Full", "2-Way", "Pending", "Down", "Unknown"] as const).map(state => <span key={state} className="inline-flex items-center gap-1.5"><span aria-hidden="true" className="size-2 rounded-full" style={{ background: EDGE_COLOR[state] }} />{state === "Pending" ? "Согласование" : state === "Unknown" ? "Неизвестно" : state}</span>)}
<span>Цифра на связи — cost. Узел открывается нажатием или Enter.</span>
<span className="lg:hidden">Схему можно прокручивать по горизонтали.</span>
</div>}
<DataPageCard>
<OspfNeighborsDataGrid
neighbors={neighbors}
@@ -1048,11 +1046,12 @@ function BfdTab({ sessions, dataIncomplete, hasSearch }: { sessions: BfdSession[
return (
<div className="flex flex-col gap-4">
<KpiStatGrid
gridClassName="grid-cols-2 md:grid-cols-4"
aria-label="Сводка BFD"
items={[
{
id: "sessions",
label: "Сессий BFD",
label: "Сессии",
value: dataIncomplete ? "—" : sessions.length,
icon: <ActivityIcon className="size-4" />,
iconClassName: "text-muted-foreground",
@@ -1066,7 +1065,7 @@ function BfdTab({ sessions, dataIncomplete, hasSearch }: { sessions: BfdSession[
},
{
id: "down",
label: "Down / Admin",
label: <span title="Down / AdminDown">Down/Adm</span>,
value: dataIncomplete ? "—" : downCount,
icon: <AlertCircleIcon className="size-4" />,
iconClassName: downCount > 0 ? "text-destructive" : "text-muted-foreground",
@@ -1307,7 +1306,7 @@ function OspfPageContent({ search, selectedNodeId, onSearch, onSelectNode, filte
<>
<ServerRailMobileButton />
<Button variant="outline" size="sm" disabled={!isLive || loading || writeBusy} onClick={refresh}>
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
<RefreshCwIcon className={cn("size-4", loading && "animate-spin motion-reduce:animate-none")} />
Обновить
</Button>
</>
@@ -1316,11 +1315,11 @@ function OspfPageContent({ search, selectedNodeId, onSearch, onSelectNode, filte
}
banner={
<div className="border-b bg-background shrink-0">
<div className="flex items-center px-6">
<div className="flex min-w-0 items-center overflow-x-auto px-4 md:px-6">
{TABS.map(t => (
<button key={t.id} aria-pressed={activeTab === t.id} disabled={writeBusy} onClick={() => { if (!operationBusy.current) onSelectTab(t.id) }}
<button type="button" key={t.id} aria-pressed={activeTab === t.id} disabled={writeBusy} onClick={() => { if (!operationBusy.current) onSelectTab(t.id) }}
className={cn(
"flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors -mb-px",
"flex shrink-0 items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring -mb-px",
activeTab === t.id
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground hover:border-border",
@@ -1339,7 +1338,7 @@ function OspfPageContent({ search, selectedNodeId, onSearch, onSelectNode, filte
{/* data source banner */}
{isLive && loading && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<RefreshCwIcon className="size-3.5 animate-spin" />Загрузка OSPF данных…
<RefreshCwIcon className="size-3.5 animate-spin motion-reduce:animate-none" />Загрузка OSPF данных…
</div>
)}
{isLive && fetchedAt && <div role="group" aria-label="Свежесть OSPF" className="text-xs text-muted-foreground">
@@ -1355,12 +1354,13 @@ function OspfPageContent({ search, selectedNodeId, onSearch, onSelectNode, filte
</span>
)}
<KpiStatGrid
{(activeTab === "interfaces" || unavailable || isLive && !liveData) && <KpiStatGrid
gridClassName="grid-cols-2 md:grid-cols-3"
aria-label="Сводка OSPF"
items={[
{
id: "routers",
label: "Роутеров",
label: "Роутеры",
value: viewIncomplete || unavailable ? "—" : totalRouters,
hint: viewIncomplete || unavailable ? "неизвестно" : undefined,
icon: <RouterIcon className="size-4" />,
@@ -1368,20 +1368,20 @@ function OspfPageContent({ search, selectedNodeId, onSearch, onSelectNode, filte
},
{
id: "ifaces",
label: "Интерфейсов",
label: <span title="Интерфейсы">Интерф.</span>,
value: viewIncomplete ? "—" : totalInterfaces,
icon: <NetworkIcon className="size-4" />,
iconClassName: "text-info",
},
{
id: "areas",
label: "Зон (Area)",
label: "Зоны",
value: viewIncomplete ? "—" : totalAreas,
icon: <LayersIcon className="size-4" />,
iconClassName: "text-primary",
},
]}
/>
/>}
{activeTab === "interfaces" && (!viewIncomplete || displayItems.length > 0) && (
<InterfacesTab key={filterServerId}
@@ -17,6 +17,7 @@ import {
} from "@/components/data-grids/shared/data-grid-layout"
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
import { EmptyState } from "@/components/empty-state"
import { Button } from "@/components/ui/button"
import { NetworkIcon } from "lucide-react"
export interface OspfNeighborRow {
@@ -69,7 +70,7 @@ function OspfNeighborsDataGrid({
<DataGridSortHeader column={column} title="Роутер" className="ml-1" />
),
cell: ({ row }) => (
<span className="font-mono whitespace-nowrap">{row.original.localLabel}</span>
onSelect ? <Button type="button" variant="ghost" size="sm" aria-label={`Открыть узел ${row.original.localLabel}`} aria-expanded={selectedRouterId === row.original.localRouter} onClick={event => { event.stopPropagation(); onSelect(selectedRouterId === row.original.localRouter ? null : row.original.localRouter) }}>{row.original.localLabel}</Button> : <span className="font-mono whitespace-nowrap">{row.original.localLabel}</span>
),
meta: {
headerTitle: "Роутер",
@@ -188,7 +189,7 @@ function OspfNeighborsDataGrid({
},
},
],
[],
[onSelect, selectedRouterId],
)
const rowSelection = useMemo(() => {
+11
View File
@@ -151,3 +151,14 @@ HTTP-фикстуры; layout/клавиатура проверены. Productio
| BGP · Stats 12 / Frame | [Аналитика](ux-ui/screens/bgp-analytics-desktop.png) | [Аналитика](ux-ui/screens/bgp-analytics-mobile.png) | Точный срез → достоверные суммы → сравнение |
27 E2E/сборки прошли; реальные RouterOS/JWT/БД не проверены. Далее — OSPF, точное продолжение в журнале.
## OSPF — локальная визуальная приёмка 2026-09-30
| Экран / референс | ПК | Телефон | Схема |
| --- | --- | --- | --- |
| Интерфейсы · [List 9](https://reui.io/preview/base/list-9?ref=mcp) / [Stats 12](https://reui.io/preview/base/stats-12?ref=mcp) | [Снимок](ux-ui/screens/ospf-interfaces-desktop.png) | [Снимок](ux-ui/screens/ospf-interfaces-mobile.png) | Устройство → свежесть → интерфейсы → подтверждённая операция |
| Соседи · [Data Grid Filtering 2](https://reui.io/preview/base/data-grid-filtering-2?ref=mcp) | [Снимок](ux-ui/screens/ospf-neighbors-desktop.png) | [Снимок](ux-ui/screens/ospf-neighbors-mobile.png) | Поиск → один срез графа/таблицы → фактическое состояние |
| Детали · Frame | [Снимок](ux-ui/screens/ospf-detail-desktop.png) | [Снимок](ux-ui/screens/ospf-detail-mobile.png) | URL узла → Router ID → соседи/интерфейсы → возврат фокуса |
| BFD · Data Grid / Stats 12 | [Снимок](ux-ui/screens/ospf-bfd-desktop.png) | [Снимок](ux-ui/screens/ospf-bfd-mobile.png) | Срез → состояния → наблюдаемые таймеры/счётчики |
HTTP-фикстуры; локальная приёмка: 43 основных + 8 source E2E, 9 unit, production frontend. Реальный RouterOS/JWT/БД не проверен. Далее рекурсивные маршруты; точное продолжение в журнале.
+9 -1
View File
@@ -26,11 +26,19 @@
| 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.22, достоверные метаданные/узлы OSPF; далее layout/keyboard |
| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.23; OSPF принят локально, далее рекурсивные маршруты |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
## Блок 10.23 — адаптивность и локальная приёмка OSPF (2026-09-30)
- Одна профильная сводка на вкладку вместо дублирования. Мобильные вкладки/таблицы прокручиваются внутри контейнеров; детали находятся под графом. Выбранный узел центрируется, легенда читабельна и включает согласование/неизвестность; семантические цвета текущей темы.
- Поиск, вкладки, кнопки таблицы и узлы доступны с клавиатуры. Enter/Space открывают детали; Escape закрывает и возвращает фокус. Reduced motion отключает SVG-анимацию, переключатель учитывает настройку устройства. Пульсация меняет прозрачность, сохраняя стабильную цель клика.
- Проверки: 43 профильных E2E, восемь source/history E2E, девять unit, TS/lint и production frontend (34 страницы) прошли. После последних коротких подписей повторно прошли три layout/motion сценария; восемь desktop/mobile снимков просмотрены. Первичный прогон: 44/45, нестабильная SVG-геометрия исправлена и identity click повторно прошёл без force.
- ReUI checklist применён в границах OSPF: Frame/Data Grid и общий KpiStatGrid переиспользованы, registry и общая тема не изменены. Backend regression/build прошли в 10.22; backend после него не менялся.
- OSPF завершён в границах локальной приёмки; реальные RouterOS/DB/JWT остаются внешней проверкой. Далее 10.24: рекурсивные маршруты — сохранение формы, строгий receipt, чтение/контекст/права. Последняя квота: 25% окна, 35% недели использовано; продолжать по поручению пользователя.
## Блок 10.22 — метаданные и однозначные узлы OSPF (2026-09-30)
- Неизвестный subtype OSPF-маршрута показывается нейтрально, без intra-area fallback. Unknown multihop BFD не становится false. Legacy parsers совместимы; строгий каталог проверен raw fixtures.
+4
View File
@@ -140,3 +140,7 @@ q/node сохраняются вместе с server/tab. Таблица и то
## 10.22. Подтверждённые метаданные/узлы
Route subtype и BFD multihop неизвестны без наблюдения. Router ID refs/ambiguity не создают ложную связь устройств; только уникальный фактический ID объединяет узлы. 13 E2E, 2 identity unit, backend regression/build и TS/lint прошли. Далее layout/keyboard/motion/приёмка.
## 10.23. Приёмка OSPF на ПК/телефоне
Срез устройства → свежесть → профильная сводка → граф/таблица → детали → подтверждение API. Детали на телефоне под графом; Escape возвращает фокус; reduced motion соблюдается. 43 основных + 8 source E2E, 9 unit и production frontend прошли; screenshot/keyboard/motion проверены. Прежние BGP/OSPF backend fixtures/build прошли в 10.22. OSPF принят локально, следующий раздел — рекурсивные маршруты. Реальный RouterOS/JWT/DB не проверен.
Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

+5
View File
@@ -0,0 +1,5 @@
"use client"
import { useSyncExternalStore } from "react"
const query = "(prefers-reduced-motion: reduce)"
function subscribe(callback: () => void) { const media=window.matchMedia(query); media.addEventListener("change",callback); return ()=>media.removeEventListener("change",callback) }
export function usePrefersReducedMotion() { return useSyncExternalStore(subscribe,()=>window.matchMedia(query).matches,()=>false) }
+2 -2
View File
@@ -59,9 +59,9 @@ test("OSPF: частичный отказ сохраняет устройств
await expect(rows.filter({ hasText: "Роутер 2" })).toContainText("Неизвестно")
await expect(rows.filter({ hasText: "198.51.100.1" })).toContainText("Full")
await page.getByRole("option").filter({ hasText: "Роутер 1" }).click()
await expect(page.getByLabel("Сводка OSPF", { exact: true })).not.toContainText("неизвестно")
await expect(page.getByLabel("Сводка соседей OSPF", { exact: true })).not.toContainText("—")
await page.getByRole("option").filter({ hasText: "Роутер 2" }).click()
await expect(page.getByLabel("Сводка OSPF", { exact: true })).toContainText("неизвестно")
await expect(page.getByLabel("Сводка соседей OSPF", { exact: true })).toContainText("—")
phase = 2
await alert.getByRole("button", { name: "Повторить загрузку OSPF" }).click()
await expect(rows).toHaveCount(0)
+48
View File
@@ -0,0 +1,48 @@
import {test,expect,type Page} from "@playwright/test"
import {mkdirSync} from "node:fs"
const host=(id:number)=>({serverId:id,serverName:"Роутер "+id,serverSite:"",serverCountry:"RU"})
async function setup(page:Page){
await page.route("**/health",r=>r.fulfill({json:{ok:true}}))
await page.route("**/api/**",r=>{const path=new URL(r.request().url()).pathname;return r.fulfill({json:path==="/api/servers"?[1,2].map(id=>({id,name:"Роутер "+id,host:"192.0.2."+id,enabled:true,status:null,country:"RU"})):path==="/api/ospf/catalog"?{interfaces:[1,2].map(id=>({...host(id),id:"i",instance:"default",area:"backbone",areaId:"0.0.0.0",interface:"ether"+id,cost:id===1?20:null,type:"ptp",disabled:false,inactive:false,priority:0,helloInterval:10,deadInterval:40,useBfd:true})),neighbors:[1,2].map(id=>({...host(id),id:"n",address:"192.0.2."+(3-id),routerId:"192.0.2."+(3-id),instance:"default",area:"backbone",areaId:"0.0.0.0",interface:"ether"+id,state:id===1?"Full":"Exchange",uptime:"1h",priority:0,stateChanges:0})),instances:[1,2].map(id=>({...host(id),id:"s",name:"default",routerId:"192.0.2."+id,version:2,disabled:false,inactive:false,redistribute:""})),bfdSessions:[1,2].map(id=>({...host(id),id:"b",localAddr:"192.0.2."+id,remoteAddr:"192.0.2."+(3-id),interface:"ether"+id,state:"Up",uptime:"1h",multihop:id===1?false:null,multiplier:id===1?0:null,txInterval:id===1?0:null,rxInterval:id===1?0:null,holdTime:id===1?0:null,packetsRx:id===1?0:null,packetsTx:id===1?0:null,stateChanges:id===1?0:null})),routes:[],failures:[]}:path==="/api/auth/config"?{required:false}:{}})})
}
for(const device of ["desktop","mobile"] as const)test("OSPF: layout/keyboard "+device,async({page})=>{
await page.setViewportSize(device==="desktop"?{width:1440,height:1000}:{width:390,height:844})
await page.emulateMedia({reducedMotion:"reduce"})
await page.clock.install({time:new Date("2026-09-30T05:00:00Z")})
await setup(page);await page.goto("/ospf?server=1")
await expect(page.getByText("ether1",{exact:true})).toBeVisible()
await expect(page.getByRole("textbox",{name:"Поиск в текущем разделе OSPF…"})).toBeVisible()
expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBe(true)
const tabs=page.getByRole("button",{name:"BFD",exact:true}).locator("..")
expect(await tabs.evaluate(el=>el.getBoundingClientRect().right<=innerWidth)).toBe(true)
await page.addStyleTag({content:"nextjs-portal { display:none!important; }"});mkdirSync("docs/ux-ui/screens",{recursive:true})
await page.screenshot({path:"docs/ux-ui/screens/ospf-interfaces-"+device+".png",animations:"disabled"})
await page.getByRole("button",{name:"Соседи",exact:true}).focus();await page.keyboard.press("Enter")
const graph=page.getByRole("group",{name:"Топология OSPF"})
await expect(graph).toBeVisible()
await expect(graph.locator("animate,animateMotion")).toHaveCount(0)
await page.screenshot({path:"docs/ux-ui/screens/ospf-neighbors-"+device+".png",animations:"disabled"})
const open=page.locator("tr[data-row-id]").getByRole("button",{name:"Открыть узел Роутер 1",exact:true})
await open.focus();await page.keyboard.press("Enter")
const details=page.getByRole("region",{name:"Детали узла OSPF"});await expect(details).toBeVisible()
await details.scrollIntoViewIfNeeded()
expect(await details.evaluate(el=>el.getBoundingClientRect().right<=innerWidth)).toBe(true)
expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBe(true)
await page.screenshot({path:"docs/ux-ui/screens/ospf-detail-"+device+".png",animations:"disabled"})
await details.getByRole("button",{name:"Закрыть",exact:true}).focus();await page.keyboard.press("Escape")
await expect(details).toHaveCount(0)
const node=graph.getByRole("button",{name:"Открыть узел Роутер 1",exact:true});await expect(node).toBeFocused()
await page.keyboard.press(" ");await expect(details).toBeVisible()
await page.keyboard.press("Escape");await expect(details).toHaveCount(0)
await page.getByRole("button",{name:"BFD",exact:true}).focus();await page.keyboard.press("Enter")
await expect(page.locator("tr[data-row-id]")).toHaveCount(1)
expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBe(true)
await page.screenshot({path:"docs/ux-ui/screens/ospf-bfd-"+device+".png",animations:"disabled"})
})
test("OSPF: reduced motion и управление анимацией",async({page})=>{
await page.emulateMedia({reducedMotion:"reduce"});await setup(page);await page.goto("/ospf?tab=neighbors")
const graph=page.getByRole("group",{name:"Топология OSPF"}),toggle=page.getByRole("button",{name:"Анимация",exact:true})
await expect(toggle).toBeDisabled();await expect(toggle).toHaveAttribute("aria-pressed","false");await expect(graph.locator("animate,animateMotion")).toHaveCount(0)
await page.emulateMedia({reducedMotion:"no-preference"});await expect(toggle).toBeEnabled();await expect.poll(()=>graph.locator("animate,animateMotion").count()).toBeGreaterThan(0)
await toggle.click();await expect(toggle).toHaveAttribute("aria-pressed","false");await expect(graph.locator("animate,animateMotion")).toHaveCount(0)
})