diff --git a/app/(main)/ospf/page.tsx b/app/(main)/ospf/page.tsx index 5066b480..f5e12f3c 100644 --- a/app/(main)/ospf/page.tsx +++ b/app/(main)/ospf/page.tsx @@ -406,6 +406,14 @@ function TopologyGraph({ graphNodes, graphEdges, nodeStates, highlightId, select return () => observer.disconnect() }, [graphNodes, selectedId]) const nodeMap = Object.fromEntries(graphNodes.map(n => [n.id, n])) + const visualLinks = useMemo(() => { + const groups = new Map() + for (const edge of graphEdges) { + const key = JSON.stringify([edge.from, edge.to].sort()) + const links = groups.get(key) ?? []; links.push(edge); groups.set(key, links) + } + return [...groups.values()] + }, [graphEdges]) function nodeState(id: string): keyof typeof NODE_THEME { if (nodeStates?.has(id)) return nodeStates.get(id)! @@ -427,34 +435,39 @@ function TopologyGraph({ graphNodes, graphEdges, nodeStates, highlightId, select - {graphEdges.map((e, i) => { + {visualLinks.map((links, i) => { + const e = links[0] + const mixed = links.some(link => link.state !== e.state) + const grouped = links.length > 1 + const badgeWidth = grouped ? 90 : 30 const a = nodeMap[e.from] const b = nodeMap[e.to] if (!a || !b) return null - const color = EDGE_COLOR[e.state] ?? "var(--muted-foreground)" - const isDashed = e.state !== "Full" + const color = mixed ? "var(--muted-foreground)" : EDGE_COLOR[e.state] ?? "var(--muted-foreground)" + const isDashed = mixed || e.state !== "Full" const active = highlightId === e.from || highlightId === e.to || selectedId === e.from || selectedId === e.to - const opacity = active ? 1 : 0.38 + const opacity = active || grouped ? 1 : 0.38 const strokeW = active ? 2.4 : 1.4 const mx = (a.x + b.x) / 2 const my = (a.y + b.y) / 2 return ( - + `${link.state}, cost ${link.cost ?? "—"}`).join("; ")}` : undefined} style={{ transition: showDots ? "opacity 0.2s" : "none" }} opacity={opacity}> + {grouped && {links.map(link => `${link.state}, cost ${link.cost ?? "—"}`).join("; ")}} - {showDots && e.state === "Full" && ( + {showDots && !mixed && e.state === "Full" && ( )} - {e.cost ?? "—"} + fill={color} fontFamily="ui-monospace,monospace">{grouped ? `${links.length} ${links.length < 5 ? "связи" : "связей"}` : e.cost ?? "—"} ) diff --git a/components/ospf-topology-scope.tsx b/components/ospf-topology-scope.tsx index 9898c797..afa43cfa 100644 --- a/components/ospf-topology-scope.tsx +++ b/components/ospf-topology-scope.tsx @@ -41,7 +41,7 @@ export function OspfTopologyScope({ nodes, edges, selectedId, onSelect, width, h 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 + 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 } diff --git a/docs/ux-ui-performance.md b/docs/ux-ui-performance.md index cba9a804..c32f51a1 100644 --- a/docs/ux-ui-performance.md +++ b/docs/ux-ui-performance.md @@ -98,3 +98,5 @@ Statistics pivot в backend/src/services/statistics-aggregate.ts огранич 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. 12.3.51: OSPF node=1/1000 neighbors+1000 interfaces →50+50 rows/635 panel nodes/2335 page DOM.4+15+1 browser/type/lint/build. Search/full counts/refresh/clamp/320px/axe приняты; parallel link labels далее12.3.52. + +12.3.52: OSPF parallel1000 between2 nodes →8 raw edges per page/1 visual line, полный источник/state/cost сохранён.9+1 browser/type/lint/build; screenshot1440 принят. Далее optimizer preview. diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index bb8040e6..2c07f2bf 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -1,6 +1,6 @@ # План реализации UX/UI по пятичасовым окнам -Актуальное продолжение 2026-10-04: 12.3.51 принят локально: большие детали OSPF по50 neighbors/interfaces, поиск полного списка/refresh/clamp/320px/axe.4+15+1 browser/type/lint/build. Далее12.3.52 — подписи параллельных связей; затем optimizer и общая main/context после delta. Внешняя12.5 отдельно. +Актуальное продолжение 2026-10-04: 12.3.52 принят локально — параллельные связи OSPF объединены визуально без общего выдуманного cost/state;9+1 browser/type/lint/build. Далее12.3.53 — большой OSPF preview оптимизатора; затем остальные матрицы/общая регрессия. Внешняя12.5 отдельно. Общий план проекта MikrotikManager. Основан на согласованном плане из задачи; точка продолжения обновлена 2026-10-04. Источник фактического прогресса, проверок и ограничений — [журнал реализации](ux-ui-progress.md). Этот документ задаёт объём и критерии завершения, журнал фиксирует результат. diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index faafb8bc..c1b1689d 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -1,6 +1,6 @@ # UX/UI: журнал реализации -Актуальное продолжение 2026-10-04: 12.3.51 принят локально: большие детали OSPF по50 neighbors/interfaces, поиск полного списка/refresh/clamp/320px/axe.4+15+1 browser/type/lint/build. Далее12.3.52 — подписи параллельных связей; затем optimizer и общая main/context после delta. Внешняя12.5 отдельно. +Актуальное продолжение 2026-10-04: 12.3.52 принят локально — параллельные связи OSPF объединены визуально без общего выдуманного cost/state;9+1 browser/type/lint/build. Далее12.3.53 — большой OSPF preview оптимизатора; затем остальные матрицы/общая регрессия. Внешняя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 70659f3c..f8989250 100644 --- a/docs/ux-ui-stage-12.md +++ b/docs/ux-ui-stage-12.md @@ -612,3 +612,12 @@ OspfDetailList: client pages50 отдельно для соседей/интер 4/4 large,15/15 profile keyboard/layout/URL/state/identity и финальный detail1/1 прошли. Полный fixture1000 neighbors/1000 interfaces →50+50 записей/635 panel DOM/2335 page DOM для URL node=1. Поиск ether1000/полный cost1000, no-match, page2/refresh, shrink1000→1/keep/node,320px/кнопки44/axe проверены. TypeScript без incremental, полный ESLint страницы/компонента/теста и production build прошли. [Профиль](ux-ui/acceptance/ospf-detail-2026-10-04.json), [панель320px](ux-ui/screens/ospf-large-detail-320.png) сохранены и просмотрены. Каталог не обрезается в памяти. Следующий12.3.52: визуальная группировка параллельных links без подмены cost/state; затем optimizer и общая регрессия после всех delta.9 specialized manualPagination:true остаются. Внешние RouterOS/PG/интеграции12.5 не проверены. Локальный коммит без push. + + +## 12.3.52 — читаемые параллельные связи OSPF (2026-10-04) + +TopologyGraph группирует видимые edges по неориентированной паре: одна линия/подпись количества на страницу, отдельные cost/state каждой связи в SVG title и accessible name. Для разных состояний нейтральный цвет, dashed без Full-анимации; Unknown не подставляется. Статусы узлов остаются по всем edges. Групповая подпись читается без hover; единственный peer расположен сбоку, подпись не накладывается на узел. Full table и node details доступны для просмотра всех связей. + +9/9 browser (large topology/details/identity/states/layout/keyboard/reduced motion) и финальный parallel1/1 прошли.1000 links between2 nodes, page8 links →одна visual group, aria-label Full/cost1 и Down/cost2, подпись8 связей. TypeScript без incremental/ESLint страницы/scope/теста и окончательный production build прошли. [Группировка1440px](ux-ui/screens/ospf-parallel-links-1440.png) сохранена и просмотрена; PNG после проверки показал недостаточный opacity, исправлен и повторно проверен. + +Следующий12.3.53: OSPF preview оптимизатора1000 rows/полная apply-ветка при pagination/search; затем матрицы и many HomeRouterCard.9 manualPagination:true specialized grids остаются. Общая main/context регрессия после delta и внешняя12.5 отдельно. Локальный коммит без push. diff --git a/docs/ux-ui/screens/ospf-parallel-links-1440.png b/docs/ux-ui/screens/ospf-parallel-links-1440.png new file mode 100644 index 00000000..fc7384a8 Binary files /dev/null and b/docs/ux-ui/screens/ospf-parallel-links-1440.png differ diff --git a/tests/e2e/large-ospf-neighbors.spec.ts b/tests/e2e/large-ospf-neighbors.spec.ts index 3e944b73..d2ac81fc 100644 --- a/tests/e2e/large-ospf-neighbors.spec.ts +++ b/tests/e2e/large-ospf-neighbors.spec.ts @@ -127,13 +127,13 @@ test("OSPF topology: полный статус, страницы связей и }) -test("OSPF: тысяча параллельных связей не обходит ограничение графа", async ({ page }) => { +test("OSPF: тысяча параллельных связей не обходит ограничение графа", async ({ page }, info) => { 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" })) + neighbors = neighbors.map((n, index) => ({ ...n, routerId: "10.0.0.1", address: "10.0.0.1", state: index % 2 ? "Down" : "Full" })) await page.route("**/health", r => r.fulfill({ json: { ok: true } })) await page.route("**/api/**", r => { const path = new URL(r.request().url()).pathname @@ -143,6 +143,12 @@ test("OSPF: тысяча параллельных связей не обходи 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) + const visual = page.locator('[data-ospf-links="8"]') + await expect(visual).toHaveCount(1) + await expect(visual).toHaveAttribute("aria-label", /Full, cost 1; Down, cost 2/) + await expect(visual.locator("text")).toHaveText("8 связей") + await page.addStyleTag({ content: "nextjs-portal{display:none!important}" }) + await page.screenshot({ path: info.outputPath("ospf-parallel-links-1440.png"), animations: "disabled" }) })