fix(ui): завершить адаптивность и приёмку OSPF
No files matched your search
@@ -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(() => {
|
||||
|
||||
@@ -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/БД не проверен. Далее рекурсивные маршруты; точное продолжение в журнале.
|
||||
@@ -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.
|
||||
|
||||
@@ -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 не проверен.
|
||||
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 43 KiB |
@@ -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) }
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||