mirror of
https://github.com/hansjone/netx.git
synced 2026-10-13 05:00:47 +08:00
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:
parent
60a0ea6021
commit
f301544b99
40 changed files with 5587 additions and 1916 deletions
295
docs/reviews/topology-optimization-plan-2026-10-12.md
Normal file
295
docs/reviews/topology-optimization-plan-2026-10-12.md
Normal 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 失败(与改动前同一批,既有问题)。
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 ---------------------------------------
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
444
netx_api/topology_views_commit.py
Normal file
444
netx_api/topology_views_commit.py
Normal 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 ""),
|
||||
)
|
||||
]
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
79
scripts/topology_clear_frozen_labels.py
Normal file
79
scripts/topology_clear_frozen_labels.py
Normal 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())
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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) =>
|
||||
|
|
|
|||
867
web/src/features/topology/components/TopologyPageView.tsx
Normal file
867
web/src/features/topology/components/TopologyPageView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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());
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
362
web/src/features/topology/hooks/useTopologyEdgeEditing.ts
Normal file
362
web/src/features/topology/hooks/useTopologyEdgeEditing.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
124
web/src/features/topology/hooks/useTopologyExport.ts
Normal file
124
web/src/features/topology/hooks/useTopologyExport.ts
Normal 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 };
|
||||
}
|
||||
269
web/src/features/topology/hooks/useTopologyFind.ts
Normal file
269
web/src/features/topology/hooks/useTopologyFind.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
133
web/src/features/topology/hooks/useTopologyKeyboard.ts
Normal file
133
web/src/features/topology/hooks/useTopologyKeyboard.ts
Normal 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]);
|
||||
}
|
||||
149
web/src/features/topology/hooks/useTopologyNodeOps.ts
Normal file
149
web/src/features/topology/hooks/useTopologyNodeOps.ts
Normal 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 };
|
||||
}
|
||||
409
web/src/features/topology/hooks/useTopologyPalette.ts
Normal file
409
web/src/features/topology/hooks/useTopologyPalette.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
152
web/src/features/topology/hooks/useTopologySave.ts
Normal file
152
web/src/features/topology/hooks/useTopologySave.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
49
web/src/features/topology/lib/fitNodes.ts
Normal file
49
web/src/features/topology/lib/fitNodes.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
163
web/src/features/topology/lib/moduleLinks.ts
Normal file
163
web/src/features/topology/lib/moduleLinks.ts
Normal 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"));
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
2
web/src/features/topology/lib/pageViewModel.ts
Normal file
2
web/src/features/topology/lib/pageViewModel.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
/** Loose bag for TopologyPage → TopologyPageView wiring (pure layout split). */
|
||||
export type TopologyPageViewModel = Record<string, any>;
|
||||
136
web/src/features/topology/lib/saveDiff.ts
Normal file
136
web/src/features/topology/lib/saveDiff.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -112,9 +112,12 @@ const topology = {
|
|||
save: "保存",
|
||||
saveDirty: "保存 *",
|
||||
unsavedConfirm: "有未保存的更改,确定丢弃并切换吗?",
|
||||
unsavedSaveBeforeAdd: "当前有未保存修改,保存后继续添加?",
|
||||
saving: "保存中…",
|
||||
saved: "拓扑已保存",
|
||||
savedLockedSkipped: "拓扑已保存({{count}} 个锁定节点未移动)",
|
||||
saveFailed: "保存失败:{{detail}}",
|
||||
renameNodeClearHint: "留空则恢复跟随设备名",
|
||||
readOnlyBanner: "只读模式(需要 ne:write 权限才能编辑)",
|
||||
readOnlyHint: "只读模式,无编辑权限",
|
||||
discoverTimeout: "发现任务超时,请稍后在 LLDP 任务页查看进度",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
116
web/tests/topologySaveDiff.test.mjs
Normal file
116
web/tests/topologySaveDiff.test.mjs
Normal 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, []);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue