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}