fix(ui): ограничить граф OSPF с сохранением полного контекста
This commit is contained in:
1 parent
1a82c1b41f
commit
8d86c5cbb0
10 files changed
+194
-13
No files matched your search
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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). План задаёт объём, этот журнал фиксирует выполненные блоки и ограничения.
|
||||
|
||||
## Правила продолжения
|
||||
|
||||
@@ -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 |
@@ -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)
|
||||
})
|
||||
Reference in new issue
Block a user