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>
23 KiB
拓扑管理问题分析与优化计划
日期: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. 结论摘要
整体架构没有大问题(服务端图 + 本地编辑 + 一次保存)。主要问题集中在**“本地编辑状态”和“服务端状态”的同步边界**,有几处会静默丢数据或误删数据:
- UI 保存会清掉 MCP 设置的节点锁定,并把设备名固化成标签。 [已验证] 保存后
locked从 True 变为 False,节点被移走;之后设备改名,画布上仍显示旧名称。 - 有未保存修改时“添加网元/投影邻居”会静默丢弃这些修改,并留下隐形删除。 被删除的连线会重新出现在画布上,但仍留在待删除队列里,下次保存时会被真正删掉。
- 修改已有连线的端口不会被保存。 界面上可以改,也会显示新值,但不会标记为未保存,保存时也不会提交,刷新后就恢复原值。
- 连线样式绕过了“编辑→保存”模型。 样式修改会立即写库,但撤销只回退本地。撤销之后,画布显示和数据库就不一致了。
- 保存不是原子的。 一次保存会依次调用 N 次建边、1 次删边、1 次加点、1 次删点和 1 次写坐标,其中 3 次会返回完整的图数据。中途失败会留下“部分已保存”的状态。另外,同一套保存逻辑在
purgePlaceholderById里重复实现了一遍。 - 后端有 N+1 查询和不确定的截断。 写坐标时每个点各查一次;外部邻居查询的
limit(5000)把视图内部的边也算进去了,边密集的视图可能找不到外部邻居;节点超过上限时截断没有排序。 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。
修改:
- 后端:把
ViewNodeIn.locked改为bool | None = None,None表示保持原值。对已锁定的行,只有请求里明确带locked=False才能移动(解锁);否则跳过并计入skipped_locked。MCP 显式传locked的行为保持不变。 - 前端:在
NeNodeData里加locked(由graphToFlow从n.locked填入)。锁定的节点设draggable: false,并显示一个锁形角标;flowToPositions不再发送locked。 - 保存结果里如果
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。
修改:
- 后端
ViewNodeOut增加label_override: str(原始vn.label);label仍是回退后的显示值,保持兼容。ViewNodeIn.label改为str | None = None,None表示不修改。 - 前端
NeNodeData增加label_override,只有用户“重命名节点”(renameSelectedNode,TopologyPage.tsx:820)时才写入。flowToPositions只对label_override有变化的节点发送label;重命名为空字符串表示清除覆盖,恢复跟随设备名。 - 数据修复脚本
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在非脏状态下整体刷新时,也不会清理撤销栈。
修改(推荐“先保存再添加”,简单而且语义清楚):
- 抽出
commitCanvas()(见 T6)。addPaletteItems/addOutsidePeers开始时如果dirtyRef.current为 true,就弹窗“当前有未保存修改,保存后继续添加?”:确认则先commitCanvas()再添加,取消则中止。 - 新增一个统一的辅助函数
resetLocalFromServer(graph),用于所有“用服务端图整体覆盖本地”的地方:同时清空两个 pending 集合、撤销栈和重做栈,然后clearDirty()。目前purgePlaceholderById(:1289-1297) 是手写的这套逻辑,其他地方都漏了。
验收(手工,Cursor 需要在 PR 描述里写明步骤和结果):
- 删除一条已有边 → 不保存 → 从面板拖入一个网元 → 应弹出确认框;选择保存后,该边被删除,新网元出现。
- 同上,但选择取消 → 不添加网元,删除操作仍处于未保存状态。
- 删除边 → 投影邻居(确认保存)→ 刷新页面,边确实被删除,没有“复活后又消失”。
- 用 grep 检查:所有
applyViewGraph(调用点要么使用localPositions合并,要么经过resetLocalFromServer。
T4 [P0] 修改已有连线的端口不会被保存
- 右键菜单对任意单条边都显示端口输入框 (
TopologyCtxMenu.tsx:400-429)。 patchSelectedEdgeStyle修改本地端口后,只把样式字段发给PATCH /edge-style(ViewEdgeStylePatch本来就没有端口字段,topology_schemas.py:450),也没有markDirty()。- 保存只处理
pendingEdgeCreatesRef里的新边,已有边的端口修改永远不会提交。
修改:
- 只有
source === "manual"的边可以编辑端口;LLDP/UME 边的端口改为只读(这两类以采集结果为准,手工修改会在下次发现时被覆盖)。 - 未保存的新手工边:端口修改继续只改本地,保存时随建边一起提交(现有逻辑已支持)。
- 已保存的手工边:新增
PATCH /v1/topology/fabric/edges/{id},body{a_port, b_port},只允许source=manual,否则 400edge_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),重试不会建出重复边,但删点和写坐标的顺序问题依然存在。
修改:
- 后端新增
POST /v1/topology/views/{id}/commit,在一个事务里依次处理:
任何一步失败都整体回滚,返回 4xx 和{ "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}] }{"failed_step": "...", "detail": "..."}。成功时只返回一次完整图数据,外加edge_id_map: {client_id: server_id}。节点上限和冻结规则与add_nodes_to_view一致。 - 前端新增
hooks/useTopologySave.ts,导出commitCanvas():从本地状态计算差异(纯函数lib/saveDiff.ts,可以单独测试),调用/commit,成功后调用resetLocalFromServer。saveMut、purgePlaceholderById(:1248-1281)、T3 的“先保存”都改用它。 - 旧的分步接口保留(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、umeQueryhooks/useTopologyEdgeEditing.ts:patchSelectedEdgeStyle、queueDeleteEdges、removeSelected、onConnect、isValidConnectionhooks/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. 建议实施顺序
- T11(先把基线修到全部通过,后面每一步的回归才有意义)
- T1 + T2(后端 schema 改动,两项一起做,再加数据修复脚本)
- T6(commit 接口 +
useTopologySave),然后在它之上做 T3、T4、T5 - T7、T8
- T9,最后做 T10(纯重构,单独一个 PR)
每一步单独提交;T10 必须单独成 PR。
4. 回归命令与手工清单
python -m pytest tests/test_topology.py tests/test_ume_topology_apply.py -q
cd web && npx tsc -b
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 描述):
- 拖动节点 → 保存 → 刷新,位置保持。
- 新建手工连线(带端口)→ 保存 → 刷新,连线和端口都在。
- 删除连线 → 撤销 → 保存,连线仍然存在。
- 删除节点 → 保存,节点从视图中移除,但 fabric 中仍存在(可以通过搜索找到)。
- 重命名节点 → 保存 → 刷新,显示新名称;再把名称清空 → 保存,恢复为设备名。
- 打开 live sync,另一个窗口用 MCP 移动节点,3 秒内本窗口同步;本窗口有未保存修改时不会被覆盖,只刷新在线状态。
- 只读用户(没有
ne:write):不能拖动、连线、保存,右键菜单里没有写操作。 - 删除占位网元(有未保存修改时),修改先被保存,占位网元被清理。
- 导出 SVG/XML 正常。
5. 验收记录(2026-10-12)
Cursor 完成 T1–T11 后由 Claude 验收并接手修复,结论:主体实现正确,另修 6 个问题(均补了回归测试):
/commit同次node_adds的节点坐标被忽略(autoflush=False下重查看不到新行)→ 先flush;_apply_positions的逐点db.get改为批量。- T8 收紧后,无放置行的旧子区域图标拖动无法保存 →
add_missing_region_placement(仅限本画布的手工子区域)。 - MCP
updateTopologyViewPositions总是发送label:""、locked:false,会清掉 UI 重命名并解锁 → 未提供则不发送。 - 已改样式/端口的边被删除后保存 404 → 前端
saveDiff与后端都跳过已删边;新建本地边的样式经edge_id_map映射。 - 框选拖动、方向键移动不标记未保存 → 补
onSelectionDrag*和键盘移动判定。 - (改动前已存在)大视图打开空白、远处节点定位失效:xyflow
fitView等待节点测量,而onlyRenderVisibleElements下视口外节点永不测量 →lib/fitNodes.ts按已知几何计算fitBounds。
开发库数据修复:topology_clear_frozen_labels.py --apply 清理 516 条固化标签(执行前已备份原值)。
回归:拓扑后端 91 通过;Web Node 测试 30 通过;tsc -b 通过;MCP 包 238 通过 / 5 失败(与改动前同一批,既有问题)。