fix(ui): ограничить граф OSPF с сохранением полного контекста

This commit is contained in:
Denozordec committed 2026-10-04 17:25:55 +07:00
1 parent 1a82c1b41f
commit 8d86c5cbb0
10 files changed
+194 -13

No files matched your search

+15 -9
View File
@@ -1,5 +1,6 @@
"use client"
import { OspfTopologyScope } from "@/components/ospf-topology-scope"
import { usePrefersReducedMotion } from "@/hooks/use-prefers-reduced-motion"
import { deriveOspfRouterIdentity } from "@/lib/ospf-router-identity"
import { buildOspfCostIndex, ospfCostKey } from "@/lib/ospf-cost"
@@ -377,6 +378,7 @@ function Chip({ children, color }: { children: React.ReactNode; color?: string }
// ─── topology SVG ─────────────────────────────────────────────────────────────
interface TopologyGraphProps {
nodeStates?: ReadonlyMap<string, ReturnType<typeof ospfNodeState>>
graphNodes: GraphNode[]
graphEdges: GraphEdge[]
routerIds: Record<string, string>
@@ -387,7 +389,7 @@ interface TopologyGraphProps {
onNodeClick: (id: string | null) => void
}
function TopologyGraph({ graphNodes, graphEdges, highlightId, selectedId, showDots, onNodeClick }: TopologyGraphProps) {
function TopologyGraph({ graphNodes, graphEdges, nodeStates, highlightId, selectedId, showDots, onNodeClick }: TopologyGraphProps) {
const svgRef = useRef<SVGSVGElement>(null)
useEffect(() => {
const svg = svgRef.current, viewport = svg?.parentElement
@@ -405,6 +407,7 @@ function TopologyGraph({ graphNodes, graphEdges, highlightId, selectedId, showDo
const nodeMap = Object.fromEntries(graphNodes.map(n => [n.id, n]))
function nodeState(id: string): keyof typeof NODE_THEME {
if (nodeStates?.has(id)) return nodeStates.get(id)!
const involved = graphEdges.filter(e => e.from === id || e.to === id)
return ospfNodeState(involved.map(edge => edge.state))
}
@@ -469,7 +472,7 @@ function TopologyGraph({ graphNodes, graphEdges, highlightId, selectedId, showDo
(e.from === highlightId && e.to === node.id) ||
(e.to === highlightId && e.from === node.id))
return (
<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})`}
<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} data-ospf-state={state} 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) }}
@@ -896,7 +899,7 @@ function NeighborsTab({
const reducedMotion = usePrefersReducedMotion()
function closeNode() {
const previous = selectedId; onSelect(null)
if (previous) requestAnimationFrame(() => document.querySelector<SVGGElement>(`[data-ospf-node="${CSS.escape(previous)}"]`)?.focus())
if (previous) requestAnimationFrame(() => (document.querySelector<SVGGElement>(`[data-ospf-node="${CSS.escape(previous)}"]`) ?? document.querySelector<HTMLInputElement>('[aria-label="Найти узел топологии"]'))?.focus())
}
const [showDots, setShowDots] = useState(true)
@@ -906,7 +909,7 @@ function NeighborsTab({
return (
<div className="flex flex-col gap-5">
<KpiStatGrid
gridClassName="grid-cols-2 md:grid-cols-3"
gridClassName="grid-cols-1 sm:grid-cols-3"
aria-label="Сводка соседей OSPF"
items={[
{
@@ -937,11 +940,13 @@ 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 min-w-0 flex-col rounded-xl overflow-hidden border border-border lg:h-[360px] lg:flex-row">
<OspfTopologyScope nodes={graphNodes} edges={graphEdges} selectedId={selectedId} onSelect={onSelect} width={TOPO_W} height={TOPO_H}>
{view => <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}
graphNodes={view.nodes}
graphEdges={view.edges}
nodeStates={view.nodeStates}
routerIds={routerIds}
neighbors={neighbors}
highlightId={highlightId}
@@ -952,7 +957,7 @@ function NeighborsTab({
<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 focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring disabled:opacity-50",
"absolute top-3 right-3 flex min-h-11 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-background border-border text-foreground hover:bg-muted"
: "bg-background border-border text-muted-foreground hover:bg-muted",
@@ -976,7 +981,8 @@ function NeighborsTab({
onClose={closeNode}
/>
)}
</div>
</div>}
</OspfTopologyScope>
)}
{!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">
+70
View File
@@ -0,0 +1,70 @@
"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 = 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>
}
+2
View File
@@ -94,3 +94,5 @@ Statistics pivot в backend/src/services/statistics-aggregate.ts огранич
12.3.48: OSPF1000 neighbors/1000 interfaces/1001 nodes,583875 JSON bytes →1000 rows/14012 SVG descendants/32635 page nodes/6760ms dev ready.1 browser/test lint прошли, исходные профиль и desktop/mobile snapshots сохранены. Граф перекрывается и не принят. Далее12.3.49 таблица,12.3.50 topology;10 manualPagination:true остаются.
12.3.49: OSPF1000 neighbors→50 rows,1001 SVG nodes/12015 descendants/13597 page nodes/2827ms dev ready. URL page20/search/global Cost sort/refresh/cache/clamp/320px/axe приняты;22 browser/type/lint/build. Весь граф не принят, KPI1000 переносится на320px, большой node detail вне scope; далее12.3.50–51.9 manualPagination:true остаются.
12.3.50: OSPF1000 neighbors → scope8 связей/9 узлов, выбранный remote1000:2 SVG nodes/27 descendants/1630 page DOM.26+3 main/8 context/type/lint/build. Полный источник и статус/поиск/URL сохранены; большие node details и читаемость parallel edges не приняты. Далее12.3.51.
+2
View File
@@ -1,5 +1,7 @@
# План реализации UX/UI по пятичасовым окнам
Актуальное продолжение 2026-10-04: 12.3.50 принят локально: OSPF topology показывает до8 связей/9 узлов, полный статус и поиск/URL.26+3 main/8 context, types/lint/build; полный источник1000 сохранён. Далее12.3.51 — большие детали узла; затем читаемость параллельных связей и optimizer. Общая регрессия после delta и внешняя12.5 остаются.
Общий план проекта MikrotikManager. Основан на согласованном плане из задачи; точка продолжения обновлена 2026-10-04. Источник фактического прогресса, проверок и ограничений — [журнал реализации](ux-ui-progress.md). Этот документ задаёт объём и критерии завершения, журнал фиксирует результат.
## Актуальная точка продолжения — 2026-10-04
+2
View File
@@ -1,5 +1,7 @@
# UX/UI: журнал реализации
Актуальное продолжение 2026-10-04: 12.3.50 принят локально: OSPF topology показывает до8 связей/9 узлов, полный статус и поиск/URL.26+3 main/8 context, types/lint/build; полный источник1000 сохранён. Далее12.3.51 — большие детали узла; затем читаемость параллельных связей и optimizer. Общая регрессия после delta и внешняя12.5 остаются.
Общий план: [UX/UI по этапам и пятичасовым окнам](ux-ui-plan.md). План задаёт объём, этот журнал фиксирует выполненные блоки и ограничения.
## Правила продолжения
+9
View File
@@ -594,3 +594,12 @@ ReUI pagination50/clamp/ScrollArea, закреплённый local router и к
Осталось9 manualPagination:true specialized grids. Весь экран не принят: граф1001 перекрывается; KPI1000 на320px переносит число; NodeDetailPanel локального узла рендерит все связи. Следующий блок12.3.50: читаемый ограниченный срез topology/явные counts/поиск и выбор любого узла/URL/полный источник, исправить отображение чисел. Затем12.3.51 большие node details, optimizer и общая main/context после delta. Внешняя12.5 отдельно. Коммит локальный без push.
## 12.3.50 — ограниченный срез topology OSPF (2026-10-04)
OspfTopologyScope использует полный источник: до8 incident edges/9 SVG nodes, focal выбранного URL узла или первого устройства, pagination/clamp/сброс при смене focal, точные полные counts, поиск всех узлов с пятью результатами. Малый граф до12 узлов/12 связей сохраняет прежнюю композицию; 1000 параллельных связей между двумя узлами тоже включают ограничение. Полный nodeState вычисляется по всем edges, а не видимой странице. Close возвращает фокус к узлу или поиску, если узел исчез из среза. KPI1000 на320px не дробится, HTML-кнопки выбора/pages/animation44px.
26/26 профильных main,8/8 context и финальные3/3 large прошли. Fixture1000 neighbors/1000 interfaces/1001 nodes/583875 bytes →50 rows/2 SVG nodes/27 descendants/1630 page nodes/1531ms dev ready для URL remote1000. Проверены page2/clamp после уменьшения1000→13→12, полная hidden Full-связь при первых Down, выбор remote1000 из всего каталога/keep, close/focus,320px/axe, bounded parallel1000. TypeScript без incremental, ESLint страницы/компонента/теста и production build прошли. [Профиль](ux-ui/acceptance/ospf-topology-scope-2026-10-04.json), [срез1440px](ux-ui/screens/ospf-topology-scope-1440.png), [320px](ux-ui/screens/ospf-topology-scope-320.png) сохранены и просмотрены. Dev latency не production benchmark. На большой топологии SVG доступен через горизонтальный scroll; все узлы доступны через поиск.
Это не приёмка всех вариантов графа: параллельные edges ограничены по DOM, но их подписи на общей линии могут перекрываться. Большая NodeDetailPanel ещё рендерит все1000 neighbors/interfaces — следующий12.3.51. Затем параллельные связи/optimizer/общая main-context регрессия после delta.9 manualPagination:true specialized grids остаются. Внешние RouterOS/PG/интеграции12.5 не проверены. Локальный коммит без push.
@@ -0,0 +1,9 @@
{
"count": 1000,
"bytes": 583875,
"rows": 50,
"graphNodes": 2,
"svgNodes": 27,
"pageNodes": 1630,
"readyMs": 1531
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

+85 -4
View File
@@ -23,13 +23,23 @@ test("OSPF1000 соседей: профиль всего экрана с topolog
const rows = page.locator("tr[data-row-id]")
await expect(rows).toHaveCount(50)
await expect(rows.first()).toHaveAttribute("data-row-id", "1-n951")
await expect(page.locator("[data-ospf-node]")).toHaveCount(1001)
await expect(page.locator("[data-ospf-node]")).toHaveCount(2)
const profile = { count: neighbors.length, bytes: Buffer.byteLength(JSON.stringify(catalog)), rows: await rows.count(), graphNodes: await page.locator("[data-ospf-node]").count(), svgNodes: await page.getByRole("group", { name: "Топология OSPF", exact: true }).locator("*").count(), pageNodes: await page.locator("*").count(), readyMs: Date.now() - start }
console.log("OSPF neighbors table", JSON.stringify(profile))
console.log("OSPF neighbors topology", JSON.stringify(profile))
writeFileSync(info.outputPath("ospf-neighbors-profile.json"), JSON.stringify(profile, null, 2))
await info.attach("profile", { body: JSON.stringify(profile), contentType: "application/json" })
await page.addStyleTag({ content: "nextjs-portal{display:none!important}" })
await page.screenshot({ path: info.outputPath("ospf-neighbors-table-1440.png"), animations: "disabled" })
await page.screenshot({ path: info.outputPath("ospf-neighbors-topology-1440.png"), animations: "disabled" })
const scope = page.getByRole("region", { name: "Область топологии OSPF" })
await expect(scope.getByRole("status")).toContainText("Показаны 2 из 1001 узлов")
await expect(scope.getByRole("status")).toContainText("Всего в топологии 1000 связей")
await scope.getByRole("textbox", { name: "Найти узел топологии" }).fill("10.0.0.1")
await scope.getByRole("button", { name: "Открыть узел топологии 10.0.0.1 (remote-10.0.0.1)", exact: true }).click()
await expect(page).toHaveURL(url => url.searchParams.get("node") === "remote-10.0.0.1" && url.searchParams.get("keep") === "1")
await expect(page.locator("[data-ospf-node]")).toHaveCount(2)
await scope.getByRole("textbox", { name: "Найти узел топологии" }).fill("10.3.231.1")
await scope.getByRole("button", { name: "Открыть узел топологии 10.3.231.1 (remote-10.3.231.1)", exact: true }).click()
await expect(rows.first()).toHaveAttribute("data-row-id", "1-n951")
const next = page.getByRole("button", { name: "Следующая страница", exact: true })
await expect(next).toBeDisabled()
const before = reads; await page.getByRole("button", { name: "Обновить", exact: true }).click()
@@ -58,8 +68,79 @@ test("OSPF1000 соседей: профиль всего экрана с topolog
expect((await page.evaluate(async () => (window as unknown as { axe: typeof axe }).axe.run(document.querySelector('[data-slot="data-grid"]')!, { runOnly: { type: "tag", values: ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"] } }))).violations.map(v => ({ id: v.id, nodes: v.nodes.map(n => n.target) }))).toEqual([])
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(320)
await page.addStyleTag({ content: "nextjs-portal{display:none!important}" })
await page.screenshot({ path: info.outputPath("ospf-neighbors-table-320.png"), animations: "disabled" })
await page.screenshot({ path: info.outputPath("ospf-neighbors-topology-320.png"), animations: "disabled" })
failed = false; neighbors = [neighbors[999]]; await page.getByRole("button", { name: "Обновить", exact: true }).click()
await expect(rows).toHaveCount(1); await expect(rows.first()).toHaveAttribute("data-row-id", "1-n1000")
expect(writes).toEqual([])
})
test("OSPF topology: полный статус, страницы связей и поиск скрытого узла", async ({ page }, info) => {
test.setTimeout(90000)
const host = { serverId: 1, serverName: "Роутер 1", serverSite: "", serverCountry: "RU" }
let neighbors = Array.from({ length: 1000 }, (_, i) => ({ ...host, id: `n${i + 1}`, address: `10.${Math.floor(i / 256)}.${i % 256}.1`, routerId: `10.${Math.floor(i / 256)}.${i % 256}.1`, instance: "default", area: "backbone", areaId: "0.0.0.0", interface: `ether${i + 1}`, state: "Full", uptime: "1h", stateChanges: 0, priority: 1 }))
const interfaces = neighbors.map((n, i) => ({ ...host, id: `i${i + 1}`, instance: "default", area: "backbone", areaId: "0.0.0.0", interface: n.interface, cost: i + 1, type: "broadcast", disabled: false, inactive: false, priority: 1, helloInterval: 10, deadInterval: 40, useBfd: false }))
const catalog = ospfCatalogSchema.parse({ neighbors, interfaces, instances: [{ ...host, id: "instance1", name: "default", routerId: "192.0.2.1", version: 2, disabled: false, inactive: false, redistribute: "" }], bfdSessions: [], routes: [], failures: [] })
neighbors = neighbors.map((n,i) => ({ ...n, state: i === 999 ? "Full" : "Down" }))
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/auth/config" ? { required: false } : path === "/api/ospf/catalog" ? { ...catalog, neighbors } : path === "/api/servers" ? [{ id: 1, name: "Роутер 1", host: "192.0.2.1", enabled: true, country: "RU", status: null }] : {} })
})
await page.goto("/ospf?tab=neighbors&server=1&keep=1")
const scope = page.getByRole("region", { name: "Область топологии OSPF" })
await expect(scope.getByRole("status")).toContainText("Показаны 9 из 1001 узлов. Связи узла: 1–8 из 1000")
await expect(page.locator("[data-ospf-node]")).toHaveCount(9)
const local = page.locator('[data-ospf-node="1"]')
await expect(local).toHaveAttribute("data-ospf-state", "Full")
await scope.getByRole("button", { name: "Следующая страница связей OSPF" }).click()
await expect(scope.getByRole("status")).toContainText("9–16 из 1000")
await expect(local).toHaveAttribute("data-ospf-state", "Full")
await expect(page.locator('[data-ospf-node="remote-10.0.8.1"]')).toHaveCount(1)
await scope.getByRole("button", { name: "Предыдущая страница связей OSPF" }).click()
await page.addStyleTag({ content: "nextjs-portal{display:none!important}" })
await page.screenshot({ path: info.outputPath("ospf-topology-scope-1440.png"), animations: "disabled" })
await page.setViewportSize({ width: 320, height: 1000 })
await page.screenshot({ path: info.outputPath("ospf-topology-scope-320.png"), animations: "disabled" })
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(320)
for (const button of await scope.locator("button").all()) expect((await button.boundingBox())!.height).toBeGreaterThanOrEqual(44)
await page.addScriptTag({ content: axe.source })
expect((await page.evaluate(async () => (window as unknown as { axe: typeof axe }).axe.run(document.querySelector('[aria-label="Область топологии OSPF"]')!, { runOnly: { type: "tag", values: ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"] } }))).violations.map(v => ({ id: v.id, nodes: v.nodes.map(n => n.target) }))).toEqual([])
const picker = scope.getByRole("textbox", { name: "Найти узел топологии" })
await picker.fill("10.3.231.1")
await scope.getByRole("button", { name: "Открыть узел топологии 10.3.231.1 (remote-10.3.231.1)", exact: true }).click()
await expect(page).toHaveURL(url => url.searchParams.get("node") === "remote-10.3.231.1" && url.searchParams.get("keep") === "1")
await expect(page.locator("[data-ospf-node]")).toHaveCount(2)
await page.getByRole("region", { name: "Детали узла OSPF" }).getByRole("button", { name: "Закрыть", exact: true }).click()
await expect(picker).toBeFocused()
await expect(scope.getByRole("status")).toContainText("1–8 из 1000")
await scope.getByRole("button", { name: "Следующая страница связей OSPF" }).click()
await scope.getByRole("button", { name: "Следующая страница связей OSPF" }).click()
neighbors = neighbors.slice(0, 13)
await page.getByRole("button", { name: "Обновить", exact: true }).click()
await expect(scope.getByRole("status")).toContainText("9–13 из 13")
await expect(scope.getByRole("button", { name: "Следующая страница связей OSPF" })).toBeDisabled()
neighbors = neighbors.slice(0, 12)
await page.getByRole("button", { name: "Обновить", exact: true }).click()
await expect(scope.getByRole("status")).toContainText("9–12 из 12")
})
test("OSPF: тысяча параллельных связей не обходит ограничение графа", async ({ page }) => {
const host = { serverId: 1, serverName: "Роутер 1", serverSite: "", serverCountry: "RU" }
let neighbors = Array.from({ length: 1000 }, (_, i) => ({ ...host, id: `n${i + 1}`, address: `10.${Math.floor(i / 256)}.${i % 256}.1`, routerId: `10.${Math.floor(i / 256)}.${i % 256}.1`, instance: "default", area: "backbone", areaId: "0.0.0.0", interface: `ether${i + 1}`, state: "Full", uptime: "1h", stateChanges: 0, priority: 1 }))
const interfaces = neighbors.map((n, i) => ({ ...host, id: `i${i + 1}`, instance: "default", area: "backbone", areaId: "0.0.0.0", interface: n.interface, cost: i + 1, type: "broadcast", disabled: false, inactive: false, priority: 1, helloInterval: 10, deadInterval: 40, useBfd: false }))
const catalog = ospfCatalogSchema.parse({ neighbors, interfaces, instances: [{ ...host, id: "instance1", name: "default", routerId: "192.0.2.1", version: 2, disabled: false, inactive: false, redistribute: "" }], bfdSessions: [], routes: [], failures: [] })
neighbors = neighbors.map(n => ({ ...n, routerId: "10.0.0.1", address: "10.0.0.1" }))
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/auth/config" ? { required: false } : path === "/api/ospf/catalog" ? { ...catalog, neighbors } : path === "/api/servers" ? [{ id: 1, name: "Роутер 1", host: "192.0.2.1", enabled: true, country: "RU", status: null }] : {} })
})
await page.goto("/ospf?tab=neighbors&server=1")
await expect(page.getByRole("region", { name: "Область топологии OSPF" }).getByRole("status")).toContainText("Показаны 2 из 2 узлов. Связи узла: 1–8 из 1000")
await expect(page.locator("[data-ospf-node]")).toHaveCount(2)
expect(await page.getByRole("group", { name: "Топология OSPF", exact: true }).locator("*").count()).toBeLessThan(200)
})