mirror of
https://github.com/hansjone/netx.git
synced 2026-10-18 21:03:57 +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
|
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:
|
def remove_region_canvas_placements(db: Session, folder_id: str) -> int:
|
||||||
"""Drop this folder's icon from any parent canvas."""
|
"""Drop this folder's icon from any parent canvas."""
|
||||||
nid = region_canvas_node_id(folder_id)
|
nid = region_canvas_node_id(folder_id)
|
||||||
|
|
@ -139,6 +172,7 @@ def region_node_out(
|
||||||
y: float,
|
y: float,
|
||||||
locked: bool = False,
|
locked: bool = False,
|
||||||
label: str = "",
|
label: str = "",
|
||||||
|
label_override: str | None = None,
|
||||||
) -> ViewNodeOut:
|
) -> ViewNodeOut:
|
||||||
child_view = _child_primary_view(db, folder.id)
|
child_view = _child_primary_view(db, folder.id)
|
||||||
kids = (
|
kids = (
|
||||||
|
|
@ -154,12 +188,14 @@ def region_node_out(
|
||||||
.filter(TopoViewNode.view_id == child_view.id)
|
.filter(TopoViewNode.view_id == child_view.id)
|
||||||
.count()
|
.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(
|
return ViewNodeOut(
|
||||||
fabric_node_id=region_canvas_node_id(folder.id),
|
fabric_node_id=region_canvas_node_id(folder.id),
|
||||||
managed_ne_id="",
|
managed_ne_id="",
|
||||||
ume_ne_id="",
|
ume_ne_id="",
|
||||||
label=display,
|
label=display,
|
||||||
|
label_override=raw[:256],
|
||||||
x=float(x),
|
x=float(x),
|
||||||
y=float(y),
|
y=float(y),
|
||||||
locked=bool(locked),
|
locked=bool(locked),
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,8 @@ from .topology_schemas import (
|
||||||
TopologyPlaceholderCreate,
|
TopologyPlaceholderCreate,
|
||||||
TopologyViewCreate,
|
TopologyViewCreate,
|
||||||
TopologyViewUpdate,
|
TopologyViewUpdate,
|
||||||
|
FabricEdgePortsPatch,
|
||||||
|
ViewCommitIn,
|
||||||
ViewEdgeStylePatch,
|
ViewEdgeStylePatch,
|
||||||
ViewNodesAdd,
|
ViewNodesAdd,
|
||||||
ViewNodesRemove,
|
ViewNodesRemove,
|
||||||
|
|
@ -61,6 +63,7 @@ from .topology_fabric import (
|
||||||
from .topology_views import (
|
from .topology_views import (
|
||||||
add_nodes_to_view,
|
add_nodes_to_view,
|
||||||
bootstrap_topology_tree,
|
bootstrap_topology_tree,
|
||||||
|
commit_view_changes,
|
||||||
create_folder,
|
create_folder,
|
||||||
create_topology_placeholder_on_view,
|
create_topology_placeholder_on_view,
|
||||||
create_view,
|
create_view,
|
||||||
|
|
@ -69,6 +72,7 @@ from .topology_views import (
|
||||||
get_topology_tree,
|
get_topology_tree,
|
||||||
get_view_graph,
|
get_view_graph,
|
||||||
list_views,
|
list_views,
|
||||||
|
patch_manual_edge_ports,
|
||||||
patch_view_edge_style,
|
patch_view_edge_style,
|
||||||
patch_view_positions,
|
patch_view_positions,
|
||||||
populate_view,
|
populate_view,
|
||||||
|
|
@ -481,6 +485,32 @@ def api_edge_style(
|
||||||
return patch_view_edge_style(db, view_id, body).model_dump()
|
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 ---------------------------------------
|
# --- Classify rules / slices / search ---------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -273,8 +273,10 @@ class ViewNodeIn(BaseModel):
|
||||||
fabric_node_id: str = Field(min_length=1, max_length=64)
|
fabric_node_id: str = Field(min_length=1, max_length=64)
|
||||||
x: float = 0.0
|
x: float = 0.0
|
||||||
y: float = 0.0
|
y: float = 0.0
|
||||||
label: str = ""
|
# None = leave existing override unchanged; "" = clear override (follow device name).
|
||||||
locked: bool = False
|
label: str | None = None
|
||||||
|
# None = leave lock unchanged; False unlocks (and allows move); True locks after move.
|
||||||
|
locked: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
class ViewNodeOut(BaseModel):
|
class ViewNodeOut(BaseModel):
|
||||||
|
|
@ -282,6 +284,8 @@ class ViewNodeOut(BaseModel):
|
||||||
managed_ne_id: str = ""
|
managed_ne_id: str = ""
|
||||||
ume_ne_id: str = ""
|
ume_ne_id: str = ""
|
||||||
label: str = ""
|
label: str = ""
|
||||||
|
# Raw placement override (empty = follow fabric name/ip). ``label`` is the display value.
|
||||||
|
label_override: str = ""
|
||||||
x: float = 0.0
|
x: float = 0.0
|
||||||
y: float = 0.0
|
y: float = 0.0
|
||||||
locked: bool = False
|
locked: bool = False
|
||||||
|
|
@ -448,6 +452,16 @@ class ViewNodesRemove(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
class ViewEdgeStylePatch(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
|
fabric_edge_id: str
|
||||||
stroke_color: str = ""
|
stroke_color: str = ""
|
||||||
stroke_width: int = Field(default=0, ge=0, le=12)
|
stroke_width: int = Field(default=0, ge=0, le=12)
|
||||||
|
|
@ -461,6 +475,11 @@ class FabricManualEdgeIn(BaseModel):
|
||||||
b_port: str = ""
|
b_port: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class FabricEdgePortsPatch(BaseModel):
|
||||||
|
a_port: str = ""
|
||||||
|
b_port: str = ""
|
||||||
|
|
||||||
|
|
||||||
class FabricEdgesDeleteRequest(BaseModel):
|
class FabricEdgesDeleteRequest(BaseModel):
|
||||||
edge_ids: list[str] = Field(default_factory=list, min_length=1)
|
edge_ids: list[str] = Field(default_factory=list, min_length=1)
|
||||||
|
|
||||||
|
|
@ -469,6 +488,40 @@ class FabricEdgesDeleteOut(BaseModel):
|
||||||
deleted: int = 0
|
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
|
# Classify rules + slices + search
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ from .topology_views import (
|
||||||
_place_fabric_ids_on_view,
|
_place_fabric_ids_on_view,
|
||||||
add_nodes_to_view,
|
add_nodes_to_view,
|
||||||
bootstrap_topology_tree,
|
bootstrap_topology_tree,
|
||||||
|
commit_view_changes,
|
||||||
create_folder,
|
create_folder,
|
||||||
create_topology_placeholder_on_view,
|
create_topology_placeholder_on_view,
|
||||||
create_view,
|
create_view,
|
||||||
|
|
@ -48,6 +49,7 @@ from .topology_views import (
|
||||||
get_topology_tree,
|
get_topology_tree,
|
||||||
get_view_graph,
|
get_view_graph,
|
||||||
list_views,
|
list_views,
|
||||||
|
patch_manual_edge_ports,
|
||||||
patch_view_edge_style,
|
patch_view_edge_style,
|
||||||
patch_view_positions,
|
patch_view_positions,
|
||||||
populate_view,
|
populate_view,
|
||||||
|
|
@ -68,6 +70,7 @@ __all__ = [
|
||||||
"_place_fabric_ids_on_view",
|
"_place_fabric_ids_on_view",
|
||||||
"add_nodes_to_view",
|
"add_nodes_to_view",
|
||||||
"bootstrap_topology_tree",
|
"bootstrap_topology_tree",
|
||||||
|
"commit_view_changes",
|
||||||
"create_folder",
|
"create_folder",
|
||||||
"create_topology_placeholder_on_view",
|
"create_topology_placeholder_on_view",
|
||||||
"create_view",
|
"create_view",
|
||||||
|
|
@ -88,6 +91,7 @@ __all__ = [
|
||||||
"list_fabric_nodes",
|
"list_fabric_nodes",
|
||||||
"list_views",
|
"list_views",
|
||||||
"merge_duplicate_fabric_nodes",
|
"merge_duplicate_fabric_nodes",
|
||||||
|
"patch_manual_edge_ports",
|
||||||
"patch_view_edge_style",
|
"patch_view_edge_style",
|
||||||
"patch_view_positions",
|
"patch_view_positions",
|
||||||
"pause_discover_job",
|
"pause_discover_job",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
"""Topology folder tree and leaf view operations (facade)."""
|
"""Topology folder tree and leaf view operations (facade)."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .topology_views_commit import commit_view_changes, patch_manual_edge_ports
|
||||||
from .topology_views_graph import (
|
from .topology_views_graph import (
|
||||||
_place_fabric_ids_on_view,
|
_place_fabric_ids_on_view,
|
||||||
add_nodes_to_view,
|
add_nodes_to_view,
|
||||||
|
|
@ -29,6 +30,7 @@ __all__ = [
|
||||||
"_place_fabric_ids_on_view",
|
"_place_fabric_ids_on_view",
|
||||||
"add_nodes_to_view",
|
"add_nodes_to_view",
|
||||||
"bootstrap_topology_tree",
|
"bootstrap_topology_tree",
|
||||||
|
"commit_view_changes",
|
||||||
"create_folder",
|
"create_folder",
|
||||||
"create_topology_placeholder_on_view",
|
"create_topology_placeholder_on_view",
|
||||||
"create_view",
|
"create_view",
|
||||||
|
|
@ -38,6 +40,7 @@ __all__ = [
|
||||||
"get_topology_tree",
|
"get_topology_tree",
|
||||||
"get_view_graph",
|
"get_view_graph",
|
||||||
"list_views",
|
"list_views",
|
||||||
|
"patch_manual_edge_ports",
|
||||||
"patch_view_edge_style",
|
"patch_view_edge_style",
|
||||||
"patch_view_positions",
|
"patch_view_positions",
|
||||||
"populate_view",
|
"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.
|
# Save/mutate return_graph must use the same UME synthetic graph as GET.
|
||||||
if is_ume_canvas_view(view):
|
if is_ume_canvas_view(view):
|
||||||
return get_ume_canvas_graph(db, view_id)
|
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
|
truncated = False
|
||||||
reason = ""
|
reason = ""
|
||||||
if len(vnodes) > VIEW_GRAPH_NODE_HARD_CAP:
|
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()
|
n.id: n for n in db.query(TopoFabricNode).filter(TopoFabricNode.id.in_(fids)).all()
|
||||||
} if fids else {}
|
} if fids else {}
|
||||||
managed_by_id, ume_by_id = _batch_ne_lookups(db, fabric_nodes)
|
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 {})
|
filt = dict(view.filter or {})
|
||||||
layer = str(filt.get("layer") or "physical").strip() or "physical"
|
layer = str(filt.get("layer") or "physical").strip() or "physical"
|
||||||
status = str(filt.get("status") or "").strip().lower()
|
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()
|
region_ids_seen: set[str] = set()
|
||||||
for vn in vnodes:
|
for vn in vnodes:
|
||||||
if is_region_canvas_node_id(vn.fabric_node_id):
|
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":
|
if folder is None or str(folder.kind or "") != "region":
|
||||||
continue
|
continue
|
||||||
region_ids_seen.add(folder.id)
|
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),
|
x=float(vn.x or 0),
|
||||||
y=float(vn.y or 0),
|
y=float(vn.y or 0),
|
||||||
locked=bool(vn.locked),
|
locked=bool(vn.locked),
|
||||||
label=str(vn.label or folder.name or ""),
|
label_override=str(vn.label or ""),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
fn = fabric_nodes.get(vn.fabric_node_id)
|
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:
|
if not label and fn is not None:
|
||||||
label = (fn.name or fn.ip or vn.fabric_node_id)[:256]
|
label = (fn.name or fn.ip or vn.fabric_node_id)[:256]
|
||||||
connect_status = ""
|
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 "",
|
managed_ne_id=(fn.managed_ne_id if fn else "") or "",
|
||||||
ume_ne_id=(fn.ume_ne_id if fn else "") or "",
|
ume_ne_id=(fn.ume_ne_id if fn else "") or "",
|
||||||
label=label,
|
label=label,
|
||||||
|
label_override=label_override[:256],
|
||||||
x=float(vn.x or 0),
|
x=float(vn.x or 0),
|
||||||
y=float(vn.y or 0),
|
y=float(vn.y or 0),
|
||||||
locked=bool(vn.locked),
|
locked=bool(vn.locked),
|
||||||
|
|
@ -348,22 +368,31 @@ def _outside_peers_for_view(
|
||||||
member_ids: set[str],
|
member_ids: set[str],
|
||||||
layer: str,
|
layer: str,
|
||||||
limit: int = 50,
|
limit: int = 50,
|
||||||
|
edge_scan_limit: int = 5000,
|
||||||
) -> list[dict[str, str]]:
|
) -> list[dict[str, str]]:
|
||||||
if not member_ids:
|
if not member_ids:
|
||||||
return []
|
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 = (
|
edges = (
|
||||||
db.query(TopoFabricEdge)
|
db.query(TopoFabricEdge)
|
||||||
.filter(
|
.filter(
|
||||||
TopoFabricEdge.layer == layer,
|
TopoFabricEdge.layer == layer,
|
||||||
or_(
|
or_(
|
||||||
TopoFabricEdge.a_node_id.in_(list(member_ids)),
|
TopoFabricEdge.a_node_id.in_(members),
|
||||||
TopoFabricEdge.b_node_id.in_(list(member_ids)),
|
TopoFabricEdge.b_node_id.in_(members),
|
||||||
),
|
),
|
||||||
|
~both_internal,
|
||||||
)
|
)
|
||||||
.limit(5000)
|
.limit(max(1, int(edge_scan_limit or 5000)))
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
out: list[dict[str, str]] = []
|
peer_via: list[tuple[str, str]] = []
|
||||||
seen: set[str] = set()
|
seen: set[str] = set()
|
||||||
for e in edges:
|
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)):
|
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:
|
if peer_id in seen:
|
||||||
continue
|
continue
|
||||||
seen.add(peer_id)
|
seen.add(peer_id)
|
||||||
fn = db.get(TopoFabricNode, peer_id)
|
peer_via.append((peer_id, local_id))
|
||||||
out.append(
|
if len(peer_via) >= limit:
|
||||||
{
|
break
|
||||||
"fabric_node_id": peer_id,
|
if len(peer_via) >= limit:
|
||||||
"name": (fn.name if fn else "") or "",
|
break
|
||||||
"ip": (fn.ip if fn else "") or "",
|
if not peer_via:
|
||||||
"via_node_id": local_id,
|
return []
|
||||||
}
|
peer_ids = [p for p, _ in peer_via]
|
||||||
)
|
peers = {
|
||||||
if len(out) >= limit:
|
n.id: n
|
||||||
return out
|
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
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -646,6 +687,7 @@ def patch_view_positions(
|
||||||
layout = str(body.layout or "").strip().lower()
|
layout = str(body.layout or "").strip().lower()
|
||||||
updated = 0
|
updated = 0
|
||||||
skipped_locked = 0
|
skipped_locked = 0
|
||||||
|
skipped_missing = 0
|
||||||
matched = 0
|
matched = 0
|
||||||
|
|
||||||
existing = {
|
existing = {
|
||||||
|
|
@ -706,45 +748,54 @@ def patch_view_positions(
|
||||||
row.updated_at = now
|
row.updated_at = now
|
||||||
updated += 1
|
updated += 1
|
||||||
else:
|
else:
|
||||||
|
from .topology_region_canvas import add_missing_region_placement, is_region_canvas_node_id
|
||||||
|
|
||||||
positions = list(body.positions or [])
|
positions = list(body.positions or [])
|
||||||
if len(positions) > VIEW_GRAPH_NODE_HARD_CAP:
|
if len(positions) > VIEW_GRAPH_NODE_HARD_CAP:
|
||||||
raise HTTPException(status_code=400, detail="too_many_positions")
|
raise HTTPException(status_code=400, detail="too_many_positions")
|
||||||
matched = len(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:
|
for p in positions:
|
||||||
fid = str(p.fabric_node_id or "").strip()
|
fid = str(p.fabric_node_id or "").strip()
|
||||||
if not fid:
|
if not fid:
|
||||||
continue
|
continue
|
||||||
from .topology_region_canvas import is_region_canvas_node_id
|
if not is_region_canvas_node_id(fid) and fid not in fabric_alive:
|
||||||
|
|
||||||
if not is_region_canvas_node_id(fid) and db.get(TopoFabricNode, fid) is None:
|
|
||||||
raise HTTPException(status_code=400, detail=f"fabric_node_not_found:{fid}")
|
raise HTTPException(status_code=400, detail=f"fabric_node_not_found:{fid}")
|
||||||
row = existing.get(fid)
|
row = existing.get(fid)
|
||||||
if row is None:
|
if row is None:
|
||||||
row = TopoViewNode(
|
# Child-region icons may be synthesized without a placement row; persist those.
|
||||||
id=uuid4().hex,
|
# Fabric NEs must not bypass membership caps / freeze (MCP adds via /nodes).
|
||||||
view_id=view.id,
|
row = add_missing_region_placement(db, view, fid, x=p.x, y=p.y)
|
||||||
fabric_node_id=fid,
|
if row is None:
|
||||||
x=float(p.x or 0),
|
skipped_missing += 1
|
||||||
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
|
|
||||||
continue
|
continue
|
||||||
row.x = float(p.x or 0)
|
existing[fid] = row
|
||||||
row.y = float(p.y or 0)
|
# Locked rows stay put unless the client explicitly unlocks (locked=False).
|
||||||
if p.label is not None:
|
if row.locked and p.locked is not False:
|
||||||
row.label = str(p.label or "")[:256]
|
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.locked = bool(p.locked)
|
||||||
row.updated_at = now
|
row.updated_at = now
|
||||||
updated += 1
|
updated += 1
|
||||||
|
|
||||||
view.updated_at = now
|
view.updated_at = now
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
@ -756,6 +807,7 @@ def patch_view_positions(
|
||||||
matched=matched,
|
matched=matched,
|
||||||
updated=updated,
|
updated=updated,
|
||||||
skipped_locked=skipped_locked,
|
skipped_locked=skipped_locked,
|
||||||
|
skipped_missing=skipped_missing,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1353,42 +1405,48 @@ _LINE_STYLES = {"", "solid", "dashed", "dotted"}
|
||||||
def patch_view_edge_style(
|
def patch_view_edge_style(
|
||||||
db: Session, view_id: str, body: ViewEdgeStylePatch
|
db: Session, view_id: str, body: ViewEdgeStylePatch
|
||||||
) -> TopologyViewGraphOut:
|
) -> TopologyViewGraphOut:
|
||||||
|
from .topology_views_commit import normalize_edge_style_items
|
||||||
|
|
||||||
view = _get_view_or_404(db, view_id)
|
view = _get_view_or_404(db, view_id)
|
||||||
eid = str(body.fabric_edge_id or "").strip()
|
items = normalize_edge_style_items(body)
|
||||||
if not eid or db.get(TopoFabricEdge, eid) is None:
|
if not items:
|
||||||
raise HTTPException(status_code=404, detail="fabric_edge_not_found")
|
raise HTTPException(status_code=400, detail="fabric_edge_id_required")
|
||||||
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")
|
|
||||||
now = _utcnow()
|
now = _utcnow()
|
||||||
row = (
|
for item in items:
|
||||||
db.query(TopoViewEdgeStyle)
|
eid = str(item.fabric_edge_id or "").strip()
|
||||||
.filter(TopoViewEdgeStyle.view_id == view.id, TopoViewEdgeStyle.fabric_edge_id == eid)
|
if not eid or db.get(TopoFabricEdge, eid) is None:
|
||||||
.one_or_none()
|
raise HTTPException(status_code=404, detail="fabric_edge_not_found")
|
||||||
)
|
color = str(item.stroke_color or "").strip()
|
||||||
if row is None:
|
if color and not _HEX_COLOR_RE.match(color):
|
||||||
row = TopoViewEdgeStyle(
|
raise HTTPException(status_code=400, detail="invalid_stroke_color")
|
||||||
id=uuid4().hex,
|
width = int(item.stroke_width or 0)
|
||||||
view_id=view.id,
|
if width < 0 or width > 12:
|
||||||
fabric_edge_id=eid,
|
raise HTTPException(status_code=400, detail="invalid_stroke_width")
|
||||||
stroke_color=color,
|
style = str(item.line_style or "").strip().lower()
|
||||||
stroke_width=width,
|
if style not in _LINE_STYLES:
|
||||||
line_style=style,
|
raise HTTPException(status_code=400, detail="invalid_line_style")
|
||||||
created_at=now,
|
row = (
|
||||||
updated_at=now,
|
db.query(TopoViewEdgeStyle)
|
||||||
|
.filter(TopoViewEdgeStyle.view_id == view.id, TopoViewEdgeStyle.fabric_edge_id == eid)
|
||||||
|
.one_or_none()
|
||||||
)
|
)
|
||||||
db.add(row)
|
if row is None:
|
||||||
else:
|
row = TopoViewEdgeStyle(
|
||||||
row.stroke_color = color
|
id=uuid4().hex,
|
||||||
row.stroke_width = width
|
view_id=view.id,
|
||||||
row.line_style = style
|
fabric_edge_id=eid,
|
||||||
row.updated_at = now
|
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
|
view.updated_at = now
|
||||||
db.commit()
|
db.commit()
|
||||||
return get_view_graph(db, view.id)
|
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()
|
fid = str(p.get("fabric_node_id") or "").strip()
|
||||||
if not fid:
|
if not fid:
|
||||||
continue
|
continue
|
||||||
cleaned.append(
|
row: dict[str, Any] = {
|
||||||
{
|
"fabric_node_id": fid,
|
||||||
"fabric_node_id": fid,
|
"x": float(p.get("x") or 0),
|
||||||
"x": float(p.get("x") or 0),
|
"y": float(p.get("y") or 0),
|
||||||
"y": float(p.get("y") or 0),
|
}
|
||||||
"label": str(p.get("label") or ""),
|
# Omitted label/locked = keep server value (a UI rename or an existing lock).
|
||||||
"locked": bool(p.get("locked") or False),
|
# 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:
|
if not cleaned:
|
||||||
return {"ok": False, "error": "positions_required"}
|
return {"ok": False, "error": "positions_required"}
|
||||||
parts: list[dict[str, Any]] = []
|
parts: list[dict[str, Any]] = []
|
||||||
|
|
@ -3727,8 +3730,14 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
|
||||||
"fabric_node_id": {"type": "string"},
|
"fabric_node_id": {"type": "string"},
|
||||||
"x": {"type": "number"},
|
"x": {"type": "number"},
|
||||||
"y": {"type": "number"},
|
"y": {"type": "number"},
|
||||||
"label": {"type": "string"},
|
"label": {
|
||||||
"locked": {"type": "boolean"},
|
"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"],
|
"required": ["fabric_node_id"],
|
||||||
"additionalProperties": False,
|
"additionalProperties": False,
|
||||||
|
|
|
||||||
|
|
@ -614,3 +614,24 @@ def test_analyze_topology_view_layout_single() -> None:
|
||||||
assert "sparsity" in payload
|
assert "sparsity" in payload
|
||||||
assert "verdict" in payload
|
assert "verdict" in payload
|
||||||
assert "total" in payload["verdict"]
|
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,
|
TopoViewEdgeStyle,
|
||||||
TopoViewNode,
|
TopoViewNode,
|
||||||
)
|
)
|
||||||
|
from netx_api.ne_crypto import encrypt_secret
|
||||||
from netx_api.topology_schemas import (
|
from netx_api.topology_schemas import (
|
||||||
FabricDiscoverRequest,
|
FabricDiscoverRequest,
|
||||||
TopologyFolderCreate,
|
TopologyFolderCreate,
|
||||||
TopologyPlaceholderCreate,
|
TopologyPlaceholderCreate,
|
||||||
TopologyViewCreate,
|
TopologyViewCreate,
|
||||||
TopologyViewUpdate,
|
TopologyViewUpdate,
|
||||||
|
ViewCommitEdgeCreate,
|
||||||
|
ViewCommitEdgePortEdit,
|
||||||
|
ViewCommitIn,
|
||||||
|
ViewEdgeStyleItem,
|
||||||
ViewNodesAdd,
|
ViewNodesAdd,
|
||||||
ViewPopulateRequest,
|
ViewPopulateRequest,
|
||||||
ViewPositionsPatch,
|
ViewPositionsPatch,
|
||||||
|
|
@ -384,6 +389,388 @@ class FabricTopologyTests(unittest.TestCase):
|
||||||
self.db.refresh(view)
|
self.db.refresh(view)
|
||||||
return view.id
|
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:
|
def test_view_crud_and_positions(self) -> None:
|
||||||
suffix = uuid4().hex[:8]
|
suffix = uuid4().hex[:8]
|
||||||
ne = ManagedNE(
|
ne = ManagedNE(
|
||||||
|
|
@ -417,6 +804,420 @@ class FabricTopologyTests(unittest.TestCase):
|
||||||
self.db.delete(ne)
|
self.db.delete(ne)
|
||||||
self.db.commit()
|
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:
|
def test_create_topology_placeholder_on_view(self) -> None:
|
||||||
suffix = uuid4().hex[:8]
|
suffix = uuid4().hex[:8]
|
||||||
view_id = self._canvas(f"Rph-{suffix}")
|
view_id = self._canvas(f"Rph-{suffix}")
|
||||||
|
|
@ -1249,6 +2050,8 @@ class FabricTopologyTests(unittest.TestCase):
|
||||||
vendor="Cisco",
|
vendor="Cisco",
|
||||||
device_type="cisco_ios",
|
device_type="cisco_ios",
|
||||||
ip_address=f"203.0.113.{(int(suffix[:2], 16) % 100) + 1}",
|
ip_address=f"203.0.113.{(int(suffix[:2], 16) % 100) + 1}",
|
||||||
|
username="u",
|
||||||
|
password_enc=encrypt_secret("p"),
|
||||||
)
|
)
|
||||||
ne_b = ManagedNE(
|
ne_b = ManagedNE(
|
||||||
id=f"neb-{suffix}",
|
id=f"neb-{suffix}",
|
||||||
|
|
@ -1256,6 +2059,8 @@ class FabricTopologyTests(unittest.TestCase):
|
||||||
vendor="Cisco",
|
vendor="Cisco",
|
||||||
device_type="cisco_ios",
|
device_type="cisco_ios",
|
||||||
ip_address=f"203.0.113.{(int(suffix[2:4], 16) % 100) + 101}",
|
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_a)
|
||||||
self.db.add(ne_b)
|
self.db.add(ne_b)
|
||||||
|
|
@ -1270,7 +2075,12 @@ class FabricTopologyTests(unittest.TestCase):
|
||||||
"output": CISCO_LLDP_DETAIL,
|
"output": CISCO_LLDP_DETAIL,
|
||||||
"commands": ["show lldp neighbors 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(
|
job = svc.start_discover_job(
|
||||||
self.db,
|
self.db,
|
||||||
FabricDiscoverRequest(scope="ne_ids", ne_ids=[ne_a.id], concurrency=1),
|
FabricDiscoverRequest(scope="ne_ids", ne_ids=[ne_a.id], concurrency=1),
|
||||||
|
|
@ -1422,6 +2232,8 @@ class FabricTopologyTests(unittest.TestCase):
|
||||||
device_type="cisco_ios",
|
device_type="cisco_ios",
|
||||||
ip_address=f"203.0.113.{(int(suffix[:2], 16) % 80) + 40}",
|
ip_address=f"203.0.113.{(int(suffix[:2], 16) % 80) + 40}",
|
||||||
source="manual",
|
source="manual",
|
||||||
|
username="u",
|
||||||
|
password_enc=encrypt_secret("p"),
|
||||||
)
|
)
|
||||||
self.db.add(ne_a)
|
self.db.add(ne_a)
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
|
|
@ -1442,7 +2254,11 @@ Management Addresses:
|
||||||
"output": lldp_out,
|
"output": lldp_out,
|
||||||
"commands": ["show lldp neighbors detail"],
|
"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(
|
job = svc.start_discover_job(
|
||||||
self.db,
|
self.db,
|
||||||
FabricDiscoverRequest(
|
FabricDiscoverRequest(
|
||||||
|
|
@ -2283,7 +3099,9 @@ Management Addresses:
|
||||||
self.db.add(orphan)
|
self.db.add(orphan)
|
||||||
self.db.commit()
|
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.assertGreaterEqual(int(stats.get("purged_orphans") or 0), 1)
|
||||||
self.db.expire_all()
|
self.db.expire_all()
|
||||||
self.assertIsNone(self.db.get(TopoFabricNode, orphan_id))
|
self.assertIsNone(self.db.get(TopoFabricNode, orphan_id))
|
||||||
|
|
|
||||||
|
|
@ -345,20 +345,12 @@ class UmeFabricApplyTests(unittest.TestCase):
|
||||||
edge.source = "ume"
|
edge.source = "ume"
|
||||||
edge.attrs = {"sources": ["ume"], "ume_link_id": "link-1"}
|
edge.attrs = {"sources": ["ume"], "ume_link_id": "link-1"}
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
a = (
|
# Ports are already assigned to the edge's canonical A/B order; pass the
|
||||||
self.db.query(TopoFabricNode)
|
# same node ids so _normalize_endpoints cannot swap ports onto the wrong ends.
|
||||||
.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()
|
|
||||||
)
|
|
||||||
edge2, action = upsert_fabric_edge(
|
edge2, action = upsert_fabric_edge(
|
||||||
self.db,
|
self.db,
|
||||||
a_node_id=a.id,
|
a_node_id=edge.a_node_id,
|
||||||
b_node_id=b.id,
|
b_node_id=edge.b_node_id,
|
||||||
a_port=rich_a,
|
a_port=rich_a,
|
||||||
b_port=rich_b,
|
b_port=rich_b,
|
||||||
source="lldp",
|
source="lldp",
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -399,8 +399,10 @@ export function TopologyCtxMenu({
|
||||||
className="topo-ctx__style-input"
|
className="topo-ctx__style-input"
|
||||||
value={selectedEdgeData.source_port || ""}
|
value={selectedEdgeData.source_port || ""}
|
||||||
placeholder="?"
|
placeholder="?"
|
||||||
|
readOnly={String(selectedEdgeData.source || "").toLowerCase() !== "manual"}
|
||||||
disabled={
|
disabled={
|
||||||
Number(selectedEdgeData.member_count || 1) > 1 && !expandPhysicalLinks
|
String(selectedEdgeData.source || "").toLowerCase() !== "manual" ||
|
||||||
|
(Number(selectedEdgeData.member_count || 1) > 1 && !expandPhysicalLinks)
|
||||||
}
|
}
|
||||||
onFocus={() => onPushHistory()}
|
onFocus={() => onPushHistory()}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
|
|
@ -421,8 +423,10 @@ export function TopologyCtxMenu({
|
||||||
className="topo-ctx__style-input"
|
className="topo-ctx__style-input"
|
||||||
value={selectedEdgeData.target_port || ""}
|
value={selectedEdgeData.target_port || ""}
|
||||||
placeholder="?"
|
placeholder="?"
|
||||||
|
readOnly={String(selectedEdgeData.source || "").toLowerCase() !== "manual"}
|
||||||
disabled={
|
disabled={
|
||||||
Number(selectedEdgeData.member_count || 1) > 1 && !expandPhysicalLinks
|
String(selectedEdgeData.source || "").toLowerCase() !== "manual" ||
|
||||||
|
(Number(selectedEdgeData.member_count || 1) > 1 && !expandPhysicalLinks)
|
||||||
}
|
}
|
||||||
onFocus={() => onPushHistory()}
|
onFocus={() => onPushHistory()}
|
||||||
onChange={(e) =>
|
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 {
|
import {
|
||||||
ReactFlow,
|
ReactFlow,
|
||||||
Background,
|
Background,
|
||||||
|
|
@ -24,6 +31,7 @@ import {
|
||||||
import "@xyflow/react/dist/style.css";
|
import "@xyflow/react/dist/style.css";
|
||||||
import { WorldScatterLayer, type WorldScatterPoint } from "./WorldScatterLayer";
|
import { WorldScatterLayer, type WorldScatterPoint } from "./WorldScatterLayer";
|
||||||
import { ParallelEdge } from "./ParallelEdge";
|
import { ParallelEdge } from "./ParallelEdge";
|
||||||
|
import { fitToNodes } from "../lib/fitNodes";
|
||||||
import { isAggregateEdgeId } from "../lib/linkDisplay";
|
import { isAggregateEdgeId } from "../lib/linkDisplay";
|
||||||
import type { ToolMode, ToolModeBehavior } from "../lib/toolMode";
|
import type { ToolMode, ToolModeBehavior } from "../lib/toolMode";
|
||||||
import type { TopologyViewRole, TopologyWorldTransform } from "../../../types";
|
import type { TopologyViewRole, TopologyWorldTransform } from "../../../types";
|
||||||
|
|
@ -36,6 +44,13 @@ export { isPlaceholderSource };
|
||||||
|
|
||||||
export type NeNodeData = {
|
export type NeNodeData = {
|
||||||
label: string;
|
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;
|
managed_ne_id: string;
|
||||||
ume_ne_id: string;
|
ume_ne_id: string;
|
||||||
ne_ip: string;
|
ne_ip: string;
|
||||||
|
|
@ -215,6 +230,16 @@ const NeNode = memo(function NeNode({ id, data, selected }: NodeProps<Node<NeNod
|
||||||
isConnectable={connectMode && !isRegion}
|
isConnectable={connectMode && !isRegion}
|
||||||
/>
|
/>
|
||||||
{!isRegion ? <RouterIcon /> : <RegionCanvasIcon />}
|
{!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 ? (
|
{showConnectStatus && !isRegion ? (
|
||||||
<span className={`topo-node__conn topo-node__conn--${connect}`} aria-hidden />
|
<span className={`topo-node__conn topo-node__conn--${connect}`} aria-hidden />
|
||||||
) : null}
|
) : null}
|
||||||
|
|
@ -328,6 +353,8 @@ export type TopologyReactFlowViewProps = {
|
||||||
pendingFitRef: { current: boolean };
|
pendingFitRef: { current: boolean };
|
||||||
markDirty: () => void;
|
markDirty: () => void;
|
||||||
pushHistory: () => void;
|
pushHistory: () => void;
|
||||||
|
/** Drop the latest undo frame when a drag was cancelled / no real move. */
|
||||||
|
popHistory: () => void;
|
||||||
onConnect: (connection: Connection) => void;
|
onConnect: (connection: Connection) => void;
|
||||||
isValidConnection: (connection: Connection | Edge) => boolean;
|
isValidConnection: (connection: Connection | Edge) => boolean;
|
||||||
onNodeClick: (e: React.MouseEvent, node: Node<NeNodeData>) => void;
|
onNodeClick: (e: React.MouseEvent, node: Node<NeNodeData>) => void;
|
||||||
|
|
@ -376,6 +403,7 @@ export default function TopologyReactFlowView(props: TopologyReactFlowViewProps)
|
||||||
pendingFitRef,
|
pendingFitRef,
|
||||||
markDirty,
|
markDirty,
|
||||||
pushHistory,
|
pushHistory,
|
||||||
|
popHistory,
|
||||||
onConnect,
|
onConnect,
|
||||||
isValidConnection,
|
isValidConnection,
|
||||||
onNodeClick,
|
onNodeClick,
|
||||||
|
|
@ -392,6 +420,24 @@ export default function TopologyReactFlowView(props: TopologyReactFlowViewProps)
|
||||||
toggleFullscreen,
|
toggleFullscreen,
|
||||||
} = props;
|
} = 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 (
|
return (
|
||||||
<TopoDisplayContext.Provider value={displayOpts}>
|
<TopoDisplayContext.Provider value={displayOpts}>
|
||||||
<TopoAlarmContext.Provider value={alarmByNodeId || emptyAlarmMap}>
|
<TopoAlarmContext.Provider value={alarmByNodeId || emptyAlarmMap}>
|
||||||
|
|
@ -439,13 +485,26 @@ export default function TopologyReactFlowView(props: TopologyReactFlowViewProps)
|
||||||
multiSelectionKeyCode="Shift"
|
multiSelectionKeyCode="Shift"
|
||||||
snapToGrid={snapToGrid}
|
snapToGrid={snapToGrid}
|
||||||
snapGrid={SNAP_GRID}
|
snapGrid={SNAP_GRID}
|
||||||
onNodeDragStart={() => {
|
onNodeDragStart={(_e, _node, dragged) => beginDrag(dragged)}
|
||||||
pushHistory();
|
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>>[]) => {
|
onNodesChange={(changes: NodeChange<Node<NeNodeData>>[]) => {
|
||||||
if (changes.some((c) => c.type === "position" || c.type === "remove" || c.type === "add")) {
|
// Position changes alone (click / select jitter) must not mark dirty; pointer
|
||||||
markDirty();
|
// 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));
|
setNodes((nds) => applyNodeChanges(changes, nds));
|
||||||
}}
|
}}
|
||||||
onEdgesChange={(changes: EdgeChange<Edge>[]) => {
|
onEdgesChange={(changes: EdgeChange<Edge>[]) => {
|
||||||
|
|
@ -522,8 +581,8 @@ export default function TopologyReactFlowView(props: TopologyReactFlowViewProps)
|
||||||
const bounds = isWorldFlatCanvas ? worldDisplayBounds(worldTransform) : null;
|
const bounds = isWorldFlatCanvas ? worldDisplayBounds(worldTransform) : null;
|
||||||
if (bounds) {
|
if (bounds) {
|
||||||
inst.fitBounds(bounds, { padding: 0.12, duration: 0 });
|
inst.fitBounds(bounds, { padding: 0.12, duration: 0 });
|
||||||
} else if (nodesRef.current.length > 0) {
|
} else {
|
||||||
inst.fitView({ ...FIT_VIEW_OPTS, duration: 0 });
|
fitToNodes(inst, nodesRef.current, { padding: FIT_VIEW_OPTS.padding });
|
||||||
}
|
}
|
||||||
setCanvasZoom(inst.getZoom());
|
setCanvasZoom(inst.getZoom());
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ import {
|
||||||
worldVisualLodFromZoom,
|
worldVisualLodFromZoom,
|
||||||
} from "../lib/treeUtils";
|
} from "../lib/treeUtils";
|
||||||
import { mergeFlatWorldGraph } from "../lib/worldGraph";
|
import { mergeFlatWorldGraph } from "../lib/worldGraph";
|
||||||
|
import { fitToNodes } from "../lib/fitNodes";
|
||||||
import type { HistorySnap } from "../lib/pageTypes";
|
import type { HistorySnap } from "../lib/pageTypes";
|
||||||
|
|
||||||
export type UseTopologyCanvasOptions = {
|
export type UseTopologyCanvasOptions = {
|
||||||
|
|
@ -62,6 +63,10 @@ export type UseTopologyCanvasOptions = {
|
||||||
redoRef: React.MutableRefObject<HistorySnap[]>;
|
redoRef: React.MutableRefObject<HistorySnap[]>;
|
||||||
pendingEdgeDeletesRef: React.MutableRefObject<Set<string>>;
|
pendingEdgeDeletesRef: React.MutableRefObject<Set<string>>;
|
||||||
pendingEdgeCreatesRef: 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;
|
clearDirty: () => void;
|
||||||
bumpHistory: () => void;
|
bumpHistory: () => void;
|
||||||
showError: (msg: string) => void;
|
showError: (msg: string) => void;
|
||||||
|
|
@ -102,6 +107,8 @@ export function useTopologyCanvas(opts: UseTopologyCanvasOptions) {
|
||||||
redoRef,
|
redoRef,
|
||||||
pendingEdgeDeletesRef,
|
pendingEdgeDeletesRef,
|
||||||
pendingEdgeCreatesRef,
|
pendingEdgeCreatesRef,
|
||||||
|
pendingEdgePortEditsRef,
|
||||||
|
pendingEdgeStyleRef,
|
||||||
clearDirty,
|
clearDirty,
|
||||||
bumpHistory,
|
bumpHistory,
|
||||||
showError,
|
showError,
|
||||||
|
|
@ -288,7 +295,7 @@ export function useTopologyCanvas(opts: UseTopologyCanvasOptions) {
|
||||||
? worldDisplayBounds(worldTransformRef.current || graphQuery.data?.world_transform)
|
? worldDisplayBounds(worldTransformRef.current || graphQuery.data?.world_transform)
|
||||||
: null;
|
: null;
|
||||||
if (bounds) inst.fitBounds(bounds, { padding: 0.12, duration: 0 });
|
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());
|
setCanvasZoom(inst.getZoom());
|
||||||
}, [isWorldFlatCanvas, graphQuery.data?.world_transform]);
|
}, [isWorldFlatCanvas, graphQuery.data?.world_transform]);
|
||||||
|
|
||||||
|
|
@ -345,14 +352,17 @@ export function useTopologyCanvas(opts: UseTopologyCanvasOptions) {
|
||||||
setEdges(rfEdges);
|
setEdges(rfEdges);
|
||||||
appliedGraphFpRef.current = fp;
|
appliedGraphFpRef.current = fp;
|
||||||
appliedOverlayFpRef.current = overlayFingerprint(graphQuery.data);
|
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) {
|
if (entering) {
|
||||||
appliedMapIdRef.current = mapId;
|
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 hasScatter = (graphQuery.data.scatter?.length || 0) > 0;
|
||||||
const hasWorld = Boolean(worldDisplayBounds(graphQuery.data.world_transform));
|
const hasWorld = Boolean(worldDisplayBounds(graphQuery.data.world_transform));
|
||||||
pendingFitRef.current = nextNodes.length > 0 || hasScatter || hasWorld;
|
pendingFitRef.current = nextNodes.length > 0 || hasScatter || hasWorld;
|
||||||
|
|
@ -373,6 +383,8 @@ export function useTopologyCanvas(opts: UseTopologyCanvasOptions) {
|
||||||
redoRef,
|
redoRef,
|
||||||
pendingEdgeDeletesRef,
|
pendingEdgeDeletesRef,
|
||||||
pendingEdgeCreatesRef,
|
pendingEdgeCreatesRef,
|
||||||
|
pendingEdgePortEditsRef,
|
||||||
|
pendingEdgeStyleRef,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -394,7 +406,7 @@ export function useTopologyCanvas(opts: UseTopologyCanvasOptions) {
|
||||||
if (bounds) {
|
if (bounds) {
|
||||||
inst.fitBounds(bounds, { padding: 0.12, duration: 0 });
|
inst.fitBounds(bounds, { padding: 0.12, duration: 0 });
|
||||||
} else {
|
} else {
|
||||||
inst.fitView({ ...FIT_VIEW_OPTS, duration: 0 });
|
fitToNodes(inst, nodesRef.current, { padding: FIT_VIEW_OPTS.padding });
|
||||||
}
|
}
|
||||||
setCanvasZoom(inst.getZoom());
|
setCanvasZoom(inst.getZoom());
|
||||||
};
|
};
|
||||||
|
|
@ -509,12 +521,9 @@ export function useTopologyCanvas(opts: UseTopologyCanvasOptions) {
|
||||||
}
|
}
|
||||||
|
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
rfRef.current?.fitView({
|
const inst = rfRef.current;
|
||||||
nodes: [{ id: nodeId }],
|
const target = nodesRef.current.filter((n) => n.id === nodeId);
|
||||||
padding: 0.45,
|
if (inst) fitToNodes(inst, target, { padding: 0.45, duration: 280, minWidth: 600, minHeight: 360 });
|
||||||
duration: 280,
|
|
||||||
includeHiddenNodes: true,
|
|
||||||
});
|
|
||||||
}, 30);
|
}, 30);
|
||||||
},
|
},
|
||||||
[focusNode, focusWorldAround, showError, t, setSearchHitIds, searchHitTimerRef],
|
[focusNode, focusWorldAround, showError, t, setSearchHitIds, searchHitTimerRef],
|
||||||
|
|
|
||||||
|
|
@ -190,7 +190,6 @@ export function useTopologyCreateNe({
|
||||||
fabric_node_id: added.fabric_node_id,
|
fabric_node_id: added.fabric_node_id,
|
||||||
x: at.flowX,
|
x: at.flowX,
|
||||||
y: at.flowY,
|
y: at.flowY,
|
||||||
label: added.label || added.name || item.name || "",
|
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
graph = await fetchTopologyGraph(mapId);
|
graph = await fetchTopologyGraph(mapId);
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,8 @@ export type UseTopologyDiscoverOptions = {
|
||||||
appliedMapIdRef: React.MutableRefObject<string>;
|
appliedMapIdRef: React.MutableRefObject<string>;
|
||||||
markDirty: () => void;
|
markDirty: () => void;
|
||||||
clearDirty: () => void;
|
clearDirty: () => void;
|
||||||
|
/** Prefer atomic /commit when provided (T6). */
|
||||||
|
commitCanvas?: () => Promise<unknown>;
|
||||||
showOk: (msg: string) => void;
|
showOk: (msg: string) => void;
|
||||||
showError: (msg: string) => void;
|
showError: (msg: string) => void;
|
||||||
t: (key: string) => string;
|
t: (key: string) => string;
|
||||||
|
|
@ -72,6 +74,7 @@ export function useTopologyDiscover(opts: UseTopologyDiscoverOptions) {
|
||||||
appliedMapIdRef,
|
appliedMapIdRef,
|
||||||
markDirty,
|
markDirty,
|
||||||
clearDirty,
|
clearDirty,
|
||||||
|
commitCanvas,
|
||||||
showOk,
|
showOk,
|
||||||
showError,
|
showError,
|
||||||
t,
|
t,
|
||||||
|
|
@ -95,6 +98,10 @@ export function useTopologyDiscover(opts: UseTopologyDiscoverOptions) {
|
||||||
|
|
||||||
const flushDirtyCanvas = useCallback(async () => {
|
const flushDirtyCanvas = useCallback(async () => {
|
||||||
if (!mapId || !dirtyRef.current) return;
|
if (!mapId || !dirtyRef.current) return;
|
||||||
|
if (commitCanvas) {
|
||||||
|
await commitCanvas();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const createIds = [...pendingEdgeCreatesRef.current];
|
const createIds = [...pendingEdgeCreatesRef.current];
|
||||||
for (const id of createIds) {
|
for (const id of createIds) {
|
||||||
const e = edges.find((x) => x.id === id);
|
const e = edges.find((x) => x.id === id);
|
||||||
|
|
@ -136,6 +143,7 @@ export function useTopologyDiscover(opts: UseTopologyDiscoverOptions) {
|
||||||
pendingEdgeCreatesRef,
|
pendingEdgeCreatesRef,
|
||||||
pendingEdgeDeletesRef,
|
pendingEdgeDeletesRef,
|
||||||
clearDirty,
|
clearDirty,
|
||||||
|
commitCanvas,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const runDiscover = useCallback(
|
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 historyLockRef = useRef(false);
|
||||||
const pendingEdgeDeletesRef = useRef<Set<string>>(new Set());
|
const pendingEdgeDeletesRef = useRef<Set<string>>(new Set());
|
||||||
const pendingEdgeCreatesRef = 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 canUndo = historyTick >= 0 && historyRef.current.length > 0;
|
||||||
const canRedo = historyTick >= 0 && redoRef.current.length > 0;
|
const canRedo = historyTick >= 0 && redoRef.current.length > 0;
|
||||||
|
|
@ -48,6 +54,8 @@ export function useTopologyHistory(
|
||||||
edges: edges.map((e) => ({ ...e })),
|
edges: edges.map((e) => ({ ...e })),
|
||||||
pendingEdgeDeletes: [...pendingEdgeDeletesRef.current],
|
pendingEdgeDeletes: [...pendingEdgeDeletesRef.current],
|
||||||
pendingEdgeCreates: [...pendingEdgeCreatesRef.current],
|
pendingEdgeCreates: [...pendingEdgeCreatesRef.current],
|
||||||
|
pendingEdgePortEdits: [...pendingEdgePortEditsRef.current.entries()],
|
||||||
|
pendingEdgeStyles: [...pendingEdgeStyleRef.current.entries()],
|
||||||
}),
|
}),
|
||||||
[nodes, edges],
|
[nodes, edges],
|
||||||
);
|
);
|
||||||
|
|
@ -59,6 +67,14 @@ export function useTopologyHistory(
|
||||||
bumpHistory();
|
bumpHistory();
|
||||||
}, [snapshot, 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 undo = useCallback(() => {
|
||||||
const prev = historyRef.current.pop();
|
const prev = historyRef.current.pop();
|
||||||
if (!prev) return;
|
if (!prev) return;
|
||||||
|
|
@ -68,6 +84,8 @@ export function useTopologyHistory(
|
||||||
setEdges(prev.edges);
|
setEdges(prev.edges);
|
||||||
pendingEdgeDeletesRef.current = new Set(prev.pendingEdgeDeletes);
|
pendingEdgeDeletesRef.current = new Set(prev.pendingEdgeDeletes);
|
||||||
pendingEdgeCreatesRef.current = new Set(prev.pendingEdgeCreates || []);
|
pendingEdgeCreatesRef.current = new Set(prev.pendingEdgeCreates || []);
|
||||||
|
pendingEdgePortEditsRef.current = new Map(prev.pendingEdgePortEdits || []);
|
||||||
|
pendingEdgeStyleRef.current = new Map(prev.pendingEdgeStyles || []);
|
||||||
markDirty();
|
markDirty();
|
||||||
bumpHistory();
|
bumpHistory();
|
||||||
historyLockRef.current = false;
|
historyLockRef.current = false;
|
||||||
|
|
@ -82,6 +100,8 @@ export function useTopologyHistory(
|
||||||
setEdges(next.edges);
|
setEdges(next.edges);
|
||||||
pendingEdgeDeletesRef.current = new Set(next.pendingEdgeDeletes);
|
pendingEdgeDeletesRef.current = new Set(next.pendingEdgeDeletes);
|
||||||
pendingEdgeCreatesRef.current = new Set(next.pendingEdgeCreates || []);
|
pendingEdgeCreatesRef.current = new Set(next.pendingEdgeCreates || []);
|
||||||
|
pendingEdgePortEditsRef.current = new Map(next.pendingEdgePortEdits || []);
|
||||||
|
pendingEdgeStyleRef.current = new Map(next.pendingEdgeStyles || []);
|
||||||
markDirty();
|
markDirty();
|
||||||
bumpHistory();
|
bumpHistory();
|
||||||
historyLockRef.current = false;
|
historyLockRef.current = false;
|
||||||
|
|
@ -97,11 +117,14 @@ export function useTopologyHistory(
|
||||||
historyLockRef,
|
historyLockRef,
|
||||||
pendingEdgeDeletesRef,
|
pendingEdgeDeletesRef,
|
||||||
pendingEdgeCreatesRef,
|
pendingEdgeCreatesRef,
|
||||||
|
pendingEdgePortEditsRef,
|
||||||
|
pendingEdgeStyleRef,
|
||||||
markDirty,
|
markDirty,
|
||||||
clearDirty,
|
clearDirty,
|
||||||
bumpHistory,
|
bumpHistory,
|
||||||
confirmDiscardIfDirty,
|
confirmDiscardIfDirty,
|
||||||
pushHistory,
|
pushHistory,
|
||||||
|
popHistory,
|
||||||
undo,
|
undo,
|
||||||
redo,
|
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 position = isRegion ? regionFlowPosition(apiX, apiY) : neFlowPosition(apiX, apiY);
|
||||||
const hx = isRegion ? TOPO_REGION_HANDLE_X : TOPO_HANDLE_X;
|
const hx = isRegion ? TOPO_REGION_HANDLE_X : TOPO_HANDLE_X;
|
||||||
const hy = isRegion ? TOPO_REGION_HANDLE_Y : TOPO_HANDLE_Y;
|
const hy = isRegion ? TOPO_REGION_HANDLE_Y : TOPO_HANDLE_Y;
|
||||||
|
const locked = Boolean(n.locked);
|
||||||
|
const labelOverride = String(n.label_override ?? "");
|
||||||
return {
|
return {
|
||||||
id: n.fabric_node_id,
|
id: n.fabric_node_id,
|
||||||
type: "neNode",
|
type: "neNode",
|
||||||
position,
|
position,
|
||||||
width: TOPO_NODE_W,
|
width: TOPO_NODE_W,
|
||||||
height: isRegion ? TOPO_REGION_ICON_H : TOPO_NODE_H,
|
height: isRegion ? TOPO_REGION_ICON_H : TOPO_NODE_H,
|
||||||
|
draggable: locked ? false : undefined,
|
||||||
handles: [
|
handles: [
|
||||||
{ type: "target", position: "left" as Position, x: hx, y: hy },
|
{ type: "target", position: "left" as Position, x: hx, y: hy },
|
||||||
{ type: "source", position: "right" as Position, x: hx, y: hy },
|
{ type: "source", position: "right" as Position, x: hx, y: hy },
|
||||||
],
|
],
|
||||||
data: {
|
data: {
|
||||||
label: n.label || n.name || n.ip || n.fabric_node_id,
|
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 || "",
|
managed_ne_id: n.managed_ne_id || "",
|
||||||
ume_ne_id: n.ume_ne_id || "",
|
ume_ne_id: n.ume_ne_id || "",
|
||||||
ne_ip: n.ip || "",
|
ne_ip: n.ip || "",
|
||||||
|
|
@ -93,12 +100,23 @@ export function flowToPositions(nodes: Node<NeNodeData>[]) {
|
||||||
const pos = isRegion
|
const pos = isRegion
|
||||||
? regionApiPosition(n.position.x, n.position.y)
|
? regionApiPosition(n.position.x, n.position.y)
|
||||||
: neApiPosition(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,
|
fabric_node_id: n.id,
|
||||||
x: pos.x,
|
x: pos.x,
|
||||||
y: pos.y,
|
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[];
|
edges: Edge[];
|
||||||
pendingEdgeDeletes: string[];
|
pendingEdgeDeletes: string[];
|
||||||
pendingEdgeCreates: 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";
|
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",
|
save: "Save",
|
||||||
saveDirty: "Save *",
|
saveDirty: "Save *",
|
||||||
unsavedConfirm: "You have unsaved changes. Discard and switch maps?",
|
unsavedConfirm: "You have unsaved changes. Discard and switch maps?",
|
||||||
|
unsavedSaveBeforeAdd: "You have unsaved changes. Save and continue adding?",
|
||||||
saving: "Saving…",
|
saving: "Saving…",
|
||||||
saved: "Topology saved",
|
saved: "Topology saved",
|
||||||
|
savedLockedSkipped: "Topology saved ({{count}} locked node(s) skipped)",
|
||||||
saveFailed: "Save failed: {{detail}}",
|
saveFailed: "Save failed: {{detail}}",
|
||||||
|
renameNodeClearHint: "Leave empty to follow the device name",
|
||||||
readOnlyBanner: "Read-only (ne:write scope required to edit)",
|
readOnlyBanner: "Read-only (ne:write scope required to edit)",
|
||||||
readOnlyHint: "Read-only — no write permission",
|
readOnlyHint: "Read-only — no write permission",
|
||||||
discoverTimeout: "Discovery timed out; check LLDP tasks for progress",
|
discoverTimeout: "Discovery timed out; check LLDP tasks for progress",
|
||||||
|
|
|
||||||
|
|
@ -112,9 +112,12 @@ const topology = {
|
||||||
save: "保存",
|
save: "保存",
|
||||||
saveDirty: "保存 *",
|
saveDirty: "保存 *",
|
||||||
unsavedConfirm: "有未保存的更改,确定丢弃并切换吗?",
|
unsavedConfirm: "有未保存的更改,确定丢弃并切换吗?",
|
||||||
|
unsavedSaveBeforeAdd: "当前有未保存修改,保存后继续添加?",
|
||||||
saving: "保存中…",
|
saving: "保存中…",
|
||||||
saved: "拓扑已保存",
|
saved: "拓扑已保存",
|
||||||
|
savedLockedSkipped: "拓扑已保存({{count}} 个锁定节点未移动)",
|
||||||
saveFailed: "保存失败:{{detail}}",
|
saveFailed: "保存失败:{{detail}}",
|
||||||
|
renameNodeClearHint: "留空则恢复跟随设备名",
|
||||||
readOnlyBanner: "只读模式(需要 ne:write 权限才能编辑)",
|
readOnlyBanner: "只读模式(需要 ne:write 权限才能编辑)",
|
||||||
readOnlyHint: "只读模式,无编辑权限",
|
readOnlyHint: "只读模式,无编辑权限",
|
||||||
discoverTimeout: "发现任务超时,请稍后在 LLDP 任务页查看进度",
|
discoverTimeout: "发现任务超时,请稍后在 LLDP 任务页查看进度",
|
||||||
|
|
|
||||||
|
|
@ -132,6 +132,14 @@ export const deleteTopologyView = (viewId: string, force = false) =>
|
||||||
`/v1/topology/views/${encodeURIComponent(viewId)}?force=${force ? "true" : "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 = (
|
export const patchTopologyPositions = (
|
||||||
viewId: string,
|
viewId: string,
|
||||||
positions: Array<{
|
positions: Array<{
|
||||||
|
|
@ -141,10 +149,17 @@ export const patchTopologyPositions = (
|
||||||
label?: string;
|
label?: string;
|
||||||
locked?: boolean;
|
locked?: boolean;
|
||||||
}>,
|
}>,
|
||||||
) =>
|
opts?: { return_graph?: boolean },
|
||||||
apiPatch<TopologyViewGraph>(`/v1/topology/views/${encodeURIComponent(viewId)}/positions`, {
|
) => {
|
||||||
positions,
|
const returnGraph = opts?.return_graph !== false;
|
||||||
});
|
return apiPatch<TopologyViewGraph | TopologyPositionsMutation>(
|
||||||
|
`/v1/topology/views/${encodeURIComponent(viewId)}/positions`,
|
||||||
|
{
|
||||||
|
positions,
|
||||||
|
return_graph: returnGraph,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const addTopologyViewNodes = (
|
export const addTopologyViewNodes = (
|
||||||
viewId: string,
|
viewId: string,
|
||||||
|
|
@ -401,6 +416,62 @@ export const deleteFabricEdge = (edgeId: string) =>
|
||||||
`/v1/topology/fabric/edges/${encodeURIComponent(edgeId)}`,
|
`/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. */
|
/** Back-compat aliases used by older call sites during cutover. */
|
||||||
export const fetchTopologyMaps = fetchTopologyViews;
|
export const fetchTopologyMaps = fetchTopologyViews;
|
||||||
export const createTopologyMap = createTopologyView;
|
export const createTopologyMap = createTopologyView;
|
||||||
|
|
|
||||||
|
|
@ -3086,6 +3086,22 @@
|
||||||
opacity: 0.92;
|
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 {
|
.topo-node__badge {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -4px;
|
top: -4px;
|
||||||
|
|
|
||||||
|
|
@ -115,6 +115,8 @@ export type TopologyViewNodeItem = {
|
||||||
managed_ne_id: string;
|
managed_ne_id: string;
|
||||||
ume_ne_id: string;
|
ume_ne_id: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
/** Raw placement override; empty means follow device name/ip. */
|
||||||
|
label_override?: string;
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
locked: boolean;
|
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