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