mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 03:10:46 +08:00
Stop auto-creating maps on new regions; keep aggregate edges single-width by default.
This commit is contained in:
parent
4abb037dce
commit
af29977878
10 changed files with 58 additions and 40 deletions
|
|
@ -19,14 +19,14 @@ disable-model-invocation: false
|
||||||
3. **只动已有 Fabric**:禁止 `managed_ne_ids` / `ume_ne_ids`;禁止臆造 fabric id。
|
3. **只动已有 Fabric**:禁止 `managed_ne_ids` / `ume_ne_ids`;禁止臆造 fabric id。
|
||||||
4. **不污染 Fabric**:不能手工建链、不能 populate、不能删 Fabric / 整图。邻居用 `projectTopologyNeighbors`。
|
4. **不污染 Fabric**:不能手工建链、不能 populate、不能删 Fabric / 整图。邻居用 `projectTopologyNeighbors`。
|
||||||
5. **写权限**:需要 `ne:write`。tools/list 没有写工具 → 停,让用户用「MCP + 拓扑写」签发 Token。
|
5. **写权限**:需要 `ne:write`。tools/list 没有写工具 → 停,让用户用「MCP + 拓扑写」签发 Token。
|
||||||
6. **无区域则先建**:`createTopologyView` 需要 `folder_id`;没有合适区域时用 `createTopologyFolder`(API 会自动建默认 physical 图)。
|
6. **无区域则先建**:`createTopologyView` 需要 `folder_id`;没有合适区域时用 `createTopologyFolder`,再 `createTopologyView` 建画布。
|
||||||
7. **单画布硬顶 2000**:满了 `truncated` / 触顶 → 新建另一张画布继续;全网五万设备靠多画布切片。
|
7. **单画布硬顶 2000**:满了 `truncated` / 触顶 → 新建另一张画布继续;全网五万设备靠多画布切片。
|
||||||
|
|
||||||
## 推荐流水线(从零画一张图)
|
## 推荐流水线(从零画一张图)
|
||||||
|
|
||||||
```
|
```
|
||||||
1 getTopologyTree → 看有无区域;没有则 createTopologyFolder → folder_id
|
1 getTopologyTree → 看有无区域;没有则 createTopologyFolder → folder_id
|
||||||
2 createTopologyView(或用区域自带的 physical 图)→ view_id
|
2 createTopologyView → view_id
|
||||||
3 addTopologyViewNodes(keyword=…, limit) → 看 added / next_offset,循环 offset 直到无更多或满 cap
|
3 addTopologyViewNodes(keyword=…, limit) → 看 added / next_offset,循环 offset 直到无更多或满 cap
|
||||||
4 projectTopologyNeighbors → 可选
|
4 projectTopologyNeighbors → 可选
|
||||||
5 updateTopologyViewPositions(layout=grid|offset|stack, keyword=…) → API 自己筛并摆
|
5 updateTopologyViewPositions(layout=grid|offset|stack, keyword=…) → API 自己筛并摆
|
||||||
|
|
|
||||||
|
|
@ -80,7 +80,7 @@ oclaw:Install from JSON → Health → Sync Tools(应看到 **14** 个工具
|
||||||
|
|
||||||
| 工具 | 作用 |
|
| 工具 | 作用 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `createTopologyFolder` | 新建**区域**文件夹(挂在根下);返回 `id` 供建画布;并自动创建默认 physical 图 |
|
| `createTopologyFolder` | 新建**区域**文件夹(挂在根下);返回 `id`;**不**自动建画布,需再调 `createTopologyView` |
|
||||||
| `createTopologyView` | 在 folder 下新建画布 |
|
| `createTopologyView` | 在 folder 下新建画布 |
|
||||||
| `addTopologyViewNodes` | **优先**传 `keyword`/`role`/`vendor`/`link_status` + `limit`/`offset`,由 API 筛选落点;也可 `fabric_node_ids`。拒绝 managed/UME。返回摘要。 |
|
| `addTopologyViewNodes` | **优先**传 `keyword`/`role`/`vendor`/`link_status` + `limit`/`offset`,由 API 筛选落点;也可 `fabric_node_ids`。拒绝 managed/UME。返回摘要。 |
|
||||||
| `removeTopologyViewNodes` | 筛选或 id 从画布移除(不删 Fabric),摘要 |
|
| `removeTopologyViewNodes` | 筛选或 id 从画布移除(不删 Fabric),摘要 |
|
||||||
|
|
|
||||||
|
|
@ -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]:
|
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()
|
now = _utcnow()
|
||||||
root = (
|
root = (
|
||||||
db.query(TopoFolder)
|
db.query(TopoFolder)
|
||||||
|
|
@ -221,11 +221,6 @@ def bootstrap_topology_tree(db: Session) -> dict[str, str]:
|
||||||
if changed:
|
if changed:
|
||||||
v.updated_at = now
|
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()
|
db.commit()
|
||||||
return {"root_id": root.id}
|
return {"root_id": root.id}
|
||||||
|
|
||||||
|
|
@ -258,7 +253,6 @@ def create_folder(db: Session, body: TopologyFolderCreate) -> TopologyFolderOut:
|
||||||
)
|
)
|
||||||
db.add(row)
|
db.add(row)
|
||||||
db.flush()
|
db.flush()
|
||||||
ensure_region_physical_view(db, row.id, commit=False)
|
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(row)
|
db.refresh(row)
|
||||||
return _folder_out(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]:
|
def delete_folder(db: Session, folder_id: str, *, force: bool = False) -> dict[str, Any]:
|
||||||
"""Delete a region and cascade-delete its maps.
|
"""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.
|
``force`` is accepted for API compatibility; cascade always runs.
|
||||||
"""
|
"""
|
||||||
row = _get_folder_or_404(db, folder_id)
|
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]:
|
def delete_view(db: Session, view_id: str, *, force: bool = False) -> dict[str, Any]:
|
||||||
row = _get_view_or_404(db, view_id)
|
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
|
is_physical = normalize_view_kind(row.kind) == VIEW_KIND_PHYSICAL
|
||||||
if is_physical and not force:
|
if is_physical and not force:
|
||||||
raise HTTPException(status_code=400, detail="cannot_delete_physical_view")
|
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.query(TopoViewNode).filter(TopoViewNode.view_id == row.id).delete(synchronize_session=False)
|
||||||
db.delete(row)
|
db.delete(row)
|
||||||
db.flush()
|
|
||||||
if folder_id and is_physical:
|
|
||||||
ensure_region_physical_view(db, folder_id, commit=False)
|
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"ok": True, "view_id": view_id, "deleted": True}
|
return {"ok": True, "view_id": view_id, "deleted": True}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -381,7 +381,7 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
|
||||||
"name": "createTopologyFolder",
|
"name": "createTopologyFolder",
|
||||||
"description": (
|
"description": (
|
||||||
"Create a region folder under the topology root. Returns folder id for createTopologyView. "
|
"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": {
|
"inputSchema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
|
|
||||||
|
|
@ -368,11 +368,11 @@ class FabricTopologyTests(unittest.TestCase):
|
||||||
region = svc.create_folder(
|
region = svc.create_folder(
|
||||||
self.db, TopologyFolderCreate(name="East", kind="region")
|
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)
|
tree1 = svc.get_topology_tree(self.db)
|
||||||
assert tree1.root is not None
|
assert tree1.root is not None
|
||||||
east0 = next(c for c in tree1.root.children if c.id == region.id)
|
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(
|
view = svc.create_view(
|
||||||
self.db,
|
self.db,
|
||||||
|
|
@ -390,20 +390,27 @@ class FabricTopologyTests(unittest.TestCase):
|
||||||
assert tree2.root is not None
|
assert tree2.root is not None
|
||||||
east = next(c for c in tree2.root.children if c.id == region.id)
|
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))
|
self.assertTrue(any(v.id == view.id for v in east.views))
|
||||||
# Flat siblings: physical + custom
|
self.assertEqual(len(east.views), 1)
|
||||||
self.assertGreaterEqual(len(east.views), 2)
|
|
||||||
self.assertTrue(all(not getattr(v, "children", None) for v in east.views))
|
self.assertTrue(all(not getattr(v, "children", None) for v in east.views))
|
||||||
|
|
||||||
def test_site_physical_and_custom_flat(self) -> None:
|
def test_site_physical_and_custom_flat(self) -> None:
|
||||||
region = svc.create_folder(
|
region = svc.create_folder(
|
||||||
self.db, TopologyFolderCreate(name="Site-R", kind="region")
|
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)
|
tree = svc.get_topology_tree(self.db)
|
||||||
assert tree.root is not None
|
assert tree.root is not None
|
||||||
reg = next(c for c in tree.root.children if c.id == region.id)
|
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"]
|
physicals = [v for v in reg.views if v.kind == "physical"]
|
||||||
self.assertEqual(len(physicals), 1)
|
self.assertEqual(len(physicals), 1)
|
||||||
phys = physicals[0]
|
self.assertEqual(physicals[0].id, phys.id)
|
||||||
|
|
||||||
custom = svc.create_view(
|
custom = svc.create_view(
|
||||||
self.db,
|
self.db,
|
||||||
|
|
@ -425,12 +432,12 @@ class FabricTopologyTests(unittest.TestCase):
|
||||||
with self.assertRaises(Exception):
|
with self.assertRaises(Exception):
|
||||||
svc.delete_view(self.db, phys.id)
|
svc.delete_view(self.db, phys.id)
|
||||||
svc.delete_view(self.db, custom.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)
|
svc.delete_view(self.db, phys.id, force=True)
|
||||||
tree3 = svc.get_topology_tree(self.db)
|
tree3 = svc.get_topology_tree(self.db)
|
||||||
assert tree3.root is not None
|
assert tree3.root is not None
|
||||||
reg3 = next(c for c in tree3.root.children if c.id == region.id)
|
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:
|
def test_delete_region_cascades_views(self) -> None:
|
||||||
region = svc.create_folder(
|
region = svc.create_folder(
|
||||||
|
|
@ -447,7 +454,7 @@ class FabricTopologyTests(unittest.TestCase):
|
||||||
tree = svc.get_topology_tree(self.db)
|
tree = svc.get_topology_tree(self.db)
|
||||||
assert tree.root is not None
|
assert tree.root is not None
|
||||||
reg = next(c for c in tree.root.children if c.id == region.id)
|
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)
|
out = svc.delete_folder(self.db, region.id)
|
||||||
self.assertTrue(out.get("deleted"))
|
self.assertTrue(out.get("deleted"))
|
||||||
|
|
|
||||||
|
|
@ -129,7 +129,7 @@ src/
|
||||||
- 删除运维网元:解绑 `fabric.managed_ne_id`(及 view membership),**不删**上图点/边;UME 库存 reconcile 对称解绑 `ume_ne_id`。历史悬空引用:`POST /fabric/reconcile-links`(亦并入 `cleanup-duplicates`)。
|
- 删除运维网元:解绑 `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_fabric_node` / `topo_fabric_edge`(按 5 万网元 / 100 万链路设计;物理层仅 LLDP)
|
||||||
- 站点树:`topo_folder`(系统隐藏 `root`;用户新建站点/区域,无默认「未分区」)
|
- 站点树:`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` 不得无界灌全网
|
- 边界:图 `filter.membership`(max_nodes / expand_hops / frozen);`project-neighbors` / `populate` 不得无界灌全网
|
||||||
- API:`/v1/topology/tree`、`/folders*`、`/fabric/*`、`/views*`(含 `populate`、`kind`)
|
- API:`/v1/topology/tree`、`/folders*`、`/fabric/*`、`/views*`(含 `populate`、`kind`)
|
||||||
- 前端:左侧站点→物理/自定义图;右侧目录浏览,打开本图进设备画布;「添加网元」支持勾选批量上图(也可拖放单台)
|
- 前端:左侧站点→物理/自定义图;右侧目录浏览,打开本图进设备画布;「添加网元」支持勾选批量上图(也可拖放单台)
|
||||||
|
|
|
||||||
|
|
@ -1260,7 +1260,7 @@ const en = {
|
||||||
maps: "Maps",
|
maps: "Maps",
|
||||||
tree: "Topology tree",
|
tree: "Topology tree",
|
||||||
treeLoading: "Loading 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",
|
treeSearch: "Search NEs across maps",
|
||||||
treeSearchPh: "Name / IP",
|
treeSearchPh: "Name / IP",
|
||||||
treeSearchEmpty: "No matching NEs",
|
treeSearchEmpty: "No matching NEs",
|
||||||
|
|
@ -1279,7 +1279,7 @@ const en = {
|
||||||
layerNodeEmpty: "Click to create & open",
|
layerNodeEmpty: "Click to create & open",
|
||||||
rootCanvasHint: "Pick a region from the left tree.",
|
rootCanvasHint: "Pick a region from the left tree.",
|
||||||
regionCanvasHint: "Open a map to edit devices, or create a custom map.",
|
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",
|
browserTitle: "Topology folder",
|
||||||
browserRegionSub: "{{count}} map(s)",
|
browserRegionSub: "{{count}} map(s)",
|
||||||
browserRegionsSub: "{{count}} region(s)",
|
browserRegionsSub: "{{count}} region(s)",
|
||||||
|
|
@ -1292,7 +1292,7 @@ const en = {
|
||||||
regionCreated: "Region created",
|
regionCreated: "Region created",
|
||||||
regionDeleted: "Region deleted",
|
regionDeleted: "Region deleted",
|
||||||
deleteRegion: "Delete region",
|
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",
|
renameRegion: "Rename region",
|
||||||
renameRegionPrompt: "Region name",
|
renameRegionPrompt: "Region name",
|
||||||
regionRenamed: "Region renamed",
|
regionRenamed: "Region renamed",
|
||||||
|
|
@ -1301,7 +1301,7 @@ const en = {
|
||||||
newCustom: "Custom map",
|
newCustom: "Custom map",
|
||||||
newCustomName: "Custom topology",
|
newCustomName: "Custom topology",
|
||||||
newCustomPrompt: "Custom map name",
|
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",
|
folderHintShort: "Region folder",
|
||||||
outsidePeers: "{{count}} outside peer(s) linked but not on this map",
|
outsidePeers: "{{count}} outside peer(s) linked but not on this map",
|
||||||
newMap: "New",
|
newMap: "New",
|
||||||
|
|
@ -1341,9 +1341,10 @@ const en = {
|
||||||
endpointB: "Endpoint B",
|
endpointB: "Endpoint B",
|
||||||
port: "port",
|
port: "port",
|
||||||
expandPhysicalLinks: "Expand physical links",
|
expandPhysicalLinks: "Expand physical links",
|
||||||
|
scaleBundleWidth: "Thicken bundles by link count",
|
||||||
linkMembers: "Physical members ({{count}})",
|
linkMembers: "Physical members ({{count}})",
|
||||||
canvasHint:
|
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",
|
selected: "Selected",
|
||||||
selectedEdge: "Selected edge",
|
selectedEdge: "Selected edge",
|
||||||
selectedCount: "{{count}} selected",
|
selectedCount: "{{count}} selected",
|
||||||
|
|
|
||||||
|
|
@ -1254,7 +1254,7 @@ const zh = {
|
||||||
maps: "拓扑图",
|
maps: "拓扑图",
|
||||||
tree: "拓扑树",
|
tree: "拓扑树",
|
||||||
treeLoading: "加载拓扑树…",
|
treeLoading: "加载拓扑树…",
|
||||||
treeHint: "区域下为物理拓扑与自定义图;点图打开设备画布。",
|
treeHint: "区域下为画布列表;点图打开设备画布(区域本身不自动建图)。",
|
||||||
treeSearch: "全拓扑搜索网元",
|
treeSearch: "全拓扑搜索网元",
|
||||||
treeSearchPh: "名称 / IP",
|
treeSearchPh: "名称 / IP",
|
||||||
treeSearchEmpty: "没有匹配的网元",
|
treeSearchEmpty: "没有匹配的网元",
|
||||||
|
|
@ -1273,7 +1273,7 @@ const zh = {
|
||||||
layerNodeEmpty: "点击创建并打开",
|
layerNodeEmpty: "点击创建并打开",
|
||||||
rootCanvasHint: "从左侧选择区域进入目录。",
|
rootCanvasHint: "从左侧选择区域进入目录。",
|
||||||
regionCanvasHint: "点击图打开设备画布,或新建自定义图。",
|
regionCanvasHint: "点击图打开设备画布,或新建自定义图。",
|
||||||
regionBrowseHint: "区域目录:物理拓扑 + 自定义图。点击打开。",
|
regionBrowseHint: "区域目录:自行添加的画布。点击打开。",
|
||||||
browserTitle: "拓扑目录",
|
browserTitle: "拓扑目录",
|
||||||
browserRegionSub: "共 {{count}} 张拓扑图",
|
browserRegionSub: "共 {{count}} 张拓扑图",
|
||||||
browserRegionsSub: "共 {{count}} 个区域",
|
browserRegionsSub: "共 {{count}} 个区域",
|
||||||
|
|
@ -1286,7 +1286,7 @@ const zh = {
|
||||||
regionCreated: "区域已创建",
|
regionCreated: "区域已创建",
|
||||||
regionDeleted: "区域已删除",
|
regionDeleted: "区域已删除",
|
||||||
deleteRegion: "删除区域",
|
deleteRegion: "删除区域",
|
||||||
deleteRegionConfirm: "删除区域「{{name}}」?其下物理拓扑与自定义图将一并删除。",
|
deleteRegionConfirm: "删除区域「{{name}}」?其下画布将一并删除。",
|
||||||
renameRegion: "重命名区域",
|
renameRegion: "重命名区域",
|
||||||
renameRegionPrompt: "区域名称",
|
renameRegionPrompt: "区域名称",
|
||||||
regionRenamed: "区域已重命名",
|
regionRenamed: "区域已重命名",
|
||||||
|
|
@ -1295,7 +1295,7 @@ const zh = {
|
||||||
newCustom: "自定义图",
|
newCustom: "自定义图",
|
||||||
newCustomName: "自定义拓扑",
|
newCustomName: "自定义拓扑",
|
||||||
newCustomPrompt: "自定义拓扑图名称",
|
newCustomPrompt: "自定义拓扑图名称",
|
||||||
folderHint: "区域用于分组。每区有一张物理拓扑,可再加自定义图。",
|
folderHint: "区域用于分组。新建区域后请自行添加物理拓扑或自定义图。",
|
||||||
folderHintShort: "区域目录",
|
folderHintShort: "区域目录",
|
||||||
outsidePeers: "图外对端 {{count}} 个(有链路未上图)",
|
outsidePeers: "图外对端 {{count}} 个(有链路未上图)",
|
||||||
newMap: "新建",
|
newMap: "新建",
|
||||||
|
|
@ -1335,9 +1335,10 @@ const zh = {
|
||||||
endpointB: "端点 B",
|
endpointB: "端点 B",
|
||||||
port: "端口",
|
port: "端口",
|
||||||
expandPhysicalLinks: "展开物理链路",
|
expandPhysicalLinks: "展开物理链路",
|
||||||
|
scaleBundleWidth: "聚合线按条数加粗",
|
||||||
linkMembers: "物理成员({{count}})",
|
linkMembers: "物理成员({{count}})",
|
||||||
canvasHint:
|
canvasHint:
|
||||||
"左侧为区域目录(物理拓扑 + 自定义图);打开本图后进入设备画布,用「添加网元」上图并保存。",
|
"左侧为区域目录(需自行添加画布);打开本图后进入设备画布,用「添加网元」上图并保存。",
|
||||||
selected: "已选节点",
|
selected: "已选节点",
|
||||||
selectedEdge: "已选链路",
|
selectedEdge: "已选链路",
|
||||||
selectedCount: "已选 {{count}}",
|
selectedCount: "已选 {{count}}",
|
||||||
|
|
|
||||||
|
|
@ -450,6 +450,7 @@ const EDGE_DEFAULTS_KEY = "netx.topology.edgeDefaults";
|
||||||
const AUTO_LAYOUT_DISCOVER_KEY = "netx.topology.autoLayoutAfterDiscover";
|
const AUTO_LAYOUT_DISCOVER_KEY = "netx.topology.autoLayoutAfterDiscover";
|
||||||
const DISCOVER_AUTO_ADD_KEY = "netx.topology.discoverAutoAddUnmatched";
|
const DISCOVER_AUTO_ADD_KEY = "netx.topology.discoverAutoAddUnmatched";
|
||||||
const DISCOVER_PROJECT_NEIGHBORS_KEY = "netx.topology.discoverProjectNeighbors";
|
const DISCOVER_PROJECT_NEIGHBORS_KEY = "netx.topology.discoverProjectNeighbors";
|
||||||
|
const SCALE_BUNDLE_WIDTH_KEY = "netx.topology.scaleBundleWidth";
|
||||||
|
|
||||||
function loadBoolFlag(key: string, defaultValue: boolean): boolean {
|
function loadBoolFlag(key: string, defaultValue: boolean): boolean {
|
||||||
try {
|
try {
|
||||||
|
|
@ -696,6 +697,10 @@ export function TopologyPage() {
|
||||||
const [hidePorts, setHidePorts] = useState(true);
|
const [hidePorts, setHidePorts] = useState(true);
|
||||||
/** Default: logical aggregate of parallel links; expand to see each physical edge. */
|
/** Default: logical aggregate of parallel links; expand to see each physical edge. */
|
||||||
const [expandPhysicalLinks, setExpandPhysicalLinks] = useState(false);
|
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 [edgeFlow, setEdgeFlow] = useState(false);
|
||||||
const [edgeDefaults, setEdgeDefaults] = useState<EdgeDefaults>(() => loadEdgeDefaults());
|
const [edgeDefaults, setEdgeDefaults] = useState<EdgeDefaults>(() => loadEdgeDefaults());
|
||||||
const [toolMode, setToolMode] = useState<ToolMode>("select");
|
const [toolMode, setToolMode] = useState<ToolMode>("select");
|
||||||
|
|
@ -802,7 +807,7 @@ export function TopologyPage() {
|
||||||
[hideIp, hideVendor, hidePorts, toolMode],
|
[hideIp, hideVendor, hidePorts, toolMode],
|
||||||
);
|
);
|
||||||
const displayEdges = useMemo(() => {
|
const displayEdges = useMemo(() => {
|
||||||
const built = buildLinkDisplayEdges(edges, expandPhysicalLinks, hidePorts).map((e) =>
|
const built = buildLinkDisplayEdges(edges, expandPhysicalLinks, hidePorts, scaleBundleWidth).map((e) =>
|
||||||
withEdgeVisual(e, edgeDefaults),
|
withEdgeVisual(e, edgeDefaults),
|
||||||
);
|
);
|
||||||
return built.map((e) => {
|
return built.map((e) => {
|
||||||
|
|
@ -818,7 +823,7 @@ export function TopologyPage() {
|
||||||
animated: edgeFlow,
|
animated: edgeFlow,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}, [edges, expandPhysicalLinks, hidePorts, edgeFlow, edgeDefaults, selectedEdgeId]);
|
}, [edges, expandPhysicalLinks, hidePorts, scaleBundleWidth, edgeFlow, edgeDefaults, selectedEdgeId]);
|
||||||
|
|
||||||
const treeQuery = useQuery({
|
const treeQuery = useQuery({
|
||||||
queryKey: queryKeys.topologyTree,
|
queryKey: queryKeys.topologyTree,
|
||||||
|
|
@ -2757,6 +2762,19 @@ export function TopologyPage() {
|
||||||
/>
|
/>
|
||||||
{t("topology.expandPhysicalLinks")}
|
{t("topology.expandPhysicalLinks")}
|
||||||
</label>
|
</label>
|
||||||
|
<label className="topo-display-toggles__item">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={scaleBundleWidth}
|
||||||
|
disabled={expandPhysicalLinks}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = e.target.checked;
|
||||||
|
setScaleBundleWidth(next);
|
||||||
|
persistBoolFlag(SCALE_BUNDLE_WIDTH_KEY, next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{t("topology.scaleBundleWidth")}
|
||||||
|
</label>
|
||||||
<label className="topo-display-toggles__item">
|
<label className="topo-display-toggles__item">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,7 @@ export function buildLinkDisplayEdges(
|
||||||
edges: Edge[],
|
edges: Edge[],
|
||||||
expandPhysical: boolean,
|
expandPhysical: boolean,
|
||||||
hidePorts = false,
|
hidePorts = false,
|
||||||
|
scaleBundleWidth = false,
|
||||||
): Edge[] {
|
): Edge[] {
|
||||||
const groups = new Map<string, Edge[]>();
|
const groups = new Map<string, Edge[]>();
|
||||||
for (const e of edges) {
|
for (const e of edges) {
|
||||||
|
|
@ -153,9 +154,11 @@ export function buildLinkDisplayEdges(
|
||||||
aggregated: true,
|
aggregated: true,
|
||||||
member_count: list.length,
|
member_count: list.length,
|
||||||
members,
|
members,
|
||||||
// Slightly thicker logical bundle.
|
|
||||||
stroke_width: Math.max(Number(prev.stroke_width || 0), Math.min(8, 2 + list.length)),
|
|
||||||
};
|
};
|
||||||
|
// Optional: thicken logical bundles by member count (default keeps single-line width).
|
||||||
|
if (scaleBundleWidth) {
|
||||||
|
data.stroke_width = Math.max(Number(prev.stroke_width || 0), Math.min(8, 2 + list.length));
|
||||||
|
}
|
||||||
out.push({
|
out.push({
|
||||||
...primary,
|
...primary,
|
||||||
id: aggregateIdForPair(primary.source, primary.target),
|
id: aggregateIdForPair(primary.source, primary.target),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue