fix(ui): объединить параллельные связи OSPF в читаемый граф

This commit is contained in:
Denozordec committed 2026-10-04 17:37:42 +07:00
1 parent 086f118204
commit 9f74cfebfe
8 files changed
+43 -13

No files matched your search

+21 -8
View File
@@ -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<string, GraphEdge[]>()
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
<rect width={TOPO_W} height={TOPO_H} fill="var(--background)" />
<rect width={TOPO_W} height={TOPO_H} fill="url(#ospf-dots)" />
{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 (
<g key={i} style={{ transition: showDots ? "opacity 0.2s" : "none" }} opacity={opacity}>
<g key={i} role={grouped ? "img" : undefined} data-ospf-links={links.length} aria-label={grouped ? `Параллельные связи: ${links.length}. ${links.map(link => `${link.state}, cost ${link.cost ?? "—"}`).join("; ")}` : undefined} style={{ transition: showDots ? "opacity 0.2s" : "none" }} opacity={opacity}>
{grouped && <title>{links.map(link => `${link.state}, cost ${link.cost ?? "—"}`).join("; ")}</title>}
<line x1={a.x} y1={a.y} x2={b.x} y2={b.y}
stroke={color} strokeWidth={strokeW}
strokeDasharray={isDashed ? "7 4" : "none"} />
{showDots && e.state === "Full" && (
{showDots && !mixed && e.state === "Full" && (
<circle r="3.5" fill={color} opacity="0.9">
<animateMotion dur={`${2.2 + (i % 5) * 0.38}s`} repeatCount="indefinite"
path={`M ${a.x} ${a.y} L ${b.x} ${b.y}`} />
</circle>
)}
<g transform={`translate(${mx},${my})`}>
<rect x="-15" y="-10" width="30" height="18" rx="4"
<rect x={-badgeWidth / 2} y="-10" width={badgeWidth} height="18" rx="4"
fill="var(--background)" stroke={color} strokeWidth="0.8" opacity="0.95" />
<text textAnchor="middle" y="4" fontSize="12" fontWeight="700"
fill={color} fontFamily="ui-monospace,monospace">{e.cost ?? "—"}</text>
fill={color} fontFamily="ui-monospace,monospace">{grouped ? `${links.length} ${links.length < 5 ? "связи" : "связей"}` : e.cost ?? "—"}</text>
</g>
</g>
)
+1 -1
View File
@@ -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 }
+2
View File
@@ -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.
+1 -1
View File
@@ -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). Этот документ задаёт объём и критерии завершения, журнал фиксирует результат.
+1 -1
View File
@@ -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). План задаёт объём, этот журнал фиксирует выполненные блоки и ограничения.
+9
View File
@@ -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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

+8 -2
View File
@@ -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" })
})