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 <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-08-12 16:13:05 +08:00
parent b1aee86701
commit b81e5869a6
91 changed files with 10395 additions and 1513 deletions

View file

@ -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;勿收到 &lt;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: <FROM>, view_id: <TO>,
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: <FROM>, view_id: <TO>,
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 写拓扑。

View file

@ -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

View file

@ -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` **迁出**到本包,避免重复。

View file

@ -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

View file

@ -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()

View file

@ -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:

View file

@ -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

View file

@ -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")

View file

@ -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

View file

@ -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:

View file

@ -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

View file

@ -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]}

View file

@ -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")

View file

@ -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,
)

View file

@ -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()

View file

@ -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"),
):

View file

@ -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",

View file

@ -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)

View file

@ -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)

View file

@ -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),
}

View file

@ -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 {

View file

@ -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")

View file

@ -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

View file

@ -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 ''",

View file

@ -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)

View file

@ -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

View file

@ -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")

View file

@ -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:

View file

@ -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 == "",
)

138
netx_api/topology_level.py Normal file
View file

@ -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()]

View file

@ -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 ''",

View file

@ -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()

View file

@ -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)

View file

@ -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}%"))

View file

@ -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.

View file

@ -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"

File diff suppressed because it is too large Load diff

View file

@ -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

View file

@ -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(),
}

View file

@ -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",
)

View file

@ -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 = [

View file

@ -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,
}

View file

@ -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<j on hubs_a."""
adj, names = state.adj, state.names
out: list[DualUnit] = []
if allow_same:
for i, a in enumerate(hubs_a):
for b in hubs_a[i + 1 :]:
forbid = core_set - {a, b}
paths = cover_hub_paths(a, b, adj, names, forbid=forbid)
if len(paths) < 2:
paths = cover_hub_paths(a, b, adj, names, forbid=set())
if len(paths) < 2:
continue
out.append(_unit_from_hub_paths(state, a, b, paths))
return out
seen: set[frozenset[str]] = set()
for a in hubs_a:
for b in hubs_b:
if a == b:
continue
key = frozenset((a, b))
if key in seen:
continue
seen.add(key)
forbid = core_set - {a, b}
paths = cover_hub_paths(a, b, adj, names, forbid=forbid)
if len(paths) < 2:
paths = cover_hub_paths(a, b, adj, names, forbid=set())
if len(paths) < 2:
continue
out.append(_unit_from_hub_paths(state, a, b, paths))
return out
def find_dual_portal_units(
state: LayoutState,
*,
max_units: int = 120,
) -> 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

View file

@ -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},

View file

@ -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

View file

@ -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):

View file

@ -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)),
}

View file

@ -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):

View file

@ -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),

View file

@ -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])

View file

@ -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"),

View file

@ -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": {

View file

@ -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 → 手拖或改初布;勿指望金标对齐。"
),
}

View file

@ -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

View file

@ -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

View file

@ -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(

View file

@ -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)

View file

@ -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

View file

@ -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:

View file

@ -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,

View file

@ -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

View file

@ -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

View file

@ -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"

View file

@ -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,

View file

@ -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()

View file

@ -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]

View file

@ -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

View file

@ -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 (
<div className={`pager pt-list-pager ${className}`.trim()}>
<div className="pager__meta muted">
{t("common.pagerMeta", {
total: String(total),
page: String(page),
pages: String(safePages),
})}
</div>
<div className="pager__controls btn-row">
<button
className="pager__btn"
type="button"
disabled={disabled || page <= 1}
onClick={() => onPageChange(Math.max(1, page - 1))}
>
{t("common.prevPage")}
</button>
<label className="pager__jump">
<span className="visually-hidden">{t("common.jumpPage")}</span>
<input
className="pager__jump-input"
type="number"
min={1}
max={safePages}
value={jumpDraft}
disabled={disabled}
aria-label={t("common.jumpPage")}
title={t("common.jumpPage")}
onChange={(e) => setJumpDraft(e.target.value)}
onBlur={commitJump}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
commitJump();
}
}}
/>
<span className="muted">/ {safePages}</span>
</label>
<button
className="pager__btn"
type="button"
disabled={disabled || page >= safePages}
onClick={() => onPageChange(Math.min(safePages, page + 1))}
>
{t("common.nextPage")}
</button>
{onPageSizeChange ? (
<select
className="pager__size"
value={String(pageSize)}
disabled={disabled}
aria-label={t("common.pageSize")}
onChange={(e) => {
const next = Number(e.target.value) || pageSizeOptions[0] || 50;
onPageSizeChange(next);
}}
>
{pageSizeOptions.map((n) => (
<option key={n} value={String(n)}>
{t("common.perPage", { n: String(n) })}
</option>
))}
</select>
) : null}
</div>
</div>
);
}

View file

@ -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,

View file

@ -0,0 +1,11 @@
import { useEffect, useState } from "react";
/** Debounce a rapidly changing value (e.g. keyword input) before querying. */
export function useDebouncedValue<T>(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;
}

View file

@ -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",

View file

@ -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: "操作审计",

View file

@ -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;
}

View file

@ -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<EligibleNeItem, "id" | "source">): 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<Record<string, CollectionTargetRef>>({});
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<string, CollectionTargetRef> = {};
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<string, CollectionTargetRef> = {};
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<CollectionJobItem>({
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}
</div>
<div className="btn-row">
<button
type="button"
disabled={exportingJobs || jobTotal === 0}
onClick={() => void exportJobsCsv()}
>
{exportingJobs ? t("common.exporting") : t("common.exportCsv")}
</button>
<button type="button" onClick={refreshAll} disabled={jobsQuery.isFetching || dashQuery.isFetching}>
{jobsQuery.isFetching || dashQuery.isFetching ? t("common.refreshing") : t("common.refresh")}
</button>
<button type="button" className="btn-primary" onClick={() => setCreateOpen(true)}>
<button
type="button"
className="btn-primary"
disabled={startFromPolicyMutation.isPending || jobActive}
onClick={() => startFromPolicyMutation.mutate()}
>
{startFromPolicyMutation.isPending ? t("collect.form.starting") : t("collect.collectNow")}
</button>
<button type="button" onClick={() => setCreateOpen(true)}>
{t("collect.create.expand")}
</button>
{running?.status === "running" || running?.status === "pending" ? (
@ -294,10 +515,6 @@ export function CollectPage() {
: t("collect.kpi.idle")}
</div>
</div>
<div className="pt-list-kpi">
<div className="pt-list-kpi__label">{t("collect.kpi.active")}</div>
<div className="pt-list-kpi__value">{dash?.active_count ?? "—"}</div>
</div>
<div className="pt-list-kpi">
<div className="pt-list-kpi__label">{t("collect.kpi.last")}</div>
<div className="pt-list-kpi__value" style={{ fontSize: 15 }}>
@ -306,10 +523,225 @@ export function CollectPage() {
: t("common.empty")}
</div>
</div>
<div className="pt-list-kpi">
<div className="pt-list-kpi__label">{t("collect.kpi.nextDue")}</div>
<div className="pt-list-kpi__value" style={{ fontSize: 15 }}>
{dash?.next_due_at ? formatSystemTime(dash.next_due_at) : t("common.empty")}
</div>
</div>
</div>
</div>
</section>
<section className="panel" style={{ marginBottom: 16 }}>
<h3>{t("collect.policyTitle")}</h3>
<p className="muted" style={{ marginTop: 0 }}>
{t("collect.policyHint")}
</p>
<div className="config-sync-policy-row">
<label className="config-sync-policy-check">
<input
type="checkbox"
checked={policyEnabled}
onChange={(e) => setPolicyEnabled(e.target.checked)}
/>
<span>{t("collect.enabled")}</span>
</label>
<label className="config-sync-policy-field">
<span>{t("collect.interval")}</span>
<input
type="number"
min={1}
max={policyIntervalUnit === "days" ? 365 : 8760}
value={policyIntervalValue}
onChange={(e) => {
const max = policyIntervalUnit === "days" ? 365 : 8760;
setPolicyIntervalValue(Math.max(1, Math.min(max, Number(e.target.value) || 1)));
}}
/>
<select
value={policyIntervalUnit}
onChange={(e) => {
const next = e.target.value === "hours" ? "hours" : "days";
if (next === policyIntervalUnit) return;
if (next === "hours") {
setPolicyIntervalValue(Math.max(1, Math.min(8760, policyIntervalValue * 24)));
} else {
setPolicyIntervalValue(
Math.max(1, Math.min(365, Math.round(policyIntervalValue / 24) || 1)),
);
}
setPolicyIntervalUnit(next);
}}
>
<option value="days">{t("collect.intervalUnitDays")}</option>
<option value="hours">{t("collect.intervalUnitHours")}</option>
</select>
</label>
<label className="config-sync-policy-field">
<span>{t("collect.historyKeep")}</span>
<input
type="number"
min={0}
max={200}
value={policyHistoryKeep}
onChange={(e) =>
setPolicyHistoryKeep(Math.max(0, Math.min(200, Number(e.target.value) || 0)))
}
/>
</label>
<label className="config-sync-policy-field">
<span>{t("collect.scope")}</span>
<select
value={policyScopeMode}
onChange={(e) => setPolicyScopeMode(e.target.value === "selected" ? "selected" : "all")}
>
<option value="all">{t("collect.scopeAll")}</option>
<option value="selected">{t("collect.scopeSelected")}</option>
</select>
</label>
<button
type="button"
className="btn-primary"
disabled={savePolicyMutation.isPending}
onClick={() => savePolicyMutation.mutate()}
>
{t("collect.savePolicy")}
</button>
</div>
<div className="filter-inline" style={{ marginTop: 12 }}>
<label className="config-sync-policy-field" style={{ flex: 1 }}>
<span>{t("collect.form.jobTitle")}</span>
<input
value={policyTitle}
placeholder={t("collect.form.jobTitlePh")}
onChange={(e) => setPolicyTitle(e.target.value)}
/>
</label>
</div>
<label className="config-sync-policy-field" style={{ display: "block", marginTop: 12 }}>
<span>{t("collect.form.commands")}</span>
<textarea
rows={5}
value={policyCommands}
placeholder={t("collect.form.commandsPh")}
onChange={(e) => setPolicyCommands(e.target.value)}
style={{ width: "100%", fontFamily: "var(--font-mono, monospace)" }}
/>
<span className="muted">{t("collect.form.commandsHint")}</span>
</label>
{policyScopeMode === "selected" ? (
<div style={{ marginTop: 12 }}>
<p className="muted">
{t("collect.selectedCount", { count: String(Object.keys(policySelectedMap).length) })}
</p>
<div className="filter-inline">
<input
value={policyTargetKeyword}
placeholder={t("collect.create.filterKeywordPh")}
onChange={(e) => {
setPolicyTargetKeyword(e.target.value);
setPolicyTargetPage(1);
}}
/>
</div>
<table className="data-table">
<thead>
<tr>
<th />
<th>{t("collect.create.pickTitle")}</th>
<th>IP</th>
<th>{t("collect.colSource")}</th>
</tr>
</thead>
<tbody>
{(policyTargetsQuery.data?.items ?? []).map((row) => {
const key = eligibleKey(row);
const checked = Boolean(policySelectedMap[key]);
return (
<tr key={key}>
<td>
<input
type="checkbox"
checked={checked}
onChange={() => {
setPolicySelectedMap((prev) => {
const next = { ...prev };
if (next[key]) delete next[key];
else next[key] = { source: row.source || "managed", id: row.id };
return next;
});
}}
/>
</td>
<td>{row.name || row.id}</td>
<td>{row.ip_address}</td>
<td>{row.source}</td>
</tr>
);
})}
</tbody>
</table>
<ListPager
page={policyTargetPage}
pages={pageCount(policyTargetsQuery.data?.total ?? 0, POLICY_TARGET_PAGE_SIZE)}
total={policyTargetsQuery.data?.total ?? 0}
pageSize={POLICY_TARGET_PAGE_SIZE}
onPageChange={setPolicyTargetPage}
/>
</div>
) : null}
</section>
<section className="panel">
<div className="panel__toolbar">
<div>
<h2>{t("collect.jobs.listTitle")}</h2>
</div>
</div>
<div className="pt-list">
<div className="filter-inline">
<input
type="search"
value={jobKeyword}
placeholder={t("collect.keywordPh")}
onChange={(e) => {
setJobKeyword(e.target.value);
setJobPage(1);
}}
/>
<select
value={jobStatus}
onChange={(e) => {
setJobStatus(e.target.value);
setJobPage(1);
}}
>
<option value="">{t("collect.statusAll")}</option>
{JOB_STATUS_OPTIONS.map((st) => (
<option key={st} value={st}>
{st}
</option>
))}
</select>
<button
type="button"
disabled={!hasJobFilters}
onClick={() => {
setJobKeyword("");
setJobStatus("");
setJobPage(1);
}}
>
{t("common.clearFilters")}
</button>
</div>
{jobsQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
{jobsQuery.isError ? <p className="error-text">{t("common.opFailed")}</p> : null}
{!(jobsQuery.data?.items ?? []).length && !jobsQuery.isLoading ? (
<div className="pt-list-empty">
<p>{t("common.empty")}</p>
@ -347,23 +779,19 @@ export function CollectPage() {
</table>
</div>
)}
<div className="pager pt-list-pager">
<div className="pager__meta muted">
{t("common.pagerMeta", {
total: String(jobTotal),
page: String(jobPage),
pages: String(jobPages),
})}
</div>
<div className="pager__controls btn-row">
<button className="pager__btn" disabled={jobPage <= 1} onClick={() => setJobPage(jobPage - 1)}>
{t("common.prevPage")}
</button>
<button className="pager__btn" disabled={jobPage >= jobPages} onClick={() => setJobPage(jobPage + 1)}>
{t("common.nextPage")}
</button>
</div>
</div>
<ListPager
page={jobPage}
pages={jobPages}
total={jobTotal}
pageSize={jobPageSize}
pageSizeOptions={JOB_PAGE_SIZE_OPTIONS}
onPageChange={setJobPage}
onPageSizeChange={(size) => {
setJobPageSize(size);
setJobPage(1);
}}
disabled={jobsQuery.isLoading}
/>
</div>
</section>
@ -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<CollectionRunItem>({
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({
</option>
))}
</select>
{runStatus || runKeyword ? (
{hasRunFilters ? (
<button type="button" onClick={clearRunFilters}>
{t("common.clearFilters")}
</button>
) : null}
<button type="button" disabled={exportingRuns || runTotal === 0} onClick={() => void exportRunsCsv()}>
{exportingRuns ? t("common.exporting") : t("common.exportCsv")}
</button>
{jobStatus !== "running" && failCount > 0 ? (
<button type="button" disabled={retryPending} onClick={onRetryFailed}>
{t("collect.jobs.retryFailed")}
@ -907,21 +1390,19 @@ function JobRunsPanel({
</div>
</div>
<div className="ops-detail-modal__foot">
<span className="muted">
{t("common.pagerMeta", {
total: String(runTotal),
page: String(runPage),
pages: String(runPages),
})}
</span>
<div className="btn-row">
<button type="button" disabled={runPage <= 1} onClick={() => setRunPage(runPage - 1)}>
{t("common.prevPage")}
</button>
<button type="button" disabled={runPage >= runPages} onClick={() => setRunPage(runPage + 1)}>
{t("common.nextPage")}
</button>
</div>
<ListPager
page={runPage}
pages={runPages}
total={runTotal}
pageSize={runPageSize}
pageSizeOptions={RUN_PAGE_SIZE_OPTIONS}
onPageChange={setRunPage}
onPageSizeChange={(size) => {
setRunPageSize(size);
setRunPage(1);
}}
disabled={runsQuery.isLoading}
/>
</div>
</>
);

View file

@ -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<ConfigSyncCycle>({
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<ConfigSyncTask>({
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() {
</div>
<h3>{t("configSync.cyclesTitle")}</h3>
<div className="pt-list-table-wrap">
<table className="data-table pt-list-table">
<thead>
<tr>
<th />
<th>ID</th>
<th>{t("configSync.col.trigger")}</th>
<th>{t("configSync.col.status")}</th>
<th>{t("configSync.col.progress")}</th>
<th>{t("configSync.col.started")}</th>
<th>{t("configSync.col.ended")}</th>
<th>{t("configSync.col.actions")}</th>
</tr>
</thead>
<tbody>
{cycles.map((c) => (
<tr key={c.id}>
<td>
<button
type="button"
className="btn btn--sm btn--ghost"
onClick={() => {
setExpandedCycleId(c.id);
setTaskPage(1);
setTaskKeyword("");
setTaskStatus("");
}}
>
{t("configSync.expand")}
</button>
</td>
<td title={c.id} className="pt-list-num">{c.id.slice(0, 8)}</td>
<td>{c.trigger_mode}</td>
<td>
<span
className={`pt-list-status ${
c.status === "running" || c.status === "pending"
? "pt-list-status--running"
: c.status === "paused"
? "pt-list-status--paused"
: c.status === "failed"
? "pt-list-status--failed"
: c.status === "success" || c.status === "completed"
? "pt-list-status--ok"
: "pt-list-status--other"
}`}
>
{c.status}
</span>
</td>
<td>
{c.success_count}/{c.planned_count} · fail {c.fail_count}
</td>
<td>{c.started_at ? formatSystemTime(c.started_at) : "-"}</td>
<td>{c.ended_at ? formatSystemTime(c.ended_at) : "-"}</td>
<td>
<div className="btn-row">
{c.status === "running" || c.status === "pending" ? (
<button type="button" onClick={() => pauseMut.mutate(c.id)} disabled={pauseMut.isPending}>
{t("configSync.pause")}
</button>
) : null}
{c.status === "paused" ? (
<button type="button" onClick={() => resumeMut.mutate(c.id)} disabled={resumeMut.isPending}>
{t("configSync.resume")}
</button>
) : null}
{c.status === "running" || c.status === "paused" || c.status === "pending" ? (
<div className="pt-list">
<div className="filter-inline">
<input
value={cycleKeyword}
placeholder={t("configSync.keywordPh")}
onChange={(e) => {
setCycleKeyword(e.target.value);
setCyclePage(1);
}}
/>
<select
value={cycleStatus}
onChange={(e) => {
setCycleStatus(e.target.value);
setCyclePage(1);
}}
>
<option value="">{t("configSync.statusAll")}</option>
<option value="pending">pending</option>
<option value="running">running</option>
<option value="paused">paused</option>
<option value="success">success</option>
<option value="completed">completed</option>
<option value="failed">failed</option>
<option value="stopped">stopped</option>
</select>
<button
type="button"
disabled={!hasCycleFilters}
onClick={() => {
setCycleKeyword("");
setCycleStatus("");
setCyclePage(1);
}}
>
{t("common.clearFilters")}
</button>
<button
type="button"
disabled={exportingCycles || cycleTotal === 0}
onClick={() => void exportCyclesCsv()}
>
{exportingCycles ? t("common.exporting") : t("common.exportCsv")}
</button>
</div>
{cyclesQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
{cyclesQuery.isError ? <p className="error-text">{formatErr(cyclesQuery.error)}</p> : null}
<div className="pt-list-table-wrap">
<table className="data-table pt-list-table">
<thead>
<tr>
<th />
<th>ID</th>
<th>{t("configSync.col.trigger")}</th>
<th>{t("configSync.col.status")}</th>
<th>{t("configSync.col.progress")}</th>
<th>{t("configSync.col.started")}</th>
<th>{t("configSync.col.ended")}</th>
<th>{t("configSync.col.actions")}</th>
</tr>
</thead>
<tbody>
{cycles.map((c) => (
<tr key={c.id}>
<td>
<button
type="button"
onClick={() => {
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")}
</button>
) : null}
</div>
</td>
</tr>
))}
{!cycles.length ? (
<tr>
<td colSpan={8} className="muted">
{t("configSync.cyclesEmpty")}
</td>
</tr>
) : null}
</tbody>
</table>
</div>
<div className="pager pt-list-pager">
<span className="muted">
{t("common.pagerMeta", {
total: String(cycleTotal),
page: String(cyclePage),
pages: String(cyclePages),
})}
</span>
<div className="btn-row">
<button type="button" disabled={cyclePage <= 1} onClick={() => setCyclePage((p) => p - 1)}>
{t("common.prevPage")}
</button>
<button type="button" disabled={cyclePage >= cyclePages} onClick={() => setCyclePage((p) => p + 1)}>
{t("common.nextPage")}
</button>
</td>
<td title={c.id} className="pt-list-num">{c.id.slice(0, 8)}</td>
<td>{c.trigger_mode}</td>
<td>
<span
className={`pt-list-status ${
c.status === "running" || c.status === "pending"
? "pt-list-status--running"
: c.status === "paused"
? "pt-list-status--paused"
: c.status === "failed"
? "pt-list-status--failed"
: c.status === "success" || c.status === "completed"
? "pt-list-status--ok"
: "pt-list-status--other"
}`}
>
{c.status}
</span>
</td>
<td>
{c.success_count}/{c.planned_count} · fail {c.fail_count}
</td>
<td>{c.started_at ? formatSystemTime(c.started_at) : "-"}</td>
<td>{c.ended_at ? formatSystemTime(c.ended_at) : "-"}</td>
<td>
<div className="btn-row">
{c.status === "running" || c.status === "pending" ? (
<button type="button" onClick={() => pauseMut.mutate(c.id)} disabled={pauseMut.isPending}>
{t("configSync.pause")}
</button>
) : null}
{c.status === "paused" ? (
<button type="button" onClick={() => resumeMut.mutate(c.id)} disabled={resumeMut.isPending}>
{t("configSync.resume")}
</button>
) : null}
{c.status === "running" || c.status === "paused" || c.status === "pending" ? (
<button
type="button"
onClick={() => {
if (window.confirm(t("configSync.confirmStop"))) stopMut.mutate(c.id);
}}
disabled={stopMut.isPending}
>
{t("configSync.stop")}
</button>
) : null}
</div>
</td>
</tr>
))}
{!cycles.length && !cyclesQuery.isLoading ? (
<tr>
<td colSpan={8} className="muted">
{t("configSync.cyclesEmpty")}
</td>
</tr>
) : null}
</tbody>
</table>
</div>
<ListPager
page={cyclePage}
pages={cyclePages}
total={cycleTotal}
pageSize={cyclePageSize}
pageSizeOptions={[20, 50, 100]}
onPageChange={setCyclePage}
onPageSizeChange={(size) => {
setCyclePageSize(size);
setCyclePage(1);
}}
disabled={cyclesQuery.isLoading}
/>
</div>
{expandedCycleId ? (
@ -553,6 +741,13 @@ export function ConfigSyncPage() {
</p>
</div>
<div className="btn-row ops-detail-modal__actions">
<button
type="button"
disabled={exportingTasks || taskTotal === 0}
onClick={() => void exportTasksCsv()}
>
{exportingTasks ? t("common.exporting") : t("common.exportCsv")}
</button>
<button type="button" onClick={() => setExpandedCycleId("")}>
{t("networkConfigs.close")}
</button>
@ -575,7 +770,7 @@ export function ConfigSyncPage() {
setTaskPage(1);
}}
>
<option value="">{t("configSync.allStatus")}</option>
<option value="">{t("configSync.statusAll")}</option>
<option value="pending">pending</option>
<option value="running">running</option>
<option value="success">success</option>
@ -586,9 +781,7 @@ export function ConfigSyncPage() {
{tasksQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
{tasksQuery.isError ? (
<p className="ops-detail-modal__error">
{tasksQuery.error instanceof Error ? tasksQuery.error.message : String(tasksQuery.error)}
</p>
<p className="ops-detail-modal__error">{formatErr(tasksQuery.error)}</p>
) : null}
<div className="ops-detail-modal__scroll">
@ -628,25 +821,19 @@ export function ConfigSyncPage() {
</div>
<div className="ops-detail-modal__foot">
<span className="muted">
{t("common.pagerMeta", {
total: String(tasksQuery.data?.total ?? 0),
page: String(taskPage),
pages: String(pageCount(Number(tasksQuery.data?.total || 0), 20)),
})}
</span>
<div className="btn-row">
<button type="button" disabled={taskPage <= 1} onClick={() => setTaskPage((p) => p - 1)}>
{t("common.prevPage")}
</button>
<button
type="button"
disabled={taskPage >= pageCount(Number(tasksQuery.data?.total || 0), 20)}
onClick={() => setTaskPage((p) => p + 1)}
>
{t("common.nextPage")}
</button>
</div>
<ListPager
page={taskPage}
pages={taskPages}
total={taskTotal}
pageSize={taskPageSize}
pageSizeOptions={[20, 50, 100, 200]}
onPageChange={setTaskPage}
onPageSizeChange={(size) => {
setTaskPageSize(size);
setTaskPage(1);
}}
disabled={tasksQuery.isLoading}
/>
</div>
</div>
</div>

View file

@ -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<boolean> => {
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<NeNodeData> | 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() {
<main className="topo-main">
{canvasMode ? (
<TopologyToolbar
readOnly={!canWriteTopology}
breadcrumbFolders={breadcrumbFolders}
activeView={activeView}
activeRegion={activeRegion}
@ -2051,6 +2078,11 @@ export function TopologyPage() {
{truncateBannerText}
</div>
) : null}
{!canWriteTopology && mapId ? (
<div className="topo-readonly-banner" role="status">
{t("topology.readOnlyBanner")}
</div>
) : null}
<TopologyViewTools
fullscreen={fullscreen}
toolbarSlot={viewToolsToolbarSlot}
@ -2248,6 +2280,23 @@ export function TopologyPage() {
</button>
</div>
)}
{treeRoot && canvasGraphError ? (
<div className="topo-canvas__overlay topo-canvas__overlay--error" role="alert">
<p className="topo-canvas__overlay-title">{t("topology.graphLoadFailed")}</p>
{canvasGraphErrorMsg ? (
<p className="topo-canvas__overlay-hint muted">{canvasGraphErrorMsg}</p>
) : null}
<div className="topo-browser__empty-actions">
<button
type="button"
className="btn btn--sm btn--ghost"
onClick={() => void graphQuery.refetch()}
>
{t("topology.treeRetry")}
</button>
</div>
</div>
) : null}
{treeRoot && canvasGraphLoading ? (
<div className="topo-canvas__overlay topo-canvas__overlay--loading" role="status">
<span className="topo-loading-spinner topo-loading-spinner--lg" aria-hidden="true" />

View file

@ -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<TopologyDiscoverJobItem | null>(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<FabricEdge>({
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<LldpCollectJobSummary>({
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<TopologyDiscoverJobItem>({
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() {
<option value="active">{t("lldpLinks.edgeStatusActive")}</option>
<option value="missing">{t("lldpLinks.edgeStatusMissing")}</option>
</select>
<select
value={edgeSource}
onChange={(e) => {
setEdgeSource(e.target.value);
setEdgePage(1);
}}
>
<option value="">{t("lldpLinks.edgeSourceAll")}</option>
<option value="lldp">{t("lldpLinks.edgeSource.lldp")}</option>
<option value="manual">{t("lldpLinks.edgeSource.manual")}</option>
</select>
<input
value={edgeKeyword}
placeholder={t("lldpLinks.edgeKeywordPh")}
@ -584,6 +822,13 @@ export function LldpLinksPage() {
setEdgePage(1);
}}
/>
<button
type="button"
disabled={exportingEdges || edgeTotal === 0}
onClick={() => void exportEdgesCsv()}
>
{exportingEdges ? t("common.exporting") : t("common.exportCsv")}
</button>
</div>
<div className="pt-list-table-wrap">
<table className="data-table pt-list-table">
@ -643,27 +888,54 @@ export function LldpLinksPage() {
</tbody>
</table>
</div>
<div className="pager">
<button type="button" disabled={edgePage <= 1} onClick={() => setEdgePage((p) => p - 1)}>
{t("common.prevPage")}
</button>
<span className="muted">
{t("common.pagerMeta", {
total: String(edgeTotal),
page: String(edgePage),
pages: String(edgePages),
})}
</span>
<button
type="button"
disabled={edgePage >= edgePages}
onClick={() => setEdgePage((p) => p + 1)}
>
{t("common.nextPage")}
</button>
</div>
<ListPager
page={edgePage}
pages={edgePages}
total={edgeTotal}
pageSize={edgePageSize}
pageSizeOptions={[20, 50, 100, 200]}
onPageChange={setEdgePage}
onPageSizeChange={(size) => {
setEdgePageSize(size);
setEdgePage(1);
}}
disabled={edgesQuery.isLoading}
/>
<h3 style={{ marginTop: 24 }}>{t("lldpLinks.jobsTitle")}</h3>
<div className="filter-inline" style={{ marginBottom: 8 }}>
<select
value={jobStatus}
onChange={(e) => {
setJobStatus(e.target.value);
setJobPage(1);
}}
>
<option value="">{t("lldpLinks.statusAll")}</option>
<option value="running">{t("lldpLinks.jobStatus.running")}</option>
<option value="pending">{t("lldpLinks.jobStatus.pending")}</option>
<option value="paused">{t("lldpLinks.jobStatus.paused")}</option>
<option value="done">{t("lldpLinks.jobStatus.done")}</option>
<option value="failed">{t("lldpLinks.jobStatus.failed")}</option>
<option value="cancelled">{t("lldpLinks.jobStatus.cancelled")}</option>
<option value="stopped">{t("lldpLinks.jobStatus.stopped")}</option>
</select>
<input
value={jobKeyword}
placeholder={t("lldpLinks.jobKeywordPh")}
onChange={(e) => {
setJobKeyword(e.target.value);
setJobPage(1);
}}
/>
<button
type="button"
disabled={exportingJobs || jobTotal === 0}
onClick={() => void exportJobsCsv()}
>
{exportingJobs ? t("common.exporting") : t("common.exportCsv")}
</button>
</div>
<div className="pt-list-table-wrap">
<table className="data-table pt-list-table">
<thead>
@ -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() {
</tbody>
</table>
</div>
<div className="pager">
<button type="button" disabled={jobPage <= 1} onClick={() => setJobPage((p) => p - 1)}>
{t("common.prevPage")}
</button>
<span className="muted">
{t("common.pagerMeta", {
total: String(jobTotal),
page: String(jobPage),
pages: String(jobPages),
})}
</span>
<button type="button" disabled={jobPage >= jobPages} onClick={() => setJobPage((p) => p + 1)}>
{t("common.nextPage")}
</button>
</div>
<ListPager
page={jobPage}
pages={jobPages}
total={jobTotal}
pageSize={jobPageSize}
pageSizeOptions={[10, 20, 50, 100]}
onPageChange={setJobPage}
onPageSizeChange={(size) => {
setJobPageSize(size);
setJobPage(1);
}}
disabled={jobsQuery.isLoading}
/>
{expandedJobId ? (
<div
@ -823,11 +1095,21 @@ export function LldpLinksPage() {
</p>
</div>
<div className="btn-row ops-detail-modal__actions">
<button
type="button"
disabled={exportingItems || itemTotal === 0}
onClick={() => void exportItemsCsv()}
>
{exportingItems ? t("common.exporting") : t("common.exportCsv")}
</button>
<button
type="button"
onClick={() => {
setExpandedJobId("");
setItemDetail(null);
setItemStatus("");
setItemKeyword("");
setItemPage(1);
}}
>
{t("networkConfigs.close")}
@ -835,6 +1117,29 @@ export function LldpLinksPage() {
</div>
</div>
<div className="filter-inline" style={{ marginBottom: 8 }}>
<select
value={itemStatus}
onChange={(e) => {
setItemStatus(e.target.value);
setItemPage(1);
}}
>
<option value="">{t("lldpLinks.itemResultAll")}</option>
<option value="ok">{t("lldpLinks.itemResult.ok")}</option>
<option value="warn">{t("lldpLinks.itemResult.warn")}</option>
<option value="fail">{t("lldpLinks.itemResult.fail")}</option>
</select>
<input
value={itemKeyword}
placeholder={t("lldpLinks.itemKeywordPh")}
onChange={(e) => {
setItemKeyword(e.target.value);
setItemPage(1);
}}
/>
</div>
{jobDetailQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
{jobDetailQuery.data?.error ? (
<p className="ops-detail-modal__error">{jobDetailQuery.data.error}</p>
@ -897,25 +1202,19 @@ export function LldpLinksPage() {
</div>
<div className="ops-detail-modal__foot">
<span className="muted">
{t("common.pagerMeta", {
total: String(itemTotal),
page: String(itemPage),
pages: String(itemPages),
})}
</span>
<div className="btn-row">
<button type="button" disabled={itemPage <= 1} onClick={() => setItemPage((p) => p - 1)}>
{t("common.prevPage")}
</button>
<button
type="button"
disabled={itemPage >= itemPages}
onClick={() => setItemPage((p) => p + 1)}
>
{t("common.nextPage")}
</button>
</div>
<ListPager
page={itemPage}
pages={itemPages}
total={itemTotal}
pageSize={itemPageSize}
pageSizeOptions={[20, 50, 100]}
onPageChange={setItemPage}
onPageSizeChange={(size) => {
setItemPageSize(size);
setItemPage(1);
}}
disabled={jobDetailQuery.isLoading}
/>
</div>
</div>
</div>

View file

@ -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<UmeAlarmItem>({
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 (
<section className="panel">
<div className="panel__toolbar">
<h2>{t("ume.alarms.title")}</h2>
<div className="btn-row">
<button type="button" disabled={exporting || curTotal === 0} onClick={() => void exportCsv()}>
{exporting ? t("common.exporting") : t("common.exportCsv")}
</button>
</div>
</div>
<div className="pt-list">
@ -99,8 +153,8 @@ export function NetworkAlarmsPage() {
}}
>
<option value="">{t("ume.alarms.clearedAll")}</option>
<option value="true">true</option>
<option value="false">false</option>
<option value="false">{t("ume.alarms.clearedNo")}</option>
<option value="true">{t("ume.alarms.clearedYes")}</option>
</select>
<button
type="button"
@ -130,12 +184,12 @@ export function NetworkAlarmsPage() {
<table className="data-table pt-list-table">
<thead>
<tr>
<th>time_created</th>
<th>severity</th>
<th>ne_id</th>
<th>host_name</th>
<th>ne_type</th>
<th>cause</th>
<th>{t("ume.alarms.col.time")}</th>
<th>{t("ume.alarms.col.severity")}</th>
<th>{t("ume.alarms.col.neId")}</th>
<th>{t("ume.alarms.col.hostName")}</th>
<th>{t("ume.alarms.col.neType")}</th>
<th>{t("ume.alarms.col.cause")}</th>
</tr>
</thead>
<tbody>
@ -177,46 +231,19 @@ export function NetworkAlarmsPage() {
</div>
)}
<div className="pager pt-list-pager">
<div className="pager__meta muted">
{t("common.pagerMeta", {
total: String(curTotal),
page: String(curPage),
pages: String(curPages),
})}
</div>
<div className="pager__controls btn-row">
<button
className="pager__btn"
type="button"
onClick={() => setCurPage(Math.max(1, curPage - 1))}
disabled={curPage <= 1}
>
{t("common.prevPage")}
</button>
<button
className="pager__btn"
type="button"
onClick={() => setCurPage(curPage + 1)}
disabled={curPage >= curPages}
>
{t("common.nextPage")}
</button>
<select
className="pager__size"
value={String(curPageSize)}
onChange={(e) => {
setCurPageSize(Number(e.target.value) || 50);
setCurPage(1);
}}
>
<option value="50">{perPage(50)}</option>
<option value="100">{perPage(100)}</option>
<option value="200">{perPage(200)}</option>
<option value="500">{perPage(500)}</option>
</select>
</div>
</div>
<ListPager
page={curPage}
pages={curPages}
total={curTotal}
pageSize={curPageSize}
pageSizeOptions={[50, 100, 200, 500]}
onPageChange={setCurPage}
onPageSizeChange={(size) => {
setCurPageSize(size);
setCurPage(1);
}}
disabled={currentQuery.isLoading}
/>
</div>
</section>
);

View file

@ -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<NeConfigSnapshotMeta>({
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 (
<section className="panel">
<div className="panel__toolbar">
<h2>{t("networkConfigs.title")}</h2>
<div className="btn-row">
<button
type="button"
disabled={exportingList || total === 0}
onClick={() => void exportListCsv()}
>
{exportingList ? t("common.exporting") : t("common.exportCsv")}
</button>
</div>
</div>
<div className="pt-list">
@ -118,6 +204,7 @@ export function NetworkConfigsPage() {
</div>
{listQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
{listQuery.isError ? <p className="error-text">{t("common.opFailed")}</p> : null}
{!items.length && !listQuery.isLoading ? (
<div className="pt-list-empty">
@ -199,23 +286,19 @@ export function NetworkConfigsPage() {
</div>
)}
<div className="pager pt-list-pager">
<span className="muted">
{t("common.pagerMeta", {
total: String(total),
page: String(page),
pages: String(pages),
})}
</span>
<div className="btn-row">
<button type="button" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
{t("common.prevPage")}
</button>
<button type="button" disabled={page >= pages} onClick={() => setPage((p) => p + 1)}>
{t("common.nextPage")}
</button>
</div>
</div>
<ListPager
page={page}
pages={pages}
total={total}
pageSize={pageSize}
pageSizeOptions={[20, 50, 100]}
onPageChange={setPage}
onPageSizeChange={(size) => {
setPageSize(size);
setPage(1);
}}
disabled={listQuery.isLoading}
/>
</div>
{selected ? (

View file

@ -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<CliTargetItem>({
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 (
<section className="panel">
<div className="panel__toolbar">
<h2>{t("networkDevices.title")}</h2>
<div className="btn-row">
<button type="button" disabled={exporting || total === 0} onClick={() => void exportCsv()}>
{exporting ? t("common.exporting") : t("common.exportCsv")}
</button>
</div>
</div>
<div className="pt-list">
@ -76,6 +132,7 @@ export function NetworkDevicesPage() {
</div>
{listQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
{listQuery.isError ? <p className="error-text">{t("common.opFailed")}</p> : null}
{!items.length && !listQuery.isLoading ? (
<div className="pt-list-empty">
@ -92,6 +149,7 @@ export function NetworkDevicesPage() {
<th>{t("networkDevices.col.vendor")}</th>
<th>{t("networkDevices.col.deviceType")}</th>
<th>{t("networkDevices.col.connect")}</th>
<th>{t("networkDevices.col.actions")}</th>
</tr>
</thead>
<tbody>
@ -107,6 +165,24 @@ export function NetworkDevicesPage() {
{row.connect_status || "—"}
</span>
</td>
<td>
<div className="btn-row pt-list-actions table-actions">
<button type="button" onClick={() => openWebcrt(row)}>
WebCRT
</button>
<button
type="button"
onClick={() =>
openNewModuleWindow({
moduleId: "network",
path: `/network/configs?q=${encodeURIComponent(row.name || row.ip_address || row.id)}`,
})
}
>
{t("network.nav.configs")}
</button>
</div>
</td>
</tr>
))}
</tbody>
@ -114,23 +190,19 @@ export function NetworkDevicesPage() {
</div>
)}
<div className="pager pt-list-pager">
<span className="muted">
{t("common.pagerMeta", {
total: String(total),
page: String(page),
pages: String(pages),
})}
</span>
<div className="btn-row">
<button type="button" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
{t("common.prevPage")}
</button>
<button type="button" disabled={page >= pages} onClick={() => setPage((p) => p + 1)}>
{t("common.nextPage")}
</button>
</div>
</div>
<ListPager
page={page}
pages={pages}
total={total}
pageSize={pageSize}
pageSizeOptions={[20, 50, 100, 200]}
onPageChange={setPage}
onPageSizeChange={(size) => {
setPageSize(size);
setPage(1);
}}
disabled={listQuery.isLoading}
/>
</div>
</section>
);

View file

@ -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<ViewMode>("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<PortTrafficDevice | null>(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<PortTrafficDevice>({
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 (
<section className="panel">
<div className="panel__toolbar">
@ -601,6 +711,13 @@ export function PortTrafficPage() {
<button type="button" onClick={() => navigate("/network/tasks/port-traffic/wall")}>
{t("portTraffic.wall")}
</button>
<button
type="button"
disabled={exportingDevices || listTotal === 0}
onClick={() => void exportDevicesCsv()}
>
{exportingDevices ? t("common.exporting") : t("common.exportCsv")}
</button>
<button type="button" className="btn-primary" onClick={openWizard}>
{t("portTraffic.create")}
</button>
@ -634,157 +751,189 @@ export function PortTrafficPage() {
</div>
</div>
{!devices.length ? (
<div className="filter-inline">
<input
value={listKeyword}
placeholder={t("portTraffic.keywordPh")}
onChange={(e) => {
setListKeyword(e.target.value);
setListPage(1);
}}
/>
<select
value={listStatus}
onChange={(e) => {
setListStatus(e.target.value);
setListPage(1);
}}
>
<option value="">{t("portTraffic.allStatus")}</option>
<option value="running">{t("portTraffic.statusRunning")}</option>
<option value="paused">{t("portTraffic.statusPaused")}</option>
<option value="stopped">{t("portTraffic.statusStopped")}</option>
<option value="draft">{t("portTraffic.statusDraft")}</option>
</select>
<button
type="button"
disabled={!hasListFilters}
onClick={() => {
setListKeyword("");
setListStatus("");
setListPage(1);
}}
>
{t("common.clearFilters")}
</button>
</div>
{devicesQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
{devicesQuery.isError ? <p className="error-text">{t("common.opFailed")}</p> : null}
{!devices.length && !devicesQuery.isLoading ? (
<div className="pt-list-empty">
<p>{t("portTraffic.empty")}</p>
<button type="button" className="btn-primary" onClick={openWizard}>
{t("portTraffic.create")}
</button>
{!hasListFilters ? (
<button type="button" className="btn-primary" onClick={openWizard}>
{t("portTraffic.create")}
</button>
) : null}
</div>
) : (
<>
<div className="pt-list-table-wrap">
<table className="data-table pt-list-table">
<thead>
<tr>
<th>{t("portTraffic.col.device")}</th>
<th>{t("portTraffic.col.status")}</th>
<th>{t("portTraffic.col.ports")}</th>
<th>{t("portTraffic.col.interval")}</th>
<th>{t("portTraffic.col.last")}</th>
<th>{t("portTraffic.col.actions")}</th>
</tr>
</thead>
<tbody>
{devices.map((row) => {
const tone = statusTone(row.status);
return (
<tr key={row.id}>
<td>
<div className="pt-list-task-name">{deviceLabel(row)}</div>
<div className="muted" style={{ fontSize: 12 }}>
{row.ne_ip || row.ne_id}
{row.vendor ? ` · ${row.vendor}` : ""}
{row.note ? ` · ${row.note}` : ""}
</div>
</td>
<td>
<span className={`pt-list-status pt-list-status--${tone}`}>
{statusText(row.status)}
) : devices.length ? (
<div className="pt-list-table-wrap">
<table className="data-table pt-list-table">
<thead>
<tr>
<th>{t("portTraffic.col.device")}</th>
<th>{t("portTraffic.col.status")}</th>
<th>{t("portTraffic.col.ports")}</th>
<th>{t("portTraffic.col.interval")}</th>
<th>{t("portTraffic.col.last")}</th>
<th>{t("portTraffic.col.actions")}</th>
</tr>
</thead>
<tbody>
{devices.map((row) => {
const tone = statusTone(row.status);
return (
<tr key={row.id}>
<td>
<div className="pt-list-task-name">{deviceLabel(row)}</div>
<div className="muted" style={{ fontSize: 12 }}>
{row.ne_ip || row.ne_id}
{row.vendor ? ` · ${row.vendor}` : ""}
{row.note ? ` · ${row.note}` : ""}
</div>
</td>
<td>
<span className={`pt-list-status pt-list-status--${tone}`}>
{statusText(row.status)}
</span>
{row.collect_running ? (
<span className="pt-list-status pt-list-status--collect">
{t("portTraffic.collecting")}
</span>
{row.collect_running ? (
<span className="pt-list-status pt-list-status--collect">
{t("portTraffic.collecting")}
</span>
) : null}
</td>
<td className="pt-list-num">
{row.active_target_count}/{row.target_count}
</td>
<td className="pt-list-num">{row.interval_sec}s</td>
<td className="pt-list-time">
{formatSystemTime(row.last_collect_ended_at) || "—"}
</td>
<td>
<div className="pt-list-actions">
<button
type="button"
className="btn-primary"
onClick={() => openWallList()}
>
{t("portTraffic.wall")}
</button>
<button type="button" onClick={() => openEdit(row)}>
{t("portTraffic.edit")}
</button>
{needsNeRebind(row) ? (
<button
type="button"
title={t("portTraffic.rebindHint")}
onClick={() => openRebind(row)}
>
{t("portTraffic.rebind")}
</button>
) : null}
</td>
<td className="pt-list-num">
{row.active_target_count}/{row.target_count}
</td>
<td className="pt-list-num">{row.interval_sec}s</td>
<td className="pt-list-time">
{formatSystemTime(row.last_collect_ended_at) || "—"}
</td>
<td>
<div className="pt-list-actions">
{row.status !== "running" ? (
<button
type="button"
className="btn-primary"
onClick={() => openWallList()}
disabled={startMut.isPending}
onClick={() => startMut.mutate(row.id)}
>
{t("portTraffic.wall")}
{t("portTraffic.start")}
</button>
<button type="button" onClick={() => openEdit(row)}>
{t("portTraffic.edit")}
</button>
{needsNeRebind(row) ? (
<button
type="button"
title={t("portTraffic.rebindHint")}
onClick={() => openRebind(row)}
>
{t("portTraffic.rebind")}
</button>
) : null}
{row.status !== "running" ? (
<button
type="button"
disabled={startMut.isPending}
onClick={() => startMut.mutate(row.id)}
>
{t("portTraffic.start")}
</button>
) : (
<button
type="button"
disabled={pauseMut.isPending}
onClick={() => pauseMut.mutate(row.id)}
>
{t("portTraffic.pause")}
</button>
)}
{row.status !== "stopped" ? (
<button
type="button"
disabled={stopMut.isPending}
onClick={() => stopMut.mutate(row.id)}
>
{t("portTraffic.stop")}
</button>
) : null}
) : (
<button
type="button"
className={row.last_error ? "pt-list-log-btn--error" : undefined}
onClick={() => {
setLogDeviceId(row.id);
setLogDeviceLabel(deviceLabel(row));
}}
disabled={pauseMut.isPending}
onClick={() => pauseMut.mutate(row.id)}
>
{t("portTraffic.log")}
{t("portTraffic.pause")}
</button>
)}
{row.status !== "stopped" ? (
<button
type="button"
className="btn--danger"
disabled={deleteMut.isPending}
onClick={() => {
if (window.confirm(t("portTraffic.confirmDelete"))) {
deleteMut.mutate(row.id);
}
}}
disabled={stopMut.isPending}
onClick={() => stopMut.mutate(row.id)}
>
{t("portTraffic.delete")}
{t("portTraffic.stop")}
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="pager pt-list-pager">
<button
type="button"
className="pager__btn"
disabled={listPage <= 1}
onClick={() => setListPage((p) => p - 1)}
>
‹
</button>
<span className="muted">
{listPage}/{Math.max(1, pages)}
</span>
<button
type="button"
className="pager__btn"
disabled={listPage >= pages}
onClick={() => setListPage((p) => p + 1)}
>
›
</button>
</div>
</>
)}
) : null}
<button
type="button"
className={row.last_error ? "pt-list-log-btn--error" : undefined}
onClick={() => {
setLogDeviceId(row.id);
setLogDeviceLabel(deviceLabel(row));
setLogKeyword("");
}}
>
{t("portTraffic.log")}
</button>
<button
type="button"
className="btn--danger"
disabled={deleteMut.isPending}
onClick={() => {
if (window.confirm(t("portTraffic.confirmDelete"))) {
deleteMut.mutate(row.id);
}
}}
>
{t("portTraffic.delete")}
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
) : null}
<ListPager
page={listPage}
pages={pages}
total={listTotal}
pageSize={listPageSize}
pageSizeOptions={[20, 50, 100]}
onPageChange={setListPage}
onPageSizeChange={(size) => {
setListPageSize(size);
setListPage(1);
}}
disabled={devicesQuery.isLoading}
/>
</div>
) : null}
@ -1179,14 +1328,7 @@ export function PortTrafficPage() {
) : null}
{logDeviceId ? (
<div
className="modal-backdrop"
role="presentation"
onClick={() => {
setLogDeviceId("");
setLogDeviceLabel("");
}}
>
<div className="modal-backdrop" role="presentation" onClick={closeLog}>
<div
className="modal modal--wide pt-log-modal"
role="dialog"
@ -1201,23 +1343,35 @@ export function PortTrafficPage() {
{t("portTraffic.logDevice")}: {logDeviceLabel || "—"}
</p>
</div>
<button
type="button"
onClick={() => {
setLogDeviceId("");
setLogDeviceLabel("");
}}
>
{t("portTraffic.logClose")}
</button>
<div className="btn-row">
<button
type="button"
disabled={!filteredLogEvents.length}
onClick={exportEventsCsv}
>
{t("common.exportCsv")}
</button>
<button type="button" onClick={closeLog}>
{t("portTraffic.logClose")}
</button>
</div>
</div>
<div className="filter-inline" style={{ marginBottom: 10 }}>
<input
value={logKeyword}
placeholder={t("portTraffic.keywordPh")}
onChange={(e) => setLogKeyword(e.target.value)}
/>
</div>
{logQuery.isLoading ? (
<p className="muted">…</p>
) : !(logQuery.data?.items || []).length ? (
) : !logEvents.length ? (
<p className="muted">{t("portTraffic.logEmpty")}</p>
) : !filteredLogEvents.length ? (
<p className="muted">{t("portTraffic.logEmpty")}</p>
) : (
<div className="pt-log-modal__list">
{(logQuery.data?.items || []).map((ev) => {
{filteredLogEvents.map((ev) => {
const level = String(ev.level || "error").toLowerCase();
const tone =
level === "warn" || level === "warning"

View file

@ -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<string[]>([]);
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<Record<string, boolean>>({});
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<SliceGenerateResult | null>(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() {
<button
type="button"
className="btn-primary"
onClick={() => 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")}
</button>
@ -299,16 +307,15 @@ export function TopologyClassifyPage() {
/>
<select
className="input"
value={filterRole}
value={filterMajor}
onChange={(e) => {
setFilterRole(e.target.value);
setFilterMajor(e.target.value);
setPage(1);
}}
>
<option value="">{t("topoClassify.filterRoleAll")}</option>
{ROLE_OPTIONS.map((r) => (
<option key={r} value={r}>
{roleLabel(t, r)}
{FILTER_MAJOR.map((r) => (
<option key={r.value || "all"} value={r.value}>
{t(r.labelKey)}
</option>
))}
</select>
@ -337,7 +344,7 @@ export function TopologyClassifyPage() {
>
<option value="">{t("topoClassify.filterUnmatchedOff")}</option>
<option value="any">{t("topoClassify.kindAny")}</option>
<option value="role">{t("topoClassify.kindRole")}</option>
<option value="level">{t("topoClassify.kindLevel")}</option>
<option value="region">{t("topoClassify.kindRegion")}</option>
</select>
<select
@ -390,26 +397,26 @@ export function TopologyClassifyPage() {
<select
className="input"
value={assignWhat}
onChange={(e) => setAssignWhat(e.target.value as "role" | "region" | "both")}
onChange={(e) => setAssignWhat(e.target.value as "level" | "region" | "both")}
>
<option value="role">{t("topoClassify.assignRole")}</option>
<option value="level">{t("topoClassify.assignLevel")}</option>
<option value="region">{t("topoClassify.assignRegion")}</option>
<option value="both">{t("topoClassify.assignBoth")}</option>
</select>
{assignWhat !== "region" ? (
<select
className="input"
value={assignRole}
onChange={(e) => setAssignRole(e.target.value)}
value={assignLevel}
onChange={(e) => setAssignLevel(e.target.value)}
>
{ROLE_OPTIONS.map((r) => (
<option key={r} value={r}>
{roleLabel(t, r)}
{LEVEL_PRESETS.map((r) => (
<option key={r.value} value={r.value}>
{levelPresetLabel(t, r.value)}
</option>
))}
</select>
) : null}
{assignWhat !== "role" ? (
{assignWhat !== "level" ? (
<select
className="input"
value={assignRegion}
@ -475,7 +482,7 @@ export function TopologyClassifyPage() {
<th>{t("topoClassify.colNe")}</th>
<th>IP</th>
<th>{t("topoClassify.colLink")}</th>
<th>{t("topoClassify.colRole")}</th>
<th>{t("topoClassify.colLevel")}</th>
<th>{t("topoClassify.colRegion")}</th>
<th>{t("topoClassify.colActions")}</th>
</tr>
@ -503,20 +510,14 @@ export function TopologyClassifyPage() {
</td>
<td>
{editing ? (
<select
<input
className="input"
value={editRole}
onChange={(e) => setEditRole(e.target.value)}
>
<option value="">-</option>
{ROLE_OPTIONS.map((r) => (
<option key={r} value={r}>
{roleLabel(t, r)}
</option>
))}
</select>
value={editLevel}
placeholder="1 / 1.1 / 2.1"
onChange={(e) => setEditLevel(e.target.value)}
/>
) : (
roleLabel(t, n.role || "")
formatLevel(n.level)
)}
</td>
<td>
@ -627,95 +628,6 @@ export function TopologyClassifyPage() {
</button>
</div>
</section>
<section className="topo-classify__section">
<button
type="button"
className="btn btn--sm btn--ghost"
onClick={() => setShowSlices((v) => !v)}
>
{showSlices ? t("topoClassify.hideSlices") : t("topoClassify.showSlices")}
</button>
{showSlices ? (
<>
<h3>{t("topoClassify.slices")}</h3>
<div className="topo-classify__draft">
<select
className="input"
value={sliceFolderId}
onChange={(e) => {
setSliceFolderId(e.target.value);
setSlicePreview(null);
}}
>
<option value="">{t("topoClassify.pickRegion")}</option>
{regions.map((r) => (
<option key={r.id} value={r.id}>
{r.name}
</option>
))}
</select>
<select
className="input"
value={sliceTemplate}
onChange={(e) =>
setSliceTemplate(e.target.value as "core_only" | "core_agg" | "agg_access")
}
>
<option value="core_only">{t("topoClassify.tplCore")}</option>
<option value="core_agg">{t("topoClassify.tplCoreAgg")}</option>
<option value="agg_access">{t("topoClassify.tplAggAcc")}</option>
</select>
<label className="topo-classify__check">
<input
type="checkbox"
checked={seedPhysical}
onChange={(e) => setSeedPhysical(e.target.checked)}
/>
{t("topoClassify.seedPhysical")}
</label>
<button
type="button"
className="btn btn--sm btn--ghost"
disabled={!sliceFolderId || slicePreviewMut.isPending}
onClick={() => slicePreviewMut.mutate()}
>
{t("topoClassify.slicePreview")}
</button>
<button
type="button"
className="btn btn--sm"
disabled={!sliceFolderId || sliceApplyMut.isPending}
onClick={() => {
if (window.confirm(t("topoClassify.sliceConfirm"))) sliceApplyMut.mutate();
}}
>
{t("topoClassify.sliceApply")}
</button>
</div>
{slicePreview ? (
<div className="topo-classify__preview">
<p className="panel__hint">
{t("topoClassify.sliceStats")
.replace("{{maps}}", String(slicePreview.map_count))
.replace("{{overlap}}", String(slicePreview.overlap_node_count))}
</p>
<ul className="topo-classify__slice-list">
{slicePreview.maps.map((m) => (
<li key={m.name}>
<strong>{m.name}</strong>
{" · "}
{roleLabel(t, m.role)}
{" · "}
{t("topoClassify.nodeCount").replace("{{count}}", String(m.node_count))}
</li>
))}
</ul>
</div>
) : null}
</>
) : null}
</section>
</div>
);
}

View file

@ -291,6 +291,7 @@ export type TopologyReactFlowViewProps = {
setCanvasZoom: (z: number) => void;
scheduleFlatViewportRefresh: () => void;
toggleFullscreen: () => void | Promise<void>;
readOnly?: boolean;
};
export default function TopologyReactFlowView(props: TopologyReactFlowViewProps) {

View file

@ -8,6 +8,7 @@ import type { NeNodeData } from "./TopologyReactFlowView";
import type { ToolMode } from "./toolMode";
export type TopologyToolbarProps = {
readOnly?: boolean;
breadcrumbFolders: TopologyTreeFolderItem[];
activeView: TopologyTreeViewItem | null | undefined;
activeRegion: TopologyTreeFolderItem | null | undefined;
@ -51,6 +52,7 @@ export type TopologyToolbarProps = {
};
export function TopologyToolbar({
readOnly = false,
breadcrumbFolders,
activeView,
activeRegion,
@ -186,8 +188,14 @@ export function TopologyToolbar({
<button
type="button"
className="btn btn--sm"
disabled={isWorldFlatCanvas}
title={isWorldFlatCanvas ? t("topology.worldMapNoDirectNes") : undefined}
disabled={readOnly || isWorldFlatCanvas}
title={
readOnly
? t("topology.readOnlyHint")
: isWorldFlatCanvas
? t("topology.worldMapNoDirectNes")
: undefined
}
onClick={onAddNe}
>
{t("topology.addNe")}
@ -195,8 +203,14 @@ export function TopologyToolbar({
<button
type="button"
className="btn btn--sm"
disabled={isWorldFlatCanvas}
title={isWorldFlatCanvas ? t("topology.worldMapNoDirectNes") : undefined}
disabled={readOnly || isWorldFlatCanvas}
title={
readOnly
? t("topology.readOnlyHint")
: isWorldFlatCanvas
? t("topology.worldMapNoDirectNes")
: undefined
}
onClick={handleCreateNe}
>
{t("topology.createNe")}
@ -207,7 +221,7 @@ export function TopologyToolbar({
<button
type="button"
className="btn btn--sm btn--ghost"
disabled={!canUndo}
disabled={readOnly || !canUndo}
onClick={onUndo}
title="Ctrl+Z"
>
@ -216,7 +230,7 @@ export function TopologyToolbar({
<button
type="button"
className="btn btn--sm btn--ghost"
disabled={!canRedo}
disabled={readOnly || !canRedo}
onClick={onRedo}
title="Ctrl+Y"
>
@ -225,9 +239,9 @@ export function TopologyToolbar({
<button
type="button"
className={`btn btn--sm${dirty ? "" : " btn--ghost"}`}
disabled={savePending || !dirty}
disabled={readOnly || savePending || !dirty}
onClick={onSave}
title="Ctrl+S"
title={readOnly ? t("topology.readOnlyHint") : "Ctrl+S"}
>
{savePending ? t("topology.saving") : dirty ? t("topology.saveDirty") : t("topology.save")}
</button>
@ -265,8 +279,8 @@ export function TopologyToolbar({
<button
type="button"
className="btn btn--sm btn--ghost"
disabled={!staleEdgeCount}
title={t("topology.removeStaleHint")}
disabled={readOnly || !staleEdgeCount}
title={readOnly ? t("topology.readOnlyHint") : t("topology.removeStaleHint")}
onClick={() => void onRemoveStale()}
>
{t("topology.removeStale").replace("{{count}}", String(staleEdgeCount))}
@ -286,8 +300,9 @@ export function TopologyToolbar({
key={mode}
type="button"
className={`topo-tools__btn${toolMode === mode ? " is-active" : ""}`}
title={`${label} (${key})`}
title={`${label} (${key})${readOnly && mode === "connect" ? ` — ${t("topology.readOnlyHint")}` : ""}`}
aria-pressed={toolMode === mode}
disabled={readOnly && mode === "connect"}
onClick={() => {
onToolModeChange(mode);
onConnectClickReset();

View file

@ -1,9 +1,10 @@
import type { RefObject } from "react";
import { createPortal } from "react-dom";
import { useQueryClient } from "@tanstack/react-query";
import type { RefObject } from "react";
import type { Node } from "@xyflow/react";
import { createPortal } from "react-dom";
import { queryKeys } from "../../constants/queryKeys";
import { useI18n } from "../../i18n";
import { useToast } from "../../hooks/useToast";
import { updateLldpCollectPolicy } from "../../services/api";
import {
DEFAULT_CANVAS_BG,
@ -87,6 +88,7 @@ export type TopologyViewToolsProps = {
export function TopologyViewTools(props: TopologyViewToolsProps) {
const { t } = useI18n();
const { showError } = useToast();
const queryClient = useQueryClient();
const {
fullscreen,
@ -225,7 +227,7 @@ export function TopologyViewTools(props: TopologyViewToolsProps) {
void queryClient.invalidateQueries({ queryKey: queryKeys.lldpCollectDashboard });
})
.catch(() => {
/* local toggle still applies to this canvas discover */
showError(t("topology.policySyncFailed"));
});
}}
/>

View file

@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useQuery, type QueryClient } from "@tanstack/react-query";
import type { Edge, Node, ReactFlowInstance } from "@xyflow/react";
import { fetchTopologyGraph } from "../../../services/api";
import { fetchTopologyGraph, formatErr } from "../../../services/api";
import { queryKeys } from "../../../constants/queryKeys";
import type { TopologyTreeViewItem, TopologyViewGraph, TopologyWorldTransform } from "../../../types";
import {
@ -493,6 +493,8 @@ export function useTopologyCanvas(opts: UseTopologyCanvasOptions) {
const showWorldScatter =
isWorldFlatCanvas && worldVisualLod !== "full" && worldScatter.length > 0;
const canvasGraphLoading = Boolean(mapId) && (graphQuery.isPending || !graphQuery.data);
const canvasGraphError = Boolean(mapId) && graphQuery.isError;
const canvasGraphErrorMsg = graphQuery.error ? formatErr(graphQuery.error) : "";
const canvasGraphEmpty =
Boolean(mapId) &&
graphQuery.isSuccess &&
@ -551,6 +553,8 @@ export function useTopologyCanvas(opts: UseTopologyCanvasOptions) {
worldDockMe,
showWorldScatter,
canvasGraphLoading,
canvasGraphError,
canvasGraphErrorMsg,
canvasGraphEmpty,
worldNeedsApply,
canvasGraphRefreshing,

View file

@ -13,6 +13,7 @@ import {
removeTopologyViewNodes,
startLldpDiscover,
stopLldpCollectJob,
formatErr,
} from "../../../services/api";
import type {
TopologyDiscoverJob,
@ -181,6 +182,11 @@ export function useTopologyDiscover(opts: UseTopologyDiscoverOptions) {
setDiscoverJobId(jobStart.id);
let job: TopologyDiscoverJob = jobStart;
let cancelled = false;
const isTerminalStatus = (status: string) =>
status === "done" ||
status === "failed" ||
status === "cancelled" ||
status === "stopped";
for (let i = 0; i < 600; i++) {
if (discoverAbortRef.current) {
cancelled = true;
@ -202,12 +208,7 @@ export function useTopologyDiscover(opts: UseTopologyDiscoverOptions) {
neIp: job.items?.[job.items.length - 1]?.ne_ip || p.neIp,
}));
setDiscoverLiveResults((job.items || []) as TopologyDiscoverNeResult[]);
if (
job.status === "done" ||
job.status === "failed" ||
job.status === "cancelled" ||
job.status === "stopped"
) {
if (isTerminalStatus(job.status)) {
if (job.status === "cancelled" || job.status === "stopped") cancelled = true;
break;
}
@ -227,6 +228,9 @@ export function useTopologyDiscover(opts: UseTopologyDiscoverOptions) {
});
return;
}
if (!isTerminalStatus(job.status)) {
throw new Error(t("topology.discoverTimeout"));
}
if (job.status === "failed") {
throw new Error(job.error || "discover_failed");
}
@ -293,8 +297,9 @@ export function useTopologyDiscover(opts: UseTopologyDiscoverOptions) {
if (discoverAbortRef.current) {
setDiscoverError(t("topology.discoverCancelled"));
} else {
setDiscoverError(String(err));
showError(t("topology.discoverFail").replace("{{detail}}", String(err)));
const detail = formatErr(err);
setDiscoverError(detail);
showError(t("topology.discoverFail").replace("{{detail}}", detail));
}
} finally {
setDiscovering(false);

View file

@ -2,6 +2,7 @@ import type {
CollectionJobDetail,
CollectionJobItem,
CollectionDashboard,
CollectionPolicy,
CollectionRunList,
EligibleNeItem,
IntegrationStatus,
@ -147,6 +148,13 @@ export class ApiRequestError extends Error {
}
}
/** User-facing message from API or thrown errors (prefers ApiRequestError.message). */
export function formatErr(err: unknown): string {
if (err instanceof ApiRequestError) return err.message;
if (err instanceof Error) return err.message;
return String(err);
}
export function formatApiDetail(detail: unknown): string {
if (typeof detail === "string") return detail;
if (Array.isArray(detail)) {
@ -175,16 +183,18 @@ export function formatApiDetail(detail: unknown): string {
}
export const apiGet = async <T,>(path: string): Promise<T> => {
let res = await fetch(path, { headers: authHeaders(), credentials: fetchCreds });
const doFetch = () => fetch(path, { headers: authHeaders(), credentials: fetchCreds });
let res = await doFetch();
if (res.status === 401 && shouldAttemptRefresh(path) && (await tryRefreshAccessToken())) {
res = await fetch(path, { headers: authHeaders(), credentials: fetchCreds });
res = await doFetch();
}
const data = await parseApiResponse(res);
if (res.status === 401) {
handleUnauthorized(path);
throw new Error("401 unauthorized");
throw new ApiRequestError(401, data.detail || "unauthorized");
}
if (!res.ok) throw new Error(`${res.status} ${path}`);
return (await res.json()) as T;
if (!res.ok) throw new ApiRequestError(res.status, data.detail || `${res.status} ${path}`);
return data as T;
};
export const apiPost = async <T,>(path: string, body: unknown): Promise<T> => {
@ -600,9 +610,12 @@ export const importManagedNe = async (file: File): Promise<ManagedNeImportResult
handleUnauthorized(path);
throw new Error("unauthorized");
}
const text = await res.text();
const data = text ? JSON.parse(text) : {};
if (!res.ok) throw new Error(String((data as { detail?: string }).detail || `${res.status} import`));
const data = await parseApiResponse(res);
if (res.status === 401) {
handleUnauthorized(path);
throw new ApiRequestError(401, data.detail || "unauthorized");
}
if (!res.ok) throw new ApiRequestError(res.status, data.detail || `${res.status} import`);
return data as ManagedNeImportResult;
};
@ -624,10 +637,17 @@ export const createNeCollection = (body: {
}) =>
apiPost<CollectionJobItem>("/v1/ne-collections", body);
export const fetchNeCollections = (params: { page: number; pageSize: number }) => {
export const fetchNeCollections = (params: {
page: number;
pageSize: number;
status?: string;
keyword?: string;
}) => {
const p = new URLSearchParams();
p.set("page", String(Math.max(1, params.page)));
p.set("page_size", String(Math.max(1, Math.min(100, params.pageSize))));
if (params.status?.trim()) p.set("status", params.status.trim());
if (params.keyword?.trim()) p.set("keyword", params.keyword.trim());
return apiGet<{ total: number; page: number; page_size: number; items: CollectionJobItem[] }>(
`/v1/ne-collections?${p.toString()}`,
);
@ -636,6 +656,14 @@ export const fetchNeCollections = (params: { page: number; pageSize: number }) =
export const fetchCollectionDashboard = () =>
apiGet<CollectionDashboard>("/v1/ne-collections/dashboard");
export const fetchCollectionPolicy = () => apiGet<CollectionPolicy>("/v1/ne-collections/policy");
export const updateCollectionPolicy = (body: Partial<CollectionPolicy>) =>
apiPut<CollectionPolicy>("/v1/ne-collections/policy", body);
export const startCollectionFromPolicy = () =>
apiPost<CollectionJobItem>("/v1/ne-collections/start-from-policy", {});
export const fetchCollectionJob = (jobId: string) => apiGet<CollectionJobDetail>(`/v1/ne-collections/${jobId}`);
export const fetchCollectionRuns = (params: {
@ -1233,6 +1261,8 @@ export const fetchFabricSummary = () => apiGet<FabricSummary>("/v1/topology/fabr
export const fetchFabricNodes = (params?: {
keyword?: string;
role?: string;
level?: string;
levelMajor?: string;
regionFolderId?: string;
unmatched?: string;
linkStatus?: string;
@ -1242,6 +1272,8 @@ export const fetchFabricNodes = (params?: {
const p = new URLSearchParams();
if (params?.keyword) p.set("keyword", params.keyword);
if (params?.role) p.set("role", params.role);
if (params?.level) p.set("level", params.level);
if (params?.levelMajor) p.set("level_major", params.levelMajor);
if (params?.regionFolderId) p.set("region_folder_id", params.regionFolderId);
if (params?.unmatched) p.set("unmatched", params.unmatched);
if (params?.linkStatus) p.set("link_status", params.linkStatus);
@ -1272,6 +1304,7 @@ export const bulkTagFabricNodes = (body: {
fabric_node_ids?: string[];
pattern?: string;
match_field?: string;
level?: number | null;
role?: string | null;
region_folder_id?: string | null;
dry_run?: boolean;
@ -1280,6 +1313,7 @@ export const bulkTagFabricNodes = (body: {
dry_run: boolean;
matched: number;
updated: number;
level?: number | null;
role?: string | null;
region_folder_id?: string | null;
samples: Array<Record<string, string>>;
@ -1287,7 +1321,7 @@ export const bulkTagFabricNodes = (body: {
export const patchFabricNodeTags = (
fabricNodeId: string,
body: { role?: string | null; region_folder_id?: string | null },
body: { level?: number | null; role?: string | null; region_folder_id?: string | null },
) =>
apiPatch<import("../types").FabricNodeSearchHit>(
`/v1/topology/fabric/nodes/${encodeURIComponent(fabricNodeId)}/tags`,
@ -1317,14 +1351,6 @@ export const purgePlaceholderFabricNodes = (fabricNodeIds: string[]) =>
fabric_node_ids: fabricNodeIds,
});
export const generateTopologySlices = (body: {
folder_id: string;
template: "core_only" | "core_agg" | "agg_access";
dry_run?: boolean;
max_nodes?: number;
seed_physical_cores?: boolean;
}) => apiPost<import("../types").SliceGenerateResult>("/v1/topology/slices/generate", body);
export const searchFabricNodes = (params?: { q?: string; page?: number; pageSize?: number }) => {
const p = new URLSearchParams();
if (params?.q) p.set("q", params.q);
@ -1399,10 +1425,17 @@ export const resumeLldpCollectJob = (jobId: string) =>
export const stopLldpCollectJob = (jobId: string) =>
apiPost<TopologyDiscoverJob>(`/v1/topology/lldp-collect/jobs/${encodeURIComponent(jobId)}/stop`, {});
export const fetchLldpCollectJobs = (params: { page?: number; pageSize?: number }) => {
export const fetchLldpCollectJobs = (params: {
page?: number;
pageSize?: number;
status?: string;
keyword?: string;
}) => {
const p = new URLSearchParams();
p.set("page", String(Math.max(1, Number(params.page || 1))));
p.set("page_size", String(Math.max(1, Math.min(100, Number(params.pageSize || 20)))));
if (params.status?.trim()) p.set("status", params.status.trim());
if (params.keyword?.trim()) p.set("keyword", params.keyword.trim());
return apiGet<{ total: number; page: number; page_size: number; items: LldpCollectJobSummary[] }>(
`/v1/topology/lldp-collect/jobs?${p.toString()}`,
);
@ -1410,11 +1443,13 @@ export const fetchLldpCollectJobs = (params: { page?: number; pageSize?: number
export const fetchLldpCollectJob = (
jobId: string,
params?: { page?: number; pageSize?: number },
params?: { page?: number; pageSize?: number; status?: string; keyword?: string },
) => {
const p = new URLSearchParams();
p.set("page", String(Math.max(1, Number(params?.page || 1))));
p.set("page_size", String(Math.max(1, Math.min(100, Number(params?.pageSize || 20)))));
if (params?.status?.trim()) p.set("status", params.status.trim());
if (params?.keyword?.trim()) p.set("keyword", params.keyword.trim());
return apiGet<TopologyDiscoverJob>(
`/v1/topology/lldp-collect/jobs/${encodeURIComponent(jobId)}?${p.toString()}`,
);
@ -1452,10 +1487,17 @@ export const fetchConfigSyncPolicy = () => apiGet<ConfigSyncPolicy>("/v1/config-
export const updateConfigSyncPolicy = (body: Partial<ConfigSyncPolicy>) =>
apiPut<ConfigSyncPolicy>("/v1/config-sync/policy", body);
export const fetchConfigSyncCycles = (params: { page?: number; pageSize?: number }) => {
export const fetchConfigSyncCycles = (params: {
page?: number;
pageSize?: number;
status?: string;
keyword?: string;
}) => {
const p = new URLSearchParams();
p.set("page", String(Math.max(1, Number(params.page || 1))));
p.set("page_size", String(Math.max(1, Math.min(100, Number(params.pageSize || 20)))));
if (params.status?.trim()) p.set("status", params.status.trim());
if (params.keyword?.trim()) p.set("keyword", params.keyword.trim());
return apiGet<{ total: number; page: number; page_size: number; items: ConfigSyncCycle[] }>(
`/v1/config-sync/cycles?${p.toString()}`,
);
@ -1554,10 +1596,17 @@ export const downloadNeConfigSnapshot = async (
export const fetchPortTrafficDashboard = () =>
apiGet<PortTrafficDashboard>("/v1/port-traffic/dashboard");
export const fetchPortTrafficDevices = (params: { page?: number; pageSize?: number }) => {
export const fetchPortTrafficDevices = (params: {
page?: number;
pageSize?: number;
status?: string;
keyword?: string;
}) => {
const p = new URLSearchParams();
p.set("page", String(Math.max(1, Number(params.page || 1))));
p.set("page_size", String(Math.max(1, Math.min(100, Number(params.pageSize || 20)))));
if (params.status?.trim()) p.set("status", params.status.trim());
if (params.keyword?.trim()) p.set("keyword", params.keyword.trim());
return apiGet<{ total: number; page: number; page_size: number; items: PortTrafficDevice[] }>(
`/v1/port-traffic/devices?${p.toString()}`,
);

View file

@ -320,6 +320,7 @@ export type CollectionJobItem = {
id: string;
title: string;
commands: string;
trigger_mode?: string;
status: string;
ne_count: number;
success_count: number;
@ -336,6 +337,7 @@ export type CollectionJobSummary = {
id: string;
title: string;
status: string;
trigger_mode?: string;
ne_count: number;
success_count: number;
fail_count: number;
@ -345,11 +347,30 @@ export type CollectionJobSummary = {
last_run_at?: string | null;
};
export type CollectionTargetRef = {
source: "managed" | "ume" | string;
id: string;
};
export type CollectionPolicy = {
enabled: boolean;
interval_days: number;
interval_hours: number;
scope_mode: "all" | "selected" | string;
selected_targets: CollectionTargetRef[];
title: string;
commands: string;
history_keep: number;
updated_at?: string | null;
};
export type CollectionDashboard = {
job_count: number;
active_count: number;
running_job: CollectionJobSummary | null;
last_job: CollectionJobSummary | null;
next_due_at?: string | null;
policy?: CollectionPolicy | null;
};
export type CollectionRunItem = {
@ -480,6 +501,8 @@ export type FabricNodeSearchHit = {
vendor: string;
managed_ne_id?: string;
ume_ne_id?: string;
/** Layout rank major.minor; null/undefined = unclassified */
level?: number | null;
role?: string;
region_folder_id?: string | null;
world_x?: number | null;
@ -498,22 +521,6 @@ export type FabricNodeSearchHit = {
}>;
};
export type SliceGenerateResult = {
folder_id: string;
template: string;
dry_run: boolean;
map_count: number;
overlap_node_count: number;
created_view_ids: string[];
maps: Array<{
name: string;
role: string;
node_count: number;
seed_fabric_node_ids: string[];
member_fabric_node_ids: string[];
}>;
};
export type TopologyViewNodeItem = {
fabric_node_id: string;
managed_ne_id: string;

View file

@ -0,0 +1,62 @@
export type CsvColumn<T> = {
key: string;
header: string;
value?: (row: T) => string | number | null | undefined;
};
function escapeCsvCell(raw: string): string {
if (/[",\r\n]/.test(raw)) return `"${raw.replace(/"/g, '""')}"`;
return raw;
}
/** Build CSV text (UTF-8 BOM for Excel). */
export function toCsv<T>(rows: T[], columns: CsvColumn<T>[]): string {
const header = columns.map((c) => escapeCsvCell(c.header)).join(",");
const lines = rows.map((row) =>
columns
.map((c) => {
const v = c.value
? c.value(row)
: (row as Record<string, unknown>)[c.key];
return escapeCsvCell(v == null ? "" : String(v));
})
.join(","),
);
return `\uFEFF${[header, ...lines].join("\r\n")}\r\n`;
}
export function downloadTextFile(filename: string, content: string, mime = "text/csv;charset=utf-8") {
const blob = new Blob([content], { type: mime });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
export function downloadCsv<T>(filename: string, rows: T[], columns: CsvColumn<T>[]) {
downloadTextFile(filename, toCsv(rows, columns));
}
/** Page through a list API up to maxRows (default 2000). */
export async function fetchAllPages<T>(opts: {
pageSize?: number;
maxRows?: number;
fetchPage: (page: number, pageSize: number) => Promise<{ total?: number; items: T[] }>;
}): Promise<T[]> {
const pageSize = Math.max(1, Math.min(500, opts.pageSize || 200));
const maxRows = Math.max(1, opts.maxRows || 2000);
const out: T[] = [];
let page = 1;
while (out.length < maxRows) {
const res = await opts.fetchPage(page, pageSize);
const batch = res.items || [];
if (!batch.length) break;
out.push(...batch);
if (batch.length < pageSize) break;
if (typeof res.total === "number" && out.length >= res.total) break;
page += 1;
}
return out.slice(0, maxRows);
}