diff --git a/app/(main)/ospf/page.tsx b/app/(main)/ospf/page.tsx index e58ba867..b688ad0c 100644 --- a/app/(main)/ospf/page.tsx +++ b/app/(main)/ospf/page.tsx @@ -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> graphNodes: GraphNode[] graphEdges: GraphEdge[] routerIds: Record @@ -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(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 ( - { 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(`[data-ospf-node="${CSS.escape(previous)}"]`)?.focus()) + if (previous) requestAnimationFrame(() => (document.querySelector(`[data-ospf-node="${CSS.escape(previous)}"]`) ?? document.querySelector('[aria-label="Найти узел топологии"]'))?.focus()) } const [showDots, setShowDots] = useState(true) @@ -906,7 +909,7 @@ function NeighborsTab({ return (
node.id === selectedId) && Узел OSPF недоступенУзел из ссылки отсутствует в текущем срезе.} {graphUnknown &&

Топология OSPF неизвестна до успешного опроса всех устройств.

} {!graphUnknown && graphNodes.length > 0 && ( -
+ + {view =>
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} /> )} -
+
} +
)} {!graphUnknown && graphNodes.length > 0 &&
diff --git a/components/ospf-topology-scope.tsx b/components/ospf-topology-scope.tsx new file mode 100644 index 00000000..9898c797 --- /dev/null +++ b/components/ospf-topology-scope.tsx @@ -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> +} + +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() + 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
+
+

Связи узла: {focal?.label}

+

Показаны {view.nodes.length} из {nodes.length} узлов. Связи узла: {incident.length ? currentPage * 8 + 1 : 0}–{Math.min((currentPage + 1) * 8, incident.length)} из {incident.length}. Всего в топологии {edges.length} связей.

+
+ + +
+ setSearch(event.target.value)} /> +

Найдено {matches.length}; показаны первые {Math.min(5, matches.length)}. Уточните поиск, чтобы найти другой узел.

+
+ {matches.slice(0, 5).map(node => )} +
+
+ {children(view)} +
+} diff --git a/docs/ux-ui-performance.md b/docs/ux-ui-performance.md index 9f898543..2884cea1 100644 --- a/docs/ux-ui-performance.md +++ b/docs/ux-ui-performance.md @@ -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. diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index a8f637b0..153b6e5d 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -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 diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 33e88cb9..5e2adf8c 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -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). План задаёт объём, этот журнал фиксирует выполненные блоки и ограничения. ## Правила продолжения diff --git a/docs/ux-ui-stage-12.md b/docs/ux-ui-stage-12.md index a5c9090b..c2b31050 100644 --- a/docs/ux-ui-stage-12.md +++ b/docs/ux-ui-stage-12.md @@ -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. diff --git a/docs/ux-ui/acceptance/ospf-topology-scope-2026-10-04.json b/docs/ux-ui/acceptance/ospf-topology-scope-2026-10-04.json new file mode 100644 index 00000000..1f7c18e9 --- /dev/null +++ b/docs/ux-ui/acceptance/ospf-topology-scope-2026-10-04.json @@ -0,0 +1,9 @@ +{ + "count": 1000, + "bytes": 583875, + "rows": 50, + "graphNodes": 2, + "svgNodes": 27, + "pageNodes": 1630, + "readyMs": 1531 +} \ No newline at end of file diff --git a/docs/ux-ui/screens/ospf-topology-scope-1440.png b/docs/ux-ui/screens/ospf-topology-scope-1440.png new file mode 100644 index 00000000..ccbb7e91 Binary files /dev/null and b/docs/ux-ui/screens/ospf-topology-scope-1440.png differ diff --git a/docs/ux-ui/screens/ospf-topology-scope-320.png b/docs/ux-ui/screens/ospf-topology-scope-320.png new file mode 100644 index 00000000..b7be1f81 Binary files /dev/null and b/docs/ux-ui/screens/ospf-topology-scope-320.png differ diff --git a/tests/e2e/large-ospf-neighbors.spec.ts b/tests/e2e/large-ospf-neighbors.spec.ts index c8b51939..e99ca376 100644 --- a/tests/e2e/large-ospf-neighbors.spec.ts +++ b/tests/e2e/large-ospf-neighbors.spec.ts @@ -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) +})