diff --git a/.cursor/skills/netx-topology/SKILL.md b/.cursor/skills/netx-topology/SKILL.md index 6f77761..68ec148 100644 --- a/.cursor/skills/netx-topology/SKILL.md +++ b/.cursor/skills/netx-topology/SKILL.md @@ -19,14 +19,14 @@ disable-model-invocation: false 3. **只动已有 Fabric**:禁止 `managed_ne_ids` / `ume_ne_ids`;禁止臆造 fabric id。 4. **不污染 Fabric**:不能手工建链、不能 populate、不能删 Fabric / 整图。邻居用 `projectTopologyNeighbors`。 5. **写权限**:需要 `ne:write`。tools/list 没有写工具 → 停,让用户用「MCP + 拓扑写」签发 Token。 -6. **无区域则先建**:`createTopologyView` 需要 `folder_id`;没有合适区域时用 `createTopologyFolder`(API 会自动建默认 physical 图)。 +6. **无区域则先建**:`createTopologyView` 需要 `folder_id`;没有合适区域时用 `createTopologyFolder`,再 `createTopologyView` 建画布。 7. **单画布硬顶 2000**:满了 `truncated` / 触顶 → 新建另一张画布继续;全网五万设备靠多画布切片。 ## 推荐流水线(从零画一张图) ``` 1 getTopologyTree → 看有无区域;没有则 createTopologyFolder → folder_id -2 createTopologyView(或用区域自带的 physical 图)→ view_id +2 createTopologyView → view_id 3 addTopologyViewNodes(keyword=…, limit) → 看 added / next_offset,循环 offset 直到无更多或满 cap 4 projectTopologyNeighbors → 可选 5 updateTopologyViewPositions(layout=grid|offset|stack, keyword=…) → API 自己筛并摆 diff --git a/docs/MCP_TOPOLOGY.md b/docs/MCP_TOPOLOGY.md index 3717c41..bafadd6 100644 --- a/docs/MCP_TOPOLOGY.md +++ b/docs/MCP_TOPOLOGY.md @@ -80,7 +80,7 @@ oclaw:Install from JSON → Health → Sync Tools(应看到 **14** 个工具 | 工具 | 作用 | |------|------| -| `createTopologyFolder` | 新建**区域**文件夹(挂在根下);返回 `id` 供建画布;并自动创建默认 physical 图 | +| `createTopologyFolder` | 新建**区域**文件夹(挂在根下);返回 `id`;**不**自动建画布,需再调 `createTopologyView` | | `createTopologyView` | 在 folder 下新建画布 | | `addTopologyViewNodes` | **优先**传 `keyword`/`role`/`vendor`/`link_status` + `limit`/`offset`,由 API 筛选落点;也可 `fabric_node_ids`。拒绝 managed/UME。返回摘要。 | | `removeTopologyViewNodes` | 筛选或 id 从画布移除(不删 Fabric),摘要 | diff --git a/netx_api/topology_views_tree.py b/netx_api/topology_views_tree.py index 93b6c94..9944903 100644 --- a/netx_api/topology_views_tree.py +++ b/netx_api/topology_views_tree.py @@ -160,7 +160,7 @@ def ensure_region_physical_view(db: Session, folder_id: str, *, commit: bool = T def bootstrap_topology_tree(db: Session) -> dict[str, str]: - """Ensure hidden system root; flatten legacy nesting; ensure physical map per site.""" + """Ensure hidden system root; flatten legacy nesting.""" now = _utcnow() root = ( db.query(TopoFolder) @@ -221,11 +221,6 @@ def bootstrap_topology_tree(db: Session) -> dict[str, str]: if changed: v.updated_at = now - # Ensure every region has a physical map. - regions = db.query(TopoFolder).filter(TopoFolder.kind == "region").all() - for region in regions: - ensure_region_physical_view(db, region.id, commit=False) - db.commit() return {"root_id": root.id} @@ -258,7 +253,6 @@ def create_folder(db: Session, body: TopologyFolderCreate) -> TopologyFolderOut: ) db.add(row) db.flush() - ensure_region_physical_view(db, row.id, commit=False) db.commit() db.refresh(row) return _folder_out(row) @@ -295,8 +289,6 @@ def update_folder(db: Session, folder_id: str, body: TopologyFolderUpdate) -> To def delete_folder(db: Session, folder_id: str, *, force: bool = False) -> dict[str, Any]: """Delete a region and cascade-delete its maps. - Every region has a default physical map, so folder delete must purge views - itself (cannot call ``delete_view``, which recreates physical). ``force`` is accepted for API compatibility; cascade always runs. """ row = _get_folder_or_404(db, folder_id) @@ -489,7 +481,6 @@ def update_view(db: Session, view_id: str, body: TopologyViewUpdate) -> Topology def delete_view(db: Session, view_id: str, *, force: bool = False) -> dict[str, Any]: row = _get_view_or_404(db, view_id) - folder_id = str(row.folder_id or "") is_physical = normalize_view_kind(row.kind) == VIEW_KIND_PHYSICAL if is_physical and not force: raise HTTPException(status_code=400, detail="cannot_delete_physical_view") @@ -498,9 +489,6 @@ def delete_view(db: Session, view_id: str, *, force: bool = False) -> dict[str, ) db.query(TopoViewNode).filter(TopoViewNode.view_id == row.id).delete(synchronize_session=False) db.delete(row) - db.flush() - if folder_id and is_physical: - ensure_region_physical_view(db, folder_id, commit=False) db.commit() return {"ok": True, "view_id": view_id, "deleted": True} diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/http_tools.py b/packages/netx-topology-mcp/src/netx_topology_mcp/http_tools.py index bcc4d8b..fac88cc 100644 --- a/packages/netx-topology-mcp/src/netx_topology_mcp/http_tools.py +++ b/packages/netx-topology-mcp/src/netx_topology_mcp/http_tools.py @@ -381,7 +381,7 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [ "name": "createTopologyFolder", "description": ( "Create a region folder under the topology root. Returns folder id for createTopologyView. " - "Also auto-creates a default physical map under the region. Requires ne:write." + "Does not create a canvas — call createTopologyView next. Requires ne:write." ), "inputSchema": { "type": "object", diff --git a/tests/test_topology.py b/tests/test_topology.py index 8e644f3..9a34c4e 100644 --- a/tests/test_topology.py +++ b/tests/test_topology.py @@ -368,11 +368,11 @@ class FabricTopologyTests(unittest.TestCase): region = svc.create_folder( self.db, TopologyFolderCreate(name="East", kind="region") ) - # Creating a site auto-creates a physical map. + # Creating a region does not auto-create a map — user/MCP adds views. tree1 = svc.get_topology_tree(self.db) assert tree1.root is not None east0 = next(c for c in tree1.root.children if c.id == region.id) - self.assertTrue(any(v.kind == "physical" for v in east0.views)) + self.assertEqual(east0.views, []) view = svc.create_view( self.db, @@ -390,20 +390,27 @@ class FabricTopologyTests(unittest.TestCase): assert tree2.root is not None east = next(c for c in tree2.root.children if c.id == region.id) self.assertTrue(any(v.id == view.id for v in east.views)) - # Flat siblings: physical + custom - self.assertGreaterEqual(len(east.views), 2) + self.assertEqual(len(east.views), 1) self.assertTrue(all(not getattr(v, "children", None) for v in east.views)) def test_site_physical_and_custom_flat(self) -> None: region = svc.create_folder( self.db, TopologyFolderCreate(name="Site-R", kind="region") ) + phys = svc.create_view( + self.db, + TopologyViewCreate( + name="Physical", + folder_id=region.id, + kind="physical", + ), + ) tree = svc.get_topology_tree(self.db) assert tree.root is not None reg = next(c for c in tree.root.children if c.id == region.id) physicals = [v for v in reg.views if v.kind == "physical"] self.assertEqual(len(physicals), 1) - phys = physicals[0] + self.assertEqual(physicals[0].id, phys.id) custom = svc.create_view( self.db, @@ -425,12 +432,12 @@ class FabricTopologyTests(unittest.TestCase): with self.assertRaises(Exception): svc.delete_view(self.db, phys.id) svc.delete_view(self.db, custom.id) - # force-delete physical recreates a fresh physical map + # force-delete physical does not recreate another map svc.delete_view(self.db, phys.id, force=True) tree3 = svc.get_topology_tree(self.db) assert tree3.root is not None reg3 = next(c for c in tree3.root.children if c.id == region.id) - self.assertEqual(sum(1 for v in reg3.views if v.kind == "physical"), 1) + self.assertEqual(sum(1 for v in reg3.views if v.kind == "physical"), 0) def test_delete_region_cascades_views(self) -> None: region = svc.create_folder( @@ -447,7 +454,7 @@ class FabricTopologyTests(unittest.TestCase): tree = svc.get_topology_tree(self.db) assert tree.root is not None reg = next(c for c in tree.root.children if c.id == region.id) - self.assertGreaterEqual(len(reg.views), 2) + self.assertEqual(len(reg.views), 1) out = svc.delete_folder(self.db, region.id) self.assertTrue(out.get("deleted")) diff --git a/web/WEB.md b/web/WEB.md index 3084f75..f44811b 100644 --- a/web/WEB.md +++ b/web/WEB.md @@ -129,7 +129,7 @@ src/ - 删除运维网元:解绑 `fabric.managed_ne_id`(及 view membership),**不删**上图点/边;UME 库存 reconcile 对称解绑 `ume_ne_id`。历史悬空引用:`POST /fabric/reconcile-links`(亦并入 `cleanup-duplicates`)。 - 事实库:`topo_fabric_node` / `topo_fabric_edge`(按 5 万网元 / 100 万链路设计;物理层仅 LLDP) - 站点树:`topo_folder`(系统隐藏 `root`;用户新建站点/区域,无默认「未分区」) -- 拓扑图:`topo_view.kind=physical|custom`(同站点下平级;建站自动建物理拓扑)+ `topo_view_node` +- 拓扑图:`topo_view.kind=physical|custom`(同站点下平级;建区域后需手工/MCP 建画布)+ `topo_view_node` - 边界:图 `filter.membership`(max_nodes / expand_hops / frozen);`project-neighbors` / `populate` 不得无界灌全网 - API:`/v1/topology/tree`、`/folders*`、`/fabric/*`、`/views*`(含 `populate`、`kind`) - 前端:左侧站点→物理/自定义图;右侧目录浏览,打开本图进设备画布;「添加网元」支持勾选批量上图(也可拖放单台) diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index c207191..8e7c126 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -1260,7 +1260,7 @@ const en = { maps: "Maps", tree: "Topology tree", treeLoading: "Loading tree…", - treeHint: "Regions hold a physical map plus optional custom maps; open a map to edit the canvas.", + treeHint: "Regions hold maps you create; open a map to edit the canvas.", treeSearch: "Search NEs across maps", treeSearchPh: "Name / IP", treeSearchEmpty: "No matching NEs", @@ -1279,7 +1279,7 @@ const en = { layerNodeEmpty: "Click to create & open", rootCanvasHint: "Pick a region from the left tree.", regionCanvasHint: "Open a map to edit devices, or create a custom map.", - regionBrowseHint: "Region folder: physical + custom maps. Click to open.", + regionBrowseHint: "Region folder: maps you add. Click to open.", browserTitle: "Topology folder", browserRegionSub: "{{count}} map(s)", browserRegionsSub: "{{count}} region(s)", @@ -1292,7 +1292,7 @@ const en = { regionCreated: "Region created", regionDeleted: "Region deleted", deleteRegion: "Delete region", - deleteRegionConfirm: "Delete region \"{{name}}\"? Its physical and custom maps will also be deleted.", + deleteRegionConfirm: "Delete region \"{{name}}\"? Its maps will also be deleted.", renameRegion: "Rename region", renameRegionPrompt: "Region name", regionRenamed: "Region renamed", @@ -1301,7 +1301,7 @@ const en = { newCustom: "Custom map", newCustomName: "Custom topology", newCustomPrompt: "Custom map name", - folderHint: "Regions group maps. Each region gets a physical map; add custom maps as needed.", + folderHint: "Regions group maps. After creating a region, add a physical or custom map yourself.", folderHintShort: "Region folder", outsidePeers: "{{count}} outside peer(s) linked but not on this map", newMap: "New", @@ -1341,9 +1341,10 @@ const en = { endpointB: "Endpoint B", port: "port", expandPhysicalLinks: "Expand physical links", + scaleBundleWidth: "Thicken bundles by link count", linkMembers: "Physical members ({{count}})", canvasHint: - "Left tree lists regions with a physical map and optional custom maps. Open a map to edit devices; use Add NE and save positions.", + "Left tree lists regions; add maps yourself, then open a map to edit devices with Add NE and save positions.", selected: "Selected", selectedEdge: "Selected edge", selectedCount: "{{count}} selected", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 96fa301..c3a8593 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -1254,7 +1254,7 @@ const zh = { maps: "拓扑图", tree: "拓扑树", treeLoading: "加载拓扑树…", - treeHint: "区域下为物理拓扑与自定义图;点图打开设备画布。", + treeHint: "区域下为画布列表;点图打开设备画布(区域本身不自动建图)。", treeSearch: "全拓扑搜索网元", treeSearchPh: "名称 / IP", treeSearchEmpty: "没有匹配的网元", @@ -1273,7 +1273,7 @@ const zh = { layerNodeEmpty: "点击创建并打开", rootCanvasHint: "从左侧选择区域进入目录。", regionCanvasHint: "点击图打开设备画布,或新建自定义图。", - regionBrowseHint: "区域目录:物理拓扑 + 自定义图。点击打开。", + regionBrowseHint: "区域目录:自行添加的画布。点击打开。", browserTitle: "拓扑目录", browserRegionSub: "共 {{count}} 张拓扑图", browserRegionsSub: "共 {{count}} 个区域", @@ -1286,7 +1286,7 @@ const zh = { regionCreated: "区域已创建", regionDeleted: "区域已删除", deleteRegion: "删除区域", - deleteRegionConfirm: "删除区域「{{name}}」?其下物理拓扑与自定义图将一并删除。", + deleteRegionConfirm: "删除区域「{{name}}」?其下画布将一并删除。", renameRegion: "重命名区域", renameRegionPrompt: "区域名称", regionRenamed: "区域已重命名", @@ -1295,7 +1295,7 @@ const zh = { newCustom: "自定义图", newCustomName: "自定义拓扑", newCustomPrompt: "自定义拓扑图名称", - folderHint: "区域用于分组。每区有一张物理拓扑,可再加自定义图。", + folderHint: "区域用于分组。新建区域后请自行添加物理拓扑或自定义图。", folderHintShort: "区域目录", outsidePeers: "图外对端 {{count}} 个(有链路未上图)", newMap: "新建", @@ -1335,9 +1335,10 @@ const zh = { endpointB: "端点 B", port: "端口", expandPhysicalLinks: "展开物理链路", + scaleBundleWidth: "聚合线按条数加粗", linkMembers: "物理成员({{count}})", canvasHint: - "左侧为区域目录(物理拓扑 + 自定义图);打开本图后进入设备画布,用「添加网元」上图并保存。", + "左侧为区域目录(需自行添加画布);打开本图后进入设备画布,用「添加网元」上图并保存。", selected: "已选节点", selectedEdge: "已选链路", selectedCount: "已选 {{count}}", diff --git a/web/src/pages/TopologyPage.tsx b/web/src/pages/TopologyPage.tsx index 8cffe25..4caf866 100644 --- a/web/src/pages/TopologyPage.tsx +++ b/web/src/pages/TopologyPage.tsx @@ -450,6 +450,7 @@ const EDGE_DEFAULTS_KEY = "netx.topology.edgeDefaults"; const AUTO_LAYOUT_DISCOVER_KEY = "netx.topology.autoLayoutAfterDiscover"; const DISCOVER_AUTO_ADD_KEY = "netx.topology.discoverAutoAddUnmatched"; const DISCOVER_PROJECT_NEIGHBORS_KEY = "netx.topology.discoverProjectNeighbors"; +const SCALE_BUNDLE_WIDTH_KEY = "netx.topology.scaleBundleWidth"; function loadBoolFlag(key: string, defaultValue: boolean): boolean { try { @@ -696,6 +697,10 @@ export function TopologyPage() { const [hidePorts, setHidePorts] = useState(true); /** Default: logical aggregate of parallel links; expand to see each physical edge. */ const [expandPhysicalLinks, setExpandPhysicalLinks] = useState(false); + /** When aggregating, optionally thicken the line by member count (off = single-line width). */ + const [scaleBundleWidth, setScaleBundleWidth] = useState(() => + loadBoolFlag(SCALE_BUNDLE_WIDTH_KEY, false), + ); const [edgeFlow, setEdgeFlow] = useState(false); const [edgeDefaults, setEdgeDefaults] = useState(() => loadEdgeDefaults()); const [toolMode, setToolMode] = useState("select"); @@ -802,7 +807,7 @@ export function TopologyPage() { [hideIp, hideVendor, hidePorts, toolMode], ); const displayEdges = useMemo(() => { - const built = buildLinkDisplayEdges(edges, expandPhysicalLinks, hidePorts).map((e) => + const built = buildLinkDisplayEdges(edges, expandPhysicalLinks, hidePorts, scaleBundleWidth).map((e) => withEdgeVisual(e, edgeDefaults), ); return built.map((e) => { @@ -818,7 +823,7 @@ export function TopologyPage() { animated: edgeFlow, }; }); - }, [edges, expandPhysicalLinks, hidePorts, edgeFlow, edgeDefaults, selectedEdgeId]); + }, [edges, expandPhysicalLinks, hidePorts, scaleBundleWidth, edgeFlow, edgeDefaults, selectedEdgeId]); const treeQuery = useQuery({ queryKey: queryKeys.topologyTree, @@ -2757,6 +2762,19 @@ export function TopologyPage() { /> {t("topology.expandPhysicalLinks")} +