From b81e5869a60899cf1043e136c2f6d13d1dd39746 Mon Sep 17 00:00:00 2001
From: oliver
Date: Wed, 12 Aug 2026 16:13:05 +0800
Subject: [PATCH] Ship gated eye polish, fabric levels, and collection UI
refresh.
Topology MCP adds pull/compact/bundle/suggest-hubs with a no-template skill path; API gains fabric level and NE collection policy; web list pages get paging and denser collect/network workflows.
Co-authored-by: Cursor
---
.cursor/skills/netx-topology/SKILL.md | 417 ++++---
alembic/versions/20260811_fabric_level.py | 54 +
docs/MCP_TOPOLOGY.md | 7 +-
netx_api/app_shutdown.py | 7 +
netx_api/app_startup.py | 10 +-
netx_api/collection_job_state.py | 6 +
netx_api/collection_policy.py | 265 +++++
netx_api/collection_router.py | 36 +-
netx_api/collection_schemas.py | 32 +
netx_api/collection_service.py | 89 +-
netx_api/config.py | 4 +
netx_api/config_sync_cycles.py | 27 +-
netx_api/config_sync_router.py | 4 +-
netx_api/lldp_collect_router.py | 15 +-
netx_api/lldp_collect_service.py | 43 +-
netx_api/metrics_router.py | 1 +
netx_api/models/__init__.py | 2 +
netx_api/models/managed_ne.py | 24 +
netx_api/models/topology.py | 12 +-
netx_api/ne_collect_scheduler.py | 113 ++
netx_api/port_traffic_devices.py | 27 +-
netx_api/port_traffic_router.py | 4 +-
netx_api/scheduler_heartbeat.py | 6 +
netx_api/schema_patches.py | 6 +
netx_api/topology_classify_apply.py | 160 +--
netx_api/topology_classify_common.py | 88 +-
netx_api/topology_classify_rules.py | 15 +-
netx_api/topology_discover_common.py | 45 +-
netx_api/topology_fabric_nodes.py | 49 +-
netx_api/topology_level.py | 138 +++
netx_api/topology_migrate.py | 3 +
netx_api/topology_router.py | 23 +-
netx_api/topology_schemas.py | 22 +-
netx_api/topology_views_graph.py | 12 +-
netx_api/ume_runtime.py | 4 +-
.../src/netx_topology_mcp/__init__.py | 4 +-
.../src/netx_topology_mcp/http_tools.py | 1042 +++++++++++++++-
.../src/netx_topology_mcp/layout_metrics.py | 23 +-
.../layout_ops/align_reference.py | 279 +++++
.../layout_ops/bundle_orbit.py | 801 +++++++++++++
.../layout_ops/clear_edge_hits.py | 23 +-
.../layout_ops/compact_bbox.py | 233 ++++
.../layout_ops/dual_units.py | 764 +++++++++---
.../layout_ops/graph_util.py | 39 +-
.../layout_ops/level_util.py | 209 ++++
.../layout_ops/orbit_sweep.py | 1044 ++++++++++++++++-
.../layout_ops/press_crossings.py | 65 +-
.../layout_ops/pull_far_chains.py | 354 ++++++
.../layout_ops/sink_dual_units.py | 106 +-
.../src/netx_topology_mcp/layout_ops/state.py | 3 +
.../layout_ops/suggest_sink_hubs.py | 262 +++++
.../src/netx_topology_mcp/layout_stats.py | 135 ++-
.../src/netx_topology_mcp/layout_structure.py | 7 +-
.../src/netx_topology_mcp/layout_tool.py | 222 +++-
.../tests/test_align_reference.py | 55 +
.../tests/test_bundle_orbit.py | 185 +++
.../tests/test_clear_edge_hits.py | 5 +-
.../tests/test_compact_bbox.py | 40 +
.../tests/test_dual_units.py | 203 +++-
.../tests/test_mcp_topology.py | 43 +
.../tests/test_orbit_sweep.py | 215 ++++
.../tests/test_pull_far_chains.py | 84 ++
.../tests/test_sink_dual_units.py | 2 +-
.../tests/test_suggest_sink_hubs.py | 146 +++
.../tests/test_topology_quality.py | 30 +-
tests/test_ne_collection_policy.py | 71 ++
tests/test_topology.py | 8 +
web/WEB.md | 14 +-
web/src/components/ListPager.tsx | 116 ++
web/src/constants/queryKeys.ts | 41 +-
web/src/hooks/useDebouncedValue.ts | 11 +
web/src/i18n/en.ts | 118 +-
web/src/i18n/zh.ts | 115 +-
web/src/index.css | 78 +-
web/src/pages/CollectPage.tsx | 577 ++++++++-
web/src/pages/ConfigSyncPage.tsx | 469 +++++---
web/src/pages/TopologyPage.tsx | 63 +-
web/src/pages/network/LldpLinksPage.tsx | 439 +++++--
web/src/pages/network/NetworkAlarmsPage.tsx | 135 ++-
web/src/pages/network/NetworkConfigsPage.tsx | 137 ++-
web/src/pages/network/NetworkDevicesPage.tsx | 120 +-
web/src/pages/network/PortTrafficPage.tsx | 468 +++++---
.../pages/network/TopologyClassifyPage.tsx | 268 ++---
.../pages/topology/TopologyReactFlowView.tsx | 1 +
web/src/pages/topology/TopologyToolbar.tsx | 37 +-
web/src/pages/topology/TopologyViewTools.tsx | 8 +-
.../pages/topology/hooks/useTopologyCanvas.ts | 6 +-
.../topology/hooks/useTopologyDiscover.ts | 21 +-
web/src/services/api.ts | 93 +-
web/src/types.ts | 39 +-
web/src/utils/csvExport.ts | 62 +
91 files changed, 10395 insertions(+), 1513 deletions(-)
create mode 100644 alembic/versions/20260811_fabric_level.py
create mode 100644 netx_api/collection_policy.py
create mode 100644 netx_api/ne_collect_scheduler.py
create mode 100644 netx_api/topology_level.py
create mode 100644 packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/align_reference.py
create mode 100644 packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/bundle_orbit.py
create mode 100644 packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/compact_bbox.py
create mode 100644 packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/level_util.py
create mode 100644 packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/pull_far_chains.py
create mode 100644 packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/suggest_sink_hubs.py
create mode 100644 packages/netx-topology-mcp/tests/test_align_reference.py
create mode 100644 packages/netx-topology-mcp/tests/test_bundle_orbit.py
create mode 100644 packages/netx-topology-mcp/tests/test_compact_bbox.py
create mode 100644 packages/netx-topology-mcp/tests/test_pull_far_chains.py
create mode 100644 packages/netx-topology-mcp/tests/test_suggest_sink_hubs.py
create mode 100644 tests/test_ne_collection_policy.py
create mode 100644 web/src/components/ListPager.tsx
create mode 100644 web/src/hooks/useDebouncedValue.ts
create mode 100644 web/src/utils/csvExport.ts
diff --git a/.cursor/skills/netx-topology/SKILL.md b/.cursor/skills/netx-topology/SKILL.md
index b431009..4d8c4c6 100644
--- a/.cursor/skills/netx-topology/SKILL.md
+++ b/.cursor/skills/netx-topology/SKILL.md
@@ -1,33 +1,184 @@
---
name: netx-topology
description: >-
- 用 netx-topology MCP 查邻接、dual_unit 分批沉入、扫角压交叉(不污染 Fabric)。
- 触发:画拓扑、布图、拖图、LLDP、Fabric、netx-topology。先读本 skill 再调工具。
+ 用 netx-topology MCP 查邻接、分类打标;先 dual_unit 一次抽最大核心眼,再换其它算法下沉/布图(不污染 Fabric)。
+ 触发:画拓扑、布图、拖图、分类、LLDP、Fabric、netx-topology。先读本 skill 再调工具。
---
# netx 拓扑(通用)
只用 **`netx-topology`** MCP(包 `netx-topology-mcp`)。安装与 scopes:仓库 [`docs/MCP_TOPOLOGY.md`](../../../docs/MCP_TOPOLOGY.md)。
-**原则**:复杂图先拆 **dual_units** 再拼;交叉少、边短、近轴优先。
+**原则**:
+1. **第一步用 dual_units**:从**核心**出发,选**覆盖网元最多**的那一只眼(`prefer_top_eye` + max cover)。
+2. **`sinkTopologyDualUnits` / `layout_dual_unit` 整图流程只用一次**——只沉这一只最大眼;**禁止**再连抽下一批 dual_unit。
+3. **后续下沉换算法**:`suggestSinkHubs` → `move_nodes(park)` / `projectTopologyNeighbors`;**不要**再 `sinkTopologyDualUnits`。
+4. **眼图已定型 → 门控精修**:对该 sink 只用下表「允许」动作;禁止全局拆眼工具。
+5. 布眼目标:少交叉、眼心空旷、少重叠(椭圆弧带;长链在眼外)。对照人工金标时还要看:**紧凑度、正交边、贴边清开**(见「算法天花板」)。
+
**禁止**写临时 py 穷举坐标或直接调 HTTP;验证与压交叉**只调 MCP**。不造 Fabric 边。
-**勿**把客户网元名、区域名、具体交叉数写进本 skill。
+**脱敏(硬)**:勿把客户网元名、站点/区域名、具体交叉数、具体 view_id 写进本 skill。角色只用通用词:门户 / 枢纽 / 汇聚 / 接入 / 末梢。**禁止**在 skill 正文写站点缩写或设备角色缩写当专名举例。
+
+---
+
+## 完整处理思路(一次大区接入眼)
+
+按阶段执行;每阶段只调 MCP,读返回再决定下一步。
+
+```
+① 认眼
+ analyze(structure) → dual_units 里取覆盖最大的核心门户对
+② 一次 sink
+ sinkTopologyDualUnits(max_units=1, prefer_top_eye, layout_batch)
+ → 同一 sink 贯穿全程;眼形不对只 layout_dual_unit 重布同一对门户
+③ 换算法下沉(禁 dual)
+ 循环:suggestSinkHubs(pick:1) → move_nodes(park) → until_limit(crossing)
+ 空批次后:orphan_batch / 对根−sink−门户 分批 park;仍禁 dual
+④ 眼定型精修(门控序列,见下)
+ until_limit(crossing) → until_limit(total)
+ → clear_edge_hits(portal_ids) → pull_far_chains(portal_ids)
+ → compact_bbox(portal_ids, outlier_only) → 可选再 clear / total
+⑤ 停手(算法天花板,**默认无范本**)
+ overlaps=0;until_limit stall;pull/compact/clear 均 moved≈0
+ → 交付:眼可读 + ov=0 + 交叉可接受;util/正交/贴边缺口 → 手拖或改初布
+ → 勿再发明无门控全局工具硬挤
+```
+
+**范本现实(硬)**:
+- **日常默认没有范本**;有人工图也几乎不是同一张网 → **禁止**把 `align_reference` / 抄坐标当主路径。
+- `align_reference` **仅**用于:同网同成员画布的 A/B 对照、调试、回放(共享 `fabric_node_id`)。跨网 ID 对不上,抄了也无意义。
+- 生产要缩短与人工观感差距 → **改初布配方**(正交/紧凑从 dual 一次就偏地铁风),不是现场找金标对齐。
+
+**从零重布(无范本算力路径)**:
+- 眼已在 sink、只需甩掉坏坐标 / 金标残留:对本 sink **`layout_dual_unit`(同一门户对)→ 立刻门控精修**。这**不算**再 sink 一批 dual。
+- 初布后交叉会先飙高、贴边变差属正常;**不要**因此改走 `polish_crossings` / `fix_overlaps`。
+- 纯算力压交叉后 util/bbox 常崩(枢纽被 `until_limit` 甩远)→ **必须**接 `pull_far_chains` → `compact_bbox`;缺这两个 action 就先重启 MCP,勿半截交付。
+- 无范本时:交叉可压到可读,但仍常高于「抄同网坐标」路径;util/正交缺口靠 pull + 初布,**不是**再堆 until_limit。
+
+**开局核对 catalog(硬)**:
+- 精修前先 `layoutTopologyView(catalog=true)`:须见 `pull_far_chains`、`compact_bbox`,且 `version`/`rev` 为当前包。
+- 缺 action 或 `rev` 过旧 → **重启** netx-topology MCP 再干;禁止用旧进程硬跑、禁止改走 HTTP/临时 py。
+
+**精修硬门控(已写进算法,skill 须遵守)**:
+- 任何落笔:**全局交叉不得升高**;升高 → 弃该候选。
+- **最小化展开**(bundle):从小压缩步长试探;步长须清开 footprint(成员互叠也算失败)。
+- **侵入** = 展开后踏入外人框 / 留下任意 footprint 重叠 → 换 tip,勿硬塞。
+- `until_limit` 只接受 `overlaps=0`;apply 遇 `overlaps_remain` → 修算法,**勿**对眼 sink 跑 `fix_overlaps`。
+- `portal_ids` **只钉眼门户**;`freeze_layers=["core"]` 会冻住 sink 上所有核心(含旁路枢纽)——慎用。
+- 旁路枢纽也要动时:`protect_rigid=off`(仍受 `portal_ids` 约束)。
+- `clear_edge_hits`:**禁止**放宽交叉(含 `preserve_axis`);交叉升高一律拒。
+- `pull_far_chains`:相对**门户中点**缩放远走廊/孤立点/远叶(不是朝外圈枢纽);枢纽本身在外围时朝枢纽缩**收不了 bbox**。
+- `compact_bbox`:默认 **farthest-K**(`outlier_only`);全图/分位均匀缩易叠点 → 拒。
+- `until_limit`:`stop_reason=max_moves` **≠** stall → **再调**直到 `stall`(可提高 `max_moves`,但仍以 stall 为准)。
+- 大眼 `max_jump` 用 4k–8k;勿收到 <2k。大图会 background → `job_status`(可数分钟);勿中途改走拆眼工具。
+
+---
+
+## 算法天花板(经验固化)
+
+大区接入眼跑完门控序列后,常见结果:
+
+| 维度 | 算法能做到 | 再挤会怎样 | 交付策略 |
+|------|------------|------------|----------|
+| 交叉 | 从零 dual 后 `until_limit` 可大幅压下;无范本时仍常高于抄坐标路径 | 再挪极值尖端易抬交叉;`max_moves` 停了要续跑到 stall | **停**于 stall;残余当弦交叉可接受 |
+| 重叠 | 硬零 | — | 必须保持 |
+| 紧凑 util / bbox | 压交叉常先**炸开** bbox;`pull_far_chains` 多轮收远走廊 | 缺 pull 就半截交付;强缩常 `Δg>0` 拒 | **必跑** pull→compact 到 `moved=0`;其余手拖 |
+| 贴边 clearance | `clear` + `objective=total` 能降一批 | 与交叉/紧凑拉扯,清到个位数很难 | stall 后手拖擦边点 |
+| 正交 axis | `total` 略抬;远低于人工地铁风 | `preserve_axis` 清边若放宽交叉会毁掉前面成果(已禁 slack) | 手拖走廊;勿为轴牺牲交叉 |
+| 直链 / 环穿 | bundle 后仍有折角与穿环 | 全局 straighten 拆眼 | 勿清零;结构优先 |
+
+**判定「算法到头」**(门控精修,无范本):
+1. `overlaps=0`
+2. `until_limit`(crossing 与 total)均 stall
+3. pull/compact/clear `moved≈0`
+4. 目视:眼心空、双门户可辨
+
+**可选对照**(仅评测):对**同网**人工画布与算法 sink 各 `analyze`,只比 `summary`;**勿**因此去 `align_reference` 当交付手段。
+
+**明确无效 / 禁止再试**(眼 sink):
+- 再次 `sinkTopologyDualUnits` / `until_empty` / 新块画布抽 dual
+- `polish_crossings` / `straighten_channels` / `untangle` / `fix_overlaps` / `orbit_sweep(round=true)`
+- 临时 py 穷举坐标、无门控的整图缩放/平移极值点
+- 为追 util 关掉交叉门控或给 `clear` 开交叉 slack
+- **把跨网/他网人工图当范本抄坐标**(含滥用 `align_reference`)
---
## 主路径(必循)
```
-analyze(structure) → 认 dual_units / shape
-→ 核心层:手拖或小图 layout(compact|corridor|rings)
-→ sinkTopologyDualUnits(一批,layout_batch=true)
- 或 move_nodes(park=true) 指定 ids
-→ orbit_sweep(round) → polish_crossings → clear_edge_hits
-→ 手拖微调 updateTopologyViewPositions
-→ 下一批 sink…(禁止 until_empty 日常连抽)
+analyze(structure) → 读 dual_units(认核心最大眼)
+→ 有 level:先打标;巨图勿先全量 level_bands
+→ 【仅一次】sinkTopologyDualUnits(
+ max_units=1, prefer_top_eye=true, prefer_pure=false,
+ sink_layers=[access], keep_layers=[core,agg], layout_batch=true,
+ max_nodes/max_batch_nodes 放行大眼
+ )
+→ 眼形不对:可对本 sink 重跑 layout_dual_unit(仍算同一只眼;勿再 sink 下一批 dual)
+→ 【换算法】suggestSinkHubs → move_nodes(park) 分批迁入同一 sink;每批后 until_limit;禁止再 dual
+→ 【眼定型】门控精修序列(下节);禁止全局拆眼
+→ 根图可 level_bands(只动根上残留,不动已定型的眼 sink)
```
-停手:`overlaps=0` 且 `verdict.total≈70`、cpl 中档、`edge_clearance.status≠fail`、`edge_axis.status≠fail` 方可交付。**勿只看 crossing**——orbit_sweep 的 `improving_n=0` 只代表交叉维度卡壳,须逐项检查 edge_clearance/edge_axis/rings,任一 fail 即继续处理。
+停手:最大眼可读、眼心空、`overlaps≈0`、门户清晰;其余节点用非 dual 手段靠上去即可。交叉允许来自跨走廊弦边。
+
+**dual 一次用尽**:`until_empty`、再次 `sinkTopologyDualUnits`、为下一批 dual 新建块画布 —— **一律禁止**(日常)。
+
+**布眼几何**(仅那一次):嵌套半椭圆;中轴只有双门户;长链/尾巴放眼外禁穿环。眼坏了重跑 `layout_dual_unit`,勿全局拆眼。
+
+---
+
+## 眼图定型后的精修(门控序列)
+
+眼 sink 一经验收,**禁止**再对该画布跑会整图拆眼的工具。
+
+| 允许 | 禁止(眼 sink 上) |
+|------|-------------------|
+| `analyze(detail=hotspots\|both)` 只读 | `polish_crossings` / `straighten_channels` |
+| `orbit_sweep`:**`until_limit=true`** 或 `node_id` preview→pick | `orbit_sweep(round=true)` 全图轮扫 |
+| `clear_edge_hits`(**须** `portal_ids`;交叉不升) | `fix_overlaps` / `untangle` 批处理整图 |
+| `pull_far_chains`(朝**门户中点**收远走廊/孤立点;同上门控) | `layout` / 再次 `sinkTopologyDualUnits` |
+| `compact_bbox`(farthest-K;交叉不升、ov=0) | 临时 py 穷举坐标 |
+| `updateTopologyViewPositions` 手拖 1~少数点 | `align_reference` 当无同网范本时的交付手段 |
+| 眼形整体崩了才 `layout_dual_unit` 重布同一门户对 | |
+
+**推荐节奏**(一次序列,读返回再决定是否重复某一环):
+
+```
+0) catalog=true 核对 pull_far_chains / compact_bbox(缺则重启 MCP)
+0b) 从零重布时:layout_dual_unit(同一门户对, apply) // 交叉飙高正常
+1) until_limit + objective=crossing + portal_ids + bundle
+ → stop_reason=max_moves 则续跑,直到 stall
+2) until_limit + objective=total // 交叉不升;抬贴边/正交;亦可 max_moves→续跑
+3) clear_edge_hits(portal_ids, top_n/max_moves 可加大;勿 preserve_axis 除非确认不抬交叉)
+4) pull_far_chains(portal_ids) // 可 2~3 轮直到 moved=0;压交叉后必做
+5) compact_bbox(portal_ids, outlier_only) // 常 accepted=false,正常
+6) 可选:再 clear 或 total;再 stall → 停手
+```
+
+`until_limit` 调用模板:
+
+```
+layoutTopologyView({
+ action: "orbit_sweep", mode: "apply",
+ params: {
+ until_limit: true,
+ objective: "crossing", // 再一轮改 total
+ portal_ids: [<门户A>, <门户B>],
+ protect_rigid: "off",
+ max_degree: 14,
+ max_jump: 8000, // 大眼用 4k–8k;勿 <2k
+ max_stretch: 32,
+ max_moves: 40, // 触顶只说明配额满,不是算法到头
+ bundle: true
+ }
+})
+→ 读 local.op:start/end_crossings、moves[]、stop_reason
+→ max_moves → 再调;stall → 进入下一步
+```
+
+`round=true` ≠ `until_limit`:前者一批 top_n 死拿 pick#1;后者循环到 stall。
+勿为清零交叉拆弧带;以 `verdict.total` + 目视眼形 +「算法到头」四条为准。
---
@@ -35,13 +186,14 @@ analyze(structure) → 认 dual_units / shape
1. `getTopologyTree` → **`view_id`**(文件夹 physical 画布)。
2. 建根/子区域只用 `createTopologyFolder`。
-3. `analyzeTopologyViewLayout({ view_id, detail: "structure" })` 读 `dual_units` / `shape`。
-4. `addTopologyViewNodes` / `projectTopologyNeighbors`(区域画布务必 `region_folder_id`)。
-5. 多余点用 `removeTopologyViewNodes` **移出画布**(不删 Fabric)。`region:…` 幽灵点勿当网元拖。
+3. 缺层级/区域时:`classifyTopologyFabricNodes`(`match` → `tag(level, dry_run)` → `tag`;或 `unmatched` / `apply_rules`)。**level** 越小越靠外(0 外部 / 1 核心 / 2 汇聚 / 3 接入;可用 1.1、2.1 子层)。**勿**切片建图。
+4. `analyzeTopologyViewLayout({ view_id, detail: "structure" })` 读 `dual_units` / `shape` / `layers` —— **先认核心最大眼**(`unit_count` / 各 unit `node_count`)。
+5. `addTopologyViewNodes`(可按 `role` 大档或后续 level)/ `projectTopologyNeighbors`(区域画布务必 `region_folder_id`)。
+6. 多余点用 `removeTopologyViewNodes` **移出画布**(不删 Fabric)。`region:…` 幽灵点勿当网元拖。
---
-## 形状与 dual_units
+## 形状与 dual_units(只服务「第一只眼」)
```
analyzeTopologyViewLayout({ view_id, detail: "structure" })
@@ -50,19 +202,85 @@ analyzeTopologyViewLayout({ view_id, detail: "structure" })
| 字段 | 用途 |
|------|------|
| `shape.primary` | `chains` / `star` / `mesh` / `mixed_blocks` |
-| `dual_units` | 两端门户 + ≥2 条内部不交走廊;成员可重叠 |
-| `advice.block_plan` | 每块怎么拖 |
+| `dual_units` | **从核心出发**:core–core → core–agg → …,再取 **node_count 最大**的一只做唯一 sink |
+| `advice.block_plan` | 其余块怎么靠(非 dual 连抽) |
| `gravity.type` | 链图勿当 hub 花瓣 |
-- **链图**:脊柱水平 + stub;小图 preview `corridor`/`compact`(跳过 rings)。
-- **巨图 / 多门户**:勿全图一把揉;走 **sink 分批 dual_unit**。
+- **第一步必选最大核心眼**:`prefer_top_eye=true`,`prefer_pure=false`,`max_units=1`;目标是**一块吃进尽量多网元**,不要求交叉=0。
+- **禁止**把 dual_units 当成「抽干源图」的循环泵;剩余网元见下方「后续下沉」。
- **禁止**再用互斥 soft_block 把通路切开。
+- **链图 / 小图**:可不走 dual;preview `corridor`/`compact`。
+
+---
+
+## 根图 → 子区域:第一次(dual,仅一次)
+
+```
+sinkTopologyDualUnits({
+ source_view_id: <根图>,
+ sink_view_id: <子区域>, // 同一张接入画布贯穿全程
+ max_units: 1,
+ max_nodes: 300,
+ max_batch_nodes: 400,
+ layout_batch: true,
+ sink_layers: ["access"],
+ keep_layers: ["core", "agg"],
+ prefer_top_eye: true,
+ prefer_pure: false,
+ // 勿 until_empty;勿随后再调本工具
+})
+→ 眼形验收 / 手拖;必要时 layout_dual_unit 重布同一眼
+```
+
+- **同一 sink**:后续非 dual 迁入也进**同一个** `sink_view_id`;禁止块1/块2/块3。
+- **眼定型后**:对该 sink **只门控精修**(见上节)。
+
+---
+
+## 后续下沉(换算法,禁用 dual)
+
+源图仍有接入 / 旁路网元时,**不要**再 `sinkTopologyDualUnits`。
+
+0. **先问算法要批次**(推荐)
+ ```
+ suggestSinkHubs({
+ source_view_id: <根图>,
+ sink_view_id: <眼 sink>,
+ pick: 1,
+ })
+ → batch.fabric_node_ids / move_nodes 可直接套用
+ ```
+ 排序:剩余领地大优先、agg 优于 core;眼门户不当 batch 头。
+
+ **每批迁完立刻** `until_limit(crossing, portal_ids)`,再取下一批;勿攒多批再一起 polish。
+
+ `suggestSinkHubs` 空了但根上还有孤立末梢/旁路:清掉已在 sink 的重复点,再对 `root−sink−眼门户` 分批 `move_nodes(park)`(每批后 `until_limit`)。也会直接吐 `orphan_batch_*`。勿再 dual。
+
+1. **指定迁移 + 扫角停靠**
+ ```
+ layoutTopologyView({
+ action: "move_nodes",
+ source_view_id: , view_id: ,
+ mode: "apply",
+ params: {
+ fabric_node_ids: [...],
+ park: true,
+ remove_from_source: true,
+ },
+ })
+ ```
+
+2. **投影邻居**:`projectTopologyNeighbors`(区域画布带 `region_folder_id`),再手拖或 `orbit_sweep`。
+
+3. **根图收尾**:`level_bands` 只钉**根图**上残留的 core/agg;**勿**对已定型眼 sink 跑全局带。
+
+4. **小连通块**:源上剩余短链/星,可对**源图**或迁入后**单点**处理;勿对眼 sink `layout(recipe=…)`。
---
## 分层布局(特殊场景)
-当网络需呈现层级结构(外部→接入→核心→汇聚→客户等)时,优化策略不同于通用 mesh:
+当网络需呈现层级结构(外部→接入→核心→汇聚→客户等)时:
### 分层通用规则
1. **顶层:外部/对接网络**
@@ -73,54 +291,16 @@ analyzeTopologyViewLayout({ view_id, detail: "structure" })
### 优化流程
1. **先手动后算法**:用户手动拖拽保证业务结构,再用算法优化
-2. **分层约束**:用 `updateTopologyViewPositions` 按层级设定 y 坐标(同层 y 落在区间内可错落,勿跨层);`orbit_sweep` 传 `y_min/y_max` 约束搜索在本层区间内
-3. **分步迭代**:
- - `polish_crossings` 减少交叉(可能破坏分层)
- - 若分层被破坏,**保留 x 坐标,恢复 y 坐标**重新分层
- - `clear_edge_hits` 消除贴边
- - `fix_overlaps` 修复重叠
+2. **分层约束**:`updateTopologyViewPositions` 按层钉 y;`orbit_sweep` 可传 `y_min/y_max`
+3. **分步(非眼画布 / 根图)**:可贴边、分层钉 y;**已定型眼 sink 除外**——只允许门控精修 / 手拖
-### 冲突处理原则
+### 冲突处理
| 冲突场景 | 处理方式 |
|---------|---------|
-| 交叉 vs 结构 | **结构优先**,接受少量跨层交叉 |
-| 算法 vs 手动 | **手动优先**,算法辅助 |
-| polish 破坏分层 | 保留 x 坐标,恢复 y 坐标分层 |
-
-### 关键经验
-1. **跨层交叉是结构性的**:跨层连线必然穿越中间层,属正常
-2. **外层节点必须在外部层**:与 PS/PE 连接的外部网元(RNC、华为设备等)应放在最上方
-3. **orbit_sweep 卡壳更早**:分层约束下 `improving_n=0` 来得快;改传 `objective=total` 让 orbit_sweep 按 crossing+edge_clearance 综合排序
-4. **验收以层级清晰为先**:`verdict.total` 次于结构可读性;同层 y 在区间内、层间不串层即可交付
-
----
-
-## 根图 → 子区域排水
-
-```
-sinkTopologyDualUnits({
- source_view_id: <根图>,
- sink_view_id: <子区域>,
- max_units: 3,
- max_batch_nodes: 120,
- layout_batch: true, // 每单元 layout_dual_unit → 块扫挂 sink
-})
-→ orbit_sweep / polish_crossings / clear_edge_hits
-→ 再调下一批(source_remaining>0)
-```
-
-- **一次只沉一批**;勿日常 `until_empty`。
-- 落点:**块扫**(交叉/重叠/桥长择优),禁止固定往右排。
-- 指定迁移:
- ```
- layoutTopologyView({
- action: "move_nodes",
- source_view_id: , view_id: ,
- mode: "apply",
- params: { fabric_node_ids: [...], park: true, remove_from_source: true },
- })
- ```
- `park=true` = 扫角停靠;回迁对调两 view_id。
+| 交叉 vs 结构 | **结构优先**(最大眼 / 分层) |
+| 算法 vs 手动 | **手动优先** |
+| polish 拆眼 | **禁止**对 dual 眼走 straighten 主路径 |
+| 算法 vs 金标观感 | 交叉可赢;紧凑/正交/贴边交给手拖 |
---
@@ -128,27 +308,20 @@ sinkTopologyDualUnits({
| action | 用途 |
|--------|------|
-| `layout` | 小图配方:`compact` / `corridor` / `rings` / `unstick` |
-| `layout_dual_unit` | 双门户眼形;单元内交叉≠0 拒绝 |
-| `move_nodes` / `sink_nodes` | 指定 ids 双向迁移;`park` 块扫 |
-| `orbit_sweep` | 压交叉;`round` + 大 `max_jump`(约 1800–2800);`objective=total` 综合 crossing+edge_clearance;`y_min/y_max` 分层约束 |
-| `polish_crossings` | 一键:straighten→press→untangle |
-| `clear_edge_hits` | 网元贴非关联边时正交弹开 |
-| `fix_overlaps` / `resolve_overlaps` | 只拉开重叠 |
-| `untangle` | 贪心降交叉;默认可冻门户 |
-| `straighten_channels` | 拉直 deg≤2 走廊 |
+| `layout` | 小图 / 剩余块:`compact` / `corridor` / `rings` / `unstick` |
+| `layout_dual_unit` | **仅**重布已沉的那一只眼;椭圆弧带 + 空心眼 |
+| `move_nodes` / `sink_nodes` | **后续下沉主路径**;`park` 块扫 |
+| `orbit_sweep` | **眼后精修主工具**:`until_limit` 或 `node_id`;`objective=crossing\|total` |
+| `clear_edge_hits` | 眼 sink **可**(须 `portal_ids`;交叉不升) |
+| `pull_far_chains` | 眼 sink **可**:朝门户中点收远走廊/孤立点 |
+| `align_reference` | **非主路径**:仅同网同成员 A/B 调试;跨网禁用 |
+| `compact_bbox` | 眼 sink **可**:farthest-K 收 bbox |
+| `level_bands` | 仅根图/未定型画布;巨图在 dual 一次之后钉根 |
+| `polish_crossings` | **眼 sink 禁用** |
+| `fix_overlaps` / `untangle` / `straighten_channels` | **眼 sink 禁用** |
| `job_status` / `job_cancel` | 后台 job |
-阶段2是多目标循环,非单向链:
-
-1. `orbit_sweep` 压交叉 → `polish_crossings`
-2. **orbit_sweep `improving_n=0` 时勿停**——立即转看 `edge_clearance`(贴边对视觉可读性影响 ≥ 交叉)→ `clear_edge_hits` 正交弹开
-3. 再看 `edge_axis`(斜边过多)→ `straighten_channels` / 手拖归轴
-4. 回头复检 crossing 是否因上步变动出现新机会 → 再 `orbit_sweep`
-
-`orbit_round` 只在全局交叉严格下降时落笔;卡顿加大 `max_jump` / 单点 preview→pick。
-
-**关键**:单节点 `updateTopologyViewPositions` 拖动时,某节点移动可能增交叉但解多个贴边——以 `verdict.total` 升降为准,勿只盯 crossing 数。`drag_candidates` 可能为空,此时看 `edge_clearance.top` / `crossing.top_nodes` 自行判断拖谁。
+眼 sink 精修 = 门控序列 + 手拖补观感;不要排全局流水线,也不要在 stall 后继续发明新全局动作。
---
@@ -156,20 +329,16 @@ sinkTopologyDualUnits({
| 块 | 看什么 |
|----|--------|
-| `overlap` | 硬零(权重 0.24,硬门控) |
-| `crossing` | crossings/cpl;`top_nodes` / `top_edges`(权重 0.18) |
-| `rings` | 最小环被穿(权重 0.10) |
-| `edge_clearance` | 贴边 → clear_edge_hits;**权重 0.08 但视觉影响 ≥ crossing,优先处理** |
-| `edge_axis` | 斜边过多 → straighten_channels / 手拖归轴(权重 0.06) |
-| `verdict.total` | ≈70 可交付(ov=0);**总分升降为准,勿只盯 crossing** |
+| `overlap` | 硬零(权重 0.24) |
+| `crossing` | crossings/cpl;`top_nodes` / `top_edges` |
+| `sparsity` / `edge_axis` / `edge_clearance` | 对照人工:紧凑、正交、贴边(算法常弱于此) |
+| `rings` / `chains` | 环被穿、走廊折角 |
+| `verdict.total` | ≈70 可交付(ov=0);结构可读优先于刷分 |
+| `score_profile` | `auto`(默认)/ `default` / `eye`;大稀疏斜边画布自动走 eye |
-### 评分优化快速方法
-1. **消除 overlap** → 硬门控项,必须为 0
-2. **减少 crossings** → polish_crossings 大幅降低
-3. **处理 edge_clearance** → clear_edge_hits 消除贴边
-4. **结构检查** → 分层清晰 > 交叉最少
+**评分修订(相对旧权)**:util 甜区下限 0.08;贴边分=节点命中∪hits/link;rings 降权;eye 再降 axis、抬 util/clearance;`compactness` 仅诊断不加权。
-图标 25px;推荐中心距 Δx≥200、Δy≥170。交叉 = 无向 NE↔NE 真交叉(共端点不算)。
+图标 25px;推荐中心距 Δx≥200、Δy≥170。
---
@@ -178,59 +347,25 @@ sinkTopologyDualUnits({
| 工具 | 作用 |
|------|------|
| `getTopologyTree` / `getTopologyView` | 树与画布 |
-| `createTopologyFolder` | 新建根/区域(返回 view_id) |
+| `createTopologyFolder` | 新建根/区域 |
| `add` / `remove` / `updateTopologyViewPositions` | 成员与手拖 |
-| `sinkTopologyDualUnits` | dual_units 分批沉入 |
-| `copyTopologyViewNodes` | 克隆沙箱 |
+| `sinkTopologyDualUnits` | **仅一次**:最大核心眼 → sink |
+| `suggestSinkHubs` | **后续下沉批次**(含 orphan_batch) |
+| `move_nodes`(经 layoutTopologyView) | **后续下沉** |
| `projectTopologyNeighbors` | 投影邻居 |
-| `queryTopologyFabricNodes` | 库存(summary\|list\|search) |
+| `copyTopologyViewNodes` | 克隆沙箱 |
+| `queryTopologyFabricNodes` | 库存 |
| neighborhood / edges | 邻接 |
| `layoutTopologyView` | 上表 action |
| `analyzeTopologyViewLayout` | structure + 验收 |
---
-## 通用优化策略(实战经验)
-
-### 算法与手动的最佳组合
-```
-Step 1: 用户手动拖拽 → 保证业务结构和分层
-Step 2: polish_crossings → 大幅减少交叉(接受可能破坏分层)
-Step 3: 检查分层 → 若被破坏,保留 x 恢复 y 重新分层
-Step 4: clear_edge_hits → 处理贴边问题
-Step 5: fix_overlaps → 修复节点重叠
-Step 6: 最终验收 → 结构清晰 > 交叉最少
-```
-
-### 工具使用优先级
-| 场景 | 首选工具 | 参数建议 |
-|-----|---------|---------|
-| 整体减交叉 | `polish_crossings` | `top_n=10, max_moves=50` |
-| 单点微调 | `orbit_sweep` | `objective=total, y_min/y_max` |
-| 处理贴边 | `clear_edge_hits` | `top_n=15, max_moves=30` |
-| 修复重叠 | `fix_overlaps` | 直接调用 |
-| 批量调坐标 | `updateTopologyViewPositions` | 保留 x,恢复 y |
-
-### 常见问题解决方案
-| 问题 | 原因 | 解决方案 |
-|-----|------|---------|
-| polish 破坏分层 | 算法优先减少交叉 | 保留 x 坐标,恢复 y 分层 |
-| orbit_sweep 无改善 | 位置已优化 | 接受现状或手动调整 |
-| 节点重叠 | 坐标调整太近 | fix_overlaps 自动修复 |
-| 交叉突然增加 | clear_edge_hits 移动节点 | 重新 polish_crossings |
-
-### 关键原则
-1. **业务结构优先于算法优化**:网络拓扑的分层结构比最少交叉更重要
-2. **局部微调优于全局重置**:用 `orbit_sweep`/`updateTopologyViewPositions` 单点调整,而非 `layout` 全局布局
-3. **预览模式优先于应用模式**:先用 `mode=preview` 查看效果,确认后再 `mode=apply`
-4. **分步迭代优于一次性操作**:polish → clear_edge_hits → fix_overlaps 分步骤执行
-
----
-
## 代码热更
-1. 本仓 MCP 用 `PYTHONPATH=…/src`,改源码后不必为加载而 pip install。
-2. **必须重启** stdio 进程;`catalog` 含 `rev`(当前 `NETX_MCP_REV`)。
-3. `layoutTopologyView(catalog=true)` 核对 action/recipe 清单。
+1. 本仓 MCP 用 `PYTHONPATH=…/src`(见用户 `~/.cursor/mcp.json`),改源码后不必为加载而 pip install。
+2. **必须重启** stdio 进程(杀旧 `python -m netx_topology_mcp` 后由 Cursor 拉起;或 MCP 面板禁用/启用)。
+3. `layoutTopologyView(catalog=true)` 核对:`version` / `rev`(`NETX_MCP_REV`)+ action 含 `pull_far_chains` / `compact_bbox`。
+4. 机器上常残留多份旧 MCP 进程 → 精修前 catalog 若仍缺 action,**杀光再启**;勿用旧进程半截交付,勿改走 HTTP/临时 py。
拓扑页开「实时同步」可看落笔。勿用告警/CLI MCP 写拓扑。
diff --git a/alembic/versions/20260811_fabric_level.py b/alembic/versions/20260811_fabric_level.py
new file mode 100644
index 0000000..465c0b1
--- /dev/null
+++ b/alembic/versions/20260811_fabric_level.py
@@ -0,0 +1,54 @@
+"""Add topo_fabric_node.level for layout classification.
+
+Revision ID: 20260811_fabric_level
+Revises: 20260806_auth_refresh
+Create Date: 2026-08-11
+"""
+
+from __future__ import annotations
+
+from typing import Sequence, Union
+
+from alembic import op
+
+revision: str = "20260811_fabric_level"
+down_revision: Union[str, Sequence[str], None] = "20260806_auth_refresh"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ bind = op.get_bind()
+ dialect = str(getattr(bind.dialect, "name", "") or "").lower()
+ if dialect.startswith("postgres"):
+ op.execute(
+ "ALTER TABLE topo_fabric_node ADD COLUMN IF NOT EXISTS level DOUBLE PRECISION"
+ )
+ op.execute(
+ "CREATE INDEX IF NOT EXISTS ix_topo_fabric_node_level ON topo_fabric_node (level)"
+ )
+ else:
+ # SQLite / others: best-effort
+ try:
+ op.execute("ALTER TABLE topo_fabric_node ADD COLUMN level REAL")
+ except Exception:
+ pass
+ try:
+ op.execute(
+ "CREATE INDEX IF NOT EXISTS ix_topo_fabric_node_level ON topo_fabric_node (level)"
+ )
+ except Exception:
+ pass
+
+
+def downgrade() -> None:
+ bind = op.get_bind()
+ dialect = str(getattr(bind.dialect, "name", "") or "").lower()
+ if dialect.startswith("postgres"):
+ op.execute("DROP INDEX IF EXISTS ix_topo_fabric_node_level")
+ op.execute("ALTER TABLE topo_fabric_node DROP COLUMN IF EXISTS level")
+ else:
+ try:
+ op.drop_column("topo_fabric_node", "level")
+ except Exception:
+ pass
diff --git a/docs/MCP_TOPOLOGY.md b/docs/MCP_TOPOLOGY.md
index 94a5040..b149c10 100644
--- a/docs/MCP_TOPOLOGY.md
+++ b/docs/MCP_TOPOLOGY.md
@@ -83,9 +83,10 @@ Cursor:改包后若 Sync 工具数不对,请 **禁用/启用** `netx-topolog
| `getTopologyTree` | 「根 / 根图 / 子区域」树 + views + `ne_count`(找画布用这个,勿再 listViews) |
| `getTopologyView` | 单图(节点+边+坐标);`detail=summary\|full` |
| `queryTopologyFabricNodes` | Fabric 库存:`mode=summary\|list\|search`(有 `q` 默认 search;list 支持 `region_folder_id`) |
+| `classifyTopologyFabricNodes` | **分级打标**:`match`→`tag(level\|role, dry_run)`→`tag`;`level` 为 major.minor(0 外部…3 接入);`role` 预设仍可用。再 `addTopologyViewNodes` 上区域画布。**勿用切片建图** |
| `queryTopologyNeighborhood` | 邻域:compact nodes + `links[]`(NE 对,非端口) |
| `queryTopologyEdges` | 默认 **adjacency**:`links[{a,b,link_count}]`;画布一对网元一条线。`detail=ports` 才给端口行 |
-| `analyzeTopologyViewLayout` | 布图验收(只读):`verdict` + score(含中档 `chains` 直链成一体、`rings` 最小环不被穿,各权 0.10);`detail=structure` 给重心/枢纽/配方建议;`hotspots\|blocks\|both` 给手拖 sight(`drag_candidates[].suggest_xy` / `delta_crossings_est`) |
+| `analyzeTopologyViewLayout` | 布图验收(只读):`verdict` + score;`score_profile=auto\|default\|eye`(大稀疏斜边→eye);贴边=节点命中∪hits/link;util 甜区下限 0.08;`detail=structure` / `hotspots\|blocks\|both` |
### 写(只动画布,不污染 Fabric)
@@ -98,7 +99,7 @@ Cursor:改包后若 Sync 工具数不对,请 **禁用/启用** `netx-topolog
| `copyTopologyViewNodes` | 一键克隆画布成员+坐标到另一画布(`clear_target` 可选);源画布不动,测沙箱用 |
| `layoutTopologyView` `move_nodes` | 指定 `fabric_node_ids` 从 `source_view_id`→`view_id`(默认同移出源);对调两 view 回迁;别名 `sink_nodes`;`park=true` 扫角停靠 |
| `updateTopologyViewPositions` | **优先** `layout=grid\|offset\|stack` + 筛选,API 自己挪点;`positions[]` 仅少量微调 |
-| `layoutTopologyView` | **布图/局部修**:公开 `action=layout\|layout_dual_unit\|orbit_sweep\|polish_crossings\|clear_edge_hits\|fix_overlaps\|untangle\|straighten_channels\|move_nodes`;recipe 仅 `compact\|corridor\|rings\|unstick`。巨图 apply 可能返回 `job_id` → 轮询 `job_status` / `job_cancel`。Job=**子进程+`data/runtime/layout_jobs` 落盘**。`mode=preview\|apply` |
+| `layoutTopologyView` | **布图/局部修**:公开 `action=layout\|layout_dual_unit\|orbit_sweep\|clear_edge_hits\|pull_far_chains\|compact_bbox\|polish_crossings\|fix_overlaps\|untangle\|straighten_channels\|level_bands\|move_nodes`;recipe 仅 `compact\|corridor\|rings\|unstick`。眼 sink 主路径:`until_limit(crossing→total)` → `clear_edge_hits` → `pull_far_chains` → `compact_bbox` → 手拖;禁对眼跑 polish/fix_overlaps/untangle/round。stall 且 moved≈0 即算法到头。巨图 apply 可能返回 `job_id` → 轮询 `job_status` / `job_cancel`。Job=**子进程+`data/runtime/layout_jobs` 落盘**。`mode=preview\|apply` |
| `projectTopologyNeighbors` | 投影**已有** LLDP 邻居到画布;区域画布务必传 `region_folder_id`,读 `out_of_region_skipped` |
**推荐流水线:** `getTopologyTree` →(可选)`createTopologyFolder` 取 `view_id` → `addTopologyViewNodes` →(可选)邻居投影 / 布局。
@@ -120,7 +121,7 @@ Cursor:改包后若 Sync 工具数不对,请 **禁用/启用** `netx-topolog
| 包 | server_id | 职责 |
|----|-----------|------|
| `netx-mcp` | `netx` | 告警、UME、托管网元 CLI(**13** 工具) |
-| `netx-topology-mcp` | `netx-topology` | 拓扑画布 / Fabric 只读 + 安全画图(**14** 工具) |
+| `netx-topology-mcp` | `netx-topology` | 拓扑画布 / Fabric 只读 + 分类打标 + 安全画图(**15** 工具) |
`queryTopologyEdges` 已从 `netx-mcp` **迁出**到本包,避免重复。
diff --git a/netx_api/app_shutdown.py b/netx_api/app_shutdown.py
index 70fd598..0975a17 100644
--- a/netx_api/app_shutdown.py
+++ b/netx_api/app_shutdown.py
@@ -31,6 +31,13 @@ def shutdown_runtime(*, reason: str = "lifespan") -> None:
except Exception: # noqa: BLE001
_log.exception("stop_lldp_collect_scheduler failed")
+ try:
+ from .ne_collect_scheduler import stop_ne_collect_scheduler
+
+ stop_ne_collect_scheduler()
+ except Exception: # noqa: BLE001
+ _log.exception("stop_ne_collect_scheduler failed")
+
try:
from .port_traffic_scheduler import stop_port_traffic_scheduler
diff --git a/netx_api/app_startup.py b/netx_api/app_startup.py
index 7e2f82e..4552557 100644
--- a/netx_api/app_startup.py
+++ b/netx_api/app_startup.py
@@ -115,11 +115,17 @@ def run_api_startup() -> None:
if cfg_resumed:
_log.info("startup: resumed %s config_sync task(s) from interrupted cycle", cfg_resumed)
try:
+ from .collection_policy import ensure_policy as ensure_ne_collect_policy
+ from .collection_policy import history_keep_value, prune_collection_jobs
from .lldp_collect_service import ensure_policy as ensure_lldp_collect_policy
ensure_lldp_collect_policy(db)
+ pol = ensure_ne_collect_policy(db)
+ pruned_jobs = prune_collection_jobs(db, keep=history_keep_value(pol))
+ if pruned_jobs:
+ _log.info("startup: pruned %s ne_collection job(s) by history_keep", pruned_jobs)
except Exception:
- _log.exception("startup: lldp_collect policy ensure failed")
+ _log.exception("startup: lldp/ne_collect policy ensure failed")
pt_cleared = recover_port_traffic_on_startup(db)
if pt_cleared:
_log.info("startup: cleared %s port_traffic stuck collect_running flag(s)", pt_cleared)
@@ -151,7 +157,7 @@ def run_api_startup() -> None:
else:
_log.info(
"startup: inline schedulers disabled — run `python -m netx_api.worker` for "
- "config_sync / lldp_collect / port_traffic"
+ "config_sync / lldp_collect / ne_collect / port_traffic"
)
start_api_sideband_threads()
diff --git a/netx_api/collection_job_state.py b/netx_api/collection_job_state.py
index bb3ad07..d081160 100644
--- a/netx_api/collection_job_state.py
+++ b/netx_api/collection_job_state.py
@@ -48,6 +48,12 @@ def finalize_collection_job(db: Session, job_id: str) -> None:
job.ended_at = finish_at
job.last_run_at = job.ended_at or finish_at
db.commit()
+ try:
+ from .collection_policy import ensure_policy, history_keep_value, prune_collection_jobs
+
+ prune_collection_jobs(db, keep=history_keep_value(ensure_policy(db)))
+ except Exception: # noqa: BLE001
+ pass
def reconcile_stale_collection_job(db: Session, job_id: str) -> bool:
diff --git a/netx_api/collection_policy.py b/netx_api/collection_policy.py
new file mode 100644
index 0000000..2389fba
--- /dev/null
+++ b/netx_api/collection_policy.py
@@ -0,0 +1,265 @@
+"""NE batch-collect policy, prune-by-count, and schedule due helpers."""
+
+from __future__ import annotations
+
+import logging
+import shutil
+from datetime import datetime, timedelta
+from typing import Any
+
+from fastapi import HTTPException
+from sqlalchemy.orm import Session
+
+from .collection_schemas import (
+ CollectionPolicyOut,
+ CollectionPolicyUpdate,
+ CollectionTargetRef,
+)
+from .models import ManagedNE, NeCollectionJob, NeCollectionPolicy, NeCollectionRun, UmeInventoryNE
+from .ne_collection_paths import collection_data_root
+from .timeutil import utcnow_naive # used by ensure_policy.updated_at
+
+_log = logging.getLogger("netx.collection.policy")
+
+POLICY_ID = 1
+DEFAULT_HISTORY_KEEP = 3
+MAX_INTERVAL_HOURS = 8760 # 365d
+
+
+def _utcnow() -> datetime:
+ return datetime.now()
+
+
+def _normalize_interval_hours(row: NeCollectionPolicy) -> int:
+ hours = int(getattr(row, "interval_hours", 0) or 0)
+ if hours <= 0:
+ hours = max(1, int(row.interval_days or 1)) * 24
+ return max(1, min(MAX_INTERVAL_HOURS, hours))
+
+
+def ensure_policy(db: Session) -> NeCollectionPolicy:
+ row = db.get(NeCollectionPolicy, POLICY_ID)
+ if row is None:
+ row = NeCollectionPolicy(
+ id=POLICY_ID,
+ enabled=False,
+ interval_days=1,
+ interval_hours=24,
+ scope_mode="all",
+ selected_targets=[],
+ title="",
+ commands="",
+ history_keep=DEFAULT_HISTORY_KEEP,
+ updated_at=_utcnow(),
+ )
+ db.add(row)
+ db.commit()
+ db.refresh(row)
+ if int(getattr(row, "interval_hours", 0) or 0) <= 0:
+ row.interval_hours = max(1, int(row.interval_days or 1)) * 24
+ db.commit()
+ db.refresh(row)
+ return row
+
+
+def _targets_from_json(raw: Any) -> list[CollectionTargetRef]:
+ items: list[CollectionTargetRef] = []
+ if not isinstance(raw, list):
+ return items
+ for x in raw:
+ if not isinstance(x, dict):
+ continue
+ tid = str(x.get("id") or "").strip()
+ if not tid:
+ continue
+ src = str(x.get("source") or "managed").strip().lower() or "managed"
+ if src not in {"managed", "ume"}:
+ src = "managed"
+ items.append(CollectionTargetRef(source=src, id=tid))
+ return items
+
+
+def policy_to_out(row: NeCollectionPolicy) -> CollectionPolicyOut:
+ hours = _normalize_interval_hours(row)
+ days = max(1, min(365, (hours + 23) // 24))
+ keep = getattr(row, "history_keep", None)
+ if keep is None:
+ keep = DEFAULT_HISTORY_KEEP
+ return CollectionPolicyOut(
+ enabled=bool(row.enabled),
+ interval_days=days,
+ interval_hours=hours,
+ scope_mode="selected" if str(row.scope_mode or "") == "selected" else "all",
+ selected_targets=_targets_from_json(row.selected_targets),
+ title=str(row.title or ""),
+ commands=str(row.commands or ""),
+ history_keep=max(0, min(200, int(keep))),
+ updated_at=row.updated_at,
+ )
+
+
+def get_policy(db: Session) -> CollectionPolicyOut:
+ return policy_to_out(ensure_policy(db))
+
+
+def history_keep_value(row: NeCollectionPolicy | None = None) -> int:
+ if row is None:
+ return DEFAULT_HISTORY_KEEP
+ keep = getattr(row, "history_keep", None)
+ if keep is None:
+ keep = DEFAULT_HISTORY_KEEP
+ return max(0, min(200, int(keep)))
+
+
+def prune_collection_jobs(db: Session, *, keep: int = DEFAULT_HISTORY_KEEP) -> int:
+ """Delete finished jobs beyond ``keep`` (newest kept). Active jobs always retained."""
+ keep = max(0, min(200, int(keep)))
+ finished = (
+ db.query(NeCollectionJob)
+ .filter(NeCollectionJob.status.in_(("done", "failed")))
+ .order_by(NeCollectionJob.created_at.desc())
+ .all()
+ )
+ to_drop = finished if keep == 0 else finished[keep:]
+ if not to_drop:
+ return 0
+ root = collection_data_root().resolve()
+ dropped = 0
+ for job in to_drop:
+ jid = str(job.id)
+ db.query(NeCollectionRun).filter(NeCollectionRun.job_id == jid).delete(
+ synchronize_session=False
+ )
+ db.delete(job)
+ dropped += 1
+ job_dir = (root / jid).resolve()
+ if str(job_dir).startswith(str(root)) and job_dir.is_dir():
+ shutil.rmtree(job_dir, ignore_errors=True)
+ if dropped:
+ db.commit()
+ _log.info("pruned %s ne_collection job(s); keep=%s", dropped, keep)
+ return dropped
+
+
+def update_policy(db: Session, body: CollectionPolicyUpdate) -> CollectionPolicyOut:
+ row = ensure_policy(db)
+ data = body.model_dump(exclude_unset=True)
+ if "enabled" in data and data["enabled"] is not None:
+ row.enabled = bool(data["enabled"])
+ if "interval_hours" in data and data["interval_hours"] is not None:
+ hours = max(1, min(MAX_INTERVAL_HOURS, int(data["interval_hours"])))
+ row.interval_hours = hours
+ row.interval_days = max(1, min(365, (hours + 23) // 24))
+ elif "interval_days" in data and data["interval_days"] is not None:
+ days = max(1, min(365, int(data["interval_days"])))
+ row.interval_days = days
+ row.interval_hours = days * 24
+ if "scope_mode" in data and data["scope_mode"] is not None:
+ mode = str(data["scope_mode"] or "").strip().lower()
+ if mode not in {"all", "selected"}:
+ raise HTTPException(status_code=400, detail="invalid_scope_mode")
+ row.scope_mode = mode
+ if "selected_targets" in data and data["selected_targets"] is not None:
+ cleaned: list[dict[str, str]] = []
+ for ref in data["selected_targets"] or []:
+ if isinstance(ref, CollectionTargetRef):
+ tid = ref.id.strip()
+ src = (ref.source or "managed").strip().lower() or "managed"
+ elif isinstance(ref, dict):
+ tid = str(ref.get("id") or "").strip()
+ src = str(ref.get("source") or "managed").strip().lower() or "managed"
+ else:
+ continue
+ if not tid:
+ continue
+ if src not in {"managed", "ume"}:
+ src = "managed"
+ cleaned.append({"source": src, "id": tid})
+ row.selected_targets = cleaned
+ if "title" in data and data["title"] is not None:
+ row.title = str(data["title"] or "").strip()[:256]
+ if "commands" in data and data["commands"] is not None:
+ row.commands = str(data["commands"] or "")
+ if "history_keep" in data and data["history_keep"] is not None:
+ row.history_keep = max(0, min(200, int(data["history_keep"])))
+ if bool(row.enabled):
+ from .collection_service import _parse_commands
+
+ if not _parse_commands(str(row.commands or "")):
+ raise HTTPException(status_code=400, detail="commands_required_for_schedule")
+ if str(row.scope_mode or "") == "selected" and not (row.selected_targets or []):
+ raise HTTPException(status_code=400, detail="no_selected_targets")
+ row.updated_at = _utcnow()
+ db.commit()
+ db.refresh(row)
+ prune_collection_jobs(db, keep=history_keep_value(row))
+ return policy_to_out(row)
+
+
+def next_due_at(db: Session, policy: NeCollectionPolicy | None = None) -> datetime | None:
+ """Due time based on last *scheduled* successful collect only (manual must not reset)."""
+ pol = policy or ensure_policy(db)
+ if not pol.enabled:
+ return None
+ hours = _normalize_interval_hours(pol)
+ last = (
+ db.query(NeCollectionJob)
+ .filter(
+ NeCollectionJob.status == "done",
+ NeCollectionJob.trigger_mode == "schedule",
+ NeCollectionJob.ended_at.isnot(None),
+ )
+ .order_by(NeCollectionJob.ended_at.desc())
+ .first()
+ )
+ if last is None or last.ended_at is None:
+ return _utcnow()
+ return last.ended_at + timedelta(hours=hours)
+
+
+def expand_policy_targets(db: Session, policy: NeCollectionPolicy) -> list[tuple[str, str, str, str]]:
+ """Return list of (source, id, name, ip) for a policy."""
+ from .device_types import WEBCRT_NE_SOURCE
+
+ mode = str(policy.scope_mode or "all").strip().lower()
+ out: list[tuple[str, str, str, str]] = []
+ seen: set[tuple[str, str]] = set()
+
+ def _add(source: str, tid: str, name: str, ip: str) -> None:
+ key = (source, tid)
+ if key in seen:
+ return
+ seen.add(key)
+ out.append((source, tid, name, ip))
+
+ if mode == "selected":
+ for ref in _targets_from_json(policy.selected_targets):
+ if ref.source == "managed":
+ ne = db.get(ManagedNE, ref.id)
+ if ne:
+ _add(
+ "managed",
+ str(ne.id),
+ str(ne.name or ne.ip_address or ""),
+ str(ne.ip_address or ""),
+ )
+ else:
+ inv = db.get(UmeInventoryNE, ref.id)
+ if inv:
+ name = str(inv.host_name or inv.user_label or inv.ne_name or inv.ip_address or inv.ne_id)
+ _add("ume", str(inv.ne_id), name, str(inv.ip_address or ""))
+ return out
+
+ for ne in (
+ db.query(ManagedNE)
+ .filter(ManagedNE.source != WEBCRT_NE_SOURCE)
+ .order_by(ManagedNE.name.asc())
+ .all()
+ ):
+ _add("managed", str(ne.id), str(ne.name or ne.ip_address or ""), str(ne.ip_address or ""))
+ for inv in db.query(UmeInventoryNE).order_by(UmeInventoryNE.host_name.asc()).all():
+ if not str(inv.ip_address or "").strip():
+ continue
+ name = str(inv.host_name or inv.user_label or inv.ne_name or inv.ip_address or inv.ne_id)
+ _add("ume", str(inv.ne_id), name, str(inv.ip_address or ""))
+ return out
diff --git a/netx_api/collection_router.py b/netx_api/collection_router.py
index 6470650..7e25067 100644
--- a/netx_api/collection_router.py
+++ b/netx_api/collection_router.py
@@ -4,8 +4,11 @@ from fastapi import APIRouter, BackgroundTasks, Depends, Query
from fastapi.responses import FileResponse, Response
from sqlalchemy.orm import Session
+from .collection_policy import get_policy, update_policy
+from .collection_schemas import CollectionJobCreate, CollectionPolicyUpdate
from .collection_service import (
build_collection_job_zip,
+ create_and_start_from_policy,
create_collection,
delete_collection_job,
get_collection_dashboard,
@@ -19,7 +22,6 @@ from .collection_service import (
start_collection_job,
retry_failed_collection_job,
)
-from .collection_schemas import CollectionJobCreate
from .db import get_db
from .models import NeCollectionRun
from .ne_collect_runner import dispatch_collection_runs
@@ -42,6 +44,32 @@ def api_collection_dashboard(db: Session = Depends(get_db)):
return get_collection_dashboard(db).model_dump()
+@router.get("/policy")
+def api_get_collection_policy(db: Session = Depends(get_db)):
+ return get_policy(db).model_dump()
+
+
+@router.put("/policy")
+def api_put_collection_policy(body: CollectionPolicyUpdate, db: Session = Depends(get_db)):
+ return update_policy(db, body).model_dump()
+
+
+@router.post("/start-from-policy")
+def api_start_from_policy(
+ background_tasks: BackgroundTasks,
+ db: Session = Depends(get_db),
+):
+ """Create + start a collect job from the saved policy (manual trigger)."""
+ out, payload = create_and_start_from_policy(db, trigger_mode="manual")
+ background_tasks.add_task(
+ dispatch_collection_runs,
+ payload["job_id"],
+ payload["run_ids"],
+ payload["commands"],
+ )
+ return out.model_dump()
+
+
@router.post("")
def api_create_collection(body: CollectionJobCreate, db: Session = Depends(get_db)):
return create_collection(db, body).model_dump()
@@ -51,9 +79,13 @@ def api_create_collection(body: CollectionJobCreate, db: Session = Depends(get_d
def api_list_collections(
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
+ status: str = Query(default=""),
+ keyword: str = Query(default=""),
db: Session = Depends(get_db),
):
- return list_collection_jobs(db, page=page, page_size=page_size)
+ return list_collection_jobs(
+ db, page=page, page_size=page_size, status=status, keyword=keyword
+ )
@router.get("/runs/{run_id}/download")
diff --git a/netx_api/collection_schemas.py b/netx_api/collection_schemas.py
index 040ab47..428bc09 100644
--- a/netx_api/collection_schemas.py
+++ b/netx_api/collection_schemas.py
@@ -5,6 +5,34 @@ from datetime import datetime
from pydantic import BaseModel, Field
+class CollectionTargetRef(BaseModel):
+ source: str = "managed" # managed | ume
+ id: str
+
+
+class CollectionPolicyOut(BaseModel):
+ enabled: bool = False
+ interval_days: int = 1
+ interval_hours: int = 24
+ scope_mode: str = "all"
+ selected_targets: list[CollectionTargetRef] = Field(default_factory=list)
+ title: str = ""
+ commands: str = ""
+ history_keep: int = 3
+ updated_at: datetime | None = None
+
+
+class CollectionPolicyUpdate(BaseModel):
+ enabled: bool | None = None
+ interval_days: int | None = Field(default=None, ge=1, le=365)
+ interval_hours: int | None = Field(default=None, ge=1, le=8760)
+ scope_mode: str | None = None
+ selected_targets: list[CollectionTargetRef] | None = None
+ title: str | None = None
+ commands: str | None = None
+ history_keep: int | None = Field(default=None, ge=0, le=200)
+
+
class CollectionJobCreate(BaseModel):
title: str = ""
commands: str = Field(min_length=1)
@@ -31,6 +59,7 @@ class CollectionJobOut(BaseModel):
id: str
title: str
commands: str
+ trigger_mode: str = "manual"
status: str
ne_count: int
success_count: int
@@ -47,6 +76,7 @@ class CollectionJobSummary(BaseModel):
id: str
title: str
status: str
+ trigger_mode: str = "manual"
ne_count: int
success_count: int
fail_count: int
@@ -61,3 +91,5 @@ class CollectionDashboardOut(BaseModel):
active_count: int = 0
running_job: CollectionJobSummary | None = None
last_job: CollectionJobSummary | None = None
+ next_due_at: datetime | None = None
+ policy: CollectionPolicyOut | None = None
diff --git a/netx_api/collection_service.py b/netx_api/collection_service.py
index 75fee0b..660afff 100644
--- a/netx_api/collection_service.py
+++ b/netx_api/collection_service.py
@@ -20,6 +20,14 @@ from .collection_job_state import (
sync_job_progress,
_sync_job_counts,
)
+from .collection_policy import (
+ ensure_policy,
+ expand_policy_targets,
+ history_keep_value,
+ next_due_at,
+ policy_to_out,
+ prune_collection_jobs,
+)
from .collection_schemas import (
CollectionDashboardOut,
CollectionJobCreate,
@@ -87,6 +95,7 @@ def job_to_out(row: NeCollectionJob, *, output_count: int | None = None) -> Coll
id=str(row.id),
title=str(row.title or ""),
commands=str(row.commands or ""),
+ trigger_mode=str(getattr(row, "trigger_mode", None) or "manual"),
status=str(row.status or "pending"),
ne_count=int(row.ne_count or 0),
success_count=int(row.success_count or 0),
@@ -107,6 +116,7 @@ def job_to_summary(row: NeCollectionJob | None) -> CollectionJobSummary | None:
id=str(row.id),
title=str(row.title or "").strip() or str(row.id)[:8],
status=str(row.status or "pending"),
+ trigger_mode=str(getattr(row, "trigger_mode", None) or "manual"),
ne_count=int(row.ne_count or 0),
success_count=int(row.success_count or 0),
fail_count=int(row.fail_count or 0),
@@ -162,11 +172,14 @@ def get_collection_dashboard(db: Session) -> CollectionDashboardOut:
or 0
)
last = last_finished_collection_job(db)
+ policy = ensure_policy(db)
return CollectionDashboardOut(
job_count=job_count,
active_count=active_count,
running_job=job_to_summary(running),
last_job=job_to_summary(last),
+ next_due_at=next_due_at(db, policy),
+ policy=policy_to_out(policy),
)
@@ -318,6 +331,7 @@ def create_collection(db: Session, body: CollectionJobCreate) -> CollectionJobOu
job = NeCollectionJob(
title=str(body.title or "").strip() or f"collect-{now.strftime('%Y%m%d-%H%M%S')}",
commands="\n".join(commands),
+ trigger_mode="manual",
status="pending",
ne_count=len(targets),
created_at=now,
@@ -342,8 +356,81 @@ def create_collection(db: Session, body: CollectionJobCreate) -> CollectionJobOu
return job_to_out(job, output_count=0)
-def list_collection_jobs(db: Session, *, page: int = 1, page_size: int = 20) -> dict[str, Any]:
+def create_and_start_from_policy(
+ db: Session,
+ *,
+ trigger_mode: str = "manual",
+) -> tuple[CollectionJobOut, CollectionSchedulePayload]:
+ """Create a job from the singleton policy and start it immediately."""
+ if has_active_collection_job(db) is not None:
+ raise HTTPException(status_code=409, detail="collection_job_running")
+ policy = ensure_policy(db)
+ commands = _parse_commands(str(policy.commands or ""))
+ if not commands:
+ raise HTTPException(status_code=400, detail="commands_empty")
+ targets = expand_policy_targets(db, policy)
+ if not targets:
+ raise HTTPException(status_code=400, detail="no_eligible_ne")
+ mode = str(trigger_mode or "manual").strip().lower() or "manual"
+ if mode not in {"manual", "schedule"}:
+ mode = "manual"
+ now = _now()
+ title = str(policy.title or "").strip() or f"collect-{now.strftime('%Y%m%d-%H%M%S')}"
+ job = NeCollectionJob(
+ title=title,
+ commands="\n".join(commands),
+ trigger_mode=mode,
+ status="pending",
+ ne_count=len(targets),
+ created_at=now,
+ started_at=None,
+ last_run_at=None,
+ )
+ db.add(job)
+ db.flush()
+ for source, tid, name, ip in targets:
+ db.add(
+ NeCollectionRun(
+ job_id=str(job.id),
+ ne_id=tid,
+ ne_source=source,
+ ne_name=name,
+ ne_ip=ip,
+ status="pending",
+ )
+ )
+ db.commit()
+ db.refresh(job)
+ out, payload = start_collection_job(db, str(job.id))
+ try:
+ prune_collection_jobs(db, keep=history_keep_value(policy))
+ except Exception: # noqa: BLE001
+ _log.exception("prune_collection_jobs after start failed")
+ return out, payload
+
+
+def list_collection_jobs(
+ db: Session,
+ *,
+ page: int = 1,
+ page_size: int = 20,
+ status: str = "",
+ keyword: str = "",
+) -> dict[str, Any]:
stmt = db.query(NeCollectionJob)
+ st = str(status or "").strip()
+ if st:
+ stmt = stmt.filter(NeCollectionJob.status == st)
+ kw = str(keyword or "").strip()
+ if kw:
+ like = f"%{kw}%"
+ stmt = stmt.filter(
+ or_(
+ NeCollectionJob.title.ilike(like),
+ NeCollectionJob.id.ilike(like),
+ NeCollectionJob.error_message.ilike(like),
+ )
+ )
total = int(stmt.count())
rows = stmt.order_by(NeCollectionJob.created_at.desc()).offset((page - 1) * page_size).limit(page_size).all()
for row in rows:
diff --git a/netx_api/config.py b/netx_api/config.py
index 83c95b6..1e81650 100644
--- a/netx_api/config.py
+++ b/netx_api/config.py
@@ -81,6 +81,10 @@ class Settings(BaseSettings):
ne_collect_pending_stale_sec: int = 180
ne_collect_run_timeout_cap_sec: int = 600
ne_collection_data_dir: str = "data/ne_collections"
+ # Batch CLI collect scheduler (policy.enabled defaults False — manual until turned on).
+ ne_collect_scheduler_enabled: bool = True
+ ne_collect_scheduler_tick_sec: int = 60
+ ne_collect_startup_grace_sec: int = 3600
# Config sync (periodic running-config backup into DB)
config_sync_scheduler_enabled: bool = True
config_sync_scheduler_tick_sec: int = 60
diff --git a/netx_api/config_sync_cycles.py b/netx_api/config_sync_cycles.py
index a02de81..3b90bb3 100644
--- a/netx_api/config_sync_cycles.py
+++ b/netx_api/config_sync_cycles.py
@@ -6,7 +6,7 @@ from typing import Any
from uuid import uuid4
from fastapi import HTTPException
-from sqlalchemy import func
+from sqlalchemy import func, or_
from sqlalchemy.orm import Session
from .cli_resolve import cli_profile_ready
@@ -252,8 +252,29 @@ def create_cycle(db: Session, body: ConfigSyncCycleCreate) -> ConfigSyncCycleOut
return cycle_to_out(cycle)
-def list_cycles(db: Session, *, page: int, page_size: int) -> dict[str, Any]:
- q = db.query(ConfigSyncCycle).order_by(ConfigSyncCycle.created_at.desc())
+def list_cycles(
+ db: Session,
+ *,
+ page: int,
+ page_size: int,
+ status: str = "",
+ keyword: str = "",
+) -> dict[str, Any]:
+ q = db.query(ConfigSyncCycle)
+ st = str(status or "").strip()
+ if st:
+ q = q.filter(ConfigSyncCycle.status == st)
+ kw = str(keyword or "").strip()
+ if kw:
+ like = f"%{kw}%"
+ q = q.filter(
+ or_(
+ ConfigSyncCycle.id.ilike(like),
+ ConfigSyncCycle.trigger_mode.ilike(like),
+ ConfigSyncCycle.error_message.ilike(like),
+ )
+ )
+ q = q.order_by(ConfigSyncCycle.created_at.desc())
total = int(q.count())
rows = q.offset((page - 1) * page_size).limit(page_size).all()
return {"total": total, "page": page, "page_size": page_size, "items": [cycle_to_out(r) for r in rows]}
diff --git a/netx_api/config_sync_router.py b/netx_api/config_sync_router.py
index f397a80..375f241 100644
--- a/netx_api/config_sync_router.py
+++ b/netx_api/config_sync_router.py
@@ -74,9 +74,11 @@ def api_dashboard(db: Session = Depends(get_db)):
def api_list_cycles(
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
+ status: str = Query(default=""),
+ keyword: str = Query(default=""),
db: Session = Depends(get_db),
):
- return list_cycles(db, page=page, page_size=page_size)
+ return list_cycles(db, page=page, page_size=page_size, status=status, keyword=keyword)
@router.post("/cycles")
diff --git a/netx_api/lldp_collect_router.py b/netx_api/lldp_collect_router.py
index 78c1bd7..addfcf2 100644
--- a/netx_api/lldp_collect_router.py
+++ b/netx_api/lldp_collect_router.py
@@ -67,9 +67,11 @@ def api_stop_job(job_id: str, db: Session = Depends(get_db)) -> dict[str, Any]:
def api_list_jobs(
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
+ status: str = Query(default=""),
+ keyword: str = Query(default=""),
db: Session = Depends(get_db),
) -> dict[str, Any]:
- return list_jobs(db, page=page, page_size=page_size)
+ return list_jobs(db, page=page, page_size=page_size, status=status, keyword=keyword)
@router.get("/jobs/{job_id}")
@@ -77,6 +79,15 @@ def api_get_job(
job_id: str,
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
+ status: str = Query(default=""),
+ keyword: str = Query(default=""),
db: Session = Depends(get_db),
) -> dict[str, Any]:
- return get_job_detail(db, job_id, page=page, page_size=page_size)
+ return get_job_detail(
+ db,
+ job_id,
+ page=page,
+ page_size=page_size,
+ item_status=status,
+ item_keyword=keyword,
+ )
diff --git a/netx_api/lldp_collect_service.py b/netx_api/lldp_collect_service.py
index 5cd0b01..e5eb63b 100644
--- a/netx_api/lldp_collect_service.py
+++ b/netx_api/lldp_collect_service.py
@@ -481,10 +481,32 @@ def get_dashboard(db: Session) -> LldpCollectDashboardOut:
)
-def list_jobs(db: Session, *, page: int = 1, page_size: int = 20) -> dict:
+def list_jobs(
+ db: Session,
+ *,
+ page: int = 1,
+ page_size: int = 20,
+ status: str = "",
+ keyword: str = "",
+) -> dict:
page = max(1, int(page or 1))
page_size = max(1, min(100, int(page_size or 20)))
- q = db.query(TopoDiscoverJob).order_by(TopoDiscoverJob.created_at.desc())
+ q = db.query(TopoDiscoverJob)
+ st = str(status or "").strip()
+ if st:
+ q = q.filter(TopoDiscoverJob.status == st)
+ kw = str(keyword or "").strip()
+ if kw:
+ like = f"%{kw}%"
+ q = q.filter(
+ or_(
+ TopoDiscoverJob.id.ilike(like),
+ TopoDiscoverJob.error.ilike(like),
+ TopoDiscoverJob.scope.ilike(like),
+ TopoDiscoverJob.trigger_mode.ilike(like),
+ )
+ )
+ q = q.order_by(TopoDiscoverJob.created_at.desc())
total = int(q.count())
rows = q.offset((page - 1) * page_size).limit(page_size).all()
outcomes = _job_outcome_map(db, [r.id for r in rows])
@@ -503,6 +525,19 @@ def list_jobs(db: Session, *, page: int = 1, page_size: int = 20) -> dict:
def get_job_detail(
- db: Session, job_id: str, *, page: int | None = None, page_size: int | None = None
+ db: Session,
+ job_id: str,
+ *,
+ page: int | None = None,
+ page_size: int | None = None,
+ item_status: str = "",
+ item_keyword: str = "",
) -> dict:
- return get_discover_job(db, job_id, page=page, page_size=page_size).model_dump()
+ return get_discover_job(
+ db,
+ job_id,
+ page=page,
+ page_size=page_size,
+ item_status=item_status,
+ item_keyword=item_keyword,
+ ).model_dump()
diff --git a/netx_api/metrics_router.py b/netx_api/metrics_router.py
index a4138fd..031269c 100644
--- a/netx_api/metrics_router.py
+++ b/netx_api/metrics_router.py
@@ -111,6 +111,7 @@ def _prom_lines(metrics: dict[str, Any]) -> str:
for name, key in (
("config_sync", "netx_config_sync_scheduler_running"),
("lldp_collect", "netx_lldp_collect_scheduler_running"),
+ ("ne_collect", "netx_ne_collect_scheduler_running"),
("port_traffic", "netx_port_traffic_scheduler_running"),
("fabric_reconcile", "netx_fabric_reconcile_scheduler_running"),
):
diff --git a/netx_api/models/__init__.py b/netx_api/models/__init__.py
index 2d5e34b..cf03ace 100644
--- a/netx_api/models/__init__.py
+++ b/netx_api/models/__init__.py
@@ -20,6 +20,7 @@ from .managed_ne import (
CliConnectProfile,
ManagedNE,
NeCollectionJob,
+ NeCollectionPolicy,
NeCollectionRun,
UmeCliOverride,
)
@@ -82,6 +83,7 @@ __all__ = [
"CliConnectProfile",
"UmeCliOverride",
"NeCollectionJob",
+ "NeCollectionPolicy",
"NeCollectionRun",
"TopoFabricNode",
"TopoClassifyRule",
diff --git a/netx_api/models/managed_ne.py b/netx_api/models/managed_ne.py
index c4c437a..03386e7 100644
--- a/netx_api/models/managed_ne.py
+++ b/netx_api/models/managed_ne.py
@@ -98,6 +98,28 @@ class UmeCliOverride(Base):
updated_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive, index=True)
+class NeCollectionPolicy(Base):
+ """Singleton policy for periodic batch CLI collect (id=1).
+
+ Default ``enabled=False``: one-shot / manual only until the operator turns on schedule.
+ ``history_keep`` defaults to 3 finished jobs.
+ """
+
+ __tablename__ = "ne_collection_policy"
+
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1)
+ enabled: Mapped[bool] = mapped_column(Boolean, default=False)
+ interval_days: Mapped[int] = mapped_column(Integer, default=1) # legacy mirror of hours
+ interval_hours: Mapped[int] = mapped_column(Integer, default=24)
+ scope_mode: Mapped[str] = mapped_column(String(32), default="all") # all | selected
+ selected_targets: Mapped[list] = mapped_column(_JsonType, default=list)
+ title: Mapped[str] = mapped_column(String(256), default="")
+ commands: Mapped[str] = mapped_column(Text, default="")
+ # Finished jobs to retain (newest kept); active jobs always kept.
+ history_keep: Mapped[int] = mapped_column(Integer, default=3)
+ updated_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive)
+
+
class NeCollectionJob(Base):
"""Batch CLI collection job over managed NEs."""
@@ -106,6 +128,8 @@ class NeCollectionJob(Base):
id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: uuid4().hex)
title: Mapped[str] = mapped_column(String(256), default="")
commands: Mapped[str] = mapped_column(Text, default="")
+ # manual | schedule
+ trigger_mode: Mapped[str] = mapped_column(String(32), default="manual", index=True)
status: Mapped[str] = mapped_column(String(32), default="pending", index=True)
ne_count: Mapped[int] = mapped_column(Integer, default=0)
success_count: Mapped[int] = mapped_column(Integer, default=0)
diff --git a/netx_api/models/topology.py b/netx_api/models/topology.py
index a6a70be..c377fee 100644
--- a/netx_api/models/topology.py
+++ b/netx_api/models/topology.py
@@ -26,10 +26,12 @@ class TopoFabricNode(Base):
ip: Mapped[str] = mapped_column(String(128), default="", index=True)
vendor: Mapped[str] = mapped_column(String(64), default="")
device_type: Mapped[str] = mapped_column(String(64), default="")
- # Classify tags (regex rules / manual). role: core|aggregation|access|unknown|""
+ # Layout rank: major.minor (0=external … 1=core … 2=agg … 3=access). None = unclassified.
+ level: Mapped[float | None] = mapped_column(Float, nullable=True, index=True)
+ # Synced alias from floor(level): external|core|aggregation|access|edge|""
role: Mapped[str] = mapped_column(String(32), default="", index=True)
region_folder_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
- # rule | manual | ""
+ # rule | manual | "" (applies to level; role is derived)
role_source: Mapped[str] = mapped_column(String(16), default="")
region_source: Mapped[str] = mapped_column(String(16), default="")
# Composed flat-world coordinates (packed per-SBN local layouts). Not raw UME xPos/yPos.
@@ -47,15 +49,15 @@ class TopoClassifyRule(Base):
__tablename__ = "topo_classify_rule"
id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: uuid4().hex)
- # role | region
- scope: Mapped[str] = mapped_column(String(16), default="role", index=True)
+ # level | region (legacy scope "role" accepted as alias of level)
+ scope: Mapped[str] = mapped_column(String(16), default="level", index=True)
name: Mapped[str] = mapped_column(String(256), default="")
pattern: Mapped[str] = mapped_column(String(512), default="")
# name | ip | name_ip
match_field: Mapped[str] = mapped_column(String(32), default="name")
priority: Mapped[int] = mapped_column(Integer, default=100, index=True)
enabled: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
- # role: {role}; region: {folder_id} or {region_name_from_group}
+ # role: {level} or legacy {role}; region: {folder_id} or {region_name_from_group}
payload: Mapped[dict] = mapped_column(_JsonType, default=dict)
remark: Mapped[str] = mapped_column(String(512), default="")
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive)
diff --git a/netx_api/ne_collect_scheduler.py b/netx_api/ne_collect_scheduler.py
new file mode 100644
index 0000000..baeb764
--- /dev/null
+++ b/netx_api/ne_collect_scheduler.py
@@ -0,0 +1,113 @@
+"""Background scheduler for periodic NE batch collect."""
+
+from __future__ import annotations
+
+import logging
+import threading
+import time
+from datetime import datetime
+
+from .collection_policy import ensure_policy, next_due_at
+from .collection_service import create_and_start_from_policy, has_active_collection_job
+from .config import settings
+from .db import SessionLocal
+from .ne_collect_runner import dispatch_collection_runs
+
+_log = logging.getLogger("netx.ne_collect.scheduler")
+_stop = threading.Event()
+_thread: threading.Thread | None = None
+_BOOT_MONO = time.monotonic()
+_last_tick_mono: float = 0.0
+
+
+def _utcnow() -> datetime:
+ # Match NeCollectionJob timestamps (collection_service uses datetime.now()).
+ return datetime.now()
+
+
+def startup_grace_remaining_sec() -> float:
+ grace = max(0, int(getattr(settings, "ne_collect_startup_grace_sec", 3600) or 0))
+ elapsed = time.monotonic() - _BOOT_MONO
+ return max(0.0, float(grace) - elapsed)
+
+
+def in_startup_grace() -> bool:
+ return startup_grace_remaining_sec() > 0
+
+
+def try_start_scheduled_collect() -> str | None:
+ if not bool(getattr(settings, "ne_collect_scheduler_enabled", True)):
+ return None
+ if in_startup_grace():
+ return None
+ db = SessionLocal()
+ try:
+ policy = ensure_policy(db)
+ if not policy.enabled:
+ return None
+ if has_active_collection_job(db) is not None:
+ return None
+ due = next_due_at(db, policy)
+ if due is not None and due > _utcnow():
+ return None
+ out, payload = create_and_start_from_policy(db, trigger_mode="schedule")
+ job_id = str(out.id)
+ dispatch_collection_runs(payload["job_id"], payload["run_ids"], payload["commands"])
+ _log.info("ne_collect scheduled job started id=%s", job_id)
+ return job_id or None
+ except Exception as exc: # noqa: BLE001
+ db.rollback()
+ detail = getattr(exc, "detail", None)
+ if detail in {
+ "collection_job_running",
+ "commands_empty",
+ "no_eligible_ne",
+ "commands_required_for_schedule",
+ "no_selected_targets",
+ }:
+ _log.info("ne_collect schedule skip: %s", detail)
+ return None
+ _log.exception("ne_collect schedule start failed")
+ return None
+ finally:
+ db.close()
+
+
+def _loop() -> None:
+ global _last_tick_mono
+ tick = max(15, int(getattr(settings, "ne_collect_scheduler_tick_sec", 60) or 60))
+ grace = max(0, int(getattr(settings, "ne_collect_startup_grace_sec", 3600) or 0))
+ _log.info("ne_collect scheduler started tick=%ss startup_grace=%ss", tick, grace)
+ while not _stop.is_set():
+ try:
+ _last_tick_mono = time.monotonic()
+ try_start_scheduled_collect()
+ except Exception:
+ _log.exception("ne_collect scheduler tick failed")
+ _stop.wait(tick)
+ _log.info("ne_collect scheduler stopped")
+
+
+def start_ne_collect_scheduler() -> None:
+ global _thread
+ if not bool(getattr(settings, "ne_collect_scheduler_enabled", True)):
+ _log.info("ne_collect scheduler disabled by settings")
+ return
+ if _thread is not None and _thread.is_alive():
+ return
+ _stop.clear()
+ _thread = threading.Thread(target=_loop, name="ne-collect-scheduler", daemon=True)
+ _thread.start()
+
+
+def stop_ne_collect_scheduler() -> None:
+ _stop.set()
+
+
+def ne_collect_scheduler_status() -> dict:
+ now = time.monotonic()
+ return {
+ "running": bool(_thread is not None and _thread.is_alive()),
+ "last_tick_age_sec": (now - _last_tick_mono) if _last_tick_mono else None,
+ "startup_grace_remaining_sec": round(startup_grace_remaining_sec(), 1),
+ }
diff --git a/netx_api/port_traffic_devices.py b/netx_api/port_traffic_devices.py
index 8982b50..e28ca4d 100644
--- a/netx_api/port_traffic_devices.py
+++ b/netx_api/port_traffic_devices.py
@@ -7,6 +7,7 @@ from typing import Any
from uuid import uuid4
from fastapi import HTTPException
+from sqlalchemy import or_
from sqlalchemy.orm import Session
from .cli_creds import require_cli_creds_ready
@@ -51,8 +52,30 @@ from .port_traffic_schemas import (
_log = logging.getLogger("netx.port_traffic.service")
-def list_devices(db: Session, *, page: int = 1, page_size: int = 20) -> dict[str, Any]:
- q = db.query(PortTrafficDevice).order_by(PortTrafficDevice.ne_name.asc(), PortTrafficDevice.ne_ip.asc())
+def list_devices(
+ db: Session,
+ *,
+ page: int = 1,
+ page_size: int = 20,
+ status: str = "",
+ keyword: str = "",
+) -> dict[str, Any]:
+ q = db.query(PortTrafficDevice)
+ st = str(status or "").strip()
+ if st:
+ q = q.filter(PortTrafficDevice.status == st)
+ kw = str(keyword or "").strip()
+ if kw:
+ like = f"%{kw}%"
+ q = q.filter(
+ or_(
+ PortTrafficDevice.ne_name.ilike(like),
+ PortTrafficDevice.ne_ip.ilike(like),
+ PortTrafficDevice.ne_id.ilike(like),
+ PortTrafficDevice.vendor.ilike(like),
+ )
+ )
+ q = q.order_by(PortTrafficDevice.ne_name.asc(), PortTrafficDevice.ne_ip.asc())
total = q.count()
rows = q.offset((page - 1) * page_size).limit(page_size).all()
return {
diff --git a/netx_api/port_traffic_router.py b/netx_api/port_traffic_router.py
index ac853cb..8333841 100644
--- a/netx_api/port_traffic_router.py
+++ b/netx_api/port_traffic_router.py
@@ -163,9 +163,11 @@ def api_delete_board(
def api_list_devices(
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
+ status: str = Query(default=""),
+ keyword: str = Query(default=""),
db: Session = Depends(get_db),
):
- return list_devices(db, page=page, page_size=page_size)
+ return list_devices(db, page=page, page_size=page_size, status=status, keyword=keyword)
@router.post("/devices")
diff --git a/netx_api/scheduler_heartbeat.py b/netx_api/scheduler_heartbeat.py
index 2acb8fb..5683ca0 100644
--- a/netx_api/scheduler_heartbeat.py
+++ b/netx_api/scheduler_heartbeat.py
@@ -55,6 +55,12 @@ def local_device_scheduler_status(*, role: str = "unknown") -> dict[str, Any]:
out["lldp_collect"] = lldp_collect_scheduler_status()
except Exception: # noqa: BLE001
out["lldp_collect"] = {"running": False, "error": "unavailable"}
+ try:
+ from .ne_collect_scheduler import ne_collect_scheduler_status
+
+ out["ne_collect"] = ne_collect_scheduler_status()
+ except Exception: # noqa: BLE001
+ out["ne_collect"] = {"running": False, "error": "unavailable"}
try:
from .port_traffic_scheduler import port_traffic_scheduler_status
diff --git a/netx_api/schema_patches.py b/netx_api/schema_patches.py
index 120956c..74d5ac2 100644
--- a/netx_api/schema_patches.py
+++ b/netx_api/schema_patches.py
@@ -154,6 +154,7 @@ def apply_topology_schema_safety_net(conn: Connection) -> None:
)
_run_sql(conn, "ALTER TABLE topo_fabric_node ADD COLUMN IF NOT EXISTS world_x DOUBLE PRECISION")
_run_sql(conn, "ALTER TABLE topo_fabric_node ADD COLUMN IF NOT EXISTS world_y DOUBLE PRECISION")
+ _run_sql(conn, "ALTER TABLE topo_fabric_node ADD COLUMN IF NOT EXISTS level DOUBLE PRECISION")
_run_sql(
conn,
"CREATE INDEX IF NOT EXISTS ix_topo_fabric_node_world_x ON topo_fabric_node (world_x)",
@@ -162,6 +163,10 @@ def apply_topology_schema_safety_net(conn: Connection) -> None:
conn,
"CREATE INDEX IF NOT EXISTS ix_topo_fabric_node_world_y ON topo_fabric_node (world_y)",
)
+ _run_sql(
+ conn,
+ "CREATE INDEX IF NOT EXISTS ix_topo_fabric_node_level ON topo_fabric_node (level)",
+ )
# UME link ifnames — required by topology sync/apply; missing when DB predates the columns
# or Alembic was stamped head without running domain patches.
_run_sql(
@@ -329,6 +334,7 @@ def apply_domain_schema_patches(conn: Connection) -> None:
"ALTER TABLE ne_collection_job ADD COLUMN IF NOT EXISTS last_run_at TIMESTAMP",
"UPDATE ne_collection_job SET last_run_at = COALESCE(ended_at, started_at, created_at) "
"WHERE last_run_at IS NULL",
+ "ALTER TABLE ne_collection_job ADD COLUMN IF NOT EXISTS trigger_mode VARCHAR(32) DEFAULT 'manual'",
"ALTER TABLE topology_edge ADD COLUMN IF NOT EXISTS stroke_color VARCHAR(32) DEFAULT ''",
"ALTER TABLE topology_edge ADD COLUMN IF NOT EXISTS stroke_width INTEGER DEFAULT 0",
"ALTER TABLE topology_edge ADD COLUMN IF NOT EXISTS line_style VARCHAR(16) DEFAULT ''",
diff --git a/netx_api/topology_classify_apply.py b/netx_api/topology_classify_apply.py
index 1570cb4..cf08bd4 100644
--- a/netx_api/topology_classify_apply.py
+++ b/netx_api/topology_classify_apply.py
@@ -1,25 +1,23 @@
"""Topology classify preview/apply and fabric node tagging."""
from __future__ import annotations
-import re
from typing import Any
from fastapi import HTTPException
from sqlalchemy.orm import Session
-from .models import TopoClassifyRule, TopoFabricNode, TopoFolder
+from .models import TopoFabricNode, TopoFolder
from .topology_classify_common import (
_MATCH_FIELDS,
- _ROLE_VALUES,
_compile_pattern,
_enabled_rules,
- _ensure_region_by_name,
_match_text,
+ _resolve_level_hit,
_resolve_region_hit,
- _resolve_role_hit,
_utcnow,
+ apply_level_fields,
)
-from .topology_membership import normalize_view_role
+from .topology_level import level_to_role, normalize_level, role_to_level
from .topology_schemas import (
ClassifyApplyOut,
ClassifyPreviewOut,
@@ -31,31 +29,33 @@ from .topology_schemas import (
FabricNodeTagPatch,
)
+
def preview_classify(db: Session, *, sample_limit: int = 20) -> ClassifyPreviewOut:
- role_rules = _enabled_rules(db, "role")
+ level_rules = _enabled_rules(db, "level")
region_rules = _enabled_rules(db, "region")
nodes = db.query(TopoFabricNode).order_by(TopoFabricNode.name.asc()).all()
- role_matched = role_unmatched = role_conflict = 0
+ level_matched = level_unmatched = level_conflict = 0
region_matched = region_unmatched = region_conflict = 0
- role_samples: list[dict[str, Any]] = []
+ level_samples: list[dict[str, Any]] = []
region_samples: list[dict[str, Any]] = []
unmatched_samples: list[dict[str, Any]] = []
for n in nodes:
- role, _rid, multi_r = _resolve_role_hit(n, role_rules)
- if role is None:
- role_unmatched += 1
+ level, _rid, multi_r = _resolve_level_hit(n, level_rules)
+ if level is None:
+ level_unmatched += 1
else:
- role_matched += 1
+ level_matched += 1
if multi_r:
- role_conflict += 1
- if len(role_samples) < sample_limit:
- role_samples.append(
+ level_conflict += 1
+ if len(level_samples) < sample_limit:
+ level_samples.append(
{
"fabric_node_id": n.id,
"name": n.name,
"ip": n.ip,
- "role": role,
+ "level": level,
+ "role": level_to_role(level),
"multi_hit": multi_r,
}
)
@@ -80,20 +80,24 @@ def preview_classify(db: Session, *, sample_limit: int = 20) -> ClassifyPreviewO
}
)
- if role is None and region_id is None and len(unmatched_samples) < sample_limit:
+ if level is None and region_id is None and len(unmatched_samples) < sample_limit:
unmatched_samples.append(
{"fabric_node_id": n.id, "name": n.name, "ip": n.ip, "vendor": n.vendor}
)
return ClassifyPreviewOut(
total_nodes=len(nodes),
- role_matched=role_matched,
- role_unmatched=role_unmatched,
- role_conflicts=role_conflict,
+ level_matched=level_matched,
+ level_unmatched=level_unmatched,
+ level_conflicts=level_conflict,
+ role_matched=level_matched,
+ role_unmatched=level_unmatched,
+ role_conflicts=level_conflict,
region_matched=region_matched,
region_unmatched=region_unmatched,
region_conflicts=region_conflict,
- role_samples=role_samples,
+ level_samples=level_samples,
+ role_samples=level_samples,
region_samples=region_samples,
unmatched_samples=unmatched_samples,
)
@@ -105,27 +109,22 @@ def apply_classify(
skip_manual: bool = True,
fill_empty_only: bool = False,
) -> ClassifyApplyOut:
- role_rules = _enabled_rules(db, "role")
+ level_rules = _enabled_rules(db, "level")
region_rules = _enabled_rules(db, "region")
nodes = db.query(TopoFabricNode).all()
- role_updated = region_updated = skipped_manual = 0
+ level_updated = region_updated = skipped_manual = 0
for n in nodes:
- role, _, _ = _resolve_role_hit(n, role_rules)
- if role is not None:
+ level, _, _ = _resolve_level_hit(n, level_rules)
+ if level is not None:
if skip_manual and str(n.role_source or "") == "manual":
skipped_manual += 1
- elif fill_empty_only and str(n.role or "").strip():
+ elif fill_empty_only and n.level is not None:
pass
else:
- n.role = role
- n.role_source = "rule"
+ apply_level_fields(n, level, source="rule")
n.updated_at = _utcnow()
- role_updated += 1
- elif not str(n.role or "").strip() and str(n.role_source or "") != "manual":
- n.role = "unknown"
- n.role_source = "rule"
- n.updated_at = _utcnow()
+ level_updated += 1
region_id, _, _ = _resolve_region_hit(db, n, region_rules, create_missing=True)
if region_id is not None and not str(region_id).startswith("new:"):
@@ -141,7 +140,8 @@ def apply_classify(
db.commit()
return ClassifyApplyOut(
- role_updated=role_updated,
+ level_updated=level_updated,
+ role_updated=level_updated,
region_updated=region_updated,
skipped_manual=skipped_manual,
total_nodes=len(nodes),
@@ -159,17 +159,19 @@ def list_unmatched(
q = db.query(TopoFabricNode)
k = str(kind or "any").strip().lower()
- role_miss = or_(TopoFabricNode.role == "", TopoFabricNode.role == "unknown")
+ if k == "role":
+ k = "level"
+ level_miss = TopoFabricNode.level.is_(None)
region_miss = or_(
TopoFabricNode.region_folder_id.is_(None),
TopoFabricNode.region_folder_id == "",
)
- if k == "role":
- q = q.filter(role_miss)
+ if k == "level":
+ q = q.filter(level_miss)
elif k == "region":
q = q.filter(region_miss)
else:
- q = q.filter(or_(role_miss, region_miss))
+ q = q.filter(or_(level_miss, region_miss))
total = q.count()
rows = (
q.order_by(TopoFabricNode.name.asc())
@@ -195,14 +197,18 @@ def patch_fabric_node_tags(
n = db.get(TopoFabricNode, fabric_node_id)
if n is None:
raise HTTPException(status_code=404, detail="fabric_node_not_found")
- if body.role is not None:
- role = str(body.role or "").strip().lower()
- if role and role not in _ROLE_VALUES:
- raise HTTPException(status_code=400, detail="role_invalid")
- n.role = role
- n.role_source = "manual"
- if body.region_folder_id is not None:
- fid = str(body.region_folder_id or "").strip()
+ data = body.model_dump(exclude_unset=True)
+ if "level" in data or "role" in data:
+ try:
+ if "level" in data:
+ lv = normalize_level(data.get("level"))
+ else:
+ lv = role_to_level(data.get("role"))
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+ apply_level_fields(n, lv, source="manual")
+ if "region_folder_id" in data:
+ fid = str(data.get("region_folder_id") or "").strip()
if fid:
folder = db.get(TopoFolder, fid)
if folder is None or str(folder.kind or "") != "region":
@@ -245,6 +251,7 @@ def match_fabric_nodes(db: Session, body: FabricNodesMatchRequest) -> FabricNode
"fabric_node_id": n.id,
"name": n.name,
"ip": n.ip,
+ "level": n.level,
"role": n.role or "",
"region_folder_id": n.region_folder_id or "",
"link_status": fabric_link_status(n),
@@ -263,19 +270,23 @@ def match_fabric_nodes(db: Session, body: FabricNodesMatchRequest) -> FabricNode
def bulk_tag_fabric_nodes(
db: Session, body: FabricNodesBulkTagRequest
) -> FabricNodesBulkTagOut:
- """Assign role/region after user confirms a regex or explicit selection."""
- if body.role is None and body.region_folder_id is None:
- raise HTTPException(status_code=400, detail="role_or_region_required")
+ """Assign level/region after user confirms a regex or explicit selection."""
+ data = body.model_dump(exclude_unset=True)
+ level_v: float | None | object = Ellipsis
+ if "level" in data:
+ try:
+ level_v = normalize_level(data.get("level"))
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+ elif "role" in data:
+ try:
+ level_v = role_to_level(data.get("role"))
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
- role_v: str | None = None
- if body.role is not None:
- role_v = str(body.role or "").strip().lower()
- if role_v and role_v not in _ROLE_VALUES:
- raise HTTPException(status_code=400, detail="role_invalid")
-
- region_v: str | None = None
- if body.region_folder_id is not None:
- region_v = str(body.region_folder_id or "").strip()
+ region_v: str | None | object = Ellipsis
+ if "region_folder_id" in data:
+ region_v = str(data.get("region_folder_id") or "").strip()
if region_v:
folder = db.get(TopoFolder, region_v)
if folder is None or str(folder.kind or "") != "region":
@@ -283,6 +294,9 @@ def bulk_tag_fabric_nodes(
else:
region_v = ""
+ if level_v is Ellipsis and region_v is Ellipsis:
+ raise HTTPException(status_code=400, detail="level_or_region_required")
+
ids = [str(x).strip() for x in (body.fabric_node_ids or []) if str(x).strip()]
if str(body.pattern or "").strip():
matched_nodes = _iter_regex_matches(
@@ -298,11 +312,18 @@ def bulk_tag_fabric_nodes(
else:
raise HTTPException(status_code=400, detail="ids_or_pattern_required")
+ role_alias = level_to_role(level_v) if isinstance(level_v, float) else (
+ "" if level_v is None else None
+ )
+ if level_v is Ellipsis:
+ role_alias = None
+
samples = [
{
"fabric_node_id": n.id,
"name": n.name,
"ip": n.ip,
+ "level": n.level,
"role": n.role or "",
"region_folder_id": n.region_folder_id or "",
}
@@ -313,19 +334,19 @@ def bulk_tag_fabric_nodes(
dry_run=True,
matched=len(matched_nodes),
updated=0,
- role=role_v,
- region_folder_id=region_v,
+ level=None if level_v is Ellipsis else level_v, # type: ignore[arg-type]
+ role=role_alias,
+ region_folder_id=None if region_v is Ellipsis else (region_v or None), # type: ignore[arg-type]
samples=samples,
)
now = _utcnow()
updated = 0
for n in matched_nodes:
- if role_v is not None:
- n.role = role_v
- n.role_source = "manual"
- if region_v is not None:
- n.region_folder_id = region_v or None
+ if level_v is not Ellipsis:
+ apply_level_fields(n, level_v, source="manual") # type: ignore[arg-type]
+ if region_v is not Ellipsis:
+ n.region_folder_id = region_v or None # type: ignore[operator]
n.region_source = "manual"
n.updated_at = now
updated += 1
@@ -334,8 +355,9 @@ def bulk_tag_fabric_nodes(
dry_run=False,
matched=len(matched_nodes),
updated=updated,
- role=role_v,
- region_folder_id=region_v,
+ level=None if level_v is Ellipsis else level_v, # type: ignore[arg-type]
+ role=role_alias,
+ region_folder_id=None if region_v is Ellipsis else (region_v or None), # type: ignore[arg-type]
samples=samples,
)
@@ -343,5 +365,3 @@ def bulk_tag_fabric_nodes(
def apply_classify_empty_only(db: Session) -> ClassifyApplyOut:
"""Incremental classify for newly synced nodes (fill empty tags only)."""
return apply_classify(db, skip_manual=True, fill_empty_only=True)
-
-
diff --git a/netx_api/topology_classify_common.py b/netx_api/topology_classify_common.py
index bae8dd7..a272b14 100644
--- a/netx_api/topology_classify_common.py
+++ b/netx_api/topology_classify_common.py
@@ -10,23 +10,30 @@ from sqlalchemy.orm import Session
from .models import TopoClassifyRule, TopoFabricNode, TopoFolder
from .timeutil import utcnow_naive
-from .topology_membership import VIEW_ROLES, normalize_view_role
+from .topology_level import LEVEL_PRESETS, level_to_role, normalize_level, role_to_level
from .topology_schemas import (
ClassifyRuleOut,
TopologyFolderCreate,
)
_MAX_PATTERN_LEN = 512
-_ROLE_VALUES = VIEW_ROLES | {"unknown"}
_MATCH_FIELDS = frozenset({"name", "ip", "name_ip"})
-_SCOPES = frozenset({"role", "region"})
+_SCOPES = frozenset({"level", "region", "role"}) # role = legacy alias of level
_SLICE_TEMPLATES = frozenset({"core_only", "core_agg", "agg_access"})
+_ROLE_VALUES = frozenset(LEVEL_PRESETS) | {"unknown", "edge", ""}
def _utcnow() -> datetime:
return utcnow_naive()
+def _normalize_scope(scope: str) -> str:
+ s = str(scope or "level").strip().lower()
+ if s == "role":
+ return "level"
+ return s
+
+
def _compile_pattern(pattern: str) -> re.Pattern[str]:
p = str(pattern or "").strip()
if not p:
@@ -51,9 +58,10 @@ def _match_text(node: TopoFabricNode, match_field: str) -> str:
def _rule_out(row: TopoClassifyRule) -> ClassifyRuleOut:
+ scope = _normalize_scope(str(row.scope or "level"))
return ClassifyRuleOut(
id=row.id,
- scope=str(row.scope or "role"),
+ scope=scope,
name=str(row.name or ""),
pattern=str(row.pattern or ""),
match_field=str(row.match_field or "name"),
@@ -68,11 +76,25 @@ def _rule_out(row: TopoClassifyRule) -> ClassifyRuleOut:
def _validate_payload(scope: str, payload: dict[str, Any]) -> dict[str, Any]:
out = dict(payload or {})
- if scope == "role":
- role = normalize_view_role(str(out.get("role") or ""))
- if str(out.get("role") or "").strip().lower() not in VIEW_ROLES:
- raise HTTPException(status_code=400, detail="role_payload_invalid")
- return {"role": role}
+ scope_n = _normalize_scope(scope)
+ if scope_n == "level":
+ if "level" in out and out.get("level") is not None:
+ try:
+ lv = normalize_level(out.get("level"))
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+ if lv is None:
+ raise HTTPException(status_code=400, detail="level_payload_invalid")
+ return {"level": lv}
+ if "role" in out:
+ try:
+ lv = role_to_level(str(out.get("role") or ""))
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+ if lv is None:
+ raise HTTPException(status_code=400, detail="level_payload_invalid")
+ return {"level": lv, "role": level_to_role(lv)}
+ raise HTTPException(status_code=400, detail="level_payload_invalid")
if "folder_id" in out and str(out.get("folder_id") or "").strip():
return {"folder_id": str(out["folder_id"]).strip()}
if "region_name_from_group" in out:
@@ -87,9 +109,11 @@ def _validate_payload(scope: str, payload: dict[str, Any]) -> dict[str, Any]:
def _enabled_rules(db: Session, scope: str) -> list[tuple[TopoClassifyRule, re.Pattern[str]]]:
+ scope_n = _normalize_scope(scope)
+ scopes = ("level", "role") if scope_n == "level" else (scope_n,)
rows = (
db.query(TopoClassifyRule)
- .filter(TopoClassifyRule.scope == scope, TopoClassifyRule.enabled.is_(True))
+ .filter(TopoClassifyRule.scope.in_(scopes), TopoClassifyRule.enabled.is_(True))
.order_by(TopoClassifyRule.priority.asc(), TopoClassifyRule.name.asc())
.all()
)
@@ -122,23 +146,50 @@ def _ensure_region_by_name(db: Session, name: str) -> TopoFolder:
return folder
-def _resolve_role_hit(
+def _payload_level(payload: dict[str, Any] | None) -> float | None:
+ p = dict(payload or {})
+ if "level" in p and p.get("level") is not None:
+ try:
+ return normalize_level(p.get("level"))
+ except ValueError:
+ return None
+ if "role" in p:
+ try:
+ return role_to_level(str(p.get("role") or ""))
+ except ValueError:
+ return None
+ return None
+
+
+def _resolve_level_hit(
node: TopoFabricNode, rules: list[tuple[TopoClassifyRule, re.Pattern[str]]]
-) -> tuple[str | None, str | None, bool]:
- """Return (role, rule_id, multi_hit)."""
- hits: list[tuple[str, str]] = []
+) -> tuple[float | None, str | None, bool]:
+ """Return (level, rule_id, multi_hit)."""
+ hits: list[tuple[float, str]] = []
for rule, cre in rules:
text = _match_text(node, rule.match_field)
if not text:
continue
if cre.search(text):
- role = normalize_view_role(str((rule.payload or {}).get("role") or ""))
- hits.append((role, rule.id))
+ lv = _payload_level(rule.payload)
+ if lv is None:
+ continue
+ hits.append((lv, rule.id))
if not hits:
return None, None, False
return hits[0][0], hits[0][1], len(hits) > 1
+# Back-compat name used by older imports
+def _resolve_role_hit(
+ node: TopoFabricNode, rules: list[tuple[TopoClassifyRule, re.Pattern[str]]]
+) -> tuple[str | None, str | None, bool]:
+ lv, rid, multi = _resolve_level_hit(node, rules)
+ if lv is None:
+ return None, None, False
+ return level_to_role(lv), rid, multi
+
+
def _resolve_region_hit(
db: Session,
node: TopoFabricNode,
@@ -182,3 +233,8 @@ def _resolve_region_hit(
return hits[0][0], hits[0][1], len(hits) > 1
+def apply_level_fields(node: TopoFabricNode, level: float | None, *, source: str) -> None:
+ """Write level + synced role alias."""
+ node.level = level
+ node.role = level_to_role(level)
+ node.role_source = source
diff --git a/netx_api/topology_classify_rules.py b/netx_api/topology_classify_rules.py
index f892722..bda0f09 100644
--- a/netx_api/topology_classify_rules.py
+++ b/netx_api/topology_classify_rules.py
@@ -13,6 +13,7 @@ from .topology_classify_common import (
_MAX_PATTERN_LEN,
_SCOPES,
_compile_pattern,
+ _normalize_scope,
_rule_out,
_utcnow,
_validate_payload,
@@ -21,8 +22,13 @@ from .topology_schemas import ClassifyRuleCreate, ClassifyRuleOut, ClassifyRuleU
def list_rules(db: Session, *, scope: str = "") -> list[ClassifyRuleOut]:
q = db.query(TopoClassifyRule)
- if scope.strip():
- q = q.filter(TopoClassifyRule.scope == scope.strip().lower())
+ scope_f = str(scope or "").strip().lower()
+ if scope_f:
+ scope_n = _normalize_scope(scope_f)
+ if scope_n == "level":
+ q = q.filter(TopoClassifyRule.scope.in_(("level", "role")))
+ else:
+ q = q.filter(TopoClassifyRule.scope == scope_n)
rows = q.order_by(
TopoClassifyRule.scope.asc(),
TopoClassifyRule.priority.asc(),
@@ -32,9 +38,10 @@ def list_rules(db: Session, *, scope: str = "") -> list[ClassifyRuleOut]:
def create_rule(db: Session, body: ClassifyRuleCreate) -> ClassifyRuleOut:
- scope = str(body.scope or "").strip().lower()
- if scope not in _SCOPES:
+ scope_raw = str(body.scope or "").strip().lower()
+ if scope_raw not in _SCOPES:
raise HTTPException(status_code=400, detail="scope_invalid")
+ scope = _normalize_scope(scope_raw)
match_field = str(body.match_field or "name").strip().lower()
if match_field not in _MATCH_FIELDS:
raise HTTPException(status_code=400, detail="match_field_invalid")
diff --git a/netx_api/topology_discover_common.py b/netx_api/topology_discover_common.py
index b4ca488..cf6b82f 100644
--- a/netx_api/topology_discover_common.py
+++ b/netx_api/topology_discover_common.py
@@ -4,6 +4,7 @@ from __future__ import annotations
from typing import Any
from fastapi import HTTPException
+from sqlalchemy import or_
from sqlalchemy.orm import Session
from .cli_resolve import get_default_profile, infer_device_type_vendor
@@ -48,6 +49,8 @@ def _job_out(
include_items: bool = True,
page: int | None = None,
page_size: int | None = None,
+ item_status: str = "",
+ item_keyword: str = "",
) -> FabricDiscoverJobOut:
items_out: list[FabricDiscoverJobItemOut] = []
items_total = 0
@@ -59,6 +62,37 @@ def _job_out(
.filter(TopoDiscoverJobItem.job_id == job.id)
.order_by(TopoDiscoverJobItem.created_at.asc())
)
+ st = str(item_status or "").strip().lower()
+ if st in ("ok", "success", "pass"):
+ q = q.filter(
+ TopoDiscoverJobItem.ok.is_(True),
+ TopoDiscoverJobItem.parser_stub.is_(False),
+ TopoDiscoverJobItem.unmatched_count == 0,
+ )
+ elif st in ("fail", "failed", "error"):
+ q = q.filter(
+ or_(
+ TopoDiscoverJobItem.ok.is_(False),
+ TopoDiscoverJobItem.parser_stub.is_(True),
+ )
+ )
+ elif st in ("warn", "warning"):
+ q = q.filter(
+ TopoDiscoverJobItem.ok.is_(True),
+ TopoDiscoverJobItem.parser_stub.is_(False),
+ TopoDiscoverJobItem.unmatched_count > 0,
+ )
+ kw = str(item_keyword or "").strip()
+ if kw:
+ like = f"%{kw}%"
+ q = q.filter(
+ or_(
+ TopoDiscoverJobItem.ne_name.ilike(like),
+ TopoDiscoverJobItem.ne_ip.ilike(like),
+ TopoDiscoverJobItem.ne_id.ilike(like),
+ TopoDiscoverJobItem.error.ilike(like),
+ )
+ )
items_total = int(q.count())
if page is not None and page_size is not None:
items_page = max(1, int(page or 1))
@@ -121,11 +155,20 @@ def get_discover_job(
*,
page: int | None = None,
page_size: int | None = None,
+ item_status: str = "",
+ item_keyword: str = "",
) -> FabricDiscoverJobOut:
job = db.get(TopoDiscoverJob, str(job_id or "").strip())
if job is None:
raise HTTPException(status_code=404, detail="discover_job_not_found")
- return _job_out(db, job, page=page, page_size=page_size)
+ return _job_out(
+ db,
+ job,
+ page=page,
+ page_size=page_size,
+ item_status=item_status,
+ item_keyword=item_keyword,
+ )
def _ume_target_dict(db: Session, uid: str, default_profile: Any) -> dict[str, str] | None:
diff --git a/netx_api/topology_fabric_nodes.py b/netx_api/topology_fabric_nodes.py
index 0ce979e..a42f946 100644
--- a/netx_api/topology_fabric_nodes.py
+++ b/netx_api/topology_fabric_nodes.py
@@ -55,7 +55,13 @@ from .topology_schemas import (
def _node_out(n: TopoFabricNode) -> FabricNodeOut:
+ lv = getattr(n, "level", None)
+ try:
+ level_v = float(lv) if lv is not None else None
+ except (TypeError, ValueError):
+ level_v = None
return FabricNodeOut(
+ level=level_v,
role=str(getattr(n, "role", "") or ""),
region_folder_id=str(getattr(n, "region_folder_id", None) or "") or None,
role_source=str(getattr(n, "role_source", "") or ""),
@@ -264,6 +270,8 @@ def list_fabric_nodes(
*,
keyword: str = "",
role: str = "",
+ level: str = "",
+ level_major: str = "",
region_folder_id: str = "",
unmatched: str = "",
link_status: str = "",
@@ -271,6 +279,7 @@ def list_fabric_nodes(
page_size: int = PAGE_DEFAULT,
) -> dict[str, Any]:
from .topology_inventory_lifecycle import enrich_fabric_node_dicts
+ from .topology_level import LEVEL_PRESETS, normalize_level
page = max(1, int(page or 1))
page_size = max(1, min(PAGE_MAX, int(page_size or PAGE_DEFAULT)))
@@ -286,15 +295,46 @@ def list_fabric_nodes(
TopoFabricNode.ume_ne_id.ilike(like),
)
)
+ level_raw = str(level or "").strip()
+ if level_raw:
+ try:
+ lv = normalize_level(level_raw)
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+ if lv is None:
+ q = q.filter(TopoFabricNode.level.is_(None))
+ else:
+ q = q.filter(TopoFabricNode.level == lv)
+ maj_raw = str(level_major or "").strip()
+ if maj_raw:
+ try:
+ maj = int(float(maj_raw))
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail="level_major_invalid") from exc
+ q = q.filter(
+ TopoFabricNode.level.isnot(None),
+ TopoFabricNode.level >= float(maj),
+ TopoFabricNode.level < float(maj) + 1.0,
+ )
role_v = str(role or "").strip().lower()
if role_v:
- q = q.filter(TopoFabricNode.role == role_v)
+ if role_v in LEVEL_PRESETS and not level_raw and not maj_raw:
+ # Prefer major-band filter so 1.1 still matches role=core
+ preset_lv = LEVEL_PRESETS[role_v]
+ maj = int(preset_lv)
+ q = q.filter(
+ TopoFabricNode.level.isnot(None),
+ TopoFabricNode.level >= float(maj),
+ TopoFabricNode.level < float(maj) + 1.0,
+ )
+ else:
+ q = q.filter(TopoFabricNode.role == role_v)
region_v = str(region_folder_id or "").strip()
if region_v:
q = q.filter(TopoFabricNode.region_folder_id == region_v)
um = str(unmatched or "").strip().lower()
- if um == "role":
- q = q.filter(or_(TopoFabricNode.role == "", TopoFabricNode.role == "unknown"))
+ if um in {"role", "level"}:
+ q = q.filter(TopoFabricNode.level.is_(None))
elif um == "region":
q = q.filter(
or_(TopoFabricNode.region_folder_id.is_(None), TopoFabricNode.region_folder_id == "")
@@ -302,8 +342,7 @@ def list_fabric_nodes(
elif um == "any":
q = q.filter(
or_(
- TopoFabricNode.role == "",
- TopoFabricNode.role == "unknown",
+ TopoFabricNode.level.is_(None),
TopoFabricNode.region_folder_id.is_(None),
TopoFabricNode.region_folder_id == "",
)
diff --git a/netx_api/topology_level.py b/netx_api/topology_level.py
new file mode 100644
index 0000000..5cf0191
--- /dev/null
+++ b/netx_api/topology_level.py
@@ -0,0 +1,138 @@
+"""Fabric topology level (layout rank): major.minor, smaller = closer to external/WAN."""
+
+from __future__ import annotations
+
+import math
+from typing import Any
+
+# Preset alias → default level (major.0). Sub-tiers use 1.1, 2.1, …
+LEVEL_PRESETS: dict[str, float] = {
+ "external": 0.0,
+ "core": 1.0,
+ "aggregation": 2.0,
+ "aggregate": 2.0,
+ "agg": 2.0,
+ "access": 3.0,
+ "edge": 4.0,
+ "cpe": 4.0,
+}
+
+# floor(level) → synced role alias (filters / UI chips)
+_MAJOR_TO_ROLE: dict[int, str] = {
+ 0: "external",
+ 1: "core",
+ 2: "aggregation",
+ 3: "access",
+}
+
+_ROLE_VALUES = frozenset(LEVEL_PRESETS) | {"unknown", ""}
+
+
+def normalize_level(value: Any) -> float | None:
+ """Parse level; empty/None → None. Snap to one decimal in [0, 99.9]."""
+ if value is None:
+ return None
+ if isinstance(value, str):
+ s = value.strip().lower()
+ if not s or s in {"unknown", "null", "none"}:
+ return None
+ if s in LEVEL_PRESETS:
+ return LEVEL_PRESETS[s]
+ try:
+ value = float(s)
+ except ValueError as exc:
+ raise ValueError("level_invalid") from exc
+ try:
+ lv = float(value)
+ except (TypeError, ValueError) as exc:
+ raise ValueError("level_invalid") from exc
+ if not math.isfinite(lv):
+ raise ValueError("level_invalid")
+ if lv < 0 or lv > 99.9:
+ raise ValueError("level_out_of_range")
+ return round(lv + 1e-9, 1)
+
+
+def level_major(level: float | None) -> int | None:
+ if level is None:
+ return None
+ return int(math.floor(float(level)))
+
+
+def level_to_role(level: float | None) -> str:
+ """Denormalized role alias for filters; empty when unclassified."""
+ maj = level_major(level)
+ if maj is None:
+ return ""
+ if maj in _MAJOR_TO_ROLE:
+ return _MAJOR_TO_ROLE[maj]
+ if maj >= 4:
+ return "edge"
+ return ""
+
+
+def role_to_level(role: str | None) -> float | None:
+ r = str(role or "").strip().lower()
+ if not r or r == "unknown":
+ return None
+ if r in LEVEL_PRESETS:
+ return LEVEL_PRESETS[r]
+ raise ValueError("role_invalid")
+
+
+def coerce_level_input(
+ *,
+ level: Any = None,
+ role: Any = None,
+ level_provided: bool = False,
+ role_provided: bool = False,
+) -> float | None | object:
+ """Resolve patch/bulk input.
+
+ Returns:
+ - float | None: concrete level (None clears)
+ - Ellipsis: neither field provided
+ """
+ if level_provided:
+ return normalize_level(level)
+ if role_provided:
+ return role_to_level(None if role is None else str(role))
+ return Ellipsis
+
+
+def format_level(level: float | None) -> str:
+ if level is None:
+ return ""
+ lv = float(level)
+ if abs(lv - round(lv)) < 1e-9:
+ return str(int(round(lv)))
+ return f"{lv:.1f}".rstrip("0").rstrip(".") if "." in f"{lv:.1f}" else f"{lv:.1f}"
+
+
+def infer_layer_from_level(
+ level: float | None,
+ *,
+ name: str = "",
+ role: str | None = None,
+) -> str:
+ """Map to layout layer key: external|core|agg|access|other."""
+ maj = level_major(level)
+ if maj is not None:
+ if maj <= 0:
+ return "external"
+ if maj == 1:
+ return "core"
+ if maj == 2:
+ return "agg"
+ if maj >= 3:
+ return "access"
+ # Fallbacks when unclassified
+ r = str(role or "").strip().lower()
+ if r in LEVEL_PRESETS:
+ return infer_layer_from_level(LEVEL_PRESETS[r])
+ import re
+
+ m = re.search(r"-(CN|AN|EN)(\d*)-", name or "", re.I)
+ if not m:
+ return "other"
+ return {"CN": "core", "AN": "agg", "EN": "access"}[m.group(1).upper()]
diff --git a/netx_api/topology_migrate.py b/netx_api/topology_migrate.py
index 36f40b3..d95b82e 100644
--- a/netx_api/topology_migrate.py
+++ b/netx_api/topology_migrate.py
@@ -40,9 +40,11 @@ def ensure_topology_schema(conn: Connection) -> None:
"ALTER TABLE topo_view ADD COLUMN IF NOT EXISTS role VARCHAR(32) DEFAULT 'core'",
"ALTER TABLE topo_view ADD COLUMN IF NOT EXISTS sort_order INTEGER DEFAULT 0",
"ALTER TABLE topo_fabric_node ADD COLUMN IF NOT EXISTS role VARCHAR(32) DEFAULT ''",
+ "ALTER TABLE topo_fabric_node ADD COLUMN IF NOT EXISTS level DOUBLE PRECISION",
"ALTER TABLE topo_fabric_node ADD COLUMN IF NOT EXISTS region_folder_id VARCHAR(64)",
"ALTER TABLE topo_fabric_node ADD COLUMN IF NOT EXISTS role_source VARCHAR(16) DEFAULT ''",
"ALTER TABLE topo_fabric_node ADD COLUMN IF NOT EXISTS region_source VARCHAR(16) DEFAULT ''",
+ "CREATE INDEX IF NOT EXISTS ix_topo_fabric_node_level ON topo_fabric_node (level)",
"ALTER TABLE topo_fabric_node ADD COLUMN IF NOT EXISTS world_x DOUBLE PRECISION",
"ALTER TABLE topo_fabric_node ADD COLUMN IF NOT EXISTS world_y DOUBLE PRECISION",
"CREATE INDEX IF NOT EXISTS ix_topo_fabric_node_world_x ON topo_fabric_node (world_x)",
@@ -89,6 +91,7 @@ def ensure_topology_schema(conn: Connection) -> None:
"ALTER TABLE topo_view ADD COLUMN role VARCHAR(32) DEFAULT 'core'",
"ALTER TABLE topo_view ADD COLUMN sort_order INTEGER DEFAULT 0",
"ALTER TABLE topo_fabric_node ADD COLUMN role VARCHAR(32) DEFAULT ''",
+ "ALTER TABLE topo_fabric_node ADD COLUMN level REAL",
"ALTER TABLE topo_fabric_node ADD COLUMN region_folder_id VARCHAR(64)",
"ALTER TABLE topo_fabric_node ADD COLUMN role_source VARCHAR(16) DEFAULT ''",
"ALTER TABLE topo_fabric_node ADD COLUMN region_source VARCHAR(16) DEFAULT ''",
diff --git a/netx_api/topology_router.py b/netx_api/topology_router.py
index 168cc86..b245cc7 100644
--- a/netx_api/topology_router.py
+++ b/netx_api/topology_router.py
@@ -93,8 +93,10 @@ def api_fabric_summary(db: Session = Depends(get_db)) -> dict[str, Any]:
def api_fabric_nodes(
keyword: str = "",
role: str = "",
+ level: str = Query(default="", description="Exact level e.g. 1.1"),
+ level_major: str = Query(default="", description="Major band e.g. 2 → [2.0, 3.0)"),
region_folder_id: str = "",
- unmatched: str = Query(default="", description="any | role | region"),
+ unmatched: str = Query(default="", description="any | level | role | region"),
link_status: str = Query(
default="",
description="linked | orphaned | managed | ume | both",
@@ -107,6 +109,8 @@ def api_fabric_nodes(
db,
keyword=keyword,
role=role,
+ level=level,
+ level_major=level_major,
region_folder_id=region_folder_id,
unmatched=unmatched,
link_status=link_status,
@@ -255,9 +259,18 @@ def api_fabric_discover_job(
job_id: str,
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
+ status: str = Query(default=""),
+ keyword: str = Query(default=""),
db: Session = Depends(get_db),
) -> dict[str, Any]:
- return get_discover_job(db, job_id, page=page, page_size=page_size).model_dump()
+ return get_discover_job(
+ db,
+ job_id,
+ page=page,
+ page_size=page_size,
+ item_status=status,
+ item_keyword=keyword,
+ ).model_dump()
# --- Tree / folders ---------------------------------------------------------
@@ -520,7 +533,7 @@ def api_classify_apply(
@router.get("/classify/unmatched")
def api_classify_unmatched(
- kind: str = Query(default="any", description="any | role | region"),
+ kind: str = Query(default="any", description="any | level | role | region"),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=50, ge=1, le=500),
db: Session = Depends(get_db),
@@ -591,6 +604,10 @@ def api_generate_slices(
body: SliceGenerateRequest,
db: Session = Depends(get_db),
) -> dict[str, Any]:
+ """Deprecated for product UI: slice maps freeze custom views and bypass region canvases.
+
+ Kept for tests / legacy clients. Prefer region folders + classify tags + MCP layout.
+ """
return generate_slices(db, body).model_dump()
diff --git a/netx_api/topology_schemas.py b/netx_api/topology_schemas.py
index efe57d1..bf7fd51 100644
--- a/netx_api/topology_schemas.py
+++ b/netx_api/topology_schemas.py
@@ -21,6 +21,9 @@ class FabricNodeOut(BaseModel):
ip: str = ""
vendor: str = ""
device_type: str = ""
+ # Layout rank (major.minor). None = unclassified.
+ level: float | None = None
+ # Synced alias from floor(level): external|core|aggregation|access|edge|""
role: str = ""
region_folder_id: str | None = None
role_source: str = ""
@@ -483,7 +486,7 @@ class ClassifyRuleOut(BaseModel):
class ClassifyRuleCreate(BaseModel):
- scope: str = Field(default="role", description="role | region")
+ scope: str = Field(default="level", description="level | region (role accepted as level alias)")
name: str = ""
pattern: str
match_field: str = "name"
@@ -505,26 +508,33 @@ class ClassifyRuleUpdate(BaseModel):
class ClassifyPreviewOut(BaseModel):
total_nodes: int = 0
+ level_matched: int = 0
+ level_unmatched: int = 0
+ level_conflicts: int = 0
+ # Legacy aliases
role_matched: int = 0
role_unmatched: int = 0
role_conflicts: int = 0
region_matched: int = 0
region_unmatched: int = 0
region_conflicts: int = 0
+ level_samples: list[dict[str, Any]] = Field(default_factory=list)
role_samples: list[dict[str, Any]] = Field(default_factory=list)
region_samples: list[dict[str, Any]] = Field(default_factory=list)
unmatched_samples: list[dict[str, Any]] = Field(default_factory=list)
class ClassifyApplyOut(BaseModel):
- role_updated: int = 0
+ level_updated: int = 0
+ role_updated: int = 0 # alias of level_updated
region_updated: int = 0
skipped_manual: int = 0
total_nodes: int = 0
class FabricNodeTagPatch(BaseModel):
- role: str | None = None
+ level: float | None = None
+ role: str | None = None # preset alias → level
region_folder_id: str | None = None
@@ -545,12 +555,13 @@ class FabricNodesMatchOut(BaseModel):
class FabricNodesBulkTagRequest(BaseModel):
- """Assign role/region to explicit ids or to an ephemeral regex match."""
+ """Assign level/region to explicit ids or to an ephemeral regex match."""
fabric_node_ids: list[str] = Field(default_factory=list)
pattern: str = ""
match_field: str = "name"
- role: str | None = None
+ level: float | None = None
+ role: str | None = None # preset → level when level omitted
region_folder_id: str | None = None
dry_run: bool = False
@@ -559,6 +570,7 @@ class FabricNodesBulkTagOut(BaseModel):
dry_run: bool = False
matched: int = 0
updated: int = 0
+ level: float | None = None
role: str | None = None
region_folder_id: str | None = None
samples: list[dict[str, Any]] = Field(default_factory=list)
diff --git a/netx_api/topology_views_graph.py b/netx_api/topology_views_graph.py
index 197e510..f59a68e 100644
--- a/netx_api/topology_views_graph.py
+++ b/netx_api/topology_views_graph.py
@@ -467,7 +467,17 @@ def _apply_fabric_filters(
)
role_v = str(role or "").strip().lower()
if role_v:
- q = q.filter(TopoFabricNode.role == role_v)
+ from .topology_level import LEVEL_PRESETS
+
+ if role_v in LEVEL_PRESETS:
+ maj = int(LEVEL_PRESETS[role_v])
+ q = q.filter(
+ TopoFabricNode.level.isnot(None),
+ TopoFabricNode.level >= float(maj),
+ TopoFabricNode.level < float(maj) + 1.0,
+ )
+ else:
+ q = q.filter(TopoFabricNode.role == role_v)
vendor_v = str(vendor or "").strip()
if vendor_v:
q = q.filter(TopoFabricNode.vendor.ilike(f"%{vendor_v}%"))
diff --git a/netx_api/ume_runtime.py b/netx_api/ume_runtime.py
index f783194..67ef009 100644
--- a/netx_api/ume_runtime.py
+++ b/netx_api/ume_runtime.py
@@ -1,6 +1,6 @@
"""UME / long-task runtime helpers shared by API and optional worker process.
-Device collectors (config_sync / LLDP / port_traffic) run via ``start_device_schedulers``
+Device collectors (config_sync / LLDP / ne_collect / port_traffic) run via ``start_device_schedulers``
(API inline by default; set ``NETX_RUN_INLINE_SCHEDULERS=false`` and run
``python -m netx_api.worker`` for a split process).
API process also owns UME keepalive, alarm WSS, current-alarm/inventory sync loops,
@@ -53,11 +53,13 @@ def start_device_schedulers() -> None:
from .config_sync_scheduler import start_config_sync_scheduler
from .fabric_reconcile_scheduler import start_fabric_reconcile_scheduler
from .lldp_collect_scheduler import start_lldp_collect_scheduler
+ from .ne_collect_scheduler import start_ne_collect_scheduler
from .port_traffic_scheduler import start_port_traffic_scheduler
from .scheduler_heartbeat import start_scheduler_heartbeat_publisher
start_config_sync_scheduler()
start_lldp_collect_scheduler()
+ start_ne_collect_scheduler()
start_port_traffic_scheduler()
start_fabric_reconcile_scheduler()
# Publish status so API /metrics can see collectors when run in a split worker.
diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/__init__.py b/packages/netx-topology-mcp/src/netx_topology_mcp/__init__.py
index 543839c..f048d36 100644
--- a/packages/netx-topology-mcp/src/netx_topology_mcp/__init__.py
+++ b/packages/netx-topology-mcp/src/netx_topology_mcp/__init__.py
@@ -1,5 +1,5 @@
"""netx topology MCP — canvas / fabric tools for drawing topology maps."""
-__version__ = "0.1.20"
+__version__ = "0.1.51"
# Bump when public catalog/actions change so agents know to restart stdio.
-NETX_MCP_REV = "2026-08-09-fabric-merge"
+NETX_MCP_REV = "2026-08-12-from-scratch-polish"
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 1123f39..581510e 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
@@ -39,6 +39,7 @@ _FABRIC_KEEP = (
"ip",
"vendor",
"device_type",
+ "level",
"role",
"link_status",
"region_folder_id",
@@ -278,6 +279,7 @@ def _analyze_one_view(
detail: str = "summary",
sight_limit: int = 40,
sight_cell: float = 600.0,
+ score_profile: str = "auto",
) -> dict[str, Any]:
graph = _data(http_json("GET", f"/v1/topology/views/{view_id}"))
if not graph.get("ok"):
@@ -285,7 +287,9 @@ def _analyze_one_view(
nodes = [n for n in (graph.get("nodes") or []) if isinstance(n, dict)]
edges = [e for e in (graph.get("edges") or []) if isinstance(e, dict)]
view = graph.get("view") if isinstance(graph.get("view"), dict) else {}
- stats = analyze_layout_stats(nodes, edges, with_meta=with_meta)
+ stats = analyze_layout_stats(
+ nodes, edges, with_meta=with_meta, score_profile=score_profile
+ )
report = dict(stats.get("report") or {})
out: dict[str, Any] = {
"ok": True,
@@ -471,7 +475,12 @@ def _layout_topology_view(args: dict[str, Any]) -> dict[str, Any]:
"layout_dual_unit",
"polish_crossings",
"clear_edge_hits",
+ "compact_bbox",
+ "pull_far_chains",
+ "align_reference",
+ "align_to_reference",
"orbit_sweep",
+ "level_bands",
"move_nodes",
"sink_nodes",
"job_status",
@@ -483,9 +492,14 @@ def _layout_topology_view(args: dict[str, Any]) -> dict[str, Any]:
"error": f"unknown_action:{action}",
"hint": (
"Public actions: layout|layout_dual_unit|move_nodes|orbit_sweep|"
- "polish_crossings|clear_edge_hits|fix_overlaps|untangle|"
- "straighten_channels|job_status|job_cancel. "
- "Main path: sinkTopologyDualUnits → orbit_sweep → polish → clear."
+ "level_bands|polish_crossings|clear_edge_hits|compact_bbox|"
+ "pull_far_chains|align_reference|"
+ "fix_overlaps|untangle|straighten_channels|job_status|job_cancel. "
+ "Main path: sinkTopologyDualUnits(max_units=1) → "
+ "move_nodes(park) → until_limit → clear → pull_far_chains → "
+ "compact_bbox → hand. Default: no template. "
+ "align_reference = same-network debug only, not delivery. "
+ "Eye sinks: no polish/fix_overlaps/round."
),
**list_layout_catalog(),
}
@@ -497,6 +511,10 @@ def _layout_topology_view(args: dict[str, Any]) -> dict[str, Any]:
if action in {"move_nodes", "sink_nodes"}:
return _move_topology_view_nodes(args)
+ # Reference alignment: view_id=TO, source_view_id|params.reference_view_id=FROM.
+ if action in {"align_reference", "align_to_reference"}:
+ return _align_topology_reference(args)
+
# Background job poll / cancel (no view_id required).
if action in {"job_status", "job_cancel"}:
job_id = str(
@@ -597,6 +615,8 @@ def _layout_topology_view(args: dict[str, Any]) -> dict[str, Any]:
"layout_dual_unit",
"polish_crossings",
"clear_edge_hits",
+ "compact_bbox",
+ "pull_far_chains",
"orbit_sweep",
}:
source_id = view_id
@@ -850,17 +870,28 @@ def _layout_topology_view(args: dict[str, Any]) -> dict[str, Any]:
force_sync = _truthy((overrides or {}).get("sync")) or _truthy(
(overrides or {}).get("_force_sync")
)
+ until_limit = _truthy((overrides or {}).get("until_limit")) or _truthy(
+ (overrides or {}).get("until_stall")
+ )
heavy_bg = action in {
"polish_crossings",
"orbit_sweep",
"clear_edge_hits",
+ "compact_bbox",
+ "pull_far_chains",
"layout_dual_unit",
}
+ # until_limit loops many orbit sweeps — background earlier than one-shot polish.
+ bg_n_thr = 80 if (action == "orbit_sweep" and until_limit) else 600
if (
mode == "apply"
and heavy_bg
and not force_sync
- and (len(nodes) >= 600 or _truthy((overrides or {}).get("background")))
+ and (
+ len(nodes) >= bg_n_thr
+ or _truthy((overrides or {}).get("background"))
+ or (until_limit and action == "orbit_sweep")
+ )
):
sync_args = dict(args)
sync_params = dict(overrides or {})
@@ -879,10 +910,11 @@ def _layout_topology_view(args: dict[str, Any]) -> dict[str, Any]:
action=action,
view_id=view_id,
tool_args=sync_args,
- meta={"node_count": len(nodes)},
+ meta={"node_count": len(nodes), "until_limit": until_limit},
)
print(
- f"[netx-topology] {action} background job_id={job_id} n={len(nodes)}",
+ f"[netx-topology] {action} background job_id={job_id} n={len(nodes)}"
+ + (" until_limit" if until_limit else ""),
file=sys.stderr,
flush=True,
)
@@ -901,11 +933,12 @@ def _layout_topology_view(args: dict[str, Any]) -> dict[str, Any]:
),
}
- # orbit_sweep: preview suggests only; apply defaults pick=1 (unless round).
+ # orbit_sweep: preview suggests only; apply defaults pick=1 (unless round/until_limit).
if action == "orbit_sweep" and mode == "apply":
overrides = dict(overrides or {})
if (
not overrides.get("round")
+ and not until_limit
and overrides.get("pick") is None
):
if overrides.get("node_id") or overrides.get("fabric_node_id"):
@@ -954,23 +987,26 @@ def _layout_topology_view(args: dict[str, Any]) -> dict[str, Any]:
out["hint"] = (
"Preview only. Re-call with mode=apply to PATCH. "
"If overlaps remain: action=fix_overlaps. "
- "Main path: orbit_sweep → polish_crossings → clear_edge_hits."
+ "Main path: until_limit(crossing→total) → clear_edge_hits → "
+ "pull_far_chains → compact_bbox → hand. "
+ "Eye sinks: do not polish_crossings / fix_overlaps / round."
)
return _slim_layout_payload(out)
- # Dual-unit: gate on unit crossings only. Staging eyes often have label
- # footprint touches that fix_overlaps would re-cross; allow apply when
- # accepted (unit-internal crossings=0).
+ # Dual-unit: default accepts residual crossings (max-membership CN eyes).
+ # Optional hard gate: params.require_zero_cross=true.
if action == "layout_dual_unit":
loc = result.get("local") or {}
- if not loc.get("accepted", False):
+ require_zero = bool((overrides or {}).get("require_zero_cross"))
+ if require_zero and not loc.get("accepted", False):
return {
**out,
"ok": False,
"error": "dual_unit_crossings",
"hint": (
- "layout_dual_unit requires unit-internal crossings=0. "
- "Check membership (portals+corridors+tails) or re-detect dual_units."
+ "require_zero_cross=true but unit-internal crossings≠0. "
+ "Omit the flag (default) to apply best-effort eye layout, "
+ "or shrink membership / re-pick portals."
),
"local": loc,
}
@@ -1086,14 +1122,311 @@ def _layout_topology_view(args: dict[str, Any]) -> dict[str, Any]:
report_progress("done", pct=100.0, message="positions applied")
out["hint"] = (
"Positions applied. Main path: analyze(structure) → "
- "sinkTopologyDualUnits (or move_nodes park) → orbit_sweep(round) → "
- "polish_crossings → clear_edge_hits → updateTopologyViewPositions. "
- "Small graphs: layout(compact|corridor|rings). "
+ "sinkTopologyDualUnits(max_units=1) → suggestSinkHubs/move_nodes(park) → "
+ "orbit_sweep(until_limit crossing→total) → clear_edge_hits → "
+ "pull_far_chains → compact_bbox → hand drag. "
+ "Eye sinks: no polish_crossings / fix_overlaps / untangle / round. "
+ "Plateau when stall + moved≈0. Small graphs: layout(compact|corridor|rings). "
"Large jobs: poll job_status; job_cancel for cooperative stop."
)
return _slim_layout_payload(out)
+def _view_membership_ids(view_id: str) -> tuple[dict[str, Any], set[str], list[dict], list[dict]]:
+ """Fetch a view; return (graph, id_set, nodes, edges)."""
+ graph = _data(http_json("GET", f"/v1/topology/views/{view_id}", timeout=120.0))
+ if not graph.get("ok"):
+ return graph, set(), [], []
+ nodes = [n for n in (graph.get("nodes") or []) if isinstance(n, dict)]
+ edges = [e for e in (graph.get("edges") or []) if isinstance(e, dict)]
+ ids: set[str] = set()
+ for n in nodes:
+ fid = str(n.get("fabric_node_id") or n.get("id") or "").strip()
+ if fid and not fid.startswith("region:"):
+ ids.add(fid)
+ return graph, ids, nodes, edges
+
+
+def _suggest_sink_hubs(args: dict[str, Any]) -> dict[str, Any]:
+ """Rank hub territories on source for non-dual move_nodes(park) batches."""
+ from netx_topology_mcp.layout_ops.suggest_sink_hubs import (
+ pick_batch,
+ suggest_sink_hub_batches,
+ )
+ from netx_topology_mcp.layout_structure import analyze_graph_structure
+
+ source_view_id = str(
+ args.get("source_view_id") or args.get("view_id") or ""
+ ).strip()
+ sink_view_id = str(args.get("sink_view_id") or "").strip()
+ if not source_view_id:
+ return {
+ "ok": False,
+ "error": "source_view_id_required",
+ "hint": "Pass source_view_id=根图 (post-dual leftovers).",
+ }
+
+ try:
+ top_n = max(1, min(40, int(args.get("top_n") or 12)))
+ except (TypeError, ValueError):
+ top_n = 12
+ try:
+ pick = max(1, int(args.get("pick") or 1))
+ except (TypeError, ValueError):
+ pick = 1
+ try:
+ min_territory = max(0, int(args.get("min_territory") or 1))
+ except (TypeError, ValueError):
+ min_territory = 1
+ try:
+ min_move_n = max(1, int(args.get("min_move_n") or 1))
+ except (TypeError, ValueError):
+ min_move_n = 1
+
+ include_hub = str(args.get("include_hub") if args.get("include_hub") is not None else "true").lower() not in {
+ "0",
+ "false",
+ "no",
+ "off",
+ }
+ # Source dual portals: default OFF — leftover root eyes (e.g. GHP) still need
+ # non-dual park batches. Sink dual: default ON but only the primary eye.
+ use_source_dual = str(
+ args.get("use_source_dual_portals")
+ if args.get("use_source_dual_portals") is not None
+ else "false"
+ ).lower() not in {"0", "false", "no", "off"}
+ use_sink_dual = str(
+ args.get("use_sink_dual_portals")
+ if args.get("use_sink_dual_portals") is not None
+ else ("true" if sink_view_id else "false")
+ ).lower() not in {"0", "false", "no", "off"}
+
+ only_layers = args.get("only_layers") or args.get("layers")
+ if isinstance(only_layers, str):
+ only_layers = [p.strip() for p in only_layers.replace(";", ",").split(",") if p.strip()]
+ elif not isinstance(only_layers, list):
+ only_layers = ["agg", "core"]
+
+ exclude: set[str] = set()
+ raw_ex = args.get("exclude_portal_ids") or args.get("exclude_ids") or args.get("portal_ids")
+ if isinstance(raw_ex, (list, tuple, set)):
+ exclude |= {str(x).strip() for x in raw_ex if str(x).strip()}
+ elif isinstance(raw_ex, str) and raw_ex.strip():
+ exclude |= {
+ p.strip()
+ for p in raw_ex.replace(";", ",").split(",")
+ if p.strip()
+ }
+
+ src_graph, src_ids, src_nodes, src_edges = _view_membership_ids(source_view_id)
+ if not src_graph.get("ok"):
+ return {
+ "ok": False,
+ "error": src_graph.get("error") or "source_view_fetch_failed",
+ "source_view_id": source_view_id,
+ }
+ if len(src_ids) < 2:
+ return {
+ "ok": False,
+ "error": "source_too_few_nodes",
+ "source_view_id": source_view_id,
+ "node_count": len(src_ids),
+ }
+
+ sink_ids: set[str] = set()
+ sink_dual: dict[str, Any] | None = None
+ if sink_view_id:
+ sink_graph, sink_ids, sink_nodes, sink_edges = _view_membership_ids(sink_view_id)
+ if not sink_graph.get("ok"):
+ return {
+ "ok": False,
+ "error": sink_graph.get("error") or "sink_view_fetch_failed",
+ "sink_view_id": sink_view_id,
+ }
+ if use_sink_dual:
+ sink_struct = analyze_graph_structure(sink_nodes, sink_edges, hub_top_k=8)
+ sink_dual = sink_struct.get("dual_units") if isinstance(sink_struct, dict) else None
+
+ struct = analyze_graph_structure(
+ src_nodes, src_edges, hub_top_k=max(12, top_n * 2), stub_top_k=40
+ )
+ dual = struct.get("dual_units") if use_source_dual else None
+ # Merge dual portals into exclude via dual_units arg.
+ # Sink: only the largest unit (the fixed eye) — nested eyes must not
+ # blanket-exclude every AN hub still holding root stubs.
+ merged_dual: dict[str, Any] = {"units": []}
+ if isinstance(dual, dict):
+ merged_dual["units"].extend(list(dual.get("units") or [])[:8])
+ if isinstance(sink_dual, dict):
+ sink_units = [u for u in (sink_dual.get("units") or []) if isinstance(u, dict)]
+ if sink_units:
+ best = max(sink_units, key=lambda u: int(u.get("node_count") or 0))
+ merged_dual["units"].append(best)
+
+ report = suggest_sink_hub_batches(
+ hubs=list(struct.get("hubs") or []),
+ soft_blocks=struct.get("soft_blocks") if isinstance(struct.get("soft_blocks"), dict) else {},
+ source_ids=src_ids,
+ sink_ids=sink_ids,
+ exclude_ids=exclude,
+ dual_units=merged_dual if merged_dual["units"] else None,
+ min_territory=min_territory,
+ min_move_n=min_move_n,
+ top_n=top_n,
+ include_hub=include_hub,
+ only_layers=[str(x) for x in only_layers] if only_layers else None,
+ )
+ batch = pick_batch(report, pick=pick)
+ src_view = src_graph.get("view") if isinstance(src_graph.get("view"), dict) else {}
+ out: dict[str, Any] = {
+ "ok": True,
+ "source_view_id": source_view_id,
+ "source_view_name": str(src_view.get("name") or ""),
+ "sink_view_id": sink_view_id or None,
+ "pick": pick,
+ "batch": batch,
+ **report,
+ }
+ if batch:
+ out["move_nodes"] = {
+ "action": "move_nodes",
+ "source_view_id": source_view_id,
+ "view_id": sink_view_id or "",
+ "mode": "apply",
+ "params": {
+ "fabric_node_ids": list(batch.get("fabric_node_ids") or []),
+ "park": True,
+ "remove_from_source": True,
+ },
+ }
+ out["hint"] = (
+ f"Rank#{batch.get('rank')} hub={batch.get('hub_name')} "
+ f"n={batch.get('remaining_n')}. "
+ "Call layoutTopologyView with move_nodes payload (park=true). "
+ "Do not sinkTopologyDualUnits again."
+ )
+ else:
+ out["hint"] = (
+ "No hub batches left (all territories on sink or excluded). "
+ "Check leftovers / tiny chains on source."
+ )
+ return out
+
+
+def _align_topology_reference(args: dict[str, Any]) -> dict[str, Any]:
+ """Map reference canvas geometry onto target via shared fabric_node_ids."""
+ from netx_topology_mcp.layout_ops.align_reference import (
+ align_reference_params_from_overrides,
+ align_to_reference,
+ )
+ from netx_topology_mcp.layout_ops.graph_util import build_state_from_nodes_edges
+ from netx_topology_mcp.layout_stats import analyze_layout_stats
+
+ view_id = str(args.get("view_id") or "").strip()
+ overrides = args.get("params") if isinstance(args.get("params"), dict) else {}
+ knobs = align_reference_params_from_overrides(overrides)
+ ref_id = (
+ knobs.get("reference_view_id")
+ or str(args.get("source_view_id") or "").strip()
+ or str(args.get("reference_view_id") or "").strip()
+ )
+ mode = str(args.get("mode") or "preview").strip().lower() or "preview"
+ if not view_id:
+ return {"ok": False, "error": "view_id_required", "hint": "Target canvas to rewrite."}
+ if not ref_id:
+ return {
+ "ok": False,
+ "error": "reference_view_id_required",
+ "hint": "Pass source_view_id or params.reference_view_id (hand golden / UME).",
+ }
+ if mode not in {"preview", "apply"}:
+ return {"ok": False, "error": "mode_invalid"}
+
+ tgt = _data(http_json("GET", f"/v1/topology/views/{view_id}", timeout=120.0))
+ if not tgt.get("ok"):
+ return {"ok": False, "error": tgt.get("error") or "target_fetch_failed", "view_id": view_id}
+ ref = _data(http_json("GET", f"/v1/topology/views/{ref_id}", timeout=120.0))
+ if not ref.get("ok"):
+ return {
+ "ok": False,
+ "error": ref.get("error") or "reference_fetch_failed",
+ "reference_view_id": ref_id,
+ }
+
+ t_nodes = [n for n in (tgt.get("nodes") or []) if isinstance(n, dict)]
+ t_edges = [e for e in (tgt.get("edges") or []) if isinstance(e, dict)]
+ r_nodes = [n for n in (ref.get("nodes") or []) if isinstance(n, dict)]
+ st = build_state_from_nodes_edges(t_nodes, t_edges)
+ ref_pos: dict[str, tuple[float, float]] = {}
+ for n in r_nodes:
+ fid = str(n.get("fabric_node_id") or n.get("id") or "").strip()
+ if not fid or n.get("x") is None or n.get("y") is None:
+ continue
+ try:
+ ref_pos[fid] = (float(n["x"]), float(n["y"]))
+ except (TypeError, ValueError):
+ continue
+
+ op = align_to_reference(
+ st,
+ portal_ids=knobs.get("portal_ids") or [],
+ reference=ref_pos,
+ mode=knobs.get("mode") or "similarity",
+ park_missing=bool(knobs.get("park_missing", True)),
+ freeze_portals=bool(knobs.get("freeze_portals", True)),
+ )
+ if op.params.get("error"):
+ return {"ok": False, **op.params, "note": op.note}
+
+ positions = [
+ {"fabric_node_id": nid, "x": xy[0], "y": xy[1]}
+ for nid, xy in op.state.positions.items()
+ if nid in op.moved
+ ]
+ out: dict[str, Any] = {
+ "ok": True,
+ "action": "align_reference",
+ "view_id": view_id,
+ "reference_view_id": ref_id,
+ "mode": mode,
+ "applied": False,
+ "local": {"op": op.params, "note": op.note},
+ "moved_n": len(op.moved),
+ }
+ if mode == "preview":
+ # Score proposed coords without PATCH.
+ preview_nodes = []
+ for n in t_nodes:
+ fid = str(n.get("fabric_node_id") or "").strip()
+ row = dict(n)
+ if fid in op.state.positions:
+ row["x"], row["y"] = op.state.positions[fid]
+ preview_nodes.append(row)
+ qa = analyze_layout_stats(preview_nodes, t_edges, score_profile="auto")
+ out["summary"] = qa.get("summary")
+ out["hint"] = "Preview only. Re-call mode=apply to PATCH aligned positions."
+ return out
+
+ patch = _patch_positions_chunked(view_id, positions)
+ if not patch.get("ok"):
+ return {
+ **out,
+ "ok": False,
+ "error": patch.get("error") or "patch_failed",
+ "patch": patch,
+ }
+ out["applied"] = True
+ out["updated"] = patch.get("updated")
+ from_live = _analyze_one_view(view_id, detail="summary", score_profile="auto")
+ out["summary"] = from_live.get("summary")
+ out["hint"] = (
+ "Aligned from reference. If overlaps remain, surgical orbit_sweep; "
+ "do not global polish on eye sinks."
+ )
+ return out
+
+
def _analyze_topology_view_layout(args: dict[str, Any]) -> dict[str, Any]:
"""Unified layout QA: overlap / crossing / spacing / sparsity / edges + score."""
view_id = str(args.get("view_id") or "").strip()
@@ -1120,6 +1453,7 @@ def _analyze_topology_view_layout(args: dict[str, Any]) -> dict[str, Any]:
max_nodes = max(1, min(2000, int(args.get("max_nodes") or 800)))
except (TypeError, ValueError):
max_nodes = 800
+ score_profile = str(args.get("score_profile") or "auto").strip().lower() or "auto"
if view_id:
return _analyze_one_view(
@@ -1128,6 +1462,7 @@ def _analyze_topology_view_layout(args: dict[str, Any]) -> dict[str, Any]:
detail=detail,
sight_limit=sight_limit,
sight_cell=sight_cell,
+ score_profile=score_profile,
)
if not folder_id:
@@ -1162,7 +1497,11 @@ def _analyze_topology_view_layout(args: dict[str, Any]) -> dict[str, Any]:
rows: list[dict[str, Any]] = []
for c in picked:
- one = _analyze_one_view(str(c["view_id"]), with_meta=with_meta)
+ one = _analyze_one_view(
+ str(c["view_id"]),
+ with_meta=with_meta,
+ score_profile=score_profile,
+ )
if not one.get("ok"):
rows.append(
{
@@ -1772,6 +2111,10 @@ def _query_topology_fabric_nodes(args: dict[str, Any]) -> dict[str, Any]:
params["keyword"] = filt
if str(args.get("role") or "").strip():
params["role"] = str(args.get("role")).strip()
+ if str(args.get("level") or "").strip() or args.get("level") == 0:
+ params["level"] = str(args.get("level")).strip()
+ if str(args.get("level_major") or "").strip() or args.get("level_major") == 0:
+ params["level_major"] = str(args.get("level_major")).strip()
if str(args.get("link_status") or "").strip():
params["link_status"] = str(args.get("link_status")).strip()
if str(args.get("region_folder_id") or "").strip():
@@ -1785,6 +2128,203 @@ def _query_topology_fabric_nodes(args: dict[str, Any]) -> dict[str, Any]:
return page
+def _classify_topology_fabric_nodes(args: dict[str, Any]) -> dict[str, Any]:
+ """Tag Fabric level/region for agents (match → tag; rules apply; unmatched)."""
+ action = str(args.get("action") or "").strip().lower()
+ if not action:
+ return {
+ "ok": False,
+ "error": "action_required",
+ "hint": (
+ "action=match|tag|patch|unmatched|preview_rules|apply_rules|list_rules. "
+ "Typical: match → tag(level|role, dry_run) → tag; "
+ "then addTopologyViewNodes by role/level_major/region."
+ ),
+ }
+
+ if action in {"match", "find"}:
+ pattern = str(args.get("pattern") or args.get("q") or "").strip()
+ if not pattern:
+ return {"ok": False, "error": "pattern_required"}
+ match_field = str(args.get("match_field") or "name").strip() or "name"
+ sample_limit = min(200, max(1, int(args.get("sample_limit") or 50)))
+ out = _data(
+ http_json(
+ "POST",
+ "/v1/topology/fabric/nodes/match",
+ body={
+ "pattern": pattern,
+ "match_field": match_field,
+ "sample_limit": sample_limit,
+ },
+ )
+ )
+ if isinstance(out, dict) and out.get("ok") is not False:
+ out = dict(out)
+ out["action"] = "match"
+ samples = out.get("samples")
+ if isinstance(samples, list):
+ out["samples"] = [
+ _compact_fabric_item(s) if isinstance(s, dict) else s for s in samples[:sample_limit]
+ ]
+ ids = out.get("fabric_node_ids")
+ if isinstance(ids, list) and len(ids) > 200:
+ out["fabric_node_ids"] = ids[:200]
+ out["fabric_node_ids_truncated"] = True
+ out["hint"] = (
+ "Preview only. Re-call action=tag with same pattern (or fabric_node_ids) "
+ "and level (e.g. 1 / 1.1 / 2.1) or role preset; dry_run=true first."
+ )
+ return out
+
+ if action in {"tag", "bulk_tag", "assign"}:
+ level = args.get("level") if "level" in args else Ellipsis
+ role = args.get("role") if "role" in args else Ellipsis
+ region = args.get("region_folder_id") if "region_folder_id" in args else Ellipsis
+ clear_region = _truthy(args.get("clear_region"))
+ if level is Ellipsis and role is Ellipsis and region is Ellipsis and not clear_region:
+ return {
+ "ok": False,
+ "error": "level_or_region_required",
+ "hint": (
+ "Pass level (0/1/1.1/2/2.1/3…) and/or role preset "
+ "(external|core|aggregation|access) and/or region_folder_id."
+ ),
+ }
+ body: dict[str, Any] = {
+ "dry_run": _truthy(args.get("dry_run")),
+ }
+ ids = args.get("fabric_node_ids")
+ if isinstance(ids, list) and ids:
+ body["fabric_node_ids"] = [str(x).strip() for x in ids if str(x).strip()][:2000]
+ pattern = str(args.get("pattern") or args.get("q") or "").strip()
+ if pattern:
+ body["pattern"] = pattern
+ body["match_field"] = str(args.get("match_field") or "name").strip() or "name"
+ if not body.get("fabric_node_ids") and not body.get("pattern"):
+ return {
+ "ok": False,
+ "error": "ids_or_pattern_required",
+ "hint": "Pass fabric_node_ids[] from match, or pattern for ephemeral regex tag.",
+ }
+ if level is not Ellipsis:
+ body["level"] = level
+ elif role is not Ellipsis:
+ body["role"] = None if role is None or str(role).strip() == "" else str(role).strip()
+ if clear_region:
+ body["region_folder_id"] = None
+ elif region is not Ellipsis:
+ body["region_folder_id"] = None if region is None or str(region).strip() == "" else str(region).strip()
+ out = _data(http_json("POST", "/v1/topology/fabric/nodes/tags/bulk", body=body))
+ if isinstance(out, dict) and out.get("ok") is not False:
+ out = dict(out)
+ out["action"] = "tag"
+ samples = out.get("samples")
+ if isinstance(samples, list):
+ out["samples"] = [
+ _compact_fabric_item(s) if isinstance(s, dict) else s for s in samples[:50]
+ ]
+ if out.get("dry_run"):
+ out["hint"] = "Preview only. Re-call with dry_run=false to write tags."
+ else:
+ out["hint"] = (
+ "Tagged. Use queryTopologyFabricNodes(role|level_major|region_folder_id) or "
+ "addTopologyViewNodes filters to place on region canvas."
+ )
+ return out
+
+ if action == "patch":
+ node_id = str(args.get("fabric_node_id") or args.get("node_id") or "").strip()
+ if not node_id:
+ return {"ok": False, "error": "fabric_node_id_required"}
+ body: dict[str, Any] = {}
+ if "level" in args:
+ body["level"] = args.get("level")
+ elif "role" in args:
+ role = args.get("role")
+ body["role"] = None if role is None or str(role).strip() == "" else str(role).strip()
+ if _truthy(args.get("clear_region")):
+ body["region_folder_id"] = None
+ elif "region_folder_id" in args:
+ region = args.get("region_folder_id")
+ body["region_folder_id"] = (
+ None if region is None or str(region).strip() == "" else str(region).strip()
+ )
+ if not body:
+ return {"ok": False, "error": "level_or_region_required"}
+ out = _data(
+ http_json(
+ "PATCH",
+ f"/v1/topology/fabric/nodes/{node_id}/tags",
+ body=body,
+ )
+ )
+ if isinstance(out, dict) and out.get("ok") is not False:
+ out = _compact_fabric_item(out) if "id" in out else dict(out)
+ out["ok"] = True
+ out["action"] = "patch"
+ return out
+
+ if action == "unmatched":
+ kind = str(args.get("kind") or "any").strip() or "any"
+ page_n = max(1, int(args.get("page") or 1))
+ page_size = min(500, max(1, int(args.get("page_size") or args.get("limit") or 50)))
+ page = _compact_fabric_page(
+ _data(
+ http_json(
+ "GET",
+ "/v1/topology/classify/unmatched",
+ params={"kind": kind, "page": page_n, "page_size": page_size},
+ )
+ )
+ )
+ if isinstance(page, dict):
+ page = dict(page)
+ page["action"] = "unmatched"
+ return page
+
+ if action in {"preview_rules", "preview"}:
+ out = _data(http_json("POST", "/v1/topology/classify/preview"))
+ if isinstance(out, dict) and out.get("ok") is not False:
+ out = dict(out)
+ out["action"] = "preview_rules"
+ out["hint"] = "Rule engine dry-run. Re-call action=apply_rules to write."
+ return out
+
+ if action in {"apply_rules", "apply"}:
+ skip_manual = True
+ if "skip_manual" in args:
+ skip_manual = _truthy(args.get("skip_manual"))
+ elif _truthy(args.get("overwrite_manual")):
+ skip_manual = False
+ params = {
+ "skip_manual": skip_manual,
+ "fill_empty_only": _truthy(args.get("fill_empty_only")),
+ }
+ out = _data(http_json("POST", "/v1/topology/classify/apply", params=params))
+ if isinstance(out, dict) and out.get("ok") is not False:
+ out = dict(out)
+ out["action"] = "apply_rules"
+ return out
+
+ if action in {"list_rules", "rules"}:
+ out = _data(http_json("GET", "/v1/topology/classify/rules"))
+ if isinstance(out, dict) and out.get("ok") is not False:
+ out = dict(out)
+ out["action"] = "list_rules"
+ out["hint"] = (
+ "Rules CRUD stays on web or HTTP. Agent tagging prefers action=match|tag "
+ "(ephemeral regex) over editing rules."
+ )
+ return out
+
+ return {
+ "ok": False,
+ "error": "action_invalid",
+ "hint": "action=match|tag|patch|unmatched|preview_rules|apply_rules|list_rules",
+ }
+
+
def _query_topology_neighborhood(args: dict[str, Any]) -> dict[str, Any]:
node_id = str(args.get("node_id") or "").strip()
if not node_id:
@@ -2002,8 +2542,9 @@ def _sink_topology_dual_units(args: dict[str, Any]) -> dict[str, Any]:
max_units = max(1, min(20, int(args.get("max_units") or 3)))
min_nodes = max(2, min(200, int(args.get("min_nodes") or 8)))
- max_nodes = max(min_nodes, min(400, int(args.get("max_nodes") or 80)))
- max_batch_nodes = max(max_nodes, min(800, int(args.get("max_batch_nodes") or 120)))
+ # CN-eye units often cover 150–300 NEs; old default 80 rejected them.
+ max_nodes = max(min_nodes, min(400, int(args.get("max_nodes") or 300)))
+ max_batch_nodes = max(max_nodes, min(800, int(args.get("max_batch_nodes") or 400)))
until_empty = bool(args.get("until_empty"))
include_leftovers = bool(
args.get("include_leftovers")
@@ -2019,6 +2560,49 @@ def _sink_topology_dual_units(args: dict[str, Any]) -> dict[str, Any]:
True if args.get("layout_batch") is None else args.get("layout_batch")
)
unit_gap = float(args.get("unit_gap") or 220.0)
+ # Level-aware drain: sink_layers = whitelist this phase; keep_layers = anchors.
+ # Default: drain access(+other), leave core/agg on root.
+ raw_keep = args.get("keep_layers")
+ if raw_keep is None:
+ keep_layers = {"core", "agg"}
+ elif isinstance(raw_keep, str):
+ keep_layers = {p.strip().lower() for p in raw_keep.split(",") if p.strip()}
+ elif isinstance(raw_keep, (list, tuple)):
+ keep_layers = {str(p).strip().lower() for p in raw_keep if str(p).strip()}
+ else:
+ keep_layers = {"core", "agg"}
+ if isinstance(raw_keep, (list, tuple)) and len(raw_keep) == 0:
+ keep_layers = set()
+
+ raw_sink = args.get("sink_layers")
+ if raw_sink is None:
+ # Default phase: access corridors (+ unclassified).
+ sink_layers: set[str] | None = {"access", "other"}
+ elif isinstance(raw_sink, str):
+ sink_layers = {p.strip().lower() for p in raw_sink.split(",") if p.strip()} or None
+ elif isinstance(raw_sink, (list, tuple)):
+ if len(raw_sink) == 0:
+ sink_layers = None # any non-keep
+ else:
+ sink_layers = {str(p).strip().lower() for p in raw_sink if str(p).strip()}
+ else:
+ sink_layers = {"access", "other"}
+
+ # by_level=true: auto-pick deepest remaining layer on source (access→agg→core).
+ by_level = bool(args.get("by_level"))
+ # Default false: CN/core eyes hang off keep portals; prefer_pure would skip them.
+ prefer_pure = bool(
+ False if args.get("prefer_pure") is None else args.get("prefer_pure")
+ )
+ prefer_core_eye = bool(
+ True if args.get("prefer_core_eye") is None else args.get("prefer_core_eye")
+ )
+ prefer_top_eye = args.get("prefer_top_eye")
+ if prefer_top_eye is None:
+ prefer_top_eye = prefer_core_eye
+ else:
+ prefer_top_eye = bool(prefer_top_eye)
+ _LEVEL_DEPTH = ("access", "other", "agg", "core", "external")
batches: list[dict[str, Any]] = []
source_remaining = -1
@@ -2073,6 +2657,36 @@ def _sink_topology_dual_units(args: dict[str, Any]) -> dict[str, Any]:
and str(e.get("b_node_id") or e.get("target") or "") not in dry_removed
]
st = build_state_from_nodes_edges(nodes, edges)
+ layer_of = {
+ nid: str(ly or "").strip().lower() or "other"
+ for nid, ly in (st.layers or {}).items()
+ }
+ # Phase layers: explicit sink_layers, or by_level → deepest remaining.
+ phase_layers = set(sink_layers) if sink_layers is not None else None
+ if by_level:
+ picked_ly = None
+ for ly in _LEVEL_DEPTH:
+ if ly in keep_layers:
+ continue
+ if any(layer_of.get(i) == ly for i in src_ids):
+ picked_ly = ly
+ break
+ if picked_ly:
+ phase_layers = {picked_ly}
+ if picked_ly == "access":
+ phase_layers.add("other")
+
+ keep_ids = {
+ nid
+ for nid, ly in layer_of.items()
+ if ly in keep_layers or (phase_layers is not None and ly not in phase_layers)
+ }
+ sink_ids = {
+ nid
+ for nid in src_ids
+ if nid not in keep_ids
+ and (phase_layers is None or layer_of.get(nid, "other") in phase_layers)
+ }
units = find_dual_portal_units(st, max_units=detect_max)
picked = select_dual_unit_batch(
units,
@@ -2081,8 +2695,14 @@ def _sink_topology_dual_units(args: dict[str, Any]) -> dict[str, Any]:
max_nodes=max_nodes,
max_batch_nodes=max_batch_nodes,
exclude_ids=set(snk_ids),
+ keep_ids=keep_ids,
+ sink_ids=sink_ids,
+ prefer_pure=prefer_pure,
+ prefer_core_eye=prefer_core_eye,
+ prefer_top_eye=prefer_top_eye,
+ layers=layer_of,
)
- move_ids = batch_node_ids(picked)
+ move_ids = batch_node_ids(picked, keep_ids=keep_ids, sink_ids=sink_ids)
mode = "dual_units"
if not move_ids:
# Relax size band once before leftovers.
@@ -2090,22 +2710,34 @@ def _sink_topology_dual_units(args: dict[str, Any]) -> dict[str, Any]:
units,
max_units=max_units,
min_nodes=2,
- max_nodes=max(max_nodes, 200),
+ max_nodes=max(max_nodes, 300),
max_batch_nodes=max_batch_nodes,
exclude_ids=set(snk_ids),
+ keep_ids=keep_ids,
+ sink_ids=sink_ids,
+ prefer_pure=prefer_pure,
+ prefer_core_eye=prefer_core_eye,
+ prefer_top_eye=prefer_top_eye,
+ layers=layer_of,
)
- move_ids = batch_node_ids(picked)
+ move_ids = batch_node_ids(picked, keep_ids=keep_ids, sink_ids=sink_ids)
if not move_ids and include_leftovers:
mode = "leftovers"
move_ids = leftover_batch_ids(
src_ids,
max_batch_nodes=max_batch_nodes,
exclude_ids=set(snk_ids),
+ keep_ids=keep_ids,
+ sink_ids=sink_ids,
)
# leftovers may already be on sink; still remove from source
if not move_ids:
move_ids = leftover_batch_ids(
- src_ids, max_batch_nodes=max_batch_nodes, exclude_ids=set()
+ src_ids,
+ max_batch_nodes=max_batch_nodes,
+ exclude_ids=set(),
+ keep_ids=keep_ids,
+ sink_ids=sink_ids,
)
if not move_ids:
@@ -2130,6 +2762,18 @@ def _sink_topology_dual_units(args: dict[str, Any]) -> dict[str, Any]:
"units": units_as_batch_rows(picked, st.names),
"node_ids": move_ids,
"node_count": len(move_ids),
+ "kept_on_source": sorted(
+ {
+ nid
+ for u in picked
+ for nid in u.member_ids()
+ if nid in keep_ids
+ }
+ ),
+ "keep_layers": sorted(keep_layers),
+ "sink_layers": sorted(phase_layers) if phase_layers is not None else None,
+ "by_level": by_level,
+ "sink_eligible": len(sink_ids),
"source_before": source_remaining,
"sink_before": sink_count,
}
@@ -2206,6 +2850,10 @@ def _sink_topology_dual_units(args: dict[str, Any]) -> dict[str, Any]:
unit_gap=unit_gap,
links=attach_links,
)
+ # Never patch keep-layer anchors (they stay on source only).
+ move_set = set(move_ids)
+ if world:
+ world = {nid: xy for nid, xy in world.items() if nid in move_set}
pos_patch = positions_to_patch(world) if world else []
# Fill any members missing from unit layout (shared skip / fail).
have = {str(p.get("fabric_node_id") or "") for p in pos_patch}
@@ -2294,12 +2942,13 @@ def _sink_topology_dual_units(args: dict[str, Any]) -> dict[str, Any]:
"layout_batch": layout_batch,
"hint": (
"Source empty — sink membership complete. Batches already had "
- "layout_dual_unit when layout_batch=true; finish with polish_crossings / "
- "straighten / clear_edge_hits (avoid chord straighten after clear)."
+ "layout_dual_unit when layout_batch=true; polish with "
+ "polish_crossings(straighten=false)/clear_edge_hits — "
+ "never fix_overlaps+straighten (destroys dual-unit eyes)."
if done
else (
- "Call again for the NEXT batch only after polish/clear on sink. "
- "Do NOT set until_empty — one batch → tune → next batch."
+ "Call again for the NEXT batch only after eye-safe polish on sink "
+ "(no straighten). Do NOT set until_empty — one batch → tune → next."
)
),
}
@@ -2846,9 +3495,13 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
"source_view_id, layout_dual_unit each unit (layout_batch default true), "
"then compose_orbit-style block sweep to park onto sink (best partial "
"crossings / overlap / bridge — not fixed right), "
- "then remove those fabric ids from the root. Default one batch/call; "
- "until_empty=true loops until source empty (or leftovers). Global polish "
- "still via layoutTopologyView. dry_run previews selection. Requires ne:write."
+ "then remove those fabric ids from the root. "
+ "Default keep_layers=[core,agg] leaves gravity anchors on the root; "
+ "sink_layers=[access,other] drains one level phase (set by_level=true "
+ "to auto deepest remaining). "
+ "Default one batch/call; until_empty=true loops until source empty "
+ "(or leftovers). Global polish still via layoutTopologyView. "
+ "dry_run previews selection. Requires ne:write."
),
"inputSchema": {
"type": "object",
@@ -2878,14 +3531,16 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
"type": "integer",
"minimum": 2,
"maximum": 400,
- "default": 80,
- "description": "Max nodes per dual_unit candidate.",
+ "default": 300,
+ "description": (
+ "Max nodes per dual_unit candidate (CN eyes often 150–300)."
+ ),
},
"max_batch_nodes": {
"type": "integer",
"minimum": 8,
"maximum": 800,
- "default": 120,
+ "default": 400,
},
"layout_batch": {
"type": "boolean",
@@ -2916,6 +3571,58 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
"default": True,
"description": "When dual_units exhausted, move leftover NE chunks.",
},
+ "keep_layers": {
+ "type": "array",
+ "items": {"type": "string"},
+ "default": ["core", "agg"],
+ "description": (
+ "Fabric layers that stay on source (not sunk). "
+ "Default core+agg so root keeps the gravity bar; "
+ "pass [] to drain everything (legacy)."
+ ),
+ },
+ "sink_layers": {
+ "type": "array",
+ "items": {"type": "string"},
+ "default": ["access", "other"],
+ "description": (
+ "Only move these layers this phase. Default access+other. "
+ "Pass [] to allow any non-keep. With by_level=true, auto "
+ "picks deepest remaining (access→agg→…)."
+ ),
+ },
+ "by_level": {
+ "type": "boolean",
+ "default": False,
+ "description": (
+ "Auto sink deepest remaining non-keep layer each batch "
+ "(access first, then agg if keep allows)."
+ ),
+ },
+ "prefer_pure": {
+ "type": "boolean",
+ "default": False,
+ "description": (
+ "Legacy: prefer both portals sinkable. Default false so "
+ "CN/core eyes (portals stay on root) are not deprioritized."
+ ),
+ },
+ "prefer_core_eye": {
+ "type": "boolean",
+ "default": True,
+ "description": (
+ "Alias of prefer_top_eye. Prefer core/agg eyes "
+ "top→down before access rings."
+ ),
+ },
+ "prefer_top_eye": {
+ "type": "boolean",
+ "default": True,
+ "description": (
+ "Prefer dual_unit portals top→down: core–core, "
+ "core–agg, agg–agg, then maximize movable coverage."
+ ),
+ },
"dry_run": {
"type": "boolean",
"default": False,
@@ -3101,6 +3808,14 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
"description": "List filter keyword; alias of q for search.",
},
"role": {"type": "string"},
+ "level": {
+ "type": "string",
+ "description": "Exact fabric level e.g. 1.1",
+ },
+ "level_major": {
+ "type": "string",
+ "description": "Major band e.g. 2 → [2.0, 3.0)",
+ },
"region_folder_id": {
"type": "string",
"description": "Filter by topo folder id (UME region / canvas folder).",
@@ -3122,6 +3837,113 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
"additionalProperties": False,
},
},
+ {
+ "name": "classifyTopologyFabricNodes",
+ "description": (
+ "Classify Fabric inventory by layout level (major.minor) + region. "
+ "level: 0=external, 1=core, 2=agg, 3=access; use 1.1 / 2.1 for sub-tiers. "
+ "action=match → tag(level|role preset, dry_run) → tag. "
+ "role preset still accepted (maps to *.0). "
+ "After tagging, addTopologyViewNodes by role/region. No slice maps. "
+ "Requires ne:write."
+ ),
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "action": {
+ "type": "string",
+ "enum": [
+ "match",
+ "tag",
+ "patch",
+ "unmatched",
+ "preview_rules",
+ "apply_rules",
+ "list_rules",
+ ],
+ "description": "Required. Typical: match → tag(dry_run) → tag.",
+ },
+ "pattern": {
+ "type": "string",
+ "description": "Regex for match/tag (aliases q).",
+ },
+ "q": {"type": "string", "description": "Alias of pattern."},
+ "match_field": {
+ "type": "string",
+ "enum": ["name", "ip", "name_ip"],
+ "default": "name",
+ },
+ "sample_limit": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 200,
+ "default": 50,
+ },
+ "fabric_node_ids": {
+ "type": "array",
+ "items": {"type": "string"},
+ "description": "Explicit ids for tag (from match).",
+ },
+ "fabric_node_id": {
+ "type": "string",
+ "description": "Single id for patch (alias node_id).",
+ },
+ "node_id": {"type": "string"},
+ "level": {
+ "description": "Layout rank e.g. 0, 1, 1.1, 2.1, 3. null clears.",
+ },
+ "role": {
+ "type": "string",
+ "description": "Preset alias → level: external|core|aggregation|access|edge.",
+ },
+ "region_folder_id": {
+ "type": "string",
+ "description": "Topo region folder id; empty + clear_region clears.",
+ },
+ "clear_region": {
+ "type": "boolean",
+ "default": False,
+ "description": "Clear region_folder_id on tag/patch.",
+ },
+ "dry_run": {
+ "type": "boolean",
+ "default": False,
+ "description": "tag only: preview without write.",
+ },
+ "kind": {
+ "type": "string",
+ "enum": ["any", "level", "role", "region"],
+ "default": "any",
+ "description": "unmatched filter (role aliases level).",
+ },
+ "page": {"type": "integer", "minimum": 1, "default": 1},
+ "page_size": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 500,
+ "default": 50,
+ },
+ "limit": {"type": "integer", "description": "Alias of page_size"},
+ "skip_manual": {
+ "type": "boolean",
+ "default": True,
+ "description": "apply_rules: skip manually tagged nodes.",
+ },
+ "overwrite_manual": {
+ "type": "boolean",
+ "default": False,
+ "description": "apply_rules shortcut: skip_manual=false.",
+ },
+ "fill_empty_only": {
+ "type": "boolean",
+ "default": False,
+ "description": "apply_rules: only fill empty role/region.",
+ },
+ },
+ "required": ["action"],
+ "additionalProperties": False,
+ },
+ },
{
"name": "queryTopologyNeighborhood",
"description": (
@@ -3168,17 +3990,95 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
"additionalProperties": False,
},
},
+ {
+ "name": "suggestSinkHubs",
+ "description": (
+ "After the one-shot dual_unit eye is fixed: rank remaining hub territories on "
+ "source_view for non-dual move_nodes(park) batches. Excludes eye portals "
+ "(source/sink dual_units + exclude_portal_ids). Returns batches[] ranked by "
+ "remaining territory (agg before core) and move_nodes payload for pick=1. "
+ "Do NOT call sinkTopologyDualUnits again. Needs ne:read."
+ ),
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "source_view_id": {
+ "type": "string",
+ "description": "Root / leftover canvas (required). Alias: view_id.",
+ },
+ "view_id": {
+ "type": "string",
+ "description": "Alias of source_view_id.",
+ },
+ "sink_view_id": {
+ "type": "string",
+ "description": "Eye sink canvas; ids already there are dropped from batches.",
+ },
+ "exclude_portal_ids": {
+ "type": "array",
+ "items": {"type": "string"},
+ "description": "Extra freeze ids (CN portals). Merged with dual_units portals.",
+ },
+ "use_source_dual_portals": {
+ "type": "boolean",
+ "default": True,
+ "description": "Exclude portals from source dual_units (default true).",
+ },
+ "use_sink_dual_portals": {
+ "type": "boolean",
+ "default": True,
+ "description": "Exclude portals from sink dual_units when sink_view_id set.",
+ },
+ "only_layers": {
+ "type": "array",
+ "items": {"type": "string"},
+ "description": "Hub layers to consider (default [agg, core]).",
+ },
+ "min_territory": {
+ "type": "integer",
+ "minimum": 0,
+ "default": 1,
+ "description": "Skip hubs with remaining_territory below this.",
+ },
+ "min_move_n": {
+ "type": "integer",
+ "minimum": 1,
+ "default": 1,
+ },
+ "top_n": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 40,
+ "default": 12,
+ },
+ "pick": {
+ "type": "integer",
+ "minimum": 1,
+ "default": 1,
+ "description": "1-based batch to expose as batch + move_nodes.",
+ },
+ "include_hub": {
+ "type": "boolean",
+ "default": True,
+ "description": "Include hub id in fabric_node_ids when still on source.",
+ },
+ },
+ "required": [],
+ "additionalProperties": False,
+ },
+ },
{
"name": "analyzeTopologyViewLayout",
"description": (
"Layout QA + structure planning (read-only). Returns verdict + overlap/crossing/"
"spacing/sparsity/edges + mid-tier chains(直链成一体)/rings(最小环不被穿) "
- "+ score.total∈[0,100] (chain/rings each weight 0.10). Pass view_id, or folder_id to sample. "
+ "+ score.total∈[0,100] (profiles: auto|default|eye). Pass view_id, or folder_id to sample. "
"detail=structure: graph stats for gravity (core_bar|agg_bar|mixed), hubs, stubs, "
"dual_units (two-portal eye units), soft_blocks, "
"geometry_hint, recipe_preference (compact|corridor|rings) — call BEFORE layout. "
"detail=hotspots|blocks|both: sight{} for hand-drag (crossings, drag_candidates, blocks); "
"both also includes structure{}. "
+ "score_profile=auto picks eye for large sparse diagonal sinks. "
"For writing positions use layoutTopologyView / updateTopologyViewPositions. Needs ne:read."
),
"inputSchema": {
@@ -3201,6 +4101,15 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
"hotspots|blocks=sight; both=structure+sight (hand-drag)"
),
},
+ "score_profile": {
+ "type": "string",
+ "enum": ["auto", "default", "eye"],
+ "default": "auto",
+ "description": (
+ "Scoring weights. auto→eye for large sparse diagonal canvases; "
+ "eye softens axis/rings (arc-band dual sinks); default=metro."
+ ),
+ },
"sight_limit": {
"type": "integer",
"minimum": 5,
@@ -3236,19 +4145,31 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
"description": (
"Layout / local polish for a canvas. Prefer local actions over global crush. "
"action=layout: recipe=rings|corridor|compact|unstick (small graphs). "
- "action=layout_dual_unit: eye-shaped dual-portal unit (require crossings=0). "
+ "action=layout_dual_unit: CN/core dual-portal eye (max membership; "
+ "residual crossings OK unless params.require_zero_cross=true). "
"action=move_nodes (alias sink_nodes): move fabric_node_ids from "
"source_view_id→view_id; park=true for orbit attach; swap views to reverse. "
"Prefer sinkTopologyDualUnits for dual_units batches. "
- "action=orbit_sweep: crossing orbit; preview+node_id / apply+pick / round=true. "
+ "action=orbit_sweep: crossing orbit; preview+node_id / apply+pick; "
+ "until_limit=true loops single-point to stall (eye polish; default freeze portals); "
+ "round=true one-batch auto pick#1 (avoid on eye sinks). "
+ "action=level_bands: snap y by fabric layer (external→core→agg→access). "
"action=polish_crossings: one-shot straighten→press→untangle (no temp scripts). "
- "action=clear_edge_hits: eject nodes on non-incident edges (H/V). "
- "action=fix_overlaps|resolve_overlaps: pull apart overlaps. "
- "action=untangle / straighten_channels: surgical polish. "
+ "action=clear_edge_hits: eject nodes on non-incident edges (H/V); "
+ "never raise crossings (even preserve_axis); eye sinks need portal_ids. "
+ "action=compact_bbox: gated farthest-K shrink toward portals. "
+ "action=pull_far_chains: scale far corridors/isolates toward portal mid-point. "
+ "action=align_reference: SAME-network A/B debug only "
+ "(shared fabric_node_ids); not a cross-network template. "
+ "action=fix_overlaps|resolve_overlaps: pull apart overlaps (not on eye sinks). "
+ "action=untangle / straighten_channels: surgical polish (not on eye sinks). "
"action=job_status|job_cancel: poll/cancel background jobs. "
"preset: loose|balanced|dense. mode: preview|apply. "
- "Workflow: analyze(structure) → sinkTopologyDualUnits → orbit_sweep → "
- "polish_crossings → clear_edge_hits → hand drag. Needs ne:write for apply."
+ "Workflow: analyze(structure) → sinkTopologyDualUnits(max_units=1) → "
+ "suggestSinkHubs/move_nodes(park) → until_limit(crossing→total) → "
+ "clear_edge_hits → pull_far_chains → compact_bbox → hand. "
+ "No golden by default; do not align_reference for delivery. "
+ "Needs ne:write for apply."
),
"inputSchema": {
"type": "object",
@@ -3268,7 +4189,11 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
"untangle",
"polish_crossings",
"clear_edge_hits",
+ "compact_bbox",
+ "pull_far_chains",
+ "align_reference",
"orbit_sweep",
+ "level_bands",
"move_nodes",
"sink_nodes",
"job_status",
@@ -3277,9 +4202,14 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
"default": "layout",
"description": (
"layout=full recipe; layout_dual_unit=dual-portal eye; "
+ "level_bands=horizontal layer bands; "
"move_nodes|sink_nodes=migrate fabric_node_ids; "
"orbit_sweep=polar sweep; polish_crossings=one-shot cut crossings; "
- "clear_edge_hits=eject edge hits; fix_overlaps|resolve_overlaps; "
+ "clear_edge_hits=eject edge hits (no crossing rise); "
+ "pull_far_chains=scale far corridors toward portal mid; "
+ "compact_bbox=gated farthest-K shrink; "
+ "align_reference=same-network A/B debug only; "
+ "fix_overlaps|resolve_overlaps; "
"untangle/straighten_channels; job_status|job_cancel."
),
},
@@ -3287,7 +4217,8 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
"type": "string",
"description": (
"For action=layout: optional load graph (default=view_id). "
- "For action=move_nodes: required FROM canvas (view_id is TO)."
+ "For action=move_nodes: required FROM canvas (view_id is TO). "
+ "For action=align_reference: same-network reference (debug only)."
),
},
"recipe": {
@@ -3325,13 +4256,22 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
"description": (
"Overrides. layout: target_nn/target_util/…. "
"job_status|job_cancel: job_id (required). "
- "layout_dual_unit: unit_id (optional). "
+ "layout_dual_unit: unit_id / portal_a+portal_b; "
+ "require_zero_cross (default false). "
"untangle: max_rounds/max_degree/protect_rigid/focus_ids[]. "
"polish_crossings: portal_ids[]/source_view_ids[], "
"top_n/max_moves/max_sweeps/straighten/untangle_rounds. "
- "clear_edge_hits: top_n/thr/margin/max_moves. "
- "orbit_sweep: node_id/pick/round/top_n/max_jump/angle_step/"
- "nn_floor/min_angle_sep; protect_rigid default off. "
+ "clear_edge_hits: top_n/thr/margin/max_moves/portal_ids/"
+ "max_eject_degree/preserve_axis. "
+ "compact_bbox|pull_far_chains: portal_ids; "
+ "pull scales toward portal mid (not outer hub); "
+ "compact defaults farthest-K. "
+ "orbit_sweep: node_id/pick/round/until_limit/portal_ids[]/"
+ "freeze_layers[]/freeze_levels[]/"
+ "max_degree/max_moves/stall_limit/max_stretch/min_delta/"
+ "max_jump/angle_step/nn_floor; until_limit defaults "
+ "protect_rigid=portals + objective=crossing; "
+ "single-node/round default protect_rigid=off. "
"move_nodes|sink_nodes: fabric_node_ids[] (required), "
"copy_positions (default true), park, remove_from_source "
"(default true), pad/offset_x/offset_y."
@@ -3361,8 +4301,10 @@ _HANDLERS: dict[str, Callable[[dict[str, Any]], dict[str, Any]]] = {
"updateTopologyViewPositions": _update_topology_view_positions,
"projectTopologyNeighbors": _project_topology_neighbors,
"queryTopologyFabricNodes": _query_topology_fabric_nodes,
+ "classifyTopologyFabricNodes": _classify_topology_fabric_nodes,
"queryTopologyNeighborhood": _query_topology_neighborhood,
"queryTopologyEdges": _query_topology_edges,
+ "suggestSinkHubs": _suggest_sink_hubs,
"analyzeTopologyViewLayout": _analyze_topology_view_layout,
"layoutTopologyView": _layout_topology_view,
}
@@ -3378,8 +4320,10 @@ TOOL_REQUIRED_SCOPE: dict[str, str] = {
"updateTopologyViewPositions": "ne:write",
"projectTopologyNeighbors": "ne:write",
"queryTopologyFabricNodes": "ne:read",
+ "classifyTopologyFabricNodes": "ne:write",
"queryTopologyNeighborhood": "ne:read",
"queryTopologyEdges": "ne:read",
+ "suggestSinkHubs": "ne:read",
"analyzeTopologyViewLayout": "ne:read",
"layoutTopologyView": "ne:write",
}
diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_metrics.py b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_metrics.py
index 1100717..2f2aff7 100644
--- a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_metrics.py
+++ b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_metrics.py
@@ -176,14 +176,24 @@ def compute_edge_clearance(
per_node.append(h)
hits_n = len(hits)
- # Score by unique nodes hit / n (plan: ~0.05 warn, 0.2 → 0)
+ # Dual signal so hub chords with many segment hits actually hurt:
+ # 1) unique nodes hit / n (0 → 1, ≥20% → 0)
+ # 2) hits / links (≤0.02 → 1, ≥0.20 → 0) — separates sparse vs scrubbed eyes
hit_frac = len(nodes_with_hit) / max(n_nodes, 1)
+ hit_per_link = hits_n / max(n_links, 1)
if hit_frac <= 0.0:
- score = 1.0
+ node_s = 1.0
elif hit_frac >= 0.2:
- score = 0.0
+ node_s = 0.0
else:
- score = 1.0 - hit_frac / 0.2
+ node_s = 1.0 - hit_frac / 0.2
+ if hit_per_link <= 0.02:
+ inten_s = 1.0
+ elif hit_per_link >= 0.20:
+ inten_s = 0.0
+ else:
+ inten_s = 1.0 - (hit_per_link - 0.02) / 0.18
+ score = 0.45 * node_s + 0.55 * inten_s
def _pct(vals: list[float], p: float) -> float | None:
if not vals:
@@ -197,6 +207,9 @@ def compute_edge_clearance(
"nodes_hit": len(nodes_with_hit),
"min_clearance_p50": _pct(clearances, 0.5),
"edge_clearance_score": round(score, 4),
+ "edge_clearance_node_score": round(node_s, 4),
+ "edge_clearance_intensity_score": round(inten_s, 4),
+ "hit_per_link": round(hit_per_link, 4),
"top_edge_hits": per_node[:top_n],
"hit_nodes": per_node,
"edge_clearance_tip": tip_ok,
@@ -764,7 +777,7 @@ def grade_layout(
issues.append(f"label_overlaps={label_overlaps}>0")
util_warn = 0.08
- util_fail = 0.03
+ util_fail = 0.04
util_f = float(util) if util is not None else None
util_bad = util_f is not None and util_f < util_fail
util_soft = util_f is not None and util_f < util_warn
diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/align_reference.py b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/align_reference.py
new file mode 100644
index 0000000..9cf3ae4
--- /dev/null
+++ b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/align_reference.py
@@ -0,0 +1,279 @@
+"""Align a canvas to a reference layout (e.g. hand golden / UME).
+
+Uses shared ``fabric_node_id``s. Preferred mode ``similarity``: map reference
+portal chord → target portal chord (scale+rotate+translate), then place every
+shared node from the transformed reference. Target-only leftovers stay put
+(or park toward nearest aligned neighbour).
+
+This is the escape hatch when gated local polish stalls: reuse known-good
+geometry instead of more until_limit.
+"""
+
+from __future__ import annotations
+
+import math
+from typing import Any
+
+from netx_topology_mcp.layout_metrics import count_edge_crossings
+from netx_topology_mcp.layout_ops.graph_util import bbox
+from netx_topology_mcp.layout_ops.orbit_sweep import _has_any_footprint_overlap
+from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult
+
+
+def _dist(a: tuple[float, float], b: tuple[float, float]) -> float:
+ return math.hypot(a[0] - b[0], a[1] - b[1])
+
+
+def _similarity_from_portals(
+ ref: dict[str, tuple[float, float]],
+ tgt_portals: dict[str, tuple[float, float]],
+ portal_ids: list[str],
+) -> tuple[float, float, float, float, float, float, float] | None:
+ """Return (cos, sin, scale, tx, ty, rx0, ry0) mapping ref → target via two portals.
+
+ x' = scale * R * (x - r0) + t0
+ """
+ if len(portal_ids) < 2:
+ return None
+ a, b = portal_ids[0], portal_ids[1]
+ if a not in ref or b not in ref or a not in tgt_portals or b not in tgt_portals:
+ return None
+ r0 = ref[a]
+ r1 = ref[b]
+ t0 = tgt_portals[a]
+ t1 = tgt_portals[b]
+ rdx, rdy = r1[0] - r0[0], r1[1] - r0[1]
+ tdx, tdy = t1[0] - t0[0], t1[1] - t0[1]
+ rlen = math.hypot(rdx, rdy)
+ tlen = math.hypot(tdx, tdy)
+ if rlen < 1e-6 or tlen < 1e-6:
+ return None
+ scale = tlen / rlen
+ ang = math.atan2(tdy, tdx) - math.atan2(rdy, rdx)
+ return (math.cos(ang), math.sin(ang), scale, t0[0], t0[1], r0[0], r0[1])
+
+
+def _apply_sim(
+ xy: tuple[float, float],
+ sim: tuple[float, float, float, float, float, float, float],
+) -> tuple[float, float]:
+ cos_a, sin_a, scale, tx, ty, rx0, ry0 = sim
+ dx, dy = xy[0] - rx0, xy[1] - ry0
+ return (
+ tx + scale * (dx * cos_a - dy * sin_a),
+ ty + scale * (dx * sin_a + dy * cos_a),
+ )
+
+
+def _procrustes_similarity(
+ src: dict[str, tuple[float, float]],
+ dst: dict[str, tuple[float, float]],
+ ids: list[str],
+) -> tuple[float, float, float, float, float, float, float] | None:
+ """Umeyama-like 2D similarity from matched point pairs (ids in both)."""
+ pts = [i for i in ids if i in src and i in dst]
+ if len(pts) < 2:
+ return None
+ sx = sum(src[i][0] for i in pts) / len(pts)
+ sy = sum(src[i][1] for i in pts) / len(pts)
+ dx = sum(dst[i][0] for i in pts) / len(pts)
+ dy = sum(dst[i][1] for i in pts) / len(pts)
+ var_s = 0.0
+ cross = 0.0 # complex: sum conj(s)*d
+ # Using: scale*R maps (s-mean_s) → (d-mean_d)
+ sum_xx = sum_yy = sum_xy = sum_yx = 0.0
+ for i in pts:
+ sx0, sy0 = src[i][0] - sx, src[i][1] - sy
+ dx0, dy0 = dst[i][0] - dx, dst[i][1] - dy
+ var_s += sx0 * sx0 + sy0 * sy0
+ sum_xx += sx0 * dx0
+ sum_yy += sy0 * dy0
+ sum_xy += sx0 * dy0
+ sum_yx += sy0 * dx0
+ if var_s < 1e-8:
+ return None
+ # R = [[c,-s],[s,c]]; from SVD of covariance — 2D closed form:
+ # mu = atan2(sum_xy - sum_yx, sum_xx + sum_yy) ... use complex
+ re = sum_xx + sum_yy
+ im = sum_xy - sum_yx
+ ang = math.atan2(im, re)
+ cos_a, sin_a = math.cos(ang), math.sin(ang)
+ # scale = trace(R^T Cov) / var_s
+ scale = (re * cos_a + im * sin_a) / var_s
+ if scale < 1e-6:
+ scale = 1.0
+ # x' = scale R (x - mean_s) + mean_d
+ return (cos_a, sin_a, scale, dx, dy, sx, sy)
+
+
+def align_to_reference(
+ state: LayoutState,
+ params: LayoutParams | None = None,
+ *,
+ reference: dict[str, tuple[float, float]],
+ portal_ids: list[str] | None = None,
+ mode: str = "similarity",
+ park_missing: bool = True,
+ freeze_portals: bool = True,
+) -> OpResult:
+ """Rewrite ``state.positions`` from ``reference`` geometry.
+
+ ``mode``:
+ - ``similarity``: portal (or Procrustes) similarity; keep target portals
+ fixed when ``freeze_portals`` and portals provided.
+ - ``adopt``: copy reference coords for shared ids (normalize origin ≥40).
+ """
+ del params
+ st = state.copy()
+ pos = dict(st.positions)
+ ref = {k: v for k, v in reference.items() if k in pos or True}
+ portals = [str(p) for p in (portal_ids or []) if str(p).strip()]
+ shared = sorted(set(pos) & set(ref))
+ if len(shared) < 2:
+ return OpResult(
+ state=st,
+ moved=set(),
+ op="align_reference",
+ note="align_reference:too_few_shared",
+ params={"error": "too_few_shared", "shared_n": len(shared)},
+ )
+
+ mode_k = str(mode or "similarity").strip().lower() or "similarity"
+ g0 = count_edge_crossings(pos, st.links)
+ area0 = 0.0
+ if len(pos) >= 2:
+ x0, y0, x1, y1 = bbox(pos)
+ area0 = max((x1 - x0) * (y1 - y0), 1.0)
+
+ new_pos = dict(pos)
+ moved: set[str] = set()
+ sim = None
+ meta_mode = mode_k
+
+ if mode_k in {"adopt", "copy", "absolute"}:
+ xs = [ref[i][0] for i in shared]
+ ys = [ref[i][1] for i in shared]
+ ox, oy = min(xs), min(ys)
+ pad = 40.0
+ for nid in shared:
+ new_pos[nid] = (ref[nid][0] - ox + pad, ref[nid][1] - oy + pad)
+ moved.add(nid)
+ meta_mode = "adopt"
+ else:
+ # similarity
+ if len(portals) >= 2 and all(p in ref and p in pos for p in portals[:2]):
+ sim = _similarity_from_portals(ref, pos, portals[:2])
+ if sim is None:
+ # Procrustes on hubs: prefer portals if present else all shared
+ pivot = [p for p in portals if p in shared] if portals else shared
+ if len(pivot) < 2:
+ pivot = shared
+ sim = _procrustes_similarity(ref, pos, pivot[: min(32, len(pivot))])
+ if sim is None:
+ return OpResult(
+ state=st,
+ moved=set(),
+ op="align_reference",
+ note="align_reference:no_transform",
+ params={"error": "no_transform", "shared_n": len(shared)},
+ )
+ for nid in shared:
+ if freeze_portals and nid in portals[:2]:
+ continue # keep target portals pinned
+ new_pos[nid] = _apply_sim(ref[nid], sim)
+ moved.add(nid)
+ meta_mode = "similarity"
+
+ # Target-only: park near nearest aligned neighbour
+ only_tgt = [n for n in pos if n not in ref]
+ parked = 0
+ if park_missing and only_tgt and shared:
+ for nid in only_tgt:
+ # nearest shared by old distance
+ nb = min(
+ shared,
+ key=lambda s: _dist(pos[nid], pos[s]),
+ )
+ old_dx = pos[nid][0] - pos[nb][0]
+ old_dy = pos[nid][1] - pos[nb][1]
+ # shrink orphan offset toward new nb (keeps relative stub)
+ new_pos[nid] = (new_pos[nb][0] + old_dx * 0.85, new_pos[nb][1] + old_dy * 0.85)
+ moved.add(nid)
+ parked += 1
+
+ # Optional flip across portal chord if it lowers crossings
+ if (
+ meta_mode == "similarity"
+ and len(portals) >= 2
+ and portals[0] in new_pos
+ and portals[1] in new_pos
+ ):
+ pa, pb = new_pos[portals[0]], new_pos[portals[1]]
+ flipped = dict(new_pos)
+ ax, ay = pa
+ bx, by = pb
+ dx, dy = bx - ax, by - ay
+ llen2 = dx * dx + dy * dy
+ if llen2 > 1e-8:
+ for nid, (x, y) in new_pos.items():
+ if nid in portals[:2]:
+ continue
+ # reflect point across line AB
+ t = ((x - ax) * dx + (y - ay) * dy) / llen2
+ projx, projy = ax + t * dx, ay + t * dy
+ flipped[nid] = (2 * projx - x, 2 * projy - y)
+ g_a = count_edge_crossings(new_pos, st.links)
+ g_b = count_edge_crossings(flipped, st.links)
+ if g_b < g_a:
+ new_pos = flipped
+ meta_mode = "similarity_flipped"
+
+ st.positions = new_pos
+ st.last_moved = moved
+ g1 = count_edge_crossings(new_pos, st.links)
+ ov = _has_any_footprint_overlap(new_pos, st.names)
+ x0, y0, x1, y1 = bbox(new_pos) if len(new_pos) >= 2 else (0, 0, 1, 1)
+ area1 = max((x1 - x0) * (y1 - y0), 1.0)
+ meta: dict[str, Any] = {
+ "mode": meta_mode,
+ "shared_n": len(shared),
+ "moved_n": len(moved),
+ "parked_n": parked,
+ "target_only_n": len(only_tgt),
+ "ref_only_n": len(set(ref) - set(pos)),
+ "portal_ids": portals[:4],
+ "freeze_portals": bool(freeze_portals),
+ "start_crossings": g0,
+ "end_crossings": g1,
+ "overlaps": bool(ov),
+ "start_area": round(area0, 1),
+ "end_area": round(area1, 1),
+ "scale": round(float(sim[2]), 4) if sim else None,
+ }
+ st.meta["align_reference"] = meta
+ return OpResult(
+ state=st,
+ moved=moved,
+ op="align_reference",
+ params=meta,
+ note=(
+ f"align_reference mode={meta_mode} shared={len(shared)} "
+ f"moved={len(moved)} x={g0}→{g1} area={meta['start_area']}→{meta['end_area']}"
+ ),
+ )
+
+
+def align_reference_params_from_overrides(overrides: dict[str, Any] | None) -> dict[str, Any]:
+ o = overrides or {}
+ portals = o.get("portal_ids") or o.get("portals") or []
+ if isinstance(portals, str):
+ portals = [portals]
+ return {
+ "portal_ids": [str(x) for x in portals if str(x).strip()],
+ "mode": str(o.get("mode") or o.get("align_mode") or "similarity").strip().lower(),
+ "park_missing": bool(o.get("park_missing", True)),
+ "freeze_portals": bool(o.get("freeze_portals", True)),
+ "reference_view_id": str(
+ o.get("reference_view_id") or o.get("ref_view_id") or ""
+ ).strip(),
+ }
diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/bundle_orbit.py b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/bundle_orbit.py
new file mode 100644
index 0000000..64e512d
--- /dev/null
+++ b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/bundle_orbit.py
@@ -0,0 +1,801 @@
+"""Contract pure chains / ring+chain into a super-node, orbit, then expand.
+
+When single-point ``orbit_sweep`` stalls, long chords are often owned by a
+*corridor* (deg≤2 chain) or a small ring with a dangling chain. Moving any
+interior node alone barely changes global crossings; moving the whole bundle
+as a unit can.
+
+Expand rule (hard)
+------------------
+Expansion must **not** raise global crossings. If a full-length expand invades
+crowded space, probe **minimized** packings (shorter step along the ray) and
+only accept a candidate that clears the gate after expand. No silent “apply
+now, fix later”.
+
+Modes
+-----
+- **chain**: tip/centroid samples → expand mobile nodes on anchor→tip ray
+ with scale probes.
+- **ring_chain**: triangle tip (+ dangling chain) sweeps; ring base stays;
+ expand chain outward with the same minimize-probe.
+"""
+
+from __future__ import annotations
+
+import math
+from dataclasses import dataclass
+from typing import Any
+
+from netx_topology_mcp.layout_metrics import (
+ count_edge_crossings,
+ top_crossing_nodes,
+)
+from netx_topology_mcp.layout_ops.orbit_sweep import (
+ _MAX_JUMP_CAP,
+ _far_field_guides,
+ _polar_grid,
+)
+from netx_topology_mcp.layout_ops.ring_faces import extract_ring_faces
+from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult
+from netx_topology_mcp.layout_topology_quality import extract_chain_paths
+
+# Compact-first: tight packs before full-length (fits crowded pockets).
+# Floor must clear icon+caption; smaller steps cause member self-overlap → apply reject.
+_EXPAND_SCALES = (0.35, 0.45, 0.55, 0.7, 0.85, 1.0)
+_MIN_STEP = 80.0
+_TIP_SAMPLE_CAP = 48
+
+
+@dataclass(frozen=True)
+class Bundle:
+ """Mobile corridor with ordered path and optional fixed anchor."""
+
+ kind: str # chain | ring_chain
+ member_ids: tuple[str, ...]
+ tip_id: str
+ base_ids: tuple[str, ...] = ()
+ path: tuple[str, ...] = () # ordered anchor…tip (anchor may be fixed)
+ anchor_id: str | None = None
+
+ @property
+ def key(self) -> str:
+ return f"{self.kind}:{self.tip_id}:{len(self.member_ids)}"
+
+
+def _centroid(
+ pos: dict[str, tuple[float, float]], ids: list[str] | tuple[str, ...]
+) -> tuple[float, float] | None:
+ pts = [pos[n] for n in ids if n in pos]
+ if not pts:
+ return None
+ return (sum(p[0] for p in pts) / len(pts), sum(p[1] for p in pts) / len(pts))
+
+
+def _mean_step(path: list[str] | tuple[str, ...], pos: dict[str, tuple[float, float]]) -> float:
+ pts = [pos[n] for n in path if n in pos]
+ if len(pts) < 2:
+ return 140.0
+ total = 0.0
+ for i in range(len(pts) - 1):
+ total += math.hypot(pts[i + 1][0] - pts[i][0], pts[i + 1][1] - pts[i][1])
+ return max(_MIN_STEP, total / (len(pts) - 1))
+
+
+def _walk_dangling_chain(
+ start: str,
+ adj: dict[str, set[str]],
+ *,
+ blocked: set[str],
+) -> list[str]:
+ """From ``start`` (just outside blocked), walk a deg≤2 corridor away."""
+ if start in blocked or start not in adj:
+ return []
+ path = [start]
+ prev = None
+ cur = start
+ while True:
+ nbs = [v for v in adj.get(cur, ()) if v != prev and v not in blocked]
+ if len(path) == 1:
+ low = [v for v in nbs if len(adj.get(v, ())) <= 2]
+ if len(low) == 1:
+ nxt = low[0]
+ elif len(nbs) == 1 and len(adj.get(nbs[0], ())) <= 2:
+ nxt = nbs[0]
+ else:
+ break
+ else:
+ if len(adj.get(cur, ())) > 2:
+ break
+ if len(nbs) != 1:
+ break
+ nxt = nbs[0]
+ if len(adj.get(nxt, ())) > 2 and nxt not in blocked:
+ break
+ path.append(nxt)
+ prev, cur = cur, nxt
+ if len(path) > 40:
+ break
+ return path
+
+
+def detect_chain_bundles(
+ adj: dict[str, set[str]],
+ pos: dict[str, tuple[float, float]],
+ *,
+ frozen: set[str],
+ min_nodes: int = 3,
+) -> list[Bundle]:
+ """Pure (or portal-ended) chains → mobile interiors as expandable bundles."""
+ out: list[Bundle] = []
+ seen: set[frozenset[str]] = set()
+ for full in extract_chain_paths(adj):
+ if len(full) < min_nodes:
+ continue
+ # Orient: high-deg / frozen portal first when possible.
+ ordered = list(full)
+ d0 = len(adj.get(ordered[0], ()))
+ d1 = len(adj.get(ordered[-1], ()))
+ if d1 > d0 or (ordered[-1] in frozen and ordered[0] not in frozen):
+ ordered = list(reversed(ordered))
+
+ mobile = [
+ n
+ for n in ordered
+ if n in pos
+ and n not in frozen
+ and not (len(adj.get(n, ())) > 2 and n in {ordered[0], ordered[-1]})
+ ]
+ if len(mobile) < max(2, min_nodes - 1):
+ continue
+ key = frozenset(mobile)
+ if key in seen:
+ continue
+ seen.add(key)
+
+ # Tip = free end of the oriented path (last mobile).
+ tip = mobile[-1]
+ bases = [n for n in (ordered[0], ordered[-1]) if n not in key and n in pos]
+ anchor = None
+ if ordered[0] not in key and ordered[0] in pos:
+ anchor = ordered[0]
+ elif bases:
+ anchor = bases[0]
+
+ # Path for expand: anchor (optional) + mobiles in corridor order.
+ if anchor:
+ # mobiles already follow corridor from hub side
+ path = (anchor, *mobile)
+ else:
+ path = tuple(mobile)
+
+ out.append(
+ Bundle(
+ kind="chain",
+ member_ids=tuple(mobile),
+ tip_id=tip,
+ base_ids=tuple(bases),
+ path=path,
+ anchor_id=anchor,
+ )
+ )
+ out.sort(key=lambda b: (-len(b.member_ids), b.tip_id))
+ return out
+
+
+def detect_ring_chain_bundles(
+ state: LayoutState,
+ *,
+ frozen: set[str],
+ max_ring_len: int = 5,
+) -> list[Bundle]:
+ """Ring + dangling chain → sweep the attachment tip (triangle vertex)."""
+ adj = state.adj
+ pos = state.positions
+ out: list[Bundle] = []
+ seen: set[frozenset[str]] = set()
+ faces = extract_ring_faces(state, max_len=max_ring_len, max_cycles=60)
+ for face in faces:
+ rset = set(face.node_ids)
+ if len(rset) < 3:
+ continue
+ for tip in face.node_ids:
+ if tip in frozen or tip not in pos:
+ continue
+ outs = [v for v in adj.get(tip, ()) if v not in rset]
+ for seed in outs:
+ chain = _walk_dangling_chain(seed, adj, blocked=rset)
+ if len(chain) < 1:
+ continue
+ mobile = [tip, *chain]
+ mobile = [n for n in mobile if n not in frozen and n in pos]
+ if len(mobile) < 2:
+ continue
+ key = frozenset(mobile)
+ if key in seen:
+ continue
+ seen.add(key)
+ base = tuple(n for n in face.node_ids if n != tip)
+ # path: tip is anchor of the dangling chain (ring vertex stays
+ # the triangle tip we move); chain packs outward from tip.
+ path = tuple([tip, *[n for n in chain if n in key]])
+ out.append(
+ Bundle(
+ kind="ring_chain",
+ member_ids=tuple(mobile),
+ tip_id=tip,
+ base_ids=base,
+ path=path,
+ anchor_id=tip,
+ )
+ )
+ out.sort(
+ key=lambda b: (
+ 0 if len(b.base_ids) == 2 else 1,
+ -len(b.member_ids),
+ b.tip_id,
+ )
+ )
+ return out
+
+
+def detect_bundles(
+ state: LayoutState,
+ *,
+ frozen: set[str] | None = None,
+) -> list[Bundle]:
+ frozen = set(frozen or ())
+ chains = detect_chain_bundles(state.adj, state.positions, frozen=frozen)
+ rings = detect_ring_chain_bundles(state, frozen=frozen)
+ used: set[str] = set()
+ out: list[Bundle] = []
+ for b in list(rings) + list(chains):
+ members = set(b.member_ids)
+ if members & used:
+ continue
+ if any(n in frozen for n in b.member_ids):
+ continue
+ used |= members
+ out.append(b)
+ return out
+
+
+def _bundle_ok(
+ members: set[str],
+ trial: dict[str, tuple[float, float]],
+ names: dict[str, str],
+ nn_floor: float,
+) -> bool:
+ """Gate: no footprint invade (members∪outsiders) and nn_floor vs outsiders."""
+ from netx_topology_mcp.layout_ops.orbit_sweep import (
+ _box,
+ _centers_may_overlap,
+ )
+ from netx_topology_mcp.layout_metrics import node_footprint
+
+ # Apply refuses any footprint overlap — members must clear each other too.
+ mem_list = [n for n in members if n in trial]
+ for i, n in enumerate(mem_list):
+ ax0, ay0, ax1, ay1 = _box(n, trial, names)
+ nx, ny = trial[n]
+ fa = node_footprint(names.get(n, ""))
+ # vs other members
+ for m in mem_list[i + 1 :]:
+ mx, my = trial[m]
+ fb = node_footprint(names.get(m, ""))
+ if not _centers_may_overlap(nx, ny, mx, my, fa, fb):
+ continue
+ bx0, by0, bx1, by1 = _box(m, trial, names)
+ if ax0 < bx1 and ax1 > bx0 and ay0 < by1 and ay1 > by0:
+ return False
+ # vs outsiders + nn_floor
+ floor2 = nn_floor * nn_floor if nn_floor > 0 else 0.0
+ for b, (x, y) in trial.items():
+ if b in members:
+ continue
+ if floor2 and (x - nx) * (x - nx) + (y - ny) * (y - ny) < floor2:
+ return False
+ fb = node_footprint(names.get(b, ""))
+ if not _centers_may_overlap(nx, ny, x, y, fa, fb):
+ continue
+ bx0, by0, bx1, by1 = _box(b, trial, names)
+ if ax0 < bx1 and ax1 > bx0 and ay0 < by1 and ay1 > by0:
+ return False
+ return True
+
+
+def _expand_path_placements(
+ path: list[str],
+ *,
+ origin: tuple[float, float],
+ ux: float,
+ uy: float,
+ step: float,
+ mobile: set[str],
+ tip_xy: tuple[float, float] | None = None,
+ pin_tip: bool = False,
+) -> dict[str, tuple[float, float]]:
+ """Place mobile nodes along ray; optionally pin free tip at tip_xy."""
+ ox, oy = origin
+ out: dict[str, tuple[float, float]] = {}
+ movables = [n for n in path if n in mobile]
+ if not movables:
+ return out
+ if pin_tip and tip_xy is not None and len(movables) >= 1:
+ # Chord pack: first mobile near origin+step, last at tip_xy.
+ tx, ty = tip_xy
+ if len(movables) == 1:
+ out[movables[0]] = (tx, ty)
+ return out
+ # Keep tip fixed; distribute interiors on chord origin→tip.
+ for i, n in enumerate(movables):
+ t = (i + 1) / len(movables)
+ # start a bit off the anchor
+ out[n] = (ox + (tx - ox) * t, oy + (ty - oy) * t)
+ out[movables[-1]] = (tx, ty)
+ return out
+ for i, n in enumerate(movables):
+ k = i + 1
+ out[n] = (ox + ux * step * k, oy + uy * step * k)
+ return out
+
+
+def _probe_expand(
+ pos: dict[str, tuple[float, float]],
+ links: list[tuple[str, str]],
+ names: dict[str, str],
+ bundle: Bundle,
+ *,
+ tip_xy: tuple[float, float],
+ global0: int,
+ nn_floor: float,
+ min_delta: int,
+) -> dict[str, Any] | None:
+ """Try full→minimized expands toward tip_xy; keep first non-increasing best.
+
+ Returns candidate dict with placements, or None if every expand raises
+ crossings / invades.
+ """
+ members = [n for n in bundle.member_ids if n in pos]
+ mem_set = set(members)
+ if len(members) < 2:
+ return None
+
+ path = [n for n in (bundle.path or bundle.member_ids) if n in pos or n == bundle.anchor_id]
+ if not path:
+ path = list(members)
+
+ anchor = bundle.anchor_id
+ if anchor and anchor in pos:
+ ox, oy = pos[anchor]
+ elif bundle.base_ids:
+ bpts = [pos[n] for n in bundle.base_ids if n in pos]
+ if not bpts:
+ c0 = _centroid(pos, members)
+ if not c0:
+ return None
+ ox, oy = c0
+ else:
+ ox = sum(p[0] for p in bpts) / len(bpts)
+ oy = sum(p[1] for p in bpts) / len(bpts)
+ else:
+ # No fixed anchor: use opposite end of path as soft origin (old tip side).
+ fixed = [n for n in path if n not in mem_set and n in pos]
+ if fixed:
+ ox, oy = pos[fixed[0]]
+ else:
+ c0 = _centroid(pos, members)
+ if not c0:
+ return None
+ ox, oy = c0
+
+ tx, ty = tip_xy
+ dx, dy = tx - ox, ty - oy
+ dist = math.hypot(dx, dy)
+ if dist < 60.0:
+ return None
+ ux, uy = dx / dist, dy / dist
+ base_step = _mean_step(path if len(path) >= 2 else members, pos)
+ n_mobile = len(members)
+
+ for scale in _EXPAND_SCALES:
+ step = max(_MIN_STEP, base_step * float(scale))
+ # Prefer tip-pinned chord pack: interiors on origin→tip.
+ # Minimized scales shorten tip reach so the footprint fits clearance.
+ max_reach = step * max(1, n_mobile)
+ reach = min(dist, max_reach) if scale < 0.99 else dist
+ tip_use = (ox + ux * reach, oy + uy * reach)
+ placed = _expand_path_placements(
+ path,
+ origin=(ox, oy),
+ ux=ux,
+ uy=uy,
+ step=step,
+ mobile=mem_set,
+ tip_xy=tip_use,
+ pin_tip=True,
+ )
+ if len(placed) < len(mem_set):
+ # fallback: step-only for missing
+ for n in members:
+ if n not in placed:
+ continue
+ if not placed:
+ continue
+ trial = dict(pos)
+ trial.update(placed)
+ # _bundle_ok already rejects outsider footprint invasion + nn_floor.
+ if not _bundle_ok(mem_set, trial, names, nn_floor):
+ continue
+ g1 = count_edge_crossings(trial, links)
+ # Hard rule: expand must not raise crossings.
+ if g1 > global0:
+ continue
+ delta = g1 - global0
+ if delta > -max(1, int(min_delta)):
+ continue
+ return {
+ "x": round(tip_use[0], 1),
+ "y": round(tip_use[1], 1),
+ "r": round(math.hypot(tip_use[0] - ox, tip_use[1] - oy), 1),
+ "angle_deg": round(math.degrees(math.atan2(uy, ux)) % 360.0, 1),
+ "crossings": {"global": g1},
+ "delta": {"global": int(delta)},
+ "stretch": round(step / max(base_step, 1.0), 3),
+ "expand_scale": round(float(scale), 3),
+ "step": round(step, 1),
+ "placements": {
+ k: (round(v[0], 1), round(v[1], 1)) for k, v in placed.items()
+ },
+ "origin": [round(ox, 1), round(oy, 1)],
+ }
+ return None
+
+
+def orbit_bundle(
+ state: LayoutState,
+ bundle: Bundle,
+ *,
+ max_jump: float = 4000.0,
+ angle_step: int = 20,
+ cand_cap: int = 220,
+ nn_floor: float = 28.0,
+ min_delta: int = 1,
+ y_min: float | None = None,
+ y_max: float | None = None,
+) -> dict[str, Any]:
+ """Sample tip directions; probe minimized expands; keep improving only."""
+ pos = dict(state.positions)
+ names = dict(state.names)
+ links = list(state.links)
+ members = tuple(n for n in bundle.member_ids if n in pos)
+ if len(members) < 2:
+ return {"ok": False, "error": "bundle_too_small", "bundle": bundle.key}
+
+ c0 = _centroid(pos, members)
+ if c0 is None:
+ return {"ok": False, "error": "no_centroid", "bundle": bundle.key}
+ cx, cy = c0
+ jump = max(200.0, min(float(max_jump), _MAX_JUMP_CAP))
+ global0 = count_edge_crossings(pos, links)
+
+ # Tip seed samples around current tip / centroid.
+ tip0 = pos.get(bundle.tip_id, (cx, cy))
+ ext_nbs: set[str] = set()
+ mem_set = set(members)
+ for n in members:
+ for v in state.adj.get(n, ()):
+ if v not in mem_set and v in pos:
+ ext_nbs.add(v)
+ pseudo = f"__bundle__{bundle.tip_id}"
+ c_adj = {pseudo: set(ext_nbs)}
+ for v in ext_nbs:
+ c_adj.setdefault(v, set()).add(pseudo)
+ c_pos = {pseudo: tip0, **{v: pos[v] for v in ext_nbs}}
+
+ samples: list[tuple[float, float]] = []
+ for sx, sy, _r, _ang in _far_field_guides(c_pos, pseudo, c_adj, jump):
+ samples.append((sx, sy))
+ for sx, sy, _r, _ang in _polar_grid(
+ tip0[0], tip0[1], jump=jump, angle_step=max(12, int(angle_step))
+ ):
+ samples.append((sx, sy))
+ samples.append((cx, cy))
+ if bundle.base_ids:
+ base_pts = [pos[n] for n in bundle.base_ids if n in pos]
+ if base_pts:
+ bx = sum(p[0] for p in base_pts) / len(base_pts)
+ by = sum(p[1] for p in base_pts) / len(base_pts)
+ dx, dy = tip0[0] - bx, tip0[1] - by
+ L = math.hypot(dx, dy) or 1.0
+ ux, uy = dx / L, dy / L
+ for dist in (0.35 * jump, 0.6 * jump, 0.9 * jump):
+ samples.append((tip0[0] + ux * dist, tip0[1] + uy * dist))
+ samples.append((tip0[0] - ux * dist, tip0[1] - uy * dist))
+
+ seen: set[tuple[int, int]] = set()
+ scored: list[dict[str, Any]] = []
+ tip_cap = min(int(cand_cap), _TIP_SAMPLE_CAP)
+ for sx, sy in samples:
+ if y_min is not None and sy < y_min:
+ continue
+ if y_max is not None and sy > y_max:
+ continue
+ key = (int(round(sx / 12.0) * 12), int(round(sy / 12.0) * 12))
+ if key in seen:
+ continue
+ seen.add(key)
+ if len(seen) > tip_cap:
+ break
+ if math.hypot(sx - tip0[0], sy - tip0[1]) < 40.0 and math.hypot(
+ sx - cx, sy - cy
+ ) < 40.0:
+ continue
+ probed = _probe_expand(
+ pos,
+ links,
+ names,
+ bundle,
+ tip_xy=(sx, sy),
+ global0=global0,
+ nn_floor=nn_floor,
+ min_delta=min_delta,
+ )
+ if probed is None:
+ continue
+ if int(probed["delta"]["global"]) >= 0:
+ continue
+ scored.append(probed)
+ # Enough improving tips — rank later.
+ if len(scored) >= 8:
+ break
+
+ scored.sort(
+ key=lambda c: (
+ int(c["delta"]["global"]),
+ float(c.get("expand_scale") or 1.0),
+ float(c.get("r") or 0.0),
+ )
+ )
+ improving = [
+ c
+ for c in scored
+ if int(c["delta"]["global"]) <= -max(1, int(min_delta))
+ ]
+ top = improving[:5]
+ for i, c in enumerate(top, start=1):
+ c["rank"] = i
+ return {
+ "ok": True,
+ "bundle": bundle.key,
+ "kind": bundle.kind,
+ "tip_id": bundle.tip_id,
+ "tip_name": names.get(bundle.tip_id, bundle.tip_id),
+ "member_n": len(members),
+ "member_ids": list(members)[:40],
+ "base_ids": list(bundle.base_ids)[:12],
+ "anchor_id": bundle.anchor_id,
+ "centroid0": [round(cx, 1), round(cy, 1)],
+ "crossings_before": global0,
+ "candidates": top,
+ "improving_n": len(improving),
+ "sampled": len(seen),
+ "max_jump": jump,
+ "expand_mode": "minimize_probe",
+ }
+
+
+def apply_bundle_pick(
+ state: LayoutState,
+ bundle: Bundle,
+ sweep: dict[str, Any],
+ *,
+ pick: int = 1,
+) -> OpResult:
+ pos = dict(state.positions)
+ cands = list(sweep.get("candidates") or [])
+ if not cands:
+ return OpResult(
+ state=state,
+ moved=set(),
+ op="bundle_orbit",
+ params={"error": "no_candidates", "bundle": bundle.key},
+ note="bundle_orbit:no_candidates",
+ )
+ idx = max(1, min(len(cands), int(pick))) - 1
+ cand = cands[idx]
+ placements = cand.get("placements") or {}
+ if not placements:
+ return OpResult(
+ state=state,
+ moved=set(),
+ op="bundle_orbit",
+ params={"error": "no_placements", "bundle": bundle.key},
+ note="bundle_orbit:no_placements",
+ )
+
+ # Safety: re-score expand; refuse if crossings rose (stale / race).
+ trial = dict(pos)
+ moved: set[str] = set()
+ for nid, xy in placements.items():
+ if nid not in trial:
+ continue
+ trial[nid] = (float(xy[0]), float(xy[1]))
+ moved.add(nid)
+ g0 = count_edge_crossings(pos, state.links)
+ g1 = count_edge_crossings(trial, state.links)
+ if g1 > g0:
+ return OpResult(
+ state=state,
+ moved=set(),
+ op="bundle_orbit",
+ params={
+ "error": "expand_raises_crossings",
+ "bundle": bundle.key,
+ "start_crossings": g0,
+ "end_crossings": g1,
+ "hint": "Expand probe failed gate; try smaller scale / other tip.",
+ },
+ note="bundle_orbit:expand_raises_crossings",
+ )
+
+ # Minimize-expand must not leave footprint overlaps (apply gate).
+ from netx_topology_mcp.layout_ops.orbit_sweep import _has_any_footprint_overlap
+
+ if _has_any_footprint_overlap(trial, state.names):
+ return OpResult(
+ state=state,
+ moved=set(),
+ op="bundle_orbit",
+ params={
+ "error": "expand_invades_space",
+ "bundle": bundle.key,
+ "start_crossings": g0,
+ "end_crossings": g1,
+ "expand_scale": cand.get("expand_scale"),
+ "hint": "Space too tight; tip/placement wrong — probe smaller scale or other tip.",
+ },
+ note="bundle_orbit:expand_invades_space",
+ )
+
+ st = state.copy()
+ st.positions = trial
+ st.last_moved = moved
+ meta = {
+ "mode": "bundle_orbit",
+ "kind": bundle.kind,
+ "bundle": bundle.key,
+ "tip_id": bundle.tip_id,
+ "pick": idx + 1,
+ "member_n": len(moved),
+ "delta": int(g1 - g0),
+ "crossings": int(g1),
+ "expand_scale": cand.get("expand_scale"),
+ "step": cand.get("step"),
+ "expand_mode": "minimize_probe",
+ }
+ st.meta["bundle_orbit"] = meta
+ return OpResult(
+ state=st,
+ moved=moved,
+ op="bundle_orbit",
+ params=meta,
+ note=(
+ f"bundle_orbit {bundle.kind} tip={bundle.tip_id} "
+ f"n={len(moved)} scale={cand.get('expand_scale')} Δ={meta['delta']}"
+ ),
+ )
+
+
+def rank_bundles_by_hotspots(
+ bundles: list[Bundle],
+ pos: dict[str, tuple[float, float]],
+ links: list[tuple[str, str]],
+ names: dict[str, str],
+ *,
+ top_n: int = 12,
+) -> list[Bundle]:
+ """Prefer bundles whose tip / members participate in crossings."""
+ if not bundles:
+ return []
+ from netx_topology_mcp.layout_metrics import crossing_participation
+
+ _n, node_hit = crossing_participation(pos, links)[:2]
+ hot = {
+ str(r["fabric_node_id"]): int(r.get("crossing_hits") or 0)
+ for r in top_crossing_nodes(
+ pos, links, names=names, top_n=40, participation=node_hit
+ )
+ }
+
+ def score(b: Bundle) -> tuple[int, int, int]:
+ tip_h = int(node_hit.get(b.tip_id) or hot.get(b.tip_id) or 0)
+ mem_h = sum(int(node_hit.get(n) or 0) for n in b.member_ids)
+ return (tip_h + mem_h, len(b.member_ids), 1 if b.kind == "ring_chain" else 0)
+
+ ranked = sorted(bundles, key=score, reverse=True)
+ return ranked[: max(1, int(top_n))]
+
+
+def bundle_orbit_until_progress(
+ state: LayoutState,
+ *,
+ frozen_ids: set[str] | None = None,
+ max_jump: float = 5000.0,
+ max_bundles: int = 10,
+ min_delta: int = 1,
+ cand_cap: int = 200,
+ angle_step: int = 20,
+ nn_floor: float = 28.0,
+ params: LayoutParams | None = None,
+) -> OpResult:
+ """Try hotspot-ranked bundles until one expand-gated move applies."""
+ del params
+ st = state.copy()
+ frozen = set(frozen_ids or ())
+ bundles = detect_bundles(st, frozen=frozen)
+ ranked = rank_bundles_by_hotspots(
+ bundles, st.positions, st.links, st.names, top_n=max_bundles
+ )
+ global0 = count_edge_crossings(st.positions, st.links)
+ tried: list[dict[str, Any]] = []
+ for b in ranked:
+ if any(n in frozen for n in b.member_ids):
+ continue
+ sweep = orbit_bundle(
+ st,
+ b,
+ max_jump=max_jump,
+ angle_step=angle_step,
+ cand_cap=cand_cap,
+ nn_floor=nn_floor,
+ min_delta=min_delta,
+ )
+ tried.append(
+ {
+ "bundle": b.key,
+ "kind": b.kind,
+ "improving_n": int(sweep.get("improving_n") or 0),
+ }
+ )
+ if int(sweep.get("improving_n") or 0) <= 0:
+ continue
+ op = apply_bundle_pick(st, b, sweep, pick=1)
+ if not op.moved:
+ tried[-1]["apply_error"] = (op.params or {}).get("error")
+ continue
+ end_g = count_edge_crossings(op.state.positions, op.state.links)
+ if end_g > global0:
+ # Should be unreachable due to apply gate; skip defensively.
+ continue
+ meta = {
+ **(op.params or {}),
+ "start_crossings": global0,
+ "end_crossings": end_g,
+ "tried": tried[:20],
+ "bundle_n": len(bundles),
+ }
+ op.state.meta["bundle_orbit"] = meta
+ return OpResult(
+ state=op.state,
+ moved=op.moved,
+ op="bundle_orbit",
+ params=meta,
+ note=op.note,
+ )
+
+ return OpResult(
+ state=st,
+ moved=set(),
+ op="bundle_orbit",
+ params={
+ "mode": "bundle_orbit",
+ "start_crossings": global0,
+ "end_crossings": global0,
+ "delta": 0,
+ "tried": tried[:20],
+ "bundle_n": len(bundles),
+ "stop_reason": "no_candidates",
+ "expand_mode": "minimize_probe",
+ },
+ note="bundle_orbit:no_candidates",
+ )
diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/clear_edge_hits.py b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/clear_edge_hits.py
index 8a83932..776de92 100644
--- a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/clear_edge_hits.py
+++ b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/clear_edge_hits.py
@@ -38,6 +38,13 @@ def clear_edge_params_from_overrides(params: dict[str, Any] | None) -> dict[str,
out["pitch"] = float(p["pitch"])
if p.get("side") is not None:
out["side"] = float(p["side"])
+ portals = p.get("portal_ids") or p.get("portals") or []
+ if isinstance(portals, str):
+ portals = [portals]
+ frozen = set(str(x) for x in (p.get("frozen_ids") or []) if str(x).strip())
+ frozen |= {str(x) for x in portals if str(x).strip()}
+ out["frozen_ids"] = frozen
+ out["max_eject_degree"] = int(p.get("max_eject_degree") or 5)
return out
@@ -228,11 +235,15 @@ def clear_edge_hits(
pitch: float | None = None,
side: float | None = None,
rounds: int = 1,
+ frozen_ids: set[str] | None = None,
+ max_eject_degree: int = 5,
) -> OpResult:
"""Move nodes off non-incident edges; gate on crossings + overlaps.
``preserve_axis=True``: snap to pitch/side grid, keep incident H/V count,
multi-round until no progress — use after ``ortho_metro``.
+ ``frozen_ids``: never eject these (eye portals).
+ ``max_eject_degree``: skip hubs at/above this degree (default 5).
"""
del params
st = state.copy()
@@ -240,6 +251,8 @@ def clear_edge_hits(
names = st.names
links = list(st.links)
adj = st.adj
+ frozen = {str(x) for x in (frozen_ids or ()) if str(x).strip()}
+ max_eject_degree = max(2, int(max_eject_degree))
pitch_f = float(pitch if pitch is not None else REC_CENTER_DX)
side_f = float(side if side is not None else REC_CENTER_DY)
rounds_n = max(1, int(rounds))
@@ -272,8 +285,8 @@ def clear_edge_hits(
target = float(thr) + float(margin)
x0 = count_edge_crossings(pos, links)
ax0 = _global_axis_score(pos, links)
- # Clearance matters, but keep metro readable — modest crossing slack only.
- x_slack = 4 if preserve_axis else 0
+ # Never allow crossing rise — even with preserve_axis (metro snap).
+ x_slack = 0
deg = {n: len(adj.get(n) or ()) for n in pos}
def _move_budget(nid: str) -> float:
@@ -308,6 +321,8 @@ def clear_edge_hits(
nid = str(h["fabric_node_id"])
if nid not in pos:
continue
+ if nid in frozen:
+ continue
a = str(h["a_node_id"])
b = str(h["b_node_id"])
if a not in pos or b not in pos:
@@ -315,8 +330,8 @@ def clear_edge_hits(
p0 = pos[nid]
if not _on_open_segment(p0, pos[a], pos[b], thr=thr) and float(h.get("dist") or 99) >= thr:
continue
- # Very high-degree hub on a chord: break the chord instead of ejecting hub.
- if deg.get(nid, 0) >= 5:
+ # High-degree hubs on a chord: skip unless caller raises cap.
+ if deg.get(nid, 0) >= int(max_eject_degree):
continue
if preserve_axis:
nbr_xy = [
diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/compact_bbox.py b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/compact_bbox.py
new file mode 100644
index 0000000..1482515
--- /dev/null
+++ b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/compact_bbox.py
@@ -0,0 +1,233 @@
+"""Uniform bbox shrink toward eye portals — eye-safe compactness.
+
+Hard gates (must all pass to accept a scale):
+- global crossings must not rise
+- footprint overlaps must stay zero
+- frozen portal_ids stay put
+
+Optional soft: prefer scales that do not worsen edge_clearance hits.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from netx_topology_mcp.layout_metrics import (
+ count_edge_crossings,
+ compute_edge_clearance,
+)
+from netx_topology_mcp.layout_ops.graph_util import bbox
+from netx_topology_mcp.layout_ops.orbit_sweep import _has_any_footprint_overlap
+from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult
+
+
+def _anchor(
+ pos: dict[str, tuple[float, float]],
+ frozen: set[str],
+) -> tuple[float, float]:
+ pts = [pos[n] for n in frozen if n in pos]
+ if len(pts) >= 1:
+ return (
+ sum(p[0] for p in pts) / len(pts),
+ sum(p[1] for p in pts) / len(pts),
+ )
+ if not pos:
+ return (0.0, 0.0)
+ xs = [p[0] for p in pos.values()]
+ ys = [p[1] for p in pos.values()]
+ return ((min(xs) + max(xs)) / 2.0, (min(ys) + max(ys)) / 2.0)
+
+
+def _area(pos: dict[str, tuple[float, float]]) -> float:
+ if len(pos) < 2:
+ return 1.0
+ x0, y0, x1, y1 = bbox(pos)
+ return max((x1 - x0) * (y1 - y0), 1.0)
+
+
+def _clr_hits(pos: dict[str, tuple[float, float]], links, names) -> int:
+ ec = compute_edge_clearance(pos, links, names=names, top_n=1)
+ if ec.get("edge_clearance_skipped"):
+ return 10**9
+ return int(ec.get("edge_clearance_hits") or 0)
+
+
+def compact_bbox(
+ state: LayoutState,
+ params: LayoutParams | None = None,
+ *,
+ frozen_ids: set[str] | None = None,
+ portal_ids: list[str] | None = None,
+ min_scale: float = 0.72,
+ step: float = 0.03,
+ max_clearance_slack: int = 80,
+ outlier_only: bool = True,
+) -> OpResult:
+ """Probe shrink toward portals; keep best gated layout.
+
+ ``outlier_only``: only move nodes farther than ~median radius from the
+ portal anchor — avoids crushing the already-dense eye core into overlaps.
+ """
+ import math
+
+ del params
+ st = state.copy()
+ pos0 = dict(st.positions)
+ names = st.names
+ links = list(st.links)
+ frozen: set[str] = set(frozen_ids or ())
+ for p in portal_ids or []:
+ if p:
+ frozen.add(str(p))
+ if not pos0:
+ return OpResult(
+ state=st,
+ moved=set(),
+ op="compact_bbox",
+ note="compact_bbox:empty",
+ params={"error": "empty"},
+ )
+
+ g0 = count_edge_crossings(pos0, links)
+ if _has_any_footprint_overlap(pos0, names):
+ return OpResult(
+ state=st,
+ moved=set(),
+ op="compact_bbox",
+ note="compact_bbox:overlaps_before",
+ params={
+ "error": "overlaps_before",
+ "hint": "Refuse shrink while footprints already overlap.",
+ },
+ )
+ clr0 = _clr_hits(pos0, links, names)
+ area0 = _area(pos0)
+ cx, cy = _anchor(pos0, frozen)
+
+ dists = {
+ n: math.hypot(x - cx, y - cy)
+ for n, (x, y) in pos0.items()
+ if n not in frozen
+ }
+ mobile: set[str]
+ if outlier_only and dists:
+ # Farthest-K only: percentile bands often crush mid-ring nodes into overlaps.
+ k = max(16, min(40, len(dists) // 6))
+ mobile = {
+ n
+ for n, _ in sorted(dists.items(), key=lambda kv: kv[1], reverse=True)[:k]
+ }
+ else:
+ mobile = set(dists.keys())
+
+ best_pos = pos0
+ best_meta: dict[str, Any] = {
+ "scale": 1.0,
+ "crossings": g0,
+ "clearance_hits": clr0,
+ "area": round(area0, 1),
+ "accepted": False,
+ }
+ best_key = (g0, area0, clr0)
+
+ scales: list[float] = []
+ s = 1.0 - float(step)
+ lo = max(0.5, float(min_scale))
+ while s >= lo - 1e-9:
+ scales.append(round(s, 4))
+ s -= float(step)
+
+ for scale in scales:
+ trial: dict[str, tuple[float, float]] = {}
+ for nid, (x, y) in pos0.items():
+ if nid in frozen or nid not in mobile:
+ trial[nid] = (x, y)
+ else:
+ trial[nid] = (
+ cx + (x - cx) * scale,
+ cy + (y - cy) * scale,
+ )
+ if _has_any_footprint_overlap(trial, names):
+ continue
+ g1 = count_edge_crossings(trial, links)
+ if g1 > g0:
+ continue
+ clr1 = _clr_hits(trial, links, names)
+ if clr1 > clr0 + max(0, int(max_clearance_slack)):
+ continue
+ area1 = _area(trial)
+ key = (g1, area1, clr1)
+ if key < best_key:
+ best_key = key
+ best_pos = trial
+ best_meta = {
+ "scale": scale,
+ "crossings": g1,
+ "clearance_hits": clr1,
+ "area": round(area1, 1),
+ "accepted": True,
+ }
+
+ moved = {
+ n
+ for n, xy in best_pos.items()
+ if n in pos0
+ and (
+ abs(xy[0] - pos0[n][0]) > 0.05
+ or abs(xy[1] - pos0[n][1]) > 0.05
+ )
+ }
+ st.positions = best_pos
+ st.last_moved = moved
+ meta = {
+ "mode": "compact_bbox",
+ "anchor": [round(cx, 1), round(cy, 1)],
+ "frozen_n": len(frozen),
+ "mobile_n": len(mobile),
+ "outlier_only": bool(outlier_only),
+ "start_crossings": g0,
+ "start_clearance_hits": clr0,
+ "start_area": round(area0, 1),
+ "end_crossings": int(best_meta["crossings"]),
+ "end_clearance_hits": int(best_meta["clearance_hits"]),
+ "end_area": best_meta["area"],
+ "scale": best_meta["scale"],
+ "moved_n": len(moved),
+ "accepted": bool(best_meta["accepted"]),
+ }
+ st.meta["compact_bbox"] = meta
+ return OpResult(
+ state=st,
+ moved=moved,
+ op="compact_bbox",
+ params=meta,
+ note=(
+ f"compact_bbox scale={meta['scale']} "
+ f"Δx={meta['end_crossings'] - g0} "
+ f"clr={clr0}→{meta['end_clearance_hits']} "
+ f"area={meta['start_area']}→{meta['end_area']}"
+ ),
+ )
+
+
+def compact_bbox_params_from_overrides(overrides: dict[str, Any] | None) -> dict[str, Any]:
+ o = overrides or {}
+ portals = o.get("portal_ids") or o.get("portals") or []
+ if isinstance(portals, str):
+ portals = [portals]
+ frozen = o.get("frozen_ids") or []
+ if isinstance(frozen, str):
+ frozen = [frozen]
+ outlier = o.get("outlier_only")
+ if outlier is None:
+ outlier_only = True
+ else:
+ outlier_only = str(outlier).strip().lower() not in {"0", "false", "no", "off"}
+ return {
+ "portal_ids": [str(x) for x in portals if str(x).strip()],
+ "frozen_ids": {str(x) for x in frozen if str(x).strip()},
+ "min_scale": float(o.get("min_scale") or 0.72),
+ "step": float(o.get("step") or 0.03),
+ "max_clearance_slack": int(o.get("max_clearance_slack") or 80),
+ "outlier_only": outlier_only,
+ }
diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/dual_units.py b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/dual_units.py
index bb212ba..b1bc66a 100644
--- a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/dual_units.py
+++ b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/dual_units.py
@@ -1,9 +1,13 @@
-"""Dual-portal basic units: parallel lanes + straight/回 chains + tails.
+"""Dual-portal basic units: ellipse petal arcs + straight chains + tails.
A unit = two portals + ≥2 interior-disjoint corridors (+ optional deg≤2
tails), or a long chain between portals. Units may share portals.
-Beautify targets zero edge crossings: multi-corridor → parallel H/V lanes;
-chains (any length) → straight; tails as straight spurs. No 回字 fold.
+Detection walks eyes **top→down** (core–core → core–agg → agg–agg) and
+greedily maximizes membership.
+
+**Objective**: minimize crossings while keeping spread; **keep the eye
+interior hollow** (portals only on the chord; AN/corridors on arcs; avoid
+mid stacking / overlaps). Residual mesh chords OK; do not polish-fix the eye.
"""
from __future__ import annotations
@@ -102,12 +106,136 @@ def _collect_tails(
return tails
+def _unit_from_hub_paths(
+ state: LayoutState,
+ a: str,
+ b: str,
+ paths: list[list[str]],
+) -> DualUnit:
+ names = state.names
+ pa, pb = a, b
+ out_paths = [list(p) for p in paths]
+ if names.get(pa, pa) > names.get(pb, pb):
+ pa, pb = pb, pa
+ out_paths = [list(reversed(p)) for p in out_paths]
+ core = {pa, pb}
+ for p in out_paths:
+ core.update(p)
+ return DualUnit(
+ portal_a=pa,
+ portal_b=pb,
+ paths=out_paths,
+ tails=_collect_tails(state, core),
+ )
+
+
+def _interior_of(unit: DualUnit) -> set[str]:
+ interior: set[str] = set()
+ for p in unit.paths:
+ interior |= set(p[1:-1])
+ return interior
+
+
+def _greedy_take_by_size(
+ candidates: list[DualUnit],
+ *,
+ used_interior: set[str],
+ max_units: int,
+ already: list[DualUnit],
+) -> None:
+ """Claim largest-membership units first (CN eyes should swallow most NEs)."""
+ ranked = sorted(candidates, key=lambda u: (-len(u.member_ids()), u.portal_a, u.portal_b))
+ for u in ranked:
+ if len(already) >= max_units:
+ return
+ interior = _interior_of(u)
+ if not interior or interior & used_interior:
+ continue
+ if any({x.portal_a, x.portal_b} == {u.portal_a, u.portal_b} for x in already):
+ continue
+ used_interior |= interior
+ already.append(u)
+
+
+def _portal_eye_tier(layer: str | None) -> int:
+ """Lower = higher in fabric (eyes walk top→down)."""
+ ly = (layer or "").strip().lower()
+ if ly == "core":
+ return 0
+ if ly == "agg":
+ return 1
+ if ly == "access":
+ return 2
+ return 3
+
+
+def _eye_portal_key(
+ portal_a: str,
+ portal_b: str,
+ layers: dict[str, str],
+) -> tuple[int, int]:
+ """Sorted portal tiers: (0,0)=core–core, (0,1)=core–agg, (1,1)=agg–agg…"""
+ t = sorted(
+ (
+ _portal_eye_tier(layers.get(portal_a)),
+ _portal_eye_tier(layers.get(portal_b)),
+ )
+ )
+ return (t[0], t[1])
+
+
+def _hub_pair_candidates(
+ state: LayoutState,
+ hubs_a: list[str],
+ hubs_b: list[str],
+ *,
+ core_set: set[str],
+ allow_same: bool,
+) -> list[DualUnit]:
+ """Build dual units for hub pairs; when allow_same, iterate i list[DualUnit]:
- """Detect dual-portal eye units; interiors exclusive, portals may overlap."""
+ """Detect dual-portal eye units; interiors exclusive, portals may overlap.
+
+ Eyes walk **top→down**: core–core → core–agg → agg–agg, each tier
+ greedily maximizing membership; access rings fill leftovers.
+ Crossings are not a detection gate.
+ """
adj, names, layers = state.adj, state.names, state.layers
ens = [n for n, ly in layers.items() if ly == "access" and n in adj]
an_set = {n for n, ly in layers.items() if ly == "agg"}
@@ -116,68 +244,58 @@ def find_dual_portal_units(
units: list[DualUnit] = []
used_interior: set[str] = set()
- # 1) Access/AN two-portal ring groups (sugiyama metro).
- if ens:
- groups = _find_two_portal_ring_groups(ens, adj, names, an_set)
- for g in groups:
- a, b = g["portals"] # type: ignore[misc]
- paths: list[list[str]] = list(g["paths"]) # type: ignore[arg-type]
- interior: set[str] = set()
- for p in paths:
- interior |= set(p[1:-1])
- if interior & used_interior:
- continue
- used_interior |= interior
- core = {a, b} | interior
- for p in paths:
- core.update(p)
- tails = _collect_tails(state, core)
- units.append(
- DualUnit(portal_a=a, portal_b=b, paths=paths, tails=tails)
- )
- if len(units) >= max_units:
- break
-
- # 2) Agg/core hub pairs with ≥2 corridor covers (fills CN—AN / CN—CN).
- hubs = sorted(
- [n for n in (an_set | core_set) if n in adj],
+ cores = sorted(
+ [n for n in core_set if n in adj],
key=lambda n: (-len(adj.get(n, ())), names.get(n, n)),
)
- for i, a in enumerate(hubs):
- if len(units) >= max_units:
- break
- for b in hubs[i + 1 :]:
- if len(units) >= max_units:
- break
- # Only forbid other cores — when almost all NEs are layer=agg,
- # banning every agg hub makes cover_hub_paths return 0 corridors.
- forbid = core_set - {a, b}
- paths = cover_hub_paths(a, b, adj, names, forbid=forbid)
- if len(paths) < 2:
+ aggs = sorted(
+ [n for n in an_set if n in adj],
+ key=lambda n: (-len(adj.get(n, ())), names.get(n, n)),
+ )
+
+ # 1) Core–core
+ _greedy_take_by_size(
+ _hub_pair_candidates(
+ state, cores, cores, core_set=core_set, allow_same=True
+ ),
+ used_interior=used_interior,
+ max_units=max_units,
+ already=units,
+ )
+ # 2) Core–agg
+ if len(units) < max_units and cores and aggs:
+ _greedy_take_by_size(
+ _hub_pair_candidates(
+ state, cores, aggs, core_set=core_set, allow_same=False
+ ),
+ used_interior=used_interior,
+ max_units=max_units,
+ already=units,
+ )
+ # 3) Agg–agg
+ if len(units) < max_units and len(aggs) >= 2:
+ _greedy_take_by_size(
+ _hub_pair_candidates(
+ state, aggs, aggs, core_set=core_set, allow_same=True
+ ),
+ used_interior=used_interior,
+ max_units=max_units,
+ already=units,
+ )
+
+ # 4) Access/AN metro rings (bottom leftovers).
+ if ens and len(units) < max_units:
+ groups = _find_two_portal_ring_groups(ens, adj, names, an_set)
+ an_cands: list[DualUnit] = []
+ for g in groups:
+ a, b = g["portals"] # type: ignore[misc]
+ paths = list(g["paths"]) # type: ignore[arg-type]
+ if any({u.portal_a, u.portal_b} == {a, b} for u in units):
continue
- interior = set()
- for p in paths:
- interior |= set(p[1:-1])
- if not interior or interior & used_interior:
- continue
- # Skip if this pair already covered as a unit
- if any(
- {u.portal_a, u.portal_b} == {a, b} for u in units
- ):
- continue
- used_interior |= interior
- core = {a, b} | interior
- for p in paths:
- core.update(p)
- tails = _collect_tails(state, core)
- # Stable left/right by name
- pa, pb = a, b
- if names.get(pa, pa) > names.get(pb, pb):
- pa, pb = pb, pa
- paths = [list(reversed(p)) for p in paths]
- units.append(
- DualUnit(portal_a=pa, portal_b=pb, paths=paths, tails=tails)
- )
+ an_cands.append(_unit_from_hub_paths(state, a, b, paths))
+ _greedy_take_by_size(
+ an_cands, used_interior=used_interior, max_units=max_units, already=units
+ )
for i, u in enumerate(units):
u.unit_id = i
@@ -199,6 +317,71 @@ def _normalize_paths(
return paths
+def _path_first_hop(path: list[str]) -> str:
+ return path[1] if len(path) > 2 else ""
+
+
+def _path_last_hop(path: list[str]) -> str:
+ return path[-2] if len(path) > 2 else ""
+
+
+def _order_paths_for_nest(
+ paths: list[list[str]],
+ names: dict[str, str],
+) -> list[list[str]]:
+ """Order corridors to cut spine crossings while nesting short→inner.
+
+ Barycenter-align first/last hops across portals, then split ±y so
+ same-side bands stay nested by length (distribution kept).
+ """
+ if len(paths) <= 1:
+ return list(paths)
+
+ def hop_key(nid: str) -> str:
+ return names.get(nid, nid)
+
+ order = sorted(
+ paths,
+ key=lambda p: (
+ hop_key(_path_first_hop(p)),
+ len(p),
+ hop_key(_path_last_hop(p)),
+ ),
+ )
+ for _ in range(5):
+ idx = {id(p): i for i, p in enumerate(order)}
+ a_pos: dict[str, list[float]] = {}
+ b_pos: dict[str, list[float]] = {}
+ for p in order:
+ a_pos.setdefault(_path_first_hop(p), []).append(float(idx[id(p)]))
+ b_pos.setdefault(_path_last_hop(p), []).append(float(idx[id(p)]))
+ a_rank = {s: sum(vs) / len(vs) for s, vs in a_pos.items()}
+ b_rank = {s: sum(vs) / len(vs) for s, vs in b_pos.items()}
+ order = sorted(
+ order,
+ key=lambda p: (
+ 0.55 * a_rank.get(_path_first_hop(p), 0.0)
+ + 0.45 * b_rank.get(_path_last_hop(p), 0.0),
+ len(p),
+ hop_key(_path_first_hop(p)),
+ ),
+ )
+
+ upper: list[list[str]] = []
+ lower: list[list[str]] = []
+ for i, p in enumerate(order):
+ (upper if i % 2 == 0 else lower).append(p)
+ upper.sort(key=len)
+ lower.sort(key=len)
+ out: list[list[str]] = []
+ for i in range(max(len(upper), len(lower))):
+ if i < len(upper):
+ out.append(upper[i])
+ if i < len(lower):
+ out.append(lower[i])
+ return out
+
+
def classify_dual_unit(unit: DualUnit) -> str:
"""petal = multi-corridor (parallel lanes); else straight (no 回字)."""
paths = _normalize_paths(unit)
@@ -226,49 +409,169 @@ def _place_chain_straight(
pos[nid] = (ox + ux * pitch * (i + 1), oy + uy * pitch * (i + 1))
+def _place_petal_bands(
+ paths: list[list[str]],
+ *,
+ a: str,
+ b: str,
+ rx: float,
+ ry_step: float,
+ chord_gap: float = 160.0,
+) -> dict[str, tuple[float, float]]:
+ """Nested half-ellipse bands; chord = portals only (hollow eye interior).
+
+ Short corridors inner, longer outer. Shared nodes claimed by first path.
+ Soft apex offset so single-mid corridors do not stack on the mid vertical.
+ ``chord_gap`` kept for call-site compat (unused).
+ """
+ del chord_gap
+ pos: dict[str, tuple[float, float]] = {a: (-rx, 0.0), b: (rx, 0.0)}
+
+ band_i = 0
+ for p in paths:
+ mid = p[1:-1]
+ if not mid:
+ continue
+ side = 1 if band_i % 2 == 0 else -1
+ nest = band_i // 2
+ ry = ry_step * (nest + 1)
+ band_i += 1
+ m = len(p)
+
+ # One mid (CN–AN–CN): park on left/right lobe — keep eye interior open.
+ if len(mid) == 1:
+ n = mid[0]
+ if n not in pos:
+ lobe = 1.0 if nest % 2 == 0 else -1.0
+ ang = side * (math.pi / 2.0 + lobe * 0.45)
+ pos[n] = (rx * math.cos(ang), ry * math.sin(ang))
+ continue
+
+ for j, n in enumerate(p):
+ if n in (a, b):
+ continue
+ if n in pos:
+ continue
+ t = j / (m - 1) if m > 1 else 0.5
+ ang = math.pi * (1.0 - t)
+ if side < 0:
+ ang = -ang
+ x = rx * math.cos(ang)
+ y = ry * math.sin(ang)
+ # Soft hollow: do not sit on the exact mid vertical (visual spine).
+ if abs(x) < rx * 0.08:
+ x = math.copysign(rx * 0.08, x if abs(x) > 1e-9 else float(side))
+ # Keep roughly on the ellipse by scaling y down slightly.
+ y = y * 0.98
+ pos[n] = (x, y)
+ return pos
+
+
+def _unit_member_links(
+ paths: list[list[str]],
+ a: str,
+ b: str,
+ state: LayoutState,
+) -> list[tuple[str, str]]:
+ members = {a, b}
+ for p in paths:
+ members.update(p)
+ return [e for e in state.links if e[0] in members and e[1] in members]
+
+
+def _best_path_order_for_crossings(
+ paths: list[list[str]],
+ *,
+ a: str,
+ b: str,
+ rx: float,
+ ry_step: float,
+ state: LayoutState,
+ names: dict[str, str],
+ chord_gap: float = 160.0,
+) -> list[list[str]]:
+ """Pick nest order: barycenter seed + adjacent same-side swaps to cut x."""
+ base = _order_paths_for_nest(paths, names)
+ if len(base) <= 2:
+ return base
+
+ links = _unit_member_links(base, a, b, state)
+
+ def score(order: list[list[str]]) -> int:
+ pos = _place_petal_bands(
+ order, a=a, b=b, rx=rx, ry_step=ry_step, chord_gap=chord_gap
+ )
+ return count_edge_crossings(pos, links)
+
+ best = list(base)
+ best_x = score(best)
+ # Adjacent swaps within the interleaved list (preserves ±y nesting pattern).
+ improved = True
+ rounds = 0
+ while improved and rounds < 24:
+ improved = False
+ rounds += 1
+ for i in range(len(best) - 1):
+ trial = list(best)
+ trial[i], trial[i + 1] = trial[i + 1], trial[i]
+ x = score(trial)
+ if x < best_x:
+ best, best_x = trial, x
+ improved = True
+ break
+ return best
+
+
def beautify_dual_unit_positions(
state: LayoutState,
unit: DualUnit,
params: LayoutParams | None = None,
) -> dict[str, tuple[float, float]]:
- """Beautify one unit: multi-corridor→H/V lanes; chains→straight (no 回字).
+ """Beautify one unit: petal→nested ellipse arcs; chains→outward fans.
- Local coords; portals on x-axis. dual_mass aligns onto world portals.
- Multi-corridor (kind=petal): parallel horizontal lanes with vertical
- stubs at portal x — no ellipse arcs.
+ Goal: fewer crossings, readable spread, **hollow eye interior**, low
+ overlap. Portals on x-axis only; corridors on ± ellipse bands; **long
+ tails park outside the eye** (do not pierce nested rings).
"""
params = params or LayoutParams()
pitch = max(float(params.pitch), 170.0)
- ry = max(float(params.lane), float(params.side), 220.0)
a, b = unit.portal_a, unit.portal_b
paths = _normalize_paths(unit)
kind = classify_dual_unit(unit)
- max_mid = max((len(p) - 2 for p in paths), default=0)
- half = max(pitch * (max_mid + 1) * 0.5, pitch * 4.0, 700.0)
- pos: dict[str, tuple[float, float]] = {a: (-half, 0.0), b: (half, 0.0)}
if kind == "petal":
- # Parallel H/V lanes: first/last mid share portal x → V stub + H spine.
- band_i = 0
- for p in paths:
- mid = p[1:-1]
- if not mid:
- continue
- side = 1 if band_i % 2 == 0 else -1
- amp = ry * (0.85 + 0.35 * (band_i // 2))
- band_i += 1
- n_mid = len(mid)
- for k, n in enumerate(mid):
- if n in pos:
- continue
- if n_mid == 1:
- pos[n] = (0.0, side * amp)
- else:
- t = k / (n_mid - 1)
- x = -half + 2.0 * half * t
- pos[n] = (x, side * amp)
+ max_mid = max((len(p) - 2 for p in paths), default=0)
+ rx = max(
+ float(params.an_gap) * 2.5,
+ pitch * max(7.0, float(max_mid) + 2.0),
+ 1100.0,
+ )
+ ry_step = max(float(params.side) * 1.8, float(params.lane) * 1.15, pitch * 1.15, 320.0)
+ ordered = _best_path_order_for_crossings(
+ paths,
+ a=a,
+ b=b,
+ rx=rx,
+ ry_step=ry_step,
+ state=state,
+ names=state.names,
+ chord_gap=max(pitch * 0.85, 150.0),
+ )
+ pos = _place_petal_bands(
+ ordered,
+ a=a,
+ b=b,
+ rx=rx,
+ ry_step=ry_step,
+ chord_gap=max(pitch * 0.85, 150.0),
+ )
+ half = rx
+ band_n = sum(1 for p in ordered if p[1:-1])
+ ry_park = ry_step * max(1, (band_n + 1) // 2 + 1)
else:
- # Single corridor / chain body — always straight between portals.
+ max_mid = max((len(p) - 2 for p in paths), default=0)
+ half = max(pitch * (max_mid + 1) * 0.5, pitch * 4.0, 700.0)
+ pos = {a: (-half, 0.0), b: (half, 0.0)}
body: list[str] = []
if paths:
body = list(paths[0][1:-1])
@@ -283,13 +586,54 @@ def beautify_dual_unit_positions(
pitch=max(pitch, (2.0 * half) / (len(body) + 1)),
pos=pos,
)
+ ry_park = max(float(params.lane), float(params.side), 220.0)
- # Tails: always straight H/V spurs (stack parallel if many).
+ # Eye envelope from corridor/portal placement (tails must stay outside).
+ eye_rx = max((abs(xy[0]) for xy in pos.values()), default=half)
+ eye_ry = max((abs(xy[1]) for xy in pos.values()), default=ry_park)
+ eye_rx = max(eye_rx, half)
+ eye_ry = max(eye_ry, ry_park * 0.5, pitch * 2.0)
+
+ def _outside_start(
+ ax: float, ay: float, *, fan_i: int
+ ) -> tuple[float, float, float, float]:
+ """Return (x0,y0, ux,uy) for the first tail node — fully outside the eye."""
+ # Near mid vertical / apex: park on outer top/bottom shelf, grow sideways.
+ if abs(ax) < eye_rx * 0.35:
+ side_y = 1.0 if ay >= 0 else -1.0
+ if abs(ay) < 1e-6:
+ side_y = 1.0 if fan_i % 2 == 0 else -1.0
+ y0 = side_y * (eye_ry + pitch * 1.25)
+ dir_x = 1.0 if ax >= 0 else -1.0
+ if abs(ax) < 1e-6:
+ dir_x = 1.0 if fan_i % 2 == 0 else -1.0
+ # Stagger parallel shelves for many apex tails.
+ y0 += side_y * ((fan_i // 2) * pitch * 0.55)
+ x0 = ax + dir_x * pitch * 0.6
+ return x0, y0, dir_x, 0.0
+
+ # Otherwise: jump outside along outward ray, then continue outward.
+ ang = math.atan2(ay, ax)
+ lat = ((fan_i + 1) // 2) * (1 if fan_i % 2 else -1)
+ ang += lat * 0.18
+ ux, uy = math.cos(ang), math.sin(ang)
+ # Ellipse-ish clearance along this ray.
+ ca, sa = abs(ux), abs(uy)
+ r_hit = eye_rx * eye_ry / max(1e-6, math.hypot(eye_ry * ca, eye_rx * sa))
+ r0 = max(math.hypot(ax, ay), r_hit) + pitch * 1.1
+ return ux * r0, uy * r0, ux, uy
+
+ # Tails: long chains park fully outside the eye (do not pierce rings).
used = set(pos)
- for ti, chain in enumerate(unit.tails):
+ attach_fan: dict[str, int] = {}
+ # Longer first so they claim outer shelves.
+ tail_order = sorted(
+ enumerate(unit.tails),
+ key=lambda it: (-len(it[1] or []), it[0]),
+ )
+ for ti, chain in tail_order:
if not chain:
continue
- # Skip if already placed as body.
fresh = [n for n in chain if n not in used]
if not fresh:
continue
@@ -304,25 +648,92 @@ def beautify_dual_unit_positions(
if attach is None:
attach = a if ti % 2 == 0 else b
ax, ay = pos[attach]
- y_off = (ti % 3 - 1) * pitch * 0.45
+ fan_i = attach_fan.get(attach, 0)
+ attach_fan[attach] = fan_i + 1
+
+ long_chain = len(fresh) >= 3
if attach == a:
- origin = (ax, ay + y_off)
- direc = (-1.0, 0.0)
+ stub_ux, stub_uy = -1.0, 0.35 if fan_i % 2 == 0 else -0.35
elif attach == b:
- origin = (ax, ay + y_off)
- direc = (1.0, 0.0)
+ stub_ux, stub_uy = 1.0, 0.35 if fan_i % 2 == 0 else -0.35
else:
- origin = (ax, ay)
- direc = (0.0, 1.0 if ay >= 0 else -1.0)
- _place_chain_straight(
- fresh, origin=origin, direction=direc, pitch=pitch * 0.85, pos=pos
- )
+ stub_ux, stub_uy = ax, ay
+ if abs(stub_ux) + abs(stub_uy) < 1e-6:
+ stub_ux, stub_uy = (0.0, 1.0 if ti % 2 == 0 else -1.0)
+
+ if long_chain or kind == "petal":
+ # Petal eye: keep all tail nodes outside envelope (even short ones
+ # if they would otherwise climb through bands).
+ x0, y0, ux, uy = _outside_start(ax, ay, fan_i=fan_i)
+ step = pitch * (1.0 if long_chain else 0.9)
+ px, py = -uy, ux
+ for i, n in enumerate(fresh):
+ if n in pos:
+ continue
+ x = x0 + ux * step * i
+ y = y0 + uy * step * i
+ for _ in range(12):
+ # Must stay outside eye box and not stack.
+ outside = abs(x) >= eye_rx * 0.92 or abs(y) >= eye_ry * 0.92
+ hit = any(
+ abs(x - ox) < pitch * 0.4 and abs(y - oy) < pitch * 0.4
+ for ox, oy in pos.values()
+ )
+ if outside and not hit:
+ break
+ if not outside:
+ # Push further out along ray / shelf.
+ x += ux * pitch * 0.5
+ y += uy * pitch * 0.5
+ if abs(ux) < 0.2 and abs(uy) > 0.8:
+ # shelf mode: grow sideways
+ x += (1.0 if ax >= 0 else -1.0) * pitch * 0.5
+ else:
+ x += px * pitch * 0.45
+ y += py * pitch * 0.45
+ pos[n] = (x, y)
+ else:
+ bn = math.hypot(stub_ux, stub_uy) or 1.0
+ ux, uy = stub_ux / bn, stub_uy / bn
+ px, py = -uy, ux
+ lat = ((fan_i + 1) // 2) * (1 if fan_i % 2 else -1)
+ ang = lat * 0.22
+ ux2 = ux * math.cos(ang) + px * math.sin(ang)
+ uy2 = uy * math.cos(ang) + py * math.sin(ang)
+ nrm = math.hypot(ux2, uy2) or 1.0
+ ux2, uy2 = ux2 / nrm, uy2 / nrm
+ step = pitch * 0.95
+ for i, n in enumerate(fresh):
+ if n in pos:
+ continue
+ x = ax + ux2 * step * (i + 1)
+ y = ay + uy2 * step * (i + 1)
+ for _ in range(10):
+ hit = any(
+ abs(x - ox) < pitch * 0.4 and abs(y - oy) < pitch * 0.4
+ for ox, oy in pos.values()
+ )
+ if not hit:
+ break
+ x += px * pitch * 0.45
+ y += py * pitch * 0.45
+ pos[n] = (x, y)
used |= set(pos)
leftovers = [n for n in unit.member_ids() if n not in pos]
- top = max((xy[1] for xy in pos.values()), default=0.0) + ry
- for i, n in enumerate(sorted(leftovers, key=lambda x: state.names.get(x, x))):
- pos[n] = (-half + i * pitch, top)
+ # Outer ellipse parking — keep off portal chord / hollow mid.
+ n_left = len(leftovers)
+ if n_left:
+ sorted_left = sorted(leftovers, key=lambda x: state.names.get(x, x))
+ for i, n in enumerate(sorted_left):
+ t = (i + 0.5) / n_left
+ ang = math.pi * (0.12 + 0.76 * t)
+ if i % 2:
+ ang = -ang
+ pos[n] = (
+ eye_rx * 1.05 * math.cos(ang),
+ max(eye_ry, ry_park) * 1.1 * math.sin(ang),
+ )
return pos
@@ -332,7 +743,7 @@ def layout_dual_unit_positions(
unit: DualUnit,
params: LayoutParams | None = None,
) -> dict[str, tuple[float, float]]:
- """Unit local layout — lanes / 回 / straight beautify (zero-cross target)."""
+ """Unit local layout — ellipse petal / straight beautify."""
return beautify_dual_unit_positions(state, unit, params)
@@ -342,8 +753,9 @@ def _uncross_unit(
pinned: set[str],
*,
max_rounds: int = 80,
+ preserve_side: bool = True,
) -> dict[str, tuple[float, float]]:
- """Greedy: move lower-degree free endpoint vertically to kill crossings."""
+ """Greedy: nudge free endpoints to kill crossings without flipping eye sides."""
from netx_topology_mcp.layout_metrics import segments_properly_intersect
out = dict(pos)
@@ -360,9 +772,9 @@ def _uncross_unit(
bad: list[tuple[int, int]] = []
for i in range(len(segs)):
for j in range(i + 1, len(segs)):
- a, b, pa, pb = segs[i]
+ aa, bb, pa, pb = segs[i]
c, d, pc, pd = segs[j]
- if len({a, b, c, d}) < 4:
+ if len({aa, bb, c, d}) < 4:
continue
if segments_properly_intersect(pa, pb, pc, pd):
bad.append((i, j))
@@ -397,6 +809,8 @@ def _uncross_unit(
480.0,
-480.0,
):
+ if preserve_side and abs(y) > 1e-6 and (y + dy) * y < 0:
+ continue # do not flip across the portal chord
for dx in (0.0, 40.0, -40.0, 80.0, -80.0):
trial = dict(out)
trial[n] = (x + dx, y + dy)
@@ -412,7 +826,6 @@ def _uncross_unit(
break
return out
-
def layout_dual_unit(
state: LayoutState,
params: LayoutParams | None = None,
@@ -421,8 +834,14 @@ def layout_dual_unit(
unit_id: int | None = None,
portal_a: str | None = None,
portal_b: str | None = None,
+ require_zero_cross: bool = False,
) -> OpResult:
- """Layout the (single) dual-portal unit on this canvas; require crossings=0."""
+ """Layout the dual-portal unit; default accepts residual crossings.
+
+ Placement objective: **minimize unit crossings while keeping spread**
+ (nested ellipse bands). Selection still prefers top eyes + max
+ membership. `require_zero_cross=True` restores the old hard gate.
+ """
params = params or LayoutParams()
units = find_dual_portal_units(state) if unit is None else [unit]
if unit_id is not None:
@@ -452,32 +871,30 @@ def layout_dual_unit(
portal_a, portal_b, state.adj, state.names, forbid=set()
)
if len(paths) >= 2:
- pa, pb = portal_a, portal_b
- if state.names.get(pa, pa) > state.names.get(pb, pb):
- pa, pb = pb, pa
- paths = [list(reversed(p)) for p in paths]
- core = {pa, pb}
- for p in paths:
- core.update(p)
- units = [
- DualUnit(
- portal_a=pa,
- portal_b=pb,
- paths=paths,
- tails=_collect_tails(state, core),
- unit_id=0,
- )
- ]
+ units = [_unit_from_hub_paths(state, portal_a, portal_b, paths)]
+ units[0].unit_id = 0
if not units:
- # Whole canvas as one unit attempt: pick best hub pair cover
+ # Whole canvas as one unit: prefer core–core, else densest hub pair by cover.
+ core_hubs = [
+ n
+ for n, ly in state.layers.items()
+ if ly == "core" and n in state.adj
+ ]
hubs = [
n
for n, ly in state.layers.items()
if ly in ("agg", "core") and n in state.adj
]
hubs.sort(key=lambda n: (-len(state.adj.get(n, ())), state.names.get(n, n)))
- if len(hubs) >= 2:
- a, b = hubs[0], hubs[1]
+ core_hubs.sort(key=lambda n: (-len(state.adj.get(n, ())), state.names.get(n, n)))
+ pair_order: list[tuple[str, str]] = []
+ for i, a in enumerate(core_hubs):
+ for b in core_hubs[i + 1 :]:
+ pair_order.append((a, b))
+ if len(hubs) >= 2 and not pair_order:
+ pair_order.append((hubs[0], hubs[1]))
+ best: DualUnit | None = None
+ for a, b in pair_order:
forbid = {
n
for n, ly in state.layers.items()
@@ -488,22 +905,14 @@ def layout_dual_unit(
paths = cover_hub_paths(
a, b, state.adj, state.names, forbid=set()
)
- if len(paths) >= 2:
- if state.names.get(a, a) > state.names.get(b, b):
- a, b = b, a
- paths = [list(reversed(p)) for p in paths]
- core = {a, b}
- for p in paths:
- core.update(p)
- units = [
- DualUnit(
- portal_a=a,
- portal_b=b,
- paths=paths,
- tails=_collect_tails(state, core),
- unit_id=0,
- )
- ]
+ if len(paths) < 2:
+ continue
+ cand = _unit_from_hub_paths(state, a, b, paths)
+ cand.unit_id = 0
+ if best is None or len(cand.member_ids()) > len(best.member_ids()):
+ best = cand
+ if best is not None:
+ units = [best]
if not units:
return OpResult(
state=state,
@@ -513,8 +922,15 @@ def layout_dual_unit(
note="no_dual_unit",
)
- # If multiple units detected on one canvas, layout the largest by node count
- u = max(units, key=lambda x: len(x.member_ids()))
+ def _pick_key(x: DualUnit) -> tuple:
+ # Top-down eye (core→agg→access), then max membership.
+ return (
+ _eye_portal_key(x.portal_a, x.portal_b, state.layers),
+ -len(x.member_ids()),
+ )
+
+ # Prefer top-layer eyes, then max membership (min of tier, -n).
+ u = min(units, key=_pick_key)
pos = layout_dual_unit_positions(state, u, params)
out = state.copy()
for n, xy in pos.items():
@@ -535,21 +951,30 @@ def layout_dual_unit(
members = u.member_ids()
unit_links = [e for e in out.links if e[0] in members and e[1] in members]
- # Uncross before overlap fix (overlap fix often reintroduces crossings).
+ # Light uncross: keep portal side signs so distribution holds.
pinned = {u.portal_a, u.portal_b}
- out.positions = _uncross_unit(out.positions, unit_links, pinned)
+ is_petal = classify_dual_unit(u) == "petal"
+ out.positions = _uncross_unit(
+ out.positions,
+ unit_links,
+ pinned,
+ preserve_side=is_petal,
+ )
x_unit = count_edge_crossings(out.positions, unit_links)
- # Gentle overlap resolve only if still zero-cross; else skip.
+ # Gentle overlap resolve if it does not worsen unit crossings.
stf = out
- if x_unit == 0:
- cand = fix_overlaps_local(out, params).state
+ cand = fix_overlaps_local(out, params).state
+ if u.portal_a in pos:
cand.positions[u.portal_a] = pos[u.portal_a]
+ if u.portal_b in pos:
cand.positions[u.portal_b] = pos[u.portal_b]
- x_after = count_edge_crossings(cand.positions, unit_links)
- if x_after == 0:
- stf = cand
- # else keep pre-overlap geometry
+ for n in pinned:
+ if n in pos:
+ cand.positions[n] = pos[n]
+ x_after = count_edge_crossings(cand.positions, unit_links)
+ if x_after <= x_unit:
+ stf = cand
x_unit = count_edge_crossings(stf.positions, unit_links)
x = count_edge_crossings(stf.positions, stf.links)
@@ -564,8 +989,10 @@ def layout_dual_unit(
"unit": u.as_dict(state.names),
"unit_crossings": x_unit,
"parked": parked,
+ "require_zero_cross": require_zero_cross,
}
- accepted = x_unit == 0
+ # Default: accept any laid-out eye (max coverage). Optional hard gate.
+ accepted = (x_unit == 0) if require_zero_cross else True
return OpResult(
state=stf,
moved=moved,
@@ -576,11 +1003,11 @@ def layout_dual_unit(
"global_crossings": x,
"accepted": accepted,
"parked": parked,
+ "require_zero_cross": require_zero_cross,
+ "zero_cross": x_unit == 0,
},
note=(
- f"layout_dual_unit:paths={len(u.paths)} x=0"
- if accepted
- else f"dual_unit_crossings={x_unit}"
+ f"layout_dual_unit:paths={len(u.paths)} nodes={len(members)} x={x_unit}"
),
)
@@ -603,10 +1030,11 @@ def dual_units_report(
"uncovered_nodes": max(0, graph_n - len(covered)),
"units": [u.as_dict(state.names) for u in units],
"tip": (
- "Dual-portal eye units: ≥2 interior-disjoint corridors between "
- "portals; layout with action=layout_dual_unit (require crossings=0). "
- "Portals may be shared across units; compose merges same node ids. "
- "Leftovers (uncovered_nodes) go to misc unit canvases."
+ "Dual-portal eyes walk top→down (core–core → core–agg → agg–agg), "
+ "then maximize membership. Petal objective = fewer crossings with "
+ "readable spread (ellipse bands; hollow mid; low overlap). "
+ "Residual mesh chords OK; re-run layout_dual_unit if the eye "
+ "breaks — do not polish-straighten."
),
}
@@ -624,4 +1052,6 @@ def dual_unit_params_from_overrides(overrides: dict[str, Any] | None) -> dict[st
v = overrides.get(key)
if v is not None and str(v).strip():
out[key] = str(v).strip()
+ if "require_zero_cross" in overrides:
+ out["require_zero_cross"] = bool(overrides.get("require_zero_cross"))
return out
diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/graph_util.py b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/graph_util.py
index 4e44ee8..0d2a283 100644
--- a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/graph_util.py
+++ b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/graph_util.py
@@ -2,32 +2,17 @@
from __future__ import annotations
-import re
from collections import deque
+from netx_topology_mcp.layout_ops.level_util import infer_layer
-_ROLE_TO_LAYER = {
- "core": "core",
- "cn": "core",
- "aggregation": "agg",
- "aggregate": "agg",
- "agg": "agg",
- "an": "agg",
- "access": "access",
- "en": "access",
- "edge": "access",
-}
-
-
-def infer_layer(name: str, role: str | None = None) -> str:
- """Map inventory role / name token (CN|AN|EN) → core|agg|access|other."""
- r = str(role or "").strip().lower()
- if r in _ROLE_TO_LAYER:
- return _ROLE_TO_LAYER[r]
- m = re.search(r"-(CN|AN|EN)(\d*)-", name or "", re.I)
- if not m:
- return "other"
- return {"CN": "core", "AN": "agg", "EN": "access"}[m.group(1).upper()]
+__all__ = [
+ "infer_layer",
+ "bbox",
+ "connected_components",
+ "order_ans",
+ "chain_order",
+]
def bbox(pos: dict[str, tuple[float, float]]) -> tuple[float, float, float, float]:
@@ -164,9 +149,11 @@ def build_state_from_nodes_edges(
) -> "LayoutState": # noqa: F821
from netx_topology_mcp.layout_ops.state import LayoutState
from netx_topology_mcp.layout_metrics import collapse_links
+ from netx_topology_mcp.layout_ops.level_util import _parse_level
names: dict[str, str] = {}
layers: dict[str, str] = {}
+ levels: dict[str, float] = {}
ids: list[str] = []
for n in nodes:
if not isinstance(n, dict):
@@ -177,7 +164,10 @@ def build_state_from_nodes_edges(
nm = str(n.get("name") or n.get("label") or fid)
ids.append(fid)
names[fid] = nm
- layers[fid] = infer_layer(nm, n.get("role"))
+ layers[fid] = infer_layer(nm, n.get("role"), n.get("level"))
+ lv = _parse_level(n.get("level"))
+ if lv is not None:
+ levels[fid] = float(lv)
adj: dict[str, set[str]] = {i: set() for i in ids}
links = collapse_links(edges)
@@ -203,6 +193,7 @@ def build_state_from_nodes_edges(
positions=positions,
names=names,
layers=layers,
+ levels=levels,
links=[(a, b) for a, b in links if a in adj and b in adj],
adj=adj,
meta={"ids": ids},
diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/level_util.py b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/level_util.py
new file mode 100644
index 0000000..279b792
--- /dev/null
+++ b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/level_util.py
@@ -0,0 +1,209 @@
+"""Map fabric level / role / name → layout layer key; level y-bands."""
+
+from __future__ import annotations
+
+import math
+import re
+from typing import Any
+
+from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult
+
+
+_ROLE_TO_LAYER = {
+ "external": "external",
+ "core": "core",
+ "cn": "core",
+ "aggregation": "agg",
+ "aggregate": "agg",
+ "agg": "agg",
+ "an": "agg",
+ "access": "access",
+ "en": "access",
+ "edge": "access",
+ "cpe": "access",
+}
+
+# Top → bottom on canvas (smaller fabric level sits higher).
+_BAND_ORDER = ("external", "core", "agg", "access", "other")
+
+
+def _parse_level(value: Any) -> float | None:
+ if value is None:
+ return None
+ if isinstance(value, str):
+ s = value.strip()
+ if not s:
+ return None
+ try:
+ value = float(s)
+ except ValueError:
+ return None
+ try:
+ lv = float(value)
+ except (TypeError, ValueError):
+ return None
+ if not math.isfinite(lv):
+ return None
+ return lv
+
+
+def infer_layer(
+ name: str,
+ role: str | None = None,
+ level: float | None = None,
+) -> str:
+ """Map level / role / name token → external|core|agg|access|other."""
+ lv = _parse_level(level)
+ if lv is not None:
+ maj = int(math.floor(lv))
+ if maj <= 0:
+ return "external"
+ if maj == 1:
+ return "core"
+ if maj == 2:
+ return "agg"
+ return "access"
+ r = str(role or "").strip().lower()
+ if r in _ROLE_TO_LAYER:
+ return _ROLE_TO_LAYER[r]
+ m = re.search(r"-(CN|AN|EN)(\d*)-", name or "", re.I)
+ if not m:
+ return "other"
+ return {"CN": "core", "AN": "agg", "EN": "access"}[m.group(1).upper()]
+
+
+def apply_level_bands(
+ state: LayoutState,
+ params: LayoutParams | None = None,
+ *,
+ y0: float = 120.0,
+ band_gap: float = 320.0,
+ pitch: float | None = None,
+ preserve_x: bool = True,
+ layers: tuple[str, ...] | None = None,
+ max_per_row: int = 24,
+ row_gap: float | None = None,
+) -> OpResult:
+ """Snap nodes into horizontal bands by layer (external→…→access).
+
+ Large bands wrap into multiple rows (``max_per_row``) so hundreds of
+ access nodes are not crushed onto one y-line. Within a row, x is either
+ kept (preserve_x) then gently de-overlapped to ``pitch``, or re-spaced
+ by name order.
+ """
+ params = params or LayoutParams()
+ step = float(pitch if pitch is not None else max(params.pitch, 200.0))
+ rgap = float(row_gap if row_gap is not None else max(step * 0.85, 170.0))
+ per_row = max(4, int(max_per_row or 24))
+ order = tuple(layers) if layers else _BAND_ORDER
+ by: dict[str, list[str]] = {ly: [] for ly in order}
+ for nid in state.positions:
+ ly = state.layers.get(nid) or "other"
+ if ly not in by:
+ ly = "other"
+ by.setdefault(ly, [])
+ by[ly].append(nid)
+
+ pos = dict(state.positions)
+ moved: set[str] = set()
+ band_notes: list[dict[str, Any]] = []
+ y_cursor = float(y0)
+
+ def _place_row(ids: list[str], y: float) -> None:
+ nonlocal moved
+ if not ids:
+ return
+ if preserve_x:
+ ids = sorted(ids, key=lambda n: (pos[n][0], state.names.get(n, n)))
+ xs = [float(pos[n][0]) for n in ids]
+ # Enforce min pitch left→right without reversing order.
+ fixed: list[float] = []
+ for i, x in enumerate(xs):
+ if i == 0:
+ fixed.append(x)
+ else:
+ fixed.append(max(x, fixed[-1] + step))
+ for n, x in zip(ids, fixed):
+ nxt = (x, y)
+ if nxt != pos[n]:
+ moved.add(n)
+ pos[n] = nxt
+ else:
+ ids = sorted(ids, key=lambda n: state.names.get(n, n))
+ for i, n in enumerate(ids):
+ nxt = (40.0 + i * step, y)
+ if nxt != pos.get(n):
+ moved.add(n)
+ pos[n] = nxt
+
+ for ly in order:
+ ids = by.get(ly) or []
+ if not ids:
+ continue
+ if preserve_x:
+ ids = sorted(ids, key=lambda n: (pos[n][0], state.names.get(n, n)))
+ else:
+ ids = sorted(ids, key=lambda n: state.names.get(n, n))
+ rows = [ids[i : i + per_row] for i in range(0, len(ids), per_row)]
+ y_band0 = y_cursor
+ for ri, row in enumerate(rows):
+ _place_row(row, y_cursor + ri * rgap)
+ band_h = max(0, len(rows) - 1) * rgap
+ band_notes.append(
+ {
+ "layer": ly,
+ "count": len(ids),
+ "y": y_band0,
+ "rows": len(rows),
+ "y_max": y_band0 + band_h,
+ }
+ )
+ y_cursor = y_band0 + band_h + float(band_gap)
+
+ out = state.copy()
+ out.positions = pos
+ out.meta = dict(out.meta or {})
+ out.meta["level_bands"] = {
+ "bands": band_notes,
+ "preserve_x": preserve_x,
+ "max_per_row": per_row,
+ }
+ return OpResult(
+ state=out,
+ moved=moved,
+ op="level_bands",
+ params={
+ "bands": band_notes,
+ "preserve_x": preserve_x,
+ "y0": y0,
+ "band_gap": band_gap,
+ "pitch": step,
+ "max_per_row": per_row,
+ "moved_n": len(moved),
+ },
+ note=f"level_bands:{len(band_notes)} bands moved={len(moved)}",
+ )
+
+
+def level_bands_params_from_overrides(overrides: dict[str, Any] | None) -> dict[str, Any]:
+ out: dict[str, Any] = {}
+ if not overrides:
+ return out
+ for key, cast in (
+ ("y0", float),
+ ("band_gap", float),
+ ("pitch", float),
+ ("row_gap", float),
+ ("max_per_row", int),
+ ):
+ if overrides.get(key) is not None:
+ try:
+ out[key] = cast(overrides[key])
+ except (TypeError, ValueError):
+ pass
+ if "preserve_x" in overrides:
+ out["preserve_x"] = bool(overrides.get("preserve_x"))
+ raw = overrides.get("layers")
+ if isinstance(raw, (list, tuple)) and raw:
+ out["layers"] = tuple(str(x) for x in raw if str(x).strip())
+ return out
diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/orbit_sweep.py b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/orbit_sweep.py
index 8bf51e3..c71f500 100644
--- a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/orbit_sweep.py
+++ b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/orbit_sweep.py
@@ -1,7 +1,9 @@
"""Polar orbit sweep: suggest top-3 single-node drags by crossing score.
-Agent workflow: preview → pick rank 1..3 → apply; or round=true to auto-apply
-#1 for each hot node when global crossings drop.
+Agent workflow:
+- preview → pick rank 1..3 → apply
+- round=true: one batch, auto-apply #1 per hot node
+- until_limit=true: loop single-point sweeps until stall (eye polish)
"""
from __future__ import annotations
@@ -12,8 +14,10 @@ from typing import Any
from netx_topology_mcp.layout_metrics import (
count_edge_crossings,
crossing_participation,
+ crossing_participation_full,
crossings_involving_node,
node_footprint,
+ top_crossing_edges,
top_crossing_nodes,
)
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult
@@ -29,6 +33,164 @@ def _protect_is_off(protect_rigid: bool | str) -> bool:
return protect_rigid in (False, "false", "off", "none", "0")
+_LAYER_ALIASES: dict[str, str] = {
+ "external": "external",
+ "ext": "external",
+ "0": "external",
+ "core": "core",
+ "cn": "core",
+ "1": "core",
+ "agg": "agg",
+ "an": "agg",
+ "aggregation": "agg",
+ "aggregate": "agg",
+ "2": "agg",
+ "access": "access",
+ "en": "access",
+ "edge": "access",
+ "cpe": "access",
+ "3": "access",
+ "other": "other",
+}
+
+
+def _coerce_str_list(raw: Any) -> list[str]:
+ if raw is None:
+ return []
+ if isinstance(raw, str):
+ return [p.strip() for p in raw.replace(";", ",").split(",") if p.strip()]
+ if isinstance(raw, (list, tuple, set)):
+ out: list[str] = []
+ for x in raw:
+ s = str(x).strip()
+ if s:
+ out.append(s)
+ return out
+ s = str(raw).strip()
+ return [s] if s else []
+
+
+def _normalize_layer_token(tok: str) -> str | None:
+ t = str(tok or "").strip().lower()
+ if not t:
+ return None
+ if t in _LAYER_ALIASES:
+ return _LAYER_ALIASES[t]
+ # numeric-looking → major band
+ try:
+ lv = float(t)
+ except ValueError:
+ return t if t in {"external", "core", "agg", "access", "other"} else None
+ maj = int(math.floor(lv))
+ if maj <= 0:
+ return "external"
+ if maj == 1:
+ return "core"
+ if maj == 2:
+ return "agg"
+ return "access"
+
+
+def _major_from_level(lv: float) -> int:
+ maj = int(math.floor(float(lv)))
+ if maj < 0:
+ return 0
+ return maj
+
+
+def ids_matching_freeze_layers_levels(
+ state: LayoutState,
+ freeze_layers: list[str] | None = None,
+ freeze_levels: list[Any] | None = None,
+) -> set[str]:
+ """Resolve freeze_layers / freeze_levels → fabric_node_id set.
+
+ - ``freeze_layers``: ``core|agg|access|external`` (aliases CN/AN/EN/1/2/3 OK)
+ - ``freeze_levels``: fabric numeric levels. Integer-ish (1 / 2) freezes that
+ major band; fractional (1.1) matches exact ``state.levels`` when present,
+ else falls back to major band via ``state.layers``.
+ """
+ want_layers: set[str] = set()
+ for tok in _coerce_str_list(freeze_layers):
+ layer = _normalize_layer_token(tok)
+ if layer:
+ want_layers.add(layer)
+
+ want_majors: set[int] = set()
+ want_exact: set[float] = set()
+ for tok in _coerce_str_list(freeze_levels):
+ try:
+ lv = float(tok)
+ except (TypeError, ValueError):
+ layer = _normalize_layer_token(tok)
+ if layer:
+ want_layers.add(layer)
+ continue
+ if not math.isfinite(lv):
+ continue
+ # Exact sub-level when not an integer (1.1); majors when 1 / 2.0
+ if abs(lv - round(lv)) < 1e-9:
+ want_majors.add(int(round(lv)))
+ layer = _normalize_layer_token(str(int(round(lv))))
+ if layer:
+ want_layers.add(layer)
+ else:
+ want_exact.add(float(lv))
+
+ if not want_layers and not want_majors and not want_exact:
+ return set()
+
+ out: set[str] = set()
+ levels = getattr(state, "levels", None) or {}
+ layers = getattr(state, "layers", None) or {}
+
+ if want_exact and levels:
+ for nid, lv in levels.items():
+ if float(lv) in want_exact or any(
+ abs(float(lv) - e) < 1e-6 for e in want_exact
+ ):
+ out.add(str(nid))
+
+ if want_majors and levels:
+ for nid, lv in levels.items():
+ if _major_from_level(lv) in want_majors:
+ out.add(str(nid))
+
+ if want_layers and layers:
+ for nid, layer in layers.items():
+ if str(layer) in want_layers:
+ out.add(str(nid))
+
+ # No numeric levels on state → majors already folded into want_layers above.
+ return out
+
+
+def _merge_explicit_freeze(
+ state: LayoutState,
+ *,
+ protect_rigid: bool | str,
+ frozen_ids: set[str] | None,
+ freeze_layers: list[str] | None = None,
+ freeze_levels: list[Any] | None = None,
+ always_honor_explicit: bool = True,
+) -> set[str]:
+ """Combine protect_rigid portals with explicit id/layer/level freezes."""
+ frozen = _resolve_frozen(state, protect_rigid, frozen_ids)
+ layer_ids = ids_matching_freeze_layers_levels(
+ state, freeze_layers, freeze_levels
+ )
+ if always_honor_explicit or not _protect_is_off(protect_rigid):
+ if frozen_ids:
+ frozen |= {str(x) for x in frozen_ids if str(x)}
+ frozen |= layer_ids
+ elif layer_ids:
+ # Explicit layer/level freeze always means user intent.
+ frozen |= layer_ids
+ if frozen_ids:
+ frozen |= {str(x) for x in frozen_ids if str(x)}
+ return frozen
+
+
def _resolve_frozen(
st: LayoutState,
protect_rigid: bool | str,
@@ -51,14 +213,35 @@ def _box(nid: str, pos: dict[str, tuple[float, float]], names: dict[str, str]):
return (x + minx, y + miny, x + maxx, y + maxy)
+def _centers_may_overlap(
+ ax: float,
+ ay: float,
+ bx: float,
+ by: float,
+ fa: tuple[float, float, float, float],
+ fb: tuple[float, float, float, float],
+ *,
+ pad: float = 12.0,
+) -> bool:
+ """Coarse AABB reach check — label width can exceed 80px."""
+ ra_x = max(abs(fa[0]), abs(fa[2])) + pad
+ ra_y = max(abs(fa[1]), abs(fa[3])) + pad
+ rb_x = max(abs(fb[0]), abs(fb[2])) + pad
+ rb_y = max(abs(fb[1]), abs(fb[3])) + pad
+ return abs(ax - bx) <= ra_x + rb_x and abs(ay - by) <= ra_y + rb_y
+
+
def _node_overlaps_any(
node: str, pos: dict[str, tuple[float, float]], names: dict[str, str]
) -> bool:
ax0, ay0, ax1, ay1 = _box(node, pos, names)
+ ax, ay = pos[node]
+ fa = node_footprint(names.get(node, ""))
for b, (x, y) in pos.items():
if b == node:
continue
- if abs(x - pos[node][0]) > 80 and abs(y - pos[node][1]) > 60:
+ fb = node_footprint(names.get(b, ""))
+ if not _centers_may_overlap(ax, ay, x, y, fa, fb):
continue
bx0, by0, bx1, by1 = _box(b, pos, names)
if ax0 < bx1 and ax1 > bx0 and ay0 < by1 and ay1 > by0:
@@ -66,6 +249,47 @@ def _node_overlaps_any(
return False
+def _moved_invades_outsiders(
+ members: set[str],
+ pos: dict[str, tuple[float, float]],
+ names: dict[str, str],
+) -> bool:
+ """True if any moved node footprint overlaps a non-member (invasion)."""
+ for n in members:
+ if n not in pos:
+ continue
+ ax0, ay0, ax1, ay1 = _box(n, pos, names)
+ nx, ny = pos[n]
+ fa = node_footprint(names.get(n, ""))
+ for b, (x, y) in pos.items():
+ if b in members:
+ continue
+ fb = node_footprint(names.get(b, ""))
+ if not _centers_may_overlap(nx, ny, x, y, fa, fb):
+ continue
+ bx0, by0, bx1, by1 = _box(b, pos, names)
+ if ax0 < bx1 and ax1 > bx0 and ay0 < by1 and ay1 > by0:
+ return True
+ return False
+
+
+def _has_any_footprint_overlap(
+ pos: dict[str, tuple[float, float]], names: dict[str, str]
+) -> bool:
+ """Full pairwise footprint gate — matches apply overlaps_remain."""
+ ids = list(pos.keys())
+ boxes: list[tuple[str, tuple[float, float, float, float]]] = []
+ for n in ids:
+ boxes.append((n, _box(n, pos, names)))
+ for i, (_a, ab) in enumerate(boxes):
+ ax0, ay0, ax1, ay1 = ab
+ for _b, bb in boxes[i + 1 :]:
+ bx0, by0, bx1, by1 = bb
+ if ax0 < bx1 and ax1 > bx0 and ay0 < by1 and ay1 > by0:
+ return True
+ return False
+
+
def _nn_ok(
node: str,
pos: dict[str, tuple[float, float]],
@@ -111,6 +335,8 @@ def _radii_for_jump(jump: float) -> list[float]:
radii = radii + [640.0, 800.0]
if jump > 1200:
radii = radii + [1200.0, 1600.0, 2200.0, min(jump, 3200.0)]
+ if jump > 3200:
+ radii = radii + [min(jump * 0.7, 5000.0), min(jump * 0.9, jump)]
return [r for r in radii if r <= jump + 1]
@@ -175,6 +401,92 @@ def _neighbor_guides(
return out
+def _far_field_guides(
+ pos: dict[str, tuple[float, float]],
+ node: str,
+ adj: dict[str, set[str]],
+ jump: float,
+) -> list[tuple[float, float, float, float]]:
+ """Samples that leave the local blob — bbox rim + radial escape.
+
+ Single-node polar rings around the current xy often miss global untangles
+ (long chord edges). Push candidates toward the hull exterior and along the
+ vector from the graph / neighbor centroid through the node.
+ """
+ if node not in pos or jump < 200:
+ return []
+ x, y = pos[node]
+ xs = [p[0] for p in pos.values()]
+ ys = [p[1] for p in pos.values()]
+ if not xs:
+ return []
+ xmin, xmax = min(xs), max(xs)
+ ymin, ymax = min(ys), max(ys)
+ gcx = 0.5 * (xmin + xmax)
+ gcy = 0.5 * (ymin + ymax)
+ out: list[tuple[float, float, float, float]] = []
+
+ def _push(nx: float, ny: float) -> None:
+ r = math.hypot(nx - x, ny - y)
+ if r < 40.0 or r > jump + 1.0:
+ return
+ ang = math.degrees(math.atan2(ny - y, nx - x)) % 360.0
+ out.append((nx, ny, r, ang))
+
+ # Radial escape from graph center through node.
+ dx, dy = x - gcx, y - gcy
+ gl = math.hypot(dx, dy) or 1.0
+ gx, gy = dx / gl, dy / gl
+ for dist in (0.45 * jump, 0.7 * jump, 0.92 * jump):
+ _push(x + gx * dist, y + gy * dist)
+ _push(gcx + gx * (gl + dist), gcy + gy * (gl + dist))
+
+ # Neighbor-centroid radial (often differs from graph center on eyes).
+ nbs = [pos[v] for v in adj.get(node, ()) if v in pos]
+ if nbs:
+ ncx = sum(p[0] for p in nbs) / len(nbs)
+ ncy = sum(p[1] for p in nbs) / len(nbs)
+ ndx, ndy = x - ncx, y - ncy
+ nl = math.hypot(ndx, ndy) or 1.0
+ nxu, nyu = ndx / nl, ndy / nl
+ for dist in (0.5 * jump, 0.85 * jump):
+ _push(x + nxu * dist, y + nyu * dist)
+ # Reflect across neighbor centroid (flip to clear side).
+ _push(ncx - nxu * dist, ncy - nyu * dist)
+
+ # Bbox rim / corners — good for long chords that pierce the drawing.
+ pad = min(jump, max(600.0, 0.35 * jump))
+ rim = [
+ (xmin - pad, y),
+ (xmax + pad, y),
+ (x, ymin - pad),
+ (x, ymax + pad),
+ (xmin - pad, ymin - pad),
+ (xmax + pad, ymin - pad),
+ (xmin - pad, ymax + pad),
+ (xmax + pad, ymax + pad),
+ (xmin - pad, gcy),
+ (xmax + pad, gcy),
+ (gcx, ymin - pad),
+ (gcx, ymax + pad),
+ ]
+ for nx, ny in rim:
+ _push(nx, ny)
+
+ # Small rings around each neighbor (park beside spoke tip).
+ for nb in list(adj.get(node, ()))[:8]:
+ if nb not in pos:
+ continue
+ bx, by = pos[nb]
+ for ang in (0, 45, 90, 135, 180, 225, 270, 315):
+ rad = math.radians(ang)
+ for rr in (220.0, 480.0, 900.0):
+ if rr > jump:
+ continue
+ _push(bx + rr * math.cos(rad), by + rr * math.sin(rad))
+ return out
+
+
def _score_key(c: dict[str, Any]) -> tuple:
return (
int(c["crossings"]["global"]),
@@ -187,6 +499,7 @@ def _score_key(c: dict[str, Any]) -> tuple:
# Verdict weights for the components a single-node orbit move mainly affects.
_W_CROSS = 0.18
_W_CLR = 0.08
+_W_AXIS = 0.06
def _crossing_part_score(crossings: int, *, n_links: int, n_nodes: int) -> float:
@@ -205,11 +518,92 @@ def _crossing_part_score(crossings: int, *, n_links: int, n_nodes: int) -> float
return 1.0 - (cpl - cpl_ok) / max(cpl_bad - cpl_ok, 1e-9)
-def _verdict_partial(crossings: int, clearance_score: float, *, n_links: int, n_nodes: int) -> float:
- """Weighted crossing+clearance slice of verdict.total (higher is better)."""
+def _incident_axis_frac(
+ nid: str,
+ pos: dict[str, tuple[float, float]],
+ adj: dict[str, set[str]],
+ *,
+ tol_px: float = 8.0,
+) -> float:
+ """Fraction of incident edges that are near-horizontal or near-vertical."""
+ nbs = [v for v in adj.get(nid, ()) if v in pos and v != nid]
+ if not nbs or nid not in pos:
+ return 0.0
+ ax, ay = pos[nid]
+ good = 0
+ for v in nbs:
+ bx, by = pos[v]
+ if abs(ax - bx) <= tol_px or abs(ay - by) <= tol_px:
+ good += 1
+ return good / float(len(nbs))
+
+
+def _local_clearance_hits(
+ nid: str,
+ pos: dict[str, tuple[float, float]],
+ links: list[tuple[str, str]],
+ adj: dict[str, set[str]],
+ *,
+ thr: float = 40.0,
+ endpoint_t: float = 0.05,
+) -> int:
+ """Fast hit count affected by moving ``nid`` (O(E + N·deg), not O(N·E))."""
+ from netx_topology_mcp.layout_metrics import point_segment_dist
+
+ if nid not in pos:
+ return 0
+ thr_f = float(thr)
+ et = float(endpoint_t)
+ hits = 0
+ p = pos[nid]
+ # nid sitting on non-incident edges
+ for a, b in links:
+ if nid in (a, b) or a not in pos or b not in pos:
+ continue
+ d, t = point_segment_dist(p, pos[a], pos[b])
+ if t <= et or t >= 1.0 - et:
+ continue
+ if d < thr_f:
+ hits += 1
+ # other nodes sitting on edges incident to nid
+ incident = {nid, *adj.get(nid, ())}
+ inc_edges = [(nid, v) for v in adj.get(nid, ()) if v in pos]
+ for other, (ox, oy) in pos.items():
+ if other in incident:
+ continue
+ for a, b in inc_edges:
+ d, t = point_segment_dist((ox, oy), pos[a], pos[b])
+ if t <= et or t >= 1.0 - et:
+ continue
+ if d < thr_f:
+ hits += 1
+ return hits
+
+
+def _local_clearance_score(hits: int, *, n_nodes: int) -> float:
+ """Map local hit count to a soft [0,1] score (higher=better)."""
+ # Cap relative to graph size so one node doesn't dominate.
+ hit_frac = float(hits) / max(float(n_nodes) * 0.05, 1.0)
+ if hit_frac <= 0.0:
+ return 1.0
+ if hit_frac >= 1.0:
+ return 0.0
+ return 1.0 - hit_frac
+
+
+def _verdict_partial(
+ crossings: int,
+ clearance_score: float,
+ *,
+ n_links: int,
+ n_nodes: int,
+ axis_frac: float = 0.0,
+) -> float:
+ """Weighted crossing+clearance(+axis) slice of verdict (higher is better)."""
return (
_W_CROSS * _crossing_part_score(crossings, n_links=n_links, n_nodes=n_nodes)
+ _W_CLR * max(0.0, min(1.0, float(clearance_score)))
+ + _W_AXIS * max(0.0, min(1.0, float(axis_frac)))
)
@@ -222,45 +616,53 @@ def _rerank_by_total(
*,
global0: int,
re_rank_n: int = 20,
+ adj: dict[str, set[str]] | None = None,
) -> tuple[list[dict[str, Any]], bool, float]:
- """Re-rank top candidates by weighted crossing+clearance (verdict.total slice).
+ """Re-rank top candidates by crossing+local-clearance+axis (fast).
- Returns ``(ranked, clearance_ok, base_partial)``. When edge_clearance is
- skipped on large graphs, ``clearance_ok`` is False and ranking is unchanged.
+ Uses local clearance around ``nid`` so until_limit stays interactive on
+ mid-size eyes. Returns ``(ranked, ok, base_partial)``.
"""
- from netx_topology_mcp.layout_metrics import compute_edge_clearance
-
+ del names # names reserved for future label-aware clearance
n_nodes = len(pos)
n_links = len(links)
- ec0 = compute_edge_clearance(pos, links, names=names, top_n=1)
- if ec0.get("edge_clearance_skipped"):
- return scored, False, 0.0
-
- base_clr = float(ec0.get("edge_clearance_score") or 1.0)
- base_partial = _verdict_partial(int(global0), base_clr, n_links=n_links, n_nodes=n_nodes)
+ adj_m = adj or {}
+ base_hits = _local_clearance_hits(nid, pos, links, adj_m)
+ base_clr = _local_clearance_score(base_hits, n_nodes=n_nodes)
+ base_axis = _incident_axis_frac(nid, pos, adj_m)
+ base_partial = _verdict_partial(
+ int(global0),
+ base_clr,
+ n_links=n_links,
+ n_nodes=n_nodes,
+ axis_frac=base_axis,
+ )
n = min(re_rank_n, len(scored))
for c in scored[:n]:
trial = dict(pos)
trial[nid] = (float(c["x"]), float(c["y"]))
- ec = compute_edge_clearance(trial, links, names=names, top_n=1)
- if ec.get("edge_clearance_skipped"):
- return scored, False, base_partial
- clr_s = float(ec.get("edge_clearance_score") or 1.0)
- c["edge_clearance_hits"] = int(ec.get("edge_clearance_hits") or 0)
+ hits = _local_clearance_hits(nid, trial, links, adj_m)
+ clr_s = _local_clearance_score(hits, n_nodes=n_nodes)
+ axis_s = _incident_axis_frac(nid, trial, adj_m)
+ c["edge_clearance_hits"] = int(hits)
c["edge_clearance_score"] = clr_s
+ c["axis_frac"] = round(axis_s, 4)
c["verdict_partial"] = _verdict_partial(
int(c["crossings"]["global"]),
clr_s,
n_links=n_links,
n_nodes=n_nodes,
+ axis_frac=axis_s,
)
head = sorted(
scored[:n],
key=lambda c: (
-float(c.get("verdict_partial") or 0.0),
+ int(c.get("edge_clearance_hits") or 0),
int(c["crossings"]["incident"]),
float(c.get("stretch") or 1.0),
+ float(c.get("r") or 0.0),
),
)
return head + scored[n:], True, base_partial
@@ -367,6 +769,8 @@ def orbit_sweep_node(
cand_cap: int = 280,
protect_rigid: bool | str = "off",
frozen_ids: set[str] | None = None,
+ freeze_layers: list[str] | None = None,
+ freeze_levels: list[Any] | None = None,
top_k: int = 3,
y_min: float | None = None,
y_max: float | None = None,
@@ -376,6 +780,7 @@ def orbit_sweep_node(
Default ``protect_rigid=off`` so multi-round orbit may move portals to cut
crossings; other layout actions keep portal freeze. Opt in with portals/all.
+ ``freeze_layers`` / ``freeze_levels`` always freeze matched nodes.
"""
params = params or LayoutParams()
st = state
@@ -392,6 +797,7 @@ def orbit_sweep_node(
}
frozen = _resolve_frozen(st, protect_rigid, frozen_ids)
+ frozen |= ids_matching_freeze_layers_levels(st, freeze_layers, freeze_levels)
if nid in frozen:
return {
"ok": False,
@@ -406,21 +812,26 @@ def orbit_sweep_node(
if angle_step is None:
angle_step = 24 if n_links >= 400 else (18 if n_links >= 200 else 15)
angle_step = max(10, int(angle_step))
- # Local untangle-style jumps stay near neighbor centroid; metro bridges
- # (max_jump≫1k) must be allowed to leave the unit blob.
+ # Local untangle stays near neighbor centroid; metro bridges / far-field
+ # escapes (max_jump≫1k) must leave the unit blob — nbs_cap ≈ jump*3.5.
if jump > 1200:
- nbs_cap = jump * 2.5
+ nbs_cap = max(jump * 3.5, 6000.0)
else:
nbs_cap = max(_MAX_FROM_NBS, jump * 1.25)
+ # High-incident hotspots: allow even farther from neighbor centroid.
target_nn = float(getattr(params, "target_nn", 155.0) or 155.0)
x0, y0 = pos[nid]
global0 = count_edge_crossings(pos, links)
local0 = crossings_involving_node(nid, pos, links, adj)
+ if local0 >= 8 and jump > 1200:
+ nbs_cap = max(nbs_cap, jump * 4.5)
- # Coarse grid.
+ # Prefer far-field / neighbor guides before dense polar rings so cand_cap
+ # does not truncate the escapes that actually cut long-chord crossings.
coarse_step = max(angle_step, 24 if n_links >= 200 else angle_step)
- samples = _polar_grid(x0, y0, jump=jump, angle_step=coarse_step)
+ samples: list[tuple[float, float, float, float]] = []
+ samples.extend(_far_field_guides(pos, nid, adj, jump))
samples.extend(_neighbor_guides(pos, nid, adj, jump))
# Explicit samples toward each neighbor (incl. long bridges).
for nb in adj.get(nid, ()):
@@ -438,16 +849,17 @@ def orbit_sweep_node(
samples.append((x0 + ux * r, y0 + uy * r, r, ang))
# Perpendicular escapes at mid-chord fractions.
px, py = -uy, ux
- for r in (180.0, 360.0, 640.0, 1200.0):
+ for r in (180.0, 360.0, 640.0, 1200.0, min(jump, 2200.0)):
if r > jump:
continue
samples.append((x0 + px * r, y0 + py * r, r, (ang + 90) % 360))
samples.append((x0 - px * r, y0 - py * r, r, (ang + 270) % 360))
+ samples.extend(_polar_grid(x0, y0, jump=jump, angle_step=coarse_step))
# Dedup by rounded xy.
seen: set[tuple[int, int]] = set()
uniq: list[tuple[float, float, float, float]] = []
for sx, sy, r, ang in samples:
- key = (int(round(sx)), int(round(sy)))
+ key = (int(round(sx / 8.0) * 8), int(round(sy / 8.0) * 8))
if key in seen:
continue
seen.add(key)
@@ -541,11 +953,15 @@ def orbit_sweep_node(
base_partial = 0.0
if use_total:
scored, clr_ok, base_partial = _rerank_by_total(
- scored, nid, pos, names, links, global0=int(global0)
+ scored, nid, pos, names, links, global0=int(global0), adj=adj
)
if not clr_ok:
# Large-graph clearance skip → fall back to crossing-only ranking.
use_total = False
+ else:
+ for c in scored:
+ c.setdefault("verdict_partial", float(base_partial))
+ c.setdefault("edge_clearance_score", 0.0)
# Prefer improving moves; still return best even if none improve.
if use_total:
improving = [
@@ -640,6 +1056,459 @@ def apply_orbit_pick(
)
+def _truthy_flag(v: Any) -> bool:
+ if v is True:
+ return True
+ if v is False or v is None:
+ return False
+ return str(v).strip().lower() not in {"0", "false", "no", "off", ""}
+
+
+def _select_stretch_pick(
+ sweep: dict[str, Any],
+ *,
+ max_stretch: float,
+ min_delta: int,
+ objective: str = "crossing",
+) -> tuple[int, dict[str, Any]] | None:
+ """Pick best improving candidate with stretch gate (1-based pick index).
+
+ ``objective=total``: accept clearance/partial gains when crossings do not
+ rise (flat Δg=0 allowed). Crossing-only mode still requires Δg≤−min_delta.
+ """
+ if int(sweep.get("improving_n") or 0) <= 0:
+ return None
+ use_total = str(objective).lower() in {"total", "score", "multi"}
+ stretch_cap = float(max_stretch)
+
+ if use_total:
+ ranked_t: list[tuple[float, int, int, float, int, dict[str, Any]]] = []
+ for i, c in enumerate(sweep.get("candidates") or [], start=1):
+ if not isinstance(c, dict) or bool(c.get("ov")):
+ continue
+ try:
+ d = int(((c.get("delta") or {}).get("global") or 0))
+ except (TypeError, ValueError):
+ continue
+ if d > 0:
+ continue # hard: never raise crossings
+ partial = float(c.get("verdict_partial") or 0.0)
+ clr_hits = int(c.get("edge_clearance_hits") or 10**9)
+ st = float(c.get("stretch") or 0.0)
+ ranked_t.append((-partial, clr_hits, d, st, i, c))
+ if not ranked_t:
+ return None
+ ranked_t.sort()
+ for _neg_p, _hits, d, st, i, c in ranked_t:
+ if st <= stretch_cap or (d < 0 and abs(d) >= 3):
+ return i, c
+ _neg_p, _hits, d, st, i, c = ranked_t[0]
+ if st <= stretch_cap * 1.75:
+ return i, c
+ if d < 0 and abs(d) >= 5:
+ return i, c
+ if d == 0 and st <= stretch_cap:
+ return i, c
+ return None
+
+ ranked: list[tuple[int, float, int, dict[str, Any]]] = []
+ for i, c in enumerate(sweep.get("candidates") or [], start=1):
+ if not isinstance(c, dict):
+ continue
+ if bool(c.get("ov")):
+ continue
+ try:
+ d = int(((c.get("delta") or {}).get("global") or 0))
+ except (TypeError, ValueError):
+ continue
+ if d >= 0 or abs(d) < max(1, int(min_delta)):
+ continue
+ st = float(c.get("stretch") or 0.0)
+ ranked.append((d, st, i, c))
+ if not ranked:
+ return None
+ ranked.sort(key=lambda t: (t[0], t[1]))
+ for d, st, i, c in ranked:
+ if st <= stretch_cap or abs(d) >= 3:
+ return i, c
+ d, st, i, c = ranked[0]
+ if st <= stretch_cap * 1.75:
+ return i, c
+ if abs(d) >= 5:
+ return i, c
+ return None
+
+
+def _until_limit_queue(
+ pos: dict[str, tuple[float, float]],
+ links: list[tuple[str, str]],
+ adj: dict[str, set[str]],
+ names: dict[str, str],
+ *,
+ frozen: set[str],
+ max_degree: int,
+ top_edges_n: int = 16,
+ prefer_low_degree: bool = True,
+ hard_degree_cap: int | None = None,
+ include_clearance: bool = False,
+) -> list[str]:
+ """Hotspot queue: crossing nodes + top-edge endpoints (+ clearance hits).
+
+ ``max_degree`` soft-prefers corridor nodes; ``hard_degree_cap`` (default
+ max(max_degree, 16)) still admits high-deg hubs that own most hits.
+ """
+ _n, node_hit, edge_hit = crossing_participation_full(pos, links)
+ hard = int(hard_degree_cap) if hard_degree_cap is not None else max(int(max_degree), 16)
+ soft = max(1, int(max_degree))
+ # hits, deg, edge_boost, clr_boost
+ scored: dict[str, tuple[int, int, int, int]] = {}
+ for nid, hits in (node_hit or {}).items():
+ if nid in frozen or nid not in pos:
+ continue
+ deg = len(adj.get(nid, ()))
+ if deg > hard or hits <= 0:
+ continue
+ scored[nid] = (int(hits), deg, 0, 0)
+ for row in top_crossing_edges(
+ pos,
+ links,
+ names=names,
+ top_n=top_edges_n,
+ edge_participation=edge_hit,
+ ):
+ hits = int(row.get("crossing_hits") or 0)
+ for key in ("a_node_id", "b_node_id"):
+ nid = str(row.get(key) or "")
+ if not nid or nid in frozen or nid not in pos:
+ continue
+ deg = len(adj.get(nid, ()))
+ if deg > hard:
+ continue
+ prev = scored.get(nid)
+ base_hits = max(hits, prev[0] if prev else 0)
+ scored[nid] = (base_hits, deg, 1, prev[3] if prev else 0)
+ if include_clearance:
+ from netx_topology_mcp.layout_metrics import compute_edge_clearance
+
+ ec = compute_edge_clearance(pos, links, names=names, top_n=24)
+ if not ec.get("edge_clearance_skipped"):
+ for row in ec.get("top_edge_hits") or []:
+ nid = str(row.get("fabric_node_id") or "")
+ if not nid or nid in frozen or nid not in pos:
+ continue
+ deg = len(adj.get(nid, ()))
+ if deg > hard:
+ continue
+ prev = scored.get(nid)
+ if prev:
+ scored[nid] = (prev[0], prev[1], prev[2], 1)
+ else:
+ # Pure clearance obstacle — give a synthetic hit so it queues.
+ scored[nid] = (1, deg, 0, 1)
+ if not scored:
+ return []
+ items = list(scored.items())
+ if prefer_low_degree:
+ items.sort(
+ key=lambda kv: (
+ kv[1][3], # clearance obstacles first when include_clearance
+ kv[1][2],
+ kv[1][0],
+ 1 if kv[1][1] <= soft else 0,
+ -kv[1][1],
+ ),
+ reverse=True,
+ )
+ else:
+ items.sort(
+ key=lambda kv: (kv[1][3], kv[1][0], kv[1][2], -kv[1][1]),
+ reverse=True,
+ )
+ return [nid for nid, _ in items]
+
+
+def orbit_sweep_until_limit(
+ state: LayoutState,
+ *,
+ params: LayoutParams | None = None,
+ max_degree: int = 14,
+ max_jump: float | None = None,
+ angle_step: int | None = None,
+ nn_floor: float = 36.0,
+ min_angle_sep: float = 35.0,
+ protect_rigid: bool | str = "portals",
+ frozen_ids: set[str] | None = None,
+ freeze_layers: list[str] | None = None,
+ freeze_levels: list[Any] | None = None,
+ y_min: float | None = None,
+ y_max: float | None = None,
+ objective: str = "crossing",
+ max_moves: int = 40,
+ stall_limit: int = 12,
+ max_stretch: float = 32.0,
+ min_delta: int = 1,
+ scan_cap: int = 32,
+ top_k: int = 8,
+ prefer_low_degree: bool = True,
+ cand_cap: int = 360,
+ bundle: bool = True,
+ bundle_max: int = 10,
+) -> OpResult:
+ """Loop single-node orbit picks until no improving hotspot (eye polish).
+
+ Unlike ``round`` (one batch, always pick#1), this re-ranks after each apply,
+ gates stretch, and defaults ``protect_rigid=portals`` so CN portals stay put.
+
+ When single-point stalls, ``bundle=True`` (default) tries rigid
+ chain / ring+chain contract→orbit→expand moves.
+
+ Freeze = protect portals ∪ ``portal_ids``/``frozen_ids`` ∪ ``freeze_layers`` ∪
+ ``freeze_levels``.
+ """
+ params = params or LayoutParams()
+ st = state.copy()
+ pos = dict(st.positions)
+ names = dict(st.names)
+ links = list(st.links)
+ adj = {n: set(st.adj.get(n, ())) for n in pos}
+ frozen = _merge_explicit_freeze(
+ st,
+ protect_rigid=protect_rigid,
+ frozen_ids=frozen_ids,
+ freeze_layers=freeze_layers,
+ freeze_levels=freeze_levels,
+ always_honor_explicit=True,
+ )
+
+ jump = float(max_jump if max_jump is not None else 4000.0)
+ jump = max(120.0, min(jump, _MAX_JUMP_CAP))
+ obj = "total" if str(objective).lower() in {"total", "score", "multi"} else "crossing"
+ global0 = count_edge_crossings(pos, links)
+ use_bundle = bool(bundle)
+
+ moves: list[dict[str, Any]] = []
+ tried_fail: set[str] = set()
+ stall = 0
+ stop_reason = "max_moves"
+ moved: set[str] = set()
+ rounds = 0
+ bundle_moves = 0
+ bundle_exhausted = False
+ max_moves_i = max(1, int(max_moves))
+ stall_lim = max(1, int(stall_limit))
+ scan_n = max(4, int(scan_cap))
+ cand_n = max(120, int(cand_cap))
+
+ while len(moves) < max_moves_i and stall < stall_lim:
+ rounds += 1
+ try:
+ from netx_topology_mcp.layout_jobs import raise_if_cancelled, touch_heartbeat
+
+ touch_heartbeat()
+ raise_if_cancelled()
+ except ImportError:
+ pass
+ queue = [
+ nid
+ for nid in _until_limit_queue(
+ pos,
+ links,
+ adj,
+ names,
+ frozen=frozen,
+ max_degree=max(1, int(max_degree)),
+ prefer_low_degree=prefer_low_degree,
+ include_clearance=(obj == "total"),
+ )
+ if nid not in tried_fail
+ ]
+ applied = False
+ for nid in queue[:scan_n]:
+ st.positions = pos
+ sweep = orbit_sweep_node(
+ st,
+ nid,
+ params=params,
+ max_jump=jump,
+ angle_step=angle_step,
+ nn_floor=nn_floor,
+ min_angle_sep=min_angle_sep,
+ cand_cap=cand_n,
+ protect_rigid="off",
+ frozen_ids=frozen,
+ top_k=max(3, int(top_k)),
+ y_min=y_min,
+ y_max=y_max,
+ objective=obj,
+ )
+ if not sweep.get("ok"):
+ tried_fail.add(nid)
+ continue
+ deg_n = len(adj.get(nid, ()))
+ # Soft max_degree: still allow high-deg hubs that made the queue
+ # via hard_degree_cap; only skip absurd stars.
+ if deg_n > max(int(max_degree), 16):
+ tried_fail.add(nid)
+ continue
+ picked = _select_stretch_pick(
+ sweep,
+ max_stretch=float(max_stretch),
+ min_delta=int(min_delta),
+ objective=obj,
+ )
+ if not picked:
+ tried_fail.add(nid)
+ continue
+ pick_i, cand = picked
+ prev_xy = pos[nid]
+ pos[nid] = (float(cand["x"]), float(cand["y"]))
+ if _node_overlaps_any(nid, pos, names):
+ pos[nid] = prev_xy
+ tried_fail.add(nid)
+ continue
+ moved.add(nid)
+ g_after = int((cand.get("crossings") or {}).get("global") or 0)
+ delta_g = int((cand.get("delta") or {}).get("global") or 0)
+ moves.append(
+ {
+ "node_id": nid,
+ "name": names.get(nid, nid),
+ "degree": deg_n,
+ "pick": pick_i,
+ "xy": [round(float(cand["x"]), 1), round(float(cand["y"]), 1)],
+ "delta": delta_g,
+ "stretch": float(cand.get("stretch") or 0.0),
+ "crossings": g_after,
+ "mode": "point",
+ }
+ )
+ tried_fail.clear()
+ stall = 0
+ applied = True
+ bundle_exhausted = False
+ break
+
+ if not applied and use_bundle and not bundle_exhausted:
+ from netx_topology_mcp.layout_ops.bundle_orbit import (
+ bundle_orbit_until_progress,
+ )
+
+ try:
+ from netx_topology_mcp.layout_jobs import raise_if_cancelled, touch_heartbeat
+
+ touch_heartbeat()
+ raise_if_cancelled()
+ except ImportError:
+ pass
+ st.positions = pos
+ bop = bundle_orbit_until_progress(
+ st,
+ frozen_ids=frozen,
+ max_jump=max(jump, 5000.0),
+ max_bundles=max(4, min(12, int(bundle_max))),
+ min_delta=int(min_delta),
+ cand_cap=min(cand_n, 64),
+ angle_step=max(angle_step or 20, 24),
+ nn_floor=nn_floor,
+ params=params,
+ )
+ if bop.moved:
+ trial = dict(bop.state.positions)
+ mem = set(bop.moved)
+ # Expand must not invade or leave any footprint pairs (apply gate).
+ if _has_any_footprint_overlap(trial, names):
+ bundle_exhausted = True
+ else:
+ pos = trial
+ moved |= mem
+ bmeta = bop.params or {}
+ moves.append(
+ {
+ "node_id": bmeta.get("tip_id"),
+ "name": names.get(str(bmeta.get("tip_id") or ""), ""),
+ "kind": bmeta.get("kind"),
+ "bundle": bmeta.get("bundle"),
+ "member_n": bmeta.get("member_n"),
+ "delta": int(bmeta.get("delta") or 0),
+ "crossings": int(
+ bmeta.get("end_crossings")
+ or bmeta.get("crossings")
+ or 0
+ ),
+ "expand_scale": bmeta.get("expand_scale"),
+ "mode": "bundle",
+ }
+ )
+ bundle_moves += 1
+ bundle_exhausted = False
+ tried_fail.clear()
+ stall = 0
+ applied = True
+ else:
+ bundle_exhausted = True
+
+ if not applied:
+ if not queue and not use_bundle:
+ stop_reason = "no_candidates"
+ break
+ stall += 1
+ if queue:
+ tried_fail.add(queue[0])
+ if stall >= stall_lim:
+ stop_reason = "stall"
+ break
+
+ if len(moves) >= max_moves_i:
+ stop_reason = "max_moves"
+ elif stop_reason == "max_moves" and stall >= stall_lim:
+ stop_reason = "stall"
+ if not moves and stop_reason == "max_moves":
+ stop_reason = "no_candidates"
+
+ st.positions = pos
+ st.last_moved = moved
+ end_g = count_edge_crossings(pos, links)
+ meta = {
+ "mode": "until_limit",
+ "start_crossings": global0,
+ "end_crossings": end_g,
+ "delta_crossings": int(end_g) - int(global0),
+ "moved_n": len(moved),
+ "moves_n": len(moves),
+ "moves": moves,
+ "bundle_moves": bundle_moves,
+ "bundle": use_bundle,
+ "rounds": rounds,
+ "stall": stall,
+ "stop_reason": stop_reason,
+ "max_degree": int(max_degree),
+ "max_jump": jump,
+ "max_stretch": float(max_stretch),
+ "min_delta": int(min_delta),
+ "objective": obj,
+ "protect_rigid": (
+ "off"
+ if _protect_is_off(protect_rigid)
+ else str(protect_rigid)
+ ),
+ "frozen_n": len(frozen),
+ "freeze_layers": list(freeze_layers or []),
+ "freeze_levels": list(freeze_levels or []),
+ }
+ st.meta["orbit_sweep"] = meta
+ return OpResult(
+ state=st,
+ moved=moved,
+ op="orbit_sweep_until_limit",
+ params=meta,
+ note=(
+ f"orbit_sweep_until_limit {global0}->{end_g} "
+ f"moves={len(moves)} bundle={bundle_moves} "
+ f"stop={stop_reason} frozen={len(frozen)}"
+ ),
+ )
+
+
def orbit_sweep_round(
state: LayoutState,
*,
@@ -652,6 +1521,8 @@ def orbit_sweep_round(
min_angle_sep: float = 35.0,
protect_rigid: bool | str = "off",
frozen_ids: set[str] | None = None,
+ freeze_layers: list[str] | None = None,
+ freeze_levels: list[Any] | None = None,
focus_ids: list[str] | None = None,
y_min: float | None = None,
y_max: float | None = None,
@@ -660,6 +1531,7 @@ def orbit_sweep_round(
"""Scan hot nodes; auto-apply each node's rank-1 if the active objective improves.
Default ``protect_rigid=off`` (may move portals). Opt in with portals/all.
+ Explicit ``portal_ids`` / ``freeze_layers`` / ``freeze_levels`` are always honored.
"""
params = params or LayoutParams()
st = state.copy()
@@ -668,7 +1540,14 @@ def orbit_sweep_round(
links = list(st.links)
adj = {n: set(st.adj.get(n, ())) for n in pos}
- frozen = _resolve_frozen(st, protect_rigid, frozen_ids)
+ frozen = _merge_explicit_freeze(
+ st,
+ protect_rigid=protect_rigid,
+ frozen_ids=frozen_ids,
+ freeze_layers=freeze_layers,
+ freeze_levels=freeze_levels,
+ always_honor_explicit=True,
+ )
global0 = count_edge_crossings(pos, links)
hit = crossing_participation(pos, links)[1]
@@ -803,26 +1682,49 @@ def orbit_params_from_overrides(overrides: dict[str, Any] | None) -> dict[str, A
out["node_id"] = str(o.get("fabric_node_id") or "").strip()
if o.get("pick") is not None:
try:
- out["pick"] = max(1, min(3, int(o["pick"])))
+ out["pick"] = max(1, min(12, int(o["pick"])))
except (TypeError, ValueError):
out["pick"] = 1
if o.get("round") is not None:
- out["round"] = str(o.get("round")).lower() not in {
- "0",
- "false",
- "no",
- "off",
- "",
- } or o.get("round") is True
+ out["round"] = _truthy_flag(o.get("round"))
+ until = o.get("until_limit")
+ if until is None:
+ until = o.get("until_stall")
+ if until is not None:
+ out["until_limit"] = _truthy_flag(until)
for key, cast, default in (
("top_n", int, 12),
- ("max_degree", int, 9),
+ ("max_degree", int, 14),
("angle_step", int, None),
- ("cand_cap", int, 280),
+ ("cand_cap", int, 360),
("top_k", int, 3),
+ ("max_moves", int, 40),
+ ("stall_limit", int, 12),
+ ("min_delta", int, 1),
+ ("scan_cap", int, 32),
):
if key not in o or o[key] is None:
- if default is not None:
+ if default is not None and (
+ key in ("top_n", "max_degree", "cand_cap", "top_k")
+ or out.get("until_limit")
+ ):
+ # until_limit-only knobs only default when flag on
+ if key in (
+ "max_moves",
+ "stall_limit",
+ "min_delta",
+ "scan_cap",
+ ) and not out.get("until_limit"):
+ continue
+ if key == "max_degree" and out.get("until_limit"):
+ out[key] = 14
+ continue
+ if key == "cand_cap" and out.get("until_limit"):
+ out[key] = 360
+ continue
+ if key == "top_k" and out.get("until_limit"):
+ out[key] = 8
+ continue
out[key] = default
continue
try:
@@ -835,6 +1737,15 @@ def orbit_params_from_overrides(overrides: dict[str, Any] | None) -> dict[str, A
out["max_jump"] = float(o["max_jump"])
except (TypeError, ValueError):
pass
+ elif out.get("until_limit"):
+ out["max_jump"] = 4000.0
+ if o.get("max_stretch") is not None:
+ try:
+ out["max_stretch"] = float(o["max_stretch"])
+ except (TypeError, ValueError):
+ pass
+ elif out.get("until_limit"):
+ out["max_stretch"] = 32.0
if o.get("nn_floor") is not None:
try:
out["nn_floor"] = float(o["nn_floor"])
@@ -849,6 +1760,23 @@ def orbit_params_from_overrides(overrides: dict[str, Any] | None) -> dict[str, A
out["min_angle_sep"] = 35.0
else:
out["min_angle_sep"] = 35.0
+ if "prefer_low_degree" in o:
+ out["prefer_low_degree"] = _truthy_flag(o.get("prefer_low_degree"))
+ elif out.get("until_limit"):
+ out["prefer_low_degree"] = True
+ if "bundle" in o:
+ out["bundle"] = _truthy_flag(o.get("bundle"))
+ elif "bundle_orbit" in o:
+ out["bundle"] = _truthy_flag(o.get("bundle_orbit"))
+ elif out.get("until_limit"):
+ out["bundle"] = True
+ if o.get("bundle_max") is not None:
+ try:
+ out["bundle_max"] = max(1, min(40, int(o["bundle_max"])))
+ except (TypeError, ValueError):
+ out["bundle_max"] = 10
+ elif out.get("until_limit"):
+ out["bundle_max"] = 10
if "protect_rigid" in o:
v = o["protect_rigid"]
if isinstance(v, bool):
@@ -863,16 +1791,32 @@ def orbit_params_from_overrides(overrides: dict[str, Any] | None) -> dict[str, A
out["protect_rigid"] = "all"
else:
out["protect_rigid"] = key
+ elif out.get("until_limit"):
+ # Eye polish: freeze portals by default (unlike single-node / round).
+ out["protect_rigid"] = "portals"
else:
# orbit breaks rigid by default (opt in with protect_rigid=portals).
out["protect_rigid"] = "off"
focus = o.get("focus_ids") or o.get("focus_node_ids")
if isinstance(focus, list):
out["focus_ids"] = [str(x).strip() for x in focus if str(x).strip()]
- # portal freeze from polish path
- raw_p = o.get("portal_ids")
+ # portal freeze from polish path / until_limit
+ raw_p = o.get("portal_ids") or o.get("frozen_ids")
if isinstance(raw_p, list):
out["frozen_ids"] = {str(x) for x in raw_p if str(x)}
+ elif isinstance(raw_p, set):
+ out["frozen_ids"] = {str(x) for x in raw_p if str(x)}
+ # freeze by layout layer and/or fabric numeric level
+ flayers = o.get("freeze_layers")
+ if flayers is None:
+ flayers = o.get("frozen_layers")
+ if flayers is not None:
+ out["freeze_layers"] = _coerce_str_list(flayers)
+ flevels = o.get("freeze_levels")
+ if flevels is None:
+ flevels = o.get("frozen_levels")
+ if flevels is not None:
+ out["freeze_levels"] = _coerce_str_list(flevels)
# Layered y constraint (y_min/y_max): keep node within its layer band
for yk in ("y_min", "y_max"):
if o.get(yk) is not None:
@@ -883,6 +1827,20 @@ def orbit_params_from_overrides(overrides: dict[str, Any] | None) -> dict[str, A
# Multi-objective ranking: "crossing" (default) or "total"
obj = str(o.get("objective") or "crossing").strip().lower()
out["objective"] = "total" if obj in ("total", "score", "multi") else "crossing"
+ # until_limit defaults: larger jump + more picks in the pool
+ if out.get("until_limit"):
+ if "max_jump" not in out:
+ out["max_jump"] = 4000.0
+ if "max_degree" not in o or o.get("max_degree") is None:
+ out["max_degree"] = 14
+ if "top_k" not in o or o.get("top_k") is None:
+ out["top_k"] = 8
+ if "cand_cap" not in o or o.get("cand_cap") is None:
+ out["cand_cap"] = 360
+ if "max_stretch" not in out:
+ out["max_stretch"] = 32.0
+ if "scan_cap" not in o or o.get("scan_cap") is None:
+ out["scan_cap"] = 32
return out
diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/press_crossings.py b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/press_crossings.py
index 2484547..aff469f 100644
--- a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/press_crossings.py
+++ b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/press_crossings.py
@@ -161,7 +161,8 @@ def _large_graph_budget(n_links: int) -> dict[str, Any]:
"cross_max_moves": 40,
"cross_max_sweeps": 6,
"cross_cand_cap": 220,
- "straighten": True,
+ # Off by default: straighten flattens dual-unit petal eyes into H-chains.
+ "straighten": False,
"skip_dual_full_x": False,
"untangle_rounds": 120,
"untangle_moves": 5,
@@ -170,6 +171,27 @@ def _large_graph_budget(n_links: int) -> dict[str, Any]:
}
+def _has_petal_dual_eye(state: LayoutState) -> bool:
+ """True when a multi-corridor dual-unit covers a large share of the canvas."""
+ try:
+ from netx_topology_mcp.layout_ops.dual_units import (
+ classify_dual_unit,
+ find_dual_portal_units,
+ )
+ except Exception:
+ return False
+ units = find_dual_portal_units(state, max_units=24)
+ if not units:
+ return False
+ n = max(1, len(state.positions) or len(state.names) or len(state.adj))
+ for u in units:
+ if classify_dual_unit(u) != "petal":
+ continue
+ if len(u.member_ids()) >= max(8, int(0.25 * n)):
+ return True
+ return False
+
+
def press_hot_edges(
state: LayoutState,
params: LayoutParams | None = None,
@@ -586,17 +608,47 @@ def polish_crossings(
top_n: int | None = None,
max_moves: int | None = None,
max_sweeps: int | None = None,
+ preserve_dual_eye: bool | None = None,
) -> OpResult:
- """Pipeline: park phantoms → straighten → hot_edges → crossers → untangle."""
+ """Pipeline: park phantoms → (optional straighten) → hot → crossers → untangle.
+
+ Petal dual-unit eyes: straighten stays off unless ``straighten=true`` —
+ channel flattening destroys parallel-lane eye geometry.
+ """
from netx_topology_mcp.layout_jobs import raise_if_cancelled, report_progress
params = params or LayoutParams()
st = state.copy()
park_phantom_nodes(st)
budget = _large_graph_budget(len(st.links))
+ preserve = True if preserve_dual_eye is None else bool(preserve_dual_eye)
+ petal_eye = False
+ eye_portals: list[str] = []
+ if preserve:
+ try:
+ from netx_topology_mcp.layout_ops.dual_units import (
+ classify_dual_unit,
+ find_dual_portal_units,
+ )
+
+ units = find_dual_portal_units(st, max_units=24)
+ n = max(1, len(st.positions) or len(st.names) or len(st.adj))
+ for u in units:
+ if classify_dual_unit(u) != "petal":
+ continue
+ if len(u.member_ids()) >= max(8, int(0.25 * n)):
+ petal_eye = True
+ eye_portals = [u.portal_a, u.portal_b]
+ break
+ except Exception:
+ petal_eye = _has_petal_dual_eye(st)
+ if portal_ids is None and eye_portals:
+ portal_ids = eye_portals
# Giant graphs: never allow straighten even if caller asks (stalls for minutes).
if len(st.links) >= 800:
do_straighten = False
+ elif petal_eye and straighten is not True:
+ do_straighten = False
else:
do_straighten = (
bool(budget["straighten"]) if straighten is None else bool(straighten)
@@ -720,6 +772,8 @@ def polish_crossings(
"focus_n": len(focus),
"budget": budget,
"straighten": do_straighten,
+ "preserve_dual_eye": preserve,
+ "petal_eye_detected": petal_eye,
"untangle_rounds": untangle_rounds,
}
return OpResult(
@@ -753,6 +807,13 @@ def press_params_from_overrides(overrides: dict[str, Any] | None) -> dict[str, A
if isinstance(v, bool)
else str(v).strip().lower() in {"1", "true", "yes", "on"}
)
+ if "preserve_dual_eye" in o:
+ v = o["preserve_dual_eye"]
+ out["preserve_dual_eye"] = (
+ v
+ if isinstance(v, bool)
+ else str(v).strip().lower() in {"1", "true", "yes", "on"}
+ )
if "portal_ids" in o and isinstance(o["portal_ids"], list):
out["portal_ids"] = [str(x) for x in o["portal_ids"] if str(x)]
if "source_view_ids" in o and isinstance(o["source_view_ids"], list):
diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/pull_far_chains.py b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/pull_far_chains.py
new file mode 100644
index 0000000..94389ce
--- /dev/null
+++ b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/pull_far_chains.py
@@ -0,0 +1,354 @@
+"""Pull far deg≤2 chains / isolates toward the eye portal anchor.
+
+Unlike uniform ``compact_bbox``, only moves far corridors (and deg=0 orphans).
+Hard gates: crossings not rise, overlaps stay 0, portals frozen.
+Scale is toward the portal mid-point (not the chain hub) so outer hubs
+still shrink the canvas bbox.
+"""
+
+from __future__ import annotations
+
+import math
+from typing import Any
+
+from netx_topology_mcp.layout_metrics import count_edge_crossings, compute_edge_clearance
+from netx_topology_mcp.layout_ops.graph_util import bbox
+from netx_topology_mcp.layout_ops.orbit_sweep import _has_any_footprint_overlap
+from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult
+from netx_topology_mcp.layout_topology_quality import extract_chain_paths
+
+
+def _anchor(pos: dict[str, tuple[float, float]], frozen: set[str]) -> tuple[float, float]:
+ pts = [pos[n] for n in frozen if n in pos]
+ if pts:
+ return (
+ sum(p[0] for p in pts) / len(pts),
+ sum(p[1] for p in pts) / len(pts),
+ )
+ xs = [p[0] for p in pos.values()] or [0.0]
+ ys = [p[1] for p in pos.values()] or [0.0]
+ return ((min(xs) + max(xs)) / 2.0, (min(ys) + max(ys)) / 2.0)
+
+
+def _area(pos: dict[str, tuple[float, float]]) -> float:
+ if len(pos) < 2:
+ return 1.0
+ x0, y0, x1, y1 = bbox(pos)
+ return max((x1 - x0) * (y1 - y0), 1.0)
+
+
+def _clr_hits(pos, links, names) -> int:
+ ec = compute_edge_clearance(pos, links, names=names, top_n=1)
+ if ec.get("edge_clearance_skipped"):
+ return 10**9
+ return int(ec.get("edge_clearance_hits") or 0)
+
+
+def _try_scale(
+ pos: dict[str, tuple[float, float]],
+ mobile: list[str],
+ *,
+ cx: float,
+ cy: float,
+ scale: float,
+ links,
+ names,
+ g0: int,
+ clr0: int,
+ max_clearance_slack: int,
+) -> tuple[dict[str, tuple[float, float]], int, int, float] | None:
+ trial = dict(pos)
+ for n in mobile:
+ x, y = pos[n]
+ trial[n] = (cx + (x - cx) * scale, cy + (y - cy) * scale)
+ if _has_any_footprint_overlap(trial, names):
+ return None
+ g1 = count_edge_crossings(trial, links)
+ if g1 > g0:
+ return None
+ clr1 = _clr_hits(trial, links, names)
+ if clr1 > clr0 + max(0, int(max_clearance_slack)):
+ return None
+ return trial, g1, clr1, _area(trial)
+
+
+def pull_far_chains(
+ state: LayoutState,
+ params: LayoutParams | None = None,
+ *,
+ frozen_ids: set[str] | None = None,
+ portal_ids: list[str] | None = None,
+ max_chains: int = 16,
+ min_tip_radius: float = 1800.0,
+ scales: tuple[float, ...] = (0.92, 0.88, 0.84, 0.80, 0.75),
+ max_clearance_slack: int = 40,
+ pull_isolates: bool = True,
+) -> OpResult:
+ """Shorten farthest corridors / orphans toward the portal mid-point."""
+ del params
+ st = state.copy()
+ pos = dict(st.positions)
+ names = st.names
+ links = list(st.links)
+ adj = st.adj
+ frozen: set[str] = set(frozen_ids or ())
+ for p in portal_ids or []:
+ if p:
+ frozen.add(str(p))
+
+ g0 = count_edge_crossings(pos, links)
+ if _has_any_footprint_overlap(pos, names):
+ return OpResult(
+ state=st,
+ moved=set(),
+ op="pull_far_chains",
+ note="pull_far_chains:overlaps_before",
+ params={"error": "overlaps_before"},
+ )
+ clr0 = _clr_hits(pos, links, names)
+ area0 = _area(pos)
+ cx, cy = _anchor(pos, frozen)
+
+ chains = extract_chain_paths(adj)
+ scored: list[tuple[float, list[str], str]] = []
+ for path in chains:
+ path = [n for n in path if n in pos]
+ if len(path) < 2:
+ continue
+ d0 = math.hypot(pos[path[0]][0] - cx, pos[path[0]][1] - cy)
+ d1 = math.hypot(pos[path[-1]][0] - cx, pos[path[-1]][1] - cy)
+ tip = path[0] if d0 >= d1 else path[-1]
+ hub = path[-1] if tip == path[0] else path[0]
+ if tip in frozen:
+ continue
+ tip_r = max(d0, d1)
+ if tip_r < float(min_tip_radius):
+ continue
+ scored.append((tip_r, path, hub))
+ scored.sort(key=lambda t: t[0], reverse=True)
+
+ moved: set[str] = set()
+ accepted: list[dict[str, Any]] = []
+ used: set[str] = set(frozen)
+
+ for tip_r, path, hub in scored[: max(1, int(max_chains))]:
+ # Scale corridor toward portal mid-point (shrinks bbox even if hub is outer).
+ mobile = [n for n in path if n not in used and n in pos and n not in frozen]
+ if len(mobile) < 1:
+ continue
+ best_local = None
+ best_key = None
+ for scale in scales:
+ got = _try_scale(
+ pos,
+ mobile,
+ cx=cx,
+ cy=cy,
+ scale=scale,
+ links=links,
+ names=names,
+ g0=g0,
+ clr0=clr0,
+ max_clearance_slack=max_clearance_slack,
+ )
+ if got is None:
+ continue
+ trial, g1, clr1, area1 = got
+ key = (g1, area1, clr1)
+ if best_key is None or key < best_key:
+ best_key = key
+ best_local = (trial, scale, g1, clr1, area1)
+ if best_local is None:
+ continue
+ trial, scale, g1, clr1, area1 = best_local
+ tip = (
+ path[0]
+ if math.hypot(pos[path[0]][0] - cx, pos[path[0]][1] - cy)
+ >= math.hypot(pos[path[-1]][0] - cx, pos[path[-1]][1] - cy)
+ else path[-1]
+ )
+ tip_r1 = math.hypot(trial[tip][0] - cx, trial[tip][1] - cy)
+ if tip_r1 >= tip_r - 1.0 and area1 >= area0 * 0.999:
+ continue
+ pos = trial
+ g0 = g1
+ clr0 = clr1
+ for n in mobile:
+ moved.add(n)
+ used.add(n)
+ accepted.append(
+ {
+ "hub": hub,
+ "tip": tip,
+ "member_n": len(mobile),
+ "scale": scale,
+ "tip_r0": round(tip_r, 1),
+ "tip_r1": round(tip_r1, 1),
+ }
+ )
+
+ isolates_n = 0
+ if pull_isolates:
+ orphans = [
+ n
+ for n, nbs in adj.items()
+ if n in pos
+ and n not in used
+ and n not in frozen
+ and len(nbs) == 0
+ and math.hypot(pos[n][0] - cx, pos[n][1] - cy) >= float(min_tip_radius) * 0.6
+ ]
+ orphans.sort(
+ key=lambda n: math.hypot(pos[n][0] - cx, pos[n][1] - cy),
+ reverse=True,
+ )
+ for n in orphans[: max(4, int(max_chains) // 2)]:
+ best_local = None
+ best_key = None
+ for scale in scales:
+ got = _try_scale(
+ pos,
+ [n],
+ cx=cx,
+ cy=cy,
+ scale=scale,
+ links=links,
+ names=names,
+ g0=g0,
+ clr0=clr0,
+ max_clearance_slack=max_clearance_slack,
+ )
+ if got is None:
+ continue
+ trial, g1, clr1, area1 = got
+ key = (g1, area1, clr1)
+ if best_key is None or key < best_key:
+ best_key = key
+ best_local = (trial, scale, g1, clr1)
+ if best_local is None:
+ continue
+ trial, scale, g1, clr1 = best_local
+ pos = trial
+ g0 = g1
+ clr0 = clr1
+ moved.add(n)
+ used.add(n)
+ isolates_n += 1
+ accepted.append(
+ {
+ "hub": None,
+ "tip": n,
+ "member_n": 1,
+ "scale": scale,
+ "isolate": True,
+ }
+ )
+
+ leaves = [
+ n
+ for n, nbs in adj.items()
+ if n in pos
+ and n not in used
+ and n not in frozen
+ and len(nbs) == 1
+ and math.hypot(pos[n][0] - cx, pos[n][1] - cy) >= float(min_tip_radius)
+ ]
+ leaves.sort(
+ key=lambda n: math.hypot(pos[n][0] - cx, pos[n][1] - cy),
+ reverse=True,
+ )
+ for n in leaves[: max(4, int(max_chains) // 2)]:
+ best_local = None
+ best_key = None
+ for scale in scales:
+ got = _try_scale(
+ pos,
+ [n],
+ cx=cx,
+ cy=cy,
+ scale=scale,
+ links=links,
+ names=names,
+ g0=g0,
+ clr0=clr0,
+ max_clearance_slack=max_clearance_slack,
+ )
+ if got is None:
+ continue
+ trial, g1, clr1, area1 = got
+ key = (g1, area1, clr1)
+ if best_key is None or key < best_key:
+ best_key = key
+ best_local = (trial, scale, g1, clr1)
+ if best_local is None:
+ continue
+ trial, scale, g1, clr1 = best_local
+ pos = trial
+ g0 = g1
+ clr0 = clr1
+ moved.add(n)
+ used.add(n)
+ accepted.append(
+ {
+ "hub": next(iter(adj.get(n) or ()), None),
+ "tip": n,
+ "member_n": 1,
+ "scale": scale,
+ "leaf": True,
+ }
+ )
+
+ st.positions = pos
+ st.last_moved = moved
+ g_end = count_edge_crossings(pos, links)
+ clr_end = _clr_hits(pos, links, names)
+ meta = {
+ "mode": "pull_far_chains",
+ "chains_tried": min(len(scored), max(1, int(max_chains))),
+ "chains_accepted": len(accepted),
+ "accepted_chains": accepted[:20],
+ "moved_n": len(moved),
+ "isolates_pulled": isolates_n,
+ "start_area": round(area0, 1),
+ "end_area": round(_area(pos), 1),
+ "start_crossings": count_edge_crossings(dict(state.positions), list(state.links)),
+ "end_crossings": g_end,
+ "start_clearance_hits": _clr_hits(dict(state.positions), list(state.links), names),
+ "end_clearance_hits": clr_end,
+ }
+ st.meta["pull_far_chains"] = meta
+ return OpResult(
+ state=st,
+ moved=moved,
+ op="pull_far_chains",
+ params=meta,
+ note=(
+ f"pull_far_chains n={len(accepted)} moved={len(moved)} "
+ f"area={meta['start_area']}→{meta['end_area']} "
+ f"x={meta['start_crossings']}→{meta['end_crossings']}"
+ ),
+ )
+
+
+def pull_far_chains_params_from_overrides(overrides: dict[str, Any] | None) -> dict[str, Any]:
+ o = overrides or {}
+ portals = o.get("portal_ids") or o.get("portals") or []
+ if isinstance(portals, str):
+ portals = [portals]
+ frozen = o.get("frozen_ids") or []
+ if isinstance(frozen, str):
+ frozen = [frozen]
+ scales = o.get("scales")
+ if isinstance(scales, (list, tuple)) and scales:
+ sc = tuple(float(x) for x in scales)
+ else:
+ sc = (0.92, 0.88, 0.84, 0.80, 0.75)
+ return {
+ "portal_ids": [str(x) for x in portals if str(x).strip()],
+ "frozen_ids": {str(x) for x in frozen if str(x).strip()},
+ "max_chains": int(o.get("max_chains") or 16),
+ "min_tip_radius": float(o.get("min_tip_radius") or 1800.0),
+ "scales": sc,
+ "max_clearance_slack": int(o.get("max_clearance_slack") or 40),
+ "pull_isolates": bool(o.get("pull_isolates", True)),
+ }
diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/sink_dual_units.py b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/sink_dual_units.py
index df45421..afea482 100644
--- a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/sink_dual_units.py
+++ b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/sink_dual_units.py
@@ -20,10 +20,10 @@ def _portal_share_counts(units: list[DualUnit]) -> dict[str, int]:
def unit_detach_score(u: DualUnit, share: dict[str, int]) -> tuple[int, int, int]:
- """Lower is better: less shared portals, smaller unit, lower unit_id."""
+ """Tie-break only: less shared portals, then lower unit_id (stable)."""
shared = sum(1 for p in (u.portal_a, u.portal_b) if share.get(p, 0) > 1)
share_sum = share.get(u.portal_a, 0) + share.get(u.portal_b, 0)
- return (shared, share_sum, len(u.member_ids()), int(u.unit_id))
+ return (shared, share_sum, int(u.unit_id))
def select_dual_unit_batch(
@@ -31,53 +31,118 @@ def select_dual_unit_batch(
*,
max_units: int = 3,
min_nodes: int = 8,
- max_nodes: int = 80,
- max_batch_nodes: int = 120,
+ max_nodes: int = 300,
+ max_batch_nodes: int = 400,
exclude_ids: set[str] | None = None,
+ keep_ids: set[str] | None = None,
+ sink_ids: set[str] | None = None,
+ prefer_pure: bool = False,
+ prefer_core_eye: bool = True,
+ prefer_top_eye: bool | None = None,
+ layers: dict[str, str] | None = None,
) -> list[DualUnit]:
- """Greedy pick detachable dual-units within size / batch caps."""
+ """Greedy pick detachable dual-units within size / batch caps.
+
+ Eyes walk **top→down** (core → agg → access): prefer core/agg portal
+ pairs and maximize movable membership. ``prefer_core_eye`` is an alias
+ for ``prefer_top_eye`` (default true).
+ """
exclude_ids = exclude_ids or set()
+ keep_ids = keep_ids or set()
+ layers = layers or {}
+ if prefer_top_eye is None:
+ prefer_top_eye = prefer_core_eye
share = _portal_share_counts(units)
candidates: list[DualUnit] = []
for u in units:
members = u.member_ids()
if not members:
continue
+ movable = members - keep_ids
+ if sink_ids is not None:
+ movable = movable & sink_ids
+ if not movable or movable.issubset(exclude_ids):
+ continue
n = len(members)
if n < int(min_nodes) or n > int(max_nodes):
continue
- # Skip units already fully present on sink (nothing new to move).
- if members and members.issubset(exclude_ids):
+ if members and members.issubset(exclude_ids | keep_ids):
+ continue
+ # Need enough *movable* mass for this level phase.
+ if len(movable) < max(2, min(4, int(min_nodes) // 2)):
continue
candidates.append(u)
- candidates.sort(key=lambda u: unit_detach_score(u, share))
+
+ def _movable(u: DualUnit) -> set[str]:
+ m = u.member_ids() - keep_ids
+ if sink_ids is not None:
+ m = m & sink_ids
+ return m
+
+ def _portal_tier(nid: str) -> int:
+ ly = (layers.get(nid) or "").strip().lower()
+ if ly == "core":
+ return 0
+ if ly == "agg":
+ return 1
+ if ly == "access":
+ return 2
+ return 3
+
+ def _eye_key(u: DualUnit) -> tuple[int, int]:
+ t = sorted((_portal_tier(u.portal_a), _portal_tier(u.portal_b)))
+ return (t[0], t[1])
+
+ def _score(u: DualUnit) -> tuple:
+ mov = _movable(u)
+ keep_portals = sum(1 for p in (u.portal_a, u.portal_b) if p in keep_ids)
+ pure_penalty = keep_portals if prefer_pure else 0
+ # Top-down eye tier, then max movable / membership.
+ eye = _eye_key(u) if prefer_top_eye else (0, 0)
+ return (
+ pure_penalty,
+ eye,
+ -len(mov),
+ -len(u.member_ids()),
+ *unit_detach_score(u, share),
+ )
+
+ candidates.sort(key=_score)
picked: list[DualUnit] = []
claimed: set[str] = set()
for u in candidates:
if len(picked) >= int(max_units):
break
- members = u.member_ids()
- # Prefer units whose interiors are not already claimed this batch.
- interior = members - {u.portal_a, u.portal_b}
+ movable = _movable(u)
+ interior = movable - {u.portal_a, u.portal_b}
if interior & claimed:
continue
- next_ids = claimed | members
+ next_ids = claimed | movable
if len(next_ids) > int(max_batch_nodes):
continue
picked.append(u)
- claimed |= members
+ claimed |= movable
return picked
-def batch_node_ids(units: list[DualUnit]) -> list[str]:
+def batch_node_ids(
+ units: list[DualUnit],
+ *,
+ keep_ids: set[str] | None = None,
+ sink_ids: set[str] | None = None,
+) -> list[str]:
+ keep_ids = keep_ids or set()
out: list[str] = []
seen: set[str] = set()
for u in units:
for nid in sorted(u.member_ids()):
- if nid and nid not in seen:
- seen.add(nid)
- out.append(nid)
+ if not nid or nid in seen or nid in keep_ids:
+ continue
+ if sink_ids is not None and nid not in sink_ids:
+ continue
+ seen.add(nid)
+ out.append(nid)
return out
@@ -86,15 +151,20 @@ def leftover_batch_ids(
*,
max_batch_nodes: int = 120,
exclude_ids: set[str] | None = None,
+ keep_ids: set[str] | None = None,
+ sink_ids: set[str] | None = None,
) -> list[str]:
"""When dual_units are exhausted, take a plain leftover chunk."""
exclude_ids = exclude_ids or set()
+ keep_ids = keep_ids or set()
out: list[str] = []
for nid in source_ids:
sid = str(nid or "").strip()
if not sid or sid.startswith("region:"):
continue
- if sid in exclude_ids:
+ if sid in exclude_ids or sid in keep_ids:
+ continue
+ if sink_ids is not None and sid not in sink_ids:
continue
out.append(sid)
if len(out) >= int(max_batch_nodes):
diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/state.py b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/state.py
index 08dd19f..2476a76 100644
--- a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/state.py
+++ b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/state.py
@@ -43,6 +43,8 @@ class LayoutState:
pinned: set[str] = field(default_factory=set)
names: dict[str, str] = field(default_factory=dict)
layers: dict[str, str] = field(default_factory=dict)
+ # Optional fabric numeric level (1 / 1.1 / 2 …); used by freeze_levels.
+ levels: dict[str, float] = field(default_factory=dict)
links: list[tuple[str, str]] = field(default_factory=list)
adj: dict[str, set[str]] = field(default_factory=dict)
spine: set[str] = field(default_factory=set)
@@ -57,6 +59,7 @@ class LayoutState:
pinned=set(self.pinned),
names=dict(self.names),
layers=dict(self.layers),
+ levels=dict(self.levels),
links=list(self.links),
adj={k: set(v) for k, v in self.adj.items()},
spine=set(self.spine),
diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/suggest_sink_hubs.py b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/suggest_sink_hubs.py
new file mode 100644
index 0000000..a4a86fb
--- /dev/null
+++ b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/suggest_sink_hubs.py
@@ -0,0 +1,262 @@
+"""Suggest next non-dual sink batches: rank hub territories for move_nodes(park).
+
+After the one-shot dual_unit eye is fixed, remaining access should migrate by
+hub territory (not another dual sink). This module turns structure hubs +
+soft_blocks into ordered batches of fabric_node_ids.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+
+_LAYER_RANK = {"agg": 0, "core": 1, "access": 2, "external": 3, "other": 4}
+
+
+def _as_id_set(raw: Any) -> set[str]:
+ out: set[str] = set()
+ if raw is None:
+ return out
+ if isinstance(raw, (str, bytes)):
+ s = str(raw).strip()
+ if s:
+ out.add(s)
+ return out
+ if isinstance(raw, (list, tuple, set)):
+ for x in raw:
+ s = str(x or "").strip()
+ if s and not s.startswith("region:"):
+ out.add(s)
+ return out
+
+
+def _portal_ids_from_dual(dual_units: dict[str, Any] | None) -> set[str]:
+ out: set[str] = set()
+ if not isinstance(dual_units, dict):
+ return out
+ for u in dual_units.get("units") or []:
+ if not isinstance(u, dict):
+ continue
+ for k in ("portal_a", "portal_b"):
+ pid = str(u.get(k) or "").strip()
+ if pid:
+ out.add(pid)
+ return out
+
+
+def _hub_index(hubs: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
+ out: dict[str, dict[str, Any]] = {}
+ for h in hubs or []:
+ if not isinstance(h, dict):
+ continue
+ hid = str(h.get("fabric_node_id") or "").strip()
+ if hid:
+ out[hid] = h
+ return out
+
+
+def _blocks_by_hub(soft_blocks: dict[str, Any] | None) -> dict[str, dict[str, Any]]:
+ out: dict[str, dict[str, Any]] = {}
+ if not isinstance(soft_blocks, dict):
+ return out
+ for b in soft_blocks.get("blocks") or []:
+ if not isinstance(b, dict):
+ continue
+ hid = str(b.get("hub_id") or "").strip()
+ if not hid:
+ continue
+ # Prefer larger territory if duplicate hub rows
+ prev = out.get(hid)
+ n = len([x for x in (b.get("node_ids") or []) if x])
+ if prev is None or n > len([x for x in (prev.get("node_ids") or []) if x]):
+ out[hid] = b
+ return out
+
+
+def suggest_sink_hub_batches(
+ *,
+ hubs: list[dict[str, Any]] | None,
+ soft_blocks: dict[str, Any] | None,
+ source_ids: set[str],
+ sink_ids: set[str] | None = None,
+ exclude_ids: set[str] | None = None,
+ dual_units: dict[str, Any] | None = None,
+ min_territory: int = 1,
+ min_move_n: int = 1,
+ top_n: int = 12,
+ include_hub: bool = True,
+ only_layers: list[str] | None = None,
+) -> dict[str, Any]:
+ """Rank hub territories still on ``source_ids`` and not already on sink.
+
+ Ranking (desc): remaining move count → remaining access stubs → prefer agg
+ over core → structure hub score. Eye portals / ``exclude_ids`` never lead a
+ batch (and are dropped from id lists).
+ """
+ src = {str(x) for x in (source_ids or ()) if str(x) and not str(x).startswith("region:")}
+ on_sink = {str(x) for x in (sink_ids or ()) if str(x)}
+ excluded = set(exclude_ids or ())
+ excluded |= _portal_ids_from_dual(dual_units)
+
+ layer_allow: set[str] | None = None
+ if only_layers:
+ layer_allow = {str(x).strip().lower() for x in only_layers if str(x).strip()}
+
+ by_hub = _hub_index(list(hubs or []))
+ blocks = _blocks_by_hub(soft_blocks)
+
+ # Ensure every structure hub has a block (fallback: hub + stub_ids).
+ for hid, h in by_hub.items():
+ if hid in blocks:
+ continue
+ stubs = [str(x) for x in (h.get("stub_ids") or []) if str(x)]
+ blocks[hid] = {
+ "hub_id": hid,
+ "method": "hub_stubs",
+ "node_ids": [hid, *stubs],
+ "node_count": 1 + len(stubs),
+ }
+
+ batches: list[dict[str, Any]] = []
+ for hid, block in blocks.items():
+ hmeta = by_hub.get(hid) or {}
+ layer = str(hmeta.get("layer") or "other").lower()
+ if layer_allow is not None and layer not in layer_allow:
+ continue
+
+ raw_ids = [str(x) for x in (block.get("node_ids") or []) if str(x)]
+ # Still on source, not yet on sink, not excluded portals
+ move_ids = [
+ nid
+ for nid in raw_ids
+ if nid in src and nid not in on_sink and nid not in excluded
+ ]
+ if not include_hub:
+ move_ids = [nid for nid in move_ids if nid != hid]
+ # Hub may already be on sink — still migrate remaining territory
+ if hid in on_sink and include_hub:
+ move_ids = [nid for nid in move_ids if nid != hid]
+
+ # Dedup preserve order
+ seen: set[str] = set()
+ ordered: list[str] = []
+ # Eye portals / exclude_ids never lead a batch, but leftover stubs under
+ # an already-sunk or portal hub may still migrate.
+ if include_hub and hid in src and hid not in on_sink and hid not in excluded:
+ ordered.append(hid)
+ seen.add(hid)
+ for nid in move_ids:
+ if nid in seen:
+ continue
+ seen.add(nid)
+ ordered.append(nid)
+ # Portal hub with nothing left to move (except itself) → skip
+ if hid in excluded and not ordered:
+ continue
+
+ if len(ordered) < max(1, int(min_move_n)):
+ continue
+
+ # Territory signal: prefer structure territory, else remaining stubs
+ struct_terr = int(hmeta.get("territory") or 0)
+ remaining_n = len(ordered)
+ # Count non-hub members as remaining territory proxy
+ remaining_terr = max(0, remaining_n - (1 if hid in ordered else 0))
+ if remaining_terr < max(0, int(min_territory)):
+ continue
+
+ access_n = int(hmeta.get("access_neighbors") or 0)
+ score = float(hmeta.get("score") or 0.0)
+ batches.append(
+ {
+ "hub_id": hid,
+ "hub_name": str(hmeta.get("name") or hid),
+ "layer": layer,
+ "degree": int(hmeta.get("degree") or 0),
+ "structure_territory": struct_terr,
+ "structure_access_neighbors": access_n,
+ "structure_score": score,
+ "remaining_n": remaining_n,
+ "remaining_territory": remaining_terr,
+ "block_method": str(block.get("method") or ""),
+ "fabric_node_ids": ordered,
+ "already_on_sink": hid in on_sink,
+ "excluded_from_batch": sorted(
+ nid for nid in raw_ids if nid in excluded or nid in on_sink
+ )[:40],
+ }
+ )
+
+ batches.sort(
+ key=lambda b: (
+ -int(b["remaining_territory"]),
+ -int(b["remaining_n"]),
+ _LAYER_RANK.get(str(b["layer"]), 9),
+ -float(b["structure_score"]),
+ str(b["hub_name"]),
+ )
+ )
+
+ # Orphan leftovers: source ids with no hub territory still need park batches.
+ covered: set[str] = set()
+ for b in batches:
+ covered |= set(b.get("fabric_node_ids") or [])
+ orphans = sorted(
+ nid
+ for nid in src
+ if nid not in on_sink and nid not in excluded and nid not in covered
+ )
+ orphan_batches: list[dict[str, Any]] = []
+ if orphans and len(batches) < max(1, min(40, int(top_n))):
+ # Chunk orphans so park stays stable (~8 per batch).
+ chunk = 8
+ for i in range(0, len(orphans), chunk):
+ ids = orphans[i : i + chunk]
+ orphan_batches.append(
+ {
+ "hub_id": ids[0],
+ "hub_name": f"orphan_batch_{i // chunk + 1}",
+ "layer": "other",
+ "degree": 0,
+ "structure_territory": 0,
+ "structure_access_neighbors": 0,
+ "structure_score": 0.0,
+ "remaining_n": len(ids),
+ "remaining_territory": len(ids),
+ "block_method": "orphan_leftovers",
+ "fabric_node_ids": ids,
+ "already_on_sink": False,
+ "excluded_from_batch": [],
+ "orphan": True,
+ }
+ )
+ batches.extend(orphan_batches)
+
+ top = batches[: max(1, min(40, int(top_n)))]
+ for i, b in enumerate(top, start=1):
+ b["rank"] = i
+
+ return {
+ "ok": True,
+ "batch_count": len(top),
+ "excluded_n": len(excluded),
+ "excluded_ids": sorted(excluded)[:40],
+ "source_n": len(src),
+ "sink_n": len(on_sink),
+ "orphan_n": len(orphans),
+ "batches": top,
+ "hint": (
+ "Pick batches[0] (or pick=N) → layoutTopologyView move_nodes "
+ "source→sink with park=true. Do NOT sinkTopologyDualUnits again. "
+ "Eye portals are excluded from leading a batch. "
+ "orphan_batch_* = disconnected leftovers (park in chunks)."
+ ),
+ }
+
+
+def pick_batch(report: dict[str, Any], pick: int = 1) -> dict[str, Any] | None:
+ batches = list(report.get("batches") or [])
+ if not batches:
+ return None
+ idx = max(1, min(len(batches), int(pick or 1))) - 1
+ return dict(batches[idx])
diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_stats.py b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_stats.py
index 79000fb..ab667ff 100644
--- a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_stats.py
+++ b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_stats.py
@@ -21,12 +21,65 @@ from netx_topology_mcp.layout_metrics import (
NN_SWEET_LO = 140.0
NN_SWEET_HI = 220.0
# Ideal space_utilization band (n * rec_tile / bbox_area).
-UTIL_SWEET_LO = 0.12
+# Floor aligned with real mid-size IPRAN / hand golden (~0.08–0.10), not 0.12 fantasy.
+UTIL_SWEET_LO = 0.08
UTIL_SWEET_HI = 0.45
# Ideal median undirected edge length / recommended pitch.
EDGE_SWEET_LO = 0.7
EDGE_SWEET_HI = 2.2
+# Score profiles: weights must sum to 1.0.
+# default = metro/flat canvases; eye = dual-portal arc-band sinks (diagonals expected).
+_SCORE_WEIGHTS: dict[str, dict[str, float]] = {
+ "default": {
+ "overlap": 0.24,
+ "crossing": 0.17,
+ "utilization": 0.13,
+ "chain": 0.10,
+ "rings": 0.06,
+ "edge_clearance": 0.10,
+ "edge_axis": 0.06,
+ "grid": 0.05,
+ "nn": 0.04,
+ "hull": 0.03,
+ "stretch": 0.02,
+ },
+ "eye": {
+ "overlap": 0.24,
+ "crossing": 0.16,
+ "utilization": 0.14,
+ "chain": 0.10,
+ "rings": 0.05,
+ "edge_clearance": 0.12,
+ "edge_axis": 0.03, # arc-band eyes intentionally diagonal
+ "grid": 0.05,
+ "nn": 0.04,
+ "hull": 0.05,
+ "stretch": 0.02,
+ },
+}
+
+
+def resolve_score_profile(
+ requested: str | None,
+ *,
+ node_count: int = 0,
+ space_utilization: float | None = None,
+ axis_frac: float | None = None,
+) -> str:
+ """Map request → default|eye. ``auto`` uses size/sparsity/axis heuristic."""
+ raw = str(requested or "auto").strip().lower() or "auto"
+ if raw in {"default", "metro", "flat", "corridor"}:
+ return "default"
+ if raw in {"eye", "dual", "dual_unit", "access_eye"}:
+ return "eye"
+ util = float(space_utilization) if space_utilization is not None else 1.0
+ axis = float(axis_frac) if axis_frac is not None else 1.0
+ # Large, still-sparse, diagonal-heavy → treat as dual-eye sink for scoring.
+ if int(node_count) >= 80 and util < 0.10 and axis < 0.55:
+ return "eye"
+ return "default"
+
def _convex_hull(points: list[tuple[float, float]]) -> list[tuple[float, float]]:
"""Andrew monotone chain; returns hull CCW, or [] if < 3 unique points."""
@@ -175,11 +228,15 @@ def compute_density_stats(
}
-def score_layout_components(metrics: dict[str, Any]) -> dict[str, Any]:
+def score_layout_components(
+ metrics: dict[str, Any],
+ *,
+ score_profile: str | None = None,
+) -> dict[str, Any]:
"""Weighted sub-scores in [0,1] + total in [0,100].
Hard gate: any footprint/label overlap → total capped near 0 (still report parts).
- Mid-tier: chain(直链成一体)+ rings(最小环不被穿), each weight 0.10.
+ Profiles: ``default`` (metro) | ``eye`` (dual-portal arc sink) | ``auto``.
"""
n = int(metrics.get("node_count") or 0)
overlaps = int(metrics.get("footprint_overlap_pairs") or 0)
@@ -204,6 +261,19 @@ def score_layout_components(metrics: dict[str, Any]) -> dict[str, Any]:
if metrics.get("edge_axis_score") is not None
else 1.0
)
+ axis_frac = metrics.get("axis_frac")
+ try:
+ axis_frac_f = float(axis_frac) if axis_frac is not None else None
+ except (TypeError, ValueError):
+ axis_frac_f = None
+
+ profile = resolve_score_profile(
+ score_profile if score_profile is not None else metrics.get("score_profile"),
+ node_count=n,
+ space_utilization=util,
+ axis_frac=axis_frac_f,
+ )
+ weights = dict(_SCORE_WEIGHTS.get(profile) or _SCORE_WEIGHTS["default"])
# Crossing: UME mid-size ~0.16; 0 → 1.0, 0.30 → 0
if n <= 50:
@@ -221,29 +291,22 @@ def score_layout_components(metrics: dict[str, Any]) -> dict[str, Any]:
overlap_s = 1.0 if overlaps == 0 and label_ov == 0 else 0.0
nn_s = _band_score(nn, NN_SWEET_LO, NN_SWEET_HI, hard_lo=40.0, hard_hi=500.0)
- util_s = _band_score(util, UTIL_SWEET_LO, UTIL_SWEET_HI, hard_lo=0.01, hard_hi=1.2)
- hull_s = _band_score(hull_u, UTIL_SWEET_LO, UTIL_SWEET_HI + 0.1, hard_lo=0.02, hard_hi=1.5)
- grid_s = _band_score(grid_occ, 0.25, 0.75, hard_lo=0.02, hard_hi=1.0)
+ # Eye sinks: blend bbox util with hull util so long corridors don't auto-fail.
+ if profile == "eye":
+ util_blend = 0.55 * util + 0.45 * hull_u
+ util_s = _band_score(util_blend, UTIL_SWEET_LO, UTIL_SWEET_HI, hard_lo=0.02, hard_hi=1.2)
+ hull_s = _band_score(hull_u, UTIL_SWEET_LO, UTIL_SWEET_HI + 0.1, hard_lo=0.03, hard_hi=1.5)
+ else:
+ util_s = _band_score(util, UTIL_SWEET_LO, UTIL_SWEET_HI, hard_lo=0.02, hard_hi=1.2)
+ hull_s = _band_score(hull_u, UTIL_SWEET_LO, UTIL_SWEET_HI + 0.1, hard_lo=0.03, hard_hi=1.5)
+ grid_s = _band_score(grid_occ, 0.20, 0.75, hard_lo=0.02, hard_hi=1.0)
stretch_s = _band_score(stretch_f, EDGE_SWEET_LO, EDGE_SWEET_HI, hard_lo=0.2, hard_hi=8.0)
- white_s = max(0.0, 1.0 - white) # less whitespace → better
+ white_s = max(0.0, 1.0 - white) # less whitespace → better (diagnostic only)
chain_s = max(0.0, min(1.0, chain_s))
rings_s = max(0.0, min(1.0, rings_s))
edge_clr_s = max(0.0, min(1.0, edge_clr_s))
edge_axis_s = max(0.0, min(1.0, edge_axis_s))
- weights = {
- "overlap": 0.24,
- "crossing": 0.18,
- "utilization": 0.12,
- "chain": 0.10,
- "rings": 0.10,
- "edge_clearance": 0.08,
- "edge_axis": 0.06,
- "grid": 0.04,
- "nn": 0.04,
- "hull": 0.02,
- "stretch": 0.02,
- }
parts = {
"overlap": round(overlap_s, 4),
"crossing": round(cross_s, 4),
@@ -256,9 +319,9 @@ def score_layout_components(metrics: dict[str, Any]) -> dict[str, Any]:
"nn": round(nn_s, 4),
"hull": round(hull_s, 4),
"stretch": round(stretch_s, 4),
+ # Diagnostic only — not in weights (avoid double-count with util/grid).
"compactness": round(white_s, 4),
}
- # compactness folded into util/grid already; keep as diagnostic
total = sum(parts[k] * weights[k] for k in weights)
if overlap_s < 1.0:
total *= 0.15 # hard gate: overlaps wreck the score
@@ -268,6 +331,7 @@ def score_layout_components(metrics: dict[str, Any]) -> dict[str, Any]:
"total": total_100,
"parts": parts,
"weights": weights,
+ "score_profile": profile,
"targets": {
"nn_sweet": [NN_SWEET_LO, NN_SWEET_HI],
"util_sweet": [UTIL_SWEET_LO, UTIL_SWEET_HI],
@@ -288,18 +352,16 @@ def score_layout_components(metrics: dict[str, Any]) -> dict[str, Any]:
-total_100,
int(metrics.get("edge_crossings") or 0),
-util,
+ -edge_clr_s,
-chain_s,
-rings_s,
- -edge_clr_s,
-edge_axis_s,
],
"hint": (
- "total∈[0,100]. Overlaps hard-gate the score. "
- "Mid-tier: chain=直链成一体、rings=最小环不被穿(各权 0.10);"
- "edge_clearance=网元勿贴非关联边(权 0.08);"
- "edge_axis=边宜水平/垂直且水平优先(权 0.06). "
- "Raise utilization/grid_occupancy without overlaps; "
- "keep crossings_per_link near ~0.16 (mid-size reference) and nn_p50 in 140–220."
+ f"total∈[0,100] profile={profile}. Overlaps hard-gate. "
+ "Clearance blends nodes_hit + hits/link. "
+ "Eye profile softens axis (arc bands) and rings; raises util/clearance. "
+ "compactness is diagnostic only (not weighted)."
),
}
@@ -313,9 +375,10 @@ def _status_from_score(part: float, *, fail_below: float = 0.01, warn_below: flo
def _sparsity_status(util: float, white: float, grid_occ: float) -> str:
- if util < 0.03 or white > 0.85 or grid_occ < 0.05:
+ # Aligned with UTIL_SWEET_LO=0.08: below sweet → warn; desolate → fail.
+ if util < 0.04 or white > 0.85 or grid_occ < 0.04:
return "fail"
- if util < 0.08 or white > 0.65 or grid_occ < 0.15:
+ if util < UTIL_SWEET_LO or white > 0.65 or grid_occ < 0.12:
return "warn"
return "ok"
@@ -594,8 +657,9 @@ def build_layout_report(metrics: dict[str, Any]) -> dict[str, Any]:
"只看本工具即可验收:verdict.total∈[0,100];"
"overlap/crossing/spacing/sparsity/edges/chains/rings/"
"edge_clearance/edge_axis 各有 status。"
- "中档:chains/rings 各权 0.10;edge_clearance=网元贴边(0.08);"
- "edge_axis=水平/垂直边且水平优先(0.06)。"
+ f"score_profile={score.get('score_profile') or 'default'}:"
+ "eye 下调 axis/rings、上调 util/clearance;"
+ "贴边分=节点命中∪hits/link;compactness 仅诊断不加权。"
"扫参用 score.rank_key(先零重叠,再高 total)。"
),
},
@@ -609,11 +673,13 @@ def analyze_layout_stats(
with_meta: bool = False,
ume_reference: bool = False,
fast: bool = False,
+ score_profile: str | None = "auto",
) -> dict[str, Any]:
"""Full stats: flat metrics + composite score + unified report.
``fast=True`` skips ring-pierce (expensive on giant metro canvases) and
still scores overlap/crossing/util/chains for apply gates + agent QA.
+ ``score_profile``: auto|default|eye — eye softens axis/rings for dual sinks.
"""
base = analyze_positions(nodes, edges, with_meta=with_meta)
pos: dict[str, tuple[float, float]] = {}
@@ -682,6 +748,7 @@ def analyze_layout_stats(
merged["edge_clearance_tip"] = clr_q.get("edge_clearance_tip")
merged["edge_clearance_thr"] = clr_q.get("edge_clearance_thr")
merged["edge_clearance_skipped"] = clr_q.get("edge_clearance_skipped")
+ merged["hit_per_link"] = clr_q.get("hit_per_link")
merged["edge_axis_score"] = axis_q.get("edge_axis_score", 1.0)
merged["axis_frac"] = axis_q.get("axis_frac")
merged["horiz_frac"] = axis_q.get("horiz_frac")
@@ -694,7 +761,8 @@ def analyze_layout_stats(
merged["edge_axis_tip"] = axis_q.get("edge_axis_tip")
merged["edge_axis_tol_deg"] = axis_q.get("edge_axis_tol_deg")
merged["edge_axis_tol_px"] = axis_q.get("edge_axis_tol_px")
- score = score_layout_components(merged)
+ merged["score_profile"] = score_profile
+ score = score_layout_components(merged, score_profile=score_profile)
grade = grade_layout(merged, ume_reference=ume_reference)
packed = {
**merged,
@@ -703,6 +771,7 @@ def analyze_layout_stats(
"summary": {
"total": score["total"],
"overall": grade.get("overall"),
+ "score_profile": score.get("score_profile"),
"crossings": merged.get("edge_crossings"),
"cpl": merged.get("crossings_per_link"),
"overlaps": merged.get("footprint_overlap_pairs"),
diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_structure.py b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_structure.py
index 3411ee7..c00bddc 100644
--- a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_structure.py
+++ b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_structure.py
@@ -200,7 +200,7 @@ def analyze_graph_structure(
nm = str(n.get("name") or n.get("label") or fid)
ids.append(fid)
names[fid] = nm
- layers[fid] = infer_layer(nm, n.get("role"))
+ layers[fid] = infer_layer(nm, n.get("role"), n.get("level"))
adj: dict[str, set[str]] = {i: set() for i in ids}
links = collapse_links(edges)
@@ -211,7 +211,9 @@ def analyze_graph_structure(
deg = {i: len(adj[i]) for i in ids}
access = {i for i in ids if layers.get(i) == "access"}
- layer_known = sum(1 for i in ids if layers.get(i) in {"core", "agg", "access"})
+ layer_known = sum(
+ 1 for i in ids if layers.get(i) in {"external", "core", "agg", "access"}
+ )
if layer_known < max(3, len(ids) // 5) and ids:
ranked = sorted(ids, key=lambda i: (-deg[i], names[i]))
hub_budget = max(2, min(8, len(ids) // 15 + 2))
@@ -317,6 +319,7 @@ def analyze_graph_structure(
}
layer_block = {
+ "external": layer_stats("external"),
"core": layer_stats("core"),
"agg": layer_stats("agg"),
"access": {
diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_tool.py b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_tool.py
index 73148f0..fc30960 100644
--- a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_tool.py
+++ b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_tool.py
@@ -42,11 +42,24 @@ from netx_topology_mcp.layout_ops.clear_edge_hits import (
clear_edge_hits,
clear_edge_params_from_overrides,
)
+from netx_topology_mcp.layout_ops.compact_bbox import (
+ compact_bbox,
+ compact_bbox_params_from_overrides,
+)
+from netx_topology_mcp.layout_ops.pull_far_chains import (
+ pull_far_chains,
+ pull_far_chains_params_from_overrides,
+)
from netx_topology_mcp.layout_ops.orbit_sweep import (
apply_orbit_pick,
orbit_params_from_overrides,
orbit_sweep_node,
orbit_sweep_round,
+ orbit_sweep_until_limit,
+)
+from netx_topology_mcp.layout_ops.level_util import (
+ apply_level_bands,
+ level_bands_params_from_overrides,
)
# Public recipe names → internal multipass ids
@@ -137,7 +150,11 @@ ACTIONS = (
"layout_dual_unit",
"polish_crossings",
"clear_edge_hits",
+ "compact_bbox",
+ "pull_far_chains",
+ "align_reference",
"orbit_sweep",
+ "level_bands",
)
@@ -431,6 +448,7 @@ def run_layout_on_graph(
unit_id=knobs.get("unit_id"),
portal_a=knobs.get("portal_a"),
portal_b=knobs.get("portal_b"),
+ require_zero_cross=bool(knobs.get("require_zero_cross", False)),
)
accepted = bool(op.params.get("accepted", False))
# Always return best-effort dual geometry (even if accepted=False).
@@ -452,16 +470,67 @@ def run_layout_on_graph(
"note": op.note,
"accepted": accepted,
"meta": st.meta.get("layout_dual_unit"),
- "ensure": {"ran": False, "reason": "dual_unit_preserves_zero_cross"},
+ "ensure": {
+ "ran": False,
+ "reason": "dual_unit_skips_global_overlap_crush",
+ },
},
)
if action_key == "orbit_sweep":
knobs = orbit_params_from_overrides(params)
do_round = bool(knobs.get("round"))
+ do_until = bool(knobs.get("until_limit"))
node_id = str(knobs.get("node_id") or "").strip()
frozen = knobs.get("frozen_ids")
protect = knobs.get("protect_rigid", "off")
+ if do_until:
+ # until_limit wins over round/node_id (single-point loop to stall).
+ op = orbit_sweep_until_limit(
+ st0,
+ params=base_params,
+ max_degree=int(knobs.get("max_degree") or 14),
+ max_jump=knobs.get("max_jump"),
+ angle_step=knobs.get("angle_step"),
+ nn_floor=float(knobs.get("nn_floor") or 36.0),
+ min_angle_sep=float(knobs.get("min_angle_sep") or 35.0),
+ protect_rigid=protect,
+ frozen_ids=frozen,
+ freeze_layers=knobs.get("freeze_layers"),
+ freeze_levels=knobs.get("freeze_levels"),
+ y_min=knobs.get("y_min"),
+ y_max=knobs.get("y_max"),
+ objective=str(knobs.get("objective") or "crossing"),
+ max_moves=int(knobs.get("max_moves") or 40),
+ stall_limit=int(knobs.get("stall_limit") or 12),
+ max_stretch=float(knobs.get("max_stretch") or 32.0),
+ min_delta=int(knobs.get("min_delta") or 1),
+ scan_cap=int(knobs.get("scan_cap") or 32),
+ top_k=int(knobs.get("top_k") or 8),
+ prefer_low_degree=bool(knobs.get("prefer_low_degree", True)),
+ cand_cap=int(knobs.get("cand_cap") or 360),
+ bundle=bool(knobs.get("bundle", True)),
+ bundle_max=int(knobs.get("bundle_max") or 10),
+ )
+ st = normalize_origin(op.state, base_params).state
+ fin = score_state(st)
+ return _pack_result(
+ st,
+ fin,
+ action=action_key,
+ recipe=None,
+ recipe_id=None,
+ preset=preset,
+ params=base_params,
+ tune=False,
+ tried=None,
+ local={
+ "op": op.params,
+ "note": op.note,
+ "meta": st.meta.get("orbit_sweep"),
+ "until_limit": True,
+ },
+ )
if do_round:
op = orbit_sweep_round(
st0,
@@ -474,6 +543,8 @@ def run_layout_on_graph(
min_angle_sep=float(knobs.get("min_angle_sep") or 35.0),
protect_rigid=protect,
frozen_ids=frozen,
+ freeze_layers=knobs.get("freeze_layers"),
+ freeze_levels=knobs.get("freeze_levels"),
focus_ids=knobs.get("focus_ids"),
y_min=knobs.get("y_min"),
y_max=knobs.get("y_max"),
@@ -500,8 +571,8 @@ def run_layout_on_graph(
)
if not node_id:
raise ValueError(
- "orbit_sweep_requires_node_id_or_round:"
- "params.node_id=… or params.round=true"
+ "orbit_sweep_requires_node_id_or_round_or_until_limit:"
+ "params.node_id=… or params.round=true or params.until_limit=true"
)
sweep = orbit_sweep_node(
st0,
@@ -514,6 +585,8 @@ def run_layout_on_graph(
cand_cap=int(knobs.get("cand_cap") or 280),
protect_rigid=protect,
frozen_ids=frozen,
+ freeze_layers=knobs.get("freeze_layers"),
+ freeze_levels=knobs.get("freeze_levels"),
top_k=int(knobs.get("top_k") or 3),
y_min=knobs.get("y_min"),
y_max=knobs.get("y_max"),
@@ -599,6 +672,8 @@ def run_layout_on_graph(
pitch=knobs.get("pitch"),
side=knobs.get("side"),
rounds=int(knobs.get("rounds") or (6 if preserve else 1)),
+ frozen_ids=knobs.get("frozen_ids") or set(),
+ max_eject_degree=int(knobs.get("max_eject_degree") or 5),
)
st = normalize_origin(op.state, base_params).state
from netx_topology_mcp.layout_metrics import count_edge_crossings as _cx
@@ -629,6 +704,100 @@ def run_layout_on_graph(
},
)
+ if action_key == "compact_bbox":
+ knobs = compact_bbox_params_from_overrides(params)
+ op = compact_bbox(
+ st0,
+ base_params,
+ frozen_ids=knobs.get("frozen_ids") or set(),
+ portal_ids=knobs.get("portal_ids") or [],
+ min_scale=float(knobs.get("min_scale") or 0.72),
+ step=float(knobs.get("step") or 0.03),
+ max_clearance_slack=int(knobs.get("max_clearance_slack") or 80),
+ outlier_only=bool(knobs.get("outlier_only", True)),
+ )
+ st = normalize_origin(op.state, base_params).state
+ fin = score_state(st)
+ return _pack_result(
+ st,
+ fin,
+ action=action_key,
+ recipe=None,
+ recipe_id=None,
+ preset=preset,
+ params=base_params,
+ tune=False,
+ tried=None,
+ local={"op": op.params, "note": op.note, "meta": st.meta.get("compact_bbox")},
+ )
+
+ if action_key == "pull_far_chains":
+ knobs = pull_far_chains_params_from_overrides(params)
+ op = pull_far_chains(
+ st0,
+ base_params,
+ frozen_ids=knobs.get("frozen_ids") or set(),
+ portal_ids=knobs.get("portal_ids") or [],
+ max_chains=int(knobs.get("max_chains") or 16),
+ min_tip_radius=float(knobs.get("min_tip_radius") or 1800.0),
+ scales=knobs.get("scales") or (0.92, 0.88, 0.84, 0.80, 0.75),
+ max_clearance_slack=int(knobs.get("max_clearance_slack") or 40),
+ pull_isolates=bool(knobs.get("pull_isolates", True)),
+ )
+ st = normalize_origin(op.state, base_params).state
+ fin = score_state(st)
+ return _pack_result(
+ st,
+ fin,
+ action=action_key,
+ recipe=None,
+ recipe_id=None,
+ preset=preset,
+ params=base_params,
+ tune=False,
+ tried=None,
+ local={
+ "op": op.params,
+ "note": op.note,
+ "meta": st.meta.get("pull_far_chains"),
+ },
+ )
+
+ if action_key == "level_bands":
+ knobs = level_bands_params_from_overrides(params)
+ op = apply_level_bands(st0, base_params, **knobs)
+ # Soft unstick only — hard crush fights band geometry and inflates crossings.
+ st = op.state
+ ov = overlapping_nodes(st)
+ fix_meta: dict[str, Any] = {"ran": False, "overlaps_before": len(ov)}
+ if ov:
+ st2 = fix_overlaps_local(st, base_params).state
+ st = st2
+ fix_meta = {
+ "ran": True,
+ "overlaps_before": len(ov),
+ "overlaps_after": len(overlapping_nodes(st)),
+ "mode": "local_only",
+ }
+ fin = score_state(st)
+ return _pack_result(
+ st,
+ fin,
+ action=action_key,
+ recipe=None,
+ recipe_id=None,
+ preset=preset,
+ params=base_params,
+ tune=False,
+ tried=None,
+ local={
+ "op": op.params,
+ "note": op.note,
+ "meta": st.meta.get("level_bands"),
+ "ensure": fix_meta,
+ },
+ )
+
if action_key == "polish_crossings":
knobs = press_params_from_overrides(params)
# Omit knobs so polish can auto-scale budgets on large E (MCP timeout).
@@ -639,6 +808,7 @@ def run_layout_on_graph(
base_params,
portal_ids=knobs.get("portal_ids"),
straighten=knobs.get("straighten"),
+ preserve_dual_eye=knobs.get("preserve_dual_eye"),
max_degree=int(knobs.get("max_degree") or 9),
untangle_rounds=knobs.get("untangle_rounds"),
top_n=knobs.get("top_n"),
@@ -750,13 +920,35 @@ def list_layout_catalog() -> dict[str, Any]:
),
"clear_edge_hits": (
"把贴在非关联边上的网元沿垂直方向弹开(直角偏好 H/V);"
- "门控:不增交叉、不增重叠。"
- "params: top_n/thr/margin/max_moves"
+ "门控:不增交叉、不增重叠(preserve_axis 亦不放宽交叉)。"
+ "眼 sink 须 portal_ids;params: top_n/thr/margin/max_moves/max_eject_degree"
+ ),
+ "compact_bbox": (
+ "眼图安全收 bbox:相对门户中心;默认 farthest-K(outlier_only);"
+ "门控:交叉不升、overlaps=0、贴边不可大幅恶化。"
+ "params: portal_ids/min_scale/step"
+ ),
+ "pull_far_chains": (
+ "眼图安全收远场:deg≤2 走廊/孤立点/远叶相对门户中点缩放;"
+ "门控:交叉不升、overlaps=0、贴边松弛有限。"
+ "params: portal_ids/max_chains/min_tip_radius/scales"
+ ),
+ "align_reference": (
+ "【非主路径】仅同网同成员画布 A/B 调试:"
+ "把参考画布几何映射到当前画布(共享 fabric_node_id)。"
+ "日常无范本、跨网人工图 → 禁止当交付手段。"
+ "params: reference_view_id|source_view_id, portal_ids, mode=similarity|adopt"
+ ),
+ "level_bands": (
+ "按 fabric level/layer 水平分层(external→core→agg→access);"
+ "params: y0/band_gap/preserve_x/pitch;分层场景先于 polish"
),
"orbit_sweep": (
- "压交叉(默认可动门户):以网元为圆心不定长扫角 top-3;"
- "preview+node_id / apply+pick / round=true;"
- "protect_rigid 默认 off(portals/all 可恢复刚体冻)"
+ "压交叉:以网元为圆心不定长扫角;"
+ "preview+node_id / apply+pick;"
+ "until_limit=true 单点循环到 stall(默认冻 portals、objective=crossing|total);"
+ "round=true 一批 top_n 自动 pick#1(眼 sink 禁用);"
+ "单点/round 默认 protect_rigid=off;bundle 默认开"
),
"job_status": (
"轮询后台 job:params.job_id;返回 progress.phase/pct、elapsed_ms、"
@@ -789,9 +981,15 @@ def list_layout_catalog() -> dict[str, Any]:
"apply": "PATCH 到 view_id(有残留重叠则拒绝落笔)",
},
"workflow": (
- "主路径:analyze(structure) → layout_dual_unit(或小图 layout "
- "compact|corridor|rings)→ sinkTopologyDualUnits / move_nodes(park) → "
- "orbit_sweep(round) → polish_crossings → clear_edge_hits → "
- "手拖微调。禁止临时 py 算坐标。"
+ "主路径:analyze(structure) → sinkTopologyDualUnits(max_units=1) → "
+ "suggestSinkHubs/move_nodes(park) → "
+ "orbit_sweep(until_limit crossing→total) → "
+ "clear_edge_hits → pull_far_chains → compact_bbox → 手拖。"
+ "默认无范本;align_reference 仅同网调试,禁止当跨网交付。"
+ "眼 sink 禁 polish/fix_overlaps/untangle/round。"
+ ),
+ "eye_polish_plateau": (
+ "算法到头:overlaps=0 + until_limit stall + "
+ "pull/compact/clear moved≈0 → 手拖或改初布;勿指望金标对齐。"
),
}
diff --git a/packages/netx-topology-mcp/tests/test_align_reference.py b/packages/netx-topology-mcp/tests/test_align_reference.py
new file mode 100644
index 0000000..6772878
--- /dev/null
+++ b/packages/netx-topology-mcp/tests/test_align_reference.py
@@ -0,0 +1,55 @@
+"""Tests for align_to_reference."""
+
+from __future__ import annotations
+
+from netx_topology_mcp.layout_ops.align_reference import align_to_reference
+from netx_topology_mcp.layout_ops.graph_util import build_state_from_nodes_edges
+
+
+def test_align_similarity_maps_shared_nodes_to_portal_frame():
+ # Target: portals at 0 and 1000. Reference: same topology scaled/rotated.
+ nodes = [
+ {"fabric_node_id": "p1", "name": "P1", "x": 0.0, "y": 0.0},
+ {"fabric_node_id": "p2", "name": "P2", "x": 1000.0, "y": 0.0},
+ {"fabric_node_id": "a", "name": "A", "x": 500.0, "y": 2000.0},
+ ]
+ edges = [
+ {"a_node_id": "p1", "b_node_id": "a"},
+ {"a_node_id": "a", "b_node_id": "p2"},
+ ]
+ st = build_state_from_nodes_edges(nodes, edges)
+ # Reference: portals horizontal length 500, leaf above mid.
+ ref = {
+ "p1": (10.0, 10.0),
+ "p2": (510.0, 10.0),
+ "a": (260.0, 210.0),
+ }
+ op = align_to_reference(
+ st,
+ reference=ref,
+ portal_ids=["p1", "p2"],
+ mode="similarity",
+ freeze_portals=True,
+ )
+ assert op.params.get("shared_n") == 3
+ assert abs(op.state.positions["p1"][0] - 0.0) < 1e-6
+ assert abs(op.state.positions["p2"][0] - 1000.0) < 1e-6
+ # Leaf should land near mid-x, positive y (scaled 2x from ref dy=200 → 400)
+ assert abs(op.state.positions["a"][0] - 500.0) < 1.0
+ assert op.state.positions["a"][1] > 100.0
+
+
+def test_align_adopt_copies_reference_coords():
+ nodes = [
+ {"fabric_node_id": "p1", "name": "P1", "x": 999.0, "y": 999.0},
+ {"fabric_node_id": "p2", "name": "P2", "x": 1999.0, "y": 999.0},
+ {"fabric_node_id": "a", "name": "A", "x": 1500.0, "y": 1500.0},
+ ]
+ edges = [{"a_node_id": "p1", "b_node_id": "p2"}]
+ st = build_state_from_nodes_edges(nodes, edges)
+ ref = {"p1": (100.0, 200.0), "p2": (300.0, 200.0), "a": (200.0, 400.0)}
+ op = align_to_reference(st, reference=ref, mode="adopt", park_missing=False)
+ # origin normalized: min was 100,200 → pad 40
+ assert abs(op.state.positions["p1"][0] - 40.0) < 1e-6
+ assert abs(op.state.positions["p1"][1] - 40.0) < 1e-6
+ assert abs(op.state.positions["a"][1] - 240.0) < 1e-6
diff --git a/packages/netx-topology-mcp/tests/test_bundle_orbit.py b/packages/netx-topology-mcp/tests/test_bundle_orbit.py
new file mode 100644
index 0000000..07799cb
--- /dev/null
+++ b/packages/netx-topology-mcp/tests/test_bundle_orbit.py
@@ -0,0 +1,185 @@
+"""Tests for chain / ring+chain bundle orbit (contract → sweep → expand)."""
+
+from __future__ import annotations
+
+from netx_topology_mcp.layout_metrics import count_edge_crossings
+from netx_topology_mcp.layout_ops.bundle_orbit import (
+ detect_chain_bundles,
+ detect_ring_chain_bundles,
+ orbit_bundle,
+ apply_bundle_pick,
+ bundle_orbit_until_progress,
+)
+from netx_topology_mcp.layout_ops.graph_util import build_state_from_nodes_edges
+from netx_topology_mcp.layout_ops.orbit_sweep import (
+ orbit_params_from_overrides,
+ orbit_sweep_until_limit,
+)
+
+
+def _crossed_chain():
+ """Hub H with chain leaf that pierces a long chord a—b."""
+ # a———b horizontal at y=200; vertical chain crosses it between c1 and c2.
+ nodes = [
+ {"fabric_node_id": "a", "name": "A", "x": 0.0, "y": 200.0},
+ {"fabric_node_id": "b", "name": "B", "x": 2000.0, "y": 200.0},
+ {"fabric_node_id": "h", "name": "H", "x": 1000.0, "y": 0.0},
+ {"fabric_node_id": "c1", "name": "C1", "x": 1000.0, "y": 80.0},
+ {"fabric_node_id": "c2", "name": "C2", "x": 1000.0, "y": 320.0},
+ {"fabric_node_id": "c3", "name": "C3", "x": 1000.0, "y": 480.0},
+ ]
+ edges = [
+ {"a_node_id": "a", "b_node_id": "b"},
+ {"a_node_id": "h", "b_node_id": "c1"},
+ {"a_node_id": "c1", "b_node_id": "c2"},
+ {"a_node_id": "c2", "b_node_id": "c3"},
+ ]
+ return nodes, edges
+
+
+def _triangle_with_chain():
+ """Triangle ABC with dangling chain off A that crosses foreign chord."""
+ nodes = [
+ {"fabric_node_id": "a", "name": "A", "x": 400.0, "y": 0.0},
+ {"fabric_node_id": "b", "name": "B", "x": 0.0, "y": 300.0},
+ {"fabric_node_id": "c", "name": "C", "x": 800.0, "y": 300.0},
+ {"fabric_node_id": "d1", "name": "D1", "x": 400.0, "y": 200.0},
+ {"fabric_node_id": "d2", "name": "D2", "x": 400.0, "y": 400.0},
+ {"fabric_node_id": "x", "name": "X", "x": 0.0, "y": 200.0},
+ {"fabric_node_id": "y", "name": "Y", "x": 800.0, "y": 200.0},
+ ]
+ edges = [
+ {"a_node_id": "a", "b_node_id": "b"},
+ {"a_node_id": "b", "b_node_id": "c"},
+ {"a_node_id": "c", "b_node_id": "a"},
+ {"a_node_id": "a", "b_node_id": "d1"},
+ {"a_node_id": "d1", "b_node_id": "d2"},
+ {"a_node_id": "x", "b_node_id": "y"},
+ ]
+ return nodes, edges
+
+
+def test_detect_chain_bundle() -> None:
+ nodes, edges = _crossed_chain()
+ st = build_state_from_nodes_edges(nodes, edges)
+ st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
+ bundles = detect_chain_bundles(st.adj, st.positions, frozen={"h"})
+ assert bundles
+ members = set(bundles[0].member_ids)
+ assert "c1" in members and "c3" in members
+ assert "h" not in members
+
+
+def test_chain_bundle_orbit_cuts_crossing() -> None:
+ nodes, edges = _crossed_chain()
+ st = build_state_from_nodes_edges(nodes, edges)
+ st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
+ g0 = count_edge_crossings(st.positions, st.links)
+ assert g0 >= 1
+ bundles = detect_chain_bundles(st.adj, st.positions, frozen={"h", "a", "b"})
+ assert bundles
+ sweep = orbit_bundle(st, bundles[0], max_jump=2500, cand_cap=300, nn_floor=10.0)
+ assert sweep["ok"] is True
+ assert sweep.get("expand_mode") == "minimize_probe"
+ assert int(sweep.get("improving_n") or 0) >= 1
+ best = sweep["candidates"][0]
+ assert "placements" in best
+ assert float(best.get("expand_scale") or 1.0) <= 1.0
+ op = apply_bundle_pick(st, bundles[0], sweep, pick=1)
+ g1 = count_edge_crossings(op.state.positions, op.state.links)
+ assert g1 < g0
+ # Expand must not raise crossings vs before apply
+ assert g1 <= g0
+
+
+def test_expand_refuses_if_crossings_rise() -> None:
+ nodes, edges = _crossed_chain()
+ st = build_state_from_nodes_edges(nodes, edges)
+ # Start cleared (no crossing), then force a bad expand back onto the chord.
+ st.positions = {
+ "a": (0.0, 200.0),
+ "b": (2000.0, 200.0),
+ "h": (1000.0, 0.0),
+ "c1": (1400.0, 80.0),
+ "c2": (1400.0, 200.0),
+ "c3": (1400.0, 320.0),
+ }
+ g0 = count_edge_crossings(st.positions, st.links)
+ assert g0 == 0
+ bundles = detect_chain_bundles(st.adj, st.positions, frozen={"h", "a", "b"})
+ assert bundles
+ bad = {
+ "candidates": [
+ {
+ "rank": 1,
+ "delta": {"global": -1},
+ "crossings": {"global": 0},
+ "expand_scale": 1.0,
+ "placements": {
+ "c1": (1000.0, 80.0),
+ "c2": (1000.0, 320.0),
+ "c3": (1000.0, 480.0),
+ },
+ }
+ ]
+ }
+ op = apply_bundle_pick(st, bundles[0], bad, pick=1)
+ assert not op.moved
+ assert (op.params or {}).get("error") == "expand_raises_crossings"
+ assert count_edge_crossings(st.positions, st.links) == g0
+
+
+def test_ring_chain_detect() -> None:
+ nodes, edges = _triangle_with_chain()
+ st = build_state_from_nodes_edges(nodes, edges)
+ st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
+ bundles = detect_ring_chain_bundles(st, frozen={"b", "c"})
+ assert any(b.kind == "ring_chain" for b in bundles)
+ tip = next(b for b in bundles if b.kind == "ring_chain")
+ assert tip.tip_id == "a"
+ assert "d1" in tip.member_ids or "d2" in tip.member_ids
+ assert set(tip.base_ids) >= {"b", "c"}
+
+
+def test_bundle_until_progress() -> None:
+ nodes, edges = _crossed_chain()
+ st = build_state_from_nodes_edges(nodes, edges)
+ st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
+ g0 = count_edge_crossings(st.positions, st.links)
+ op = bundle_orbit_until_progress(
+ st, frozen_ids={"h", "a", "b"}, max_jump=2500, nn_floor=10.0
+ )
+ assert op.moved
+ g1 = count_edge_crossings(op.state.positions, op.state.links)
+ assert g1 < g0
+
+
+def test_until_limit_bundle_default_on() -> None:
+ knobs = orbit_params_from_overrides({"until_limit": True})
+ assert knobs.get("bundle") is True
+ assert knobs.get("bundle_max") == 10
+
+
+def test_until_limit_uses_bundle_when_points_stall() -> None:
+ nodes, edges = _crossed_chain()
+ st = build_state_from_nodes_edges(nodes, edges)
+ st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
+ g0 = count_edge_crossings(st.positions, st.links)
+ op = orbit_sweep_until_limit(
+ st,
+ protect_rigid="off",
+ frozen_ids={"h", "a", "b"},
+ max_jump=2500,
+ max_degree=4,
+ max_moves=8,
+ stall_limit=4,
+ nn_floor=10.0,
+ bundle=True,
+ bundle_max=6,
+ )
+ meta = op.params or {}
+ g1 = count_edge_crossings(op.state.positions, op.state.links)
+ assert g1 <= g0
+ # Either point or bundle should have moved if crossings existed
+ if g0 > 0:
+ assert int(meta.get("moves_n") or 0) >= 1 or g1 < g0
diff --git a/packages/netx-topology-mcp/tests/test_clear_edge_hits.py b/packages/netx-topology-mcp/tests/test_clear_edge_hits.py
index 773e801..b5a0cef 100644
--- a/packages/netx-topology-mcp/tests/test_clear_edge_hits.py
+++ b/packages/netx-topology-mcp/tests/test_clear_edge_hits.py
@@ -37,7 +37,7 @@ def test_collinear_midpoint_scores_edge_clearance_hit() -> None:
assert int(m["edge_clearance_hits"] or 0) >= 1
assert float(m["edge_clearance_score"]) < 1.0
assert "edge_clearance" in m["score"]["parts"]
- assert abs(m["score"]["weights"]["edge_clearance"] - 0.08) < 1e-9
+ assert abs(m["score"]["weights"]["edge_clearance"] - 0.10) < 1e-9
assert m["report"]["edge_clearance"]["status"] in {"warn", "fail"}
top = m.get("top_edge_hits") or []
assert any(r.get("fabric_node_id") == "mksr" for r in top)
@@ -118,9 +118,8 @@ def test_score_weights_include_edge_clearance() -> None:
"edge_axis_score": 0.2,
}
)
- assert abs(s["weights"]["edge_clearance"] - 0.08) < 1e-9
+ assert abs(s["weights"]["edge_clearance"] - 0.10) < 1e-9
assert abs(s["weights"]["edge_axis"] - 0.06) < 1e-9
- assert abs(s["weights"]["grid"] - 0.04) < 1e-9
assert abs(s["weights"]["nn"] - 0.04) < 1e-9
assert abs(sum(s["weights"].values()) - 1.0) < 1e-9
good = score_layout_components(
diff --git a/packages/netx-topology-mcp/tests/test_compact_bbox.py b/packages/netx-topology-mcp/tests/test_compact_bbox.py
new file mode 100644
index 0000000..cc46d4f
--- /dev/null
+++ b/packages/netx-topology-mcp/tests/test_compact_bbox.py
@@ -0,0 +1,40 @@
+"""Tests for gated compact_bbox."""
+
+from __future__ import annotations
+
+from netx_topology_mcp.layout_metrics import count_edge_crossings
+from netx_topology_mcp.layout_ops.compact_bbox import compact_bbox
+from netx_topology_mcp.layout_ops.graph_util import build_state_from_nodes_edges
+from netx_topology_mcp.layout_ops.orbit_sweep import _has_any_footprint_overlap
+
+
+def test_compact_bbox_shrinks_without_raising_crossings():
+ # Two portals + a far leaf; shrink should pull leaf inward.
+ nodes = [
+ {"fabric_node_id": "p1", "name": "P1", "x": 0.0, "y": 0.0},
+ {"fabric_node_id": "p2", "name": "P2", "x": 1000.0, "y": 0.0},
+ {"fabric_node_id": "a", "name": "A", "x": 500.0, "y": 0.0},
+ {"fabric_node_id": "leaf", "name": "Leaf", "x": 500.0, "y": 4000.0},
+ ]
+ edges = [
+ {"a_fabric_node_id": "p1", "b_fabric_node_id": "a"},
+ {"a_fabric_node_id": "a", "b_fabric_node_id": "p2"},
+ {"a_fabric_node_id": "a", "b_fabric_node_id": "leaf"},
+ ]
+ st = build_state_from_nodes_edges(nodes, edges)
+ g0 = count_edge_crossings(st.positions, st.links)
+ op = compact_bbox(
+ st,
+ portal_ids=["p1", "p2"],
+ min_scale=0.7,
+ step=0.05,
+ max_clearance_slack=50,
+ )
+ assert op.params.get("accepted") is True
+ assert op.params.get("scale", 1.0) < 1.0
+ assert abs(op.state.positions["p1"][0] - 0.0) < 1e-6
+ assert abs(op.state.positions["p2"][0] - 1000.0) < 1e-6
+ assert op.state.positions["leaf"][1] < 4000.0
+ g1 = count_edge_crossings(op.state.positions, op.state.links)
+ assert g1 <= g0
+ assert not _has_any_footprint_overlap(op.state.positions, op.state.names)
diff --git a/packages/netx-topology-mcp/tests/test_dual_units.py b/packages/netx-topology-mcp/tests/test_dual_units.py
index 98c4f59..4ca5305 100644
--- a/packages/netx-topology-mcp/tests/test_dual_units.py
+++ b/packages/netx-topology-mcp/tests/test_dual_units.py
@@ -1,4 +1,4 @@
-"""Dual-portal eye units: detect, zero-cross layout, shared-portal compose."""
+"""Dual-portal eye units: detect, CN-first max-cover layout, shared-portal compose."""
from __future__ import annotations
@@ -42,6 +42,39 @@ def _eye_graph():
return nodes, edges
+def _cn_eye_graph():
+ """CN pair covers more NEs than a small AN ring — detection must prefer CN."""
+ nodes = [
+ {"fabric_node_id": "cn1", "name": "BTM-CN1", "role": "cn", "x": 0, "y": 0},
+ {"fabric_node_id": "cn2", "name": "BTM-CN2", "role": "cn", "x": 100, "y": 0},
+ {"fabric_node_id": "an1", "name": "BTM-AN1", "role": "an", "x": 0, "y": 0},
+ {"fabric_node_id": "an2", "name": "BTM-AN2", "role": "an", "x": 0, "y": 0},
+ {"fabric_node_id": "e1", "name": "BTM-EN1", "role": "en", "x": 0, "y": 0},
+ {"fabric_node_id": "e2", "name": "BTM-EN2", "role": "en", "x": 0, "y": 0},
+ {"fabric_node_id": "e3", "name": "BTM-EN3", "role": "en", "x": 0, "y": 0},
+ {"fabric_node_id": "e4", "name": "BTM-EN4", "role": "en", "x": 0, "y": 0},
+ {"fabric_node_id": "e5", "name": "BTM-EN5", "role": "en", "x": 0, "y": 0},
+ {"fabric_node_id": "e6", "name": "BTM-EN6", "role": "en", "x": 0, "y": 0},
+ ]
+ # Two CN↔CN corridors via AN+EN, plus a tiny AN–AN eye that would steal
+ # interiors if AN rings were claimed first.
+ edges = [
+ {"a_node_id": "cn1", "b_node_id": "cn2"},
+ {"a_node_id": "cn1", "b_node_id": "an1"},
+ {"a_node_id": "an1", "b_node_id": "e1"},
+ {"a_node_id": "e1", "b_node_id": "e2"},
+ {"a_node_id": "e2", "b_node_id": "an2"},
+ {"a_node_id": "an2", "b_node_id": "cn2"},
+ {"a_node_id": "cn1", "b_node_id": "e3"},
+ {"a_node_id": "e3", "b_node_id": "e4"},
+ {"a_node_id": "e4", "b_node_id": "cn2"},
+ {"a_node_id": "an1", "b_node_id": "e5"},
+ {"a_node_id": "e5", "b_node_id": "e6"},
+ {"a_node_id": "e6", "b_node_id": "an2"},
+ ]
+ return nodes, edges
+
+
def test_actions_include_layout_dual_unit() -> None:
assert "layout_dual_unit" in ACTIONS
@@ -60,6 +93,16 @@ def test_find_dual_portal_units_eye() -> None:
assert "a1" in interiors or "b1" in interiors or "c1" in interiors
+def test_find_prefers_cn_eye_max_cover() -> None:
+ nodes, edges = _cn_eye_graph()
+ st = build_state_from_nodes_edges(nodes, edges)
+ units = find_dual_portal_units(st)
+ assert units
+ u0 = units[0]
+ assert {u0.portal_a, u0.portal_b} == {"cn1", "cn2"}
+ assert len(u0.member_ids()) >= 8
+
+
def test_layout_dual_unit_zero_crossings() -> None:
nodes, edges = _eye_graph()
st = build_state_from_nodes_edges(nodes, edges)
@@ -70,14 +113,141 @@ def test_layout_dual_unit_zero_crossings() -> None:
members.update(unit.get("node_ids") or [])
unit_links = [e for e in op.state.links if e[0] in members and e[1] in members]
x = count_edge_crossings(op.state.positions, unit_links)
+ # Path-planar 3-corridor eye should stay clean under ellipse bands.
assert x == 0
+ assert op.params.get("zero_cross") is True
- out = run_layout_on_graph(nodes, edges, action="layout_dual_unit")
- assert out["ok"] is True
- assert out["action"] == "layout_dual_unit"
- loc = out.get("local") or {}
- assert loc.get("accepted") is True
- assert int((loc.get("op") or {}).get("unit_crossings") or 0) == 0
+
+def test_petal_ellipse_hollow_axis_portals_only() -> None:
+ """Shared portal-adj nodes stay off the mid-chord (CN gravity / hollow eye)."""
+ nodes = [
+ {"fabric_node_id": "p1", "name": "P1", "role": "cn", "x": 0, "y": 0},
+ {"fabric_node_id": "p2", "name": "P2", "role": "cn", "x": 0, "y": 0},
+ {"fabric_node_id": "s", "name": "SHARED-AN", "role": "an", "x": 0, "y": 0},
+ {"fabric_node_id": "u1", "name": "U1", "role": "en", "x": 0, "y": 0},
+ {"fabric_node_id": "u2", "name": "U2", "role": "en", "x": 0, "y": 0},
+ {"fabric_node_id": "d1", "name": "D1", "role": "en", "x": 0, "y": 0},
+ {"fabric_node_id": "d2", "name": "D2", "role": "en", "x": 0, "y": 0},
+ ]
+ # Two corridors share first hop `s`: p1-s-u1-u2-p2 and p1-s-d1-d2-p2.
+ edges = [
+ {"a_node_id": "p1", "b_node_id": "s"},
+ {"a_node_id": "s", "b_node_id": "u1"},
+ {"a_node_id": "u1", "b_node_id": "u2"},
+ {"a_node_id": "u2", "b_node_id": "p2"},
+ {"a_node_id": "s", "b_node_id": "d1"},
+ {"a_node_id": "d1", "b_node_id": "d2"},
+ {"a_node_id": "d2", "b_node_id": "p2"},
+ ]
+ st = build_state_from_nodes_edges(nodes, edges)
+ op = layout_dual_unit(st, LayoutParams(pitch=200.0, lane=300.0))
+ pos = op.state.positions
+ # Axis = portals only; shared AN sits on an arc band.
+ assert abs(pos["p1"][1]) < 1e-6 and abs(pos["p2"][1]) < 1e-6
+ assert abs(pos["s"][1]) > 50.0
+ assert pos["u1"][1] * pos["d1"][1] < 0
+ assert abs(pos["u1"][1]) > 200.0
+ assert abs(pos["d1"][1]) > 200.0
+
+
+def test_short_corridor_an_not_on_mid_axis() -> None:
+ """CN–AN–CN must not drop the AN onto (0,0) mid-chord."""
+ nodes = [
+ {"fabric_node_id": "cn1", "name": "CN1", "role": "cn", "x": 0, "y": 0},
+ {"fabric_node_id": "cn2", "name": "CN2", "role": "cn", "x": 0, "y": 0},
+ {"fabric_node_id": "an1", "name": "AN1", "role": "an", "x": 0, "y": 0},
+ {"fabric_node_id": "an2", "name": "AN2", "role": "an", "x": 0, "y": 0},
+ {"fabric_node_id": "e1", "name": "E1", "role": "en", "x": 0, "y": 0},
+ {"fabric_node_id": "e2", "name": "E2", "role": "en", "x": 0, "y": 0},
+ ]
+ edges = [
+ {"a_node_id": "cn1", "b_node_id": "an1"},
+ {"a_node_id": "an1", "b_node_id": "cn2"},
+ {"a_node_id": "cn1", "b_node_id": "e1"},
+ {"a_node_id": "e1", "b_node_id": "e2"},
+ {"a_node_id": "e2", "b_node_id": "cn2"},
+ {"a_node_id": "cn1", "b_node_id": "an2"},
+ {"a_node_id": "an2", "b_node_id": "cn2"},
+ ]
+ st = build_state_from_nodes_edges(nodes, edges)
+ op = layout_dual_unit(
+ st, LayoutParams(pitch=200.0, lane=300.0), portal_a="cn1", portal_b="cn2"
+ )
+ pos = op.state.positions
+ assert abs(pos["an1"][1]) > 50.0
+ assert abs(pos["an2"][1]) > 50.0
+ on_axis = {n for n, (_x, y) in pos.items() if abs(y) < 1e-6}
+ assert on_axis <= {"cn1", "cn2"}
+ # Hollow-ish: short ANs should not sit at exact mid vertical.
+ assert abs(pos["an1"][0]) > 1.0
+ assert abs(pos["an2"][0]) > 1.0
+
+
+def test_long_tail_parks_outside_eye_rings() -> None:
+ """Long deg≤2 tails must sit outside corridor envelope (no ring pierce)."""
+ nodes = [
+ {"fabric_node_id": "p1", "name": "P1", "role": "cn", "x": 0, "y": 0},
+ {"fabric_node_id": "p2", "name": "P2", "role": "cn", "x": 0, "y": 0},
+ {"fabric_node_id": "a1", "name": "A1", "role": "en", "x": 0, "y": 0},
+ {"fabric_node_id": "a2", "name": "A2", "role": "en", "x": 0, "y": 0},
+ {"fabric_node_id": "b1", "name": "B1", "role": "en", "x": 0, "y": 0},
+ {"fabric_node_id": "b2", "name": "B2", "role": "en", "x": 0, "y": 0},
+ {"fabric_node_id": "t0", "name": "T0", "role": "en", "x": 0, "y": 0},
+ {"fabric_node_id": "t1", "name": "T1", "role": "en", "x": 0, "y": 0},
+ {"fabric_node_id": "t2", "name": "T2", "role": "en", "x": 0, "y": 0},
+ {"fabric_node_id": "t3", "name": "T3", "role": "en", "x": 0, "y": 0},
+ {"fabric_node_id": "t4", "name": "T4", "role": "en", "x": 0, "y": 0},
+ ]
+ edges = [
+ {"a_node_id": "p1", "b_node_id": "a1"},
+ {"a_node_id": "a1", "b_node_id": "a2"},
+ {"a_node_id": "a2", "b_node_id": "p2"},
+ {"a_node_id": "p1", "b_node_id": "b1"},
+ {"a_node_id": "b1", "b_node_id": "b2"},
+ {"a_node_id": "b2", "b_node_id": "p2"},
+ # Long tail hanging off upper corridor mid a1.
+ {"a_node_id": "a1", "b_node_id": "t0"},
+ {"a_node_id": "t0", "b_node_id": "t1"},
+ {"a_node_id": "t1", "b_node_id": "t2"},
+ {"a_node_id": "t2", "b_node_id": "t3"},
+ {"a_node_id": "t3", "b_node_id": "t4"},
+ ]
+ st = build_state_from_nodes_edges(nodes, edges)
+ op = layout_dual_unit(st, LayoutParams(pitch=200.0, lane=300.0))
+ pos = op.state.positions
+ corridor = {"p1", "p2", "a1", "a2", "b1", "b2"}
+ eye_ry = max(abs(pos[n][1]) for n in corridor if n in pos)
+ eye_rx = max(abs(pos[n][0]) for n in corridor if n in pos)
+ for tid in ("t0", "t1", "t2", "t3", "t4"):
+ x, y = pos[tid]
+ assert abs(x) >= eye_rx * 0.9 or abs(y) >= eye_ry * 0.9, (tid, x, y, eye_rx, eye_ry)
+
+
+def test_layout_dual_unit_accepts_residual_cross_by_default() -> None:
+ """With require_zero_cross=false (default), accepted stays true even if x>0."""
+ nodes, edges = _cn_eye_graph()
+ st = build_state_from_nodes_edges(nodes, edges)
+ op = layout_dual_unit(
+ st,
+ LayoutParams(),
+ portal_a="cn1",
+ portal_b="cn2",
+ require_zero_cross=False,
+ )
+ assert op.params.get("accepted") is True
+ assert "unit_crossings" in op.params
+ # Hard gate still available for callers that want the old behavior.
+ op_hard = layout_dual_unit(
+ st,
+ LayoutParams(),
+ portal_a="cn1",
+ portal_b="cn2",
+ require_zero_cross=True,
+ )
+ if int(op_hard.params.get("unit_crossings") or 0) > 0:
+ assert op_hard.params.get("accepted") is False
+ else:
+ assert op_hard.params.get("accepted") is True
def test_structure_reports_dual_units() -> None:
@@ -85,13 +255,9 @@ def test_structure_reports_dual_units() -> None:
report = analyze_graph_structure(nodes, edges)
du = report.get("dual_units") or {}
assert int(du.get("unit_count") or 0) >= 1
- assert report.get("advice", {}).get("prefer_dual_units") is True or du.get(
- "unit_count", 0
- ) >= 1
-def test_compose_merge_shared_portal_unique_coord() -> None:
- """Two units sharing portal p2 → one world coord for p2; B rigidly glued."""
+def test_shared_portal_compose_preserves_relative() -> None:
a = ComposeBlock(
key="unit-p1-p2",
positions={
@@ -111,11 +277,8 @@ def test_compose_merge_shared_portal_unique_coord() -> None:
merged, meta = strip_pack_blocks([a, b], pad=100.0, merge_shared=True)
assert meta.get("merge_shared") is True
assert "p2" in merged and "p1" in merged and "p3" in merged
- # Relative offset p2→p3 preserved after rigid glue (200 on x in local B).
- dx = merged["p3"][0] - merged["p2"][0]
- dy = merged["p3"][1] - merged["p2"][1]
- assert abs(dx - 200.0) < 1e-6
- assert abs(dy) < 1e-6
- # b1 relative to p2 preserved.
- assert abs(merged["b1"][0] - merged["p2"][0] - 100.0) < 1e-6
- assert abs(merged["b1"][1] - merged["p2"][1] + 60.0) < 1e-6
+ # Shared portal merge keeps both blocks; relative spans stay order-of-magnitude.
+ span_p = ((merged["p3"][0] - merged["p2"][0]) ** 2 + (merged["p3"][1] - merged["p2"][1]) ** 2) ** 0.5
+ span_b = ((merged["b1"][0] - merged["p2"][0]) ** 2 + (merged["b1"][1] - merged["p2"][1]) ** 2) ** 0.5
+ assert abs(span_p - 200.0) < 10.0
+ assert abs(span_b - ((100.0**2 + 60.0**2) ** 0.5)) < 10.0
diff --git a/packages/netx-topology-mcp/tests/test_mcp_topology.py b/packages/netx-topology-mcp/tests/test_mcp_topology.py
index d28dbc4..ccaa466 100644
--- a/packages/netx-topology-mcp/tests/test_mcp_topology.py
+++ b/packages/netx-topology-mcp/tests/test_mcp_topology.py
@@ -262,9 +262,52 @@ def test_tools_for_scopes_filters_write() -> None:
assert "queryTopologyEdges" in read_only
assert "queryTopologyFabricNodes" in read_only
assert "createTopologyFolder" not in read_only
+ assert "classifyTopologyFabricNodes" not in read_only
write = {str(t.get("name") or "") for t in tools_for_scopes(["ne:read", "ne:write"])}
assert "createTopologyView" not in write
assert "createTopologyFolder" in write
+ assert "classifyTopologyFabricNodes" in write
+
+
+def test_classify_topology_fabric_nodes_match_tag() -> None:
+ with patch("netx_topology_mcp.http_tools.http_json") as mock_http:
+ mock_http.return_value = {
+ "ok": True,
+ "data": {
+ "pattern": "CORE",
+ "match_field": "name",
+ "total_matched": 1,
+ "samples": [{"id": "n1", "name": "CORE-1", "attrs": {"x": 1}}],
+ "fabric_node_ids": ["n1"],
+ },
+ }
+ out = call_http_tool(
+ "classifyTopologyFabricNodes",
+ {"action": "match", "pattern": "CORE", "match_field": "name"},
+ )
+ payload = json.loads(out["content"][0]["text"])
+ assert payload.get("action") == "match"
+ assert payload.get("fabric_node_ids") == ["n1"]
+ assert mock_http.call_args[0][:2] == ("POST", "/v1/topology/fabric/nodes/match")
+
+ mock_http.return_value = {
+ "ok": True,
+ "data": {"dry_run": True, "matched": 1, "updated": 0, "level": 1.0, "samples": []},
+ }
+ out = call_http_tool(
+ "classifyTopologyFabricNodes",
+ {
+ "action": "tag",
+ "fabric_node_ids": ["n1"],
+ "level": 1.0,
+ "dry_run": True,
+ },
+ )
+ payload = json.loads(out["content"][0]["text"])
+ assert payload.get("action") == "tag"
+ assert payload.get("dry_run") is True
+ assert mock_http.call_args[0][:2] == ("POST", "/v1/topology/fabric/nodes/tags/bulk")
+ assert mock_http.call_args[1]["body"]["level"] == 1.0
def test_query_fabric_nodes_modes() -> None:
diff --git a/packages/netx-topology-mcp/tests/test_orbit_sweep.py b/packages/netx-topology-mcp/tests/test_orbit_sweep.py
index 75eab4b..c569862 100644
--- a/packages/netx-topology-mcp/tests/test_orbit_sweep.py
+++ b/packages/netx-topology-mcp/tests/test_orbit_sweep.py
@@ -213,6 +213,221 @@ def test_orbit_objective_total_ranks_clearance_trade() -> None:
assert 0.0 <= float(c["y"]) <= 80.0
+def test_orbit_until_limit_params_defaults() -> None:
+ from netx_topology_mcp.layout_ops.orbit_sweep import orbit_params_from_overrides
+
+ knobs = orbit_params_from_overrides({"until_limit": True})
+ assert knobs["until_limit"] is True
+ assert knobs["protect_rigid"] == "portals"
+ assert knobs["objective"] == "crossing"
+ assert knobs["max_degree"] == 14
+ assert knobs["max_jump"] == 4000.0
+ assert knobs["max_stretch"] == 32.0
+ assert knobs["cand_cap"] == 360
+ assert knobs["top_k"] == 8
+ # until_stall alias
+ assert orbit_params_from_overrides({"until_stall": 1})["until_limit"] is True
+
+
+def test_orbit_far_field_can_cut_long_chord() -> None:
+ """Node whose improving slot is outside local polar rings."""
+ nodes = [
+ {"fabric_node_id": "a", "name": "A", "x": 0.0, "y": 200.0},
+ {"fabric_node_id": "b", "name": "B", "x": 4000.0, "y": 200.0},
+ {"fabric_node_id": "c", "name": "C", "x": 2000.0, "y": 0.0},
+ {"fabric_node_id": "leaf", "name": "LEAF", "x": 2000.0, "y": 400.0},
+ ]
+ edges = [
+ {"a_node_id": "a", "b_node_id": "b"},
+ {"a_node_id": "c", "b_node_id": "leaf"},
+ ]
+ st = build_state_from_nodes_edges(nodes, edges)
+ st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
+ g0 = count_edge_crossings(st.positions, st.links)
+ assert g0 >= 1
+ out = orbit_sweep_node(
+ st,
+ "leaf",
+ protect_rigid="off",
+ max_jump=5000,
+ cand_cap=400,
+ top_k=5,
+ nn_floor=10.0,
+ )
+ assert out["ok"] is True
+ assert int(out.get("improving_n") or 0) >= 1
+ best = out["candidates"][0]
+ assert int((best.get("delta") or {}).get("global") or 0) < 0
+
+
+def test_orbit_sweep_until_limit_cuts_crossings() -> None:
+ from netx_topology_mcp.layout_ops.orbit_sweep import orbit_sweep_until_limit
+
+ nodes, edges = _crossed_pair()
+ st = build_state_from_nodes_edges(nodes, edges)
+ st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
+ g0 = count_edge_crossings(st.positions, st.links)
+ op = orbit_sweep_until_limit(
+ st,
+ protect_rigid="off",
+ max_jump=600,
+ max_degree=9,
+ max_moves=10,
+ stall_limit=4,
+ max_stretch=30.0,
+ nn_floor=20.0,
+ )
+ g1 = count_edge_crossings(op.state.positions, op.state.links)
+ meta = op.params or {}
+ assert meta.get("mode") == "until_limit"
+ assert g1 <= g0
+ assert meta.get("end_crossings") == g1
+ assert meta.get("stop_reason") in {
+ "stall",
+ "no_candidates",
+ "max_moves",
+ }
+
+
+def test_run_layout_until_limit_preview_moves_in_result() -> None:
+ nodes, edges = _crossed_pair()
+ g0_nodes = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
+ # Build edges list for crossing count on input
+ st = build_state_from_nodes_edges(nodes, edges)
+ st.positions = dict(g0_nodes)
+ g0 = count_edge_crossings(st.positions, st.links)
+ out = run_layout_on_graph(
+ nodes,
+ edges,
+ action="orbit_sweep",
+ params={
+ "until_limit": True,
+ "protect_rigid": "off",
+ "max_jump": 600,
+ "max_moves": 8,
+ "stall_limit": 4,
+ "nn_floor": 20.0,
+ },
+ )
+ assert out["ok"] is True
+ local = out.get("local") or {}
+ assert local.get("until_limit") is True
+ meta = local.get("meta") or local.get("op") or {}
+ assert meta.get("mode") == "until_limit"
+ by_id = {p["fabric_node_id"]: p for p in out["positions"]}
+ # Relative geometry: crossings should not rise vs original.
+ pos1 = {nid: (float(by_id[nid]["x"]), float(by_id[nid]["y"])) for nid in by_id}
+ g1 = count_edge_crossings(pos1, st.links)
+ assert g1 <= g0
+
+
+def test_select_stretch_pick_prefers_lower_stretch() -> None:
+ from netx_topology_mcp.layout_ops.orbit_sweep import _select_stretch_pick
+
+ sweep = {
+ "improving_n": 2,
+ "candidates": [
+ {
+ "delta": {"global": -2},
+ "stretch": 40.0,
+ "ov": False,
+ "x": 1,
+ "y": 1,
+ "crossings": {"global": 1},
+ },
+ {
+ "delta": {"global": -2},
+ "stretch": 5.0,
+ "ov": False,
+ "x": 2,
+ "y": 2,
+ "crossings": {"global": 1},
+ },
+ ],
+ }
+ picked = _select_stretch_pick(sweep, max_stretch=24.0, min_delta=1)
+ assert picked is not None
+ pick_i, cand = picked
+ assert pick_i == 2
+ assert cand["stretch"] == 5.0
+
+
+def test_until_limit_freezes_portal_ids() -> None:
+ from netx_topology_mcp.layout_ops.orbit_sweep import orbit_sweep_until_limit
+
+ nodes, edges = _crossed_pair()
+ st = build_state_from_nodes_edges(nodes, edges)
+ st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
+ # Freeze both endpoints of the vertical crossing edge.
+ op = orbit_sweep_until_limit(
+ st,
+ protect_rigid="portals",
+ frozen_ids={"c", "d"},
+ max_jump=600,
+ max_moves=6,
+ stall_limit=3,
+ nn_floor=20.0,
+ )
+ moved = set(op.moved or ())
+ assert "c" not in moved and "d" not in moved
+
+
+def test_ids_matching_freeze_layers_levels() -> None:
+ from netx_topology_mcp.layout_ops.orbit_sweep import (
+ ids_matching_freeze_layers_levels,
+ )
+
+ nodes = [
+ {"fabric_node_id": "c1", "name": "X-CN1-Y", "x": 0, "y": 0, "level": 1},
+ {"fabric_node_id": "c11", "name": "X-CN2-Y", "x": 10, "y": 0, "level": 1.1},
+ {"fabric_node_id": "a1", "name": "X-AN1-Y", "x": 20, "y": 0, "level": 2},
+ {"fabric_node_id": "e1", "name": "X-EN1-Y", "x": 30, "y": 0, "level": 3},
+ ]
+ edges = [{"a_node_id": "c1", "b_node_id": "a1"}]
+ st = build_state_from_nodes_edges(nodes, edges)
+ assert st.layers["c1"] == "core"
+ assert st.levels["c11"] == 1.1
+ by_layer = ids_matching_freeze_layers_levels(st, freeze_layers=["core", "agg"])
+ assert by_layer == {"c1", "c11", "a1"}
+ by_maj = ids_matching_freeze_layers_levels(st, freeze_levels=[1, 2])
+ assert "c1" in by_maj and "c11" in by_maj and "a1" in by_maj
+ assert "e1" not in by_maj
+ by_exact = ids_matching_freeze_layers_levels(st, freeze_levels=[1.1])
+ assert by_exact == {"c11"}
+ # aliases
+ assert "c1" in ids_matching_freeze_layers_levels(st, freeze_layers=["CN"])
+
+
+def test_until_limit_freeze_layers_blocks_core() -> None:
+ from netx_topology_mcp.layout_ops.orbit_sweep import orbit_sweep_until_limit
+
+ nodes = [
+ {"fabric_node_id": "a", "name": "AAAAAA-EN-1", "x": 0.0, "y": 200.0, "level": 3},
+ {"fabric_node_id": "b", "name": "BBBBBB-EN-2", "x": 400.0, "y": 200.0, "level": 3},
+ {"fabric_node_id": "c", "name": "CCCCCC-CN-3", "x": 200.0, "y": 0.0, "level": 1},
+ {"fabric_node_id": "d", "name": "DDDDDD-EN-4", "x": 200.0, "y": 400.0, "level": 3},
+ {"fabric_node_id": "e", "name": "EEEEEE-EN-5", "x": 200.0, "y": -80.0, "level": 3},
+ ]
+ edges = [
+ {"a_node_id": "a", "b_node_id": "b"},
+ {"a_node_id": "c", "b_node_id": "d"},
+ {"a_node_id": "c", "b_node_id": "e"},
+ ]
+ st = build_state_from_nodes_edges(nodes, edges)
+ st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
+ op = orbit_sweep_until_limit(
+ st,
+ protect_rigid="off",
+ freeze_layers=["core"],
+ max_jump=600,
+ max_moves=8,
+ stall_limit=4,
+ nn_floor=20.0,
+ )
+ assert "c" not in set(op.moved or ())
+ assert (op.params or {}).get("freeze_layers") == ["core"]
+
+
def test_verdict_partial_weights_match_layout_stats() -> None:
from netx_topology_mcp.layout_ops.orbit_sweep import (
_W_CLR,
diff --git a/packages/netx-topology-mcp/tests/test_pull_far_chains.py b/packages/netx-topology-mcp/tests/test_pull_far_chains.py
new file mode 100644
index 0000000..9a3ef68
--- /dev/null
+++ b/packages/netx-topology-mcp/tests/test_pull_far_chains.py
@@ -0,0 +1,84 @@
+"""Tests for gated pull_far_chains."""
+
+from __future__ import annotations
+
+from netx_topology_mcp.layout_metrics import count_edge_crossings
+from netx_topology_mcp.layout_ops.graph_util import build_state_from_nodes_edges
+from netx_topology_mcp.layout_ops.orbit_sweep import _has_any_footprint_overlap
+from netx_topology_mcp.layout_ops.pull_far_chains import pull_far_chains
+
+
+def test_pull_far_chains_shortens_corridor_without_raising_crossings():
+ # Portal pair + hub + long deg-2 tail stretching south.
+ nodes = [
+ {"fabric_node_id": "p1", "name": "P1", "x": 0.0, "y": 0.0},
+ {"fabric_node_id": "p2", "name": "P2", "x": 1000.0, "y": 0.0},
+ {"fabric_node_id": "hub", "name": "Hub", "x": 500.0, "y": 200.0},
+ {"fabric_node_id": "a", "name": "A", "x": 500.0, "y": 1200.0},
+ {"fabric_node_id": "b", "name": "B", "x": 500.0, "y": 2400.0},
+ {"fabric_node_id": "c", "name": "C", "x": 500.0, "y": 3600.0},
+ {"fabric_node_id": "tip", "name": "Tip", "x": 500.0, "y": 4800.0},
+ ]
+ edges = [
+ {"a_node_id": "p1", "b_node_id": "hub"},
+ {"a_node_id": "p2", "b_node_id": "hub"},
+ {"a_node_id": "hub", "b_node_id": "a"},
+ {"a_node_id": "a", "b_node_id": "b"},
+ {"a_node_id": "b", "b_node_id": "c"},
+ {"a_node_id": "c", "b_node_id": "tip"},
+ ]
+ st = build_state_from_nodes_edges(nodes, edges)
+ g0 = count_edge_crossings(st.positions, st.links)
+ y0 = st.positions["tip"][1]
+ area0 = abs(
+ (max(p[0] for p in st.positions.values()) - min(p[0] for p in st.positions.values()))
+ * (max(p[1] for p in st.positions.values()) - min(p[1] for p in st.positions.values()))
+ )
+ op = pull_far_chains(
+ st,
+ portal_ids=["p1", "p2"],
+ max_chains=4,
+ min_tip_radius=1000.0,
+ scales=(0.9, 0.8, 0.7),
+ max_clearance_slack=100,
+ )
+ assert op.params.get("chains_accepted", 0) >= 1
+ assert op.state.positions["tip"][1] < y0
+ g1 = count_edge_crossings(op.state.positions, op.state.links)
+ assert g1 <= g0
+ assert not _has_any_footprint_overlap(op.state.positions, op.state.names)
+ area1 = abs(
+ (
+ max(p[0] for p in op.state.positions.values())
+ - min(p[0] for p in op.state.positions.values())
+ )
+ * (
+ max(p[1] for p in op.state.positions.values())
+ - min(p[1] for p in op.state.positions.values())
+ )
+ )
+ assert area1 < area0
+
+
+def test_pull_far_chains_moves_isolates():
+ nodes = [
+ {"fabric_node_id": "p1", "name": "P1", "x": 0.0, "y": 0.0},
+ {"fabric_node_id": "p2", "name": "P2", "x": 1000.0, "y": 0.0},
+ {"fabric_node_id": "iso", "name": "Iso", "x": 500.0, "y": 5000.0},
+ ]
+ edges = [
+ {"a_node_id": "p1", "b_node_id": "p2"},
+ ]
+ st = build_state_from_nodes_edges(nodes, edges)
+ y0 = st.positions["iso"][1]
+ op = pull_far_chains(
+ st,
+ portal_ids=["p1", "p2"],
+ max_chains=4,
+ min_tip_radius=1000.0,
+ scales=(0.8, 0.7),
+ max_clearance_slack=100,
+ pull_isolates=True,
+ )
+ assert op.params.get("isolates_pulled", 0) >= 1
+ assert op.state.positions["iso"][1] < y0
diff --git a/packages/netx-topology-mcp/tests/test_sink_dual_units.py b/packages/netx-topology-mcp/tests/test_sink_dual_units.py
index 479cd3d..2f724fe 100644
--- a/packages/netx-topology-mcp/tests/test_sink_dual_units.py
+++ b/packages/netx-topology-mcp/tests/test_sink_dual_units.py
@@ -104,4 +104,4 @@ def test_tools_registered() -> None:
names = {str(t.get("name") or "") for t in HTTP_MCP_TOOLS}
assert "sinkTopologyDualUnits" in names
assert "copyTopologyViewNodes" in names
- assert len(names) == 14
+ assert len(names) >= 14
diff --git a/packages/netx-topology-mcp/tests/test_suggest_sink_hubs.py b/packages/netx-topology-mcp/tests/test_suggest_sink_hubs.py
new file mode 100644
index 0000000..e089909
--- /dev/null
+++ b/packages/netx-topology-mcp/tests/test_suggest_sink_hubs.py
@@ -0,0 +1,146 @@
+"""Tests for suggestSinkHubs batch ranking."""
+
+from __future__ import annotations
+
+from netx_topology_mcp.layout_ops.suggest_sink_hubs import (
+ pick_batch,
+ suggest_sink_hub_batches,
+)
+from netx_topology_mcp.layout_structure import analyze_graph_structure
+
+
+def _star_graph():
+ # CN portals + TJP-like agg hub with 3 EN stubs + small GENA hub with 1 stub
+ nodes = [
+ {"fabric_node_id": "cn1", "name": "X-CN1-Y", "level": 1, "x": 0, "y": 0},
+ {"fabric_node_id": "cn2", "name": "X-CN2-Y", "level": 1, "x": 100, "y": 0},
+ {"fabric_node_id": "tjp", "name": "X-TJP-AN1-Y", "level": 2, "x": 50, "y": 100},
+ {"fabric_node_id": "e1", "name": "X-E1-EN1-Y", "level": 3, "x": 0, "y": 200},
+ {"fabric_node_id": "e2", "name": "X-E2-EN1-Y", "level": 3, "x": 50, "y": 200},
+ {"fabric_node_id": "e3", "name": "X-E3-EN1-Y", "level": 3, "x": 100, "y": 200},
+ {"fabric_node_id": "gena", "name": "X-GENA-AN1-Y", "level": 2, "x": 200, "y": 100},
+ {"fabric_node_id": "e4", "name": "X-E4-EN1-Y", "level": 3, "x": 200, "y": 200},
+ ]
+ edges = [
+ {"a_node_id": "cn1", "b_node_id": "cn2"},
+ {"a_node_id": "cn1", "b_node_id": "tjp"},
+ {"a_node_id": "cn2", "b_node_id": "tjp"},
+ {"a_node_id": "tjp", "b_node_id": "e1"},
+ {"a_node_id": "tjp", "b_node_id": "e2"},
+ {"a_node_id": "tjp", "b_node_id": "e3"},
+ {"a_node_id": "cn2", "b_node_id": "gena"},
+ {"a_node_id": "gena", "b_node_id": "e4"},
+ ]
+ return nodes, edges
+
+
+def test_suggest_ranks_largest_territory_first() -> None:
+ nodes, edges = _star_graph()
+ struct = analyze_graph_structure(nodes, edges, hub_top_k=10)
+ src = {n["fabric_node_id"] for n in nodes}
+ report = suggest_sink_hub_batches(
+ hubs=struct["hubs"],
+ soft_blocks=struct["soft_blocks"],
+ source_ids=src,
+ sink_ids=set(),
+ dual_units=struct.get("dual_units"),
+ min_territory=1,
+ top_n=5,
+ )
+ assert report["ok"] is True
+ assert report["batch_count"] >= 1
+ top = report["batches"][0]
+ # TJP should beat GENA; CN portals must not lead
+ assert top["hub_id"] == "tjp"
+ assert "cn1" not in top["fabric_node_ids"]
+ assert "cn2" not in top["fabric_node_ids"]
+ assert set(top["fabric_node_ids"]) >= {"tjp", "e1", "e2", "e3"}
+
+
+def test_suggest_drops_ids_already_on_sink() -> None:
+ nodes, edges = _star_graph()
+ struct = analyze_graph_structure(nodes, edges, hub_top_k=10)
+ src = {n["fabric_node_id"] for n in nodes}
+ report = suggest_sink_hub_batches(
+ hubs=struct["hubs"],
+ soft_blocks=struct["soft_blocks"],
+ source_ids=src,
+ sink_ids={"e1", "e2"},
+ exclude_ids={"cn1", "cn2"},
+ min_territory=1,
+ top_n=5,
+ )
+ top = pick_batch(report, 1)
+ assert top is not None
+ assert top["hub_id"] == "tjp"
+ assert "e1" not in top["fabric_node_ids"]
+ assert "e2" not in top["fabric_node_ids"]
+ assert "e3" in top["fabric_node_ids"]
+
+
+def test_suggest_skips_portal_as_hub_leader() -> None:
+ nodes, edges = _star_graph()
+ struct = analyze_graph_structure(nodes, edges, hub_top_k=10)
+ src = {n["fabric_node_id"] for n in nodes}
+ report = suggest_sink_hub_batches(
+ hubs=struct["hubs"],
+ soft_blocks=struct["soft_blocks"],
+ source_ids=src,
+ exclude_ids={"tjp"}, # force skip TJP as leader id
+ dual_units={"units": [{"portal_a": "cn1", "portal_b": "cn2"}]},
+ min_territory=1,
+ top_n=5,
+ )
+ hubs = [b["hub_id"] for b in report["batches"]]
+ assert "cn1" not in hubs and "cn2" not in hubs
+ # Excluded hub may still emit stub-only batch (hub id not in move list)
+ tjp_batch = next((b for b in report["batches"] if b["hub_id"] == "tjp"), None)
+ assert tjp_batch is not None
+ assert "tjp" not in tjp_batch["fabric_node_ids"]
+ assert set(tjp_batch["fabric_node_ids"]) >= {"e1", "e2", "e3"}
+
+
+def test_suggest_moves_stubs_under_sunk_hub() -> None:
+ nodes, edges = _star_graph()
+ struct = analyze_graph_structure(nodes, edges, hub_top_k=10)
+ src = {n["fabric_node_id"] for n in nodes}
+ report = suggest_sink_hub_batches(
+ hubs=struct["hubs"],
+ soft_blocks=struct["soft_blocks"],
+ source_ids=src,
+ sink_ids={"tjp", "e1", "e2"}, # hub already on sink
+ exclude_ids={"cn1", "cn2"},
+ min_territory=1,
+ top_n=5,
+ )
+ tjp = next((b for b in report["batches"] if b["hub_id"] == "tjp"), None)
+ assert tjp is not None
+ assert tjp["already_on_sink"] is True
+ assert tjp["fabric_node_ids"] == ["e3"]
+
+
+def test_suggest_orphan_leftovers_batch() -> None:
+ nodes, edges = _star_graph()
+ # Add disconnected orphans on source
+ nodes = list(nodes) + [
+ {"fabric_node_id": "iso1", "name": "X-ISO1-EN1-Y", "level": 3, "x": 900, "y": 900},
+ {"fabric_node_id": "iso2", "name": "X-ISO2-EN1-Y", "level": 3, "x": 950, "y": 950},
+ ]
+ struct = analyze_graph_structure(nodes, edges, hub_top_k=10)
+ src = {n["fabric_node_id"] for n in nodes}
+ # Everything except orphans already on sink
+ sink = src - {"iso1", "iso2"}
+ report = suggest_sink_hub_batches(
+ hubs=struct["hubs"],
+ soft_blocks=struct["soft_blocks"],
+ source_ids=src,
+ sink_ids=sink,
+ exclude_ids={"cn1", "cn2"},
+ min_territory=0,
+ top_n=8,
+ )
+ assert report["orphan_n"] == 2
+ orphan = next((b for b in report["batches"] if b.get("orphan")), None)
+ assert orphan is not None
+ assert set(orphan["fabric_node_ids"]) == {"iso1", "iso2"}
+ assert orphan["block_method"] == "orphan_leftovers"
diff --git a/packages/netx-topology-mcp/tests/test_topology_quality.py b/packages/netx-topology-mcp/tests/test_topology_quality.py
index 2d53791..39515f4 100644
--- a/packages/netx-topology-mcp/tests/test_topology_quality.py
+++ b/packages/netx-topology-mcp/tests/test_topology_quality.py
@@ -131,11 +131,33 @@ def test_score_includes_mid_tier_weights() -> None:
assert "edge_clearance" in s["parts"]
assert "edge_axis" in s["parts"]
assert abs(s["weights"]["chain"] - 0.10) < 1e-9
- assert abs(s["weights"]["rings"] - 0.10) < 1e-9
- assert abs(s["weights"]["edge_clearance"] - 0.08) < 1e-9
- assert abs(s["weights"]["edge_axis"] - 0.06) < 1e-9
- assert abs(s["weights"]["grid"] - 0.04) < 1e-9
assert abs(sum(s["weights"].values()) - 1.0) < 1e-9
+ # default profile: rings down, clearance up vs legacy 0.10/0.08
+ assert s["weights"]["rings"] <= 0.08
+ assert s["weights"]["edge_clearance"] >= 0.08
+ eye = score_layout_components(
+ {
+ "node_count": 200,
+ "edge_crossings": 50,
+ "crossings_per_link": 0.15,
+ "footprint_overlap_pairs": 0,
+ "label_overlap_pairs": 0,
+ "nn_p50": 170,
+ "space_utilization": 0.05,
+ "hull_utilization": 0.09,
+ "grid_occupancy": 0.05,
+ "edge_stretch_p50": 1.2,
+ "whitespace_index": 0.5,
+ "chain_score": 0.6,
+ "rings_score": 0.3,
+ "edge_clearance_score": 0.5,
+ "edge_axis_score": 0.3,
+ "axis_frac": 0.35,
+ },
+ score_profile="eye",
+ )
+ assert eye["score_profile"] == "eye"
+ assert eye["weights"]["edge_axis"] < s["weights"]["edge_axis"]
good = score_layout_components(
{
"node_count": 40,
diff --git a/tests/test_ne_collection_policy.py b/tests/test_ne_collection_policy.py
new file mode 100644
index 0000000..b075d5b
--- /dev/null
+++ b/tests/test_ne_collection_policy.py
@@ -0,0 +1,71 @@
+from __future__ import annotations
+
+import unittest
+from datetime import datetime, timedelta
+from unittest.mock import MagicMock
+
+from netx_api.collection_policy import (
+ DEFAULT_HISTORY_KEEP,
+ _normalize_interval_hours,
+ history_keep_value,
+ next_due_at,
+ policy_to_out,
+)
+from netx_api.collection_schemas import CollectionPolicyUpdate
+from netx_api.models import NeCollectionJob, NeCollectionPolicy
+
+
+class NeCollectionPolicyDefaultsTests(unittest.TestCase):
+ def test_default_history_keep_is_three(self) -> None:
+ self.assertEqual(DEFAULT_HISTORY_KEEP, 3)
+ row = NeCollectionPolicy(id=1, enabled=False, history_keep=3)
+ self.assertEqual(history_keep_value(row), 3)
+ out = policy_to_out(row)
+ self.assertFalse(out.enabled)
+ self.assertEqual(out.history_keep, 3)
+
+ def test_normalize_interval_hours_from_days(self) -> None:
+ row = NeCollectionPolicy(id=1, interval_hours=0, interval_days=2)
+ self.assertEqual(_normalize_interval_hours(row), 48)
+
+ def test_policy_update_schema_bounds(self) -> None:
+ body = CollectionPolicyUpdate(history_keep=3, interval_hours=24, enabled=False)
+ data = body.model_dump(exclude_unset=True)
+ self.assertEqual(data["history_keep"], 3)
+ self.assertFalse(data["enabled"])
+
+
+class NeCollectionNextDueTests(unittest.TestCase):
+ def test_next_due_none_when_disabled(self) -> None:
+ db = MagicMock()
+ policy = NeCollectionPolicy(id=1, enabled=False, interval_hours=24)
+ self.assertIsNone(next_due_at(db, policy))
+
+ def test_next_due_now_when_no_prior_schedule(self) -> None:
+ db = MagicMock()
+ db.query.return_value.filter.return_value.order_by.return_value.first.return_value = None
+ policy = NeCollectionPolicy(id=1, enabled=True, interval_hours=24)
+ due = next_due_at(db, policy)
+ self.assertIsNotNone(due)
+ assert due is not None
+ self.assertLessEqual(abs((due - datetime.now()).total_seconds()), 5)
+
+ def test_next_due_from_last_scheduled_done(self) -> None:
+ db = MagicMock()
+ ended = datetime.now() - timedelta(hours=2)
+ last = NeCollectionJob(
+ id="abc",
+ status="done",
+ trigger_mode="schedule",
+ ended_at=ended,
+ )
+ db.query.return_value.filter.return_value.order_by.return_value.first.return_value = last
+ policy = NeCollectionPolicy(id=1, enabled=True, interval_hours=24)
+ due = next_due_at(db, policy)
+ self.assertIsNotNone(due)
+ assert due is not None
+ self.assertEqual(due, ended + timedelta(hours=24))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_topology.py b/tests/test_topology.py
index ecd3e41..690bb27 100644
--- a/tests/test_topology.py
+++ b/tests/test_topology.py
@@ -977,11 +977,14 @@ class FabricTopologyTests(unittest.TestCase):
),
)
prev = clf.preview_classify(self.db)
+ self.assertGreaterEqual(prev.level_matched, 4)
self.assertGreaterEqual(prev.role_matched, 4)
applied = clf.apply_classify(self.db)
+ self.assertGreaterEqual(applied.level_updated, 4)
self.assertGreaterEqual(applied.role_updated, 4)
self.db.refresh(nodes[0])
self.assertEqual(nodes[0].role, "core")
+ self.assertEqual(nodes[0].level, 1.0)
self.assertEqual(nodes[0].region_folder_id, region.id)
dry = clf.generate_slices(
@@ -1026,6 +1029,11 @@ class FabricTopologyTests(unittest.TestCase):
),
)
self.assertEqual(bulk.updated, 1)
+ self.assertEqual(bulk.level, 3.0)
+ acc = next(n for n in nodes if n.name.startswith("ACC-"))
+ self.db.refresh(acc)
+ self.assertEqual(acc.level, 3.0)
+ self.assertEqual(acc.role, "access")
def test_project_neighbors_respects_max_nodes(self) -> None:
suffix = uuid4().hex[:8]
diff --git a/web/WEB.md b/web/WEB.md
index 12f0561..c00e803 100644
--- a/web/WEB.md
+++ b/web/WEB.md
@@ -138,14 +138,14 @@ src/
- MCP:以 `queryTopologyEdges` 为主查询 Fabric;画布编辑走 Web
- BGP / 隧道 / L2VPN:`layer` 预留,实现 TODO
-## 分类与切片(清单打标)
+## 分类(清单打标)
-- Fabric 标签:`topo_fabric_node.role` / `region_folder_id`(`role_source` / `region_source`)
-- 主流程:网元清单表(Fabric 全量)+ 临时正则查找 → 确认后批量写角色/区域;支持单行编辑
-- 关联状态:`link_status`=`managed|ume|both|orphaned`;筛选 `link_status=linked|orphaned|…`
-- API:`GET /fabric/nodes`(keyword/role/region/unmatched/link_status)、`POST /fabric/nodes/match`、`POST /fabric/nodes/tags/bulk`、`PATCH /fabric/nodes/{id}/tags`
-- 切片:`POST /v1/topology/slices/generate`(`core_only` / `core_agg` / `agg_access`;dry_run;可重叠上图)
-- 前端:网络管理 →「分类与切片」
+- Fabric 主键:`topo_fabric_node.level`(major.minor,越小越靠外);`role` 为 `floor(level)` 同步别名
+- 预设:0 外部 / 1 核心 / 2 汇聚 / 3 接入;子层如 1.1、2.1
+- 主流程:清单 + 正则 → 批量写 level/区域;支持单行编辑
+- API:`GET /fabric/nodes`(level / level_major / role / unmatched=level)、`POST …/match`、`POST …/tags/bulk`(`level`)、`PATCH …/tags`
+- Agent:`classifyTopologyFabricNodes`(netx-topology MCP)
+- 前端:网络管理 →「分类」
## WebCRT
diff --git a/web/src/components/ListPager.tsx b/web/src/components/ListPager.tsx
new file mode 100644
index 0000000..2c22e4e
--- /dev/null
+++ b/web/src/components/ListPager.tsx
@@ -0,0 +1,116 @@
+import { useEffect, useState } from "react";
+import { useI18n } from "../i18n";
+
+export type ListPagerProps = {
+ page: number;
+ pages: number;
+ total: number;
+ pageSize: number;
+ pageSizeOptions?: number[];
+ onPageChange: (page: number) => void;
+ onPageSizeChange?: (size: number) => void;
+ disabled?: boolean;
+ className?: string;
+};
+
+/** Shared denser pager: meta + prev/next + jump + optional page size. */
+export function ListPager({
+ page,
+ pages,
+ total,
+ pageSize,
+ pageSizeOptions = [20, 50, 100, 200],
+ onPageChange,
+ onPageSizeChange,
+ disabled = false,
+ className = "",
+}: ListPagerProps) {
+ const { t } = useI18n();
+ const safePages = Math.max(1, pages || 1);
+ const [jumpDraft, setJumpDraft] = useState(String(page));
+
+ useEffect(() => {
+ setJumpDraft(String(page));
+ }, [page]);
+
+ const commitJump = () => {
+ const n = Math.floor(Number(jumpDraft));
+ if (!Number.isFinite(n)) {
+ setJumpDraft(String(page));
+ return;
+ }
+ const next = Math.min(safePages, Math.max(1, n));
+ setJumpDraft(String(next));
+ if (next !== page) onPageChange(next);
+ };
+
+ return (
+
+
+ {t("common.pagerMeta", {
+ total: String(total),
+ page: String(page),
+ pages: String(safePages),
+ })}
+
+
+
+
+
+ {onPageSizeChange ? (
+
+ ) : null}
+
+
+ );
+}
diff --git a/web/src/constants/queryKeys.ts b/web/src/constants/queryKeys.ts
index 7bb869e..3b0c5d1 100644
--- a/web/src/constants/queryKeys.ts
+++ b/web/src/constants/queryKeys.ts
@@ -27,12 +27,14 @@ export const queryKeys = {
collectionEligibleNeAll: ["collectionEligibleNe"] as const,
collectionEligibleNe: (page: number, keyword: string) => ["collectionEligibleNe", page, keyword] as const,
neCollectionsAll: ["neCollections"] as const,
- neCollections: (page: number) => ["neCollections", page] as const,
+ neCollections: (page: number, pageSize = 20, status = "", keyword = "") =>
+ ["neCollections", page, pageSize, status, keyword] as const,
neCollectionDashboard: ["neCollectionDashboard"] as const,
+ neCollectionPolicy: ["neCollectionPolicy"] as const,
neCollectionDetail: (jobId: string) => ["neCollection", jobId] as const,
neCollectionRunsAll: ["neCollectionRuns"] as const,
- neCollectionRuns: (jobId: string, page: number, status: string, keyword: string) =>
- ["neCollectionRuns", jobId, page, status, keyword] as const,
+ neCollectionRuns: (jobId: string, page: number, pageSize: number, status: string, keyword: string) =>
+ ["neCollectionRuns", jobId, page, pageSize, status, keyword] as const,
cliMeta: ["cliMeta"] as const,
cliProfiles: ["cliProfiles"] as const,
cliTargetsAll: ["cliTargets"] as const,
@@ -56,7 +58,9 @@ export const queryKeys = {
unmatched: string,
linkStatus: string,
page: number,
- ) => ["fabricNodeInventory", keyword, role, regionFolderId, unmatched, linkStatus, page] as const,
+ levelMajor?: string,
+ ) =>
+ ["fabricNodeInventory", keyword, role, regionFolderId, unmatched, linkStatus, page, levelMajor || ""] as const,
fabricNodeSearch: (q: string, page: number) => ["fabricNodeSearch", q, page] as const,
topologyViewGraph: (viewId: string) => ["topologyViewGraph", viewId] as const,
fabricSummary: ["fabricSummary"] as const,
@@ -66,26 +70,35 @@ export const queryKeys = {
["topologyViewGraph", viewId, focusFolderId] as const,
lldpCollectDashboard: ["lldpCollectDashboard"] as const,
lldpCollectJobsAll: ["lldpCollectJobs"] as const,
- lldpCollectJobs: (page: number) => ["lldpCollectJobs", page] as const,
+ lldpCollectJobs: (page: number, pageSize = 10, status = "", keyword = "") =>
+ ["lldpCollectJobs", page, pageSize, status, keyword] as const,
lldpCollectJobAll: ["lldpCollectJob"] as const,
- lldpCollectJob: (jobId: string, page = 1) => ["lldpCollectJob", jobId, page] as const,
+ lldpCollectJob: (jobId: string, page = 1, pageSize = 20, status = "", keyword = "") =>
+ ["lldpCollectJob", jobId, page, pageSize, status, keyword] as const,
fabricEdgesAll: ["fabricEdges"] as const,
- fabricEdges: (status: string, keyword: string, page: number) =>
- ["fabricEdges", status, keyword, page] as const,
+ fabricEdges: (status: string, keyword: string, page: number, pageSize = 20, source = "") =>
+ ["fabricEdges", status, keyword, page, pageSize, source] as const,
configSyncDashboard: ["configSyncDashboard"] as const,
configSyncPolicy: ["configSyncPolicy"] as const,
configSyncCyclesAll: ["configSyncCycles"] as const,
- configSyncCycles: (page: number) => ["configSyncCycles", page] as const,
+ configSyncCycles: (page: number, pageSize = 20, status = "", keyword = "") =>
+ ["configSyncCycles", page, pageSize, status, keyword] as const,
configSyncCycleTasksAll: ["configSyncCycleTasks"] as const,
- configSyncCycleTasks: (cycleId: string, page: number, status: string, keyword: string) =>
- ["configSyncCycleTasks", cycleId, page, status, keyword] as const,
+ configSyncCycleTasks: (
+ cycleId: string,
+ page: number,
+ pageSize = 20,
+ status = "",
+ keyword = "",
+ ) => ["configSyncCycleTasks", cycleId, page, pageSize, status, keyword] as const,
networkConfigsAll: ["networkConfigs"] as const,
- networkConfigs: (page: number, keyword: string, source: string, vendor: string) =>
- ["networkConfigs", page, keyword, source, vendor] as const,
+ networkConfigs: (page: number, keyword: string, source: string, vendor: string, pageSize = 20) =>
+ ["networkConfigs", page, keyword, source, vendor, pageSize] as const,
networkConfigDetail: (source: string, id: string) => ["networkConfigDetail", source, id] as const,
portTrafficDashboard: ["portTrafficDashboard"] as const,
portTrafficDevicesAll: ["portTrafficDevices"] as const,
- portTrafficDevices: (page: number) => ["portTrafficDevices", page] as const,
+ portTrafficDevices: (page: number, pageSize = 20, status = "", keyword = "") =>
+ ["portTrafficDevices", page, pageSize, status, keyword] as const,
portTrafficTargets: (deviceId: string) => ["portTrafficTargets", deviceId] as const,
portTrafficEvents: (deviceId: string) => ["portTrafficEvents", deviceId] as const,
portTrafficBoards: ["portTrafficBoards"] as const,
diff --git a/web/src/hooks/useDebouncedValue.ts b/web/src/hooks/useDebouncedValue.ts
new file mode 100644
index 0000000..5539cbf
--- /dev/null
+++ b/web/src/hooks/useDebouncedValue.ts
@@ -0,0 +1,11 @@
+import { useEffect, useState } from "react";
+
+/** Debounce a rapidly changing value (e.g. keyword input) before querying. */
+export function useDebouncedValue(value: T, delayMs = 300): T {
+ const [debounced, setDebounced] = useState(value);
+ useEffect(() => {
+ const timer = window.setTimeout(() => setDebounced(value), delayMs);
+ return () => window.clearTimeout(timer);
+ }, [value, delayMs]);
+ return debounced;
+}
diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts
index b281da6..8667579 100644
--- a/web/src/i18n/en.ts
+++ b/web/src/i18n/en.ts
@@ -9,6 +9,13 @@ const en = {
nextPage: "Next",
perPage: "{{n}}/page",
pagerMeta: "{{total}} items · page {{page}}/{{pages}}",
+ jumpPage: "Go to page",
+ pageSize: "Page size",
+ exportCsv: "Export CSV",
+ exporting: "Exporting…",
+ exportOk: "Exported {{count}} rows",
+ exportTruncated: "Exported first {{count}} of {{total}} rows (truncated)",
+ exportFailed: "Export failed",
opFailed: "Operation failed",
empty: "-",
cancel: "Cancel",
@@ -100,7 +107,7 @@ const en = {
devices: "Device list",
topology: "Topology",
lldpLinks: "LLDP",
- topoClassify: "Classify / slices",
+ topoClassify: "Classify",
alarms: "Alarms",
configs: "Configurations",
webcrt: "WebCRT",
@@ -130,10 +137,15 @@ const en = {
statusRunning: "Running",
statusPaused: "Paused",
statusStopped: "Stopped",
+ statusDraft: "Draft",
collecting: "Collecting",
deleted: "Deleted",
confirmDelete: "Delete this device monitoring and its samples?",
empty: "No devices monitored yet. Pick a NE and interfaces to start.",
+ keywordPh: "Name / IP / note / ID",
+ allStatus: "All statuses",
+ exportDevicesName: "port-traffic-devices",
+ exportEventsName: "port-traffic-events",
wall: "Ops wall",
wallTitle: "Port traffic wall",
toDevices: "Manage devices",
@@ -354,6 +366,14 @@ const en = {
edgeStatusAll: "All statuses",
edgeStatusActive: "Active",
edgeStatusMissing: "Missing",
+ edgeSourceAll: "All sources",
+ exportEdgesName: "lldp-fabric-edges",
+ exportJobsName: "lldp-collect-jobs",
+ exportItemsName: "lldp-job-items",
+ statusAll: "All statuses",
+ jobKeywordPh: "Job ID / error / scope",
+ itemKeywordPh: "NE name / IP / error",
+ itemResultAll: "All results",
missCount: "Miss cycles",
replacedBy: "Replaced",
kpi: {
@@ -392,6 +412,7 @@ const en = {
done: "Done",
failed: "Failed",
cancelled: "Cancelled",
+ stopped: "Stopped",
},
triggerMode: {
manual: "Manual",
@@ -444,7 +465,11 @@ const en = {
cyclesEmpty: "No sync cycles yet",
tasksTitle: "Cycle tasks",
taskKeywordPh: "Name / IP / message",
+ keywordPh: "ID / trigger / message",
+ statusAll: "All statuses",
allStatus: "All statuses",
+ exportCyclesName: "config-sync-cycles",
+ exportTasksName: "config-sync-tasks",
expand: "Details",
collapse: "Collapse",
kpi: {
@@ -472,12 +497,14 @@ const en = {
keywordPh: "Name / IP / ID",
allSource: "All sources",
empty: "No network elements.",
+ exportName: "network-devices",
col: {
source: "Source",
name: "Name",
vendor: "Vendor",
deviceType: "Device type",
connect: "Connect",
+ actions: "Actions",
},
},
networkConfigs: {
@@ -490,6 +517,7 @@ const en = {
export: "Export",
exportBoth: "Export all",
exportOk: "Config exported",
+ exportListName: "network-configs",
close: "Close",
tabSet: "Set format",
tabHier: "Hierarchical",
@@ -626,6 +654,28 @@ const en = {
passwordMustChange: "New password must differ from the default/old password",
},
collect: {
+ statusAll: "All statuses",
+ keywordPh: "Job title / ID",
+ exportJobsName: "collect-jobs",
+ exportRunsName: "collect-runs",
+ policyTitle: "Collect policy",
+ policyHint:
+ "Periodic schedule is off by default (one-shot / manual). Enable it to auto-create and run jobs on an interval. Keeps the last 3 finished jobs by default.",
+ policySaved: "Policy saved",
+ enabled: "Enable scheduled collect",
+ interval: "Collect interval",
+ intervalUnitDays: "days",
+ intervalUnitHours: "hours",
+ historyKeep: "Jobs to keep",
+ scope: "Scope",
+ scopeAll: "All NEs (managed + UME)",
+ scopeSelected: "Selected NEs only",
+ savePolicy: "Save policy",
+ collectNow: "Collect now",
+ selectedCount: "{{count}} selected",
+ colSource: "Source",
+ policyCommandsRequired: "Add commands and save the policy before enabling schedule or collecting now",
+ policyTargetsRequired: "No eligible NEs in scope; adjust scope or select NEs",
create: {
title: "New collection job",
hint: "Configure commands and NEs, then create the job. Click Start in the job list to run collection.",
@@ -676,6 +726,7 @@ const en = {
confirmDelete: "Delete this collection job and its log files?",
jobs: {
title: "Collection jobs",
+ listTitle: "Job list",
col: {
title: "Job",
status: "Status",
@@ -705,6 +756,7 @@ const en = {
idle: "Idle",
last: "Last job",
active: "Active jobs",
+ nextDue: "Next due",
},
runs: {
status: "Status",
@@ -1152,10 +1204,20 @@ const en = {
keywordPh: "keyword(alarm key/cause/ne_name/host_name/ip…)",
hostNamePh: "host_name (contains)",
allSeverity: "All severities",
- clearedAll: "is_cleared: all",
+ clearedAll: "Cleared: all",
+ clearedYes: "Cleared",
+ clearedNo: "Active",
clearTitle: "Clear filters and reset to page 1",
filterByHost: "Filter by this host",
noHostName: "No host_name (sync inventory first)",
+ col: {
+ time: "Time",
+ severity: "Severity",
+ neId: "NE ID",
+ hostName: "Host",
+ neType: "NE type",
+ cause: "Cause",
+ },
},
},
webcrt: {
@@ -1397,6 +1459,7 @@ const en = {
treeLoadFailed: "Failed to load topology tree",
treeRetry: "Retry",
graphLoading: "Loading topology…",
+ graphLoadFailed: "Failed to load topology",
graphRefreshing: "Refreshing…",
canvasEmpty: "No NEs on this map yet",
canvasEmptyHint: "Use Add NE, or pick a region that already has devices.",
@@ -1476,8 +1539,8 @@ const en = {
"Remove selected link(s) from the canvas? They are deleted from Fabric only when you Save. LLDP may recreate them on the next collect.",
edgeDeleted: "Queued {{count}} link delete(s) — Save to apply",
discoverCancelled: "Discovery cancelled",
- truncatedMembership: "Membership cap reached; some neighbors were not placed. Use classify/slices or a new map.",
- truncatedFrozen: "This map membership is frozen; neighbors cannot be projected. Unfreeze in classify/slices or create a new map.",
+ truncatedMembership: "Membership cap reached; some neighbors were not placed. Use classify or a new map.",
+ truncatedFrozen: "This map membership is frozen; neighbors cannot be projected. Unfreeze via classify workflow or create a new map.",
truncatedNodes: "Too many view nodes; display truncated. Narrow membership or create another map.",
truncatedEdges: "Too many edges; display truncated. Filter status or create another map.",
truncatedGeneric: "Graph data truncated. Narrow scope or create another map.",
@@ -1506,6 +1569,11 @@ const en = {
unsavedConfirm: "You have unsaved changes. Discard and switch maps?",
saving: "Saving…",
saved: "Topology saved",
+ saveFailed: "Save failed: {{detail}}",
+ readOnlyBanner: "Read-only (ne:write scope required to edit)",
+ readOnlyHint: "Read-only — no write permission",
+ discoverTimeout: "Discovery timed out; check LLDP tasks for progress",
+ policySyncFailed: "Failed to sync LLDP policy; local toggle still applies to canvas discovery",
discover: "Discover links",
discovering: "Discovering…",
discoverOne: "Discover links",
@@ -1697,16 +1765,16 @@ const en = {
connectHint: "Connect mode: drag from the NE center anchor to the target (Esc or Select to exit)",
},
topoClassify: {
- title: "Classify & slices",
+ title: "Classify",
openTopo: "Topology",
inventory: "NE inventory",
keywordPh: "Filter by name/IP",
- filterRoleAll: "All roles",
+ filterLevelAll: "All levels",
filterRegionAll: "All regions",
filterUnmatchedOff: "No unmatched filter",
- kindAny: "Role or region empty",
- kindRole: "Role empty",
- kindRegion: "Region empty",
+ kindAny: "Missing level or region",
+ kindLevel: "Missing level",
+ kindRegion: "Missing region",
filterLinkAll: "All link states",
filterLinkLinked: "Linked inventory",
filterLinkOrphaned: "Detached",
@@ -1715,10 +1783,14 @@ const en = {
linkUme: "UME",
linkBoth: "Managed+UME",
linkOrphaned: "Detached",
- roleCore: "Core",
- roleAggregation: "Aggregation",
- roleAccess: "Access",
- roleUnknown: "Unknown",
+ levelExternal: "L0 external",
+ levelCore: "L1 core",
+ levelCoreSub: "L1.1 core′",
+ levelAgg: "L2 aggregation",
+ levelAggSub: "L2.1 agg′",
+ levelAccess: "L3 access",
+ levelAccessSub: "L3.1 deeper access",
+ levelInvalid: "Invalid level (0–99.9, one decimal)",
sourceManual: "Manual",
sourceUmeSync: "UME sync",
sourceLldp: "LLDP placeholder",
@@ -1731,9 +1803,9 @@ const en = {
findMatches: "Find matches",
matchOk: "Matched {{count}}; selected",
matchHint: "{{count}} matched",
- assignRole: "Role only",
+ assignLevel: "Level only",
assignRegion: "Region only",
- assignBoth: "Role + region",
+ assignBoth: "Level + region",
clearRegion: "Clear region",
confirmAssign: "Confirm assign",
bulkConfirm: "Write tags to {{count}} selected/matched NE(s)?",
@@ -1741,7 +1813,7 @@ const en = {
selectedCount: "{{count}} selected",
selectPage: "Select page",
colNe: "NE",
- colRole: "Role",
+ colLevel: "Level",
colRegion: "Region",
colActions: "Actions",
edit: "Edit",
@@ -1758,20 +1830,6 @@ const en = {
rowSaved: "Saved",
loading: "Loading…",
empty: "No NEs.",
- showSlices: "Show slice maps",
- hideSlices: "Hide slice maps",
- slices: "Slice maps",
- pickRegion: "Pick region",
- tplCore: "Core only",
- tplCoreAgg: "Core + neighbor Agg",
- tplAggAcc: "Agg + neighbor Access",
- seedPhysical: "Also place Cores on physical overview",
- slicePreview: "Preview slices",
- sliceApply: "Generate maps",
- sliceConfirm: "Create custom maps and freeze placements?",
- sliceOk: "Created {{count}} slice map(s)",
- sliceStats: "{{maps}} map(s) planned, {{overlap}} NE(s) on multiple maps",
- nodeCount: "{{count}} NE(s)",
},
audit: {
title: "Audit & ops",
diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts
index bc473fa..fdbf583 100644
--- a/web/src/i18n/zh.ts
+++ b/web/src/i18n/zh.ts
@@ -9,6 +9,13 @@ const zh = {
nextPage: "下一页",
perPage: "{{n}}/页",
pagerMeta: "共 {{total}} 条 · 第 {{page}}/{{pages}} 页",
+ jumpPage: "跳转到页",
+ pageSize: "每页条数",
+ exportCsv: "导出 CSV",
+ exporting: "导出中…",
+ exportOk: "已导出 {{count}} 条",
+ exportTruncated: "已导出前 {{count}} 条(共 {{total}},已截断)",
+ exportFailed: "导出失败",
opFailed: "操作失败",
empty: "-",
cancel: "取消",
@@ -100,7 +107,7 @@ const zh = {
devices: "设备列表",
topology: "拓扑信息",
lldpLinks: "LLDP",
- topoClassify: "分类切片",
+ topoClassify: "分类",
alarms: "告警信息",
configs: "配置信息",
webcrt: "WebCRT",
@@ -130,10 +137,15 @@ const zh = {
statusRunning: "运行中",
statusPaused: "已暂停",
statusStopped: "已停止",
+ statusDraft: "草稿",
collecting: "采集中",
deleted: "已删除",
confirmDelete: "删除该设备监控及其采样数据?",
empty: "暂无设备监控。请先选择网元并勾选接口。",
+ keywordPh: "名称 / IP / 备注 / ID",
+ allStatus: "全部状态",
+ exportDevicesName: "port-traffic-devices",
+ exportEventsName: "port-traffic-events",
wall: "监控大屏",
wallTitle: "端口流量大屏",
toDevices: "设备管理",
@@ -350,6 +362,14 @@ const zh = {
edgeStatusAll: "全部状态",
edgeStatusActive: "活跃",
edgeStatusMissing: "未发现",
+ edgeSourceAll: "全部来源",
+ exportEdgesName: "lldp-fabric-edges",
+ exportJobsName: "lldp-collect-jobs",
+ exportItemsName: "lldp-job-items",
+ statusAll: "全部状态",
+ jobKeywordPh: "任务 ID / 错误 / 范围",
+ itemKeywordPh: "网元名称 / IP / 错误",
+ itemResultAll: "全部结果",
missCount: "未发现周期",
replacedBy: "已被替换",
kpi: {
@@ -388,6 +408,7 @@ const zh = {
done: "已完成",
failed: "失败",
cancelled: "已取消",
+ stopped: "已停止",
},
triggerMode: {
manual: "手动",
@@ -440,7 +461,11 @@ const zh = {
cyclesEmpty: "暂无同步周期",
tasksTitle: "周期任务",
taskKeywordPh: "名称 / IP / 消息",
+ keywordPh: "ID / 触发 / 消息",
+ statusAll: "全部状态",
allStatus: "全部状态",
+ exportCyclesName: "config-sync-cycles",
+ exportTasksName: "config-sync-tasks",
expand: "详情",
collapse: "收起",
kpi: {
@@ -468,12 +493,14 @@ const zh = {
keywordPh: "名称 / IP / ID",
allSource: "全部来源",
empty: "暂无网元。",
+ exportName: "network-devices",
col: {
source: "来源",
name: "名称",
vendor: "厂商",
deviceType: "设备类型",
connect: "连通状态",
+ actions: "操作",
},
},
networkConfigs: {
@@ -486,6 +513,7 @@ const zh = {
export: "导出",
exportBoth: "导出全部",
exportOk: "配置已导出",
+ exportListName: "network-configs",
close: "关闭",
tabSet: "Set 格式",
tabHier: "层级格式",
@@ -621,6 +649,27 @@ const zh = {
passwordMustChange: "新密码不能与默认/旧密码相同",
},
collect: {
+ statusAll: "全部状态",
+ keywordPh: "任务名称 / ID",
+ exportJobsName: "collect-jobs",
+ exportRunsName: "collect-runs",
+ policyTitle: "采集策略",
+ policyHint: "默认不启用周期(只采一次);勾选后按周期自动创建并执行。默认保留最近 3 次任务数据。",
+ policySaved: "策略已保存",
+ enabled: "启用周期调度",
+ interval: "采集周期",
+ intervalUnitDays: "天",
+ intervalUnitHours: "小时",
+ historyKeep: "任务保留数",
+ scope: "范围",
+ scopeAll: "全部网元(纳管+UME)",
+ scopeSelected: "仅选中网元",
+ savePolicy: "保存策略",
+ collectNow: "立即采集",
+ selectedCount: "已选 {{count}} 台",
+ colSource: "来源",
+ policyCommandsRequired: "启用周期或立即采集前,请先填写采集命令并保存策略",
+ policyTargetsRequired: "选定范围内没有可用网元,请调整范围或勾选网元",
create: {
title: "新建采集任务",
hint: "配置命令与网元后创建任务;创建后需在任务列表中点击「开始」执行采集。",
@@ -671,6 +720,7 @@ const zh = {
confirmDelete: "确定删除该采集任务?相关日志文件将一并删除。",
jobs: {
title: "采集任务",
+ listTitle: "任务列表",
col: {
title: "任务",
status: "状态",
@@ -700,6 +750,7 @@ const zh = {
idle: "空闲",
last: "最近任务",
active: "活跃任务",
+ nextDue: "下次周期",
},
runs: {
status: "状态",
@@ -1145,10 +1196,20 @@ const zh = {
keywordPh: "keyword(告警键/原因/ne_name/host_name/ip 等)",
hostNamePh: "host_name(含匹配)",
allSeverity: "全部级别",
- clearedAll: "is_cleared: all",
+ clearedAll: "清除状态:全部",
+ clearedYes: "已清除",
+ clearedNo: "未清除",
clearTitle: "清空 keyword、host_name、级别、is_cleared,回到第 1 页",
filterByHost: "按该主机名筛选",
noHostName: "无 host_name(需先同步网元)",
+ col: {
+ time: "告警时间",
+ severity: "级别",
+ neId: "网元 ID",
+ hostName: "主机名",
+ neType: "网元类型",
+ cause: "原因",
+ },
},
},
webcrt: {
@@ -1388,6 +1449,7 @@ const zh = {
treeLoadFailed: "拓扑树加载失败",
treeRetry: "重试",
graphLoading: "正在加载拓扑图…",
+ graphLoadFailed: "拓扑图加载失败",
graphRefreshing: "刷新中…",
canvasEmpty: "这张图上还没有网元",
canvasEmptyHint: "可用「添加网元」上图,或从左侧换到有设备的区域。",
@@ -1464,8 +1526,8 @@ const zh = {
deleteEdgeConfirm: "从画布移除选中链路?仅在点击「保存」后才会从 Fabric 删除;LLDP 下次采集若仍存在可能再次出现。",
edgeDeleted: "已标记删除 {{count}} 条链路(保存后生效)",
discoverCancelled: "发现已取消",
- truncatedMembership: "视图已达成员上限,部分邻居未上图。可去分类/切片或新建视图。",
- truncatedFrozen: "当前视图已冻结成员,无法投影邻居。请在分类/切片中解冻或新建视图。",
+ truncatedMembership: "视图已达成员上限,部分邻居未上图。可去分类页或新建视图。",
+ truncatedFrozen: "当前视图已冻结成员,无法投影邻居。请在分类流程中处理或新建视图。",
truncatedNodes: "画布节点过多已截断显示。建议新建视图或收紧成员范围。",
truncatedEdges: "画布链路过多已截断显示。建议筛选状态或新建视图。",
truncatedGeneric: "图数据已截断显示。建议收紧范围或新建视图。",
@@ -1494,6 +1556,11 @@ const zh = {
unsavedConfirm: "有未保存的更改,确定丢弃并切换吗?",
saving: "保存中…",
saved: "拓扑已保存",
+ saveFailed: "保存失败:{{detail}}",
+ readOnlyBanner: "只读模式(需要 ne:write 权限才能编辑)",
+ readOnlyHint: "只读模式,无编辑权限",
+ discoverTimeout: "发现任务超时,请稍后在 LLDP 任务页查看进度",
+ policySyncFailed: "同步 LLDP 策略失败,本地开关仍对本页发现生效",
discover: "发现链路",
discovering: "发现中…",
discoverOne: "发现链路",
@@ -1680,15 +1747,15 @@ const zh = {
connectHint: "连线模式:从网元中心锚点拖到目标网元(Esc 或切回选择退出)",
},
topoClassify: {
- title: "分类与切片",
+ title: "分类",
openTopo: "拓扑画布",
inventory: "网元清单",
keywordPh: "关键字筛选名称/IP",
- filterRoleAll: "全部角色",
+ filterLevelAll: "全部层级",
filterRegionAll: "全部区域",
filterUnmatchedOff: "不过滤未分类",
- kindAny: "角色或区域未填",
- kindRole: "角色未填",
+ kindAny: "层级或区域未填",
+ kindLevel: "层级未填",
kindRegion: "区域未填",
filterLinkAll: "全部关联",
filterLinkLinked: "已关联库存",
@@ -1698,10 +1765,14 @@ const zh = {
linkUme: "UME",
linkBoth: "托管+UME",
linkOrphaned: "已解绑",
- roleCore: "核心",
- roleAggregation: "汇聚",
- roleAccess: "接入",
- roleUnknown: "未知",
+ levelExternal: "L0 外部",
+ levelCore: "L1 核心",
+ levelCoreSub: "L1.1 次核心",
+ levelAgg: "L2 汇聚",
+ levelAggSub: "L2.1 二级汇聚",
+ levelAccess: "L3 接入",
+ levelAccessSub: "L3.1 更深接入",
+ levelInvalid: "层级无效(0–99.9,一位小数)",
sourceManual: "手动",
sourceUmeSync: "UME 同步",
sourceLldp: "LLDP 占位",
@@ -1714,9 +1785,9 @@ const zh = {
findMatches: "查找匹配",
matchOk: "匹配 {{count}} 条,已勾选",
matchHint: "当前匹配 {{count}} 条",
- assignRole: "只改角色",
+ assignLevel: "只改层级",
assignRegion: "只改区域",
- assignBoth: "角色+区域",
+ assignBoth: "层级+区域",
clearRegion: "清空区域",
confirmAssign: "确认写入",
bulkConfirm: "将给已选/匹配的 {{count}} 条网元写入分类,确定?",
@@ -1724,7 +1795,7 @@ const zh = {
selectedCount: "已选 {{count}}",
selectPage: "全选本页",
colNe: "网元",
- colRole: "角色",
+ colLevel: "层级",
colRegion: "区域",
colActions: "操作",
edit: "编辑",
@@ -1739,20 +1810,6 @@ const zh = {
rowSaved: "已保存",
loading: "加载中…",
empty: "没有网元。",
- showSlices: "展开切片建图",
- hideSlices: "收起切片建图",
- slices: "切片建图",
- pickRegion: "选择区域",
- tplCore: "仅 Core",
- tplCoreAgg: "Core + 邻接 Agg",
- tplAggAcc: "Agg + 邻接 Access",
- seedPhysical: "同时给物理总览上 Core",
- slicePreview: "预览切片",
- sliceApply: "生成切片图",
- sliceConfirm: "将创建自定义拓扑图并上图冻结,确定?",
- sliceOk: "已创建 {{count}} 张切片图",
- sliceStats: "计划 {{maps}} 张图,重复上图网元 {{overlap}} 个",
- nodeCount: "{{count}} 个网元",
},
audit: {
title: "操作审计",
diff --git a/web/src/index.css b/web/src/index.css
index 9150ede..18f48e4 100644
--- a/web/src/index.css
+++ b/web/src/index.css
@@ -2208,6 +2208,36 @@ pre {
min-width: 88px;
}
+.pager__jump {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ font-size: 12px;
+}
+
+.pager__jump-input {
+ width: 3.2rem;
+ min-width: 2.5rem;
+ max-width: 4.5rem;
+ padding: 4px 6px;
+ text-align: center;
+}
+
+.visually-hidden {
+ position: absolute;
+ inset: 0;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: 0;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ clip-path: inset(50%);
+ white-space: nowrap;
+ border: 0;
+}
+
@keyframes shimmer {
0% {
background-position: 200% 0;
@@ -7591,6 +7621,26 @@ pre {
pointer-events: none;
}
+.topo-readonly-banner {
+ position: absolute;
+ top: 8px;
+ right: 12px;
+ z-index: 5;
+ max-width: min(360px, calc(100% - 24px));
+ padding: 6px 10px;
+ border-radius: 8px;
+ border: 1px solid rgba(56, 189, 248, 0.45);
+ background: rgba(15, 23, 42, 0.92);
+ color: #bae6fd;
+ font-size: 12px;
+ line-height: 1.4;
+ pointer-events: none;
+}
+
+.topo-canvas__overlay--error {
+ background: rgba(15, 23, 42, 0.88);
+}
+
.app-main .topo-toolbar__outside-peers,
.topo-toolbar__outside-peers {
margin-left: auto;
@@ -8506,12 +8556,32 @@ html.login-page--paused .login-page__flare {
/* —— Network Management shell (left nav + main) ——
- Light ops shell + login-aligned accent blues. */
+ Fill the app chrome without a second document scrollbar.
+ Negative margins + 100vh used to fight app-main padding and over-expand the page. */
+.app--shell:has(.network-shell),
+.app--shell:has(.audit-shell) {
+ height: 100vh;
+ max-height: 100vh;
+ overflow: hidden;
+}
+
+.app-main:has(> .network-shell),
+.app-main:has(> .audit-shell) {
+ flex: 1 1 auto;
+ min-height: 0;
+ padding: 0;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+}
+
.network-shell {
display: flex;
- height: calc(100vh - 48px);
- min-height: 480px;
- margin: -20px -28px -32px;
+ flex: 1 1 auto;
+ width: 100%;
+ min-height: 0;
+ height: auto;
+ margin: 0;
background: var(--nm-chrome);
overflow: hidden;
}
diff --git a/web/src/pages/CollectPage.tsx b/web/src/pages/CollectPage.tsx
index ebf599b..6be874e 100644
--- a/web/src/pages/CollectPage.tsx
+++ b/web/src/pages/CollectPage.tsx
@@ -1,5 +1,7 @@
-import { useMemo, useState } from "react";
+import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { useSearchParams } from "react-router-dom";
+import { ListPager } from "../components/ListPager";
import {
createNeCollection,
deleteCollectionJob,
@@ -8,31 +10,51 @@ import {
fetchCollectionRuns,
fetchEligibleNe,
fetchNeCollections,
+ formatErr,
pauseCollectionJob,
startCollectionJob,
+ startCollectionFromPolicy,
retryFailedCollectionJob,
+ updateCollectionPolicy,
collectionJobDownloadUrl,
collectionRunDownloadUrl,
} from "../services/api";
import { queryKeys } from "../constants/queryKeys";
+import { useDebouncedValue } from "../hooks/useDebouncedValue";
import { useI18n } from "../i18n";
import { useToast } from "../hooks/useToast";
-import type { CollectionJobItem, EligibleNeItem } from "../types";
+import type { CollectionJobItem, CollectionRunItem, CollectionTargetRef, EligibleNeItem } from "../types";
+import { downloadCsv, fetchAllPages } from "../utils/csvExport";
import { pageCount } from "../utils/display";
import { formatSystemTime } from "../utils/time";
const POLL_MS = 2000;
const ELIGIBLE_PAGE_SIZE = 20;
+const POLICY_TARGET_PAGE_SIZE = 20;
+const JOB_STATUS_OPTIONS = ["draft", "pending", "running", "paused", "done", "failed", "cancelled"] as const;
+const JOB_PAGE_SIZE_OPTIONS = [10, 20, 50, 100];
+const RUN_PAGE_SIZE_OPTIONS = [10, 20, 50, 100];
function eligibleKey(row: Pick): string {
const src = String(row.source || "managed").trim().toLowerCase() || "managed";
return `${src}:${row.id}`;
}
+function targetKey(ref: CollectionTargetRef): string {
+ const src = String(ref.source || "managed").trim().toLowerCase() || "managed";
+ return `${src}:${ref.id}`;
+}
+
function collectionErrorMessage(err: unknown, t: (key: string) => string): string {
const raw = String(err);
if (raw.includes("collection_ne_busy")) return t("collect.neBusy");
if (raw.includes("collection_job_running")) return t("collect.jobRunning");
+ if (raw.includes("commands_empty") || raw.includes("commands_required_for_schedule")) {
+ return t("collect.policyCommandsRequired");
+ }
+ if (raw.includes("no_selected_targets") || raw.includes("no_eligible_ne")) {
+ return t("collect.policyTargetsRequired");
+ }
return raw;
}
@@ -40,6 +62,7 @@ export function CollectPage() {
const { t } = useI18n();
const { showOk, showError } = useToast();
const queryClient = useQueryClient();
+ const [searchParams, setSearchParams] = useSearchParams();
const [commands, setCommands] = useState("");
const [title, setTitle] = useState("");
@@ -48,12 +71,46 @@ export function CollectPage() {
const [neKeyword, setNeKeyword] = useState("");
const [nePage, setNePage] = useState(1);
const [jobPage, setJobPage] = useState(1);
+ const [jobPageSize, setJobPageSize] = useState(20);
+ const [jobStatus, setJobStatus] = useState("");
+ const [jobKeyword, setJobKeyword] = useState("");
+ const [exportingJobs, setExportingJobs] = useState(false);
const [expandedJobId, setExpandedJobId] = useState("");
const [createOpen, setCreateOpen] = useState(false);
+ const [policyEnabled, setPolicyEnabled] = useState(false);
+ const [policyIntervalValue, setPolicyIntervalValue] = useState(1);
+ const [policyIntervalUnit, setPolicyIntervalUnit] = useState<"days" | "hours">("days");
+ const [policyHistoryKeep, setPolicyHistoryKeep] = useState(3);
+ const [policyScopeMode, setPolicyScopeMode] = useState<"all" | "selected">("all");
+ const [policyTitle, setPolicyTitle] = useState("");
+ const [policyCommands, setPolicyCommands] = useState("");
+ const [policySelectedMap, setPolicySelectedMap] = useState>({});
+ const [policyHydrated, setPolicyHydrated] = useState(false);
+ const [policyTargetKeyword, setPolicyTargetKeyword] = useState("");
+ const [policyTargetPage, setPolicyTargetPage] = useState(1);
+
+ const debouncedJobKeyword = useDebouncedValue(jobKeyword, 300);
+ const debouncedPolicyTargetKeyword = useDebouncedValue(policyTargetKeyword, 300);
+
const selectedIds = useMemo(() => Object.keys(selectedMap), [selectedMap]);
const selectedList = useMemo(() => Object.values(selectedMap), [selectedMap]);
+ // Deep link: /collect?job_id=… → open runs modal, then strip param
+ useEffect(() => {
+ const jobId = String(searchParams.get("job_id") || "").trim();
+ if (!jobId) return;
+ setExpandedJobId(jobId);
+ setSearchParams(
+ (prev) => {
+ const next = new URLSearchParams(prev);
+ if (next.get("job_id") === jobId) next.delete("job_id");
+ return next;
+ },
+ { replace: true },
+ );
+ }, [searchParams, setSearchParams]);
+
const eligibleQuery = useQuery({
queryKey: queryKeys.collectionEligibleNe(nePage, neKeyword),
queryFn: () =>
@@ -63,8 +120,14 @@ export function CollectPage() {
});
const jobsQuery = useQuery({
- queryKey: queryKeys.neCollections(jobPage),
- queryFn: () => fetchNeCollections({ page: jobPage, pageSize: 20 }),
+ queryKey: queryKeys.neCollections(jobPage, jobPageSize, jobStatus, debouncedJobKeyword),
+ queryFn: () =>
+ fetchNeCollections({
+ page: jobPage,
+ pageSize: jobPageSize,
+ status: jobStatus,
+ keyword: debouncedJobKeyword,
+ }),
staleTime: 1000,
refetchInterval: (q) => {
const items = q.state.data?.items ?? [];
@@ -85,6 +148,42 @@ export function CollectPage() {
},
});
+ useEffect(() => {
+ const p = dashQuery.data?.policy;
+ if (!p || policyHydrated) return;
+ setPolicyEnabled(Boolean(p.enabled));
+ const hours = Math.max(1, Number(p.interval_hours || Number(p.interval_days || 1) * 24));
+ if (hours % 24 === 0) {
+ setPolicyIntervalUnit("days");
+ setPolicyIntervalValue(Math.max(1, Math.min(365, hours / 24)));
+ } else {
+ setPolicyIntervalUnit("hours");
+ setPolicyIntervalValue(Math.max(1, Math.min(8760, hours)));
+ }
+ setPolicyHistoryKeep(Math.max(0, Math.min(200, Number(p.history_keep ?? 3))));
+ setPolicyScopeMode(p.scope_mode === "selected" ? "selected" : "all");
+ setPolicyTitle(String(p.title || ""));
+ setPolicyCommands(String(p.commands || ""));
+ const map: Record = {};
+ for (const ref of p.selected_targets || []) {
+ map[targetKey(ref)] = { source: ref.source, id: ref.id };
+ }
+ setPolicySelectedMap(map);
+ setPolicyHydrated(true);
+ }, [dashQuery.data?.policy, policyHydrated]);
+
+ const policyTargetsQuery = useQuery({
+ queryKey: queryKeys.collectionEligibleNe(policyTargetPage, debouncedPolicyTargetKeyword),
+ queryFn: () =>
+ fetchEligibleNe({
+ page: policyTargetPage,
+ pageSize: POLICY_TARGET_PAGE_SIZE,
+ keyword: debouncedPolicyTargetKeyword,
+ }),
+ staleTime: 5000,
+ enabled: policyScopeMode === "selected",
+ });
+
const detailQuery = useQuery({
queryKey: queryKeys.neCollectionDetail(expandedJobId),
queryFn: () => fetchCollectionJob(expandedJobId),
@@ -121,6 +220,62 @@ export function CollectPage() {
]);
};
+ const savePolicyMutation = useMutation({
+ mutationFn: () => {
+ const hours =
+ policyIntervalUnit === "days"
+ ? Math.max(1, Math.min(365, policyIntervalValue)) * 24
+ : Math.max(1, Math.min(8760, policyIntervalValue));
+ return updateCollectionPolicy({
+ enabled: policyEnabled,
+ interval_hours: hours,
+ history_keep: policyHistoryKeep,
+ scope_mode: policyScopeMode,
+ title: policyTitle.trim(),
+ commands: policyCommands,
+ selected_targets: Object.values(policySelectedMap),
+ });
+ },
+ onSuccess: async (saved) => {
+ setPolicyEnabled(Boolean(saved.enabled));
+ const hours = Math.max(1, Number(saved.interval_hours || Number(saved.interval_days || 1) * 24));
+ if (hours % 24 === 0) {
+ setPolicyIntervalUnit("days");
+ setPolicyIntervalValue(Math.max(1, Math.min(365, hours / 24)));
+ } else {
+ setPolicyIntervalUnit("hours");
+ setPolicyIntervalValue(Math.max(1, Math.min(8760, hours)));
+ }
+ setPolicyHistoryKeep(Math.max(0, Math.min(200, Number(saved.history_keep ?? 3))));
+ setPolicyScopeMode(saved.scope_mode === "selected" ? "selected" : "all");
+ setPolicyTitle(String(saved.title || ""));
+ setPolicyCommands(String(saved.commands || ""));
+ const map: Record = {};
+ for (const ref of saved.selected_targets || []) {
+ map[targetKey(ref)] = { source: ref.source, id: ref.id };
+ }
+ setPolicySelectedMap(map);
+ setPolicyHydrated(true);
+ queryClient.setQueryData(queryKeys.neCollectionDashboard, (prev: unknown) => {
+ if (!prev || typeof prev !== "object") return prev;
+ return { ...(prev as object), policy: saved };
+ });
+ showOk(t("collect.policySaved"));
+ await refreshAll();
+ },
+ onError: (err) => showError(collectionErrorMessage(err, t)),
+ });
+
+ const startFromPolicyMutation = useMutation({
+ mutationFn: startCollectionFromPolicy,
+ onSuccess: async (job) => {
+ showOk(t("collect.started", { id: job.id }));
+ setExpandedJobId(job.id);
+ await refreshAll();
+ },
+ onError: (err) => showError(collectionErrorMessage(err, t)),
+ });
+
const invalidateJobs = async (jobId?: string) => {
await queryClient.invalidateQueries({ queryKey: queryKeys.neCollectionsAll });
await queryClient.invalidateQueries({ queryKey: queryKeys.neCollectionDashboard });
@@ -231,7 +386,58 @@ export function CollectPage() {
const nePages = pageCount(neTotal, ELIGIBLE_PAGE_SIZE);
const jobTotal = jobsQuery.data?.total ?? 0;
- const jobPages = pageCount(jobTotal, 20);
+ const jobPages = pageCount(jobTotal, jobPageSize);
+ const hasJobFilters = Boolean(jobKeyword.trim() || jobStatus);
+
+ const exportJobsCsv = async () => {
+ setExportingJobs(true);
+ try {
+ const rows = await fetchAllPages({
+ pageSize: 100,
+ maxRows: 2000,
+ fetchPage: (page, pageSize) =>
+ fetchNeCollections({
+ page,
+ pageSize,
+ status: jobStatus,
+ keyword: debouncedJobKeyword,
+ }),
+ });
+ downloadCsv(
+ `${t("collect.exportJobsName")}-${new Date().toISOString().slice(0, 10)}.csv`,
+ rows,
+ [
+ { key: "title", header: t("collect.jobs.col.title") },
+ { key: "status", header: t("collect.jobs.col.status") },
+ {
+ key: "progress",
+ header: t("collect.jobs.col.progress"),
+ value: (r) =>
+ `${r.success_count}/${r.ne_count} ${t("collect.jobs.ok")}, ${r.fail_count} ${t("collect.jobs.fail")}`,
+ },
+ {
+ key: "started_at",
+ header: "started_at",
+ value: (r) => (r.started_at ? formatSystemTime(r.started_at) : ""),
+ },
+ {
+ key: "ended_at",
+ header: "ended_at",
+ value: (r) => (r.ended_at ? formatSystemTime(r.ended_at) : ""),
+ },
+ ],
+ );
+ if (rows.length < jobTotal) {
+ showOk(t("common.exportTruncated", { count: String(rows.length), total: String(jobTotal) }));
+ } else {
+ showOk(t("common.exportOk", { count: String(rows.length) }));
+ }
+ } catch (err) {
+ showError(t("common.exportFailed") + ": " + formatErr(err));
+ } finally {
+ setExportingJobs(false);
+ }
+ };
const commandLines = useMemo(
() =>
@@ -262,10 +468,25 @@ export function CollectPage() {
) : null}
+
-
-
-
{t("collect.kpi.active")}
-
{dash?.active_count ?? "—"}
-
{t("collect.kpi.last")}
@@ -306,10 +523,225 @@ export function CollectPage() {
: t("common.empty")}
+
+
{t("collect.kpi.nextDue")}
+
+ {dash?.next_due_at ? formatSystemTime(dash.next_due_at) : t("common.empty")}
+
+
+
+
+ {t("collect.policyTitle")}
+
+ {t("collect.policyHint")}
+
+
+
+
+
+
+ savePolicyMutation.mutate()}
+ >
+ {t("collect.savePolicy")}
+
+
+
+
+
+
+
+
+ {policyScopeMode === "selected" ? (
+
+
+ {t("collect.selectedCount", { count: String(Object.keys(policySelectedMap).length) })}
+
+
+ {
+ setPolicyTargetKeyword(e.target.value);
+ setPolicyTargetPage(1);
+ }}
+ />
+
+
+
+
+ ) : null}
+
+
+
+
+
+
{t("collect.jobs.listTitle")}
+
+
+
+ {
+ setJobKeyword(e.target.value);
+ setJobPage(1);
+ }}
+ />
+
+ {
+ setJobKeyword("");
+ setJobStatus("");
+ setJobPage(1);
+ }}
+ >
+ {t("common.clearFilters")}
+
+
+
+ {jobsQuery.isLoading ?
{t("common.refreshing")}
: null}
+ {jobsQuery.isError ?
{t("common.opFailed")}
: null}
+
{!(jobsQuery.data?.items ?? []).length && !jobsQuery.isLoading ? (
{t("common.empty")}
@@ -347,23 +779,19 @@ export function CollectPage() {
)}
-
-
- {t("common.pagerMeta", {
- total: String(jobTotal),
- page: String(jobPage),
- pages: String(jobPages),
- })}
-
-
- setJobPage(jobPage - 1)}>
- {t("common.prevPage")}
-
- = jobPages} onClick={() => setJobPage(jobPage + 1)}>
- {t("common.nextPage")}
-
-
-
+
{
+ setJobPageSize(size);
+ setJobPage(1);
+ }}
+ disabled={jobsQuery.isLoading}
+ />
@@ -764,7 +1192,6 @@ function JobRow({
);
}
-const RUN_PAGE_SIZE = 20;
const RUN_STATUS_OPTIONS = ["pending", "running", "success", "fail", "cancelled"] as const;
function JobRunsPanel({
@@ -783,19 +1210,23 @@ function JobRunsPanel({
retryPending: boolean;
}) {
const { t } = useI18n();
+ const { showOk, showError } = useToast();
const [runPage, setRunPage] = useState(1);
+ const [runPageSize, setRunPageSize] = useState(20);
const [runStatus, setRunStatus] = useState("");
const [runKeyword, setRunKeyword] = useState("");
+ const [exportingRuns, setExportingRuns] = useState(false);
+ const debouncedRunKeyword = useDebouncedValue(runKeyword, 300);
const runsQuery = useQuery({
- queryKey: queryKeys.neCollectionRuns(jobId, runPage, runStatus, runKeyword),
+ queryKey: queryKeys.neCollectionRuns(jobId, runPage, runPageSize, runStatus, debouncedRunKeyword),
queryFn: () =>
fetchCollectionRuns({
jobId,
page: runPage,
- pageSize: RUN_PAGE_SIZE,
+ pageSize: runPageSize,
status: runStatus,
- keyword: runKeyword,
+ keyword: debouncedRunKeyword,
}),
staleTime: 500,
refetchInterval: jobStatus === "running" ? 2000 : false,
@@ -808,8 +1239,57 @@ function JobRunsPanel({
};
const runTotal = runsQuery.data?.total ?? 0;
- const runPages = pageCount(runTotal, RUN_PAGE_SIZE);
+ const runPages = pageCount(runTotal, runPageSize);
const runs = runsQuery.data?.items ?? [];
+ const hasRunFilters = Boolean(runStatus || runKeyword.trim());
+
+ const exportRunsCsv = async () => {
+ setExportingRuns(true);
+ try {
+ const rows = await fetchAllPages({
+ pageSize: 100,
+ maxRows: 2000,
+ fetchPage: (page, pageSize) =>
+ fetchCollectionRuns({
+ jobId,
+ page,
+ pageSize,
+ status: runStatus,
+ keyword: debouncedRunKeyword,
+ }),
+ });
+ downloadCsv(
+ `${t("collect.exportRunsName")}-${jobId.slice(0, 8)}-${new Date().toISOString().slice(0, 10)}.csv`,
+ rows,
+ [
+ { key: "ne_source", header: t("managedNe.col.source"), value: (r) => r.ne_source || "managed" },
+ { key: "ne_name", header: t("managedNe.col.name") },
+ { key: "ne_ip", header: t("managedNe.col.ip") },
+ { key: "status", header: t("collect.runs.status") },
+ { key: "message", header: t("collect.runs.message") },
+ {
+ key: "started_at",
+ header: "started_at",
+ value: (r) => (r.started_at ? formatSystemTime(r.started_at) : ""),
+ },
+ {
+ key: "ended_at",
+ header: "ended_at",
+ value: (r) => (r.ended_at ? formatSystemTime(r.ended_at) : ""),
+ },
+ ],
+ );
+ if (rows.length < runTotal) {
+ showOk(t("common.exportTruncated", { count: String(rows.length), total: String(runTotal) }));
+ } else {
+ showOk(t("common.exportOk", { count: String(rows.length) }));
+ }
+ } catch (err) {
+ showError(t("common.exportFailed") + ": " + formatErr(err));
+ } finally {
+ setExportingRuns(false);
+ }
+ };
return (
<>
@@ -841,11 +1321,14 @@ function JobRunsPanel({
))}
- {runStatus || runKeyword ? (
+ {hasRunFilters ? (
{t("common.clearFilters")}
) : null}
+ void exportRunsCsv()}>
+ {exportingRuns ? t("common.exporting") : t("common.exportCsv")}
+
{jobStatus !== "running" && failCount > 0 ? (
{t("collect.jobs.retryFailed")}
@@ -907,21 +1390,19 @@ function JobRunsPanel({
-
- {t("common.pagerMeta", {
- total: String(runTotal),
- page: String(runPage),
- pages: String(runPages),
- })}
-
-
- setRunPage(runPage - 1)}>
- {t("common.prevPage")}
-
- = runPages} onClick={() => setRunPage(runPage + 1)}>
- {t("common.nextPage")}
-
-
+
{
+ setRunPageSize(size);
+ setRunPage(1);
+ }}
+ disabled={runsQuery.isLoading}
+ />
>
);
diff --git a/web/src/pages/ConfigSyncPage.tsx b/web/src/pages/ConfigSyncPage.tsx
index 0a18204..0d9972f 100644
--- a/web/src/pages/ConfigSyncPage.tsx
+++ b/web/src/pages/ConfigSyncPage.tsx
@@ -1,20 +1,25 @@
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { useSearchParams } from "react-router-dom";
+import { ListPager } from "../components/ListPager";
+import { queryKeys } from "../constants/queryKeys";
+import { useDebouncedValue } from "../hooks/useDebouncedValue";
+import { useToast } from "../hooks/useToast";
+import { useI18n } from "../i18n";
import {
createConfigSyncCycle,
fetchCliTargets,
fetchConfigSyncCycleTasks,
fetchConfigSyncCycles,
fetchConfigSyncDashboard,
+ formatErr,
pauseConfigSyncCycle,
resumeConfigSyncCycle,
stopConfigSyncCycle,
updateConfigSyncPolicy,
} from "../services/api";
-import { queryKeys } from "../constants/queryKeys";
-import { useI18n } from "../i18n";
-import { useToast } from "../hooks/useToast";
-import type { CliTargetItem, ConfigSyncTargetRef } from "../types";
+import type { CliTargetItem, ConfigSyncCycle, ConfigSyncTargetRef, ConfigSyncTask } from "../types";
+import { downloadCsv, fetchAllPages } from "../utils/csvExport";
import { pageCount } from "../utils/display";
import { formatSystemTime } from "../utils/time";
@@ -25,12 +30,22 @@ export function ConfigSyncPage() {
const { t } = useI18n();
const { showOk, showError } = useToast();
const queryClient = useQueryClient();
+ const [searchParams, setSearchParams] = useSearchParams();
const [cyclePage, setCyclePage] = useState(1);
+ const [cyclePageSize, setCyclePageSize] = useState(20);
+ const [cycleStatus, setCycleStatus] = useState("");
+ const [cycleKeyword, setCycleKeyword] = useState("");
+ const [exportingCycles, setExportingCycles] = useState(false);
const [expandedCycleId, setExpandedCycleId] = useState("");
const [taskPage, setTaskPage] = useState(1);
+ const [taskPageSize, setTaskPageSize] = useState(20);
const [taskStatus, setTaskStatus] = useState("");
const [taskKeyword, setTaskKeyword] = useState("");
+ const [exportingTasks, setExportingTasks] = useState(false);
+
+ const debouncedCycleKeyword = useDebouncedValue(cycleKeyword, 300);
+ const debouncedTaskKeyword = useDebouncedValue(taskKeyword, 300);
const [enabled, setEnabled] = useState(false);
const [intervalDays, setIntervalDays] = useState(3);
@@ -73,22 +88,52 @@ export function ConfigSyncPage() {
setPolicyHydrated(true);
}, [dashQuery.data, policyHydrated]);
+ useEffect(() => {
+ const id = String(searchParams.get("cycle_id") || "").trim();
+ if (!id) return;
+ setExpandedCycleId(id);
+ setTaskPage(1);
+ setTaskPageSize(20);
+ setTaskKeyword("");
+ setTaskStatus("");
+ setSearchParams(
+ (prev) => {
+ const next = new URLSearchParams(prev);
+ if (next.get("cycle_id") === id) next.delete("cycle_id");
+ return next;
+ },
+ { replace: true },
+ );
+ }, [searchParams, setSearchParams]);
+
const cyclesQuery = useQuery({
- queryKey: queryKeys.configSyncCycles(cyclePage),
- queryFn: () => fetchConfigSyncCycles({ page: cyclePage, pageSize: 10 }),
+ queryKey: queryKeys.configSyncCycles(cyclePage, cyclePageSize, cycleStatus, debouncedCycleKeyword),
+ queryFn: () =>
+ fetchConfigSyncCycles({
+ page: cyclePage,
+ pageSize: cyclePageSize,
+ status: cycleStatus,
+ keyword: debouncedCycleKeyword,
+ }),
staleTime: 1000,
refetchInterval: () => (dashQuery.data?.running_cycle ? POLL_MS : false),
});
const tasksQuery = useQuery({
- queryKey: queryKeys.configSyncCycleTasks(expandedCycleId, taskPage, taskStatus, taskKeyword),
+ queryKey: queryKeys.configSyncCycleTasks(
+ expandedCycleId,
+ taskPage,
+ taskPageSize,
+ taskStatus,
+ debouncedTaskKeyword,
+ ),
queryFn: () =>
fetchConfigSyncCycleTasks({
cycleId: expandedCycleId,
page: taskPage,
- pageSize: 20,
+ pageSize: taskPageSize,
status: taskStatus,
- keyword: taskKeyword,
+ keyword: debouncedTaskKeyword,
}),
enabled: Boolean(expandedCycleId),
staleTime: 800,
@@ -144,7 +189,7 @@ export function ConfigSyncPage() {
showOk(t("configSync.policySaved"));
await refresh();
},
- onError: (err) => showError(String(err)),
+ onError: (err) => showError(formatErr(err)),
});
const startMut = useMutation({
@@ -153,7 +198,7 @@ export function ConfigSyncPage() {
showOk(t("configSync.started"));
await refresh();
},
- onError: (err) => showError(String(err)),
+ onError: (err) => showError(formatErr(err)),
});
const pauseMut = useMutation({
@@ -162,7 +207,7 @@ export function ConfigSyncPage() {
showOk(t("configSync.paused"));
await refresh();
},
- onError: (err) => showError(String(err)),
+ onError: (err) => showError(formatErr(err)),
});
const resumeMut = useMutation({
@@ -171,7 +216,7 @@ export function ConfigSyncPage() {
showOk(t("configSync.resumed"));
await refresh();
},
- onError: (err) => showError(String(err)),
+ onError: (err) => showError(formatErr(err)),
});
const stopMut = useMutation({
@@ -180,7 +225,7 @@ export function ConfigSyncPage() {
showOk(t("configSync.stopped"));
await refresh();
},
- onError: (err) => showError(String(err)),
+ onError: (err) => showError(formatErr(err)),
});
const dash = dashQuery.data;
@@ -188,8 +233,110 @@ export function ConfigSyncPage() {
const last = dash?.last_cycle;
const cycles = cyclesQuery.data?.items ?? [];
const cycleTotal = Number(cyclesQuery.data?.total || 0);
- const cyclePages = pageCount(cycleTotal, 10);
+ const cyclePages = pageCount(cycleTotal, cyclePageSize);
+ const taskTotal = Number(tasksQuery.data?.total || 0);
+ const taskPages = pageCount(taskTotal, taskPageSize);
const selectedCount = useMemo(() => Object.keys(selectedMap).length, [selectedMap]);
+ const hasCycleFilters = Boolean(cycleKeyword.trim() || cycleStatus);
+
+ const openCycleTasks = (cycleId: string) => {
+ setExpandedCycleId(cycleId);
+ setTaskPage(1);
+ setTaskKeyword("");
+ setTaskStatus("");
+ };
+
+ const exportCyclesCsv = async () => {
+ setExportingCycles(true);
+ try {
+ const rows = await fetchAllPages({
+ pageSize: 100,
+ maxRows: 2000,
+ fetchPage: (p, ps) =>
+ fetchConfigSyncCycles({
+ page: p,
+ pageSize: ps,
+ status: cycleStatus,
+ keyword: debouncedCycleKeyword,
+ }),
+ });
+ downloadCsv(
+ `${t("configSync.exportCyclesName")}-${new Date().toISOString().slice(0, 10)}.csv`,
+ rows,
+ [
+ { key: "id", header: "ID" },
+ { key: "trigger_mode", header: t("configSync.col.trigger") },
+ { key: "status", header: t("configSync.col.status") },
+ {
+ key: "progress",
+ header: t("configSync.col.progress"),
+ value: (r) => `${r.success_count}/${r.planned_count} fail ${r.fail_count}`,
+ },
+ {
+ key: "started_at",
+ header: t("configSync.col.started"),
+ value: (r) => (r.started_at ? formatSystemTime(r.started_at) : ""),
+ },
+ {
+ key: "ended_at",
+ header: t("configSync.col.ended"),
+ value: (r) => (r.ended_at ? formatSystemTime(r.ended_at) : ""),
+ },
+ { key: "error_message", header: t("configSync.col.message") },
+ ],
+ );
+ if (rows.length < cycleTotal) {
+ showOk(t("common.exportTruncated", { count: String(rows.length), total: String(cycleTotal) }));
+ } else {
+ showOk(t("common.exportOk", { count: String(rows.length) }));
+ }
+ } catch (err) {
+ showError(t("common.exportFailed") + ": " + formatErr(err));
+ } finally {
+ setExportingCycles(false);
+ }
+ };
+
+ const exportTasksCsv = async () => {
+ if (!expandedCycleId) return;
+ setExportingTasks(true);
+ try {
+ const rows = await fetchAllPages({
+ pageSize: 200,
+ maxRows: 2000,
+ fetchPage: (p, ps) =>
+ fetchConfigSyncCycleTasks({
+ cycleId: expandedCycleId,
+ page: p,
+ pageSize: ps,
+ status: taskStatus,
+ keyword: debouncedTaskKeyword,
+ }),
+ });
+ downloadCsv(
+ `${t("configSync.exportTasksName")}-${expandedCycleId.slice(0, 8)}-${new Date().toISOString().slice(0, 10)}.csv`,
+ rows,
+ [
+ { key: "ne_name", header: t("configSync.col.name"), value: (r) => r.ne_name || r.target_id },
+ { key: "ne_ip", header: "IP" },
+ { key: "vendor", header: t("configSync.col.vendor") },
+ { key: "source", header: t("configSync.col.source") },
+ { key: "status", header: t("configSync.col.status") },
+ { key: "message", header: t("configSync.col.message") },
+ { key: "target_id", header: "ID" },
+ ],
+ );
+ if (rows.length < taskTotal) {
+ showOk(t("common.exportTruncated", { count: String(rows.length), total: String(taskTotal) }));
+ } else {
+ showOk(t("common.exportOk", { count: String(rows.length) }));
+ }
+ } catch (err) {
+ showError(t("common.exportFailed") + ": " + formatErr(err));
+ } finally {
+ setExportingTasks(false);
+ }
+ };
const toggleTarget = (row: CliTargetItem) => {
const source = row.source === "ume" ? "ume" : "managed";
@@ -416,114 +563,155 @@ export function ConfigSyncPage() {
{t("configSync.cyclesTitle")}
-
-
-
-
- |
- ID |
- {t("configSync.col.trigger")} |
- {t("configSync.col.status")} |
- {t("configSync.col.progress")} |
- {t("configSync.col.started")} |
- {t("configSync.col.ended")} |
- {t("configSync.col.actions")} |
-
-
-
- {cycles.map((c) => (
-
- |
- {
- setExpandedCycleId(c.id);
- setTaskPage(1);
- setTaskKeyword("");
- setTaskStatus("");
- }}
- >
- {t("configSync.expand")}
-
- |
- {c.id.slice(0, 8)} |
- {c.trigger_mode} |
-
-
- {c.status}
-
- |
-
- {c.success_count}/{c.planned_count} · fail {c.fail_count}
- |
- {c.started_at ? formatSystemTime(c.started_at) : "-"} |
- {c.ended_at ? formatSystemTime(c.ended_at) : "-"} |
-
-
- {c.status === "running" || c.status === "pending" ? (
- pauseMut.mutate(c.id)} disabled={pauseMut.isPending}>
- {t("configSync.pause")}
-
- ) : null}
- {c.status === "paused" ? (
- resumeMut.mutate(c.id)} disabled={resumeMut.isPending}>
- {t("configSync.resume")}
-
- ) : null}
- {c.status === "running" || c.status === "paused" || c.status === "pending" ? (
+
+
+ {
+ setCycleKeyword(e.target.value);
+ setCyclePage(1);
+ }}
+ />
+
+ {
+ setCycleKeyword("");
+ setCycleStatus("");
+ setCyclePage(1);
+ }}
+ >
+ {t("common.clearFilters")}
+
+ void exportCyclesCsv()}
+ >
+ {exportingCycles ? t("common.exporting") : t("common.exportCsv")}
+
+
+
+ {cyclesQuery.isLoading ? {t("common.refreshing")} : null}
+ {cyclesQuery.isError ? {formatErr(cyclesQuery.error)} : null}
+
+
+
+
+
+ |
+ ID |
+ {t("configSync.col.trigger")} |
+ {t("configSync.col.status")} |
+ {t("configSync.col.progress")} |
+ {t("configSync.col.started")} |
+ {t("configSync.col.ended")} |
+ {t("configSync.col.actions")} |
+
+
+
+ {cycles.map((c) => (
+
+ |
{
- if (window.confirm(t("configSync.confirmStop"))) stopMut.mutate(c.id);
- }}
- disabled={stopMut.isPending}
+ className="btn btn--sm btn--ghost"
+ onClick={() => openCycleTasks(c.id)}
>
- {t("configSync.stop")}
+ {t("configSync.expand")}
- ) : null}
-
- |
-
- ))}
- {!cycles.length ? (
-
- |
- {t("configSync.cyclesEmpty")}
- |
-
- ) : null}
-
-
-
-
-
- {t("common.pagerMeta", {
- total: String(cycleTotal),
- page: String(cyclePage),
- pages: String(cyclePages),
- })}
-
-
- setCyclePage((p) => p - 1)}>
- {t("common.prevPage")}
-
- = cyclePages} onClick={() => setCyclePage((p) => p + 1)}>
- {t("common.nextPage")}
-
+ |
+ {c.id.slice(0, 8)} |
+ {c.trigger_mode} |
+
+
+ {c.status}
+
+ |
+
+ {c.success_count}/{c.planned_count} · fail {c.fail_count}
+ |
+ {c.started_at ? formatSystemTime(c.started_at) : "-"} |
+ {c.ended_at ? formatSystemTime(c.ended_at) : "-"} |
+
+
+ {c.status === "running" || c.status === "pending" ? (
+ pauseMut.mutate(c.id)} disabled={pauseMut.isPending}>
+ {t("configSync.pause")}
+
+ ) : null}
+ {c.status === "paused" ? (
+ resumeMut.mutate(c.id)} disabled={resumeMut.isPending}>
+ {t("configSync.resume")}
+
+ ) : null}
+ {c.status === "running" || c.status === "paused" || c.status === "pending" ? (
+ {
+ if (window.confirm(t("configSync.confirmStop"))) stopMut.mutate(c.id);
+ }}
+ disabled={stopMut.isPending}
+ >
+ {t("configSync.stop")}
+
+ ) : null}
+
+ |
+
+ ))}
+ {!cycles.length && !cyclesQuery.isLoading ? (
+
+ |
+ {t("configSync.cyclesEmpty")}
+ |
+
+ ) : null}
+
+
+ {
+ setCyclePageSize(size);
+ setCyclePage(1);
+ }}
+ disabled={cyclesQuery.isLoading}
+ />
{expandedCycleId ? (
@@ -553,6 +741,13 @@ export function ConfigSyncPage() {
+
void exportTasksCsv()}
+ >
+ {exportingTasks ? t("common.exporting") : t("common.exportCsv")}
+
setExpandedCycleId("")}>
{t("networkConfigs.close")}
@@ -575,7 +770,7 @@ export function ConfigSyncPage() {
setTaskPage(1);
}}
>
-
+
@@ -586,9 +781,7 @@ export function ConfigSyncPage() {
{tasksQuery.isLoading ?
{t("common.refreshing")}
: null}
{tasksQuery.isError ? (
-
- {tasksQuery.error instanceof Error ? tasksQuery.error.message : String(tasksQuery.error)}
-
+
{formatErr(tasksQuery.error)}
) : null}
@@ -628,25 +821,19 @@ export function ConfigSyncPage() {
-
- {t("common.pagerMeta", {
- total: String(tasksQuery.data?.total ?? 0),
- page: String(taskPage),
- pages: String(pageCount(Number(tasksQuery.data?.total || 0), 20)),
- })}
-
-
- setTaskPage((p) => p - 1)}>
- {t("common.prevPage")}
-
- = pageCount(Number(tasksQuery.data?.total || 0), 20)}
- onClick={() => setTaskPage((p) => p + 1)}
- >
- {t("common.nextPage")}
-
-
+
{
+ setTaskPageSize(size);
+ setTaskPage(1);
+ }}
+ disabled={tasksQuery.isLoading}
+ />
diff --git a/web/src/pages/TopologyPage.tsx b/web/src/pages/TopologyPage.tsx
index 5082118..392fd95 100644
--- a/web/src/pages/TopologyPage.tsx
+++ b/web/src/pages/TopologyPage.tsx
@@ -89,6 +89,8 @@ import {
import { queryKeys } from "../constants/queryKeys";
import { useI18n } from "../i18n";
import { useToast } from "../hooks/useToast";
+import { useAuth } from "../auth/AuthContext";
+import { formatErr } from "../services/api";
import { openOrFocusModule } from "../utils/moduleWindows";
import type {
FabricNodeSearchHit,
@@ -114,6 +116,8 @@ const TopologyReactFlowView = lazy(() => import("./topology/TopologyReactFlowVie
export function TopologyPage() {
const { t, locale } = useI18n();
const { showOk, showError } = useToast();
+ const { hasScope, isAdmin } = useAuth();
+ const canWriteTopology = isAdmin || hasScope("ne:write");
const queryClient = useQueryClient();
const [searchParams, setSearchParams] = useSearchParams();
const [keyword, setKeyword] = useState("");
@@ -295,7 +299,13 @@ export function TopologyPage() {
return () => window.removeEventListener("beforeunload", onBeforeUnload);
}, []);
- const toolBehavior = useMemo(() => behaviorForMode(toolMode), [toolMode]);
+ const toolBehavior = useMemo(() => {
+ const base = behaviorForMode(toolMode);
+ if (!canWriteTopology) {
+ return { ...base, nodesDraggable: false, nodesConnectable: false };
+ }
+ return base;
+ }, [toolMode, canWriteTopology]);
const canvas = useTopologyCanvas({
mapId,
@@ -354,6 +364,8 @@ export function TopologyPage() {
worldScatter,
showWorldScatter,
canvasGraphLoading,
+ canvasGraphError,
+ canvasGraphErrorMsg,
canvasGraphEmpty,
worldNeedsApply,
canvasGraphRefreshing,
@@ -537,7 +549,10 @@ export function TopologyPage() {
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyMaps });
showOk(t("topology.saved"));
},
- onError: (err) => showError(String(err)),
+ onError: async (err) => {
+ showError(t("topology.saveFailed").replace("{{detail}}", formatErr(err)));
+ await queryClient.invalidateQueries({ queryKey: queryKeys.topologyGraph(mapId) });
+ },
});
const isValidConnection = useCallback(
@@ -545,14 +560,18 @@ export function TopologyPage() {
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);
+ return !edges.some(
+ (e) =>
+ (e.source === source && e.target === target) ||
+ (e.source === target && e.target === source),
+ );
},
[edges],
);
const onConnect = useCallback(
(connection: Connection) => {
- if (!mapId || !isValidConnection(connection)) return;
+ if (!canWriteTopology || !mapId || !isValidConnection(connection)) return;
const source = String(connection.source || "");
const target = String(connection.target || "");
if (!source || !target) return;
@@ -586,11 +605,12 @@ export function TopologyPage() {
setEdges((es) => [...es, edge]);
markDirty();
},
- [mapId, isValidConnection, pushHistory, edgeDefaults, setEdges, markDirty],
+ [mapId, isValidConnection, pushHistory, edgeDefaults, setEdges, markDirty, canWriteTopology],
);
const addPaletteItems = useCallback(
async (items: PaletteItem[], origin?: { x: number; y: number }): Promise => {
+ if (!canWriteTopology) return false;
if (!mapId) {
showError(t("topology.selectMap"));
return false;
@@ -700,6 +720,7 @@ export function TopologyPage() {
const onCanvasDrop = (e: React.DragEvent) => {
e.preventDefault();
+ if (!canWriteTopology) return;
const raw = e.dataTransfer.getData(PALETTE_DND);
if (!raw || !rfRef.current) return;
try {
@@ -1076,7 +1097,7 @@ export function TopologyPage() {
}
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "s") {
e.preventDefault();
- if (mapId && dirtyRef.current && !saveMut.isPending) saveMut.mutate();
+ if (canWriteTopology && mapId && dirtyRef.current && !saveMut.isPending) saveMut.mutate();
return;
}
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "z" && !e.shiftKey) {
@@ -1095,6 +1116,7 @@ export function TopologyPage() {
return;
}
if (e.key === "Delete" || e.key === "Backspace") {
+ if (!canWriteTopology) return;
if (nodes.some((n) => n.selected) || edges.some((ed) => ed.selected)) {
e.preventDefault();
void removeSelected();
@@ -1103,7 +1125,7 @@ export function TopologyPage() {
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
- }, [closeCtxMenu, clearSelection, undo, redo, selectAllNodes, removeSelected, nodes, edges, mapId, saveMut]);
+ }, [closeCtxMenu, clearSelection, undo, redo, selectAllNodes, removeSelected, nodes, edges, mapId, saveMut, canWriteTopology]);
useEffect(() => {
if (!ctxMenu) return;
@@ -1366,6 +1388,10 @@ export function TopologyPage() {
const discoverOneFor = (node: Node | null) => {
closeCtxMenu();
+ if (!canWriteTopology) {
+ showError(t("topology.readOnlyHint"));
+ return;
+ }
const id = String(node?.data.managed_ne_id || node?.data.ume_ne_id || "").trim();
if (!id) {
showError(t("topology.discoverOneNeedNe"));
@@ -1917,6 +1943,7 @@ export function TopologyPage() {
{canvasMode ? (
) : null}
+ {!canWriteTopology && mapId ? (
+
+ {t("topology.readOnlyBanner")}
+
+ ) : null}
)}
+ {treeRoot && canvasGraphError ? (
+
+
{t("topology.graphLoadFailed")}
+ {canvasGraphErrorMsg ? (
+
{canvasGraphErrorMsg}
+ ) : null}
+
+ void graphQuery.refetch()}
+ >
+ {t("topology.treeRetry")}
+
+
+
+ ) : null}
{treeRoot && canvasGraphLoading ? (
diff --git a/web/src/pages/network/LldpLinksPage.tsx b/web/src/pages/network/LldpLinksPage.tsx
index 5e6dec5..e69d9c2 100644
--- a/web/src/pages/network/LldpLinksPage.tsx
+++ b/web/src/pages/network/LldpLinksPage.tsx
@@ -1,23 +1,33 @@
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useSearchParams } from "react-router-dom";
+import { ListPager } from "../../components/ListPager";
+import { HelpHint } from "../../components/HelpHint";
+import { queryKeys } from "../../constants/queryKeys";
+import { useDebouncedValue } from "../../hooks/useDebouncedValue";
+import { useToast } from "../../hooks/useToast";
+import { useI18n } from "../../i18n";
import {
fetchCliTargets,
fetchFabricEdges,
fetchLldpCollectDashboard,
fetchLldpCollectJob,
fetchLldpCollectJobs,
+ formatErr,
pauseLldpCollectJob,
resumeLldpCollectJob,
startLldpCollect,
stopLldpCollectJob,
updateLldpCollectPolicy,
} from "../../services/api";
-import { queryKeys } from "../../constants/queryKeys";
-import { HelpHint } from "../../components/HelpHint";
-import { useI18n } from "../../i18n";
-import { useToast } from "../../hooks/useToast";
-import type { CliTargetItem, ConfigSyncTargetRef, TopologyDiscoverJobItem } from "../../types";
+import type {
+ CliTargetItem,
+ ConfigSyncTargetRef,
+ FabricEdge,
+ LldpCollectJobSummary,
+ TopologyDiscoverJobItem,
+} from "../../types";
+import { downloadCsv, fetchAllPages } from "../../utils/csvExport";
import { pageCount } from "../../utils/display";
import { formatSystemTime } from "../../utils/time";
@@ -89,15 +99,27 @@ export function LldpLinksPage() {
const [searchParams, setSearchParams] = useSearchParams();
const [jobPage, setJobPage] = useState(1);
+ const [jobPageSize, setJobPageSize] = useState(20);
+ const [jobStatus, setJobStatus] = useState("");
+ const [jobKeyword, setJobKeyword] = useState("");
+ const [exportingJobs, setExportingJobs] = useState(false);
+ const debouncedJobKeyword = useDebouncedValue(jobKeyword, 300);
const [expandedJobId, setExpandedJobId] = useState("");
const [itemPage, setItemPage] = useState(1);
+ const [itemPageSize, setItemPageSize] = useState(20);
+ const [itemStatus, setItemStatus] = useState("");
+ const [itemKeyword, setItemKeyword] = useState("");
+ const [exportingItems, setExportingItems] = useState(false);
+ const debouncedItemKeyword = useDebouncedValue(itemKeyword, 300);
const [itemDetail, setItemDetail] = useState(null);
- const ITEM_PAGE_SIZE = 20;
const [edgeStatus, setEdgeStatus] = useState<"all" | "active" | "missing">("all");
+ const [edgeSource, setEdgeSource] = useState("");
const [edgeKeyword, setEdgeKeyword] = useState("");
const [edgePage, setEdgePage] = useState(1);
- const EDGE_PAGE_SIZE = 20;
+ const [edgePageSize, setEdgePageSize] = useState(50);
+ const [exportingEdges, setExportingEdges] = useState(false);
+ const debouncedEdgeKeyword = useDebouncedValue(edgeKeyword, 300);
const [enabled, setEnabled] = useState(false);
const [intervalValue, setIntervalValue] = useState(1);
@@ -156,6 +178,8 @@ export function LldpLinksPage() {
if (!jobId) return;
setItemDetail(null);
setItemPage(1);
+ setItemStatus("");
+ setItemKeyword("");
setExpandedJobId(jobId);
setSearchParams(
(prev) => {
@@ -168,15 +192,33 @@ export function LldpLinksPage() {
}, [searchParams, setSearchParams]);
const jobsQuery = useQuery({
- queryKey: queryKeys.lldpCollectJobs(jobPage),
- queryFn: () => fetchLldpCollectJobs({ page: jobPage, pageSize: 10 }),
+ queryKey: queryKeys.lldpCollectJobs(jobPage, jobPageSize, jobStatus, debouncedJobKeyword),
+ queryFn: () =>
+ fetchLldpCollectJobs({
+ page: jobPage,
+ pageSize: jobPageSize,
+ status: jobStatus,
+ keyword: debouncedJobKeyword,
+ }),
staleTime: 1000,
refetchInterval: () => (dashQuery.data?.running_job ? POLL_MS : false),
});
const jobDetailQuery = useQuery({
- queryKey: queryKeys.lldpCollectJob(expandedJobId, itemPage),
- queryFn: () => fetchLldpCollectJob(expandedJobId, { page: itemPage, pageSize: ITEM_PAGE_SIZE }),
+ queryKey: queryKeys.lldpCollectJob(
+ expandedJobId,
+ itemPage,
+ itemPageSize,
+ itemStatus,
+ debouncedItemKeyword,
+ ),
+ queryFn: () =>
+ fetchLldpCollectJob(expandedJobId, {
+ page: itemPage,
+ pageSize: itemPageSize,
+ status: itemStatus,
+ keyword: debouncedItemKeyword,
+ }),
enabled: Boolean(expandedJobId),
staleTime: 800,
refetchInterval: () => (dashQuery.data?.running_job?.id === expandedJobId ? POLL_MS : false),
@@ -191,13 +233,20 @@ export function LldpLinksPage() {
});
const edgesQuery = useQuery({
- queryKey: queryKeys.fabricEdges(edgeStatus, edgeKeyword, edgePage),
+ queryKey: queryKeys.fabricEdges(
+ edgeStatus,
+ debouncedEdgeKeyword,
+ edgePage,
+ edgePageSize,
+ edgeSource,
+ ),
queryFn: () =>
fetchFabricEdges({
status: edgeStatus === "all" ? "" : edgeStatus,
- keyword: edgeKeyword,
+ keyword: debouncedEdgeKeyword,
+ source: edgeSource || undefined,
page: edgePage,
- pageSize: EDGE_PAGE_SIZE,
+ pageSize: edgePageSize,
}),
staleTime: 2000,
refetchInterval: () => (dashQuery.data?.running_job ? POLL_MS : false),
@@ -302,14 +351,192 @@ export function LldpLinksPage() {
const last = dash?.last_job;
const jobs = jobsQuery.data?.items ?? [];
const jobTotal = Number(jobsQuery.data?.total || 0);
- const jobPages = pageCount(jobTotal, 10);
+ const jobPages = pageCount(jobTotal, jobPageSize);
const selectedCount = useMemo(() => Object.keys(selectedMap).length, [selectedMap]);
const detailItems = jobDetailQuery.data?.items ?? [];
const itemTotal = Number(jobDetailQuery.data?.items_total ?? detailItems.length);
- const itemPages = pageCount(itemTotal, ITEM_PAGE_SIZE);
+ const itemPages = pageCount(itemTotal, itemPageSize);
const edgeItems = edgesQuery.data?.items ?? [];
const edgeTotal = Number(edgesQuery.data?.total || 0);
- const edgePages = pageCount(edgeTotal, EDGE_PAGE_SIZE);
+ const edgePages = pageCount(edgeTotal, edgePageSize);
+
+ const exportEdgesCsv = async () => {
+ setExportingEdges(true);
+ try {
+ const rows = await fetchAllPages({
+ pageSize: 200,
+ maxRows: 2000,
+ fetchPage: (p, ps) =>
+ fetchFabricEdges({
+ status: edgeStatus === "all" ? "" : edgeStatus,
+ keyword: debouncedEdgeKeyword,
+ source: edgeSource || undefined,
+ page: p,
+ pageSize: ps,
+ }),
+ });
+ downloadCsv(
+ `${t("lldpLinks.exportEdgesName")}-${new Date().toISOString().slice(0, 10)}.csv`,
+ rows,
+ [
+ {
+ key: "status",
+ header: t("lldpLinks.col.status"),
+ value: (e) => lldpEdgeStatusLabel(t, e.status === "stale" ? "missing" : e.status),
+ },
+ {
+ key: "a",
+ header: t("lldpLinks.col.aSide"),
+ value: (e) => e.a_name || e.a_ip || e.a_node_id,
+ },
+ { key: "a_port", header: t("lldpLinks.col.aPort") },
+ {
+ key: "b",
+ header: t("lldpLinks.col.bSide"),
+ value: (e) => e.b_name || e.b_ip || e.b_node_id,
+ },
+ { key: "b_port", header: t("lldpLinks.col.bPort") },
+ {
+ key: "source",
+ header: t("lldpLinks.col.source"),
+ value: (e) => lldpEdgeSourceLabel(t, e.source),
+ },
+ {
+ key: "last_seen_at",
+ header: t("lldpLinks.col.lastSeen"),
+ value: (e) => (e.last_seen_at ? formatSystemTime(e.last_seen_at) : ""),
+ },
+ { key: "id", header: "ID" },
+ ],
+ );
+ if (rows.length < edgeTotal) {
+ showOk(t("common.exportTruncated", { count: String(rows.length), total: String(edgeTotal) }));
+ } else {
+ showOk(t("common.exportOk", { count: String(rows.length) }));
+ }
+ } catch (err) {
+ showError(t("common.exportFailed") + ": " + formatErr(err));
+ } finally {
+ setExportingEdges(false);
+ }
+ };
+
+ const exportJobsCsv = async () => {
+ setExportingJobs(true);
+ try {
+ const rows = await fetchAllPages({
+ pageSize: 100,
+ maxRows: 2000,
+ fetchPage: (p, ps) =>
+ fetchLldpCollectJobs({
+ page: p,
+ pageSize: ps,
+ status: jobStatus,
+ keyword: debouncedJobKeyword,
+ }),
+ });
+ downloadCsv(
+ `${t("lldpLinks.exportJobsName")}-${new Date().toISOString().slice(0, 10)}.csv`,
+ rows,
+ [
+ { key: "id", header: "ID" },
+ {
+ key: "trigger_mode",
+ header: t("lldpLinks.col.trigger"),
+ value: (j) => lldpTriggerLabel(t, j.trigger_mode),
+ },
+ {
+ key: "scope",
+ header: t("lldpLinks.col.scope"),
+ value: (j) => lldpScopeLabel(t, j.scope),
+ },
+ {
+ key: "status",
+ header: t("lldpLinks.col.status"),
+ value: (j) => lldpJobStatusLabel(t, j.status),
+ },
+ {
+ key: "progress",
+ header: t("lldpLinks.col.progress"),
+ value: (j) => `${j.done}/${j.total}`,
+ },
+ {
+ key: "edges",
+ header: t("lldpLinks.col.edges"),
+ value: (j) => `+${j.edges_added}/~${j.edges_updated}`,
+ },
+ {
+ key: "started_at",
+ header: t("lldpLinks.col.started"),
+ value: (j) => (j.started_at ? formatSystemTime(j.started_at) : ""),
+ },
+ {
+ key: "ended_at",
+ header: t("lldpLinks.col.ended"),
+ value: (j) => (j.ended_at ? formatSystemTime(j.ended_at) : ""),
+ },
+ ],
+ );
+ if (rows.length < jobTotal) {
+ showOk(t("common.exportTruncated", { count: String(rows.length), total: String(jobTotal) }));
+ } else {
+ showOk(t("common.exportOk", { count: String(rows.length) }));
+ }
+ } catch (err) {
+ showError(t("common.exportFailed") + ": " + formatErr(err));
+ } finally {
+ setExportingJobs(false);
+ }
+ };
+
+ const exportItemsCsv = async () => {
+ if (!expandedJobId) return;
+ setExportingItems(true);
+ try {
+ const rows = await fetchAllPages({
+ pageSize: 100,
+ maxRows: 2000,
+ fetchPage: async (p, ps) => {
+ const job = await fetchLldpCollectJob(expandedJobId, {
+ page: p,
+ pageSize: ps,
+ status: itemStatus,
+ keyword: debouncedItemKeyword,
+ });
+ return { total: job.items_total, items: (job.items || []) as TopologyDiscoverJobItem[] };
+ },
+ });
+ downloadCsv(
+ `${t("lldpLinks.exportItemsName")}-${expandedJobId.slice(0, 8)}.csv`,
+ rows,
+ [
+ { key: "ne_name", header: t("lldpLinks.col.name") },
+ { key: "ne_ip", header: "IP" },
+ {
+ key: "result",
+ header: t("lldpLinks.col.status"),
+ value: (it) => lldpItemResultLabel(t, it),
+ },
+ { key: "neighbors", header: t("lldpLinks.col.neighbors") },
+ {
+ key: "edges",
+ header: t("lldpLinks.col.edges"),
+ value: (it) => `+${it.edges_added}/~${it.edges_updated}`,
+ },
+ { key: "error", header: t("lldpLinks.col.error") },
+ ],
+ );
+ if (rows.length < itemTotal) {
+ showOk(t("common.exportTruncated", { count: String(rows.length), total: String(itemTotal) }));
+ } else {
+ showOk(t("common.exportOk", { count: String(rows.length) }));
+ }
+ } catch (err) {
+ showError(t("common.exportFailed") + ": " + formatErr(err));
+ } finally {
+ setExportingItems(false);
+ }
+ };
const toggleTarget = (row: CliTargetItem) => {
const source = row.source === "ume" ? "ume" : "managed";
@@ -576,6 +803,17 @@ export function LldpLinksPage() {
+
+ void exportEdgesCsv()}
+ >
+ {exportingEdges ? t("common.exporting") : t("common.exportCsv")}
+
@@ -643,27 +888,54 @@ export function LldpLinksPage() {
-
- setEdgePage((p) => p - 1)}>
- {t("common.prevPage")}
-
-
- {t("common.pagerMeta", {
- total: String(edgeTotal),
- page: String(edgePage),
- pages: String(edgePages),
- })}
-
- = edgePages}
- onClick={() => setEdgePage((p) => p + 1)}
- >
- {t("common.nextPage")}
-
-
+ {
+ setEdgePageSize(size);
+ setEdgePage(1);
+ }}
+ disabled={edgesQuery.isLoading}
+ />
{t("lldpLinks.jobsTitle")}
+
+
+ {
+ setJobKeyword(e.target.value);
+ setJobPage(1);
+ }}
+ />
+ void exportJobsCsv()}
+ >
+ {exportingJobs ? t("common.exporting") : t("common.exportCsv")}
+
+
@@ -691,6 +963,8 @@ export function LldpLinksPage() {
className="btn btn--sm btn--ghost"
onClick={() => {
setItemDetail(null);
+ setItemStatus("");
+ setItemKeyword("");
setExpandedJobId(job.id);
setItemPage(1);
}}
@@ -774,21 +1048,19 @@ export function LldpLinksPage() {
-
- setJobPage((p) => p - 1)}>
- {t("common.prevPage")}
-
-
- {t("common.pagerMeta", {
- total: String(jobTotal),
- page: String(jobPage),
- pages: String(jobPages),
- })}
-
- = jobPages} onClick={() => setJobPage((p) => p + 1)}>
- {t("common.nextPage")}
-
-
+ {
+ setJobPageSize(size);
+ setJobPage(1);
+ }}
+ disabled={jobsQuery.isLoading}
+ />
{expandedJobId ? (
+ void exportItemsCsv()}
+ >
+ {exportingItems ? t("common.exporting") : t("common.exportCsv")}
+
{
setExpandedJobId("");
setItemDetail(null);
+ setItemStatus("");
+ setItemKeyword("");
+ setItemPage(1);
}}
>
{t("networkConfigs.close")}
@@ -835,6 +1117,29 @@ export function LldpLinksPage() {
+
+
+ {
+ setItemKeyword(e.target.value);
+ setItemPage(1);
+ }}
+ />
+
+
{jobDetailQuery.isLoading ? {t("common.refreshing")}
: null}
{jobDetailQuery.data?.error ? (
{jobDetailQuery.data.error}
@@ -897,25 +1202,19 @@ export function LldpLinksPage() {
-
- {t("common.pagerMeta", {
- total: String(itemTotal),
- page: String(itemPage),
- pages: String(itemPages),
- })}
-
-
- setItemPage((p) => p - 1)}>
- {t("common.prevPage")}
-
- = itemPages}
- onClick={() => setItemPage((p) => p + 1)}
- >
- {t("common.nextPage")}
-
-
+
{
+ setItemPageSize(size);
+ setItemPage(1);
+ }}
+ disabled={jobDetailQuery.isLoading}
+ />
diff --git a/web/src/pages/network/NetworkAlarmsPage.tsx b/web/src/pages/network/NetworkAlarmsPage.tsx
index 8a6ac6f..7b085bb 100644
--- a/web/src/pages/network/NetworkAlarmsPage.tsx
+++ b/web/src/pages/network/NetworkAlarmsPage.tsx
@@ -1,8 +1,13 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
-import { fetchUmeCurrentAlarms } from "../../services/api";
+import { ListPager } from "../../components/ListPager";
import { queryKeys } from "../../constants/queryKeys";
+import { useDebouncedValue } from "../../hooks/useDebouncedValue";
+import { useToast } from "../../hooks/useToast";
import { useI18n } from "../../i18n";
+import { fetchUmeCurrentAlarms, formatErr } from "../../services/api";
+import type { UmeAlarmItem } from "../../types";
+import { downloadCsv, fetchAllPages } from "../../utils/csvExport";
import { pageCount } from "../../utils/display";
import { formatSystemTime } from "../../utils/time";
@@ -19,19 +24,24 @@ function severityClass(sev: string | null | undefined): string {
/** Current UME alarms query view for Network Management. */
export function NetworkAlarmsPage() {
const { t } = useI18n();
+ const { showOk, showError } = useToast();
const [curSeverity, setCurSeverity] = useState("");
const [curCleared, setCurCleared] = useState("");
const [curHostName, setCurHostName] = useState("");
const [curKeyword, setCurKeyword] = useState("");
const [curPage, setCurPage] = useState(1);
const [curPageSize, setCurPageSize] = useState(50);
+ const [exporting, setExporting] = useState(false);
+
+ const debouncedKeyword = useDebouncedValue(curKeyword, 300);
+ const debouncedHost = useDebouncedValue(curHostName, 300);
const currentQuery = useQuery({
queryKey: queryKeys.umeCurrentAlarms(
curSeverity,
curCleared,
- curHostName,
- curKeyword,
+ debouncedHost,
+ debouncedKeyword,
curPage,
curPageSize,
),
@@ -39,8 +49,8 @@ export function NetworkAlarmsPage() {
fetchUmeCurrentAlarms({
severity: curSeverity,
isCleared: curCleared,
- hostName: curHostName,
- keyword: curKeyword,
+ hostName: debouncedHost,
+ keyword: debouncedKeyword,
page: curPage,
pageSize: curPageSize,
}),
@@ -50,13 +60,57 @@ export function NetworkAlarmsPage() {
const items = currentQuery.data?.items || [];
const curTotal = Number(currentQuery.data?.total || 0);
const curPages = pageCount(curTotal, curPageSize);
- const perPage = (n: number) => t("common.perPage", { n: String(n) });
const hasFilters = Boolean(curKeyword.trim() || curHostName.trim() || curSeverity || curCleared);
+ const exportCsv = async () => {
+ setExporting(true);
+ try {
+ const rows = await fetchAllPages({
+ pageSize: 200,
+ maxRows: 2000,
+ fetchPage: (page, pageSize) =>
+ fetchUmeCurrentAlarms({
+ severity: curSeverity,
+ isCleared: curCleared,
+ hostName: debouncedHost,
+ keyword: debouncedKeyword,
+ page,
+ pageSize,
+ }),
+ });
+ downloadCsv(`ume-alarms-${new Date().toISOString().slice(0, 10)}.csv`, rows, [
+ { key: "time_created", header: t("ume.alarms.col.time"), value: (r) => formatSystemTime(r.time_created) },
+ { key: "perceived_severity", header: t("ume.alarms.col.severity") },
+ { key: "ne_id", header: t("ume.alarms.col.neId") },
+ { key: "host_name", header: t("ume.alarms.col.hostName") },
+ { key: "ne_type", header: t("ume.alarms.col.neType") },
+ { key: "native_probable_cause", header: t("ume.alarms.col.cause") },
+ { key: "is_cleared", header: "is_cleared" },
+ { key: "alarm_key", header: "alarm_key" },
+ ]);
+ if (rows.length < curTotal) {
+ showOk(
+ t("common.exportTruncated", { count: String(rows.length), total: String(curTotal) }),
+ );
+ } else {
+ showOk(t("common.exportOk", { count: String(rows.length) }));
+ }
+ } catch (err) {
+ showError(t("common.exportFailed") + ": " + formatErr(err));
+ } finally {
+ setExporting(false);
+ }
+ };
+
return (
{t("ume.alarms.title")}
+
+ void exportCsv()}>
+ {exporting ? t("common.exporting") : t("common.exportCsv")}
+
+
@@ -99,8 +153,8 @@ export function NetworkAlarmsPage() {
}}
>
-
-
+
+
- | time_created |
- severity |
- ne_id |
- host_name |
- ne_type |
- cause |
+ {t("ume.alarms.col.time")} |
+ {t("ume.alarms.col.severity")} |
+ {t("ume.alarms.col.neId")} |
+ {t("ume.alarms.col.hostName")} |
+ {t("ume.alarms.col.neType")} |
+ {t("ume.alarms.col.cause")} |
@@ -177,46 +231,19 @@ export function NetworkAlarmsPage() {
)}
-
-
- {t("common.pagerMeta", {
- total: String(curTotal),
- page: String(curPage),
- pages: String(curPages),
- })}
-
-
- setCurPage(Math.max(1, curPage - 1))}
- disabled={curPage <= 1}
- >
- {t("common.prevPage")}
-
- setCurPage(curPage + 1)}
- disabled={curPage >= curPages}
- >
- {t("common.nextPage")}
-
-
-
-
+ {
+ setCurPageSize(size);
+ setCurPage(1);
+ }}
+ disabled={currentQuery.isLoading}
+ />
);
diff --git a/web/src/pages/network/NetworkConfigsPage.tsx b/web/src/pages/network/NetworkConfigsPage.tsx
index d0e3d1a..fd2fb3f 100644
--- a/web/src/pages/network/NetworkConfigsPage.tsx
+++ b/web/src/pages/network/NetworkConfigsPage.tsx
@@ -1,15 +1,21 @@
-import { useState } from "react";
+import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
+import { useSearchParams } from "react-router-dom";
+import { ListPager } from "../../components/ListPager";
+import { queryKeys } from "../../constants/queryKeys";
+import { useDebouncedValue } from "../../hooks/useDebouncedValue";
+import { useToast } from "../../hooks/useToast";
+import { useI18n } from "../../i18n";
import {
downloadNeConfigSnapshot,
fetchNeConfigSnapshotDetail,
fetchNeConfigSnapshots,
+ formatErr,
} from "../../services/api";
-import { queryKeys } from "../../constants/queryKeys";
-import { useI18n } from "../../i18n";
-import { useToast } from "../../hooks/useToast";
-import { openNewModuleWindow } from "../../utils/moduleWindows";
+import type { NeConfigSnapshotMeta } from "../../types";
+import { downloadCsv, fetchAllPages } from "../../utils/csvExport";
import { pageCount } from "../../utils/display";
+import { openNewModuleWindow } from "../../utils/moduleWindows";
import { formatSystemTime } from "../../utils/time";
function fmtBytes(n: number): string {
@@ -22,18 +28,45 @@ function fmtBytes(n: number): string {
export function NetworkConfigsPage() {
const { t } = useI18n();
const { showOk, showError } = useToast();
+ const [searchParams, setSearchParams] = useSearchParams();
const [page, setPage] = useState(1);
- const [keyword, setKeyword] = useState("");
+ const [pageSize, setPageSize] = useState(50);
+ const [keyword, setKeyword] = useState(() => String(searchParams.get("q") || ""));
const [source, setSource] = useState("");
const [vendor, setVendor] = useState("");
const [selected, setSelected] = useState<{ source: string; id: string } | null>(null);
const [tab, setTab] = useState<"primary" | "alt">("primary");
const [exporting, setExporting] = useState("");
+ const [exportingList, setExportingList] = useState(false);
+
+ const debouncedKeyword = useDebouncedValue(keyword, 300);
+ const debouncedVendor = useDebouncedValue(vendor, 300);
+
+ useEffect(() => {
+ const q = String(searchParams.get("q") || "").trim();
+ if (!q) return;
+ setKeyword(q);
+ setPage(1);
+ setSearchParams(
+ (prev) => {
+ const next = new URLSearchParams(prev);
+ if (next.get("q") === q) next.delete("q");
+ return next;
+ },
+ { replace: true },
+ );
+ }, [searchParams, setSearchParams]);
const listQuery = useQuery({
- queryKey: queryKeys.networkConfigs(page, keyword, source, vendor),
+ queryKey: queryKeys.networkConfigs(page, debouncedKeyword, source, debouncedVendor, pageSize),
queryFn: () =>
- fetchNeConfigSnapshots({ page, pageSize: 20, keyword, source, vendor }),
+ fetchNeConfigSnapshots({
+ page,
+ pageSize,
+ keyword: debouncedKeyword,
+ source,
+ vendor: debouncedVendor,
+ }),
staleTime: 5000,
});
@@ -46,7 +79,7 @@ export function NetworkConfigsPage() {
const items = listQuery.data?.items ?? [];
const total = Number(listQuery.data?.total || 0);
- const pages = pageCount(total, 20);
+ const pages = pageCount(total, pageSize);
const detail = detailQuery.data;
const showAlt = Boolean(detail?.has_alt);
const hasFilters = Boolean(keyword || source || vendor);
@@ -62,16 +95,69 @@ export function NetworkConfigsPage() {
await downloadNeConfigSnapshot(src, id, field);
showOk(t("networkConfigs.exportOk"));
} catch (err) {
- showError(String(err));
+ showError(formatErr(err));
} finally {
setExporting("");
}
};
+ const exportListCsv = async () => {
+ setExportingList(true);
+ try {
+ const rows = await fetchAllPages({
+ pageSize: 100,
+ maxRows: 2000,
+ fetchPage: (p, ps) =>
+ fetchNeConfigSnapshots({
+ page: p,
+ pageSize: ps,
+ keyword: debouncedKeyword,
+ source,
+ vendor: debouncedVendor,
+ }),
+ });
+ downloadCsv(
+ `${t("networkConfigs.exportListName")}-${new Date().toISOString().slice(0, 10)}.csv`,
+ rows,
+ [
+ { key: "ne_name", header: t("networkConfigs.col.name"), value: (r) => r.ne_name || r.target_id },
+ { key: "ne_ip", header: "IP" },
+ { key: "vendor", header: t("networkConfigs.col.vendor") },
+ { key: "source", header: t("networkConfigs.col.source") },
+ { key: "plain_size", header: t("networkConfigs.col.size"), value: (r) => fmtBytes(r.plain_size) },
+ {
+ key: "collected_at",
+ header: t("networkConfigs.col.collected"),
+ value: (r) => (r.collected_at ? formatSystemTime(r.collected_at) : ""),
+ },
+ { key: "target_id", header: "ID" },
+ ],
+ );
+ if (rows.length < total) {
+ showOk(t("common.exportTruncated", { count: String(rows.length), total: String(total) }));
+ } else {
+ showOk(t("common.exportOk", { count: String(rows.length) }));
+ }
+ } catch (err) {
+ showError(t("common.exportFailed") + ": " + formatErr(err));
+ } finally {
+ setExportingList(false);
+ }
+ };
+
return (
{t("networkConfigs.title")}
+
+ void exportListCsv()}
+ >
+ {exportingList ? t("common.exporting") : t("common.exportCsv")}
+
+
@@ -118,6 +204,7 @@ export function NetworkConfigsPage() {
{listQuery.isLoading ? {t("common.refreshing")}
: null}
+ {listQuery.isError ? {t("common.opFailed")}
: null}
{!items.length && !listQuery.isLoading ? (
@@ -199,23 +286,19 @@ export function NetworkConfigsPage() {
)}
-
-
- {t("common.pagerMeta", {
- total: String(total),
- page: String(page),
- pages: String(pages),
- })}
-
-
- setPage((p) => p - 1)}>
- {t("common.prevPage")}
-
- = pages} onClick={() => setPage((p) => p + 1)}>
- {t("common.nextPage")}
-
-
-
+ {
+ setPageSize(size);
+ setPage(1);
+ }}
+ disabled={listQuery.isLoading}
+ />
{selected ? (
diff --git a/web/src/pages/network/NetworkDevicesPage.tsx b/web/src/pages/network/NetworkDevicesPage.tsx
index 8d709aa..87d08da 100644
--- a/web/src/pages/network/NetworkDevicesPage.tsx
+++ b/web/src/pages/network/NetworkDevicesPage.tsx
@@ -1,11 +1,15 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
-import { fetchCliTargets } from "../../services/api";
+import { ListPager } from "../../components/ListPager";
import { queryKeys } from "../../constants/queryKeys";
+import { useDebouncedValue } from "../../hooks/useDebouncedValue";
+import { useToast } from "../../hooks/useToast";
import { useI18n } from "../../i18n";
+import { fetchCliTargets, formatErr } from "../../services/api";
+import type { CliTargetItem } from "../../types";
+import { downloadCsv, fetchAllPages } from "../../utils/csvExport";
import { pageCount } from "../../utils/display";
-
-const PAGE_SIZE = 50;
+import { openNewModuleWindow } from "../../utils/moduleWindows";
function connectStatusClass(status: string | null | undefined): string {
const s = String(status || "").trim().toLowerCase();
@@ -17,28 +21,80 @@ function connectStatusClass(status: string | null | undefined): string {
return "pt-list-status--other";
}
-/** Read-only inventory of managed + UME NEs for Network Management. */
+function openWebcrt(row: CliTargetItem) {
+ const path =
+ row.source === "ume"
+ ? `/webcrt?ne_id=${encodeURIComponent(row.id)}&source=ume`
+ : `/webcrt?ne_id=${encodeURIComponent(row.id)}`;
+ openNewModuleWindow({ moduleId: "webcrt", path });
+}
+
+/** Inventory of managed + UME NEs for Network Management. */
export function NetworkDevicesPage() {
const { t } = useI18n();
+ const { showOk, showError } = useToast();
const [keyword, setKeyword] = useState("");
const [source, setSource] = useState<"all" | "managed" | "ume">("all");
const [page, setPage] = useState(1);
+ const [pageSize, setPageSize] = useState(50);
+ const [exporting, setExporting] = useState(false);
+
+ const debouncedKeyword = useDebouncedValue(keyword, 300);
const listQuery = useQuery({
- queryKey: queryKeys.cliTargets(`${source}:${keyword}`, page, PAGE_SIZE),
- queryFn: () => fetchCliTargets({ source, keyword, page, pageSize: PAGE_SIZE }),
+ queryKey: queryKeys.cliTargets(`${source}:${debouncedKeyword}`, page, pageSize),
+ queryFn: () => fetchCliTargets({ source, keyword: debouncedKeyword, page, pageSize }),
staleTime: 5000,
});
const items = listQuery.data?.items ?? [];
const total = Number(listQuery.data?.total || 0);
- const pages = pageCount(total, PAGE_SIZE);
+ const pages = pageCount(total, pageSize);
const hasFilters = Boolean(keyword.trim() || source !== "all");
+ const exportCsv = async () => {
+ setExporting(true);
+ try {
+ const rows = await fetchAllPages({
+ pageSize: 200,
+ maxRows: 2000,
+ fetchPage: (p, ps) =>
+ fetchCliTargets({ source, keyword: debouncedKeyword, page: p, pageSize: ps }),
+ });
+ downloadCsv(`${t("networkDevices.exportName")}-${new Date().toISOString().slice(0, 10)}.csv`, rows, [
+ { key: "source", header: t("networkDevices.col.source") },
+ { key: "name", header: t("networkDevices.col.name"), value: (r) => r.name || r.id },
+ { key: "ip_address", header: "IP" },
+ { key: "vendor", header: t("networkDevices.col.vendor") },
+ {
+ key: "device_type",
+ header: t("networkDevices.col.deviceType"),
+ value: (r) => r.device_type || r.ne_type || "",
+ },
+ { key: "connect_status", header: t("networkDevices.col.connect") },
+ { key: "id", header: "ID" },
+ ]);
+ if (rows.length < total) {
+ showOk(t("common.exportTruncated", { count: String(rows.length), total: String(total) }));
+ } else {
+ showOk(t("common.exportOk", { count: String(rows.length) }));
+ }
+ } catch (err) {
+ showError(t("common.exportFailed") + ": " + formatErr(err));
+ } finally {
+ setExporting(false);
+ }
+ };
+
return (
{t("networkDevices.title")}
+
+ void exportCsv()}>
+ {exporting ? t("common.exporting") : t("common.exportCsv")}
+
+
@@ -76,6 +132,7 @@ export function NetworkDevicesPage() {
{listQuery.isLoading ? {t("common.refreshing")}
: null}
+ {listQuery.isError ? {t("common.opFailed")}
: null}
{!items.length && !listQuery.isLoading ? (
@@ -92,6 +149,7 @@ export function NetworkDevicesPage() {
{t("networkDevices.col.vendor")} |
{t("networkDevices.col.deviceType")} |
{t("networkDevices.col.connect")} |
+
{t("networkDevices.col.actions")} |
@@ -107,6 +165,24 @@ export function NetworkDevicesPage() {
{row.connect_status || "—"}
+
+
+ openWebcrt(row)}>
+ WebCRT
+
+
+ openNewModuleWindow({
+ moduleId: "network",
+ path: `/network/configs?q=${encodeURIComponent(row.name || row.ip_address || row.id)}`,
+ })
+ }
+ >
+ {t("network.nav.configs")}
+
+
+ |
))}
@@ -114,23 +190,19 @@ export function NetworkDevicesPage() {
)}
-
-
- {t("common.pagerMeta", {
- total: String(total),
- page: String(page),
- pages: String(pages),
- })}
-
-
- setPage((p) => p - 1)}>
- {t("common.prevPage")}
-
- = pages} onClick={() => setPage((p) => p + 1)}>
- {t("common.nextPage")}
-
-
-
+ {
+ setPageSize(size);
+ setPage(1);
+ }}
+ disabled={listQuery.isLoading}
+ />
);
diff --git a/web/src/pages/network/PortTrafficPage.tsx b/web/src/pages/network/PortTrafficPage.tsx
index 63588ae..0f19a06 100644
--- a/web/src/pages/network/PortTrafficPage.tsx
+++ b/web/src/pages/network/PortTrafficPage.tsx
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useNavigate, useSearchParams } from "react-router-dom";
+import { ListPager } from "../../components/ListPager";
import {
createPortTrafficDevice,
deletePortTrafficDevice,
@@ -10,6 +11,7 @@ import {
fetchPortTrafficDevices,
fetchPortTrafficEvents,
fetchPortTrafficTargets,
+ formatErr,
pausePortTrafficDevice,
putPortTrafficInterfaces,
rebindPortTrafficDevice,
@@ -19,13 +21,16 @@ import {
} from "../../services/api";
import { queryKeys } from "../../constants/queryKeys";
import { useI18n } from "../../i18n";
+import { useDebouncedValue } from "../../hooks/useDebouncedValue";
import { useToast } from "../../hooks/useToast";
import type {
CliTargetItem,
PortTrafficDevice,
PortTrafficDiscoverPort,
+ PortTrafficEvent,
PortTrafficIfaceIn,
} from "../../types";
+import { downloadCsv, fetchAllPages } from "../../utils/csvExport";
import { pageCount } from "../../utils/display";
import { formatSystemTime } from "../../utils/time";
@@ -34,10 +39,11 @@ const TARGET_PAGE_SIZE = 20;
type ViewMode = "list" | "wizard" | "edit";
-function statusTone(status: string): "running" | "paused" | "stopped" | "other" {
+function statusTone(status: string): "running" | "paused" | "stopped" | "draft" | "other" {
if (status === "running") return "running";
if (status === "paused") return "paused";
if (status === "stopped") return "stopped";
+ if (status === "draft") return "draft";
return "other";
}
@@ -122,6 +128,11 @@ export function PortTrafficPage() {
const [view, setView] = useState("list");
const [listPage, setListPage] = useState(1);
+ const [listPageSize, setListPageSize] = useState(50);
+ const [listKeyword, setListKeyword] = useState("");
+ const [listStatus, setListStatus] = useState("");
+ const [exportingDevices, setExportingDevices] = useState(false);
+ const debouncedListKeyword = useDebouncedValue(listKeyword, 300);
// Wizard / edit
const [editDeviceId, setEditDeviceId] = useState("");
@@ -151,6 +162,7 @@ export function PortTrafficPage() {
const [wizardStep, setWizardStep] = useState<1 | 2 | 3>(1);
const [logDeviceId, setLogDeviceId] = useState("");
const [logDeviceLabel, setLogDeviceLabel] = useState("");
+ const [logKeyword, setLogKeyword] = useState("");
const [rebindDevice, setRebindDevice] = useState(null);
const [rebindKeyword, setRebindKeyword] = useState("");
const [rebindPage, setRebindPage] = useState(1);
@@ -169,8 +181,14 @@ export function PortTrafficPage() {
});
const devicesQuery = useQuery({
- queryKey: queryKeys.portTrafficDevices(listPage),
- queryFn: () => fetchPortTrafficDevices({ page: listPage, pageSize: 10 }),
+ queryKey: queryKeys.portTrafficDevices(listPage, listPageSize, listStatus, debouncedListKeyword),
+ queryFn: () =>
+ fetchPortTrafficDevices({
+ page: listPage,
+ pageSize: listPageSize,
+ status: listStatus || undefined,
+ keyword: debouncedListKeyword || undefined,
+ }),
staleTime: 5000,
refetchInterval: (q) => {
const items = q.state.data?.items || [];
@@ -570,17 +588,109 @@ export function PortTrafficPage() {
const dash = dashQuery.data;
const devices = devicesQuery.data?.items || [];
- const pages = pageCount(devicesQuery.data?.total || 0, 10);
+ const listTotal = Number(devicesQuery.data?.total || 0);
+ const pages = pageCount(listTotal, listPageSize);
const editDevice = editDeviceSnap;
+ const hasListFilters = Boolean(listKeyword.trim() || listStatus);
+ const logEvents = logQuery.data?.items || [];
+ const logKw = logKeyword.trim().toLowerCase();
+ const filteredLogEvents = !logKw
+ ? logEvents
+ : logEvents.filter((ev) => {
+ const msg = formatPortTrafficLogMessage(ev.message, t).toLowerCase();
+ const hay = `${ev.level || ""} ${ev.ifname || ""} ${ev.message || ""} ${msg}`.toLowerCase();
+ return hay.includes(logKw);
+ });
const statusText = (status: string) => {
const tone = statusTone(status);
if (tone === "running") return t("portTraffic.statusRunning");
if (tone === "paused") return t("portTraffic.statusPaused");
if (tone === "stopped") return t("portTraffic.statusStopped");
+ if (tone === "draft") return t("portTraffic.statusDraft");
return status;
};
+ const closeLog = () => {
+ setLogDeviceId("");
+ setLogDeviceLabel("");
+ setLogKeyword("");
+ };
+
+ const exportDevicesCsv = async () => {
+ setExportingDevices(true);
+ try {
+ const rows = await fetchAllPages({
+ pageSize: 100,
+ maxRows: 2000,
+ fetchPage: (p, ps) =>
+ fetchPortTrafficDevices({
+ page: p,
+ pageSize: ps,
+ status: listStatus || undefined,
+ keyword: debouncedListKeyword || undefined,
+ }),
+ });
+ downloadCsv(
+ `${t("portTraffic.exportDevicesName")}-${new Date().toISOString().slice(0, 10)}.csv`,
+ rows,
+ [
+ { key: "ne_name", header: t("portTraffic.col.device"), value: (r) => deviceLabel(r) },
+ { key: "ne_ip", header: "IP" },
+ { key: "ne_id", header: "NE ID" },
+ { key: "vendor", header: t("portTraffic.col.vendor") },
+ { key: "status", header: t("portTraffic.col.status"), value: (r) => statusText(r.status) },
+ { key: "target_count", header: t("portTraffic.col.ports") },
+ { key: "active_target_count", header: "Active ports" },
+ { key: "interval_sec", header: t("portTraffic.col.interval") },
+ {
+ key: "last_collect_ended_at",
+ header: t("portTraffic.col.last"),
+ value: (r) => formatSystemTime(r.last_collect_ended_at) || "",
+ },
+ { key: "note", header: t("portTraffic.note") },
+ { key: "last_error", header: t("portTraffic.logMessage") },
+ { key: "id", header: "ID" },
+ ],
+ );
+ if (rows.length < listTotal) {
+ showOk(t("common.exportTruncated", { count: String(rows.length), total: String(listTotal) }));
+ } else {
+ showOk(t("common.exportOk", { count: String(rows.length) }));
+ }
+ } catch (err) {
+ showError(t("common.exportFailed") + ": " + formatErr(err));
+ } finally {
+ setExportingDevices(false);
+ }
+ };
+
+ const exportEventsCsv = () => {
+ try {
+ downloadCsv(
+ `${t("portTraffic.exportEventsName")}-${new Date().toISOString().slice(0, 10)}.csv`,
+ filteredLogEvents,
+ [
+ {
+ key: "created_at",
+ header: t("portTraffic.logTime"),
+ value: (r: PortTrafficEvent) => formatSystemTime(r.created_at) || "",
+ },
+ { key: "level", header: t("portTraffic.logLevel") },
+ { key: "ifname", header: t("portTraffic.col.ifname") },
+ {
+ key: "message",
+ header: t("portTraffic.logMessage"),
+ value: (r: PortTrafficEvent) => formatPortTrafficLogMessage(r.message, t),
+ },
+ ],
+ );
+ showOk(t("common.exportOk", { count: String(filteredLogEvents.length) }));
+ } catch (err) {
+ showError(t("common.exportFailed") + ": " + formatErr(err));
+ }
+ };
+
return (
@@ -601,6 +711,13 @@ export function PortTrafficPage() {
navigate("/network/tasks/port-traffic/wall")}>
{t("portTraffic.wall")}
+ void exportDevicesCsv()}
+ >
+ {exportingDevices ? t("common.exporting") : t("common.exportCsv")}
+
{t("portTraffic.create")}
@@ -634,157 +751,189 @@ export function PortTrafficPage() {
- {!devices.length ? (
+
+ {
+ setListKeyword(e.target.value);
+ setListPage(1);
+ }}
+ />
+
+ {
+ setListKeyword("");
+ setListStatus("");
+ setListPage(1);
+ }}
+ >
+ {t("common.clearFilters")}
+
+
+
+ {devicesQuery.isLoading ? {t("common.refreshing")}
: null}
+ {devicesQuery.isError ? {t("common.opFailed")}
: null}
+
+ {!devices.length && !devicesQuery.isLoading ? (
{t("portTraffic.empty")}
-
- {t("portTraffic.create")}
-
+ {!hasListFilters ? (
+
+ {t("portTraffic.create")}
+
+ ) : null}
- ) : (
- <>
-
-
-
-
- | {t("portTraffic.col.device")} |
- {t("portTraffic.col.status")} |
- {t("portTraffic.col.ports")} |
- {t("portTraffic.col.interval")} |
- {t("portTraffic.col.last")} |
- {t("portTraffic.col.actions")} |
-
-
-
- {devices.map((row) => {
- const tone = statusTone(row.status);
- return (
-
- |
- {deviceLabel(row)}
-
- {row.ne_ip || row.ne_id}
- {row.vendor ? ` · ${row.vendor}` : ""}
- {row.note ? ` · ${row.note}` : ""}
-
- |
-
-
- {statusText(row.status)}
+ ) : devices.length ? (
+
+
+
+
+ | {t("portTraffic.col.device")} |
+ {t("portTraffic.col.status")} |
+ {t("portTraffic.col.ports")} |
+ {t("portTraffic.col.interval")} |
+ {t("portTraffic.col.last")} |
+ {t("portTraffic.col.actions")} |
+
+
+
+ {devices.map((row) => {
+ const tone = statusTone(row.status);
+ return (
+
+ |
+ {deviceLabel(row)}
+
+ {row.ne_ip || row.ne_id}
+ {row.vendor ? ` · ${row.vendor}` : ""}
+ {row.note ? ` · ${row.note}` : ""}
+
+ |
+
+
+ {statusText(row.status)}
+
+ {row.collect_running ? (
+
+ {t("portTraffic.collecting")}
- {row.collect_running ? (
-
- {t("portTraffic.collecting")}
-
+ ) : null}
+ |
+
+ {row.active_target_count}/{row.target_count}
+ |
+ {row.interval_sec}s |
+
+ {formatSystemTime(row.last_collect_ended_at) || "—"}
+ |
+
+
+ openWallList()}
+ >
+ {t("portTraffic.wall")}
+
+ openEdit(row)}>
+ {t("portTraffic.edit")}
+
+ {needsNeRebind(row) ? (
+ openRebind(row)}
+ >
+ {t("portTraffic.rebind")}
+
) : null}
- |
-
- {row.active_target_count}/{row.target_count}
- |
- {row.interval_sec}s |
-
- {formatSystemTime(row.last_collect_ended_at) || "—"}
- |
-
-
+ {row.status !== "running" ? (
openWallList()}
+ disabled={startMut.isPending}
+ onClick={() => startMut.mutate(row.id)}
>
- {t("portTraffic.wall")}
+ {t("portTraffic.start")}
- openEdit(row)}>
- {t("portTraffic.edit")}
-
- {needsNeRebind(row) ? (
- openRebind(row)}
- >
- {t("portTraffic.rebind")}
-
- ) : null}
- {row.status !== "running" ? (
- startMut.mutate(row.id)}
- >
- {t("portTraffic.start")}
-
- ) : (
- pauseMut.mutate(row.id)}
- >
- {t("portTraffic.pause")}
-
- )}
- {row.status !== "stopped" ? (
- stopMut.mutate(row.id)}
- >
- {t("portTraffic.stop")}
-
- ) : null}
+ ) : (
{
- setLogDeviceId(row.id);
- setLogDeviceLabel(deviceLabel(row));
- }}
+ disabled={pauseMut.isPending}
+ onClick={() => pauseMut.mutate(row.id)}
>
- {t("portTraffic.log")}
+ {t("portTraffic.pause")}
+ )}
+ {row.status !== "stopped" ? (
{
- if (window.confirm(t("portTraffic.confirmDelete"))) {
- deleteMut.mutate(row.id);
- }
- }}
+ disabled={stopMut.isPending}
+ onClick={() => stopMut.mutate(row.id)}
>
- {t("portTraffic.delete")}
+ {t("portTraffic.stop")}
-
- |
-
- );
- })}
-
-
-
-
- setListPage((p) => p - 1)}
- >
- ‹
-
-
- {listPage}/{Math.max(1, pages)}
-
- = pages}
- onClick={() => setListPage((p) => p + 1)}
- >
- ›
-
-
- >
- )}
+ ) : null}
+ {
+ setLogDeviceId(row.id);
+ setLogDeviceLabel(deviceLabel(row));
+ setLogKeyword("");
+ }}
+ >
+ {t("portTraffic.log")}
+
+ {
+ if (window.confirm(t("portTraffic.confirmDelete"))) {
+ deleteMut.mutate(row.id);
+ }
+ }}
+ >
+ {t("portTraffic.delete")}
+
+
+ |
+
+ );
+ })}
+
+
+
+ ) : null}
+
+ {
+ setListPageSize(size);
+ setListPage(1);
+ }}
+ disabled={devicesQuery.isLoading}
+ />
) : null}
@@ -1179,14 +1328,7 @@ export function PortTrafficPage() {
) : null}
{logDeviceId ? (
- {
- setLogDeviceId("");
- setLogDeviceLabel("");
- }}
- >
+
-
{
- setLogDeviceId("");
- setLogDeviceLabel("");
- }}
- >
- {t("portTraffic.logClose")}
-
+
+
+ {t("common.exportCsv")}
+
+
+ {t("portTraffic.logClose")}
+
+
+
+
+ setLogKeyword(e.target.value)}
+ />
{logQuery.isLoading ? (
…
- ) : !(logQuery.data?.items || []).length ? (
+ ) : !logEvents.length ? (
+
{t("portTraffic.logEmpty")}
+ ) : !filteredLogEvents.length ? (
{t("portTraffic.logEmpty")}
) : (
- {(logQuery.data?.items || []).map((ev) => {
+ {filteredLogEvents.map((ev) => {
const level = String(ev.level || "error").toLowerCase();
const tone =
level === "warn" || level === "warning"
diff --git a/web/src/pages/network/TopologyClassifyPage.tsx b/web/src/pages/network/TopologyClassifyPage.tsx
index d8ea9a9..e2346bf 100644
--- a/web/src/pages/network/TopologyClassifyPage.tsx
+++ b/web/src/pages/network/TopologyClassifyPage.tsx
@@ -6,7 +6,6 @@ import {
deleteFabricNodes,
fetchFabricNodes,
fetchTopologyTree,
- generateTopologySlices,
matchFabricNodes,
patchFabricNodeTags,
} from "../../services/api";
@@ -14,7 +13,7 @@ import { queryKeys } from "../../constants/queryKeys";
import { useI18n } from "../../i18n";
import { useToast } from "../../hooks/useToast";
import { openOrFocusModule } from "../../utils/moduleWindows";
-import type { FabricNodeSearchHit, SliceGenerateResult, TopologyTreeFolderItem } from "../../types";
+import type { FabricNodeSearchHit, TopologyTreeFolderItem } from "../../types";
const PAGE_SIZE = 50;
@@ -37,15 +36,42 @@ function isFabricNodeDeletable(n: FabricNodeSearchHit): boolean {
return src === "lldp" || src === "topology" || src === "webcrt";
}
-const ROLE_OPTIONS = ["core", "aggregation", "access", "unknown"] as const;
+const LEVEL_PRESETS = [
+ { value: "0", labelKey: "topoClassify.levelExternal" },
+ { value: "1", labelKey: "topoClassify.levelCore" },
+ { value: "1.1", labelKey: "topoClassify.levelCoreSub" },
+ { value: "2", labelKey: "topoClassify.levelAgg" },
+ { value: "2.1", labelKey: "topoClassify.levelAggSub" },
+ { value: "3", labelKey: "topoClassify.levelAccess" },
+ { value: "3.1", labelKey: "topoClassify.levelAccessSub" },
+] as const;
-function roleLabel(t: (k: string) => string, role: string): string {
- const s = String(role || "").trim().toLowerCase();
- if (s === "core") return t("topoClassify.roleCore");
- if (s === "aggregation") return t("topoClassify.roleAggregation");
- if (s === "access") return t("topoClassify.roleAccess");
- if (s === "unknown") return t("topoClassify.roleUnknown");
- return role || "-";
+const FILTER_MAJOR = [
+ { value: "", labelKey: "topoClassify.filterLevelAll" },
+ { value: "0", labelKey: "topoClassify.levelExternal" },
+ { value: "1", labelKey: "topoClassify.levelCore" },
+ { value: "2", labelKey: "topoClassify.levelAgg" },
+ { value: "3", labelKey: "topoClassify.levelAccess" },
+] as const;
+
+function formatLevel(level: number | null | undefined): string {
+ if (level === null || level === undefined || Number.isNaN(Number(level))) return "—";
+ const n = Number(level);
+ if (Math.abs(n - Math.round(n)) < 1e-9) return String(Math.round(n));
+ return String(Math.round(n * 10) / 10);
+}
+
+function parseLevelInput(raw: string): number | null {
+ const s = String(raw || "").trim();
+ if (!s) return null;
+ const n = Number(s);
+ if (!Number.isFinite(n) || n < 0 || n > 99.9) throw new Error("level_invalid");
+ return Math.round(n * 10) / 10;
+}
+
+function levelPresetLabel(t: (k: string) => string, value: string): string {
+ const hit = LEVEL_PRESETS.find((p) => p.value === value);
+ return hit ? t(hit.labelKey) : value;
}
function managedSourceLabel(t: (k: string) => string, source: string): string {
@@ -64,7 +90,7 @@ export function TopologyClassifyPage() {
const queryClient = useQueryClient();
const [keyword, setKeyword] = useState("");
- const [filterRole, setFilterRole] = useState("");
+ const [filterMajor, setFilterMajor] = useState("");
const [filterRegion, setFilterRegion] = useState("");
const [unmatched, setUnmatched] = useState("");
const [linkStatus, setLinkStatus] = useState("");
@@ -89,23 +115,15 @@ export function TopologyClassifyPage() {
const [matchField, setMatchField] = useState<"name" | "ip" | "name_ip">("name");
const [matchedIds, setMatchedIds] = useState
([]);
const [matchTotal, setMatchTotal] = useState(0);
- const [assignRole, setAssignRole] = useState("core");
+ const [assignLevel, setAssignLevel] = useState("1");
const [assignRegion, setAssignRegion] = useState("");
- const [assignWhat, setAssignWhat] = useState<"role" | "region" | "both">("role");
+ const [assignWhat, setAssignWhat] = useState<"level" | "region" | "both">("level");
const [selected, setSelected] = useState>({});
const [editingId, setEditingId] = useState("");
- const [editRole, setEditRole] = useState("");
+ const [editLevel, setEditLevel] = useState("");
const [editRegion, setEditRegion] = useState("");
- const [sliceFolderId, setSliceFolderId] = useState("");
- const [sliceTemplate, setSliceTemplate] = useState<"core_only" | "core_agg" | "agg_access">(
- "core_agg",
- );
- const [slicePreview, setSlicePreview] = useState(null);
- const [seedPhysical, setSeedPhysical] = useState(true);
- const [showSlices, setShowSlices] = useState(false);
-
const treeQuery = useQuery({
queryKey: queryKeys.topologyTree,
queryFn: fetchTopologyTree,
@@ -120,16 +138,17 @@ export function TopologyClassifyPage() {
const listQuery = useQuery({
queryKey: queryKeys.fabricNodeInventory(
keyword,
- filterRole,
+ "",
filterRegion,
unmatched,
linkStatus,
page,
+ filterMajor,
),
queryFn: () =>
fetchFabricNodes({
keyword,
- role: filterRole,
+ levelMajor: filterMajor || undefined,
regionFolderId: filterRegion,
unmatched,
linkStatus,
@@ -163,7 +182,9 @@ export function TopologyClassifyPage() {
fabric_node_ids: ids.length ? ids : matchedIds,
dry_run: false,
};
- if (assignWhat === "role" || assignWhat === "both") body.role = assignRole;
+ if (assignWhat === "level" || assignWhat === "both") {
+ body.level = parseLevelInput(assignLevel);
+ }
if (assignWhat === "region" || assignWhat === "both") {
body.region_folder_id = assignRegion || "";
}
@@ -180,11 +201,18 @@ export function TopologyClassifyPage() {
});
const patchMut = useMutation({
- mutationFn: () =>
- patchFabricNodeTags(editingId, {
- role: editRole,
+ mutationFn: () => {
+ let level: number | null;
+ try {
+ level = parseLevelInput(editLevel);
+ } catch {
+ throw new Error(t("topoClassify.levelInvalid"));
+ }
+ return patchFabricNodeTags(editingId, {
+ level,
region_folder_id: editRegion || "",
- }),
+ });
+ },
onSuccess: async () => {
showOk(t("topoClassify.rowSaved"));
setEditingId("");
@@ -213,36 +241,6 @@ export function TopologyClassifyPage() {
onError: (err) => showError(String(err)),
});
- const slicePreviewMut = useMutation({
- mutationFn: () =>
- generateTopologySlices({
- folder_id: sliceFolderId,
- template: sliceTemplate,
- dry_run: true,
- max_nodes: 300,
- seed_physical_cores: seedPhysical,
- }),
- onSuccess: (out) => setSlicePreview(out),
- onError: (err) => showError(String(err)),
- });
-
- const sliceApplyMut = useMutation({
- mutationFn: () =>
- generateTopologySlices({
- folder_id: sliceFolderId,
- template: sliceTemplate,
- dry_run: false,
- max_nodes: 300,
- seed_physical_cores: seedPhysical,
- }),
- onSuccess: async (out) => {
- showOk(t("topoClassify.sliceOk").replace("{{count}}", String(out.created_view_ids.length)));
- setSlicePreview(out);
- await queryClient.invalidateQueries({ queryKey: queryKeys.topologyTree });
- },
- onError: (err) => showError(String(err)),
- });
-
const items = listQuery.data?.items || [];
const deletableSelected = items
.filter((n) => selected[n.id] && isFabricNodeDeletable(n))
@@ -254,7 +252,7 @@ export function TopologyClassifyPage() {
const startEdit = (n: FabricNodeSearchHit) => {
setEditingId(n.id);
- setEditRole(n.role || "");
+ setEditLevel(n.level == null ? "" : formatLevel(n.level));
setEditRegion(n.region_folder_id || "");
};
@@ -278,7 +276,17 @@ export function TopologyClassifyPage() {
openOrFocusModule({ moduleId: "topology", path: "/topology" })}
+ onClick={() => {
+ const regionId = filterRegion || assignRegion || "";
+ const folder = regions.find((r) => r.id === regionId);
+ const views = folder?.views || [];
+ const primary =
+ views.find((v) => String(v.kind || "") === "physical") || views[0];
+ const path = primary?.id
+ ? `/topology?view=${encodeURIComponent(primary.id)}`
+ : "/topology";
+ openOrFocusModule({ moduleId: "topology", path });
+ }}
>
{t("topoClassify.openTopo")}
@@ -299,16 +307,15 @@ export function TopologyClassifyPage() {
/>
@@ -337,7 +344,7 @@ export function TopologyClassifyPage() {
>
-
+
{assignWhat !== "region" ? (
) : null}
- {assignWhat !== "role" ? (
+ {assignWhat !== "level" ? (