Files
MikrotikManager/components/ospf-topology-scope.tsx
T

71 lines
5.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client"
import { useMemo, useState, type ReactNode } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { ospfNodeState, type OspfNeighborState } from "@/lib/ospf-state"
export interface OspfTopologyNode { id: string; label: string; short: string; site: string; x: number; y: number }
export interface OspfTopologyEdge { from: string; to: string; state: OspfNeighborState; cost: number | null }
interface TopologyView {
nodes: OspfTopologyNode[]
edges: OspfTopologyEdge[]
nodeStates: ReadonlyMap<string, ReturnType<typeof ospfNodeState>>
}
export function OspfTopologyScope({ nodes, edges, selectedId, onSelect, width, height, children }: {
nodes: OspfTopologyNode[]; edges: OspfTopologyEdge[]; selectedId: string | null
onSelect: (id: string | null) => void; width: number; height: number
children: (view: TopologyView) => ReactNode
}) {
const focal = nodes.find(node => node.id === selectedId) ?? nodes[0]
const [context, setContext] = useState(focal?.id)
const [page, setPage] = useState(0)
const [search, setSearch] = useState("")
const incident = useMemo(() => edges.filter(edge => edge.from === focal?.id || edge.to === focal?.id), [edges, focal?.id])
const pageCount = Math.max(1, Math.ceil(incident.length / 8))
if (context !== focal?.id) { setContext(focal?.id); setPage(0) }
if (page > pageCount - 1) setPage(pageCount - 1)
const currentPage = context === focal?.id ? Math.min(page, pageCount - 1) : 0
const nodeStates = useMemo(() => {
const states = new Map<string, OspfNeighborState[]>()
for (const edge of edges) for (const id of new Set([edge.from, edge.to])) {
const values = states.get(id) ?? []; values.push(edge.state); states.set(id, values)
}
return new Map(nodes.map(node => [node.id, ospfNodeState(states.get(node.id) ?? [])]))
}, [nodes, edges])
const view = useMemo(() => {
if (nodes.length <= 12 && edges.length <= 12) return { nodes, edges, nodeStates }
const visibleEdges = incident.slice(currentPage * 8, (currentPage + 1) * 8)
const ids = new Set([focal?.id, ...visibleEdges.flatMap(edge => [edge.from, edge.to])])
const peers = nodes.filter(node => node.id !== focal?.id && ids.has(node.id))
const radiusX = width * 0.39, radiusY = height * 0.30
const positioned = peers.map((node, index) => {
const angle = peers.length === 1 ? 0 : 2 * Math.PI * index / peers.length - Math.PI / 2
return { ...node, x: width / 2 + radiusX * Math.cos(angle), y: height / 2 + radiusY * Math.sin(angle) }
})
return { nodes: focal ? [{ ...focal, x: width / 2, y: height / 2 }, ...positioned] : [], edges: visibleEdges, nodeStates }
}, [nodes, edges, nodeStates, focal, incident, currentPage, width, height])
const matches = useMemo(() => {
const query = search.trim().toLowerCase()
return nodes.filter(node => !query || `${node.label} ${node.id}`.toLowerCase().includes(query))
}, [nodes, search])
if (nodes.length <= 12 && edges.length <= 12) return children(view)
return <section aria-label="Область топологии OSPF" className="flex min-w-0 flex-col gap-3">
<div className="rounded-xl border border-border bg-background px-4 py-3 flex min-w-0 flex-col gap-3">
<p className="text-sm font-medium">Связи узла: <span className="font-mono break-all">{focal?.label}</span></p>
<p role="status" className="text-xs text-muted-foreground">Показаны {view.nodes.length} из {nodes.length} узлов. Связи узла: {incident.length ? currentPage * 8 + 1 : 0}–{Math.min((currentPage + 1) * 8, incident.length)} из {incident.length}. Всего в топологии {edges.length} связей.</p>
<div className="flex flex-wrap items-center gap-2">
<Button type="button" variant="outline" className="min-h-11" aria-label="Предыдущая страница связей OSPF" disabled={currentPage === 0} onClick={() => setPage(currentPage - 1)}>Назад</Button>
<Button type="button" variant="outline" className="min-h-11" aria-label="Следующая страница связей OSPF" disabled={currentPage >= pageCount - 1} onClick={() => setPage(currentPage + 1)}>Далее</Button>
</div>
<Input aria-label="Найти узел топологии" placeholder="Имя или Router ID узла…" value={search} onChange={event => setSearch(event.target.value)} />
<p className="text-xs text-muted-foreground" aria-live="polite">Найдено {matches.length}; показаны первые {Math.min(5, matches.length)}. Уточните поиск, чтобы найти другой узел.</p>
<div className="flex flex-wrap gap-2">
{matches.slice(0, 5).map(node => <Button key={node.id} type="button" variant="outline" className="min-h-11 max-w-full" aria-label={`Открыть узел топологии ${node.label} (${node.id})`} onClick={() => onSelect(node.id)}><span className="truncate font-mono">{node.label}</span></Button>)}
</div>
</div>
{children(view)}
</section>
}