Optimize topology canvas per the 2026-10-12 plan.

Save path: one atomic POST /views/{id}/commit (edges, ports, styles, node
add/remove, positions) replaces the multi-request save. Edge styles and
manual-edge port edits now go through dirty/undo/save instead of writing
immediately; non-manual edge ports are read-only.

Data safety: UI saves no longer clear MCP node locks or freeze the device
name into the placement label (label_override + omit-means-keep semantics,
also applied to the MCP positions tool). Adding NEs/neighbors with unsaved
edits now saves first instead of silently dropping them. Positions no longer
bypass membership caps, except persisting synthesized child-region icons.

Fixes found in acceptance: nodes added in the same commit kept (40,40)
under autoflush=False; edits on edges deleted in the same save 404'd the
whole save; box-selection drags and arrow-key moves were not marked dirty;
large canvases far from the origin opened blank and locate did nothing
because fitView waits for measurement that onlyRenderVisibleElements never
does (now fitBounds from known geometry).

Also: batch N+1 lookups in graph/positions, deterministic truncation,
outside-peer scan excludes internal edges, split TopologyPage into hooks,
fix flaky UME/LLDP port test, add scripts/topology_clear_frozen_labels.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
oliver 2026-10-12 22:11:22 +08:00
parent 60a0ea6021
commit f301544b99
40 changed files with 5587 additions and 1916 deletions

View file

@ -0,0 +1,295 @@
# 拓扑管理问题分析与优化计划
日期:2026-10-12。范围:拓扑管理画布(`web/src/features/topology/*`)及其视图接口(`netx_api/topology_views_graph.py`、`topology_router.py`、`topology_fabric_links.py`)。不含:UME World 平铺大图 LOD、LLDP 采集策略、分类规则、MCP 布局算法。
用途:交给 Cursor 按任务逐项实施;Claude 负责验收。
本文结论来自代码阅读和本地脚本验证,没有在真实 PostgreSQL 或真实设备上执行。标了 **[已验证]** 的问题已经用脚本复现。
**基线(修改前):**
- `pytest tests/test_topology.py tests/test_ume_topology_apply.py`:**69 项通过,4 项失败**(见 T13,修改前就失败,与本计划无关)
- `web`:`npx tsc -b` 通过
- 拓扑前端目前没有任何单元测试
**实施进度(2026-10-12):**
| 任务 | 状态 |
|---|---|
| T11 基线失败测试 | ✅ 完成 |
| T1 保存保留 lock | ✅ 完成 |
| T2 不固化设备名为 label | ✅ 完成 |
| T6 原子 commit + useTopologySave | ✅ 完成 |
| T3 脏状态添加确认 | ✅ 完成 |
| T4 手工边端口持久化 | ✅ 完成 |
| T5 边样式走保存模型 | ✅ 完成 |
| T7 N+1 / 截断确定性 | ✅ 完成 |
| T8 positions 跳过缺失节点 | ✅ 完成(MCP 不依赖 positions 顺便加点) |
| T9 点击不标脏 | ✅ 完成 |
| T10 拆分 TopologyPage | ✅ 完成(`TopologyPage.tsx` ≤1200;抽出 palette/edge/keyboard/find/export/nodeOps/moduleLinks + `TopologyPageView`) |
回归:`pytest` 87 passed;`npx tsc -b` 通过;`topologySaveDiff.test.mjs` 4 passed。
---
## 0. 结论摘要
整体架构没有大问题(服务端图 + 本地编辑 + 一次保存)。主要问题集中在**“本地编辑状态”和“服务端状态”的同步边界**,有几处会静默丢数据或误删数据:
1. **UI 保存会清掉 MCP 设置的节点锁定,并把设备名固化成标签。** **[已验证]** 保存后 `locked` 从 True 变为 False,节点被移走;之后设备改名,画布上仍显示旧名称。
2. **有未保存修改时“添加网元/投影邻居”会静默丢弃这些修改,并留下隐形删除。** 被删除的连线会重新出现在画布上,但仍留在待删除队列里,下次保存时会被真正删掉。
3. **修改已有连线的端口不会被保存。** 界面上可以改,也会显示新值,但不会标记为未保存,保存时也不会提交,刷新后就恢复原值。
4. **连线样式绕过了“编辑→保存”模型。** 样式修改会立即写库,但撤销只回退本地。撤销之后,画布显示和数据库就不一致了。
5. **保存不是原子的。** 一次保存会依次调用 N 次建边、1 次删边、1 次加点、1 次删点和 1 次写坐标,其中 3 次会返回完整的图数据。中途失败会留下“部分已保存”的状态。另外,同一套保存逻辑在 `purgePlaceholderById` 里重复实现了一遍。
6. **后端有 N+1 查询和不确定的截断。** 写坐标时每个点各查一次;外部邻居查询的 `limit(5000)` 把视图内部的边也算进去了,边密集的视图可能找不到外部邻居;节点超过上限时截断没有排序。
7. **`TopologyPage.tsx` 有 2509 行。** 保存、面板添加、连线编辑、快捷键等逻辑都堆在这一个组件里,所以上面这些问题很难被发现,也很难测试。
---
## 1. 现状:画布编辑模型(给 Cursor 的背景)
```
GET /views/{id} ──> graphQuery.data ──(graphToFlow)──> nodes / edges (本地 React state)
│ 拖动/改名/连线/删除
▼
dirtyRef + historyRef(撤销栈, UNDO_MAX=40)
pendingEdgeCreatesRef / pendingEdgeDeletesRef
│ Ctrl+S / 保存
▼
saveMut (TopologyPage.tsx:516)
1. 每条待建边 POST /fabric/edges (逐条串行)
2. POST /fabric/edges/delete (待删边)
3. POST /views/{id}/nodes (服务端没有的本地点;返回整图)
4. POST /views/{id}/nodes/remove (本地没有的服务端点;返回整图)
5. PATCH /views/{id}/positions (全部点的 x/y/label;返回整图)
```
旁路(不走保存,直接写库):
- 添加网元/拖入面板 `addPaletteItems` (`TopologyPage.tsx:621`)、投影外部邻居 `addOutsidePeers` (`:1047`):写库后**用服务端图整体覆盖本地并 `clearDirty()`**
- 连线样式 `patchSelectedEdgeStyle` (`:765`):立即 `PATCH /edge-style`
- 删除占位网元 `purgePlaceholderById` (`:1238`):先把保存逻辑复制执行一遍,再清理
脏状态下的轮询 (`useTopologyCanvas.ts:306-318`):只合并在线状态、层级等叠加字段,不动几何信息,这部分是对的。
---
## 2. 问题清单与任务
优先级:**P0** = 静默丢数据/误删,**P1** = 可靠性/性能,**P2** = 体验/可维护性。
### T1 [P0] UI 保存会清除节点锁定 **[已验证]**
- `flowToPositions` (`web/src/features/topology/lib/graphFlow.ts:90`) 不发送 `locked` 字段。
- `ViewNodeIn.locked` 的默认值是 `False` (`netx_api/topology_schemas.py:277`)。
- `patch_view_positions` 只在“行已锁定且请求也带 locked=True”时才跳过 (`topology_views_graph.py:738`),否则会覆盖坐标并写回 `locked=False`。
- MCP 会用 `locked=true` 固定布局(`packages/netx-topology-mcp/.../http_tools.py:1985`)。用户在 UI 里随便拖一下再保存,这个锁就没了。
复现:MCP 把节点锁定在 (500,500),UI 保存 (10,10),结果节点变成 `locked=False x=10`。
**修改:**
1. 后端:把 `ViewNodeIn.locked` 改为 `bool | None = None`,`None` 表示保持原值。对已锁定的行,只有请求里明确带 `locked=False` 才能移动(解锁);否则跳过并计入 `skipped_locked`。MCP 显式传 `locked` 的行为保持不变。
2. 前端:在 `NeNodeData` 里加 `locked`(由 `graphToFlow` 从 `n.locked` 填入)。锁定的节点设 `draggable: false`,并显示一个锁形角标;`flowToPositions` 不再发送 `locked`。
3. 保存结果里如果 `skipped_locked > 0`,用 toast 提示。
**验收:**
- 新增 `tests/test_topology.py::test_positions_preserve_lock_when_omitted`:锁定后用不带 locked 的 positions 请求,坐标不变,`locked` 仍为 True。
- 新增 `test_positions_explicit_unlock_moves`:`locked=False` 可以移动并解锁。
- 现有 MCP 相关测试全部通过。
### T2 [P0] 保存会把设备名固化成节点标签 **[已验证]**
- 接口返回的 `label` 已经做过回退:`vn.label` 为空时用 `fn.name / ip` (`topology_views_graph.py:195-197`)。
- 前端把它放进 `data.label`,保存时 `flowToPositions` 又原样写回 `vn.label` (`graphFlow.ts:100`)。
- 所以保存过一次之后,所有节点的 `vn.label` 都是当时的设备名。以后设备改名(例如 UME/网管同步),画布不会跟着变。
复现:保存后把 fabric 节点改名为 `NEW-NAME`,画布仍显示 `OLD-NAME`。
**修改:**
1. 后端 `ViewNodeOut` 增加 `label_override: str`(原始 `vn.label`);`label` 仍是回退后的显示值,保持兼容。`ViewNodeIn.label` 改为 `str | None = None`,`None` 表示不修改。
2. 前端 `NeNodeData` 增加 `label_override`,只有用户“重命名节点”(`renameSelectedNode`,`TopologyPage.tsx:820`)时才写入。`flowToPositions` 只对 `label_override` 有变化的节点发送 `label`;重命名为空字符串表示清除覆盖,恢复跟随设备名。
3. 数据修复脚本 `scripts/topology_clear_frozen_labels.py`,默认 dry-run:`vn.label` 等于当前 `fn.name` 或 `fn.ip` 的行,把 label 置空。输出受影响的行数,加 `--apply` 才真正写库。
**验收:**
- 新增 `test_positions_without_label_keeps_following_name`:保存不带 label,改名后画布 label 是新名称。
- 新增 `test_label_override_survives_rename`:显式覆盖后改名,仍显示覆盖值。
- 脚本 dry-run 在测试库上输出正确的计数,`--apply` 之后再跑一次计数为 0。
### T3 [P0] 有未保存修改时“添加网元 / 投影邻居”会丢弃修改并留下隐形删除
- `addPaletteItems` (`TopologyPage.tsx:681-686`) 和 `addOutsidePeers` (`:1096-1101`) 都是直接写库,然后 `applyViewGraph(refreshed)` 再 `clearDirty()`:
- 本地未保存的拖动、改名、删点**直接丢失**,也没有提示;
- `pendingEdgeDeletesRef` **没有清空**。被删除的边按服务端数据重新画回画布,但它的 id 还在待删队列里。用户以后再改任何东西并保存时,这条**看得见的边会被删掉**;
- 撤销栈也没有清空,撤销会回到和服务端不一致的快照。
- 同类问题:`useTopologyCanvas.ts:320-355` 在非脏状态下整体刷新时,也不会清理撤销栈。
**修改(推荐“先保存再添加”,简单而且语义清楚):**
1. 抽出 `commitCanvas()`(见 T6)。`addPaletteItems` / `addOutsidePeers` 开始时如果 `dirtyRef.current` 为 true,就弹窗“当前有未保存修改,保存后继续添加?”:确认则先 `commitCanvas()` 再添加,取消则中止。
2. 新增一个统一的辅助函数 `resetLocalFromServer(graph)`,用于所有“用服务端图整体覆盖本地”的地方:同时清空两个 pending 集合、撤销栈和重做栈,然后 `clearDirty()`。目前 `purgePlaceholderById` (`:1289-1297`) 是手写的这套逻辑,其他地方都漏了。
**验收(手工,Cursor 需要在 PR 描述里写明步骤和结果):**
1. 删除一条已有边 → 不保存 → 从面板拖入一个网元 → 应弹出确认框;选择保存后,该边被删除,新网元出现。
2. 同上,但选择取消 → 不添加网元,删除操作仍处于未保存状态。
3. 删除边 → 投影邻居(确认保存)→ 刷新页面,边确实被删除,没有“复活后又消失”。
4. 用 grep 检查:所有 `applyViewGraph(` 调用点要么使用 `localPositions` 合并,要么经过 `resetLocalFromServer`。
### T4 [P0] 修改已有连线的端口不会被保存
- 右键菜单对任意单条边都显示端口输入框 (`TopologyCtxMenu.tsx:400-429`)。
- `patchSelectedEdgeStyle` 修改本地端口后,只把样式字段发给 `PATCH /edge-style`(`ViewEdgeStylePatch` 本来就没有端口字段,`topology_schemas.py:450`),也没有 `markDirty()`。
- 保存只处理 `pendingEdgeCreatesRef` 里的新边,已有边的端口修改永远不会提交。
**修改:**
1. 只有 `source === "manual"` 的边可以编辑端口;LLDP/UME 边的端口改为只读(这两类以采集结果为准,手工修改会在下次发现时被覆盖)。
2. 未保存的新手工边:端口修改继续只改本地,保存时随建边一起提交(现有逻辑已支持)。
3. 已保存的手工边:新增 `PATCH /v1/topology/fabric/edges/{id}`,body `{a_port, b_port}`,只允许 `source=manual`,否则 400 `edge_not_manual`;端口要做归一化,与已有边冲突(违反唯一约束)时返回 409。前端把修改记入 `pendingEdgePortEditsRef: Map<id,{a,b}>` 并 `markDirty()`,在 `commitCanvas()` 里提交。
**验收:**
- 新增 `test_patch_manual_edge_ports`、`test_patch_non_manual_edge_ports_rejected`、`test_patch_manual_edge_ports_conflict_409`。
- 手工测试:修改已保存手工边的端口 → 出现未保存标记 → 保存 → 刷新后仍是新端口;LLDP 边的端口输入框为只读。
### T5 [P1] 连线样式与撤销/保存模型不一致
- `patchSelectedEdgeStyle` (`TopologyPage.tsx:807-815`) 每次修改都立即发请求(颜色选择器拖动时会连续发很多次)。同时调用了 `pushHistory()`,但撤销只回退本地状态,所以撤销后画布和数据库不一致。
**修改:** 样式也改为走保存:记入 `pendingEdgeStyleRef: Map<edgeId, style>`,`markDirty()`,在 `commitCanvas()` 里批量提交(后端 `PATCH /edge-style` 支持 `items: []` 批量,旧的单条 body 保留兼容)。撤销快照要包含这个 Map。
**验收:** 改颜色 → 撤销 → 保存 → 刷新,颜色是修改前的值;改颜色 → 保存 → 刷新,颜色是修改后的值;拖动颜色选择器期间,网络面板里没有 `/edge-style` 请求。
### T6 [P1] 保存原子化,并去掉重复的保存逻辑
现状见第 1 节。中途失败时,前面已执行的步骤(例如已建的边、已加的点)不会回滚,`pending*` 集合也只清了一部分。建边是幂等 upsert(`topology_fabric_links.py:65`),重试不会建出重复边,但删点和写坐标的顺序问题依然存在。
**修改:**
1. 后端新增 `POST /v1/topology/views/{id}/commit`,在一个事务里依次处理:
```json
{
"edge_creates": [{"client_id":"local-..","a_node_id":"","b_node_id":"","a_port":"","b_port":""}],
"edge_deletes": ["edge-id"],
"edge_port_edits": [{"id":"","a_port":"","b_port":""}],
"edge_styles": [{"fabric_edge_id":"","stroke_color":"","stroke_width":0,"line_style":""}],
"node_adds": ["fabric-id"],
"node_removes": ["fabric-id"],
"positions": [{"fabric_node_id":"","x":0,"y":0,"label":null}]
}
```
任何一步失败都整体回滚,返回 4xx 和 `{"failed_step": "...", "detail": "..."}`。成功时只返回**一次**完整图数据,外加 `edge_id_map: {client_id: server_id}`。节点上限和冻结规则与 `add_nodes_to_view` 一致。
2. 前端新增 `hooks/useTopologySave.ts`,导出 `commitCanvas()`:从本地状态计算差异(纯函数 `lib/saveDiff.ts`,可以单独测试),调用 `/commit`,成功后调用 `resetLocalFromServer`。`saveMut`、`purgePlaceholderById` (`:1248-1281`)、T3 的“先保存”都改用它。
3. 旧的分步接口保留(MCP 在用)。
**验收:**
- 新增 `test_view_commit_atomic_rollback`:构造一个 `edge_creates` 自环(第一步就失败),断言 `node_adds` 和 `positions` 都没有落库。
- 新增 `test_view_commit_happy_path`:一次提交包含建边、删边、加点、删点、写坐标,结果图正确,`edge_id_map` 正确。
- `web/tests/topologySaveDiff.test.mjs`:覆盖“本地新增/删除节点”“region 节点不进入增删列表”“只发送有变化的 label”“删除后又撤销的边不出现在 edge_deletes 中”等情况。
- 手工测试:一次保存只产生 **1 个** 写请求(网络面板截图附在 PR 描述里)。
### T7 [P1] 后端视图接口的 N+1 查询与不确定截断
| 位置 | 问题 | 修改 |
|---|---|---|
| `topology_views_graph.py:719` | 写坐标时每个点各执行一次 `db.get(TopoFabricNode)`,最多 2000 次 | 先用一次 `IN` 查询拿到存在的 id 集合 |
| `:160-166` | 节点超过 `VIEW_GRAPH_NODE_HARD_CAP` 时截断没有 `ORDER BY`,每次显示哪些节点不确定 | `order_by(TopoViewNode.created_at, TopoViewNode.id)` |
| `:354-363` | 外部邻居的 `limit(5000)` 把**两端都在视图内**的边也算进去了,边密集的视图会漏掉外部邻居 | SQL 里排除两端都在视图内的边(`~and_(a.in_, b.in_)`),再加 `limit` |
| `:375` | 每个外部邻居执行一次 `db.get` | 收集 id 后用一次 `IN` 查询 |
| `:178-179` | region 节点每个执行一次 `db.get(TopoFolder)` | 一次 `IN` 查询 |
**验收:**
- 新增 `test_outside_peers_found_when_many_internal_edges`:视图内有 >5000 条内部边(测试中可以把上限改成参数,例如用 10 条模拟),外部邻居仍然能返回。
- 新增 `test_view_truncation_is_deterministic`:两次 GET 返回同一批节点。
- 用 SQLAlchemy `before_cursor_execute` 统计 2000 个点的 positions 请求,语句数 ≤ 10(写成测试)。
### T8 [P1] positions 接口可以绕过节点上限加点
`patch_view_positions` 遇到视图中没有的 `fabric_node_id` 会直接插入 `TopoViewNode`(`:720-733`),不检查 `max_nodes` 和冻结状态,而 `add_nodes_to_view` 会检查(`:778`)。
**修改:** 先确认 MCP 是否依赖“positions 顺便加点”(用 grep 查 `packages/netx-topology-mcp` 里的 `positions` 调用)。如果不依赖,就改为跳过不存在的节点,并在返回里计入 `skipped_missing`;如果依赖,就加上与 `add_nodes_to_view` 相同的上限和冻结检查。**这一项的结论需要写进 PR 描述。**
**验收:** 新增 `test_positions_respects_max_nodes`。
### T9 [P2] 点击节点就会产生“未保存”和无效撤销记录
`onNodeDragStart` 会无条件 `pushHistory()`;`onNodesChange` 收到任何 `position` 变更都会 `markDirty()`,包括没有实际移动的点击 (`TopologyReactFlowView.tsx:442-449`)。后果:单击一个节点就出现“未保存”,并且触发脏锁,live sync 不再整体刷新。
**修改:** 在 `onNodeDragStart` 记录起点坐标;`onNodeDragStop` 时只有位移超过 1px 才把起始快照压入撤销栈并 `markDirty()`。`onNodesChange` 不再对 `position` 变更调用 `markDirty`(`remove`/`add` 保持不变)。
**验收:** 单击节点后不出现未保存标记,撤销按钮保持禁用;拖动 10px 后出现未保存标记,并且可以撤销。
### T10 [P2] 拆分 `TopologyPage.tsx`(在 T1–T6 完成后进行)
目标是把主组件压到 ≤ 1200 行,**只搬代码、不改行为**:
- `hooks/useTopologySave.ts`(T6)
- `hooks/useTopologyPalette.ts`:`addPaletteItems`、`addOutsidePeers`、拖放、`neQuery`、`umeQuery`
- `hooks/useTopologyEdgeEditing.ts`:`patchSelectedEdgeStyle`、`queueDeleteEdges`、`removeSelected`、`onConnect`、`isValidConnection`
- `hooks/useTopologyKeyboard.ts`:全局快捷键(`:1130-1182`)。依赖改为通过 ref 读取,避免每次 `nodes`/`edges` 变化都重新绑定监听
- `lib/moduleLinks.ts`:`openNeInventory`、`openWebcrtFor`、`openAlarmsFor`、`openPortTrafficForEdge`
**验收:** `tsc -b` 通过;第 4 节的手工回归清单全部通过;`git diff --stat` 显示 TopologyPage.tsx 净减少 ≥ 1000 行。
### T11 [P2] 修复修改前就存在的失败测试
| 测试 | 原因 | 处理 |
|---|---|---|
| `test_ume_topology_apply.py::test_lldp_upgrades_bare_ume_ports_and_keeps_ume_primary` | **测试本身不稳定**:`rich_a/rich_b` 取自 `edge.a_port/b_port`(按节点 id 排序后的 A/B),但调用 upsert 时传的是 `a.id/b.id`(按 UME 名称取的节点)。uuid 排序不同时端口就配反了,结果约有一半概率失败。已用调试脚本确认这不是产品 bug。 | 改为传 `a_node_id=edge.a_node_id, b_node_id=edge.b_node_id`(`:358-365`)。`:297`、`:393` 有同样的写法,一起检查 |
| `test_topology.py::test_lldp_discover_writes_fabric_edge`、`test_lldp_unmatched_creates_ssh_placeholder_ne` | 发现任务一直是 `pending`,没有变成 `done`(`:1285`、`:1458`) | 排查后台 worker 在 SQLite 内存库下是否被启动或被跳过,修好测试的启动方式,或者改为同步执行 job |
| `test_topology.py::test_fabric_reconcile_scheduler_once_sweeps_orphans` | `0 not >= 1`(`:2287`) | 排查,可能和上一条同源 |
**验收:** 两个测试文件 **0 失败**,并且连续跑 5 次都通过(用于检验不稳定性)。
### 产品决定(2026-10-12 已确认)
- **Q1**:`isValidConnection` (`TopologyPage.tsx:568`) 不允许在同一对网元之间画第二条手工连线,但 LLDP/UME 支持并行链路,画布也支持聚合显示。是否应该允许手工建并行链路(端口不同即可)?
- **Q2**:T3 选择了“先保存再添加”。如果希望“添加后保留本地未保存修改”,实现会复杂很多(需要把服务端新增部分合并进本地),请确认。
- **结论**:Q1 不允许手工画并行链路,保持 `isValidConnection`(现位于 `hooks/useTopologyEdgeEditing.ts`)现状;Q2 沿用“先保存再添加”。
---
## 3. 建议实施顺序
1. **T11**(先把基线修到全部通过,后面每一步的回归才有意义)
2. **T1 + T2**(后端 schema 改动,两项一起做,再加数据修复脚本)
3. **T6**(commit 接口 + `useTopologySave`),然后在它之上做 **T3、T4、T5**
4. **T7、T8**
5. **T9**,最后做 **T10**(纯重构,单独一个 PR)
每一步单独提交;T10 必须单独成 PR。
## 4. 回归命令与手工清单
```bash
python -m pytest tests/test_topology.py tests/test_ume_topology_apply.py -q
```
```bash
cd web && npx tsc -b
```
```bash
cd web && node --experimental-strip-types --test tests/topologySaveDiff.test.mjs
```
> `lib/saveDiff.ts` 只放纯函数,除 `import type` 外不要有运行时 import(Node strip-types 不会解析不带扩展名的相对路径)。写法参考现有的 `web/tests/cutoverDataCache.test.mjs`。
手工回归(每个 PR 都要过一遍,结果写进 PR 描述):
1. 拖动节点 → 保存 → 刷新,位置保持。
2. 新建手工连线(带端口)→ 保存 → 刷新,连线和端口都在。
3. 删除连线 → 撤销 → 保存,连线仍然存在。
4. 删除节点 → 保存,节点从视图中移除,但 fabric 中仍存在(可以通过搜索找到)。
5. 重命名节点 → 保存 → 刷新,显示新名称;再把名称清空 → 保存,恢复为设备名。
6. 打开 live sync,另一个窗口用 MCP 移动节点,3 秒内本窗口同步;本窗口有未保存修改时不会被覆盖,只刷新在线状态。
7. 只读用户(没有 `ne:write`):不能拖动、连线、保存,右键菜单里没有写操作。
8. 删除占位网元(有未保存修改时),修改先被保存,占位网元被清理。
9. 导出 SVG/XML 正常。
---
## 5. 验收记录(2026-10-12)
Cursor 完成 T1–T11 后由 Claude 验收并接手修复,结论:主体实现正确,另修 6 个问题(均补了回归测试):
1. `/commit` 同次 `node_adds` 的节点坐标被忽略(`autoflush=False` 下重查看不到新行)→ 先 `flush`;`_apply_positions` 的逐点 `db.get` 改为批量。
2. T8 收紧后,无放置行的旧子区域图标拖动无法保存 → `add_missing_region_placement`(仅限本画布的手工子区域)。
3. MCP `updateTopologyViewPositions` 总是发送 `label:""`、`locked:false`,会清掉 UI 重命名并解锁 → 未提供则不发送。
4. 已改样式/端口的边被删除后保存 404 → 前端 `saveDiff` 与后端都跳过已删边;新建本地边的样式经 `edge_id_map` 映射。
5. 框选拖动、方向键移动不标记未保存 → 补 `onSelectionDrag*` 和键盘移动判定。
6. (改动前已存在)大视图打开空白、远处节点定位失效:xyflow `fitView` 等待节点测量,而 `onlyRenderVisibleElements` 下视口外节点永不测量 → `lib/fitNodes.ts` 按已知几何计算 `fitBounds`。
开发库数据修复:`topology_clear_frozen_labels.py --apply` 清理 516 条固化标签(执行前已备份原值)。
回归:拓扑后端 91 通过;Web Node 测试 30 通过;`tsc -b` 通过;MCP 包 238 通过 / 5 失败(与改动前同一批,既有问题)。

View file

@ -121,6 +121,39 @@ def place_child_region_on_parent_canvas(
return row
def add_missing_region_placement(
db: Session, view: TopoView, fabric_node_id: str, *, x: float, y: float
) -> TopoViewNode | None:
"""Persist a child-region icon that was only synthesized (no placement row yet).
Returns None unless ``fabric_node_id`` is a manual child region of the view's folder.
"""
if not is_region_canvas_node_id(fabric_node_id) or _is_world_flat(view):
return None
folder = db.get(TopoFolder, region_folder_id_from_node(fabric_node_id))
if (
folder is None
or str(folder.kind or "") != "region"
or str(folder.parent_id or "") != str(view.folder_id or "")
or _is_ume_managed_folder(folder)
):
return None
now = _utcnow()
row = TopoViewNode(
id=uuid4().hex,
view_id=view.id,
fabric_node_id=fabric_node_id,
x=float(x),
y=float(y),
label="",
locked=False,
created_at=now,
updated_at=now,
)
db.add(row)
return row
def remove_region_canvas_placements(db: Session, folder_id: str) -> int:
"""Drop this folder's icon from any parent canvas."""
nid = region_canvas_node_id(folder_id)
@ -139,6 +172,7 @@ def region_node_out(
y: float,
locked: bool = False,
label: str = "",
label_override: str | None = None,
) -> ViewNodeOut:
child_view = _child_primary_view(db, folder.id)
kids = (
@ -154,12 +188,14 @@ def region_node_out(
.filter(TopoViewNode.view_id == child_view.id)
.count()
)
display = (label or folder.name or folder.id)[:256]
raw = str(label_override if label_override is not None else label or "")
display = (raw or folder.name or folder.id)[:256]
return ViewNodeOut(
fabric_node_id=region_canvas_node_id(folder.id),
managed_ne_id="",
ume_ne_id="",
label=display,
label_override=raw[:256],
x=float(x),
y=float(y),
locked=bool(locked),

View file

@ -38,6 +38,8 @@ from .topology_schemas import (
TopologyPlaceholderCreate,
TopologyViewCreate,
TopologyViewUpdate,
FabricEdgePortsPatch,
ViewCommitIn,
ViewEdgeStylePatch,
ViewNodesAdd,
ViewNodesRemove,
@ -61,6 +63,7 @@ from .topology_fabric import (
from .topology_views import (
add_nodes_to_view,
bootstrap_topology_tree,
commit_view_changes,
create_folder,
create_topology_placeholder_on_view,
create_view,
@ -69,6 +72,7 @@ from .topology_views import (
get_topology_tree,
get_view_graph,
list_views,
patch_manual_edge_ports,
patch_view_edge_style,
patch_view_positions,
populate_view,
@ -481,6 +485,32 @@ def api_edge_style(
return patch_view_edge_style(db, view_id, body).model_dump()
@router.post("/views/{view_id}/commit")
def api_commit_view(
view_id: str, body: ViewCommitIn, db: Session = Depends(get_db)
) -> dict[str, Any]:
return commit_view_changes(db, view_id, body).model_dump()
@router.patch("/fabric/edges/{edge_id}")
def api_patch_fabric_edge_ports(
edge_id: str, body: FabricEdgePortsPatch, db: Session = Depends(get_db)
) -> dict[str, Any]:
edge = patch_manual_edge_ports(
db, edge_id, a_port=body.a_port, b_port=body.b_port, commit=True
)
return {
"id": edge.id,
"a_node_id": edge.a_node_id,
"b_node_id": edge.b_node_id,
"a_port": edge.a_port or "",
"b_port": edge.b_port or "",
"source": edge.source or "manual",
"status": edge.status or "active",
"layer": edge.layer or "physical",
}
# --- Classify rules / slices / search ---------------------------------------

View file

@ -273,8 +273,10 @@ class ViewNodeIn(BaseModel):
fabric_node_id: str = Field(min_length=1, max_length=64)
x: float = 0.0
y: float = 0.0
label: str = ""
locked: bool = False
# None = leave existing override unchanged; "" = clear override (follow device name).
label: str | None = None
# None = leave lock unchanged; False unlocks (and allows move); True locks after move.
locked: bool | None = None
class ViewNodeOut(BaseModel):
@ -282,6 +284,8 @@ class ViewNodeOut(BaseModel):
managed_ne_id: str = ""
ume_ne_id: str = ""
label: str = ""
# Raw placement override (empty = follow fabric name/ip). ``label`` is the display value.
label_override: str = ""
x: float = 0.0
y: float = 0.0
locked: bool = False
@ -448,6 +452,16 @@ class ViewNodesRemove(BaseModel):
class ViewEdgeStylePatch(BaseModel):
"""Single-edge style patch (legacy). Prefer ``items`` for batch."""
fabric_edge_id: str = ""
stroke_color: str = ""
stroke_width: int = Field(default=0, ge=0, le=12)
line_style: str = Field(default="", max_length=16)
items: list["ViewEdgeStyleItem"] = Field(default_factory=list)
class ViewEdgeStyleItem(BaseModel):
fabric_edge_id: str
stroke_color: str = ""
stroke_width: int = Field(default=0, ge=0, le=12)
@ -461,6 +475,11 @@ class FabricManualEdgeIn(BaseModel):
b_port: str = ""
class FabricEdgePortsPatch(BaseModel):
a_port: str = ""
b_port: str = ""
class FabricEdgesDeleteRequest(BaseModel):
edge_ids: list[str] = Field(default_factory=list, min_length=1)
@ -469,6 +488,40 @@ class FabricEdgesDeleteOut(BaseModel):
deleted: int = 0
class ViewCommitEdgeCreate(BaseModel):
client_id: str = ""
a_node_id: str = Field(min_length=1, max_length=64)
b_node_id: str = Field(min_length=1, max_length=64)
a_port: str = ""
b_port: str = ""
class ViewCommitEdgePortEdit(BaseModel):
id: str = Field(min_length=1, max_length=64)
a_port: str = ""
b_port: str = ""
class ViewCommitIn(BaseModel):
"""Atomic canvas save: all steps succeed or the whole request rolls back."""
edge_creates: list[ViewCommitEdgeCreate] = Field(default_factory=list)
edge_deletes: list[str] = Field(default_factory=list)
edge_port_edits: list[ViewCommitEdgePortEdit] = Field(default_factory=list)
edge_styles: list[ViewEdgeStyleItem] = Field(default_factory=list)
node_adds: list[str] = Field(default_factory=list)
node_removes: list[str] = Field(default_factory=list)
positions: list[ViewNodeIn] = Field(default_factory=list)
class ViewCommitOut(BaseModel):
ok: bool = True
view_id: str = ""
edge_id_map: dict[str, str] = Field(default_factory=dict)
skipped_locked: int = 0
graph: TopologyViewGraphOut | None = None
# ---------------------------------------------------------------------------
# Classify rules + slices + search
# ---------------------------------------------------------------------------

View file

@ -39,6 +39,7 @@ from .topology_views import (
_place_fabric_ids_on_view,
add_nodes_to_view,
bootstrap_topology_tree,
commit_view_changes,
create_folder,
create_topology_placeholder_on_view,
create_view,
@ -48,6 +49,7 @@ from .topology_views import (
get_topology_tree,
get_view_graph,
list_views,
patch_manual_edge_ports,
patch_view_edge_style,
patch_view_positions,
populate_view,
@ -68,6 +70,7 @@ __all__ = [
"_place_fabric_ids_on_view",
"add_nodes_to_view",
"bootstrap_topology_tree",
"commit_view_changes",
"create_folder",
"create_topology_placeholder_on_view",
"create_view",
@ -88,6 +91,7 @@ __all__ = [
"list_fabric_nodes",
"list_views",
"merge_duplicate_fabric_nodes",
"patch_manual_edge_ports",
"patch_view_edge_style",
"patch_view_positions",
"pause_discover_job",

View file

@ -1,6 +1,7 @@
"""Topology folder tree and leaf view operations (facade)."""
from __future__ import annotations
from .topology_views_commit import commit_view_changes, patch_manual_edge_ports
from .topology_views_graph import (
_place_fabric_ids_on_view,
add_nodes_to_view,
@ -29,6 +30,7 @@ __all__ = [
"_place_fabric_ids_on_view",
"add_nodes_to_view",
"bootstrap_topology_tree",
"commit_view_changes",
"create_folder",
"create_topology_placeholder_on_view",
"create_view",
@ -38,6 +40,7 @@ __all__ = [
"get_topology_tree",
"get_view_graph",
"list_views",
"patch_manual_edge_ports",
"patch_view_edge_style",
"patch_view_positions",
"populate_view",

View file

@ -0,0 +1,444 @@
"""Atomic view canvas commit (single transaction)."""
from __future__ import annotations
import re
from typing import Any
from uuid import uuid4
from fastapi import HTTPException
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from .models import (
TopoFabricEdge,
TopoFabricNode,
TopoViewEdgeStyle,
TopoViewNode,
)
from .topology_common import VIEW_GRAPH_NODE_HARD_CAP, _utcnow
from .topology_fabric_links import upsert_fabric_edge
from .topology_fabric_nodes import _normalize_endpoints, refresh_fabric_stats
from .topology_region_canvas import add_missing_region_placement, is_region_canvas_node_id
from .topology_schemas import (
TopologyViewGraphOut,
ViewCommitIn,
ViewCommitOut,
ViewEdgeStyleItem,
ViewNodeIn,
)
from .topology_views_graph import (
_get_view_or_404,
_membership_for_view,
get_view_graph,
)
from .ume_topology_world import is_world_flat_view
_HEX_COLOR_RE = re.compile(r"^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$")
_LINE_STYLES = {"", "solid", "dashed", "dotted"}
def _fail(step: str, detail: str, *, status: int = 400) -> None:
raise HTTPException(
status_code=status,
detail={"failed_step": step, "detail": detail},
)
def _apply_edge_creates(
db: Session, creates: list[Any], edge_id_map: dict[str, str]
) -> None:
for item in creates:
a = str(getattr(item, "a_node_id", "") or "").strip()
b = str(getattr(item, "b_node_id", "") or "").strip()
if not a or not b:
_fail("edge_creates", "edge_endpoints_required")
edge, action = upsert_fabric_edge(
db,
a_node_id=a,
b_node_id=b,
a_port=str(getattr(item, "a_port", "") or ""),
b_port=str(getattr(item, "b_port", "") or ""),
source="manual",
)
if edge is None or action == "skipped_self_loop":
_fail("edge_creates", "edge_self_loop")
client_id = str(getattr(item, "client_id", "") or "").strip()
if client_id:
edge_id_map[client_id] = str(edge.id)
def _apply_edge_deletes(db: Session, edge_ids: list[str]) -> set[str]:
"""Delete edges; return the ids that were requested (for skipping later edits)."""
ids = [str(x).strip() for x in (edge_ids or []) if str(x).strip()]
if not ids:
return set()
seen: set[str] = set()
ordered: list[str] = []
for i in ids:
if i in seen:
continue
seen.add(i)
ordered.append(i)
rows = db.query(TopoFabricEdge).filter(TopoFabricEdge.id.in_(ordered)).all()
found_ids = [str(r.id) for r in rows]
if not found_ids:
return seen
db.query(TopoViewEdgeStyle).filter(
TopoViewEdgeStyle.fabric_edge_id.in_(found_ids)
).delete(synchronize_session=False)
db.query(TopoFabricEdge).filter(TopoFabricEdge.id.in_(found_ids)).delete(
synchronize_session=False
)
return seen
def patch_manual_edge_ports(
db: Session,
edge_id: str,
*,
a_port: str,
b_port: str,
commit: bool = True,
) -> TopoFabricEdge:
"""Update ports on a manual fabric edge. Raises 400/404/409."""
eid = str(edge_id or "").strip()
row = db.get(TopoFabricEdge, eid) if eid else None
if row is None:
raise HTTPException(status_code=404, detail="fabric_edge_not_found")
if str(row.source or "").strip().lower() != "manual":
raise HTTPException(status_code=400, detail="edge_not_manual")
_a, _b, ap, bp = _normalize_endpoints(
row.a_node_id,
row.b_node_id,
str(a_port or ""),
str(b_port or ""),
)
conflict = (
db.query(TopoFabricEdge)
.filter(
TopoFabricEdge.id != row.id,
TopoFabricEdge.layer == row.layer,
TopoFabricEdge.a_node_id == row.a_node_id,
TopoFabricEdge.b_node_id == row.b_node_id,
TopoFabricEdge.a_port == ap,
TopoFabricEdge.b_port == bp,
)
.one_or_none()
)
if conflict is not None:
raise HTTPException(status_code=409, detail="edge_port_conflict")
row.a_port = ap
row.b_port = bp
row.updated_at = _utcnow()
try:
db.flush()
except IntegrityError as exc:
raise HTTPException(status_code=409, detail="edge_port_conflict") from exc
if commit:
db.commit()
db.refresh(row)
return row
def _apply_edge_port_edits(db: Session, edits: list[Any], *, deleted: set[str]) -> None:
for item in edits:
eid = str(getattr(item, "id", "") or "").strip()
if not eid:
_fail("edge_port_edits", "edge_id_required")
if eid in deleted:
continue
try:
patch_manual_edge_ports(
db,
eid,
a_port=str(getattr(item, "a_port", "") or ""),
b_port=str(getattr(item, "b_port", "") or ""),
commit=False,
)
except HTTPException as exc:
detail = exc.detail if isinstance(exc.detail, str) else "edge_port_edit_failed"
_fail("edge_port_edits", str(detail), status=int(exc.status_code or 400))
def _upsert_edge_style(
db: Session,
view_id: str,
item: ViewEdgeStyleItem,
*,
now,
) -> None:
eid = str(item.fabric_edge_id or "").strip()
if not eid or db.get(TopoFabricEdge, eid) is None:
_fail("edge_styles", "fabric_edge_not_found", status=404)
color = str(item.stroke_color or "").strip()
if color and not _HEX_COLOR_RE.match(color):
_fail("edge_styles", "invalid_stroke_color")
width = int(item.stroke_width or 0)
if width < 0 or width > 12:
_fail("edge_styles", "invalid_stroke_width")
style = str(item.line_style or "").strip().lower()
if style not in _LINE_STYLES:
_fail("edge_styles", "invalid_line_style")
row = (
db.query(TopoViewEdgeStyle)
.filter(
TopoViewEdgeStyle.view_id == view_id,
TopoViewEdgeStyle.fabric_edge_id == eid,
)
.one_or_none()
)
if row is None:
db.add(
TopoViewEdgeStyle(
id=uuid4().hex,
view_id=view_id,
fabric_edge_id=eid,
stroke_color=color,
stroke_width=width,
line_style=style,
created_at=now,
updated_at=now,
)
)
else:
row.stroke_color = color
row.stroke_width = width
row.line_style = style
row.updated_at = now
def _apply_edge_styles(
db: Session,
view_id: str,
styles: list[ViewEdgeStyleItem],
*,
edge_id_map: dict[str, str],
deleted: set[str],
) -> None:
now = _utcnow()
for item in styles:
eid = str(item.fabric_edge_id or "").strip()
if eid in deleted:
continue
# Styles on a just-created local edge are keyed by its client id.
if eid in edge_id_map:
item = item.model_copy(update={"fabric_edge_id": edge_id_map[eid]})
_upsert_edge_style(db, view_id, item, now=now)
def _apply_node_adds(
db: Session,
view,
fabric_ids: list[str],
*,
max_nodes: int,
existing: dict[str, TopoViewNode],
) -> None:
if is_world_flat_view(view):
_fail("node_adds", "world_map_no_direct_nes")
ids = [str(x).strip() for x in (fabric_ids or []) if str(x).strip()]
if not ids:
return
if len(existing) >= max_nodes:
_fail("node_adds", "membership_max_nodes")
now = _utcnow()
room = max(0, max_nodes - len(existing))
added = 0
for fid in ids:
if fid in existing:
continue
if is_region_canvas_node_id(fid):
continue
if db.get(TopoFabricNode, fid) is None:
_fail("node_adds", f"fabric_node_not_found:{fid}")
if added >= room:
_fail("node_adds", "membership_max_nodes")
row = TopoViewNode(
id=uuid4().hex,
view_id=view.id,
fabric_node_id=fid,
x=40.0,
y=40.0,
label="",
locked=False,
created_at=now,
updated_at=now,
)
db.add(row)
existing[fid] = row
added += 1
if added:
view.updated_at = now
def _apply_node_removes(
db: Session,
view,
fabric_ids: list[str],
*,
existing: dict[str, TopoViewNode],
) -> None:
ids = [str(x).strip() for x in (fabric_ids or []) if str(x).strip()]
ids = [x for x in ids if x and not is_region_canvas_node_id(x)]
if not ids:
return
db.query(TopoViewNode).filter(
TopoViewNode.view_id == view.id,
TopoViewNode.fabric_node_id.in_(ids),
).delete(synchronize_session=False)
for fid in ids:
existing.pop(fid, None)
view.updated_at = _utcnow()
def _apply_positions(
db: Session,
view,
positions: list[ViewNodeIn],
*,
existing: dict[str, TopoViewNode],
) -> int:
"""Apply positions in-session. Returns skipped_locked count."""
if len(positions) > VIEW_GRAPH_NODE_HARD_CAP:
_fail("positions", "too_many_positions")
now = _utcnow()
skipped_locked = 0
updated = 0
fabric_ids = [
str(p.fabric_node_id or "").strip()
for p in positions
if str(p.fabric_node_id or "").strip()
and not is_region_canvas_node_id(str(p.fabric_node_id or "").strip())
]
alive = (
{str(r[0]) for r in db.query(TopoFabricNode.id).filter(TopoFabricNode.id.in_(fabric_ids))}
if fabric_ids
else set()
)
for p in positions:
fid = str(p.fabric_node_id or "").strip()
if not fid:
continue
if not is_region_canvas_node_id(fid) and fid not in alive and fid not in existing:
# Missing fabric: skip rather than fail mid-commit when node was just removed.
continue
row = existing.get(fid)
if row is None:
# Fabric NEs are placed via node_adds; only synthesized child-region icons are created here.
row = add_missing_region_placement(db, view, fid, x=p.x, y=p.y)
if row is None:
continue
existing[fid] = row
if row.locked and p.locked is not False:
skipped_locked += 1
continue
row.x = float(p.x or 0)
row.y = float(p.y or 0)
if p.label is not None:
row.label = str(p.label or "")[:256]
if p.locked is not None:
row.locked = bool(p.locked)
row.updated_at = now
updated += 1
if updated or skipped_locked:
view.updated_at = now
return skipped_locked
def commit_view_changes(db: Session, view_id: str, body: ViewCommitIn) -> ViewCommitOut:
"""Apply canvas mutations atomically; return one graph + client→server edge map."""
view = _get_view_or_404(db, view_id)
mem = _membership_for_view(view)
max_nodes = int(mem.get("max_nodes") or 300)
existing = {
vn.fabric_node_id: vn
for vn in db.query(TopoViewNode).filter(TopoViewNode.view_id == view.id).all()
}
edge_id_map: dict[str, str] = {}
skipped_locked = 0
try:
_apply_edge_creates(db, list(body.edge_creates or []), edge_id_map)
deleted = _apply_edge_deletes(db, list(body.edge_deletes or []))
_apply_edge_port_edits(db, list(body.edge_port_edits or []), deleted=deleted)
_apply_edge_styles(
db,
view.id,
list(body.edge_styles or []),
edge_id_map=edge_id_map,
deleted=deleted,
)
_apply_node_adds(
db,
view,
list(body.node_adds or []),
max_nodes=max_nodes,
existing=existing,
)
_apply_node_removes(
db,
view,
list(body.node_removes or []),
existing=existing,
)
# Re-load placements after add/remove so positions see current rows.
# Flush first: sessions run with autoflush=False, so new placements would be invisible.
db.flush()
existing = {
vn.fabric_node_id: vn
for vn in db.query(TopoViewNode).filter(TopoViewNode.view_id == view.id).all()
}
skipped_locked = _apply_positions(
db,
view,
list(body.positions or []),
existing=existing,
)
db.commit()
except HTTPException:
db.rollback()
raise
except Exception as exc: # noqa: BLE001
db.rollback()
_fail("commit", str(exc)[:500], status=500)
try:
refresh_fabric_stats(db)
except Exception: # noqa: BLE001
pass
graph: TopologyViewGraphOut = get_view_graph(db, view.id)
return ViewCommitOut(
ok=True,
view_id=view.id,
edge_id_map=edge_id_map,
skipped_locked=skipped_locked,
graph=graph,
)
def normalize_edge_style_items(body: Any) -> list[ViewEdgeStyleItem]:
"""Accept legacy single-edge body or batch ``items``."""
items = list(getattr(body, "items", None) or [])
if items:
return [
ViewEdgeStyleItem(
fabric_edge_id=str(i.fabric_edge_id or ""),
stroke_color=str(i.stroke_color or ""),
stroke_width=int(i.stroke_width or 0),
line_style=str(i.line_style or ""),
)
for i in items
]
fid = str(getattr(body, "fabric_edge_id", "") or "").strip()
if not fid:
return []
return [
ViewEdgeStyleItem(
fabric_edge_id=fid,
stroke_color=str(getattr(body, "stroke_color", "") or ""),
stroke_width=int(getattr(body, "stroke_width", 0) or 0),
line_style=str(getattr(body, "line_style", "") or ""),
)
]

View file

@ -157,7 +157,12 @@ def get_view_graph(db: Session, view_id: str) -> TopologyViewGraphOut:
# Save/mutate return_graph must use the same UME synthetic graph as GET.
if is_ume_canvas_view(view):
return get_ume_canvas_graph(db, view_id)
vnodes = db.query(TopoViewNode).filter(TopoViewNode.view_id == view.id).all()
vnodes = (
db.query(TopoViewNode)
.filter(TopoViewNode.view_id == view.id)
.order_by(TopoViewNode.created_at.asc(), TopoViewNode.id.asc())
.all()
)
truncated = False
reason = ""
if len(vnodes) > VIEW_GRAPH_NODE_HARD_CAP:
@ -169,6 +174,19 @@ def get_view_graph(db: Session, view_id: str) -> TopologyViewGraphOut:
n.id: n for n in db.query(TopoFabricNode).filter(TopoFabricNode.id.in_(fids)).all()
} if fids else {}
managed_by_id, ume_by_id = _batch_ne_lookups(db, fabric_nodes)
region_folder_ids = [
region_folder_id_from_node(vn.fabric_node_id)
for vn in vnodes
if is_region_canvas_node_id(vn.fabric_node_id)
]
folders_by_id = {
f.id: f
for f in (
db.query(TopoFolder).filter(TopoFolder.id.in_(region_folder_ids)).all()
if region_folder_ids
else []
)
}
filt = dict(view.filter or {})
layer = str(filt.get("layer") or "physical").strip() or "physical"
status = str(filt.get("status") or "").strip().lower()
@ -176,7 +194,7 @@ def get_view_graph(db: Session, view_id: str) -> TopologyViewGraphOut:
region_ids_seen: set[str] = set()
for vn in vnodes:
if is_region_canvas_node_id(vn.fabric_node_id):
folder = db.get(TopoFolder, region_folder_id_from_node(vn.fabric_node_id))
folder = folders_by_id.get(region_folder_id_from_node(vn.fabric_node_id))
if folder is None or str(folder.kind or "") != "region":
continue
region_ids_seen.add(folder.id)
@ -187,12 +205,13 @@ def get_view_graph(db: Session, view_id: str) -> TopologyViewGraphOut:
x=float(vn.x or 0),
y=float(vn.y or 0),
locked=bool(vn.locked),
label=str(vn.label or folder.name or ""),
label_override=str(vn.label or ""),
)
)
continue
fn = fabric_nodes.get(vn.fabric_node_id)
label = (vn.label or "").strip()
label_override = str(vn.label or "")
label = label_override.strip()
if not label and fn is not None:
label = (fn.name or fn.ip or vn.fabric_node_id)[:256]
connect_status = ""
@ -216,6 +235,7 @@ def get_view_graph(db: Session, view_id: str) -> TopologyViewGraphOut:
managed_ne_id=(fn.managed_ne_id if fn else "") or "",
ume_ne_id=(fn.ume_ne_id if fn else "") or "",
label=label,
label_override=label_override[:256],
x=float(vn.x or 0),
y=float(vn.y or 0),
locked=bool(vn.locked),
@ -348,22 +368,31 @@ def _outside_peers_for_view(
member_ids: set[str],
layer: str,
limit: int = 50,
edge_scan_limit: int = 5000,
) -> list[dict[str, str]]:
if not member_ids:
return []
members = list(member_ids)
# Exclude edges whose both ends are already on the view so dense canvases
# do not burn the scan budget before any external neighbor is found.
both_internal = and_(
TopoFabricEdge.a_node_id.in_(members),
TopoFabricEdge.b_node_id.in_(members),
)
edges = (
db.query(TopoFabricEdge)
.filter(
TopoFabricEdge.layer == layer,
or_(
TopoFabricEdge.a_node_id.in_(list(member_ids)),
TopoFabricEdge.b_node_id.in_(list(member_ids)),
TopoFabricEdge.a_node_id.in_(members),
TopoFabricEdge.b_node_id.in_(members),
),
~both_internal,
)
.limit(5000)
.limit(max(1, int(edge_scan_limit or 5000)))
.all()
)
out: list[dict[str, str]] = []
peer_via: list[tuple[str, str]] = []
seen: set[str] = set()
for e in edges:
for peer_id, local_id in ((e.b_node_id, e.a_node_id), (e.a_node_id, e.b_node_id)):
@ -372,17 +401,29 @@ def _outside_peers_for_view(
if peer_id in seen:
continue
seen.add(peer_id)
fn = db.get(TopoFabricNode, peer_id)
out.append(
{
"fabric_node_id": peer_id,
"name": (fn.name if fn else "") or "",
"ip": (fn.ip if fn else "") or "",
"via_node_id": local_id,
}
)
if len(out) >= limit:
return out
peer_via.append((peer_id, local_id))
if len(peer_via) >= limit:
break
if len(peer_via) >= limit:
break
if not peer_via:
return []
peer_ids = [p for p, _ in peer_via]
peers = {
n.id: n
for n in db.query(TopoFabricNode).filter(TopoFabricNode.id.in_(peer_ids)).all()
}
out: list[dict[str, str]] = []
for peer_id, local_id in peer_via:
fn = peers.get(peer_id)
out.append(
{
"fabric_node_id": peer_id,
"name": (fn.name if fn else "") or "",
"ip": (fn.ip if fn else "") or "",
"via_node_id": local_id,
}
)
return out
@ -646,6 +687,7 @@ def patch_view_positions(
layout = str(body.layout or "").strip().lower()
updated = 0
skipped_locked = 0
skipped_missing = 0
matched = 0
existing = {
@ -706,45 +748,54 @@ def patch_view_positions(
row.updated_at = now
updated += 1
else:
from .topology_region_canvas import add_missing_region_placement, is_region_canvas_node_id
positions = list(body.positions or [])
if len(positions) > VIEW_GRAPH_NODE_HARD_CAP:
raise HTTPException(status_code=400, detail="too_many_positions")
matched = len(positions)
# One IN query instead of per-node db.get (N+1).
check_ids = [
str(p.fabric_node_id or "").strip()
for p in positions
if str(p.fabric_node_id or "").strip()
and not is_region_canvas_node_id(str(p.fabric_node_id or "").strip())
]
fabric_alive: set[str] = set()
if check_ids:
fabric_alive = {
str(x[0])
for x in db.query(TopoFabricNode.id)
.filter(TopoFabricNode.id.in_(check_ids))
.all()
}
for p in positions:
fid = str(p.fabric_node_id or "").strip()
if not fid:
continue
from .topology_region_canvas import is_region_canvas_node_id
if not is_region_canvas_node_id(fid) and db.get(TopoFabricNode, fid) is None:
if not is_region_canvas_node_id(fid) and fid not in fabric_alive:
raise HTTPException(status_code=400, detail=f"fabric_node_not_found:{fid}")
row = existing.get(fid)
if row is None:
row = TopoViewNode(
id=uuid4().hex,
view_id=view.id,
fabric_node_id=fid,
x=float(p.x or 0),
y=float(p.y or 0),
label=str(p.label or "")[:256],
locked=bool(p.locked),
created_at=now,
updated_at=now,
)
db.add(row)
existing[fid] = row
updated += 1
else:
if row.locked and bool(p.locked):
skipped_locked += 1
# Child-region icons may be synthesized without a placement row; persist those.
# Fabric NEs must not bypass membership caps / freeze (MCP adds via /nodes).
row = add_missing_region_placement(db, view, fid, x=p.x, y=p.y)
if row is None:
skipped_missing += 1
continue
row.x = float(p.x or 0)
row.y = float(p.y or 0)
if p.label is not None:
row.label = str(p.label or "")[:256]
existing[fid] = row
# Locked rows stay put unless the client explicitly unlocks (locked=False).
if row.locked and p.locked is not False:
skipped_locked += 1
continue
row.x = float(p.x or 0)
row.y = float(p.y or 0)
if p.label is not None:
row.label = str(p.label or "")[:256]
if p.locked is not None:
row.locked = bool(p.locked)
row.updated_at = now
updated += 1
row.updated_at = now
updated += 1
view.updated_at = now
db.commit()
@ -756,6 +807,7 @@ def patch_view_positions(
matched=matched,
updated=updated,
skipped_locked=skipped_locked,
skipped_missing=skipped_missing,
)
@ -1353,42 +1405,48 @@ _LINE_STYLES = {"", "solid", "dashed", "dotted"}
def patch_view_edge_style(
db: Session, view_id: str, body: ViewEdgeStylePatch
) -> TopologyViewGraphOut:
from .topology_views_commit import normalize_edge_style_items
view = _get_view_or_404(db, view_id)
eid = str(body.fabric_edge_id or "").strip()
if not eid or db.get(TopoFabricEdge, eid) is None:
raise HTTPException(status_code=404, detail="fabric_edge_not_found")
color = str(body.stroke_color or "").strip()
if color and not _HEX_COLOR_RE.match(color):
raise HTTPException(status_code=400, detail="invalid_stroke_color")
width = int(body.stroke_width or 0)
if width < 0 or width > 12:
raise HTTPException(status_code=400, detail="invalid_stroke_width")
style = str(body.line_style or "").strip().lower()
if style not in _LINE_STYLES:
raise HTTPException(status_code=400, detail="invalid_line_style")
items = normalize_edge_style_items(body)
if not items:
raise HTTPException(status_code=400, detail="fabric_edge_id_required")
now = _utcnow()
row = (
db.query(TopoViewEdgeStyle)
.filter(TopoViewEdgeStyle.view_id == view.id, TopoViewEdgeStyle.fabric_edge_id == eid)
.one_or_none()
)
if row is None:
row = TopoViewEdgeStyle(
id=uuid4().hex,
view_id=view.id,
fabric_edge_id=eid,
stroke_color=color,
stroke_width=width,
line_style=style,
created_at=now,
updated_at=now,
for item in items:
eid = str(item.fabric_edge_id or "").strip()
if not eid or db.get(TopoFabricEdge, eid) is None:
raise HTTPException(status_code=404, detail="fabric_edge_not_found")
color = str(item.stroke_color or "").strip()
if color and not _HEX_COLOR_RE.match(color):
raise HTTPException(status_code=400, detail="invalid_stroke_color")
width = int(item.stroke_width or 0)
if width < 0 or width > 12:
raise HTTPException(status_code=400, detail="invalid_stroke_width")
style = str(item.line_style or "").strip().lower()
if style not in _LINE_STYLES:
raise HTTPException(status_code=400, detail="invalid_line_style")
row = (
db.query(TopoViewEdgeStyle)
.filter(TopoViewEdgeStyle.view_id == view.id, TopoViewEdgeStyle.fabric_edge_id == eid)
.one_or_none()
)
db.add(row)
else:
row.stroke_color = color
row.stroke_width = width
row.line_style = style
row.updated_at = now
if row is None:
row = TopoViewEdgeStyle(
id=uuid4().hex,
view_id=view.id,
fabric_edge_id=eid,
stroke_color=color,
stroke_width=width,
line_style=style,
created_at=now,
updated_at=now,
)
db.add(row)
else:
row.stroke_color = color
row.stroke_width = width
row.line_style = style
row.updated_at = now
view.updated_at = now
db.commit()
return get_view_graph(db, view.id)

View file

@ -1976,15 +1976,18 @@ def _update_topology_view_positions(args: dict[str, Any]) -> dict[str, Any]:
fid = str(p.get("fabric_node_id") or "").strip()
if not fid:
continue
cleaned.append(
{
"fabric_node_id": fid,
"x": float(p.get("x") or 0),
"y": float(p.get("y") or 0),
"label": str(p.get("label") or ""),
"locked": bool(p.get("locked") or False),
}
)
row: dict[str, Any] = {
"fabric_node_id": fid,
"x": float(p.get("x") or 0),
"y": float(p.get("y") or 0),
}
# Omitted label/locked = keep server value (a UI rename or an existing lock).
# label "" clears the override; locked false explicitly unlocks.
if p.get("label") is not None:
row["label"] = str(p.get("label"))
if p.get("locked") is not None:
row["locked"] = bool(p.get("locked"))
cleaned.append(row)
if not cleaned:
return {"ok": False, "error": "positions_required"}
parts: list[dict[str, Any]] = []
@ -3727,8 +3730,14 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
"fabric_node_id": {"type": "string"},
"x": {"type": "number"},
"y": {"type": "number"},
"label": {"type": "string"},
"locked": {"type": "boolean"},
"label": {
"type": "string",
"description": "Omit to keep the current label; \"\" clears a rename (follow device name).",
},
"locked": {
"type": "boolean",
"description": "Omit to keep the lock. Locked nodes are skipped unless locked=false (unlock + move).",
},
},
"required": ["fabric_node_id"],
"additionalProperties": False,

View file

@ -614,3 +614,24 @@ def test_analyze_topology_view_layout_single() -> None:
assert "sparsity" in payload
assert "verdict" in payload
assert "total" in payload["verdict"]
def test_update_positions_omits_label_and_locked_unless_given() -> None:
from netx_topology_mcp.http_tools import _update_topology_view_positions
with patch("netx_topology_mcp.http_tools.http_json") as mock_http:
mock_http.return_value = {"ok": True, "data": {"ok": True, "updated": 3}}
_update_topology_view_positions(
{
"view_id": "v1",
"positions": [
{"fabric_node_id": "a", "x": 1, "y": 2},
{"fabric_node_id": "b", "x": 3, "y": 4, "label": "", "locked": False},
{"fabric_node_id": "c", "x": 5, "y": 6, "label": "Core-1", "locked": True},
],
}
)
sent = mock_http.call_args[1]["body"]["positions"]
assert sent[0] == {"fabric_node_id": "a", "x": 1.0, "y": 2.0}
assert sent[1]["label"] == "" and sent[1]["locked"] is False
assert sent[2]["label"] == "Core-1" and sent[2]["locked"] is True

View file

@ -0,0 +1,79 @@
"""Clear view-node labels that merely freeze the current fabric name/ip.
Default is dry-run. Pass --apply to write.
A placement label is considered frozen when it equals the fabric node's current
name or IP (case-insensitive, stripped). Clearing it restores follow-device-name
behavior on the next graph read.
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from sqlalchemy import or_
from netx_api.db import SessionLocal
from netx_api.models import TopoFabricNode, TopoViewNode
def _norm(s: str | None) -> str:
return str(s or "").strip().casefold()
def clear_frozen_labels(db, *, apply: bool) -> tuple[int, int]:
"""Return (scanned_nonempty_labels, frozen_cleared). Caller commits/rolls back."""
rows = (
db.query(TopoViewNode, TopoFabricNode)
.outerjoin(TopoFabricNode, TopoFabricNode.id == TopoViewNode.fabric_node_id)
.filter(TopoViewNode.label != "")
.filter(or_(TopoViewNode.label.isnot(None)))
.all()
)
scanned = 0
cleared = 0
for vn, fn in rows:
scanned += 1
label = _norm(vn.label)
if not label or fn is None:
continue
if label not in {_norm(fn.name), _norm(fn.ip)}:
continue
cleared += 1
if apply:
vn.label = ""
return scanned, cleared
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--apply",
action="store_true",
help="Write changes (default: dry-run only)",
)
args = parser.parse_args(argv)
db = SessionLocal()
try:
scanned, cleared = clear_frozen_labels(db, apply=args.apply)
if args.apply:
db.commit()
else:
db.rollback()
mode = "apply" if args.apply else "dry-run"
print(f"mode={mode} scanned={scanned} frozen_labels={cleared}")
return 0
except Exception:
db.rollback()
raise
finally:
db.close()
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -27,12 +27,17 @@ from netx_api.models import (
TopoViewEdgeStyle,
TopoViewNode,
)
from netx_api.ne_crypto import encrypt_secret
from netx_api.topology_schemas import (
FabricDiscoverRequest,
TopologyFolderCreate,
TopologyPlaceholderCreate,
TopologyViewCreate,
TopologyViewUpdate,
ViewCommitEdgeCreate,
ViewCommitEdgePortEdit,
ViewCommitIn,
ViewEdgeStyleItem,
ViewNodesAdd,
ViewPopulateRequest,
ViewPositionsPatch,
@ -384,6 +389,388 @@ class FabricTopologyTests(unittest.TestCase):
self.db.refresh(view)
return view.id
def test_patch_manual_edge_ports(self) -> None:
suffix = uuid4().hex[:8]
ne_a = ManagedNE(
id=f"ne-mp-a-{suffix}",
name=f"MPA-{suffix}",
vendor="Cisco",
device_type="cisco_ios",
ip_address=f"10.5.0.{(int(suffix[:2], 16) % 200) + 1}",
)
ne_b = ManagedNE(
id=f"ne-mp-b-{suffix}",
name=f"MPB-{suffix}",
vendor="Cisco",
device_type="cisco_ios",
ip_address=f"10.5.1.{(int(suffix[:2], 16) % 200) + 1}",
)
self.db.add_all([ne_a, ne_b])
self.db.commit()
fa = svc.ensure_fabric_node_for_managed(self.db, ne_a)
fb = svc.ensure_fabric_node_for_managed(self.db, ne_b)
edge, _ = svc.upsert_fabric_edge(
self.db,
a_node_id=fa.id,
b_node_id=fb.id,
a_port="gi0/1",
b_port="gi0/2",
source="manual",
)
self.db.commit()
assert edge is not None
updated = svc.patch_manual_edge_ports(
self.db, edge.id, a_port="Gi0/10", b_port="Gi0/20", commit=True
)
self.assertEqual(updated.a_port.lower(), "gi0/10")
self.assertEqual(updated.b_port.lower(), "gi0/20")
for ne in (ne_a, ne_b):
self.db.delete(ne)
self.db.commit()
def test_patch_non_manual_edge_ports_rejected(self) -> None:
from fastapi import HTTPException
suffix = uuid4().hex[:8]
ne_a = ManagedNE(
id=f"ne-nm-a-{suffix}",
name=f"NMA-{suffix}",
vendor="Cisco",
device_type="cisco_ios",
ip_address=f"10.5.2.{(int(suffix[:2], 16) % 200) + 1}",
)
ne_b = ManagedNE(
id=f"ne-nm-b-{suffix}",
name=f"NMB-{suffix}",
vendor="Cisco",
device_type="cisco_ios",
ip_address=f"10.5.3.{(int(suffix[:2], 16) % 200) + 1}",
)
self.db.add_all([ne_a, ne_b])
self.db.commit()
fa = svc.ensure_fabric_node_for_managed(self.db, ne_a)
fb = svc.ensure_fabric_node_for_managed(self.db, ne_b)
edge, _ = svc.upsert_fabric_edge(
self.db,
a_node_id=fa.id,
b_node_id=fb.id,
a_port="gi0/1",
b_port="gi0/2",
source="lldp",
)
self.db.commit()
assert edge is not None
with self.assertRaises(HTTPException) as ctx:
svc.patch_manual_edge_ports(
self.db, edge.id, a_port="gi0/9", b_port="gi0/8", commit=True
)
self.assertEqual(ctx.exception.status_code, 400)
self.assertEqual(ctx.exception.detail, "edge_not_manual")
for ne in (ne_a, ne_b):
self.db.delete(ne)
self.db.commit()
def test_patch_manual_edge_ports_conflict_409(self) -> None:
from fastapi import HTTPException
suffix = uuid4().hex[:8]
ne_a = ManagedNE(
id=f"ne-cf-a-{suffix}",
name=f"CFA-{suffix}",
vendor="Cisco",
device_type="cisco_ios",
ip_address=f"10.5.4.{(int(suffix[:2], 16) % 200) + 1}",
)
ne_b = ManagedNE(
id=f"ne-cf-b-{suffix}",
name=f"CFB-{suffix}",
vendor="Cisco",
device_type="cisco_ios",
ip_address=f"10.5.5.{(int(suffix[:2], 16) % 200) + 1}",
)
self.db.add_all([ne_a, ne_b])
self.db.commit()
fa = svc.ensure_fabric_node_for_managed(self.db, ne_a)
fb = svc.ensure_fabric_node_for_managed(self.db, ne_b)
e1, _ = svc.upsert_fabric_edge(
self.db,
a_node_id=fa.id,
b_node_id=fb.id,
a_port="gi0/1",
b_port="gi0/2",
source="manual",
)
e2, _ = svc.upsert_fabric_edge(
self.db,
a_node_id=fa.id,
b_node_id=fb.id,
a_port="gi0/3",
b_port="gi0/4",
source="manual",
)
self.db.commit()
assert e1 is not None and e2 is not None
# Use the canonical A/B ports already stored on e1 (normalize may have swapped).
with self.assertRaises(HTTPException) as ctx:
svc.patch_manual_edge_ports(
self.db,
e2.id,
a_port=e1.a_port,
b_port=e1.b_port,
commit=True,
)
self.assertEqual(ctx.exception.status_code, 409)
self.assertEqual(ctx.exception.detail, "edge_port_conflict")
for ne in (ne_a, ne_b):
self.db.delete(ne)
self.db.commit()
def test_view_commit_atomic_rollback(self) -> None:
from fastapi import HTTPException
suffix = uuid4().hex[:8]
ne = ManagedNE(
id=f"ne-cmt-fail-{suffix}",
name=f"CFail-{suffix}",
vendor="Cisco",
device_type="cisco_ios",
ip_address=f"10.4.0.{(int(suffix[:2], 16) % 200) + 1}",
)
self.db.add(ne)
self.db.commit()
view_id = self._canvas(f"CFail-{suffix}")
fa = svc.ensure_fabric_node_for_managed(self.db, ne)
self.db.commit()
before_nodes = (
self.db.query(TopoViewNode).filter(TopoViewNode.view_id == view_id).count()
)
before_edges = self.db.query(TopoFabricEdge).count()
with self.assertRaises(HTTPException) as ctx:
svc.commit_view_changes(
self.db,
view_id,
ViewCommitIn(
edge_creates=[
ViewCommitEdgeCreate(
client_id="local-loop",
a_node_id=fa.id,
b_node_id=fa.id,
a_port="gi0/1",
b_port="gi0/2",
)
],
node_adds=[fa.id],
positions=[ViewNodeIn(fabric_node_id=fa.id, x=99, y=99)],
),
)
detail = ctx.exception.detail
self.assertIsInstance(detail, dict)
self.assertEqual(detail.get("failed_step"), "edge_creates")
self.assertEqual(
self.db.query(TopoViewNode).filter(TopoViewNode.view_id == view_id).count(),
before_nodes,
)
self.assertEqual(self.db.query(TopoFabricEdge).count(), before_edges)
self.db.delete(ne)
self.db.commit()
def test_view_commit_happy_path(self) -> None:
suffix = uuid4().hex[:8]
ne_a = ManagedNE(
id=f"ne-cmt-a-{suffix}",
name=f"CA-{suffix}",
vendor="Cisco",
device_type="cisco_ios",
ip_address=f"10.4.1.{(int(suffix[:2], 16) % 200) + 1}",
)
ne_b = ManagedNE(
id=f"ne-cmt-b-{suffix}",
name=f"CB-{suffix}",
vendor="Cisco",
device_type="cisco_ios",
ip_address=f"10.4.2.{(int(suffix[:2], 16) % 200) + 1}",
)
ne_c = ManagedNE(
id=f"ne-cmt-c-{suffix}",
name=f"CC-{suffix}",
vendor="Cisco",
device_type="cisco_ios",
ip_address=f"10.4.3.{(int(suffix[:2], 16) % 200) + 1}",
)
self.db.add_all([ne_a, ne_b, ne_c])
self.db.commit()
view_id = self._canvas(f"COk-{suffix}")
fa = svc.ensure_fabric_node_for_managed(self.db, ne_a)
fb = svc.ensure_fabric_node_for_managed(self.db, ne_b)
fc = svc.ensure_fabric_node_for_managed(self.db, ne_c)
self.db.commit()
svc.add_nodes_to_view(
self.db, view_id, ViewNodesAdd(fabric_node_ids=[fa.id, fb.id])
)
old_edge, _ = svc.upsert_fabric_edge(
self.db,
a_node_id=fa.id,
b_node_id=fb.id,
a_port="gi0/9",
b_port="gi0/9",
source="manual",
)
self.db.commit()
assert old_edge is not None
out = svc.commit_view_changes(
self.db,
view_id,
ViewCommitIn(
edge_creates=[
ViewCommitEdgeCreate(
client_id="local-new",
a_node_id=fa.id,
b_node_id=fc.id,
a_port="gi0/1",
b_port="gi0/2",
)
],
edge_deletes=[old_edge.id],
node_adds=[fc.id],
node_removes=[fb.id],
positions=[
ViewNodeIn(fabric_node_id=fa.id, x=11, y=22),
ViewNodeIn(fabric_node_id=fc.id, x=33, y=44),
],
),
)
self.assertTrue(out.ok)
self.assertIn("local-new", out.edge_id_map)
self.assertIsNotNone(out.graph)
ids = {n.fabric_node_id for n in out.graph.nodes}
self.assertIn(fa.id, ids)
self.assertIn(fc.id, ids)
self.assertNotIn(fb.id, ids)
old_edge_id = str(old_edge.id)
self.db.expire_all()
self.assertIsNone(self.db.get(TopoFabricEdge, old_edge_id))
new_eid = out.edge_id_map["local-new"]
self.assertIsNotNone(self.db.get(TopoFabricEdge, new_eid))
pos_a = next(n for n in out.graph.nodes if n.fabric_node_id == fa.id)
self.assertEqual(float(pos_a.x), 11.0)
self.assertEqual(float(pos_a.y), 22.0)
# Node added in the same commit must also take its position (autoflush=False).
pos_c = next(n for n in out.graph.nodes if n.fabric_node_id == fc.id)
self.assertEqual((float(pos_c.x), float(pos_c.y)), (33.0, 44.0))
for ne in (ne_a, ne_b, ne_c):
self.db.delete(ne)
self.db.commit()
def _two_node_canvas(self, tag: str):
suffix = uuid4().hex[:8]
nes = [
ManagedNE(
id=f"ne-{tag}-{i}-{suffix}",
name=f"{tag.upper()}{i}-{suffix}",
vendor="Cisco",
device_type="cisco_ios",
ip_address=f"10.7.{i}.{(int(suffix[:2], 16) % 200) + 1}",
)
for i in (1, 2)
]
self.db.add_all(nes)
self.db.commit()
view_id = self._canvas(f"{tag}-{suffix}")
fa, fb = (svc.ensure_fabric_node_for_managed(self.db, ne) for ne in nes)
self.db.commit()
svc.add_nodes_to_view(self.db, view_id, ViewNodesAdd(fabric_node_ids=[fa.id, fb.id]))
return view_id, fa, fb
def test_view_commit_skips_edits_on_edges_deleted_in_same_commit(self) -> None:
view_id, fa, fb = self._two_node_canvas("cdel")
edge, _ = svc.upsert_fabric_edge(
self.db, a_node_id=fa.id, b_node_id=fb.id, a_port="gi0/1", b_port="gi0/1", source="manual"
)
self.db.commit()
assert edge is not None
eid = str(edge.id)
out = svc.commit_view_changes(
self.db,
view_id,
ViewCommitIn(
edge_deletes=[eid],
edge_port_edits=[ViewCommitEdgePortEdit(id=eid, a_port="gi0/5", b_port="gi0/6")],
edge_styles=[ViewEdgeStyleItem(fabric_edge_id=eid, stroke_color="#ff0000")],
),
)
self.assertTrue(out.ok)
self.db.expire_all()
self.assertIsNone(self.db.get(TopoFabricEdge, eid))
def test_view_commit_styles_new_edge_by_client_id(self) -> None:
view_id, fa, fb = self._two_node_canvas("csty")
out = svc.commit_view_changes(
self.db,
view_id,
ViewCommitIn(
edge_creates=[
ViewCommitEdgeCreate(client_id="local:s1", a_node_id=fa.id, b_node_id=fb.id)
],
edge_styles=[
ViewEdgeStyleItem(fabric_edge_id="local:s1", stroke_color="#00ff00", stroke_width=3)
],
),
)
new_id = out.edge_id_map["local:s1"]
edge_out = next(e for e in out.graph.edges if e.id == new_id)
self.assertEqual(edge_out.stroke_color, "#00ff00")
self.assertEqual(edge_out.stroke_width, 3)
def test_positions_persist_child_region_icon_without_placement(self) -> None:
suffix = uuid4().hex[:8]
parent_id = self._region(f"Rg-{suffix}")
view = self._physical_view(parent_id)
child = svc.create_folder(
self.db, TopologyFolderCreate(name=f"Sub-{suffix}", kind="region", parent_id=parent_id)
)
nid = f"region:{child.id}"
# Legacy data: drop the icon's placement row so it is only synthesized.
self.db.query(TopoViewNode).filter(
TopoViewNode.view_id == view.id, TopoViewNode.fabric_node_id == nid
).delete(synchronize_session=False)
self.db.commit()
g = svc.get_view_graph(self.db, view.id)
self.assertIn(nid, {n.fabric_node_id for n in g.nodes})
svc.patch_view_positions(
self.db, view.id, ViewPositionsPatch(positions=[ViewNodeIn(fabric_node_id=nid, x=321, y=123)])
)
g = svc.get_view_graph(self.db, view.id)
icon = next(n for n in g.nodes if n.fabric_node_id == nid)
self.assertEqual((icon.x, icon.y), (321.0, 123.0))
self.db.query(TopoViewNode).filter(
TopoViewNode.view_id == view.id, TopoViewNode.fabric_node_id == nid
).delete(synchronize_session=False)
self.db.commit()
out = svc.commit_view_changes(
self.db, view.id, ViewCommitIn(positions=[ViewNodeIn(fabric_node_id=nid, x=55, y=66)])
)
icon = next(n for n in out.graph.nodes if n.fabric_node_id == nid)
self.assertEqual((icon.x, icon.y), (55.0, 66.0))
# Placement follows the folder name (no frozen label).
self.assertEqual(icon.label_override, "")
def test_positions_do_not_place_foreign_region_icon(self) -> None:
suffix = uuid4().hex[:8]
view_id = self._canvas(f"Rf-{suffix}")
other = svc.create_folder(self.db, TopologyFolderCreate(name=f"Other-{suffix}", kind="region"))
out = svc.patch_view_positions(
self.db,
view_id,
ViewPositionsPatch(
positions=[ViewNodeIn(fabric_node_id=f"region:{other.id}", x=1, y=1)],
return_graph=False,
),
)
self.assertEqual(out.skipped_missing, 1)
def test_view_crud_and_positions(self) -> None:
suffix = uuid4().hex[:8]
ne = ManagedNE(
@ -417,6 +804,420 @@ class FabricTopologyTests(unittest.TestCase):
self.db.delete(ne)
self.db.commit()
def test_positions_preserve_lock_when_omitted(self) -> None:
suffix = uuid4().hex[:8]
ne = ManagedNE(
id=f"ne-lock-{suffix}",
name=f"L-{suffix}",
vendor="Cisco",
device_type="cisco_ios",
ip_address=f"10.1.0.{(int(suffix[:2], 16) % 200) + 1}",
)
self.db.add(ne)
self.db.commit()
view_id = self._canvas(f"Lock-{suffix}")
graph = svc.add_nodes_to_view(self.db, view_id, ViewNodesAdd(managed_ne_ids=[ne.id]))
fid = graph.nodes[0].fabric_node_id
svc.patch_view_positions(
self.db,
view_id,
ViewPositionsPatch(
positions=[ViewNodeIn(fabric_node_id=fid, x=500, y=500, locked=True)],
return_graph=False,
),
)
# UI-style save: omit locked — must not move or unlock.
out = svc.patch_view_positions(
self.db,
view_id,
ViewPositionsPatch(
positions=[ViewNodeIn(fabric_node_id=fid, x=10, y=10)],
return_graph=False,
),
)
self.assertEqual(out.skipped_locked, 1)
self.assertEqual(out.updated, 0)
row = (
self.db.query(TopoViewNode)
.filter(TopoViewNode.view_id == view_id, TopoViewNode.fabric_node_id == fid)
.one()
)
self.assertTrue(row.locked)
self.assertEqual(float(row.x), 500.0)
self.assertEqual(float(row.y), 500.0)
svc.delete_view(self.db, view_id, force=True)
self.db.delete(ne)
self.db.commit()
def test_positions_explicit_unlock_moves(self) -> None:
suffix = uuid4().hex[:8]
ne = ManagedNE(
id=f"ne-ulock-{suffix}",
name=f"U-{suffix}",
vendor="Cisco",
device_type="cisco_ios",
ip_address=f"10.1.1.{(int(suffix[:2], 16) % 200) + 1}",
)
self.db.add(ne)
self.db.commit()
view_id = self._canvas(f"ULock-{suffix}")
graph = svc.add_nodes_to_view(self.db, view_id, ViewNodesAdd(managed_ne_ids=[ne.id]))
fid = graph.nodes[0].fabric_node_id
svc.patch_view_positions(
self.db,
view_id,
ViewPositionsPatch(
positions=[ViewNodeIn(fabric_node_id=fid, x=500, y=500, locked=True)],
return_graph=False,
),
)
out = svc.patch_view_positions(
self.db,
view_id,
ViewPositionsPatch(
positions=[ViewNodeIn(fabric_node_id=fid, x=10, y=20, locked=False)],
return_graph=False,
),
)
self.assertEqual(out.skipped_locked, 0)
self.assertEqual(out.updated, 1)
row = (
self.db.query(TopoViewNode)
.filter(TopoViewNode.view_id == view_id, TopoViewNode.fabric_node_id == fid)
.one()
)
self.assertFalse(row.locked)
self.assertEqual(float(row.x), 10.0)
self.assertEqual(float(row.y), 20.0)
svc.delete_view(self.db, view_id, force=True)
self.db.delete(ne)
self.db.commit()
def test_positions_without_label_keeps_following_name(self) -> None:
suffix = uuid4().hex[:8]
ne = ManagedNE(
id=f"ne-lbl-{suffix}",
name="OLD-NAME",
vendor="Cisco",
device_type="cisco_ios",
ip_address=f"10.2.0.{(int(suffix[:2], 16) % 200) + 1}",
)
self.db.add(ne)
self.db.commit()
view_id = self._canvas(f"Lbl-{suffix}")
graph = svc.add_nodes_to_view(self.db, view_id, ViewNodesAdd(managed_ne_ids=[ne.id]))
fid = graph.nodes[0].fabric_node_id
self.assertEqual(graph.nodes[0].label, "OLD-NAME")
self.assertEqual(graph.nodes[0].label_override, "")
# Save positions without label — must not freeze the device name.
svc.patch_view_positions(
self.db,
view_id,
ViewPositionsPatch(
positions=[ViewNodeIn(fabric_node_id=fid, x=40, y=60)],
return_graph=False,
),
)
fn = self.db.get(TopoFabricNode, fid)
assert fn is not None
fn.name = "NEW-NAME"
self.db.commit()
refreshed = svc.get_view_graph(self.db, view_id)
self.assertEqual(refreshed.nodes[0].label, "NEW-NAME")
self.assertEqual(refreshed.nodes[0].label_override, "")
svc.delete_view(self.db, view_id, force=True)
self.db.delete(ne)
self.db.commit()
def test_label_override_survives_rename(self) -> None:
suffix = uuid4().hex[:8]
ne = ManagedNE(
id=f"ne-ov-{suffix}",
name="DEVICE-A",
vendor="Cisco",
device_type="cisco_ios",
ip_address=f"10.2.1.{(int(suffix[:2], 16) % 200) + 1}",
)
self.db.add(ne)
self.db.commit()
view_id = self._canvas(f"Ov-{suffix}")
graph = svc.add_nodes_to_view(self.db, view_id, ViewNodesAdd(managed_ne_ids=[ne.id]))
fid = graph.nodes[0].fabric_node_id
svc.patch_view_positions(
self.db,
view_id,
ViewPositionsPatch(
positions=[
ViewNodeIn(fabric_node_id=fid, x=1, y=2, label="CUSTOM"),
],
return_graph=False,
),
)
fn = self.db.get(TopoFabricNode, fid)
assert fn is not None
fn.name = "DEVICE-B"
self.db.commit()
refreshed = svc.get_view_graph(self.db, view_id)
self.assertEqual(refreshed.nodes[0].label, "CUSTOM")
self.assertEqual(refreshed.nodes[0].label_override, "CUSTOM")
# Clear override → follow device name again.
svc.patch_view_positions(
self.db,
view_id,
ViewPositionsPatch(
positions=[ViewNodeIn(fabric_node_id=fid, x=1, y=2, label="")],
return_graph=False,
),
)
cleared = svc.get_view_graph(self.db, view_id)
self.assertEqual(cleared.nodes[0].label, "DEVICE-B")
self.assertEqual(cleared.nodes[0].label_override, "")
svc.delete_view(self.db, view_id, force=True)
self.db.delete(ne)
self.db.commit()
def test_clear_frozen_labels_script_dry_run_and_apply(self) -> None:
from scripts.topology_clear_frozen_labels import clear_frozen_labels
suffix = uuid4().hex[:8]
ne = ManagedNE(
id=f"ne-frz-{suffix}",
name="FROZEN-NAME",
vendor="Cisco",
device_type="cisco_ios",
ip_address=f"10.3.0.{(int(suffix[:2], 16) % 200) + 1}",
)
self.db.add(ne)
self.db.commit()
view_id = self._canvas(f"Frz-{suffix}")
graph = svc.add_nodes_to_view(self.db, view_id, ViewNodesAdd(managed_ne_ids=[ne.id]))
fid = graph.nodes[0].fabric_node_id
# Simulate a pre-T2 save that froze the device name into vn.label.
row = (
self.db.query(TopoViewNode)
.filter(TopoViewNode.view_id == view_id, TopoViewNode.fabric_node_id == fid)
.one()
)
row.label = "FROZEN-NAME"
self.db.commit()
scanned, cleared = clear_frozen_labels(self.db, apply=False)
self.assertGreaterEqual(scanned, 1)
self.assertGreaterEqual(cleared, 1)
self.db.refresh(row)
self.assertEqual(row.label, "FROZEN-NAME")
scanned2, cleared2 = clear_frozen_labels(self.db, apply=True)
self.db.commit()
self.assertEqual(cleared2, cleared)
self.db.refresh(row)
self.assertEqual(row.label, "")
scanned3, cleared3 = clear_frozen_labels(self.db, apply=False)
self.assertEqual(cleared3, 0)
self.assertGreaterEqual(scanned3, 0)
svc.delete_view(self.db, view_id, force=True)
self.db.delete(ne)
self.db.commit()
def test_outside_peers_found_when_many_internal_edges(self) -> None:
"""Internal edges must not consume the outside-peer scan budget."""
from netx_api.topology_views_graph import _outside_peers_for_view
suffix = uuid4().hex[:8]
nodes: list[TopoFabricNode] = []
for i in range(6):
ne = ManagedNE(
id=f"ne-op-{suffix}-{i}",
name=f"OP-{suffix}-{i}",
vendor="Cisco",
device_type="cisco_ios",
ip_address=f"10.8.{i}.{(int(suffix[:2], 16) % 200) + 1}",
)
self.db.add(ne)
self.db.flush()
nodes.append(svc.ensure_fabric_node_for_managed(self.db, ne))
outside = TopoFabricNode(
id=f"out-{suffix}",
name=f"OUT-{suffix}",
ip="10.8.99.1",
)
self.db.add(outside)
self.db.commit()
# Dense internal mesh among the first 5 nodes.
for i, a in enumerate(nodes[:5]):
for j, b in enumerate(nodes[:5]):
if i >= j:
continue
svc.upsert_fabric_edge(
self.db,
a_node_id=a.id,
b_node_id=b.id,
a_port=f"gi0/{i}",
b_port=f"gi0/{j}",
source="manual",
)
# One external edge after the internal flood.
svc.upsert_fabric_edge(
self.db,
a_node_id=nodes[0].id,
b_node_id=outside.id,
a_port="gi0/99",
b_port="gi0/1",
source="manual",
)
self.db.commit()
view_id = self._canvas(f"OP-{suffix}")
view = self.db.get(TopoView, view_id)
assert view is not None
svc.add_nodes_to_view(
self.db,
view_id,
ViewNodesAdd(fabric_node_ids=[n.id for n in nodes[:5]]),
)
peers = _outside_peers_for_view(
self.db,
view,
member_ids={n.id for n in nodes[:5]},
layer="physical",
limit=50,
edge_scan_limit=10, # would miss the peer if internals counted
)
peer_ids = {p["fabric_node_id"] for p in peers}
self.assertIn(outside.id, peer_ids)
svc.delete_view(self.db, view_id, force=True)
self.db.commit()
def test_view_truncation_is_deterministic(self) -> None:
from unittest.mock import patch
from netx_api import topology_views_graph as vg
suffix = uuid4().hex[:8]
view_id = self._canvas(f"Trunc-{suffix}")
fids: list[str] = []
for i in range(5):
ne = ManagedNE(
id=f"ne-tr-{suffix}-{i}",
name=f"TR-{suffix}-{i}",
vendor="Cisco",
device_type="cisco_ios",
ip_address=f"10.9.{i}.{(int(suffix[:2], 16) % 200) + 1}",
)
self.db.add(ne)
self.db.flush()
fn = svc.ensure_fabric_node_for_managed(self.db, ne)
fids.append(fn.id)
self.db.commit()
svc.add_nodes_to_view(self.db, view_id, ViewNodesAdd(fabric_node_ids=fids))
with patch.object(vg, "VIEW_GRAPH_NODE_HARD_CAP", 3):
g1 = svc.get_view_graph(self.db, view_id)
g2 = svc.get_view_graph(self.db, view_id)
ids1 = [n.fabric_node_id for n in g1.nodes]
ids2 = [n.fabric_node_id for n in g2.nodes]
self.assertEqual(len(ids1), 3)
self.assertEqual(ids1, ids2)
self.assertTrue(g1.truncated)
svc.delete_view(self.db, view_id, force=True)
self.db.commit()
def test_positions_batch_lookup_avoids_n_plus_one(self) -> None:
"""200-node positions patch should issue a handful of SQL statements, not 200+."""
from sqlalchemy import event
suffix = uuid4().hex[:8]
view_id = self._canvas(f"N1-{suffix}")
fids: list[str] = []
for i in range(40):
ne = ManagedNE(
id=f"ne-n1-{suffix}-{i}",
name=f"N1-{suffix}-{i}",
vendor="Cisco",
device_type="cisco_ios",
ip_address=f"10.10.{i // 200}.{(i % 200) + 1}",
)
self.db.add(ne)
self.db.flush()
fids.append(svc.ensure_fabric_node_for_managed(self.db, ne).id)
self.db.commit()
svc.add_nodes_to_view(self.db, view_id, ViewNodesAdd(fabric_node_ids=fids))
statements: list[str] = []
def _count(conn, cursor, statement, parameters, context, executemany): # noqa: ANN001
statements.append(str(statement))
bind = self.db.get_bind()
event.listen(bind, "before_cursor_execute", _count)
try:
svc.patch_view_positions(
self.db,
view_id,
ViewPositionsPatch(
positions=[
ViewNodeIn(fabric_node_id=fid, x=float(i), y=float(i))
for i, fid in enumerate(fids)
],
return_graph=False,
),
)
finally:
event.remove(bind, "before_cursor_execute", _count)
# Cap well below one-query-per-node (40+).
self.assertLessEqual(len(statements), 15)
svc.delete_view(self.db, view_id, force=True)
self.db.commit()
def test_positions_respects_max_nodes(self) -> None:
"""Positions must skip unknown placements instead of inserting past the cap."""
suffix = uuid4().hex[:8]
view_id = self._canvas(
f"Cap-{suffix}",
filter={"membership": {"max_nodes": 2, "mode": "manual"}},
)
nes = []
fids = []
for i in range(3):
ne = ManagedNE(
id=f"ne-cap-{suffix}-{i}",
name=f"CAP-{suffix}-{i}",
vendor="Cisco",
device_type="cisco_ios",
ip_address=f"10.11.{i}.{(int(suffix[:2], 16) % 200) + 1}",
)
self.db.add(ne)
self.db.flush()
nes.append(ne)
fids.append(svc.ensure_fabric_node_for_managed(self.db, ne).id)
self.db.commit()
svc.add_nodes_to_view(
self.db, view_id, ViewNodesAdd(fabric_node_ids=fids[:2], return_graph=False)
)
before = (
self.db.query(TopoViewNode).filter(TopoViewNode.view_id == view_id).count()
)
self.assertEqual(before, 2)
out = svc.patch_view_positions(
self.db,
view_id,
ViewPositionsPatch(
positions=[
ViewNodeIn(fabric_node_id=fids[0], x=1, y=1),
ViewNodeIn(fabric_node_id=fids[2], x=9, y=9), # not on view
],
return_graph=False,
),
)
self.assertGreaterEqual(out.skipped_missing, 1)
after = (
self.db.query(TopoViewNode).filter(TopoViewNode.view_id == view_id).count()
)
self.assertEqual(after, 2)
svc.delete_view(self.db, view_id, force=True)
for ne in nes:
self.db.delete(ne)
self.db.commit()
def test_create_topology_placeholder_on_view(self) -> None:
suffix = uuid4().hex[:8]
view_id = self._canvas(f"Rph-{suffix}")
@ -1249,6 +2050,8 @@ class FabricTopologyTests(unittest.TestCase):
vendor="Cisco",
device_type="cisco_ios",
ip_address=f"203.0.113.{(int(suffix[:2], 16) % 100) + 1}",
username="u",
password_enc=encrypt_secret("p"),
)
ne_b = ManagedNE(
id=f"neb-{suffix}",
@ -1256,6 +2059,8 @@ class FabricTopologyTests(unittest.TestCase):
vendor="Cisco",
device_type="cisco_ios",
ip_address=f"203.0.113.{(int(suffix[2:4], 16) % 100) + 101}",
username="u",
password_enc=encrypt_secret("p"),
)
self.db.add(ne_a)
self.db.add(ne_b)
@ -1270,7 +2075,12 @@ class FabricTopologyTests(unittest.TestCase):
"output": CISCO_LLDP_DETAIL,
"commands": ["show lldp neighbors detail"],
}
with patch("netx_api.topology_discover_scan.execute_managed_ne_commands", return_value=fake_exec):
# Background workers open SessionLocal(); point them at this in-memory engine.
with (
patch("netx_api.topology_discover_jobs.SessionLocal", self.Session),
patch("netx_api.topology_discover_scan.SessionLocal", self.Session),
patch("netx_api.topology_discover_scan.execute_managed_ne_commands", return_value=fake_exec),
):
job = svc.start_discover_job(
self.db,
FabricDiscoverRequest(scope="ne_ids", ne_ids=[ne_a.id], concurrency=1),
@ -1422,6 +2232,8 @@ class FabricTopologyTests(unittest.TestCase):
device_type="cisco_ios",
ip_address=f"203.0.113.{(int(suffix[:2], 16) % 80) + 40}",
source="manual",
username="u",
password_enc=encrypt_secret("p"),
)
self.db.add(ne_a)
self.db.commit()
@ -1442,7 +2254,11 @@ Management Addresses:
"output": lldp_out,
"commands": ["show lldp neighbors detail"],
}
with patch("netx_api.topology_discover_scan.execute_managed_ne_commands", return_value=fake_exec):
with (
patch("netx_api.topology_discover_jobs.SessionLocal", self.Session),
patch("netx_api.topology_discover_scan.SessionLocal", self.Session),
patch("netx_api.topology_discover_scan.execute_managed_ne_commands", return_value=fake_exec),
):
job = svc.start_discover_job(
self.db,
FabricDiscoverRequest(
@ -2283,7 +3099,9 @@ Management Addresses:
self.db.add(orphan)
self.db.commit()
stats = run_fabric_reconcile_once()
# run_fabric_reconcile_once opens SessionLocal(); bind it to this engine.
with patch("netx_api.fabric_reconcile_scheduler.SessionLocal", self.Session):
stats = run_fabric_reconcile_once()
self.assertGreaterEqual(int(stats.get("purged_orphans") or 0), 1)
self.db.expire_all()
self.assertIsNone(self.db.get(TopoFabricNode, orphan_id))

View file

@ -345,20 +345,12 @@ class UmeFabricApplyTests(unittest.TestCase):
edge.source = "ume"
edge.attrs = {"sources": ["ume"], "ume_link_id": "link-1"}
self.db.commit()
a = (
self.db.query(TopoFabricNode)
.filter(TopoFabricNode.ume_ne_id == "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
.one()
)
b = (
self.db.query(TopoFabricNode)
.filter(TopoFabricNode.ume_ne_id == "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")
.one()
)
# Ports are already assigned to the edge's canonical A/B order; pass the
# same node ids so _normalize_endpoints cannot swap ports onto the wrong ends.
edge2, action = upsert_fabric_edge(
self.db,
a_node_id=a.id,
b_node_id=b.id,
a_node_id=edge.a_node_id,
b_node_id=edge.b_node_id,
a_port=rich_a,
b_port=rich_b,
source="lldp",

File diff suppressed because it is too large Load diff

View file

@ -399,8 +399,10 @@ export function TopologyCtxMenu({
className="topo-ctx__style-input"
value={selectedEdgeData.source_port || ""}
placeholder="?"
readOnly={String(selectedEdgeData.source || "").toLowerCase() !== "manual"}
disabled={
Number(selectedEdgeData.member_count || 1) > 1 && !expandPhysicalLinks
String(selectedEdgeData.source || "").toLowerCase() !== "manual" ||
(Number(selectedEdgeData.member_count || 1) > 1 && !expandPhysicalLinks)
}
onFocus={() => onPushHistory()}
onChange={(e) =>
@ -421,8 +423,10 @@ export function TopologyCtxMenu({
className="topo-ctx__style-input"
value={selectedEdgeData.target_port || ""}
placeholder="?"
readOnly={String(selectedEdgeData.source || "").toLowerCase() !== "manual"}
disabled={
Number(selectedEdgeData.member_count || 1) > 1 && !expandPhysicalLinks
String(selectedEdgeData.source || "").toLowerCase() !== "manual" ||
(Number(selectedEdgeData.member_count || 1) > 1 && !expandPhysicalLinks)
}
onFocus={() => onPushHistory()}
onChange={(e) =>

View file

@ -0,0 +1,867 @@
import type { CSSProperties } from "react";
import { Suspense } from "react";
import { Button } from "@heroui/react";
import { TopologyDiscoverPanel } from "./TopologyDiscoverPanel";
import { TopologyHexBrowser } from "./TopologyHexBrowser";
import { TopologyToolbar } from "./TopologyToolbar";
import { TopologyViewTools } from "./TopologyViewTools";
import { TopologySidebar } from "./TopologySidebar";
import { TopologyModals } from "./TopologyModals";
import { TopologyCtxMenu } from "./TopologyCtxMenu";
import type { TopologyPageViewModel } from "../lib/pageViewModel";
import type { PaletteItem } from "../lib/pageTypes";
import { canvasBgLuminance, canvasFloatChromeVars } from "../lib/treeUtils";
import type { PlaceholderCreateDialogState } from "./modals/PlaceholderCreateDialog";
type Props = {
v: TopologyPageViewModel;
};
export function TopologyPageView({ v }: Props) {
const {
TopologyReactFlowView,
activeRegion,
activeView,
addNeOpen,
addOutsidePeers,
addPaletteItems,
alarmByNodeId,
applyAlign,
applyWorldMut,
autoLayoutAfterDiscover,
breadcrumbFolders,
browseEntries,
canRedo,
canUndo,
canWriteTopology,
cancelDiscover,
canvasBg,
canvasGraphEmpty,
canvasGraphError,
canvasGraphErrorMsg,
canvasGraphLoading,
canvasGraphRefreshing,
canvasHits,
canvasMode,
canvasQuery,
canvasRef,
canvasZoom,
clearSelection,
closeConnectDetail,
closeCreateNeMode,
closeCtxMenu,
closeManagedForm,
closePlaceholder,
connectClickRef,
connectDetailRow,
connectTestSelectedFor,
connectTestSubmitting,
createNeModeOpen,
createRegionMut,
ctxMenu,
ctxMenuRef,
debouncedTreeNeQuery,
deleteFolderMut,
dirty,
discoverAutoAddUnmatched,
discoverError,
discoverOneFor,
discoverOpen,
discoverPct,
discoverProgress,
discoverProjectNeighbors,
discoverReport,
discoverResults,
discoverSelectedFor,
discoverSummary,
discovering,
displayEdges,
displayMenuRef,
displayOpts,
edgeDefaults,
edgeFlow,
edges,
expandPhysicalLinks,
expandedIds,
exporting,
findActiveIdx,
findBoxRef,
findOnCanvas,
findOpen,
fitCanvas,
focusEdge,
focusNode,
fullscreen,
goBackBrowse,
goCanvas,
goRegion,
goRoot,
goUmeWorldNav,
graphQuery,
graphTruncated,
hexBrowseRegion,
hideIp,
hidePorts,
hideVendor,
hotBrowseKey,
isValidConnection,
isWorldFlatCanvas,
jumpToTreeSearchHit,
keyword,
labelColors,
liveSync,
managedFormInitial,
managedFormOpen,
mapId,
markDirty,
newRootDialog,
nodes,
nodesRef,
onCanvasDragOver,
onCanvasDrop,
onConnect,
onConnectRetestSubmitted,
onManagedFormSaved,
onNodeClick,
onNodeDoubleClick,
onPaletteDragStart,
onSidebarResizeDown,
onTreeClearHotBrowseKey,
onTreeDeleteFolder,
onTreeDeleteMap,
onTreeHotBrowseKey,
onTreeToggleExpand,
openAlarmsFor,
openCreateNeAt,
openNeFor,
openPortTrafficForEdge,
openWebcrtFor,
outsidePeerNameById,
outsidePeerQuery,
outsidePeerSelectedIds,
outsidePeers,
outsidePeersAdding,
outsidePeersOpen,
outsidePeersVisible,
paletteAdding,
paletteLoading,
paletteSelectedKeys,
paletteSource,
paletteVisible,
patchSelectedEdgeStyle,
pendingFitRef,
pickCreateManaged,
pickCreatePlaceholder,
placeCtxMenu,
placeholderBusy,
placeholderDialog,
popHistory,
primaryViewOfFolder,
promptNewRegion,
promptNewSubRegion,
promptRenameMap,
promptRenameRegion,
purgePlaceholderById,
pushHistory,
redo,
regions,
removeEdgeById,
removeNodeById,
removeSelected,
removeStaleEdges,
renameMapMut,
renameRegionMut,
renameSelectedNode,
resetEdgeDefaults,
rfRef,
rootFolderId,
runConnectTestForNode,
runTopologyExport,
saveMut,
scaleBundleWidth,
scheduleFlatViewportRefresh,
searchHitIds,
selectedConnectableCount,
selectedDiscoverableCount,
selectedEdge,
selectedEdgeData,
selectedEdgeId,
selectedEdgeResolved,
selectedEdgeSourceNode,
selectedEdgeTargetNode,
selectedFolderId,
selectedNode,
selectedNodeIds,
selectedNodes,
setAddNeOpen,
setAutoLayoutAfterDiscover,
setCanvasBg,
setCanvasQuery,
setCanvasZoom,
setCtxMenu,
setDiscoverAutoAddUnmatched,
setDiscoverOpen,
setDiscoverProjectNeighbors,
setEdgeFlow,
setEdges,
setExpandPhysicalLinks,
setFindActiveIdx,
setFindOpen,
setHideIp,
setHidePorts,
setHideVendor,
setHotBrowseKey,
setKeyword,
setLabelColors,
setLiveSync,
setNewRootDialog,
setNodes,
setOutsidePeerQuery,
setOutsidePeerSelectedIds,
setOutsidePeersOpen,
setPaletteSelectedKeys,
setPaletteSource,
setPlaceholderDialog,
setScaleBundleWidth,
setSelectedFolderId,
setShowAlarmOverlay,
setShowConnectStatus,
setShowLevelColors,
setShowPlaceholderBadge,
setSidebarCollapsed,
setSnapToGrid,
setToolMode,
setTreeNeQuery,
setTreeSearchOpen,
setVendorColors,
setViewToolsToolbarSlot,
setWorldFocusFolderId,
showAlarmOverlay,
showConnectStatus,
showError,
showLevelColors,
showPlaceholderBadge,
showWorldScatter,
sidebarCollapsed,
sidebarResizing,
sidebarWidth,
snapToGrid,
staleEdgeIds,
submitNewRoot,
submitPlaceholder,
t,
titleText,
toggleFullscreen,
toolBehavior,
toolMode,
topoPageRef,
treeFailed,
treeLoading,
treeNeQuery,
treeNeSearchQuery,
treeQuery,
treeRoot,
treeSearchOpen,
treeSearchRef,
truncateBannerText,
truncateReason,
umeWorldHexModules,
undo,
updateEdgeDefault,
vendorColors,
viewToolsToolbarSlot,
worldDockMe,
worldNeedsApply,
worldScatter,
worldTotal,
worldTransformRef,
worldViewId,
worldVisualLod,
} = v;
return (
<div
ref={topoPageRef}
className={`topo-page${sidebarCollapsed ? " is-sidebar-collapsed" : ""}${
sidebarResizing ? " is-sidebar-resizing" : ""
}`}
style={
sidebarCollapsed
? undefined
: ({ "--topo-sidebar-w": `${sidebarWidth}px` } as CSSProperties)
}
>
<TopologySidebar
collapsed={sidebarCollapsed}
resizing={sidebarResizing}
treeSearchRef={treeSearchRef}
treeNeQuery={treeNeQuery}
onTreeNeQueryChange={setTreeNeQuery}
treeSearchOpen={treeSearchOpen}
onTreeSearchOpen={setTreeSearchOpen}
debouncedTreeNeQuery={debouncedTreeNeQuery}
treeSearchFetching={treeNeSearchQuery.isFetching}
treeSearchItems={treeNeSearchQuery.data?.items || []}
treeSearchTotal={treeNeSearchQuery.data?.total || 0}
onJumpToSearchHit={jumpToTreeSearchHit}
promptNewRegion={promptNewRegion}
createRegionPending={createRegionMut.isPending}
onCollapse={() => setSidebarCollapsed(true)}
onExpand={() => setSidebarCollapsed(false)}
treeRoot={treeRoot}
treeLoading={treeLoading}
treeFailed={treeFailed}
treeError={treeQuery.error}
onTreeRetry={() => void treeQuery.refetch()}
regions={regions}
treeNav={{
treeRootId: String(treeRoot?.id || ""),
expandedIds,
mapId,
worldViewId,
selectedFolderId,
hotBrowseKey,
dirty,
onToggleExpand: onTreeToggleExpand,
onHotBrowseKey: onTreeHotBrowseKey,
onClearHotBrowseKey: onTreeClearHotBrowseKey,
onWorldFocusClear: () => setWorldFocusFolderId(""),
goUmeWorldNav,
goRegion,
goCanvas,
promptRenameRegion,
promptRenameMap,
renameRegionPending: renameRegionMut.isPending,
renameMapPending: renameMapMut.isPending,
onDeleteFolder: onTreeDeleteFolder,
deleteFolderPending: deleteFolderMut.isPending,
onDeleteMap: onTreeDeleteMap,
}}
onResizeDown={onSidebarResizeDown}
/>
<main className="topo-main">
{canvasMode ? (
<TopologyToolbar
readOnly={!canWriteTopology}
breadcrumbFolders={breadcrumbFolders}
activeView={activeView}
activeRegion={activeRegion}
rootFolderId={rootFolderId}
dirty={dirty}
selectedNodes={selectedNodes}
selectedEdgeId={selectedEdgeId}
liveSync={liveSync}
onLiveSyncToggle={() => setLiveSync((v: boolean) => !v)}
isWorldFlatCanvas={isWorldFlatCanvas}
onAddNe={() => {
if (isWorldFlatCanvas) {
showError(t("topology.worldMapNoDirectNes"));
return;
}
setKeyword("");
setPaletteSelectedKeys([]);
setAddNeOpen(true);
}}
onCreateNe={(flowX, flowY) => {
if (isWorldFlatCanvas) {
showError(t("topology.worldMapNoDirectNes"));
return;
}
openCreateNeAt(flowX, flowY);
}}
nodes={nodes}
rfRef={rfRef}
onBackUp={goBackBrowse}
canUndo={canUndo}
onUndo={undo}
canRedo={canRedo}
onRedo={redo}
savePending={saveMut.isPending}
onSave={() => saveMut.mutate()}
onFit={fitCanvas}
exporting={exporting}
onExport={runTopologyExport}
mapId={mapId}
staleEdgeCount={staleEdgeIds.length}
onRemoveStale={removeStaleEdges}
toolMode={toolMode}
onToolModeChange={setToolMode}
onConnectClickReset={() => {
connectClickRef.current = null;
}}
fullscreen={fullscreen}
viewToolsToolbarSlotRef={setViewToolsToolbarSlot}
outsidePeerCount={outsidePeers.length}
onOpenOutsidePeers={() => {
setOutsidePeerQuery("");
setOutsidePeerSelectedIds([]);
setOutsidePeersOpen(true);
}}
goRoot={goRoot}
goRegion={goRegion}
primaryViewOfFolder={primaryViewOfFolder}
/>
) : null}
{discoverOpen ? (
<TopologyDiscoverPanel
discovering={discovering}
discoverProgress={discoverProgress}
discoverPct={discoverPct}
discoverReport={discoverReport}
discoverError={discoverError}
hasResults={discoverResults.length > 0 || Boolean(discoverReport)}
discoverSummary={discoverSummary}
onCancelDiscover={cancelDiscover}
onClose={() => setDiscoverOpen(false)}
/>
) : null}
{!canvasMode ? (
<TopologyHexBrowser
titleText={titleText}
breadcrumbFolders={breadcrumbFolders}
hexBrowseRegion={hexBrowseRegion}
umeWorldHexModules={umeWorldHexModules}
browseEntries={browseEntries}
regions={regions}
rootFolderId={rootFolderId}
selectedFolderId={selectedFolderId}
mapId={mapId}
hotBrowseKey={hotBrowseKey}
treeLoading={treeLoading}
treeFailed={treeFailed}
treeError={treeQuery.error}
createRegionPending={createRegionMut.isPending}
onTreeRetry={() => void treeQuery.refetch()}
onHotBrowseKey={setHotBrowseKey}
onClearHotBrowseKey={(key: string) => setHotBrowseKey((k: string) => (k === key ? "" : k))}
goRoot={goRoot}
goRegion={goRegion}
goUmeWorldNav={goUmeWorldNav}
goCanvas={goCanvas}
promptNewRegion={promptNewRegion}
promptNewSubRegion={promptNewSubRegion}
onSelectFolder={setSelectedFolderId}
/>
) : (
<div
className={`topo-canvas${fullscreen ? " is-fullscreen" : ""}${
toolMode === "pan" ? " is-pan-mode" : ""
}${canvasBgLuminance(canvasBg) > 0.55 ? " is-canvas-light" : ""}`}
ref={canvasRef}
style={
{
"--topo-canvas-bg": canvasBg,
"--topo-label-color": labelColors.name,
"--topo-edge-label-color": labelColors.edgeLabel,
"--topo-vendor-cisco": vendorColors.cisco,
"--topo-vendor-huawei": vendorColors.huawei,
"--topo-vendor-zte": vendorColors.zte,
"--topo-vendor-juniper": vendorColors.juniper,
"--topo-vendor-nokia": vendorColors.nokia,
"--topo-vendor-ericsson": vendorColors.ericsson,
"--topo-vendor-h3c": vendorColors.h3c,
"--topo-vendor-ruijie": vendorColors.ruijie,
"--topo-vendor-mikrotik": vendorColors.mikrotik,
"--topo-vendor-gray": vendorColors.gray,
...canvasFloatChromeVars(canvasBg),
} as CSSProperties
}
onDragOver={onCanvasDragOver}
onDrop={onCanvasDrop}
>
{truncateBannerText ? (
<div className="topo-truncate-banner" role="status">
{truncateBannerText}
</div>
) : null}
{!canWriteTopology && mapId ? (
<div className="topo-readonly-banner" role="status">
{t("topology.readOnlyBanner")}
</div>
) : null}
<TopologyViewTools
fullscreen={fullscreen}
toolbarSlot={viewToolsToolbarSlot}
displayMenuRef={displayMenuRef}
findBoxRef={findBoxRef}
hideIp={hideIp}
onHideIpChange={setHideIp}
hideVendor={hideVendor}
onHideVendorChange={setHideVendor}
showPlaceholderBadge={showPlaceholderBadge}
onShowPlaceholderBadgeChange={setShowPlaceholderBadge}
showConnectStatus={showConnectStatus}
onShowConnectStatusChange={setShowConnectStatus}
showAlarmOverlay={showAlarmOverlay}
onShowAlarmOverlayChange={setShowAlarmOverlay}
showLevelColors={showLevelColors}
onShowLevelColorsChange={setShowLevelColors}
hidePorts={hidePorts}
onHidePortsChange={setHidePorts}
expandPhysicalLinks={expandPhysicalLinks}
onExpandPhysicalLinksChange={setExpandPhysicalLinks}
scaleBundleWidth={scaleBundleWidth}
onScaleBundleWidthChange={setScaleBundleWidth}
edgeFlow={edgeFlow}
onEdgeFlowChange={setEdgeFlow}
snapToGrid={snapToGrid}
onSnapToGridChange={setSnapToGrid}
autoLayoutAfterDiscover={autoLayoutAfterDiscover}
onAutoLayoutAfterDiscoverChange={setAutoLayoutAfterDiscover}
discoverAutoAddUnmatched={discoverAutoAddUnmatched}
onDiscoverAutoAddUnmatchedChange={setDiscoverAutoAddUnmatched}
discoverProjectNeighbors={discoverProjectNeighbors}
onDiscoverProjectNeighborsChange={setDiscoverProjectNeighbors}
canvasBg={canvasBg}
onCanvasBgChange={setCanvasBg}
labelColors={labelColors}
onLabelColorsChange={setLabelColors}
vendorColors={vendorColors}
onVendorColorsChange={setVendorColors}
edgeDefaults={edgeDefaults}
onUpdateEdgeDefault={updateEdgeDefault}
onResetEdgeDefaults={resetEdgeDefaults}
selectedNodeCount={selectedNodes.length}
onAlign={applyAlign}
mapId={mapId}
nodeCount={nodes.length}
canvasQuery={canvasQuery}
onCanvasQueryChange={setCanvasQuery}
findOpen={findOpen}
onFindOpenChange={setFindOpen}
findActiveIdx={findActiveIdx}
onFindActiveIdxChange={setFindActiveIdx}
canvasHits={canvasHits}
onFindOnCanvas={findOnCanvas}
/>
{fullscreen ? (
<div className="topo-fs-toolbar" role="toolbar" aria-label={t("topology.toolModes")}>
{(
[
["select", t("topology.toolSelect"), "V"],
["pan", t("topology.toolPan"), "H"],
["connect", t("topology.toolConnect"), "C"],
] as const
).map(([mode, label, key]) => (
<button
key={mode}
type="button"
className={`topo-fs-toolbar__btn${toolMode === mode ? " is-active" : ""}`}
title={`${label} (${key})`}
aria-pressed={toolMode === mode}
onClick={() => {
setToolMode(mode);
connectClickRef.current = null;
}}
>
{label}
<kbd>{key}</kbd>
</button>
))}
<button
type="button"
className="topo-fs-toolbar__btn"
title={t("topology.fit")}
onClick={() => fitCanvas()}
>
{t("topology.fit")}
</button>
<button
type="button"
className="topo-fs-toolbar__btn"
title={t("topology.exitFullscreen")}
onClick={() => void toggleFullscreen()}
>
{t("topology.exitFullscreen")}
</button>
</div>
) : null}
{treeRoot ? (
<>
{isWorldFlatCanvas ? (
<div className="topo-world-hud" role="status">
<span>
{t("topology.worldHud")
.replace("{{total}}", String(worldTotal || worldScatter.length || 0))
.replace("{{cached}}", String(nodes.length))
.replace("{{zoom}}", canvasZoom.toFixed(3))
.replace("{{lod}}", worldVisualLod)}
</span>
{graphTruncated ? (
<span className="muted"> · {truncateReason || "truncated"}</span>
) : null}
</div>
) : null}
<Suspense
fallback={
<div className="topo-canvas__empty topo-canvas__status" role="status">
<span className="topo-loading-spinner" aria-hidden="true" />
<p>{t("topology.treeLoading")}</p>
</div>
}
>
<TopologyReactFlowView
displayOpts={displayOpts}
alarmByNodeId={alarmByNodeId}
nodes={nodes}
setNodes={setNodes}
edges={edges}
setEdges={setEdges}
displayEdges={displayEdges}
searchHitIds={searchHitIds}
isWorldFlatCanvas={isWorldFlatCanvas}
worldVisualLod={worldVisualLod}
toolBehavior={toolBehavior}
toolMode={toolMode}
snapToGrid={snapToGrid}
canvasBg={canvasBg}
worldScatter={worldScatter}
showWorldScatter={showWorldScatter}
fullscreen={fullscreen}
connectHint={t("topology.connectHint")}
fullscreenLabel={t("topology.fullscreen")}
exitFullscreenLabel={t("topology.exitFullscreen")}
worldTransform={worldTransformRef.current || graphQuery.data?.world_transform}
nodesRef={nodesRef}
rfRef={rfRef}
pendingFitRef={pendingFitRef}
markDirty={markDirty}
pushHistory={pushHistory}
popHistory={popHistory}
onConnect={onConnect}
isValidConnection={isValidConnection}
onNodeClick={onNodeClick}
onNodeDoubleClick={onNodeDoubleClick}
focusEdge={focusEdge}
focusNode={focusNode}
clearSelection={clearSelection}
setCtxMenu={setCtxMenu}
placeCtxMenu={placeCtxMenu}
selectedNodeIds={selectedNodeIds}
closeCtxMenu={closeCtxMenu}
setCanvasZoom={setCanvasZoom}
scheduleFlatViewportRefresh={scheduleFlatViewportRefresh}
toggleFullscreen={toggleFullscreen}
/>
</Suspense>
</>
) : treeLoading ? (
<div className="topo-canvas__empty topo-canvas__status" role="status">
<span className="topo-loading-spinner" aria-hidden="true" />
<p>{t("topology.treeLoading")}</p>
</div>
) : treeFailed ? (
<div className="topo-canvas__empty">
<p className="muted">{t("topology.treeLoadFailed")}</p>
<div className="topo-browser__empty-actions">
<Button
size="sm"
variant="ghost"
onPress={() => void treeQuery.refetch()}
>
{t("topology.treeRetry")}
</Button>
<Button
size="sm"
variant="secondary"
onPress={promptNewRegion}
isDisabled={createRegionMut.isPending}
>
{t("topology.newRegion")}
</Button>
</div>
</div>
) : (
<div className="topo-canvas__empty">
<p className="muted">{t("topology.emptyMaps")}</p>
<Button
size="sm"
variant="secondary"
onPress={promptNewRegion}
isDisabled={createRegionMut.isPending}
>
{t("topology.newRegion")}
</Button>
</div>
)}
{treeRoot && canvasGraphError ? (
<div className="topo-canvas__overlay topo-canvas__overlay--error" role="alert">
<p className="topo-canvas__overlay-title">{t("topology.graphLoadFailed")}</p>
{canvasGraphErrorMsg ? (
<p className="topo-canvas__overlay-hint muted">{canvasGraphErrorMsg}</p>
) : null}
<div className="topo-browser__empty-actions">
<Button
size="sm"
variant="ghost"
onPress={() => void graphQuery.refetch()}
>
{t("topology.treeRetry")}
</Button>
</div>
</div>
) : null}
{treeRoot && canvasGraphLoading ? (
<div className="topo-canvas__overlay topo-canvas__overlay--loading" role="status">
<span className="topo-loading-spinner topo-loading-spinner--lg" aria-hidden="true" />
<p className="topo-canvas__overlay-title">{t("topology.graphLoading")}</p>
</div>
) : null}
{treeRoot && canvasGraphEmpty ? (
<div
className={`topo-canvas__overlay topo-canvas__overlay--empty${
worldNeedsApply ? " is-interactive" : ""
}`}
role="status"
>
<p className="topo-canvas__overlay-title">
{worldNeedsApply ? t("topology.worldEmptyCoords") : t("topology.canvasEmpty")}
</p>
<p className="topo-canvas__overlay-hint">
{worldNeedsApply
? t("topology.worldEmptyCoordsHint").replace("{{dock}}", String(worldDockMe))
: t("topology.canvasEmptyHint")}
</p>
{worldNeedsApply ? (
<div className="btn-row" style={{ marginTop: 10, justifyContent: "center" }}>
<Button
size="sm"
variant="secondary"
isDisabled={applyWorldMut.isPending}
onPress={() => applyWorldMut.mutate()}
>
{applyWorldMut.isPending
? t("topology.worldApplying")
: t("topology.worldApply")}
</Button>
</div>
) : null}
</div>
) : null}
{treeRoot && canvasGraphRefreshing ? (
<div className="topo-canvas__refresh" role="status" aria-live="polite">
<span className="topo-loading-spinner" aria-hidden="true" />
{t("topology.graphRefreshing")}
</div>
) : null}
</div>
)}
</main>
<TopologyModals
canvasMode={canvasMode}
newRootDialog={newRootDialog}
onNewRootNameChange={(name) => setNewRootDialog({ name })}
onCloseNewRoot={() => setNewRootDialog(null)}
onSubmitNewRoot={submitNewRoot}
createRegionPending={createRegionMut.isPending}
createNeModeOpen={createNeModeOpen}
onCloseCreateNeMode={closeCreateNeMode}
onPickCreateManaged={pickCreateManaged}
onPickCreatePlaceholder={pickCreatePlaceholder}
placeholderDialog={placeholderDialog}
placeholderBusy={placeholderBusy}
onPlaceholderChange={(patch: Partial<PlaceholderCreateDialogState>) =>
setPlaceholderDialog((prev: PlaceholderCreateDialogState | null) =>
prev ? { ...prev, ...patch } : prev,
)
}
onClosePlaceholder={closePlaceholder}
onSubmitPlaceholder={submitPlaceholder}
managedFormOpen={managedFormOpen}
managedFormInitial={managedFormInitial}
onCloseManagedForm={closeManagedForm}
onManagedFormSaved={(item) => void onManagedFormSaved(item)}
connectDetailRow={connectDetailRow}
onCloseConnectDetail={closeConnectDetail}
onConnectRetestSubmitted={onConnectRetestSubmitted}
outsidePeersOpen={outsidePeersOpen}
outsidePeers={outsidePeers}
outsidePeersVisible={outsidePeersVisible}
outsidePeerQuery={outsidePeerQuery}
onOutsidePeerQueryChange={setOutsidePeerQuery}
outsidePeerSelectedIds={outsidePeerSelectedIds}
onOutsidePeerSelectedIdsChange={setOutsidePeerSelectedIds}
outsidePeerNameById={outsidePeerNameById}
outsidePeersAdding={outsidePeersAdding}
onCloseOutsidePeers={() => setOutsidePeersOpen(false)}
onAddOutsidePeers={addOutsidePeers}
addNeOpen={addNeOpen}
paletteSource={paletteSource}
onPaletteSourceChange={(source) => {
setPaletteSource(source);
setPaletteSelectedKeys([]);
}}
keyword={keyword}
onKeywordChange={(value) => {
setKeyword(value);
setPaletteSelectedKeys([]);
}}
paletteVisible={paletteVisible}
paletteSelectedKeys={paletteSelectedKeys}
onPaletteSelectedKeysChange={setPaletteSelectedKeys}
paletteLoading={paletteLoading}
paletteAdding={paletteAdding}
onCloseAddNe={() => setAddNeOpen(false)}
onPaletteDragStart={onPaletteDragStart}
onAddSelectedPalette={async () => {
const selected = (paletteVisible as PaletteItem[]).filter((item: PaletteItem) =>
paletteSelectedKeys.includes(item.key),
);
if (selected.length === 0) return;
const ok = await addPaletteItems(selected);
if (!ok) return;
setPaletteSelectedKeys([]);
setAddNeOpen(false);
}}
/>
{ctxMenu ? (
<TopologyCtxMenu
ctxMenu={ctxMenu}
menuRef={ctxMenuRef}
portalTarget={canvasRef.current}
fullscreen={fullscreen}
selectedNodeCount={selectedNodes.length}
selectedNode={selectedNode}
selectedEdge={selectedEdge}
selectedEdgeData={selectedEdgeData}
selectedEdgeResolved={selectedEdgeResolved}
selectedEdgeSourceNode={selectedEdgeSourceNode || null}
selectedEdgeTargetNode={selectedEdgeTargetNode || null}
expandPhysicalLinks={expandPhysicalLinks}
discovering={discovering}
isWorldFlatCanvas={isWorldFlatCanvas}
createRegionPending={createRegionMut.isPending}
onClose={closeCtxMenu}
onToggleFullscreen={toggleFullscreen}
onRemoveSelected={removeSelected}
onDiscoverSelected={discoverSelectedFor}
onConnectTestSelected={connectTestSelectedFor}
selectedDiscoverableCount={selectedDiscoverableCount}
selectedConnectableCount={selectedConnectableCount}
onOpenCreateNe={openCreateNeAt}
onPromptNewSubRegion={promptNewSubRegion}
onRenameSelectedNode={renameSelectedNode}
onDiscoverOne={discoverOneFor}
onOpenWebcrt={openWebcrtFor}
onOpenNe={openNeFor}
onOpenAlarms={openAlarmsFor}
onConnectTest={runConnectTestForNode}
connectTestBusy={connectTestSubmitting}
onPurgePlaceholder={purgePlaceholderById}
onRemoveNode={removeNodeById}
onExpandPhysicalLinks={() => setExpandPhysicalLinks(true)}
onPushHistory={pushHistory}
onPatchSelectedEdgeStyle={patchSelectedEdgeStyle}
onOpenPortTraffic={openPortTrafficForEdge}
onRemoveEdge={removeEdgeById}
/>
) : null}
</div>
);
}

View file

@ -1,4 +1,11 @@
import { createContext, memo, useContext, type Dispatch, type SetStateAction } from "react";
import {
createContext,
memo,
useContext,
useRef,
type Dispatch,
type SetStateAction,
} from "react";
import {
ReactFlow,
Background,
@ -24,6 +31,7 @@ import {
import "@xyflow/react/dist/style.css";
import { WorldScatterLayer, type WorldScatterPoint } from "./WorldScatterLayer";
import { ParallelEdge } from "./ParallelEdge";
import { fitToNodes } from "../lib/fitNodes";
import { isAggregateEdgeId } from "../lib/linkDisplay";
import type { ToolMode, ToolModeBehavior } from "../lib/toolMode";
import type { TopologyViewRole, TopologyWorldTransform } from "../../../types";
@ -36,6 +44,13 @@ export { isPlaceholderSource };
export type NeNodeData = {
label: string;
/** Current label override to persist; "" clears override. */
label_override?: string;
/** Last server label_override; used to decide whether to send label on save. */
label_override_saved?: string;
/** Inventory / fabric display name (for restoring after clearing override). */
name?: string;
locked?: boolean;
managed_ne_id: string;
ume_ne_id: string;
ne_ip: string;
@ -215,6 +230,16 @@ const NeNode = memo(function NeNode({ id, data, selected }: NodeProps<Node<NeNod
isConnectable={connectMode && !isRegion}
/>
{!isRegion ? <RouterIcon /> : <RegionCanvasIcon />}
{data.locked ? (
<span className="topo-node__lock" title="Locked" aria-label="Locked">
<svg viewBox="0 0 24 24" width="10" height="10" aria-hidden="true">
<path
fill="currentColor"
d="M12 17a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm6-6h-1V9a5 5 0 0 0-10 0v2H6a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-7a2 2 0 0 0-2-2zm-3 0H9V9a3 3 0 1 1 6 0v2z"
/>
</svg>
</span>
) : null}
{showConnectStatus && !isRegion ? (
<span className={`topo-node__conn topo-node__conn--${connect}`} aria-hidden />
) : null}
@ -328,6 +353,8 @@ export type TopologyReactFlowViewProps = {
pendingFitRef: { current: boolean };
markDirty: () => void;
pushHistory: () => void;
/** Drop the latest undo frame when a drag was cancelled / no real move. */
popHistory: () => void;
onConnect: (connection: Connection) => void;
isValidConnection: (connection: Connection | Edge) => boolean;
onNodeClick: (e: React.MouseEvent, node: Node<NeNodeData>) => void;
@ -376,6 +403,7 @@ export default function TopologyReactFlowView(props: TopologyReactFlowViewProps)
pendingFitRef,
markDirty,
pushHistory,
popHistory,
onConnect,
isValidConnection,
onNodeClick,
@ -392,6 +420,24 @@ export default function TopologyReactFlowView(props: TopologyReactFlowViewProps)
toggleFullscreen,
} = props;
/** Pre-drag positions of every node being dragged (single node or a box selection). */
const dragStartRef = useRef<Map<string, { x: number; y: number }> | null>(null);
const beginDrag = (dragged: Node<NeNodeData>[]) => {
dragStartRef.current = new Map(dragged.map((n) => [n.id, { x: n.position.x, y: n.position.y }]));
pushHistory();
};
const endDrag = (dragged: Node<NeNodeData>[]) => {
const start = dragStartRef.current;
dragStartRef.current = null;
if (!start) return;
const moved = dragged.some((n) => {
const s0 = start.get(n.id);
return Boolean(s0) && (Math.abs(n.position.x - s0!.x) > 1 || Math.abs(n.position.y - s0!.y) > 1);
});
if (moved) markDirty();
else popHistory();
};
return (
<TopoDisplayContext.Provider value={displayOpts}>
<TopoAlarmContext.Provider value={alarmByNodeId || emptyAlarmMap}>
@ -439,13 +485,26 @@ export default function TopologyReactFlowView(props: TopologyReactFlowViewProps)
multiSelectionKeyCode="Shift"
snapToGrid={snapToGrid}
snapGrid={SNAP_GRID}
onNodeDragStart={() => {
pushHistory();
}}
onNodeDragStart={(_e, _node, dragged) => beginDrag(dragged)}
onNodeDragStop={(_e, _node, dragged) => endDrag(dragged)}
// Dragging an existing box selection fires selection events, not node drag events.
onSelectionDragStart={(_e, dragged) => beginDrag(dragged)}
onSelectionDragStop={(_e, dragged) => endDrag(dragged)}
onNodesChange={(changes: NodeChange<Node<NeNodeData>>[]) => {
if (changes.some((c) => c.type === "position" || c.type === "remove" || c.type === "add")) {
markDirty();
// Position changes alone (click / select jitter) must not mark dirty; pointer
// drags are settled in endDrag. Outside a drag, a real position change comes from
// keyboard arrows (xyflow a11y move) and is an edit.
let edited = changes.some((c) => c.type === "remove" || c.type === "add");
if (!edited && !dragStartRef.current) {
const before = new Map(nodes.map((n) => [n.id, n.position]));
edited = changes.some((c) => {
if (c.type !== "position" || c.dragging || !c.position) return false;
const p0 = before.get(c.id);
return Boolean(p0) && (p0!.x !== c.position.x || p0!.y !== c.position.y);
});
if (edited) pushHistory();
}
if (edited) markDirty();
setNodes((nds) => applyNodeChanges(changes, nds));
}}
onEdgesChange={(changes: EdgeChange<Edge>[]) => {
@ -522,8 +581,8 @@ export default function TopologyReactFlowView(props: TopologyReactFlowViewProps)
const bounds = isWorldFlatCanvas ? worldDisplayBounds(worldTransform) : null;
if (bounds) {
inst.fitBounds(bounds, { padding: 0.12, duration: 0 });
} else if (nodesRef.current.length > 0) {
inst.fitView({ ...FIT_VIEW_OPTS, duration: 0 });
} else {
fitToNodes(inst, nodesRef.current, { padding: FIT_VIEW_OPTS.padding });
}
setCanvasZoom(inst.getZoom());
});

View file

@ -27,6 +27,7 @@ import {
worldVisualLodFromZoom,
} from "../lib/treeUtils";
import { mergeFlatWorldGraph } from "../lib/worldGraph";
import { fitToNodes } from "../lib/fitNodes";
import type { HistorySnap } from "../lib/pageTypes";
export type UseTopologyCanvasOptions = {
@ -62,6 +63,10 @@ export type UseTopologyCanvasOptions = {
redoRef: React.MutableRefObject<HistorySnap[]>;
pendingEdgeDeletesRef: React.MutableRefObject<Set<string>>;
pendingEdgeCreatesRef: React.MutableRefObject<Set<string>>;
pendingEdgePortEditsRef?: React.MutableRefObject<Map<string, { a_port: string; b_port: string }>>;
pendingEdgeStyleRef?: React.MutableRefObject<
Map<string, { stroke_color: string; stroke_width: number; line_style: string }>
>;
clearDirty: () => void;
bumpHistory: () => void;
showError: (msg: string) => void;
@ -102,6 +107,8 @@ export function useTopologyCanvas(opts: UseTopologyCanvasOptions) {
redoRef,
pendingEdgeDeletesRef,
pendingEdgeCreatesRef,
pendingEdgePortEditsRef,
pendingEdgeStyleRef,
clearDirty,
bumpHistory,
showError,
@ -288,7 +295,7 @@ export function useTopologyCanvas(opts: UseTopologyCanvasOptions) {
? worldDisplayBounds(worldTransformRef.current || graphQuery.data?.world_transform)
: null;
if (bounds) inst.fitBounds(bounds, { padding: 0.12, duration: 0 });
else inst.fitView({ ...FIT_VIEW_OPTS });
else fitToNodes(inst, nodesRef.current, { padding: FIT_VIEW_OPTS.padding });
setCanvasZoom(inst.getZoom());
}, [isWorldFlatCanvas, graphQuery.data?.world_transform]);
@ -345,14 +352,17 @@ export function useTopologyCanvas(opts: UseTopologyCanvasOptions) {
setEdges(rfEdges);
appliedGraphFpRef.current = fp;
appliedOverlayFpRef.current = overlayFingerprint(graphQuery.data);
// Full server overwrite (enter map or clean live sync): drop local edit queues/undo.
historyRef.current = [];
redoRef.current = [];
pendingEdgeDeletesRef.current = new Set();
pendingEdgeCreatesRef.current = new Set();
pendingEdgePortEditsRef?.current.clear();
pendingEdgeStyleRef?.current.clear();
clearDirty();
bumpHistory();
if (entering) {
appliedMapIdRef.current = mapId;
historyRef.current = [];
redoRef.current = [];
pendingEdgeDeletesRef.current = new Set();
pendingEdgeCreatesRef.current = new Set();
clearDirty();
bumpHistory();
const hasScatter = (graphQuery.data.scatter?.length || 0) > 0;
const hasWorld = Boolean(worldDisplayBounds(graphQuery.data.world_transform));
pendingFitRef.current = nextNodes.length > 0 || hasScatter || hasWorld;
@ -373,6 +383,8 @@ export function useTopologyCanvas(opts: UseTopologyCanvasOptions) {
redoRef,
pendingEdgeDeletesRef,
pendingEdgeCreatesRef,
pendingEdgePortEditsRef,
pendingEdgeStyleRef,
]);
useEffect(() => {
@ -394,7 +406,7 @@ export function useTopologyCanvas(opts: UseTopologyCanvasOptions) {
if (bounds) {
inst.fitBounds(bounds, { padding: 0.12, duration: 0 });
} else {
inst.fitView({ ...FIT_VIEW_OPTS, duration: 0 });
fitToNodes(inst, nodesRef.current, { padding: FIT_VIEW_OPTS.padding });
}
setCanvasZoom(inst.getZoom());
};
@ -509,12 +521,9 @@ export function useTopologyCanvas(opts: UseTopologyCanvasOptions) {
}
window.setTimeout(() => {
rfRef.current?.fitView({
nodes: [{ id: nodeId }],
padding: 0.45,
duration: 280,
includeHiddenNodes: true,
});
const inst = rfRef.current;
const target = nodesRef.current.filter((n) => n.id === nodeId);
if (inst) fitToNodes(inst, target, { padding: 0.45, duration: 280, minWidth: 600, minHeight: 360 });
}, 30);
},
[focusNode, focusWorldAround, showError, t, setSearchHitIds, searchHitTimerRef],

View file

@ -190,7 +190,6 @@ export function useTopologyCreateNe({
fabric_node_id: added.fabric_node_id,
x: at.flowX,
y: at.flowY,
label: added.label || added.name || item.name || "",
},
]);
graph = await fetchTopologyGraph(mapId);

View file

@ -47,6 +47,8 @@ export type UseTopologyDiscoverOptions = {
appliedMapIdRef: React.MutableRefObject<string>;
markDirty: () => void;
clearDirty: () => void;
/** Prefer atomic /commit when provided (T6). */
commitCanvas?: () => Promise<unknown>;
showOk: (msg: string) => void;
showError: (msg: string) => void;
t: (key: string) => string;
@ -72,6 +74,7 @@ export function useTopologyDiscover(opts: UseTopologyDiscoverOptions) {
appliedMapIdRef,
markDirty,
clearDirty,
commitCanvas,
showOk,
showError,
t,
@ -95,6 +98,10 @@ export function useTopologyDiscover(opts: UseTopologyDiscoverOptions) {
const flushDirtyCanvas = useCallback(async () => {
if (!mapId || !dirtyRef.current) return;
if (commitCanvas) {
await commitCanvas();
return;
}
const createIds = [...pendingEdgeCreatesRef.current];
for (const id of createIds) {
const e = edges.find((x) => x.id === id);
@ -136,6 +143,7 @@ export function useTopologyDiscover(opts: UseTopologyDiscoverOptions) {
pendingEdgeCreatesRef,
pendingEdgeDeletesRef,
clearDirty,
commitCanvas,
]);
const runDiscover = useCallback(

View file

@ -0,0 +1,362 @@
import { useCallback, useMemo } from "react";
import type { Connection, Edge, Node } from "@xyflow/react";
import type { NeNodeData } from "../components/TopologyReactFlowView";
import {
type EdgeDefaults,
type EdgeStyleData,
withEdgeVisual,
} from "../lib/edgeStyle";
import { isLocalPendingEdgeId, newLocalEdgeId } from "../lib/idUtils";
import {
formatPortPairLabel,
isAggregateEdgeId,
physicalIdsForDisplayEdge,
} from "../lib/linkDisplay";
type PortEdit = { a_port: string; b_port: string };
type StyleEdit = { stroke_color: string; stroke_width: number; line_style: string };
type Args = {
canWriteTopology: boolean;
mapId: string;
nodes: Node<NeNodeData>[];
edges: Edge[];
displayEdges: Edge[];
selectedEdgeId: string | null;
selectedEdge: Edge | null;
edgeDefaults: EdgeDefaults;
setNodes: React.Dispatch<React.SetStateAction<Node<NeNodeData>[]>>;
setEdges: React.Dispatch<React.SetStateAction<Edge[]>>;
setSelectedEdgeId: React.Dispatch<React.SetStateAction<string | null>>;
connectClickRef: React.MutableRefObject<string | null>;
pendingEdgeCreatesRef: React.MutableRefObject<Set<string>>;
pendingEdgeDeletesRef: React.MutableRefObject<Set<string>>;
pendingEdgePortEditsRef: React.MutableRefObject<Map<string, PortEdit>>;
pendingEdgeStyleRef: React.MutableRefObject<Map<string, StyleEdit>>;
pushHistory: () => void;
markDirty: () => void;
closeCtxMenu: () => void;
showOk: (msg: string) => void;
t: (key: string) => string;
};
export function useTopologyEdgeEditing({
canWriteTopology,
mapId,
nodes,
edges,
displayEdges,
selectedEdgeId,
selectedEdge,
edgeDefaults,
setNodes,
setEdges,
setSelectedEdgeId,
connectClickRef,
pendingEdgeCreatesRef,
pendingEdgeDeletesRef,
pendingEdgePortEditsRef,
pendingEdgeStyleRef,
pushHistory,
markDirty,
closeCtxMenu,
showOk,
t,
}: Args) {
const isValidConnection = useCallback(
(connection: Connection | Edge) => {
const source = String(connection.source || "");
const target = String(connection.target || "");
if (!source || !target || source === target) return false;
return !edges.some(
(e) =>
(e.source === source && e.target === target) ||
(e.source === target && e.target === source),
);
},
[edges],
);
const onConnect = useCallback(
(connection: Connection) => {
if (!canWriteTopology || !mapId || !isValidConnection(connection)) return;
const source = String(connection.source || "");
const target = String(connection.target || "");
if (!source || !target) return;
pushHistory();
connectClickRef.current = null;
const id = newLocalEdgeId();
const data: EdgeStyleData = {
source: "manual",
source_port: "",
target_port: "",
stroke_color: "",
stroke_width: 0,
line_style: "",
discovered_at: null,
};
const edge = withEdgeVisual(
{
id,
source,
target,
// Center anchors only — omit handle ids so attach matches discovered edges after Save.
sourceHandle: null,
targetHandle: null,
type: "straight",
animated: false,
data,
},
edgeDefaults,
);
pendingEdgeCreatesRef.current.add(id);
setEdges((es) => [...es, edge]);
markDirty();
},
[
mapId,
isValidConnection,
pushHistory,
edgeDefaults,
setEdges,
markDirty,
canWriteTopology,
connectClickRef,
pendingEdgeCreatesRef,
],
);
const patchSelectedEdgeStyle = useCallback(
(
patch: Partial<
Pick<EdgeStyleData, "stroke_color" | "stroke_width" | "line_style" | "source_port" | "target_port">
>,
opts?: { skipHistory?: boolean },
) => {
if (!selectedEdgeId || !mapId) return;
const display = displayEdges.find((e) => e.id === selectedEdgeId) || selectedEdge;
const targetIds = physicalIdsForDisplayEdge(display, edges);
if (!targetIds.length) return;
// Port edits only make sense on a single physical manual link.
if (
(isAggregateEdgeId(selectedEdgeId) || targetIds.length > 1) &&
(patch.source_port !== undefined || patch.target_port !== undefined)
) {
return;
}
if (!opts?.skipHistory) pushHistory();
setEdges((eds) =>
eds.map((e) => {
if (!targetIds.includes(e.id)) return e;
const prev = (e.data || {}) as EdgeStyleData;
const data: EdgeStyleData = {
...prev,
stroke_color: patch.stroke_color !== undefined ? patch.stroke_color : prev.stroke_color || "",
stroke_width:
patch.stroke_width !== undefined ? Number(patch.stroke_width || 0) : Number(prev.stroke_width || 0),
line_style: patch.line_style !== undefined ? patch.line_style : prev.line_style || "",
source_port: patch.source_port !== undefined ? patch.source_port : prev.source_port || "",
target_port: patch.target_port !== undefined ? patch.target_port : prev.target_port || "",
};
const portLabel = formatPortPairLabel(
data.source_port || "",
data.target_port || "",
data.display_label,
);
return withEdgeVisual({ ...e, data, label: portLabel || undefined }, edgeDefaults);
}),
);
markDirty();
for (const fabricEdgeId of targetIds) {
const edge = edges.find((e) => e.id === fabricEdgeId);
const prev = (edge?.data || {}) as EdgeStyleData;
const nextStyle = {
stroke_color: patch.stroke_color !== undefined ? patch.stroke_color : prev.stroke_color || "",
stroke_width:
patch.stroke_width !== undefined ? Number(patch.stroke_width || 0) : Number(prev.stroke_width || 0),
line_style: patch.line_style !== undefined ? patch.line_style : prev.line_style || "",
};
if (
patch.stroke_color !== undefined ||
patch.stroke_width !== undefined ||
patch.line_style !== undefined
) {
pendingEdgeStyleRef.current.set(fabricEdgeId, nextStyle);
}
if (patch.source_port !== undefined || patch.target_port !== undefined) {
const src = String(prev.source || "").toLowerCase();
// New local manual edges are saved via edge_creates; only queue port edits for saved edges.
if (src === "manual" && !pendingEdgeCreatesRef.current.has(fabricEdgeId) && !isLocalPendingEdgeId(fabricEdgeId)) {
pendingEdgePortEditsRef.current.set(fabricEdgeId, {
a_port: patch.source_port !== undefined ? patch.source_port : prev.source_port || "",
b_port: patch.target_port !== undefined ? patch.target_port : prev.target_port || "",
});
}
}
}
},
[
selectedEdgeId,
selectedEdge,
mapId,
edges,
displayEdges,
setEdges,
pushHistory,
edgeDefaults,
markDirty,
pendingEdgeStyleRef,
pendingEdgePortEditsRef,
pendingEdgeCreatesRef,
],
);
const queueDeleteEdges = useCallback(
(
edgeIds: string[],
opts?: { confirmKey?: string; okKey?: string; skipConfirm?: boolean },
) => {
if (!edgeIds.length) return false;
if (!opts?.skipConfirm) {
const confirmMsg = t(opts?.confirmKey || "topology.deleteEdgeConfirm");
if (!window.confirm(confirmMsg)) return false;
}
pushHistory();
for (const id of edgeIds) {
if (pendingEdgeCreatesRef.current.has(id) || isLocalPendingEdgeId(id)) {
pendingEdgeCreatesRef.current.delete(id);
} else {
pendingEdgeDeletesRef.current.add(id);
}
}
const idSet = new Set(edgeIds);
setEdges((es) => es.filter((e) => !idSet.has(e.id)));
setSelectedEdgeId((cur) => (cur && idSet.has(cur) ? null : cur));
markDirty();
showOk(
t(opts?.okKey || "topology.edgeDeleted").replace("{{count}}", String(edgeIds.length)),
);
return true;
},
[pushHistory, setEdges, setSelectedEdgeId, markDirty, showOk, t, pendingEdgeCreatesRef, pendingEdgeDeletesRef],
);
const removeSelected = useCallback(() => {
if (!mapId) return;
const nodeIds = nodes.filter((n) => n.selected).map((n) => n.id);
const selectedDisplay = displayEdges.filter((e) => e.selected);
const edgeIds = new Set<string>();
for (const de of selectedDisplay) {
for (const id of physicalIdsForDisplayEdge(de, edges)) edgeIds.add(id);
}
for (const e of edges) {
if (e.selected) edgeIds.add(e.id);
}
if (!nodeIds.length && !edgeIds.size) return;
if (nodeIds.length && edgeIds.size) {
const msg = t("topology.deleteSelectionConfirm")
.replace("{{nodes}}", String(nodeIds.length))
.replace("{{edges}}", String(edgeIds.size));
if (!window.confirm(msg)) return;
pushHistory();
const nodeSet = new Set(nodeIds);
for (const id of edgeIds) {
if (pendingEdgeCreatesRef.current.has(id) || isLocalPendingEdgeId(id)) {
pendingEdgeCreatesRef.current.delete(id);
} else {
pendingEdgeDeletesRef.current.add(id);
}
}
setNodes((ns) => ns.filter((n) => !nodeSet.has(n.id)));
setEdges((es) =>
es.filter(
(e) =>
!edgeIds.has(e.id) && !nodeSet.has(e.source) && !nodeSet.has(e.target),
),
);
setSelectedEdgeId(null);
markDirty();
showOk(
t("topology.selectionDeleted")
.replace("{{nodes}}", String(nodeIds.length))
.replace("{{edges}}", String(edgeIds.size)),
);
return;
}
if (!nodeIds.length && edgeIds.size) {
queueDeleteEdges([...edgeIds]);
return;
}
pushHistory();
const nodeSet = new Set(nodeIds);
setNodes((ns) => ns.filter((n) => !nodeSet.has(n.id)));
// Drop incident edges from the canvas only — do not queue Fabric deletes.
setEdges((es) => {
const next = es.filter((e) => !nodeSet.has(e.source) && !nodeSet.has(e.target));
for (const e of es) {
if (next.some((x) => x.id === e.id)) continue;
pendingEdgeCreatesRef.current.delete(e.id);
}
return next;
});
setSelectedEdgeId(null);
markDirty();
}, [
mapId,
nodes,
edges,
displayEdges,
setNodes,
setEdges,
setSelectedEdgeId,
pushHistory,
markDirty,
showOk,
queueDeleteEdges,
t,
pendingEdgeCreatesRef,
pendingEdgeDeletesRef,
]);
const removeEdgeById = useCallback(
(edgeId: string) => {
const display = displayEdges.find((e) => e.id === edgeId);
const ids = physicalIdsForDisplayEdge(display, edges);
const list = ids.length ? ids : [edgeId];
queueDeleteEdges(list);
closeCtxMenu();
},
[displayEdges, edges, queueDeleteEdges, closeCtxMenu],
);
const staleEdgeIds = useMemo(() => {
return edges
.filter((e) => {
const src = String((e.data as EdgeStyleData | undefined)?.source || "");
return src === "stale";
})
.map((e) => e.id);
}, [edges]);
const removeStaleEdges = useCallback(() => {
if (!staleEdgeIds.length) return;
queueDeleteEdges(staleEdgeIds, {
confirmKey: "topology.removeStaleHint",
okKey: "topology.staleRemoved",
});
}, [staleEdgeIds, queueDeleteEdges]);
return {
isValidConnection,
onConnect,
patchSelectedEdgeStyle,
queueDeleteEdges,
removeSelected,
removeEdgeById,
staleEdgeIds,
removeStaleEdges,
};
}

View file

@ -0,0 +1,124 @@
import { useCallback, useMemo, useState } from "react";
import type { Edge, Node } from "@xyflow/react";
import type { TopologyTreeFolderItem, TopologyTreeViewItem, TopologyViewGraph, TopologyViewItem } from "../../../types";
import type { NeNodeData } from "../components/TopologyReactFlowView";
import type { LabelColors, VendorColors } from "../lib/displayPrefs";
import { buildExportGraph, exportTopologySvg, exportTopologyXml } from "../lib/exportTopology";
import { regionDisplayName } from "../lib/treeUtils";
type Args = {
canvasMode: boolean;
isWorldFlatCanvas: boolean;
activeLeafName: string;
activeRegion: TopologyTreeFolderItem | null;
activeView: TopologyTreeViewItem | null;
graphQueryData?: TopologyViewGraph;
nodes: Node<NeNodeData>[];
edges: Edge[];
displayEdges: Edge[];
labelColors: LabelColors;
vendorColors: VendorColors;
hideIp: boolean;
hideVendor: boolean;
showOk: (msg: string) => void;
showError: (msg: string) => void;
t: (key: string) => string;
};
export function useTopologyExport({
canvasMode,
isWorldFlatCanvas,
activeLeafName,
activeRegion,
activeView,
graphQueryData,
nodes,
edges,
displayEdges,
labelColors,
vendorColors,
hideIp,
hideVendor,
showOk,
showError,
t,
}: Args) {
const [exporting, setExporting] = useState(false);
const runTopologyExport = useCallback(
async (format: "svg" | "xml") => {
const view: TopologyViewItem | undefined =
graphQueryData?.view ??
(activeView
? {
id: activeView.id,
name: activeView.name,
remark: "",
node_count: activeView.node_count,
kind: activeView.kind,
role: activeView.role,
sort_order: activeView.sort_order,
}
: undefined);
if (!view) {
showError(t("topology.selectMap"));
return;
}
if (format !== "xml" && nodes.length === 0 && !isWorldFlatCanvas) {
showError(t("topology.exportEmpty"));
return;
}
setExporting(true);
const filenameBase = activeLeafName || view.name || "topology";
try {
if (format === "svg") {
await exportTopologySvg(
nodes,
displayEdges,
filenameBase,
{
labelColor: labelColors.name,
edgeLabelColor: labelColors.edgeLabel,
vendorColors,
hideIp,
hideVendor,
},
filenameBase,
);
showOk(t("topology.exportedSvg"));
} else {
exportTopologyXml(buildExportGraph(view, nodes, edges, graphQueryData), filenameBase);
showOk(t("topology.exportedXml"));
}
} catch (err) {
showError(t("topology.exportFailed").replace("{{detail}}", String(err)));
} finally {
setExporting(false);
}
},
[
activeView,
graphQueryData,
nodes,
edges,
displayEdges,
isWorldFlatCanvas,
activeLeafName,
labelColors,
vendorColors,
hideIp,
hideVendor,
showError,
showOk,
t,
],
);
const titleText = useMemo(() => {
if (canvasMode) return activeLeafName || t("topology.selectMap");
if (activeRegion) return regionDisplayName(activeRegion, t);
return t("topology.rootName");
}, [canvasMode, activeLeafName, activeRegion, t]);
return { exporting, runTopologyExport, titleText };
}

View file

@ -0,0 +1,269 @@
import { useCallback, useEffect, useMemo, useRef } from "react";
import type { Edge, Node, ReactFlowInstance } from "@xyflow/react";
import type { FabricNodeSearchHit, TopologyTreeFolderItem } from "../../../types";
import type { NeNodeData } from "../components/TopologyReactFlowView";
import { WORLD_MAP_ENABLED } from "../lib/constants";
import { nodeMatchesQuery } from "../lib/searchUtils";
import { findViewInRegion, isWorldFlatViewName } from "../lib/treeUtils";
type Args = {
mapId: string;
canvasMode: boolean;
nodes: Node<NeNodeData>[];
regions: TopologyTreeFolderItem[];
pendingHighlightNe: string;
graphQueryData: unknown;
canvasQuery: string;
setCanvasQuery: React.Dispatch<React.SetStateAction<string>>;
setSearchHitIds: React.Dispatch<React.SetStateAction<string[]>>;
findOpen: boolean;
setFindOpen: React.Dispatch<React.SetStateAction<boolean>>;
findActiveIdx: number;
setFindActiveIdx: React.Dispatch<React.SetStateAction<number>>;
findBoxRef: React.MutableRefObject<HTMLDivElement | null>;
displayMenuRef: React.MutableRefObject<HTMLDetailsElement | null>;
isWorldFlatCanvasRef: React.MutableRefObject<boolean>;
rfRef: React.MutableRefObject<ReactFlowInstance<Node<NeNodeData>, Edge> | null>;
locateNode: (id: string, world?: { worldX?: number; worldY?: number }) => void;
confirmDiscardIfDirty: () => boolean;
setMapId: (id: string) => void;
setSelectedFolderId: (id: string) => void;
setExpandedIds: React.Dispatch<React.SetStateAction<Record<string, boolean>>>;
setPendingHighlightNe: (id: string) => void;
setTreeNeQuery: (q: string) => void;
setDebouncedTreeNeQuery: (q: string) => void;
setTreeSearchOpen: (open: boolean) => void;
showError: (msg: string) => void;
t: (key: string) => string;
};
export function useTopologyFind({
mapId,
canvasMode,
nodes,
regions,
pendingHighlightNe,
graphQueryData,
canvasQuery,
setCanvasQuery,
setSearchHitIds,
setFindOpen,
findActiveIdx,
setFindActiveIdx,
findBoxRef,
displayMenuRef,
isWorldFlatCanvasRef,
rfRef,
locateNode,
confirmDiscardIfDirty,
setMapId,
setSelectedFolderId,
setExpandedIds,
setPendingHighlightNe,
setTreeNeQuery,
setDebouncedTreeNeQuery,
setTreeSearchOpen,
showError,
t,
}: Args) {
const findJustLocatedRef = useRef(false);
const pendingLocateWorldRef = useRef<{
id: string;
worldX?: number;
worldY?: number;
} | null>(null);
const jumpToTreeSearchHit = useCallback(
(
hit: FabricNodeSearchHit,
view?: NonNullable<FabricNodeSearchHit["views"]>[number],
) => {
const views = hit.views || [];
const worldCoords =
hit.world_x != null && hit.world_y != null
? { worldX: Number(hit.world_x), worldY: Number(hit.world_y) }
: undefined;
const onCurrent =
Boolean(mapId) &&
(views.some((v) => v.view_id === mapId) ||
nodes.some((n) => n.id === hit.id) ||
(isWorldFlatCanvasRef.current && worldCoords != null));
if (onCurrent && mapId && (!view || view.view_id === mapId)) {
if (isWorldFlatCanvasRef.current) {
locateNode(hit.id, worldCoords);
} else if (nodes.some((n) => n.id === hit.id)) {
locateNode(hit.id);
} else {
setPendingHighlightNe(hit.id);
}
setTreeNeQuery("");
setDebouncedTreeNeQuery("");
setTreeSearchOpen(false);
return;
}
const target =
view ||
(WORLD_MAP_ENABLED
? views.find((v) => isWorldFlatViewName(v.view_name))
: undefined) ||
views.find((v) => !isWorldFlatViewName(v.view_name) && String(v.kind) === "physical") ||
views.find((v) => !isWorldFlatViewName(v.view_name)) ||
views[0] ||
null;
if (!target) {
showError(t("topology.treeSearchNotOnMap"));
return;
}
if (!WORLD_MAP_ENABLED && isWorldFlatViewName(target.view_name)) {
showError(t("topology.worldMapOffline"));
return;
}
if (!confirmDiscardIfDirty()) return;
const regionId = target.folder_id || findViewInRegion(regions, target.view_id)?.region.id;
if (regionId) {
setSelectedFolderId(regionId);
setExpandedIds((p) => ({ ...p, [regionId]: true }));
}
if (WORLD_MAP_ENABLED && isWorldFlatViewName(target.view_name) && worldCoords) {
pendingLocateWorldRef.current = { id: hit.id, ...worldCoords };
} else {
pendingLocateWorldRef.current = null;
}
setMapId(target.view_id);
setPendingHighlightNe(hit.id);
setTreeNeQuery("");
setDebouncedTreeNeQuery("");
setTreeSearchOpen(false);
},
[
mapId,
nodes,
locateNode,
showError,
t,
confirmDiscardIfDirty,
regions,
setSelectedFolderId,
setMapId,
setPendingHighlightNe,
setTreeNeQuery,
setDebouncedTreeNeQuery,
setTreeSearchOpen,
setExpandedIds,
isWorldFlatCanvasRef,
],
);
useEffect(() => {
if (!pendingHighlightNe || !canvasMode) return;
const nodeId = pendingHighlightNe;
const pendingWorld = pendingLocateWorldRef.current;
if (
pendingWorld &&
pendingWorld.id === nodeId &&
isWorldFlatCanvasRef.current &&
rfRef.current
) {
pendingLocateWorldRef.current = null;
setPendingHighlightNe("");
window.setTimeout(
() =>
locateNode(nodeId, {
worldX: pendingWorld.worldX,
worldY: pendingWorld.worldY,
}),
80,
);
return;
}
if (!nodes.length || !nodes.some((n) => n.id === nodeId)) return;
pendingLocateWorldRef.current = null;
setPendingHighlightNe("");
window.setTimeout(() => locateNode(nodeId), 80);
}, [
pendingHighlightNe,
canvasMode,
nodes,
locateNode,
mapId,
graphQueryData,
isWorldFlatCanvasRef,
rfRef,
setPendingHighlightNe,
]);
const canvasHits = useMemo(() => {
const q = canvasQuery.trim();
if (!q) return [];
return nodes.filter((n) => nodeMatchesQuery(n, q));
}, [canvasQuery, nodes]);
useEffect(() => {
const q = canvasQuery.trim();
if (!q) {
setFindActiveIdx(0);
setFindOpen(false);
if (findJustLocatedRef.current) {
findJustLocatedRef.current = false;
return;
}
setSearchHitIds([]);
return;
}
setFindOpen(true);
setSearchHitIds(canvasHits.map((n) => n.id));
setFindActiveIdx((idx) => (canvasHits.length ? Math.min(idx, canvasHits.length - 1) : 0));
}, [canvasQuery, canvasHits, setFindActiveIdx, setFindOpen, setSearchHitIds]);
const findOnCanvas = useCallback(
(nodeId?: string) => {
const q = canvasQuery.trim();
if (!q) return;
if (!canvasHits.length) {
showError(t("topology.findNoMatch"));
return;
}
const id =
nodeId ||
canvasHits[Math.max(0, Math.min(findActiveIdx, canvasHits.length - 1))]?.id ||
canvasHits[0].id;
findJustLocatedRef.current = true;
setFindOpen(false);
setCanvasQuery("");
setFindActiveIdx(0);
locateNode(id);
},
[
canvasQuery,
canvasHits,
findActiveIdx,
locateNode,
showError,
t,
setFindOpen,
setCanvasQuery,
setFindActiveIdx,
],
);
useEffect(() => {
const onDoc = (e: MouseEvent) => {
const target = e.target as HTMLElement | null;
if (findBoxRef.current && target && !findBoxRef.current.contains(target)) {
setFindOpen(false);
}
const details = displayMenuRef.current;
if (details?.open && target && !details.contains(target)) {
details.open = false;
}
};
document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, [displayMenuRef, findBoxRef, setFindOpen]);
return {
canvasHits,
jumpToTreeSearchHit,
findOnCanvas,
};
}

View file

@ -19,6 +19,12 @@ export function useTopologyHistory(
const historyLockRef = useRef(false);
const pendingEdgeDeletesRef = useRef<Set<string>>(new Set());
const pendingEdgeCreatesRef = useRef<Set<string>>(new Set());
const pendingEdgePortEditsRef = useRef<Map<string, { a_port: string; b_port: string }>>(
new Map(),
);
const pendingEdgeStyleRef = useRef<
Map<string, { stroke_color: string; stroke_width: number; line_style: string }>
>(new Map());
const canUndo = historyTick >= 0 && historyRef.current.length > 0;
const canRedo = historyTick >= 0 && redoRef.current.length > 0;
@ -48,6 +54,8 @@ export function useTopologyHistory(
edges: edges.map((e) => ({ ...e })),
pendingEdgeDeletes: [...pendingEdgeDeletesRef.current],
pendingEdgeCreates: [...pendingEdgeCreatesRef.current],
pendingEdgePortEdits: [...pendingEdgePortEditsRef.current.entries()],
pendingEdgeStyles: [...pendingEdgeStyleRef.current.entries()],
}),
[nodes, edges],
);
@ -59,6 +67,14 @@ export function useTopologyHistory(
bumpHistory();
}, [snapshot, bumpHistory]);
/** Drop the latest undo frame without applying it (e.g. click that never moved). */
const popHistory = useCallback(() => {
if (historyLockRef.current) return;
if (!historyRef.current.length) return;
historyRef.current.pop();
bumpHistory();
}, [bumpHistory]);
const undo = useCallback(() => {
const prev = historyRef.current.pop();
if (!prev) return;
@ -68,6 +84,8 @@ export function useTopologyHistory(
setEdges(prev.edges);
pendingEdgeDeletesRef.current = new Set(prev.pendingEdgeDeletes);
pendingEdgeCreatesRef.current = new Set(prev.pendingEdgeCreates || []);
pendingEdgePortEditsRef.current = new Map(prev.pendingEdgePortEdits || []);
pendingEdgeStyleRef.current = new Map(prev.pendingEdgeStyles || []);
markDirty();
bumpHistory();
historyLockRef.current = false;
@ -82,6 +100,8 @@ export function useTopologyHistory(
setEdges(next.edges);
pendingEdgeDeletesRef.current = new Set(next.pendingEdgeDeletes);
pendingEdgeCreatesRef.current = new Set(next.pendingEdgeCreates || []);
pendingEdgePortEditsRef.current = new Map(next.pendingEdgePortEdits || []);
pendingEdgeStyleRef.current = new Map(next.pendingEdgeStyles || []);
markDirty();
bumpHistory();
historyLockRef.current = false;
@ -97,11 +117,14 @@ export function useTopologyHistory(
historyLockRef,
pendingEdgeDeletesRef,
pendingEdgeCreatesRef,
pendingEdgePortEditsRef,
pendingEdgeStyleRef,
markDirty,
clearDirty,
bumpHistory,
confirmDiscardIfDirty,
pushHistory,
popHistory,
undo,
redo,
};

View file

@ -0,0 +1,133 @@
import { useEffect, useRef } from "react";
import type { Edge, Node } from "@xyflow/react";
import type { UseMutationResult } from "@tanstack/react-query";
import type { NeNodeData } from "../components/TopologyReactFlowView";
import { toolModeFromKey, type ToolMode } from "../lib/toolMode";
type Args = {
canWriteTopology: boolean;
mapId: string;
nodes: Node<NeNodeData>[];
edges: Edge[];
dirtyRef: React.MutableRefObject<boolean>;
connectClickRef: React.MutableRefObject<string | null>;
displayMenuRef: React.MutableRefObject<HTMLDetailsElement | null>;
saveMut: Pick<UseMutationResult<unknown, unknown, void, unknown>, "isPending" | "mutate">;
closeCtxMenu: () => void;
clearSelection: () => void;
setToolMode: React.Dispatch<React.SetStateAction<ToolMode>>;
setFindOpen: React.Dispatch<React.SetStateAction<boolean>>;
undo: () => void;
redo: () => void;
selectAllNodes: () => void;
removeSelected: () => void;
};
/**
* Global topology shortcuts. Handlers are read via refs so the window
* listener is not rebound on every nodes/edges change.
*/
export function useTopologyKeyboard({
canWriteTopology,
mapId,
nodes,
edges,
dirtyRef,
connectClickRef,
displayMenuRef,
saveMut,
closeCtxMenu,
clearSelection,
setToolMode,
setFindOpen,
undo,
redo,
selectAllNodes,
removeSelected,
}: Args) {
const latestRef = useRef({
canWriteTopology,
mapId,
nodes,
edges,
saveMut,
closeCtxMenu,
clearSelection,
setToolMode,
setFindOpen,
undo,
redo,
selectAllNodes,
removeSelected,
});
latestRef.current = {
canWriteTopology,
mapId,
nodes,
edges,
saveMut,
closeCtxMenu,
clearSelection,
setToolMode,
setFindOpen,
undo,
redo,
selectAllNodes,
removeSelected,
};
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const tag = (e.target as HTMLElement | null)?.tagName?.toLowerCase();
if (tag === "input" || tag === "textarea" || tag === "select" || (e.target as HTMLElement)?.isContentEditable) {
return;
}
const s = latestRef.current;
if (e.key === "Escape") {
s.closeCtxMenu();
s.clearSelection();
connectClickRef.current = null;
s.setToolMode((m) => (m === "connect" ? "select" : m));
if (displayMenuRef.current?.open) displayMenuRef.current.open = false;
s.setFindOpen(false);
return;
}
const mode = toolModeFromKey(e.key);
if (mode && !e.ctrlKey && !e.metaKey && !e.altKey) {
e.preventDefault();
s.setToolMode(mode);
connectClickRef.current = null;
return;
}
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "s") {
e.preventDefault();
if (s.canWriteTopology && s.mapId && dirtyRef.current && !s.saveMut.isPending) s.saveMut.mutate();
return;
}
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "z" && !e.shiftKey) {
e.preventDefault();
s.undo();
return;
}
if ((e.ctrlKey || e.metaKey) && (e.key.toLowerCase() === "y" || (e.key.toLowerCase() === "z" && e.shiftKey))) {
e.preventDefault();
s.redo();
return;
}
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "a") {
e.preventDefault();
s.selectAllNodes();
return;
}
if (e.key === "Delete" || e.key === "Backspace") {
if (!s.canWriteTopology) return;
if (s.nodes.some((n) => n.selected) || s.edges.some((ed) => ed.selected)) {
e.preventDefault();
void s.removeSelected();
}
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [connectClickRef, displayMenuRef, dirtyRef]);
}

View file

@ -0,0 +1,149 @@
import { useCallback } from "react";
import { useQueryClient } from "@tanstack/react-query";
import type { Edge, Node } from "@xyflow/react";
import { queryKeys } from "../../../constants/queryKeys";
import { fetchTopologyGraph, purgePlaceholderFabricNodes } from "../../../services/api";
import type { TopologyViewGraph } from "../../../types";
import type { NeNodeData } from "../components/TopologyReactFlowView";
import { isPlaceholderSource } from "../lib/searchUtils";
type Args = {
mapId: string;
nodes: Node<NeNodeData>[];
selectedNode: Node<NeNodeData> | null;
setNodes: React.Dispatch<React.SetStateAction<Node<NeNodeData>[]>>;
setEdges: React.Dispatch<React.SetStateAction<Edge[]>>;
pendingEdgeCreatesRef: React.MutableRefObject<Set<string>>;
dirtyRef: React.MutableRefObject<boolean>;
appliedMapIdRef: React.MutableRefObject<string>;
pushHistory: () => void;
markDirty: () => void;
bumpHistory: () => void;
closeCtxMenu: () => void;
commitCanvas: () => Promise<unknown>;
resetLocalFromServer: (graph: TopologyViewGraph) => void;
showOk: (msg: string) => void;
showError: (msg: string) => void;
t: (key: string) => string;
};
export function useTopologyNodeOps({
mapId,
nodes,
selectedNode,
setNodes,
setEdges,
pendingEdgeCreatesRef,
dirtyRef,
appliedMapIdRef,
pushHistory,
markDirty,
bumpHistory,
closeCtxMenu,
commitCanvas,
resetLocalFromServer,
showOk,
showError,
t,
}: Args) {
const queryClient = useQueryClient();
const renameSelectedNode = useCallback(() => {
if (!selectedNode) return;
const currentOverride = String(selectedNode.data.label_override ?? "");
const next = window.prompt(
`${t("topology.renameNodePrompt")}\n${t("topology.renameNodeClearHint")}`,
currentOverride || selectedNode.data.label || "",
);
if (next == null) return;
const override = next.trim();
const prevOverride = currentOverride;
const display =
override ||
selectedNode.data.name ||
selectedNode.data.ne_ip ||
selectedNode.id;
if (override === prevOverride && display === (selectedNode.data.label || "")) {
closeCtxMenu();
return;
}
pushHistory();
markDirty();
setNodes((ns) =>
ns.map((n) =>
n.id === selectedNode.id
? {
...n,
data: {
...n.data,
label: display,
label_override: override,
},
}
: n,
),
);
closeCtxMenu();
}, [selectedNode, t, pushHistory, markDirty, setNodes, closeCtxMenu]);
const removeNodeById = useCallback(
(nodeId: string) => {
if (!mapId) return;
pushHistory();
closeCtxMenu();
setNodes((ns) => ns.filter((n) => n.id !== nodeId));
setEdges((es) => {
const next = es.filter((e) => e.source !== nodeId && e.target !== nodeId);
for (const e of es) {
if (next.some((x) => x.id === e.id)) continue;
pendingEdgeCreatesRef.current.delete(e.id);
}
return next;
});
markDirty();
},
[mapId, pushHistory, closeCtxMenu, setNodes, setEdges, pendingEdgeCreatesRef, markDirty],
);
const purgePlaceholderById = useCallback(
async (nodeId: string) => {
if (!mapId) return;
const node = nodes.find((n) => n.id === nodeId);
if (!node || !isPlaceholderSource(node.data.managed_source, node.data.ne_ip)) {
return;
}
if (!window.confirm(t("topology.deletePlaceholderConfirm"))) return;
closeCtxMenu();
try {
if (dirtyRef.current) {
await commitCanvas();
}
await purgePlaceholderFabricNodes([nodeId]);
const graph = await fetchTopologyGraph(mapId);
queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph);
appliedMapIdRef.current = mapId;
resetLocalFromServer(graph);
bumpHistory();
showOk(t("topology.deletePlaceholderDone"));
} catch (err) {
showError(String(err));
}
},
[
mapId,
nodes,
closeCtxMenu,
commitCanvas,
resetLocalFromServer,
queryClient,
bumpHistory,
showOk,
showError,
t,
dirtyRef,
appliedMapIdRef,
],
);
return { renameSelectedNode, removeNodeById, purgePlaceholderById };
}

View file

@ -0,0 +1,409 @@
import { useCallback, useMemo, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import type { Edge, Node, ReactFlowInstance } from "@xyflow/react";
import { queryKeys } from "../../../constants/queryKeys";
import {
addTopologyViewNodes,
fetchManagedNe,
fetchTopologyGraph,
fetchUmeNe,
patchTopologyPositions,
} from "../../../services/api";
import type { TopologyOutsidePeer } from "../../../types";
import type { NeNodeData } from "../components/TopologyReactFlowView";
import { PALETTE_DND, SEP } from "../lib/constants";
import type { PaletteItem, PaletteSource } from "../lib/pageTypes";
import { fuzzyIncludes } from "../lib/searchUtils";
import { isWorldFlatViewName } from "../lib/treeUtils";
type Args = {
canWriteTopology: boolean;
mapId: string;
activeViewName?: string | null;
nodes: Node<NeNodeData>[];
keyword: string;
debouncedKeyword: string;
addNeOpen: boolean;
paletteSource: PaletteSource;
outsidePeers: TopologyOutsidePeer[];
outsidePeerQuery: string;
dirtyRef: React.MutableRefObject<boolean>;
rfRef: React.MutableRefObject<ReactFlowInstance<Node<NeNodeData>, Edge> | null>;
commitCanvas: () => Promise<unknown>;
resetLocalFromServer: (graph: Awaited<ReturnType<typeof fetchTopologyGraph>>) => void;
showOk: (msg: string) => void;
showError: (msg: string) => void;
t: (key: string) => string;
setOutsidePeersOpen: React.Dispatch<React.SetStateAction<boolean>>;
setOutsidePeerSelectedIds: React.Dispatch<React.SetStateAction<string[]>>;
};
export function useTopologyPalette({
canWriteTopology,
mapId,
activeViewName,
nodes,
keyword,
debouncedKeyword,
addNeOpen,
paletteSource,
outsidePeers,
outsidePeerQuery,
dirtyRef,
rfRef,
commitCanvas,
resetLocalFromServer,
showOk,
showError,
t,
setOutsidePeersOpen,
setOutsidePeerSelectedIds,
}: Args) {
const queryClient = useQueryClient();
const [paletteAdding, setPaletteAdding] = useState(false);
const [outsidePeersAdding, setOutsidePeersAdding] = useState(false);
const neQuery = useQuery({
queryKey: [...queryKeys.managedNeAll, "topology-add-ne", debouncedKeyword],
queryFn: () =>
fetchManagedNe({
keyword: debouncedKeyword,
vendor: "",
connectStatus: "",
page: 1,
pageSize: 100,
}),
enabled: addNeOpen && paletteSource === "managed",
});
const umeQuery = useQuery({
queryKey: ["umeInventoryNe", "topology-add-ne", debouncedKeyword],
queryFn: () =>
fetchUmeNe({
keyword: debouncedKeyword,
page: 1,
pageSize: 100,
}),
enabled: addNeOpen && paletteSource === "ume",
});
const addPaletteItems = useCallback(
async (items: PaletteItem[], origin?: { x: number; y: number }): Promise<boolean> => {
if (!canWriteTopology) return false;
if (!mapId) {
showError(t("topology.selectMap"));
return false;
}
if (isWorldFlatViewName(activeViewName)) {
showError(t("topology.worldMapNoDirectNes"));
return false;
}
if (items.length === 0 || paletteAdding) return false;
const onCanvasManaged = new Set(nodes.map((n) => n.data.managed_ne_id).filter(Boolean));
const onCanvasUme = new Set(nodes.map((n) => n.data.ume_ne_id).filter(Boolean));
const managedIds = [
...new Set(
items
.filter((item) => item.source === "managed")
.map((item) => item.managed_ne_id)
.filter((id) => id && !onCanvasManaged.has(id)),
),
];
const umeIds = [
...new Set(
items
.filter((item) => item.source === "ume")
.map((item) => item.ume_ne_id)
.filter((id) => id && !onCanvasUme.has(id)),
),
];
if (managedIds.length === 0 && umeIds.length === 0) return false;
if (dirtyRef.current) {
if (!window.confirm(t("topology.unsavedSaveBeforeAdd"))) return false;
try {
await commitCanvas();
} catch (err) {
showError(String(err));
return false;
}
}
setPaletteAdding(true);
try {
const graph = await addTopologyViewNodes(mapId, {
managed_ne_ids: managedIds,
ume_ne_ids: umeIds,
layout: "grid",
});
const managedSet = new Set(managedIds);
const umeSet = new Set(umeIds);
const added = graph.nodes.filter(
(n) =>
(n.managed_ne_id && managedSet.has(n.managed_ne_id)) ||
(n.ume_ne_id && umeSet.has(n.ume_ne_id)),
);
if (added.length > 0) {
const start = origin || { x: 80 + nodes.length * 24, y: 80 + nodes.length * 24 };
const cols = Math.max(1, Math.ceil(Math.sqrt(added.length)));
await patchTopologyPositions(
mapId,
added.map((n, i) => ({
fabric_node_id: n.fabric_node_id,
x: start.x + (i % cols) * 180,
y: start.y + Math.floor(i / cols) * 120,
})),
);
}
const refreshed = await fetchTopologyGraph(mapId);
queryClient.setQueryData(queryKeys.topologyGraph(mapId), refreshed);
resetLocalFromServer(refreshed);
const count = added.length || managedIds.length + umeIds.length;
showOk(t("topology.addSelectedDone").replace("{{count}}", String(count)));
return count > 0;
} catch (err) {
showError(String(err));
return false;
} finally {
setPaletteAdding(false);
}
},
[
mapId,
nodes,
paletteAdding,
showError,
showOk,
t,
queryClient,
resetLocalFromServer,
commitCanvas,
dirtyRef,
activeViewName,
canWriteTopology,
],
);
const addNodeAt = useCallback(
async (item: PaletteItem, position: { x: number; y: number }) => {
await addPaletteItems([item], position);
},
[addPaletteItems],
);
const onPaletteDragStart = (e: React.DragEvent, item: PaletteItem) => {
e.dataTransfer.setData(PALETTE_DND, JSON.stringify(item));
e.dataTransfer.effectAllowed = "copy";
};
const onCanvasDragOver = (e: React.DragEvent) => {
if (!e.dataTransfer.types.includes(PALETTE_DND)) return;
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
};
const onCanvasDrop = (e: React.DragEvent) => {
e.preventDefault();
if (!canWriteTopology) return;
const raw = e.dataTransfer.getData(PALETTE_DND);
if (!raw || !rfRef.current) return;
try {
const item = JSON.parse(raw) as PaletteItem;
const pos = rfRef.current.screenToFlowPosition({ x: e.clientX, y: e.clientY });
void addNodeAt(item, pos);
} catch {
/* ignore */
}
};
const addOutsidePeers = useCallback(
async (fabricNodeIds: string[]): Promise<boolean> => {
if (!mapId) return false;
const ids = [...new Set(fabricNodeIds.map((x) => String(x || "").trim()).filter(Boolean))];
if (!ids.length || outsidePeersAdding) return false;
if (dirtyRef.current) {
if (!window.confirm(t("topology.unsavedSaveBeforeAdd"))) return false;
try {
await commitCanvas();
} catch (err) {
showError(String(err));
return false;
}
}
setOutsidePeersAdding(true);
try {
const onCanvas = new Set(nodes.map((n) => n.id));
const toAdd = ids.filter((id) => !onCanvas.has(id));
if (!toAdd.length) {
setOutsidePeersOpen(false);
setOutsidePeerSelectedIds([]);
return false;
}
const graph = await addTopologyViewNodes(mapId, {
fabric_node_ids: toAdd,
layout: "grid",
});
if (graph.truncate_reason === "membership_frozen") {
showError(t("topology.truncatedFrozen"));
return false;
}
const addedSet = new Set(toAdd);
const added = (graph.nodes || []).filter((n) => addedSet.has(n.fabric_node_id));
if (added.length > 0) {
const viaAnchors = new Map(
outsidePeers
.filter((p) => addedSet.has(p.fabric_node_id))
.map((p) => [p.fabric_node_id, p.via_node_id] as const),
);
const cols = Math.max(1, Math.ceil(Math.sqrt(added.length)));
await patchTopologyPositions(
mapId,
added.map((n, i) => {
const viaId = viaAnchors.get(n.fabric_node_id);
const viaNode = viaId ? nodes.find((x) => x.id === viaId) : undefined;
const base = viaNode
? { x: viaNode.position.x + 200, y: viaNode.position.y }
: { x: 80 + nodes.length * 24, y: 80 + nodes.length * 24 };
return {
fabric_node_id: n.fabric_node_id,
x: base.x + (i % cols) * 180,
y: base.y + Math.floor(i / cols) * 120,
};
}),
);
}
const refreshed = await fetchTopologyGraph(mapId);
queryClient.setQueryData(queryKeys.topologyGraph(mapId), refreshed);
resetLocalFromServer(refreshed);
const count = added.length || toAdd.length;
showOk(t("topology.projectedNeighbors").replace("{{count}}", String(count)));
setOutsidePeerSelectedIds([]);
setOutsidePeersOpen(false);
return count > 0;
} catch (err) {
showError(String(err));
return false;
} finally {
setOutsidePeersAdding(false);
}
},
[
mapId,
nodes,
outsidePeers,
outsidePeersAdding,
resetLocalFromServer,
commitCanvas,
dirtyRef,
queryClient,
showOk,
showError,
t,
setOutsidePeersOpen,
setOutsidePeerSelectedIds,
],
);
const outsidePeerNameById = useMemo(() => {
const m = new Map<string, string>();
for (const n of nodes) {
const label = String(n.data?.label || n.data?.subtitle || "").trim();
m.set(n.id, label || n.id.slice(0, 8));
}
return m;
}, [nodes]);
const outsidePeersVisible = useMemo(() => {
const q = outsidePeerQuery.trim().toLowerCase();
if (!q) return outsidePeers;
return outsidePeers.filter((p) => {
const viaName = outsidePeerNameById.get(p.via_node_id) || p.via_node_id;
const bits = [p.name, p.ip, viaName, p.fabric_node_id];
return bits.some((b) => String(b || "").toLowerCase().includes(q));
});
}, [outsidePeers, outsidePeerQuery, outsidePeerNameById]);
const onCanvasManagedIds = useMemo(
() => new Set(nodes.map((n) => n.data.managed_ne_id).filter(Boolean)),
[nodes],
);
const onCanvasUmeIds = useMemo(
() => new Set(nodes.map((n) => n.data.ume_ne_id).filter(Boolean)),
[nodes],
);
const palette = useMemo((): PaletteItem[] => {
if (paletteSource === "ume") {
return (umeQuery.data?.items || []).map((ne) => {
const name = (ne.host_name || ne.ne_name || ne.user_label || ne.ip_address || ne.ne_id).trim();
return {
key: `ume:${ne.ne_id}`,
source: "ume" as const,
managed_ne_id: "",
ume_ne_id: ne.ne_id,
name,
ip: ne.ip_address || "",
vendor: "ZTE",
meta: `${ne.ip_address || "-"}${SEP}${ne.ne_type || "UME"}`,
connect_status: ne.connection_status || "",
};
});
}
return (neQuery.data?.items || []).map((ne) => ({
key: `managed:${ne.id}`,
source: "managed" as const,
managed_ne_id: ne.id,
ume_ne_id: "",
name: ne.name || ne.ip_address,
ip: ne.ip_address,
vendor: ne.vendor,
meta: `${ne.ip_address}${SEP}${ne.vendor}`,
connect_status: ne.connect_status,
}));
}, [paletteSource, neQuery.data, umeQuery.data]);
const paletteVisible = useMemo(() => {
const tokens = keyword
.trim()
.toLowerCase()
.split(/\s+/)
.filter(Boolean);
let list = palette;
if (tokens.length) {
list = list.filter((item) => {
const bits = [item.name, item.ip, item.vendor, item.managed_ne_id, item.ume_ne_id, item.meta];
return tokens.every((tok) => bits.some((b) => fuzzyIncludes(String(b || ""), tok)));
});
}
return list.filter((item) =>
item.source === "managed"
? !onCanvasManagedIds.has(item.managed_ne_id)
: !onCanvasUmeIds.has(item.ume_ne_id),
);
}, [palette, onCanvasManagedIds, onCanvasUmeIds, keyword]);
const paletteLoading =
(paletteSource === "managed" && neQuery.isLoading) ||
(paletteSource === "ume" && umeQuery.isLoading);
return {
paletteAdding,
outsidePeersAdding,
neQuery,
umeQuery,
addPaletteItems,
addNodeAt,
onPaletteDragStart,
onCanvasDragOver,
onCanvasDrop,
addOutsidePeers,
outsidePeerNameById,
outsidePeersVisible,
palette,
paletteVisible,
paletteLoading,
};
}

View file

@ -0,0 +1,152 @@
import { useCallback } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { Edge, Node } from "@xyflow/react";
import { queryKeys } from "../../../constants/queryKeys";
import { formatErr } from "../../../services/api";
import { commitTopologyView } from "../../../services/api/topology";
import type { TopologyViewGraph } from "../../../types";
import type { NeNodeData } from "../components/TopologyReactFlowView";
import { applyViewGraph, flowToPositions } from "../lib/graphFlow";
import type { EdgeDefaults } from "../lib/edgeStyle";
import type { HistorySnap } from "../lib/pageTypes";
import { buildViewCommitBody } from "../lib/saveDiff";
type PortEdit = { a_port: string; b_port: string };
type StyleEdit = { stroke_color: string; stroke_width: number; line_style: string };
type Args = {
mapId: string;
nodes: Node<NeNodeData>[];
edges: Edge[];
graphQueryData?: TopologyViewGraph;
edgeDefaults: EdgeDefaults;
setNodes: React.Dispatch<React.SetStateAction<Node<NeNodeData>[]>>;
setEdges: React.Dispatch<React.SetStateAction<Edge[]>>;
historyRef: React.MutableRefObject<HistorySnap[]>;
redoRef: React.MutableRefObject<HistorySnap[]>;
historyLockRef: React.MutableRefObject<boolean>;
pendingEdgeCreatesRef: React.MutableRefObject<Set<string>>;
pendingEdgeDeletesRef: React.MutableRefObject<Set<string>>;
pendingEdgePortEditsRef: React.MutableRefObject<Map<string, PortEdit>>;
pendingEdgeStyleRef: React.MutableRefObject<Map<string, StyleEdit>>;
clearDirty: () => void;
showOk: (msg: string) => void;
showError: (msg: string) => void;
t: (key: string) => string;
};
export function useTopologySave({
mapId,
nodes,
edges,
graphQueryData,
edgeDefaults,
setNodes,
setEdges,
historyRef,
redoRef,
historyLockRef,
pendingEdgeCreatesRef,
pendingEdgeDeletesRef,
pendingEdgePortEditsRef,
pendingEdgeStyleRef,
clearDirty,
showOk,
showError,
t,
}: Args) {
const queryClient = useQueryClient();
const resetLocalFromServer = useCallback(
(graph: TopologyViewGraph) => {
historyLockRef.current = true;
applyViewGraph(graph, edgeDefaults, setNodes, setEdges);
historyLockRef.current = false;
pendingEdgeCreatesRef.current = new Set();
pendingEdgeDeletesRef.current = new Set();
pendingEdgePortEditsRef.current = new Map();
pendingEdgeStyleRef.current = new Map();
historyRef.current = [];
redoRef.current = [];
clearDirty();
},
[
edgeDefaults,
setNodes,
setEdges,
historyLockRef,
pendingEdgeCreatesRef,
pendingEdgeDeletesRef,
pendingEdgePortEditsRef,
pendingEdgeStyleRef,
historyRef,
redoRef,
clearDirty,
],
);
const commitCanvas = useCallback(async () => {
if (!mapId) throw new Error(t("topology.selectMap"));
const body = buildViewCommitBody({
nodes,
edges,
serverNodeIds: (graphQueryData?.nodes || []).map((n) => n.fabric_node_id),
positions: flowToPositions(nodes),
pendingCreates: pendingEdgeCreatesRef.current,
pendingDeletes: pendingEdgeDeletesRef.current,
pendingPortEdits: pendingEdgePortEditsRef.current,
pendingStyles: pendingEdgeStyleRef.current,
});
const result = await commitTopologyView(mapId, body);
if (result.graph) {
queryClient.setQueryData(queryKeys.topologyGraph(mapId), result.graph);
resetLocalFromServer(result.graph);
} else {
pendingEdgeCreatesRef.current = new Set();
pendingEdgeDeletesRef.current = new Set();
pendingEdgePortEditsRef.current = new Map();
pendingEdgeStyleRef.current = new Map();
historyRef.current = [];
redoRef.current = [];
clearDirty();
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyGraph(mapId) });
}
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyMaps });
return result;
}, [
mapId,
nodes,
edges,
graphQueryData,
pendingEdgeCreatesRef,
pendingEdgeDeletesRef,
historyRef,
redoRef,
clearDirty,
queryClient,
resetLocalFromServer,
t,
]);
const saveMut = useMutation({
mutationFn: commitCanvas,
onSuccess: (result) => {
const skipped = Number(result.skipped_locked || 0);
if (skipped > 0) {
showOk(t("topology.savedLockedSkipped").replace("{{count}}", String(skipped)));
} else {
showOk(t("topology.saved"));
}
},
onError: async (err) => {
showError(t("topology.saveFailed").replace("{{detail}}", formatErr(err)));
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyGraph(mapId) });
},
});
return {
saveMut,
commitCanvas,
resetLocalFromServer,
};
}

View file

@ -0,0 +1,49 @@
import type { Node, ReactFlowInstance } from "@xyflow/react";
import { TOPO_NODE_H, TOPO_NODE_W } from "./topoGeometry";
type FitOpts = {
padding: number;
duration?: number;
/** Smallest box to fit (flow units); keeps a single-node locate from zooming to max. */
minWidth?: number;
minHeight?: number;
};
/**
* Fit the viewport to nodes using their known geometry.
*
* xyflow's fitView waits until the targeted nodes are measured. With
* onlyRenderVisibleElements, nodes outside the initial viewport are never rendered,
* so on a large canvas far from the origin fitView stays queued forever and the
* user sees an empty canvas. Our nodes carry explicit width/height, so we compute
* the bounds ourselves and use fitBounds, which needs no measurement.
*/
export function fitToNodes(
inst: Pick<ReactFlowInstance, "fitBounds">,
nodes: Array<Pick<Node, "position" | "width" | "height">>,
opts: FitOpts,
): boolean {
if (!nodes.length) return false;
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const n of nodes) {
const w = Number(n.width) || TOPO_NODE_W;
const h = Number(n.height) || TOPO_NODE_H;
minX = Math.min(minX, n.position.x);
minY = Math.min(minY, n.position.y);
maxX = Math.max(maxX, n.position.x + w);
maxY = Math.max(maxY, n.position.y + h);
}
if (!Number.isFinite(minX)) return false;
const width = Math.max(1, opts.minWidth ?? 0, maxX - minX);
const height = Math.max(1, opts.minHeight ?? 0, maxY - minY);
const cx = (minX + maxX) / 2;
const cy = (minY + maxY) / 2;
void inst.fitBounds(
{ x: cx - width / 2, y: cy - height / 2, width, height },
{ padding: opts.padding, duration: opts.duration ?? 0 },
);
return true;
}

View file

@ -31,18 +31,25 @@ export function graphToFlow(
const position = isRegion ? regionFlowPosition(apiX, apiY) : neFlowPosition(apiX, apiY);
const hx = isRegion ? TOPO_REGION_HANDLE_X : TOPO_HANDLE_X;
const hy = isRegion ? TOPO_REGION_HANDLE_Y : TOPO_HANDLE_Y;
const locked = Boolean(n.locked);
const labelOverride = String(n.label_override ?? "");
return {
id: n.fabric_node_id,
type: "neNode",
position,
width: TOPO_NODE_W,
height: isRegion ? TOPO_REGION_ICON_H : TOPO_NODE_H,
draggable: locked ? false : undefined,
handles: [
{ type: "target", position: "left" as Position, x: hx, y: hy },
{ type: "source", position: "right" as Position, x: hx, y: hy },
],
data: {
label: n.label || n.name || n.ip || n.fabric_node_id,
label_override: labelOverride,
label_override_saved: labelOverride,
name: n.name || "",
locked,
managed_ne_id: n.managed_ne_id || "",
ume_ne_id: n.ume_ne_id || "",
ne_ip: n.ip || "",
@ -93,12 +100,23 @@ export function flowToPositions(nodes: Node<NeNodeData>[]) {
const pos = isRegion
? regionApiPosition(n.position.x, n.position.y)
: neApiPosition(n.position.x, n.position.y);
return {
const out: {
fabric_node_id: string;
x: number;
y: number;
label?: string;
} = {
fabric_node_id: n.id,
x: pos.x,
y: pos.y,
label: n.data.label || "",
};
// Omit locked so the server keeps the existing lock; omit label unless overridden locally.
const currentOverride = String(n.data.label_override ?? "");
const savedOverride = String(n.data.label_override_saved ?? "");
if (currentOverride !== savedOverride) {
out.label = currentOverride;
}
return out;
});
}

View file

@ -0,0 +1,163 @@
import type { Edge, Node } from "@xyflow/react";
import { fetchManagedNeById } from "../../../services/api";
import { openOrFocusModule } from "../../../utils/moduleWindows";
import type { NeNodeData } from "../components/TopologyReactFlowView";
import type { EdgeStyleData } from "./edgeStyle";
export function openNeInventory(opts: {
neId?: string;
create?: boolean;
name?: string;
ip_address?: string;
vendor?: string;
}) {
const q = new URLSearchParams();
if (opts.neId) {
q.set("ne_id", opts.neId);
} else if (opts.create) {
q.set("create", "1");
if (opts.name) q.set("name", opts.name);
if (opts.ip_address) q.set("ip_address", opts.ip_address);
if (opts.vendor) q.set("vendor", opts.vendor);
}
const qs = q.toString();
openOrFocusModule({
moduleId: "ne",
path: qs ? `/ne?${qs}` : "/ne",
});
}
type LinkToast = {
showOk: (msg: string) => void;
showError: (msg: string) => void;
t: (key: string) => string;
closeCtxMenu: () => void;
};
export function openWebcrtFor(
node: Node<NeNodeData> | null,
{ showOk, showError, t, closeCtxMenu }: LinkToast,
) {
closeCtxMenu();
if (!node) return;
const managedId = String(node.data.managed_ne_id || "").trim();
const umeId = String(node.data.ume_ne_id || "").trim();
if (managedId) {
void (async () => {
try {
const ne = await fetchManagedNeById(managedId);
const src = String(ne.source || "").trim().toLowerCase();
const hasIp = Boolean(String(ne.ip_address || "").trim());
// Ready inventory hosts: reuse stored credentials (incl. hop/proxy).
// LLDP / topology placeholders / incomplete rows → full NE edit form.
const needsSetup = !hasIp || src === "lldp" || src === "topology";
if (!needsSetup) {
openOrFocusModule({
moduleId: "webcrt",
path: `/webcrt?ne_id=${encodeURIComponent(managedId)}`,
});
return;
}
showOk(t("topology.completeNeFirst"));
openNeInventory({ neId: managedId });
} catch (err) {
showError(String(err));
}
})();
return;
}
if (umeId) {
openOrFocusModule({
moduleId: "webcrt",
path: `/webcrt?ne_id=${encodeURIComponent(umeId)}&source=ume`,
});
return;
}
// No inventory link — open NE create with topology hint fields.
showOk(t("topology.completeNeFirst"));
openNeInventory({
create: true,
name: String(node.data.label || "").trim(),
ip_address: String(node.data.ne_ip || "").trim(),
vendor: String(node.data.vendor || "").trim(),
});
}
export function openNeFor(
node: Node<NeNodeData> | null,
{ showError, t, closeCtxMenu }: Pick<LinkToast, "showError" | "t" | "closeCtxMenu">,
) {
closeCtxMenu();
const managedId = String(node?.data.managed_ne_id || "").trim();
const umeId = String(node?.data.ume_ne_id || "").trim();
if (managedId) {
openNeInventory({ neId: managedId });
return;
}
if (umeId) {
openOrFocusModule({
moduleId: "ume",
path: "/ume",
});
return;
}
showError(t("topology.noNeLink"));
}
export function openAlarmsFor(
node: Node<NeNodeData> | null,
{ showError, t, closeCtxMenu }: Pick<LinkToast, "showError" | "t" | "closeCtxMenu">,
) {
closeCtxMenu();
if (!node || node.data.kind === "region") {
showError(t("topology.noAlarmLink"));
return;
}
const host = String(node.data.label || node.data.ne_ip || "").trim();
const q = new URLSearchParams();
if (host) q.set("host", host);
q.set("cleared", "false");
openOrFocusModule({
moduleId: "network",
path: `/network/alarms?${q.toString()}`,
});
}
export function openPortTrafficForEdge(
edge: Edge | null,
nodes: Node<NeNodeData>[],
{ showError, t, closeCtxMenu }: Pick<LinkToast, "showError" | "t" | "closeCtxMenu">,
) {
closeCtxMenu();
if (!edge) {
showError(t("topology.noNeLink"));
return;
}
const srcNode = nodes.find((n) => n.id === edge.source);
const data = (edge.data || {}) as EdgeStyleData;
const ifname = String(data.source_port || "").trim();
const managedId = String(srcNode?.data.managed_ne_id || "").trim();
const umeId = String(srcNode?.data.ume_ne_id || "").trim();
if (managedId) {
const q = new URLSearchParams({ ne_id: managedId, source: "managed" });
if (ifname) q.set("ifname", ifname);
openOrFocusModule({
moduleId: "network",
path: `/network/tasks/port-traffic?${q.toString()}`,
});
return;
}
if (umeId) {
const q = new URLSearchParams({ ne_id: umeId, source: "ume" });
if (ifname) q.set("ifname", ifname);
openOrFocusModule({
moduleId: "network",
path: `/network/tasks/port-traffic?${q.toString()}`,
});
return;
}
showError(t("topology.noNeLink"));
}

View file

@ -6,6 +6,10 @@ export type HistorySnap = {
edges: Edge[];
pendingEdgeDeletes: string[];
pendingEdgeCreates: string[];
pendingEdgePortEdits: Array<[string, { a_port: string; b_port: string }]>;
pendingEdgeStyles: Array<
[string, { stroke_color: string; stroke_width: number; line_style: string }]
>;
};
export type PaletteSource = "managed" | "ume";

View file

@ -0,0 +1,2 @@
/** Loose bag for TopologyPage → TopologyPageView wiring (pure layout split). */
export type TopologyPageViewModel = Record<string, any>;

View file

@ -0,0 +1,136 @@
/**
* Pure helpers that turn local canvas state into a view /commit body.
* Runtime-import-free so Node --experimental-strip-types tests can load this file.
*/
export type SaveDiffNode = {
id: string;
data?: { kind?: string };
};
export type SaveDiffEdge = {
id: string;
source: string;
target: string;
data?: {
source_port?: string;
target_port?: string;
};
};
export type SaveDiffPosition = {
fabric_node_id: string;
x: number;
y: number;
label?: string;
};
export type ViewCommitBody = {
edge_creates: Array<{
client_id: string;
a_node_id: string;
b_node_id: string;
a_port: string;
b_port: string;
}>;
edge_deletes: string[];
edge_port_edits: Array<{ id: string; a_port: string; b_port: string }>;
edge_styles: Array<{
fabric_edge_id: string;
stroke_color: string;
stroke_width: number;
line_style: string;
}>;
node_adds: string[];
node_removes: string[];
positions: SaveDiffPosition[];
};
export function isRegionNodeId(id: string): boolean {
return String(id || "").startsWith("region:");
}
export function isLocalPendingEdgeId(id: string): boolean {
return String(id || "").startsWith("local:");
}
export type BuildCommitInput = {
nodes: SaveDiffNode[];
edges: SaveDiffEdge[];
serverNodeIds: string[];
positions: SaveDiffPosition[];
pendingCreates: Iterable<string>;
pendingDeletes: Iterable<string>;
pendingPortEdits?: Iterable<[string, { a_port: string; b_port: string }]>;
pendingStyles?: Iterable<
[string, { stroke_color: string; stroke_width: number; line_style: string }]
>;
};
export function buildViewCommitBody(input: BuildCommitInput): ViewCommitBody {
const createSet = new Set(
[...input.pendingCreates].map((x) => String(x || "").trim()).filter(Boolean),
);
// Deletes that were also re-created (or are still local) must not be sent.
const deleteSet = new Set(
[...input.pendingDeletes]
.map((x) => String(x || "").trim())
.filter((id) => id && !isLocalPendingEdgeId(id) && !createSet.has(id)),
);
const edgeById = new Map(input.edges.map((e) => [e.id, e]));
const edge_creates = [...createSet]
.map((id) => edgeById.get(id))
.filter((e): e is SaveDiffEdge => Boolean(e))
.map((e) => ({
client_id: e.id,
a_node_id: e.source,
b_node_id: e.target,
a_port: String(e.data?.source_port || ""),
b_port: String(e.data?.target_port || ""),
}));
const localIds = input.nodes.map((n) => n.id);
const localSet = new Set(localIds);
const serverSet = new Set(
input.serverNodeIds.map((x) => String(x || "").trim()).filter(Boolean),
);
const node_adds = localIds.filter((id) => !serverSet.has(id) && !isRegionNodeId(id));
const node_removes = [...serverSet].filter((id) => !localSet.has(id) && !isRegionNodeId(id));
const edge_port_edits = [...(input.pendingPortEdits || [])]
.map(([id, ports]) => ({
id: String(id || "").trim(),
a_port: String(ports.a_port || ""),
b_port: String(ports.b_port || ""),
}))
.filter(
(x) => x.id && !isLocalPendingEdgeId(x.id) && !createSet.has(x.id) && !deleteSet.has(x.id),
);
const edge_styles = [...(input.pendingStyles || [])]
.map(([id, style]) => ({
fabric_edge_id: String(id || "").trim(),
stroke_color: String(style.stroke_color || ""),
stroke_width: Number(style.stroke_width || 0),
line_style: String(style.line_style || ""),
}))
// Deleted edges drop their style; a new local edge's style is keyed by its client id
// (the server maps it through edge_id_map) only while that edge is still being created.
.filter(
(x) =>
x.fabric_edge_id &&
!deleteSet.has(x.fabric_edge_id) &&
(!isLocalPendingEdgeId(x.fabric_edge_id) || createSet.has(x.fabric_edge_id)),
);
return {
edge_creates,
edge_deletes: [...deleteSet],
edge_port_edits,
edge_styles,
node_adds,
node_removes,
positions: input.positions,
};
}

View file

@ -115,9 +115,12 @@ const topology = {
save: "Save",
saveDirty: "Save *",
unsavedConfirm: "You have unsaved changes. Discard and switch maps?",
unsavedSaveBeforeAdd: "You have unsaved changes. Save and continue adding?",
saving: "Saving…",
saved: "Topology saved",
savedLockedSkipped: "Topology saved ({{count}} locked node(s) skipped)",
saveFailed: "Save failed: {{detail}}",
renameNodeClearHint: "Leave empty to follow the device name",
readOnlyBanner: "Read-only (ne:write scope required to edit)",
readOnlyHint: "Read-only — no write permission",
discoverTimeout: "Discovery timed out; check LLDP tasks for progress",

View file

@ -112,9 +112,12 @@ const topology = {
save: "保存",
saveDirty: "保存 *",
unsavedConfirm: "有未保存的更改,确定丢弃并切换吗?",
unsavedSaveBeforeAdd: "当前有未保存修改,保存后继续添加?",
saving: "保存中…",
saved: "拓扑已保存",
savedLockedSkipped: "拓扑已保存({{count}} 个锁定节点未移动)",
saveFailed: "保存失败:{{detail}}",
renameNodeClearHint: "留空则恢复跟随设备名",
readOnlyBanner: "只读模式(需要 ne:write 权限才能编辑)",
readOnlyHint: "只读模式,无编辑权限",
discoverTimeout: "发现任务超时,请稍后在 LLDP 任务页查看进度",

View file

@ -132,6 +132,14 @@ export const deleteTopologyView = (viewId: string, force = false) =>
`/v1/topology/views/${encodeURIComponent(viewId)}?force=${force ? "true" : "false"}`,
);
export type TopologyPositionsMutation = {
ok?: boolean;
skipped_locked?: number;
updated?: number;
matched?: number;
graph?: TopologyViewGraph | null;
};
export const patchTopologyPositions = (
viewId: string,
positions: Array<{
@ -141,10 +149,17 @@ export const patchTopologyPositions = (
label?: string;
locked?: boolean;
}>,
) =>
apiPatch<TopologyViewGraph>(`/v1/topology/views/${encodeURIComponent(viewId)}/positions`, {
positions,
});
opts?: { return_graph?: boolean },
) => {
const returnGraph = opts?.return_graph !== false;
return apiPatch<TopologyViewGraph | TopologyPositionsMutation>(
`/v1/topology/views/${encodeURIComponent(viewId)}/positions`,
{
positions,
return_graph: returnGraph,
},
);
};
export const addTopologyViewNodes = (
viewId: string,
@ -401,6 +416,62 @@ export const deleteFabricEdge = (edgeId: string) =>
`/v1/topology/fabric/edges/${encodeURIComponent(edgeId)}`,
);
export const patchFabricEdgePorts = (
edgeId: string,
body: { a_port?: string; b_port?: string },
) =>
apiPatch<{
id: string;
a_node_id: string;
b_node_id: string;
a_port: string;
b_port: string;
source: string;
status: string;
layer: string;
}>(`/v1/topology/fabric/edges/${encodeURIComponent(edgeId)}`, body);
export type TopologyViewCommitBody = {
edge_creates?: Array<{
client_id?: string;
a_node_id: string;
b_node_id: string;
a_port?: string;
b_port?: string;
}>;
edge_deletes?: string[];
edge_port_edits?: Array<{ id: string; a_port?: string; b_port?: string }>;
edge_styles?: Array<{
fabric_edge_id: string;
stroke_color?: string;
stroke_width?: number;
line_style?: string;
}>;
node_adds?: string[];
node_removes?: string[];
positions?: Array<{
fabric_node_id: string;
x?: number;
y?: number;
label?: string;
locked?: boolean;
}>;
};
export type TopologyViewCommitResult = {
ok: boolean;
view_id: string;
edge_id_map: Record<string, string>;
skipped_locked: number;
graph: TopologyViewGraph | null;
};
export const commitTopologyView = (viewId: string, body: TopologyViewCommitBody) =>
apiPost<TopologyViewCommitResult>(
`/v1/topology/views/${encodeURIComponent(viewId)}/commit`,
body,
);
/** Back-compat aliases used by older call sites during cutover. */
export const fetchTopologyMaps = fetchTopologyViews;
export const createTopologyMap = createTopologyView;

View file

@ -3086,6 +3086,22 @@
opacity: 0.92;
}
.topo-node__lock {
position: absolute;
top: -4px;
left: -6px;
z-index: 2;
display: inline-flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
border-radius: 999px;
background: color-mix(in srgb, var(--nx-surface-2) 82%, #94a3b8 18%);
border: 1px solid rgb(var(--nx-ink-rgb) / 0.28);
color: var(--nx-text-muted);
pointer-events: none;
}
.topo-node__badge {
position: absolute;
top: -4px;

View file

@ -115,6 +115,8 @@ export type TopologyViewNodeItem = {
managed_ne_id: string;
ume_ne_id: string;
label: string;
/** Raw placement override; empty means follow device name/ip. */
label_override?: string;
x: number;
y: number;
locked: boolean;

View file

@ -0,0 +1,116 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { buildViewCommitBody, isRegionNodeId } from "../src/features/topology/lib/saveDiff.ts";
test("local add/remove nodes; region nodes stay out of add/remove lists", () => {
const body = buildViewCommitBody({
nodes: [
{ id: "fab-a" },
{ id: "fab-new" },
{ id: "region:x", data: { kind: "region" } },
],
edges: [],
serverNodeIds: ["fab-a", "fab-gone", "region:y"],
positions: [
{ fabric_node_id: "fab-a", x: 1, y: 2 },
{ fabric_node_id: "fab-new", x: 3, y: 4 },
{ fabric_node_id: "region:x", x: 5, y: 6 },
],
pendingCreates: [],
pendingDeletes: [],
});
assert.deepEqual(body.node_adds, ["fab-new"]);
assert.deepEqual(body.node_removes, ["fab-gone"]);
assert.ok(!body.node_adds.some(isRegionNodeId));
assert.ok(!body.node_removes.some(isRegionNodeId));
assert.equal(body.positions.length, 3);
});
test("only changed label overrides appear in positions payload from caller", () => {
const body = buildViewCommitBody({
nodes: [{ id: "fab-a" }],
edges: [],
serverNodeIds: ["fab-a"],
positions: [{ fabric_node_id: "fab-a", x: 1, y: 2, label: "CUSTOM" }],
pendingCreates: [],
pendingDeletes: [],
});
assert.equal(body.positions[0].label, "CUSTOM");
});
test("deleted then re-created edge is not in edge_deletes", () => {
const body = buildViewCommitBody({
nodes: [{ id: "a" }, { id: "b" }],
edges: [
{
id: "local:1",
source: "a",
target: "b",
data: { source_port: "gi0/1", target_port: "gi0/2" },
},
],
serverNodeIds: ["a", "b"],
positions: [],
pendingCreates: ["local:1"],
pendingDeletes: ["edge-server-1", "local:1"],
});
assert.deepEqual(body.edge_deletes, ["edge-server-1"]);
assert.equal(body.edge_creates.length, 1);
assert.equal(body.edge_creates[0].client_id, "local:1");
assert.equal(body.edge_creates[0].a_port, "gi0/1");
});
test("undo after delete removes edge from edge_deletes when not pending", () => {
const body = buildViewCommitBody({
nodes: [{ id: "a" }, { id: "b" }],
edges: [{ id: "edge-server-1", source: "a", target: "b" }],
serverNodeIds: ["a", "b"],
positions: [],
pendingCreates: [],
pendingDeletes: [],
});
assert.deepEqual(body.edge_deletes, []);
assert.deepEqual(body.edge_creates, []);
});
test("styled or port-edited edge that is then deleted only appears in edge_deletes", () => {
const style = { stroke_color: "#ff0000", stroke_width: 2, line_style: "dashed" };
const body = buildViewCommitBody({
nodes: [{ id: "a" }, { id: "b" }],
edges: [],
serverNodeIds: ["a", "b"],
positions: [],
pendingCreates: [],
pendingDeletes: ["edge-1"],
pendingPortEdits: [["edge-1", { a_port: "gi0/1", b_port: "gi0/2" }]],
pendingStyles: [["edge-1", style]],
});
assert.deepEqual(body.edge_deletes, ["edge-1"]);
assert.deepEqual(body.edge_port_edits, []);
assert.deepEqual(body.edge_styles, []);
});
test("style on a new local edge is sent under its client id; dropped once the edge is gone", () => {
const style = { stroke_color: "#00ff00", stroke_width: 3, line_style: "" };
const edges = [{ id: "local:9", source: "a", target: "b", data: {} }];
const kept = buildViewCommitBody({
nodes: [{ id: "a" }, { id: "b" }],
edges,
serverNodeIds: ["a", "b"],
positions: [],
pendingCreates: ["local:9"],
pendingDeletes: [],
pendingStyles: [["local:9", style]],
});
assert.deepEqual(kept.edge_styles.map((s) => s.fabric_edge_id), ["local:9"]);
const dropped = buildViewCommitBody({
nodes: [{ id: "a" }, { id: "b" }],
edges: [],
serverNodeIds: ["a", "b"],
positions: [],
pendingCreates: [],
pendingDeletes: [],
pendingStyles: [["local:9", style]],
});
assert.deepEqual(dropped.edge_styles, []);
});