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 name: netx-topology
description: >- description: >-
用 netx-topology MCP 查邻接、dual_unit 分批沉入、扫角压交叉(不污染 Fabric)。 用 netx-topology MCP 查邻接、分类打标;先 dual_unit 一次抽最大核心眼,再换其它算法下沉/布图(不污染 Fabric)。
触发:画拓扑、布图、拖图、LLDP、Fabric、netx-topology。先读本 skill 再调工具。 触发:画拓扑、布图、拖图、分类、LLDP、Fabric、netx-topology。先读本 skill 再调工具。
--- ---
# netx 拓扑(通用) # netx 拓扑(通用)
只用 **`netx-topology`** MCP(包 `netx-topology-mcp`)。安装与 scopes:仓库 [`docs/MCP_TOPOLOGY.md`](../../../docs/MCP_TOPOLOGY.md)。 只用 **`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 边。 **禁止**写临时 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 analyze(structure) → 读 dual_units(认核心最大眼)
→ 核心层:手拖或小图 layout(compact|corridor|rings) → 有 level:先打标;巨图勿先全量 level_bands
→ sinkTopologyDualUnits(一批,layout_batch=true) → 【仅一次】sinkTopologyDualUnits(
或 move_nodes(park=true) 指定 ids max_units=1, prefer_top_eye=true, prefer_pure=false,
→ orbit_sweep(round) → polish_crossings → clear_edge_hits sink_layers=[access], keep_layers=[core,agg], layout_batch=true,
→ 手拖微调 updateTopologyViewPositions max_nodes/max_batch_nodes 放行大眼
→ 下一批 sink…(禁止 until_empty 日常连抽) )
→ 眼形不对:可对本 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 画布)。 1. `getTopologyTree` → **`view_id`**(文件夹 physical 画布)。
2. 建根/子区域只用 `createTopologyFolder`。 2. 建根/子区域只用 `createTopologyFolder`。
3. `analyzeTopologyViewLayout({ view_id, detail: "structure" })` 读 `dual_units` / `shape`。 3. 缺层级/区域时:`classifyTopologyFabricNodes`(`match` → `tag(level, dry_run)` → `tag`;或 `unmatched` / `apply_rules`)。**level** 越小越靠外(0 外部 / 1 核心 / 2 汇聚 / 3 接入;可用 1.1、2.1 子层)。**勿**切片建图。
4. `addTopologyViewNodes` / `projectTopologyNeighbors`(区域画布务必 `region_folder_id`)。 4. `analyzeTopologyViewLayout({ view_id, detail: "structure" })` 读 `dual_units` / `shape` / `layers` —— **先认核心最大眼**(`unit_count` / 各 unit `node_count`)。
5. 多余点用 `removeTopologyViewNodes` **移出画布**(不删 Fabric)。`region:…` 幽灵点勿当网元拖。 5. `addTopologyViewNodes`(可按 `role` 大档或后续 level)/ `projectTopologyNeighbors`(区域画布务必 `region_folder_id`)。
6. 多余点用 `removeTopologyViewNodes` **移出画布**(不删 Fabric)。`region:…` 幽灵点勿当网元拖。
--- ---
## 形状与 dual_units ## 形状与 dual_units(只服务「第一只眼」)
``` ```
analyzeTopologyViewLayout({ view_id, detail: "structure" }) analyzeTopologyViewLayout({ view_id, detail: "structure" })
@ -50,19 +202,85 @@ analyzeTopologyViewLayout({ view_id, detail: "structure" })
| 字段 | 用途 | | 字段 | 用途 |
|------|------| |------|------|
| `shape.primary` | `chains` / `star` / `mesh` / `mixed_blocks` | | `shape.primary` | `chains` / `star` / `mesh` / `mixed_blocks` |
| `dual_units` | 两端门户 + ≥2 条内部不交走廊;成员可重叠 | | `dual_units` | **从核心出发**:core–core → core–agg → …,再取 **node_count 最大**的一只做唯一 sink |
| `advice.block_plan` | 每块怎么拖 | | `advice.block_plan` | 其余块怎么靠(非 dual 连抽) |
| `gravity.type` | 链图勿当 hub 花瓣 | | `gravity.type` | 链图勿当 hub 花瓣 |
- **链图**:脊柱水平 + stub;小图 preview `corridor`/`compact`(跳过 rings)。 - **第一步必选最大核心眼**:`prefer_top_eye=true`,`prefer_pure=false`,`max_units=1`;目标是**一块吃进尽量多网元**,不要求交叉=0。
- **巨图 / 多门户**:勿全图一把揉;走 **sink 分批 dual_unit**。 - **禁止**把 dual_units 当成「抽干源图」的循环泵;剩余网元见下方「后续下沉」。
- **禁止**再用互斥 soft_block 把通路切开。 - **禁止**再用互斥 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. **顶层:外部/对接网络** 1. **顶层:外部/对接网络**
@ -73,54 +291,16 @@ analyzeTopologyViewLayout({ view_id, detail: "structure" })
### 优化流程 ### 优化流程
1. **先手动后算法**:用户手动拖拽保证业务结构,再用算法优化 1. **先手动后算法**:用户手动拖拽保证业务结构,再用算法优化
2. **分层约束**:用 `updateTopologyViewPositions` 按层级设定 y 坐标(同层 y 落在区间内可错落,勿跨层);`orbit_sweep` 传 `y_min/y_max` 约束搜索在本层区间内 2. **分层约束**:`updateTopologyViewPositions` 按层钉 y;`orbit_sweep` 可传 `y_min/y_max`
3. **分步迭代**: 3. **分步(非眼画布 / 根图)**:可贴边、分层钉 y;**已定型眼 sink 除外**——只允许门控精修 / 手拖
- `polish_crossings` 减少交叉(可能破坏分层)
- 若分层被破坏,**保留 x 坐标,恢复 y 坐标**重新分层
- `clear_edge_hits` 消除贴边
- `fix_overlaps` 修复重叠
### 冲突处理原则 ### 冲突处理
| 冲突场景 | 处理方式 | | 冲突场景 | 处理方式 |
|---------|---------| |---------|---------|
| 交叉 vs 结构 | **结构优先**,接受少量跨层交叉 | | 交叉 vs 结构 | **结构优先**(最大眼 / 分层) |
| 算法 vs 手动 | **手动优先**,算法辅助 | | 算法 vs 手动 | **手动优先** |
| polish 破坏分层 | 保留 x 坐标,恢复 y 坐标分层 | | polish 拆眼 | **禁止**对 dual 眼走 straighten 主路径 |
| 算法 vs 金标观感 | 交叉可赢;紧凑/正交/贴边交给手拖 |
### 关键经验
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。
--- ---
@ -128,27 +308,20 @@ sinkTopologyDualUnits({
| action | 用途 | | action | 用途 |
|--------|------| |--------|------|
| `layout` | 小图配方:`compact` / `corridor` / `rings` / `unstick` | | `layout` | 小图 / 剩余块:`compact` / `corridor` / `rings` / `unstick` |
| `layout_dual_unit` | 双门户眼形;单元内交叉≠0 拒绝 | | `layout_dual_unit` | **仅**重布已沉的那一只眼;椭圆弧带 + 空心眼 |
| `move_nodes` / `sink_nodes` | 指定 ids 双向迁移;`park` 块扫 | | `move_nodes` / `sink_nodes` | **后续下沉主路径**;`park` 块扫 |
| `orbit_sweep` | 压交叉;`round` + 大 `max_jump`(约 1800–2800);`objective=total` 综合 crossing+edge_clearance;`y_min/y_max` 分层约束 | | `orbit_sweep` | **眼后精修主工具**:`until_limit` 或 `node_id`;`objective=crossing\|total` |
| `polish_crossings` | 一键:straighten→press→untangle | | `clear_edge_hits` | 眼 sink **可**(须 `portal_ids`;交叉不升) |
| `clear_edge_hits` | 网元贴非关联边时正交弹开 | | `pull_far_chains` | 眼 sink **可**:朝门户中点收远走廊/孤立点 |
| `fix_overlaps` / `resolve_overlaps` | 只拉开重叠 | | `align_reference` | **非主路径**:仅同网同成员 A/B 调试;跨网禁用 |
| `untangle` | 贪心降交叉;默认可冻门户 | | `compact_bbox` | 眼 sink **可**:farthest-K 收 bbox |
| `straighten_channels` | 拉直 deg≤2 走廊 | | `level_bands` | 仅根图/未定型画布;巨图在 dual 一次之后钉根 |
| `polish_crossings` | **眼 sink 禁用** |
| `fix_overlaps` / `untangle` / `straighten_channels` | **眼 sink 禁用** |
| `job_status` / `job_cancel` | 后台 job | | `job_status` / `job_cancel` | 后台 job |
阶段2是多目标循环,非单向链: 眼 sink 精修 = 门控序列 + 手拖补观感;不要排全局流水线,也不要在 stall 后继续发明新全局动作。
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` 自行判断拖谁。
--- ---
@ -156,20 +329,16 @@ sinkTopologyDualUnits({
| 块 | 看什么 | | 块 | 看什么 |
|----|--------| |----|--------|
| `overlap` | 硬零(权重 0.24,硬门控) | | `overlap` | 硬零(权重 0.24) |
| `crossing` | crossings/cpl;`top_nodes` / `top_edges`(权重 0.18) | | `crossing` | crossings/cpl;`top_nodes` / `top_edges` |
| `rings` | 最小环被穿(权重 0.10) | | `sparsity` / `edge_axis` / `edge_clearance` | 对照人工:紧凑、正交、贴边(算法常弱于此) |
| `edge_clearance` | 贴边 → clear_edge_hits;**权重 0.08 但视觉影响 ≥ crossing,优先处理** | | `rings` / `chains` | 环被穿、走廊折角 |
| `edge_axis` | 斜边过多 → straighten_channels / 手拖归轴(权重 0.06) | | `verdict.total` | ≈70 可交付(ov=0);结构可读优先于刷分 |
| `verdict.total` | ≈70 可交付(ov=0);**总分升降为准,勿只盯 crossing** | | `score_profile` | `auto`(默认)/ `default` / `eye`;大稀疏斜边画布自动走 eye |
### 评分优化快速方法 **评分修订(相对旧权)**:util 甜区下限 0.08;贴边分=节点命中∪hits/link;rings 降权;eye 再降 axis、抬 util/clearance;`compactness` 仅诊断不加权。
1. **消除 overlap** → 硬门控项,必须为 0
2. **减少 crossings** → polish_crossings 大幅降低
3. **处理 edge_clearance** → clear_edge_hits 消除贴边
4. **结构检查** → 分层清晰 > 交叉最少
图标 25px;推荐中心距 Δx≥200、Δy≥170。交叉 = 无向 NE↔NE 真交叉(共端点不算)。 图标 25px;推荐中心距 Δx≥200、Δy≥170。
--- ---
@ -178,59 +347,25 @@ sinkTopologyDualUnits({
| 工具 | 作用 | | 工具 | 作用 |
|------|------| |------|------|
| `getTopologyTree` / `getTopologyView` | 树与画布 | | `getTopologyTree` / `getTopologyView` | 树与画布 |
| `createTopologyFolder` | 新建根/区域(返回 view_id) | | `createTopologyFolder` | 新建根/区域 |
| `add` / `remove` / `updateTopologyViewPositions` | 成员与手拖 | | `add` / `remove` / `updateTopologyViewPositions` | 成员与手拖 |
| `sinkTopologyDualUnits` | dual_units 分批沉入 | | `sinkTopologyDualUnits` | **仅一次**:最大核心眼 → sink |
| `copyTopologyViewNodes` | 克隆沙箱 | | `suggestSinkHubs` | **后续下沉批次**(含 orphan_batch) |
| `move_nodes`(经 layoutTopologyView) | **后续下沉** |
| `projectTopologyNeighbors` | 投影邻居 | | `projectTopologyNeighbors` | 投影邻居 |
| `queryTopologyFabricNodes` | 库存(summary\|list\|search) | | `copyTopologyViewNodes` | 克隆沙箱 |
| `queryTopologyFabricNodes` | 库存 |
| neighborhood / edges | 邻接 | | neighborhood / edges | 邻接 |
| `layoutTopologyView` | 上表 action | | `layoutTopologyView` | 上表 action |
| `analyzeTopologyViewLayout` | structure + 验收 | | `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。 1. 本仓 MCP 用 `PYTHONPATH=…/src`(见用户 `~/.cursor/mcp.json`),改源码后不必为加载而 pip install。
2. **必须重启** stdio 进程;`catalog` 含 `rev`(当前 `NETX_MCP_REV`)。 2. **必须重启** stdio 进程(杀旧 `python -m netx_topology_mcp` 后由 Cursor 拉起;或 MCP 面板禁用/启用)。
3. `layoutTopologyView(catalog=true)` 核对 action/recipe 清单。 3. `layoutTopologyView(catalog=true)` 核对:`version` / `rev`(`NETX_MCP_REV`)+ action 含 `pull_far_chains` / `compact_bbox`。
4. 机器上常残留多份旧 MCP 进程 → 精修前 catalog 若仍缺 action,**杀光再启**;勿用旧进程半截交付,勿改走 HTTP/临时 py。
拓扑页开「实时同步」可看落笔。勿用告警/CLI MCP 写拓扑。 拓扑页开「实时同步」可看落笔。勿用告警/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) | | `getTopologyTree` | 「根 / 根图 / 子区域」树 + views + `ne_count`(找画布用这个,勿再 listViews) |
| `getTopologyView` | 单图(节点+边+坐标);`detail=summary\|full` | | `getTopologyView` | 单图(节点+边+坐标);`detail=summary\|full` |
| `queryTopologyFabricNodes` | Fabric 库存:`mode=summary\|list\|search`(有 `q` 默认 search;list 支持 `region_folder_id`) | | `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 对,非端口) | | `queryTopologyNeighborhood` | 邻域:compact nodes + `links[]`(NE 对,非端口) |
| `queryTopologyEdges` | 默认 **adjacency**:`links[{a,b,link_count}]`;画布一对网元一条线。`detail=ports` 才给端口行 | | `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) ### 写(只动画布,不污染 Fabric)
@ -98,7 +99,7 @@ Cursor:改包后若 Sync 工具数不对,请 **禁用/启用** `netx-topolog
| `copyTopologyViewNodes` | 一键克隆画布成员+坐标到另一画布(`clear_target` 可选);源画布不动,测沙箱用 | | `copyTopologyViewNodes` | 一键克隆画布成员+坐标到另一画布(`clear_target` 可选);源画布不动,测沙箱用 |
| `layoutTopologyView` `move_nodes` | 指定 `fabric_node_ids` 从 `source_view_id`→`view_id`(默认同移出源);对调两 view 回迁;别名 `sink_nodes`;`park=true` 扫角停靠 | | `layoutTopologyView` `move_nodes` | 指定 `fabric_node_ids` 从 `source_view_id`→`view_id`(默认同移出源);对调两 view 回迁;别名 `sink_nodes`;`park=true` 扫角停靠 |
| `updateTopologyViewPositions` | **优先** `layout=grid\|offset\|stack` + 筛选,API 自己挪点;`positions[]` 仅少量微调 | | `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` | | `projectTopologyNeighbors` | 投影**已有** LLDP 邻居到画布;区域画布务必传 `region_folder_id`,读 `out_of_region_skipped` |
**推荐流水线:** `getTopologyTree` →(可选)`createTopologyFolder` 取 `view_id` → `addTopologyViewNodes` →(可选)邻居投影 / 布局。 **推荐流水线:** `getTopologyTree` →(可选)`createTopologyFolder` 取 `view_id` → `addTopologyViewNodes` →(可选)邻居投影 / 布局。
@ -120,7 +121,7 @@ Cursor:改包后若 Sync 工具数不对,请 **禁用/启用** `netx-topolog
| 包 | server_id | 职责 | | 包 | server_id | 职责 |
|----|-----------|------| |----|-----------|------|
| `netx-mcp` | `netx` | 告警、UME、托管网元 CLI(**13** 工具) | | `netx-mcp` | `netx` | 告警、UME、托管网元 CLI(**13** 工具) |
| `netx-topology-mcp` | `netx-topology` | 拓扑画布 / Fabric 只读 + 安全画图(**14** 工具) | | `netx-topology-mcp` | `netx-topology` | 拓扑画布 / Fabric 只读 + 分类打标 + 安全画图(**15** 工具) |
`queryTopologyEdges` 已从 `netx-mcp` **迁出**到本包,避免重复。 `queryTopologyEdges` 已从 `netx-mcp` **迁出**到本包,避免重复。

View file

@ -31,6 +31,13 @@ def shutdown_runtime(*, reason: str = "lifespan") -> None:
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
_log.exception("stop_lldp_collect_scheduler failed") _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: try:
from .port_traffic_scheduler import stop_port_traffic_scheduler from .port_traffic_scheduler import stop_port_traffic_scheduler

View file

@ -115,11 +115,17 @@ def run_api_startup() -> None:
if cfg_resumed: if cfg_resumed:
_log.info("startup: resumed %s config_sync task(s) from interrupted cycle", cfg_resumed) _log.info("startup: resumed %s config_sync task(s) from interrupted cycle", cfg_resumed)
try: 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 from .lldp_collect_service import ensure_policy as ensure_lldp_collect_policy
ensure_lldp_collect_policy(db) 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: 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) pt_cleared = recover_port_traffic_on_startup(db)
if pt_cleared: if pt_cleared:
_log.info("startup: cleared %s port_traffic stuck collect_running flag(s)", 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: else:
_log.info( _log.info(
"startup: inline schedulers disabled — run `python -m netx_api.worker` for " "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() 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.ended_at = finish_at
job.last_run_at = job.ended_at or finish_at job.last_run_at = job.ended_at or finish_at
db.commit() 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: 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 fastapi.responses import FileResponse, Response
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .collection_policy import get_policy, update_policy
from .collection_schemas import CollectionJobCreate, CollectionPolicyUpdate
from .collection_service import ( from .collection_service import (
build_collection_job_zip, build_collection_job_zip,
create_and_start_from_policy,
create_collection, create_collection,
delete_collection_job, delete_collection_job,
get_collection_dashboard, get_collection_dashboard,
@ -19,7 +22,6 @@ from .collection_service import (
start_collection_job, start_collection_job,
retry_failed_collection_job, retry_failed_collection_job,
) )
from .collection_schemas import CollectionJobCreate
from .db import get_db from .db import get_db
from .models import NeCollectionRun from .models import NeCollectionRun
from .ne_collect_runner import dispatch_collection_runs 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() 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("") @router.post("")
def api_create_collection(body: CollectionJobCreate, db: Session = Depends(get_db)): def api_create_collection(body: CollectionJobCreate, db: Session = Depends(get_db)):
return create_collection(db, body).model_dump() 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( def api_list_collections(
page: int = Query(default=1, ge=1), page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100), page_size: int = Query(default=20, ge=1, le=100),
status: str = Query(default=""),
keyword: str = Query(default=""),
db: Session = Depends(get_db), 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") @router.get("/runs/{run_id}/download")

View file

@ -5,6 +5,34 @@ from datetime import datetime
from pydantic import BaseModel, Field 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): class CollectionJobCreate(BaseModel):
title: str = "" title: str = ""
commands: str = Field(min_length=1) commands: str = Field(min_length=1)
@ -31,6 +59,7 @@ class CollectionJobOut(BaseModel):
id: str id: str
title: str title: str
commands: str commands: str
trigger_mode: str = "manual"
status: str status: str
ne_count: int ne_count: int
success_count: int success_count: int
@ -47,6 +76,7 @@ class CollectionJobSummary(BaseModel):
id: str id: str
title: str title: str
status: str status: str
trigger_mode: str = "manual"
ne_count: int ne_count: int
success_count: int success_count: int
fail_count: int fail_count: int
@ -61,3 +91,5 @@ class CollectionDashboardOut(BaseModel):
active_count: int = 0 active_count: int = 0
running_job: CollectionJobSummary | None = None running_job: CollectionJobSummary | None = None
last_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_progress,
_sync_job_counts, _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 ( from .collection_schemas import (
CollectionDashboardOut, CollectionDashboardOut,
CollectionJobCreate, CollectionJobCreate,
@ -87,6 +95,7 @@ def job_to_out(row: NeCollectionJob, *, output_count: int | None = None) -> Coll
id=str(row.id), id=str(row.id),
title=str(row.title or ""), title=str(row.title or ""),
commands=str(row.commands or ""), commands=str(row.commands or ""),
trigger_mode=str(getattr(row, "trigger_mode", None) or "manual"),
status=str(row.status or "pending"), status=str(row.status or "pending"),
ne_count=int(row.ne_count or 0), ne_count=int(row.ne_count or 0),
success_count=int(row.success_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), id=str(row.id),
title=str(row.title or "").strip() or str(row.id)[:8], title=str(row.title or "").strip() or str(row.id)[:8],
status=str(row.status or "pending"), status=str(row.status or "pending"),
trigger_mode=str(getattr(row, "trigger_mode", None) or "manual"),
ne_count=int(row.ne_count or 0), ne_count=int(row.ne_count or 0),
success_count=int(row.success_count or 0), success_count=int(row.success_count or 0),
fail_count=int(row.fail_count or 0), fail_count=int(row.fail_count or 0),
@ -162,11 +172,14 @@ def get_collection_dashboard(db: Session) -> CollectionDashboardOut:
or 0 or 0
) )
last = last_finished_collection_job(db) last = last_finished_collection_job(db)
policy = ensure_policy(db)
return CollectionDashboardOut( return CollectionDashboardOut(
job_count=job_count, job_count=job_count,
active_count=active_count, active_count=active_count,
running_job=job_to_summary(running), running_job=job_to_summary(running),
last_job=job_to_summary(last), 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( job = NeCollectionJob(
title=str(body.title or "").strip() or f"collect-{now.strftime('%Y%m%d-%H%M%S')}", title=str(body.title or "").strip() or f"collect-{now.strftime('%Y%m%d-%H%M%S')}",
commands="\n".join(commands), commands="\n".join(commands),
trigger_mode="manual",
status="pending", status="pending",
ne_count=len(targets), ne_count=len(targets),
created_at=now, created_at=now,
@ -342,8 +356,81 @@ def create_collection(db: Session, body: CollectionJobCreate) -> CollectionJobOu
return job_to_out(job, output_count=0) 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) 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()) total = int(stmt.count())
rows = stmt.order_by(NeCollectionJob.created_at.desc()).offset((page - 1) * page_size).limit(page_size).all() rows = stmt.order_by(NeCollectionJob.created_at.desc()).offset((page - 1) * page_size).limit(page_size).all()
for row in rows: for row in rows:

View file

@ -81,6 +81,10 @@ class Settings(BaseSettings):
ne_collect_pending_stale_sec: int = 180 ne_collect_pending_stale_sec: int = 180
ne_collect_run_timeout_cap_sec: int = 600 ne_collect_run_timeout_cap_sec: int = 600
ne_collection_data_dir: str = "data/ne_collections" 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 (periodic running-config backup into DB)
config_sync_scheduler_enabled: bool = True config_sync_scheduler_enabled: bool = True
config_sync_scheduler_tick_sec: int = 60 config_sync_scheduler_tick_sec: int = 60

View file

@ -6,7 +6,7 @@ from typing import Any
from uuid import uuid4 from uuid import uuid4
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy import func from sqlalchemy import func, or_
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .cli_resolve import cli_profile_ready from .cli_resolve import cli_profile_ready
@ -252,8 +252,29 @@ def create_cycle(db: Session, body: ConfigSyncCycleCreate) -> ConfigSyncCycleOut
return cycle_to_out(cycle) return cycle_to_out(cycle)
def list_cycles(db: Session, *, page: int, page_size: int) -> dict[str, Any]: def list_cycles(
q = db.query(ConfigSyncCycle).order_by(ConfigSyncCycle.created_at.desc()) 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()) total = int(q.count())
rows = q.offset((page - 1) * page_size).limit(page_size).all() 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]} 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( def api_list_cycles(
page: int = Query(default=1, ge=1), page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100), page_size: int = Query(default=20, ge=1, le=100),
status: str = Query(default=""),
keyword: str = Query(default=""),
db: Session = Depends(get_db), 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") @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( def api_list_jobs(
page: int = Query(default=1, ge=1), page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100), page_size: int = Query(default=20, ge=1, le=100),
status: str = Query(default=""),
keyword: str = Query(default=""),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict[str, Any]: ) -> 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}") @router.get("/jobs/{job_id}")
@ -77,6 +79,15 @@ def api_get_job(
job_id: str, job_id: str,
page: int = Query(default=1, ge=1), page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100), page_size: int = Query(default=20, ge=1, le=100),
status: str = Query(default=""),
keyword: str = Query(default=""),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict[str, Any]: ) -> 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 = max(1, int(page or 1))
page_size = max(1, min(100, int(page_size or 20))) 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()) total = int(q.count())
rows = q.offset((page - 1) * page_size).limit(page_size).all() rows = q.offset((page - 1) * page_size).limit(page_size).all()
outcomes = _job_outcome_map(db, [r.id for r in rows]) 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( 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: ) -> 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 ( for name, key in (
("config_sync", "netx_config_sync_scheduler_running"), ("config_sync", "netx_config_sync_scheduler_running"),
("lldp_collect", "netx_lldp_collect_scheduler_running"), ("lldp_collect", "netx_lldp_collect_scheduler_running"),
("ne_collect", "netx_ne_collect_scheduler_running"),
("port_traffic", "netx_port_traffic_scheduler_running"), ("port_traffic", "netx_port_traffic_scheduler_running"),
("fabric_reconcile", "netx_fabric_reconcile_scheduler_running"), ("fabric_reconcile", "netx_fabric_reconcile_scheduler_running"),
): ):

View file

@ -20,6 +20,7 @@ from .managed_ne import (
CliConnectProfile, CliConnectProfile,
ManagedNE, ManagedNE,
NeCollectionJob, NeCollectionJob,
NeCollectionPolicy,
NeCollectionRun, NeCollectionRun,
UmeCliOverride, UmeCliOverride,
) )
@ -82,6 +83,7 @@ __all__ = [
"CliConnectProfile", "CliConnectProfile",
"UmeCliOverride", "UmeCliOverride",
"NeCollectionJob", "NeCollectionJob",
"NeCollectionPolicy",
"NeCollectionRun", "NeCollectionRun",
"TopoFabricNode", "TopoFabricNode",
"TopoClassifyRule", "TopoClassifyRule",

View file

@ -98,6 +98,28 @@ class UmeCliOverride(Base):
updated_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive, index=True) 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): class NeCollectionJob(Base):
"""Batch CLI collection job over managed NEs.""" """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) id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: uuid4().hex)
title: Mapped[str] = mapped_column(String(256), default="") title: Mapped[str] = mapped_column(String(256), default="")
commands: Mapped[str] = mapped_column(Text, 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) status: Mapped[str] = mapped_column(String(32), default="pending", index=True)
ne_count: Mapped[int] = mapped_column(Integer, default=0) ne_count: Mapped[int] = mapped_column(Integer, default=0)
success_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) ip: Mapped[str] = mapped_column(String(128), default="", index=True)
vendor: Mapped[str] = mapped_column(String(64), default="") vendor: Mapped[str] = mapped_column(String(64), default="")
device_type: 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) role: Mapped[str] = mapped_column(String(32), default="", index=True)
region_folder_id: Mapped[str | None] = mapped_column(String(64), nullable=True, 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="") role_source: Mapped[str] = mapped_column(String(16), default="")
region_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. # 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" __tablename__ = "topo_classify_rule"
id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: uuid4().hex) id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: uuid4().hex)
# role | region # level | region (legacy scope "role" accepted as alias of level)
scope: Mapped[str] = mapped_column(String(16), default="role", index=True) scope: Mapped[str] = mapped_column(String(16), default="level", index=True)
name: Mapped[str] = mapped_column(String(256), default="") name: Mapped[str] = mapped_column(String(256), default="")
pattern: Mapped[str] = mapped_column(String(512), default="") pattern: Mapped[str] = mapped_column(String(512), default="")
# name | ip | name_ip # name | ip | name_ip
match_field: Mapped[str] = mapped_column(String(32), default="name") match_field: Mapped[str] = mapped_column(String(32), default="name")
priority: Mapped[int] = mapped_column(Integer, default=100, index=True) priority: Mapped[int] = mapped_column(Integer, default=100, index=True)
enabled: Mapped[bool] = mapped_column(Boolean, default=True, 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) payload: Mapped[dict] = mapped_column(_JsonType, default=dict)
remark: Mapped[str] = mapped_column(String(512), default="") remark: Mapped[str] = mapped_column(String(512), default="")
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive) 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 uuid import uuid4
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy import or_
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .cli_creds import require_cli_creds_ready from .cli_creds import require_cli_creds_ready
@ -51,8 +52,30 @@ from .port_traffic_schemas import (
_log = logging.getLogger("netx.port_traffic.service") _log = logging.getLogger("netx.port_traffic.service")
def list_devices(db: Session, *, page: int = 1, page_size: int = 20) -> dict[str, Any]: def list_devices(
q = db.query(PortTrafficDevice).order_by(PortTrafficDevice.ne_name.asc(), PortTrafficDevice.ne_ip.asc()) 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() total = q.count()
rows = q.offset((page - 1) * page_size).limit(page_size).all() rows = q.offset((page - 1) * page_size).limit(page_size).all()
return { return {

View file

@ -163,9 +163,11 @@ def api_delete_board(
def api_list_devices( def api_list_devices(
page: int = Query(default=1, ge=1), page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100), page_size: int = Query(default=20, ge=1, le=100),
status: str = Query(default=""),
keyword: str = Query(default=""),
db: Session = Depends(get_db), 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") @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() out["lldp_collect"] = lldp_collect_scheduler_status()
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
out["lldp_collect"] = {"running": False, "error": "unavailable"} 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: try:
from .port_traffic_scheduler import port_traffic_scheduler_status 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_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 world_y DOUBLE PRECISION")
_run_sql(conn, "ALTER TABLE topo_fabric_node ADD COLUMN IF NOT EXISTS level DOUBLE PRECISION")
_run_sql( _run_sql(
conn, conn,
"CREATE INDEX IF NOT EXISTS ix_topo_fabric_node_world_x ON topo_fabric_node (world_x)", "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, conn,
"CREATE INDEX IF NOT EXISTS ix_topo_fabric_node_world_y ON topo_fabric_node (world_y)", "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 # UME link ifnames — required by topology sync/apply; missing when DB predates the columns
# or Alembic was stamped head without running domain patches. # or Alembic was stamped head without running domain patches.
_run_sql( _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", "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) " "UPDATE ne_collection_job SET last_run_at = COALESCE(ended_at, started_at, created_at) "
"WHERE last_run_at IS NULL", "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_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 stroke_width INTEGER DEFAULT 0",
"ALTER TABLE topology_edge ADD COLUMN IF NOT EXISTS line_style VARCHAR(16) DEFAULT ''", "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.""" """Topology classify preview/apply and fabric node tagging."""
from __future__ import annotations from __future__ import annotations
import re
from typing import Any from typing import Any
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .models import TopoClassifyRule, TopoFabricNode, TopoFolder from .models import TopoFabricNode, TopoFolder
from .topology_classify_common import ( from .topology_classify_common import (
_MATCH_FIELDS, _MATCH_FIELDS,
_ROLE_VALUES,
_compile_pattern, _compile_pattern,
_enabled_rules, _enabled_rules,
_ensure_region_by_name,
_match_text, _match_text,
_resolve_level_hit,
_resolve_region_hit, _resolve_region_hit,
_resolve_role_hit,
_utcnow, _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 ( from .topology_schemas import (
ClassifyApplyOut, ClassifyApplyOut,
ClassifyPreviewOut, ClassifyPreviewOut,
@ -31,31 +29,33 @@ from .topology_schemas import (
FabricNodeTagPatch, FabricNodeTagPatch,
) )
def preview_classify(db: Session, *, sample_limit: int = 20) -> ClassifyPreviewOut: 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") region_rules = _enabled_rules(db, "region")
nodes = db.query(TopoFabricNode).order_by(TopoFabricNode.name.asc()).all() 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 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]] = [] region_samples: list[dict[str, Any]] = []
unmatched_samples: list[dict[str, Any]] = [] unmatched_samples: list[dict[str, Any]] = []
for n in nodes: for n in nodes:
role, _rid, multi_r = _resolve_role_hit(n, role_rules) level, _rid, multi_r = _resolve_level_hit(n, level_rules)
if role is None: if level is None:
role_unmatched += 1 level_unmatched += 1
else: else:
role_matched += 1 level_matched += 1
if multi_r: if multi_r:
role_conflict += 1 level_conflict += 1
if len(role_samples) < sample_limit: if len(level_samples) < sample_limit:
role_samples.append( level_samples.append(
{ {
"fabric_node_id": n.id, "fabric_node_id": n.id,
"name": n.name, "name": n.name,
"ip": n.ip, "ip": n.ip,
"role": role, "level": level,
"role": level_to_role(level),
"multi_hit": multi_r, "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( unmatched_samples.append(
{"fabric_node_id": n.id, "name": n.name, "ip": n.ip, "vendor": n.vendor} {"fabric_node_id": n.id, "name": n.name, "ip": n.ip, "vendor": n.vendor}
) )
return ClassifyPreviewOut( return ClassifyPreviewOut(
total_nodes=len(nodes), total_nodes=len(nodes),
role_matched=role_matched, level_matched=level_matched,
role_unmatched=role_unmatched, level_unmatched=level_unmatched,
role_conflicts=role_conflict, level_conflicts=level_conflict,
role_matched=level_matched,
role_unmatched=level_unmatched,
role_conflicts=level_conflict,
region_matched=region_matched, region_matched=region_matched,
region_unmatched=region_unmatched, region_unmatched=region_unmatched,
region_conflicts=region_conflict, region_conflicts=region_conflict,
role_samples=role_samples, level_samples=level_samples,
role_samples=level_samples,
region_samples=region_samples, region_samples=region_samples,
unmatched_samples=unmatched_samples, unmatched_samples=unmatched_samples,
) )
@ -105,27 +109,22 @@ def apply_classify(
skip_manual: bool = True, skip_manual: bool = True,
fill_empty_only: bool = False, fill_empty_only: bool = False,
) -> ClassifyApplyOut: ) -> ClassifyApplyOut:
role_rules = _enabled_rules(db, "role") level_rules = _enabled_rules(db, "level")
region_rules = _enabled_rules(db, "region") region_rules = _enabled_rules(db, "region")
nodes = db.query(TopoFabricNode).all() nodes = db.query(TopoFabricNode).all()
role_updated = region_updated = skipped_manual = 0 level_updated = region_updated = skipped_manual = 0
for n in nodes: for n in nodes:
role, _, _ = _resolve_role_hit(n, role_rules) level, _, _ = _resolve_level_hit(n, level_rules)
if role is not None: if level is not None:
if skip_manual and str(n.role_source or "") == "manual": if skip_manual and str(n.role_source or "") == "manual":
skipped_manual += 1 skipped_manual += 1
elif fill_empty_only and str(n.role or "").strip(): elif fill_empty_only and n.level is not None:
pass pass
else: else:
n.role = role apply_level_fields(n, level, source="rule")
n.role_source = "rule"
n.updated_at = _utcnow() n.updated_at = _utcnow()
role_updated += 1 level_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()
region_id, _, _ = _resolve_region_hit(db, n, region_rules, create_missing=True) 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:"): if region_id is not None and not str(region_id).startswith("new:"):
@ -141,7 +140,8 @@ def apply_classify(
db.commit() db.commit()
return ClassifyApplyOut( return ClassifyApplyOut(
role_updated=role_updated, level_updated=level_updated,
role_updated=level_updated,
region_updated=region_updated, region_updated=region_updated,
skipped_manual=skipped_manual, skipped_manual=skipped_manual,
total_nodes=len(nodes), total_nodes=len(nodes),
@ -159,17 +159,19 @@ def list_unmatched(
q = db.query(TopoFabricNode) q = db.query(TopoFabricNode)
k = str(kind or "any").strip().lower() 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_( region_miss = or_(
TopoFabricNode.region_folder_id.is_(None), TopoFabricNode.region_folder_id.is_(None),
TopoFabricNode.region_folder_id == "", TopoFabricNode.region_folder_id == "",
) )
if k == "role": if k == "level":
q = q.filter(role_miss) q = q.filter(level_miss)
elif k == "region": elif k == "region":
q = q.filter(region_miss) q = q.filter(region_miss)
else: else:
q = q.filter(or_(role_miss, region_miss)) q = q.filter(or_(level_miss, region_miss))
total = q.count() total = q.count()
rows = ( rows = (
q.order_by(TopoFabricNode.name.asc()) q.order_by(TopoFabricNode.name.asc())
@ -195,14 +197,18 @@ def patch_fabric_node_tags(
n = db.get(TopoFabricNode, fabric_node_id) n = db.get(TopoFabricNode, fabric_node_id)
if n is None: if n is None:
raise HTTPException(status_code=404, detail="fabric_node_not_found") raise HTTPException(status_code=404, detail="fabric_node_not_found")
if body.role is not None: data = body.model_dump(exclude_unset=True)
role = str(body.role or "").strip().lower() if "level" in data or "role" in data:
if role and role not in _ROLE_VALUES: try:
raise HTTPException(status_code=400, detail="role_invalid") if "level" in data:
n.role = role lv = normalize_level(data.get("level"))
n.role_source = "manual" else:
if body.region_folder_id is not None: lv = role_to_level(data.get("role"))
fid = str(body.region_folder_id or "").strip() 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: if fid:
folder = db.get(TopoFolder, fid) folder = db.get(TopoFolder, fid)
if folder is None or str(folder.kind or "") != "region": 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, "fabric_node_id": n.id,
"name": n.name, "name": n.name,
"ip": n.ip, "ip": n.ip,
"level": n.level,
"role": n.role or "", "role": n.role or "",
"region_folder_id": n.region_folder_id or "", "region_folder_id": n.region_folder_id or "",
"link_status": fabric_link_status(n), "link_status": fabric_link_status(n),
@ -263,19 +270,23 @@ def match_fabric_nodes(db: Session, body: FabricNodesMatchRequest) -> FabricNode
def bulk_tag_fabric_nodes( def bulk_tag_fabric_nodes(
db: Session, body: FabricNodesBulkTagRequest db: Session, body: FabricNodesBulkTagRequest
) -> FabricNodesBulkTagOut: ) -> FabricNodesBulkTagOut:
"""Assign role/region after user confirms a regex or explicit selection.""" """Assign level/region after user confirms a regex or explicit selection."""
if body.role is None and body.region_folder_id is None: data = body.model_dump(exclude_unset=True)
raise HTTPException(status_code=400, detail="role_or_region_required") 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 region_v: str | None | object = Ellipsis
if body.role is not None: if "region_folder_id" in data:
role_v = str(body.role or "").strip().lower() region_v = str(data.get("region_folder_id") or "").strip()
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()
if region_v: if region_v:
folder = db.get(TopoFolder, region_v) folder = db.get(TopoFolder, region_v)
if folder is None or str(folder.kind or "") != "region": if folder is None or str(folder.kind or "") != "region":
@ -283,6 +294,9 @@ def bulk_tag_fabric_nodes(
else: else:
region_v = "" 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()] ids = [str(x).strip() for x in (body.fabric_node_ids or []) if str(x).strip()]
if str(body.pattern or "").strip(): if str(body.pattern or "").strip():
matched_nodes = _iter_regex_matches( matched_nodes = _iter_regex_matches(
@ -298,11 +312,18 @@ def bulk_tag_fabric_nodes(
else: else:
raise HTTPException(status_code=400, detail="ids_or_pattern_required") 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 = [ samples = [
{ {
"fabric_node_id": n.id, "fabric_node_id": n.id,
"name": n.name, "name": n.name,
"ip": n.ip, "ip": n.ip,
"level": n.level,
"role": n.role or "", "role": n.role or "",
"region_folder_id": n.region_folder_id or "", "region_folder_id": n.region_folder_id or "",
} }
@ -313,19 +334,19 @@ def bulk_tag_fabric_nodes(
dry_run=True, dry_run=True,
matched=len(matched_nodes), matched=len(matched_nodes),
updated=0, updated=0,
role=role_v, level=None if level_v is Ellipsis else level_v, # type: ignore[arg-type]
region_folder_id=region_v, role=role_alias,
region_folder_id=None if region_v is Ellipsis else (region_v or None), # type: ignore[arg-type]
samples=samples, samples=samples,
) )
now = _utcnow() now = _utcnow()
updated = 0 updated = 0
for n in matched_nodes: for n in matched_nodes:
if role_v is not None: if level_v is not Ellipsis:
n.role = role_v apply_level_fields(n, level_v, source="manual") # type: ignore[arg-type]
n.role_source = "manual" if region_v is not Ellipsis:
if region_v is not None: n.region_folder_id = region_v or None # type: ignore[operator]
n.region_folder_id = region_v or None
n.region_source = "manual" n.region_source = "manual"
n.updated_at = now n.updated_at = now
updated += 1 updated += 1
@ -334,8 +355,9 @@ def bulk_tag_fabric_nodes(
dry_run=False, dry_run=False,
matched=len(matched_nodes), matched=len(matched_nodes),
updated=updated, updated=updated,
role=role_v, level=None if level_v is Ellipsis else level_v, # type: ignore[arg-type]
region_folder_id=region_v, role=role_alias,
region_folder_id=None if region_v is Ellipsis else (region_v or None), # type: ignore[arg-type]
samples=samples, samples=samples,
) )
@ -343,5 +365,3 @@ def bulk_tag_fabric_nodes(
def apply_classify_empty_only(db: Session) -> ClassifyApplyOut: def apply_classify_empty_only(db: Session) -> ClassifyApplyOut:
"""Incremental classify for newly synced nodes (fill empty tags only).""" """Incremental classify for newly synced nodes (fill empty tags only)."""
return apply_classify(db, skip_manual=True, fill_empty_only=True) 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 .models import TopoClassifyRule, TopoFabricNode, TopoFolder
from .timeutil import utcnow_naive 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 ( from .topology_schemas import (
ClassifyRuleOut, ClassifyRuleOut,
TopologyFolderCreate, TopologyFolderCreate,
) )
_MAX_PATTERN_LEN = 512 _MAX_PATTERN_LEN = 512
_ROLE_VALUES = VIEW_ROLES | {"unknown"}
_MATCH_FIELDS = frozenset({"name", "ip", "name_ip"}) _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"}) _SLICE_TEMPLATES = frozenset({"core_only", "core_agg", "agg_access"})
_ROLE_VALUES = frozenset(LEVEL_PRESETS) | {"unknown", "edge", ""}
def _utcnow() -> datetime: def _utcnow() -> datetime:
return utcnow_naive() 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]: def _compile_pattern(pattern: str) -> re.Pattern[str]:
p = str(pattern or "").strip() p = str(pattern or "").strip()
if not p: if not p:
@ -51,9 +58,10 @@ def _match_text(node: TopoFabricNode, match_field: str) -> str:
def _rule_out(row: TopoClassifyRule) -> ClassifyRuleOut: def _rule_out(row: TopoClassifyRule) -> ClassifyRuleOut:
scope = _normalize_scope(str(row.scope or "level"))
return ClassifyRuleOut( return ClassifyRuleOut(
id=row.id, id=row.id,
scope=str(row.scope or "role"), scope=scope,
name=str(row.name or ""), name=str(row.name or ""),
pattern=str(row.pattern or ""), pattern=str(row.pattern or ""),
match_field=str(row.match_field or "name"), 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]: def _validate_payload(scope: str, payload: dict[str, Any]) -> dict[str, Any]:
out = dict(payload or {}) out = dict(payload or {})
if scope == "role": scope_n = _normalize_scope(scope)
role = normalize_view_role(str(out.get("role") or "")) if scope_n == "level":
if str(out.get("role") or "").strip().lower() not in VIEW_ROLES: if "level" in out and out.get("level") is not None:
raise HTTPException(status_code=400, detail="role_payload_invalid") try:
return {"role": role} 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(): if "folder_id" in out and str(out.get("folder_id") or "").strip():
return {"folder_id": str(out["folder_id"]).strip()} return {"folder_id": str(out["folder_id"]).strip()}
if "region_name_from_group" in out: 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]]]: 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 = ( rows = (
db.query(TopoClassifyRule) 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()) .order_by(TopoClassifyRule.priority.asc(), TopoClassifyRule.name.asc())
.all() .all()
) )
@ -122,23 +146,50 @@ def _ensure_region_by_name(db: Session, name: str) -> TopoFolder:
return folder 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]]] node: TopoFabricNode, rules: list[tuple[TopoClassifyRule, re.Pattern[str]]]
) -> tuple[str | None, str | None, bool]: ) -> tuple[float | None, str | None, bool]:
"""Return (role, rule_id, multi_hit).""" """Return (level, rule_id, multi_hit)."""
hits: list[tuple[str, str]] = [] hits: list[tuple[float, str]] = []
for rule, cre in rules: for rule, cre in rules:
text = _match_text(node, rule.match_field) text = _match_text(node, rule.match_field)
if not text: if not text:
continue continue
if cre.search(text): if cre.search(text):
role = normalize_view_role(str((rule.payload or {}).get("role") or "")) lv = _payload_level(rule.payload)
hits.append((role, rule.id)) if lv is None:
continue
hits.append((lv, rule.id))
if not hits: if not hits:
return None, None, False return None, None, False
return hits[0][0], hits[0][1], len(hits) > 1 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( def _resolve_region_hit(
db: Session, db: Session,
node: TopoFabricNode, node: TopoFabricNode,
@ -182,3 +233,8 @@ def _resolve_region_hit(
return hits[0][0], hits[0][1], len(hits) > 1 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, _MAX_PATTERN_LEN,
_SCOPES, _SCOPES,
_compile_pattern, _compile_pattern,
_normalize_scope,
_rule_out, _rule_out,
_utcnow, _utcnow,
_validate_payload, _validate_payload,
@ -21,8 +22,13 @@ from .topology_schemas import ClassifyRuleCreate, ClassifyRuleOut, ClassifyRuleU
def list_rules(db: Session, *, scope: str = "") -> list[ClassifyRuleOut]: def list_rules(db: Session, *, scope: str = "") -> list[ClassifyRuleOut]:
q = db.query(TopoClassifyRule) q = db.query(TopoClassifyRule)
if scope.strip(): scope_f = str(scope or "").strip().lower()
q = q.filter(TopoClassifyRule.scope == scope.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( rows = q.order_by(
TopoClassifyRule.scope.asc(), TopoClassifyRule.scope.asc(),
TopoClassifyRule.priority.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: def create_rule(db: Session, body: ClassifyRuleCreate) -> ClassifyRuleOut:
scope = str(body.scope or "").strip().lower() scope_raw = str(body.scope or "").strip().lower()
if scope not in _SCOPES: if scope_raw not in _SCOPES:
raise HTTPException(status_code=400, detail="scope_invalid") raise HTTPException(status_code=400, detail="scope_invalid")
scope = _normalize_scope(scope_raw)
match_field = str(body.match_field or "name").strip().lower() match_field = str(body.match_field or "name").strip().lower()
if match_field not in _MATCH_FIELDS: if match_field not in _MATCH_FIELDS:
raise HTTPException(status_code=400, detail="match_field_invalid") raise HTTPException(status_code=400, detail="match_field_invalid")

View file

@ -4,6 +4,7 @@ from __future__ import annotations
from typing import Any from typing import Any
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy import or_
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .cli_resolve import get_default_profile, infer_device_type_vendor from .cli_resolve import get_default_profile, infer_device_type_vendor
@ -48,6 +49,8 @@ def _job_out(
include_items: bool = True, include_items: bool = True,
page: int | None = None, page: int | None = None,
page_size: int | None = None, page_size: int | None = None,
item_status: str = "",
item_keyword: str = "",
) -> FabricDiscoverJobOut: ) -> FabricDiscoverJobOut:
items_out: list[FabricDiscoverJobItemOut] = [] items_out: list[FabricDiscoverJobItemOut] = []
items_total = 0 items_total = 0
@ -59,6 +62,37 @@ def _job_out(
.filter(TopoDiscoverJobItem.job_id == job.id) .filter(TopoDiscoverJobItem.job_id == job.id)
.order_by(TopoDiscoverJobItem.created_at.asc()) .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()) items_total = int(q.count())
if page is not None and page_size is not None: if page is not None and page_size is not None:
items_page = max(1, int(page or 1)) items_page = max(1, int(page or 1))
@ -121,11 +155,20 @@ def get_discover_job(
*, *,
page: int | None = None, page: int | None = None,
page_size: int | None = None, page_size: int | None = None,
item_status: str = "",
item_keyword: str = "",
) -> FabricDiscoverJobOut: ) -> FabricDiscoverJobOut:
job = db.get(TopoDiscoverJob, str(job_id or "").strip()) job = db.get(TopoDiscoverJob, str(job_id or "").strip())
if job is None: if job is None:
raise HTTPException(status_code=404, detail="discover_job_not_found") 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: 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: 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( return FabricNodeOut(
level=level_v,
role=str(getattr(n, "role", "") or ""), role=str(getattr(n, "role", "") or ""),
region_folder_id=str(getattr(n, "region_folder_id", None) or "") or None, region_folder_id=str(getattr(n, "region_folder_id", None) or "") or None,
role_source=str(getattr(n, "role_source", "") or ""), role_source=str(getattr(n, "role_source", "") or ""),
@ -264,6 +270,8 @@ def list_fabric_nodes(
*, *,
keyword: str = "", keyword: str = "",
role: str = "", role: str = "",
level: str = "",
level_major: str = "",
region_folder_id: str = "", region_folder_id: str = "",
unmatched: str = "", unmatched: str = "",
link_status: str = "", link_status: str = "",
@ -271,6 +279,7 @@ def list_fabric_nodes(
page_size: int = PAGE_DEFAULT, page_size: int = PAGE_DEFAULT,
) -> dict[str, Any]: ) -> dict[str, Any]:
from .topology_inventory_lifecycle import enrich_fabric_node_dicts 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 = max(1, int(page or 1))
page_size = max(1, min(PAGE_MAX, int(page_size or PAGE_DEFAULT))) 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), 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() role_v = str(role or "").strip().lower()
if role_v: 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() region_v = str(region_folder_id or "").strip()
if region_v: if region_v:
q = q.filter(TopoFabricNode.region_folder_id == region_v) q = q.filter(TopoFabricNode.region_folder_id == region_v)
um = str(unmatched or "").strip().lower() um = str(unmatched or "").strip().lower()
if um == "role": if um in {"role", "level"}:
q = q.filter(or_(TopoFabricNode.role == "", TopoFabricNode.role == "unknown")) q = q.filter(TopoFabricNode.level.is_(None))
elif um == "region": elif um == "region":
q = q.filter( q = q.filter(
or_(TopoFabricNode.region_folder_id.is_(None), TopoFabricNode.region_folder_id == "") or_(TopoFabricNode.region_folder_id.is_(None), TopoFabricNode.region_folder_id == "")
@ -302,8 +342,7 @@ def list_fabric_nodes(
elif um == "any": elif um == "any":
q = q.filter( q = q.filter(
or_( or_(
TopoFabricNode.role == "", TopoFabricNode.level.is_(None),
TopoFabricNode.role == "unknown",
TopoFabricNode.region_folder_id.is_(None), TopoFabricNode.region_folder_id.is_(None),
TopoFabricNode.region_folder_id == "", 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 role VARCHAR(32) DEFAULT 'core'",
"ALTER TABLE topo_view ADD COLUMN IF NOT EXISTS sort_order INTEGER DEFAULT 0", "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 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 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 role_source VARCHAR(16) DEFAULT ''",
"ALTER TABLE topo_fabric_node ADD COLUMN IF NOT EXISTS region_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_x DOUBLE PRECISION",
"ALTER TABLE topo_fabric_node ADD COLUMN IF NOT EXISTS world_y 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)", "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 role VARCHAR(32) DEFAULT 'core'",
"ALTER TABLE topo_view ADD COLUMN sort_order INTEGER DEFAULT 0", "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 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 region_folder_id VARCHAR(64)",
"ALTER TABLE topo_fabric_node ADD COLUMN role_source VARCHAR(16) DEFAULT ''", "ALTER TABLE topo_fabric_node ADD COLUMN role_source VARCHAR(16) DEFAULT ''",
"ALTER TABLE topo_fabric_node ADD COLUMN region_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( def api_fabric_nodes(
keyword: str = "", keyword: str = "",
role: 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 = "", 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( link_status: str = Query(
default="", default="",
description="linked | orphaned | managed | ume | both", description="linked | orphaned | managed | ume | both",
@ -107,6 +109,8 @@ def api_fabric_nodes(
db, db,
keyword=keyword, keyword=keyword,
role=role, role=role,
level=level,
level_major=level_major,
region_folder_id=region_folder_id, region_folder_id=region_folder_id,
unmatched=unmatched, unmatched=unmatched,
link_status=link_status, link_status=link_status,
@ -255,9 +259,18 @@ def api_fabric_discover_job(
job_id: str, job_id: str,
page: int = Query(default=1, ge=1), page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100), page_size: int = Query(default=20, ge=1, le=100),
status: str = Query(default=""),
keyword: str = Query(default=""),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict[str, Any]: ) -> 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 --------------------------------------------------------- # --- Tree / folders ---------------------------------------------------------
@ -520,7 +533,7 @@ def api_classify_apply(
@router.get("/classify/unmatched") @router.get("/classify/unmatched")
def api_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: int = Query(default=1, ge=1),
page_size: int = Query(default=50, ge=1, le=500), page_size: int = Query(default=50, ge=1, le=500),
db: Session = Depends(get_db), db: Session = Depends(get_db),
@ -591,6 +604,10 @@ def api_generate_slices(
body: SliceGenerateRequest, body: SliceGenerateRequest,
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict[str, Any]: ) -> 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() return generate_slices(db, body).model_dump()

View file

@ -21,6 +21,9 @@ class FabricNodeOut(BaseModel):
ip: str = "" ip: str = ""
vendor: str = "" vendor: str = ""
device_type: 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 = "" role: str = ""
region_folder_id: str | None = None region_folder_id: str | None = None
role_source: str = "" role_source: str = ""
@ -483,7 +486,7 @@ class ClassifyRuleOut(BaseModel):
class ClassifyRuleCreate(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 = "" name: str = ""
pattern: str pattern: str
match_field: str = "name" match_field: str = "name"
@ -505,26 +508,33 @@ class ClassifyRuleUpdate(BaseModel):
class ClassifyPreviewOut(BaseModel): class ClassifyPreviewOut(BaseModel):
total_nodes: int = 0 total_nodes: int = 0
level_matched: int = 0
level_unmatched: int = 0
level_conflicts: int = 0
# Legacy aliases
role_matched: int = 0 role_matched: int = 0
role_unmatched: int = 0 role_unmatched: int = 0
role_conflicts: int = 0 role_conflicts: int = 0
region_matched: int = 0 region_matched: int = 0
region_unmatched: int = 0 region_unmatched: int = 0
region_conflicts: 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) role_samples: list[dict[str, Any]] = Field(default_factory=list)
region_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) unmatched_samples: list[dict[str, Any]] = Field(default_factory=list)
class ClassifyApplyOut(BaseModel): class ClassifyApplyOut(BaseModel):
role_updated: int = 0 level_updated: int = 0
role_updated: int = 0 # alias of level_updated
region_updated: int = 0 region_updated: int = 0
skipped_manual: int = 0 skipped_manual: int = 0
total_nodes: int = 0 total_nodes: int = 0
class FabricNodeTagPatch(BaseModel): class FabricNodeTagPatch(BaseModel):
role: str | None = None level: float | None = None
role: str | None = None # preset alias → level
region_folder_id: str | None = None region_folder_id: str | None = None
@ -545,12 +555,13 @@ class FabricNodesMatchOut(BaseModel):
class FabricNodesBulkTagRequest(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) fabric_node_ids: list[str] = Field(default_factory=list)
pattern: str = "" pattern: str = ""
match_field: str = "name" 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 region_folder_id: str | None = None
dry_run: bool = False dry_run: bool = False
@ -559,6 +570,7 @@ class FabricNodesBulkTagOut(BaseModel):
dry_run: bool = False dry_run: bool = False
matched: int = 0 matched: int = 0
updated: int = 0 updated: int = 0
level: float | None = None
role: str | None = None role: str | None = None
region_folder_id: str | None = None region_folder_id: str | None = None
samples: list[dict[str, Any]] = Field(default_factory=list) 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() role_v = str(role or "").strip().lower()
if role_v: 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() vendor_v = str(vendor or "").strip()
if vendor_v: if vendor_v:
q = q.filter(TopoFabricNode.vendor.ilike(f"%{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. """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 (API inline by default; set ``NETX_RUN_INLINE_SCHEDULERS=false`` and run
``python -m netx_api.worker`` for a split process). ``python -m netx_api.worker`` for a split process).
API process also owns UME keepalive, alarm WSS, current-alarm/inventory sync loops, 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 .config_sync_scheduler import start_config_sync_scheduler
from .fabric_reconcile_scheduler import start_fabric_reconcile_scheduler from .fabric_reconcile_scheduler import start_fabric_reconcile_scheduler
from .lldp_collect_scheduler import start_lldp_collect_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 .port_traffic_scheduler import start_port_traffic_scheduler
from .scheduler_heartbeat import start_scheduler_heartbeat_publisher from .scheduler_heartbeat import start_scheduler_heartbeat_publisher
start_config_sync_scheduler() start_config_sync_scheduler()
start_lldp_collect_scheduler() start_lldp_collect_scheduler()
start_ne_collect_scheduler()
start_port_traffic_scheduler() start_port_traffic_scheduler()
start_fabric_reconcile_scheduler() start_fabric_reconcile_scheduler()
# Publish status so API /metrics can see collectors when run in a split worker. # 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.""" """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. # 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) per_node.append(h)
hits_n = len(hits) 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_frac = len(nodes_with_hit) / max(n_nodes, 1)
hit_per_link = hits_n / max(n_links, 1)
if hit_frac <= 0.0: if hit_frac <= 0.0:
score = 1.0 node_s = 1.0
elif hit_frac >= 0.2: elif hit_frac >= 0.2:
score = 0.0 node_s = 0.0
else: 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: def _pct(vals: list[float], p: float) -> float | None:
if not vals: if not vals:
@ -197,6 +207,9 @@ def compute_edge_clearance(
"nodes_hit": len(nodes_with_hit), "nodes_hit": len(nodes_with_hit),
"min_clearance_p50": _pct(clearances, 0.5), "min_clearance_p50": _pct(clearances, 0.5),
"edge_clearance_score": round(score, 4), "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], "top_edge_hits": per_node[:top_n],
"hit_nodes": per_node, "hit_nodes": per_node,
"edge_clearance_tip": tip_ok, "edge_clearance_tip": tip_ok,
@ -764,7 +777,7 @@ def grade_layout(
issues.append(f"label_overlaps={label_overlaps}>0") issues.append(f"label_overlaps={label_overlaps}>0")
util_warn = 0.08 util_warn = 0.08
util_fail = 0.03 util_fail = 0.04
util_f = float(util) if util is not None else None util_f = float(util) if util is not None else None
util_bad = util_f is not None and util_f < util_fail util_bad = util_f is not None and util_f < util_fail
util_soft = util_f is not None and util_f < util_warn 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"]) out["pitch"] = float(p["pitch"])
if p.get("side") is not None: if p.get("side") is not None:
out["side"] = float(p["side"]) 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 return out
@ -228,11 +235,15 @@ def clear_edge_hits(
pitch: float | None = None, pitch: float | None = None,
side: float | None = None, side: float | None = None,
rounds: int = 1, rounds: int = 1,
frozen_ids: set[str] | None = None,
max_eject_degree: int = 5,
) -> OpResult: ) -> OpResult:
"""Move nodes off non-incident edges; gate on crossings + overlaps. """Move nodes off non-incident edges; gate on crossings + overlaps.
``preserve_axis=True``: snap to pitch/side grid, keep incident H/V count, ``preserve_axis=True``: snap to pitch/side grid, keep incident H/V count,
multi-round until no progress — use after ``ortho_metro``. 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 del params
st = state.copy() st = state.copy()
@ -240,6 +251,8 @@ def clear_edge_hits(
names = st.names names = st.names
links = list(st.links) links = list(st.links)
adj = st.adj 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) 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) side_f = float(side if side is not None else REC_CENTER_DY)
rounds_n = max(1, int(rounds)) rounds_n = max(1, int(rounds))
@ -272,8 +285,8 @@ def clear_edge_hits(
target = float(thr) + float(margin) target = float(thr) + float(margin)
x0 = count_edge_crossings(pos, links) x0 = count_edge_crossings(pos, links)
ax0 = _global_axis_score(pos, links) ax0 = _global_axis_score(pos, links)
# Clearance matters, but keep metro readable — modest crossing slack only. # Never allow crossing rise — even with preserve_axis (metro snap).
x_slack = 4 if preserve_axis else 0 x_slack = 0
deg = {n: len(adj.get(n) or ()) for n in pos} deg = {n: len(adj.get(n) or ()) for n in pos}
def _move_budget(nid: str) -> float: def _move_budget(nid: str) -> float:
@ -308,6 +321,8 @@ def clear_edge_hits(
nid = str(h["fabric_node_id"]) nid = str(h["fabric_node_id"])
if nid not in pos: if nid not in pos:
continue continue
if nid in frozen:
continue
a = str(h["a_node_id"]) a = str(h["a_node_id"])
b = str(h["b_node_id"]) b = str(h["b_node_id"])
if a not in pos or b not in pos: if a not in pos or b not in pos:
@ -315,8 +330,8 @@ def clear_edge_hits(
p0 = pos[nid] p0 = pos[nid]
if not _on_open_segment(p0, pos[a], pos[b], thr=thr) and float(h.get("dist") or 99) >= thr: if not _on_open_segment(p0, pos[a], pos[b], thr=thr) and float(h.get("dist") or 99) >= thr:
continue continue
# Very high-degree hub on a chord: break the chord instead of ejecting hub. # High-degree hubs on a chord: skip unless caller raises cap.
if deg.get(nid, 0) >= 5: if deg.get(nid, 0) >= int(max_eject_degree):
continue continue
if preserve_axis: if preserve_axis:
nbr_xy = [ 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 A unit = two portals + ≥2 interior-disjoint corridors (+ optional deg≤2
tails), or a long chain between portals. Units may share portals. tails), or a long chain between portals. Units may share portals.
Beautify targets zero edge crossings: multi-corridor → parallel H/V lanes; Detection walks eyes **top→down** (core–core → core–agg → agg–agg) and
chains (any length) → straight; tails as straight spurs. No 回字 fold. 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 from __future__ import annotations
@ -102,12 +106,136 @@ def _collect_tails(
return 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( def find_dual_portal_units(
state: LayoutState, state: LayoutState,
*, *,
max_units: int = 120, max_units: int = 120,
) -> list[DualUnit]: ) -> 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 adj, names, layers = state.adj, state.names, state.layers
ens = [n for n, ly in layers.items() if ly == "access" and n in adj] 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"} an_set = {n for n, ly in layers.items() if ly == "agg"}
@ -116,68 +244,58 @@ def find_dual_portal_units(
units: list[DualUnit] = [] units: list[DualUnit] = []
used_interior: set[str] = set() used_interior: set[str] = set()
# 1) Access/AN two-portal ring groups (sugiyama metro). cores = sorted(
if ens: [n for n in core_set if n in adj],
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],
key=lambda n: (-len(adj.get(n, ())), names.get(n, n)), key=lambda n: (-len(adj.get(n, ())), names.get(n, n)),
) )
for i, a in enumerate(hubs): aggs = sorted(
if len(units) >= max_units: [n for n in an_set if n in adj],
break key=lambda n: (-len(adj.get(n, ())), names.get(n, n)),
for b in hubs[i + 1 :]: )
if len(units) >= max_units:
break # 1) Core–core
# Only forbid other cores — when almost all NEs are layer=agg, _greedy_take_by_size(
# banning every agg hub makes cover_hub_paths return 0 corridors. _hub_pair_candidates(
forbid = core_set - {a, b} state, cores, cores, core_set=core_set, allow_same=True
paths = cover_hub_paths(a, b, adj, names, forbid=forbid) ),
if len(paths) < 2: 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 continue
interior = set() an_cands.append(_unit_from_hub_paths(state, a, b, paths))
for p in paths: _greedy_take_by_size(
interior |= set(p[1:-1]) an_cands, used_interior=used_interior, max_units=max_units, already=units
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)
)
for i, u in enumerate(units): for i, u in enumerate(units):
u.unit_id = i u.unit_id = i
@ -199,6 +317,71 @@ def _normalize_paths(
return 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: def classify_dual_unit(unit: DualUnit) -> str:
"""petal = multi-corridor (parallel lanes); else straight (no 回字).""" """petal = multi-corridor (parallel lanes); else straight (no 回字)."""
paths = _normalize_paths(unit) paths = _normalize_paths(unit)
@ -226,49 +409,169 @@ def _place_chain_straight(
pos[nid] = (ox + ux * pitch * (i + 1), oy + uy * pitch * (i + 1)) 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( def beautify_dual_unit_positions(
state: LayoutState, state: LayoutState,
unit: DualUnit, unit: DualUnit,
params: LayoutParams | None = None, params: LayoutParams | None = None,
) -> dict[str, tuple[float, float]]: ) -> 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. Goal: fewer crossings, readable spread, **hollow eye interior**, low
Multi-corridor (kind=petal): parallel horizontal lanes with vertical overlap. Portals on x-axis only; corridors on ± ellipse bands; **long
stubs at portal x — no ellipse arcs. tails park outside the eye** (do not pierce nested rings).
""" """
params = params or LayoutParams() params = params or LayoutParams()
pitch = max(float(params.pitch), 170.0) 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 a, b = unit.portal_a, unit.portal_b
paths = _normalize_paths(unit) paths = _normalize_paths(unit)
kind = classify_dual_unit(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": if kind == "petal":
# Parallel H/V lanes: first/last mid share portal x → V stub + H spine. max_mid = max((len(p) - 2 for p in paths), default=0)
band_i = 0 rx = max(
for p in paths: float(params.an_gap) * 2.5,
mid = p[1:-1] pitch * max(7.0, float(max_mid) + 2.0),
if not mid: 1100.0,
continue )
side = 1 if band_i % 2 == 0 else -1 ry_step = max(float(params.side) * 1.8, float(params.lane) * 1.15, pitch * 1.15, 320.0)
amp = ry * (0.85 + 0.35 * (band_i // 2)) ordered = _best_path_order_for_crossings(
band_i += 1 paths,
n_mid = len(mid) a=a,
for k, n in enumerate(mid): b=b,
if n in pos: rx=rx,
continue ry_step=ry_step,
if n_mid == 1: state=state,
pos[n] = (0.0, side * amp) names=state.names,
else: chord_gap=max(pitch * 0.85, 150.0),
t = k / (n_mid - 1) )
x = -half + 2.0 * half * t pos = _place_petal_bands(
pos[n] = (x, side * amp) 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: 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] = [] body: list[str] = []
if paths: if paths:
body = list(paths[0][1:-1]) body = list(paths[0][1:-1])
@ -283,13 +586,54 @@ def beautify_dual_unit_positions(
pitch=max(pitch, (2.0 * half) / (len(body) + 1)), pitch=max(pitch, (2.0 * half) / (len(body) + 1)),
pos=pos, 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) 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: if not chain:
continue continue
# Skip if already placed as body.
fresh = [n for n in chain if n not in used] fresh = [n for n in chain if n not in used]
if not fresh: if not fresh:
continue continue
@ -304,25 +648,92 @@ def beautify_dual_unit_positions(
if attach is None: if attach is None:
attach = a if ti % 2 == 0 else b attach = a if ti % 2 == 0 else b
ax, ay = pos[attach] 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: if attach == a:
origin = (ax, ay + y_off) stub_ux, stub_uy = -1.0, 0.35 if fan_i % 2 == 0 else -0.35
direc = (-1.0, 0.0)
elif attach == b: elif attach == b:
origin = (ax, ay + y_off) stub_ux, stub_uy = 1.0, 0.35 if fan_i % 2 == 0 else -0.35
direc = (1.0, 0.0)
else: else:
origin = (ax, ay) stub_ux, stub_uy = ax, ay
direc = (0.0, 1.0 if ay >= 0 else -1.0) if abs(stub_ux) + abs(stub_uy) < 1e-6:
_place_chain_straight( stub_ux, stub_uy = (0.0, 1.0 if ti % 2 == 0 else -1.0)
fresh, origin=origin, direction=direc, pitch=pitch * 0.85, pos=pos
) 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) used |= set(pos)
leftovers = [n for n in unit.member_ids() if n not in 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 # Outer ellipse parking — keep off portal chord / hollow mid.
for i, n in enumerate(sorted(leftovers, key=lambda x: state.names.get(x, x))): n_left = len(leftovers)
pos[n] = (-half + i * pitch, top) 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 return pos
@ -332,7 +743,7 @@ def layout_dual_unit_positions(
unit: DualUnit, unit: DualUnit,
params: LayoutParams | None = None, params: LayoutParams | None = None,
) -> dict[str, tuple[float, float]]: ) -> 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) return beautify_dual_unit_positions(state, unit, params)
@ -342,8 +753,9 @@ def _uncross_unit(
pinned: set[str], pinned: set[str],
*, *,
max_rounds: int = 80, max_rounds: int = 80,
preserve_side: bool = True,
) -> dict[str, tuple[float, float]]: ) -> 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 from netx_topology_mcp.layout_metrics import segments_properly_intersect
out = dict(pos) out = dict(pos)
@ -360,9 +772,9 @@ def _uncross_unit(
bad: list[tuple[int, int]] = [] bad: list[tuple[int, int]] = []
for i in range(len(segs)): for i in range(len(segs)):
for j in range(i + 1, 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] c, d, pc, pd = segs[j]
if len({a, b, c, d}) < 4: if len({aa, bb, c, d}) < 4:
continue continue
if segments_properly_intersect(pa, pb, pc, pd): if segments_properly_intersect(pa, pb, pc, pd):
bad.append((i, j)) bad.append((i, j))
@ -397,6 +809,8 @@ def _uncross_unit(
480.0, 480.0,
-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): for dx in (0.0, 40.0, -40.0, 80.0, -80.0):
trial = dict(out) trial = dict(out)
trial[n] = (x + dx, y + dy) trial[n] = (x + dx, y + dy)
@ -412,7 +826,6 @@ def _uncross_unit(
break break
return out return out
def layout_dual_unit( def layout_dual_unit(
state: LayoutState, state: LayoutState,
params: LayoutParams | None = None, params: LayoutParams | None = None,
@ -421,8 +834,14 @@ def layout_dual_unit(
unit_id: int | None = None, unit_id: int | None = None,
portal_a: str | None = None, portal_a: str | None = None,
portal_b: str | None = None, portal_b: str | None = None,
require_zero_cross: bool = False,
) -> OpResult: ) -> 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() params = params or LayoutParams()
units = find_dual_portal_units(state) if unit is None else [unit] units = find_dual_portal_units(state) if unit is None else [unit]
if unit_id is not None: if unit_id is not None:
@ -452,32 +871,30 @@ def layout_dual_unit(
portal_a, portal_b, state.adj, state.names, forbid=set() portal_a, portal_b, state.adj, state.names, forbid=set()
) )
if len(paths) >= 2: if len(paths) >= 2:
pa, pb = portal_a, portal_b units = [_unit_from_hub_paths(state, portal_a, portal_b, paths)]
if state.names.get(pa, pa) > state.names.get(pb, pb): units[0].unit_id = 0
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,
)
]
if not units: 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 = [ hubs = [
n n
for n, ly in state.layers.items() for n, ly in state.layers.items()
if ly in ("agg", "core") and n in state.adj 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))) hubs.sort(key=lambda n: (-len(state.adj.get(n, ())), state.names.get(n, n)))
if len(hubs) >= 2: core_hubs.sort(key=lambda n: (-len(state.adj.get(n, ())), state.names.get(n, n)))
a, b = hubs[0], hubs[1] 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 = { forbid = {
n n
for n, ly in state.layers.items() for n, ly in state.layers.items()
@ -488,22 +905,14 @@ def layout_dual_unit(
paths = cover_hub_paths( paths = cover_hub_paths(
a, b, state.adj, state.names, forbid=set() a, b, state.adj, state.names, forbid=set()
) )
if len(paths) >= 2: if len(paths) < 2:
if state.names.get(a, a) > state.names.get(b, b): continue
a, b = b, a cand = _unit_from_hub_paths(state, a, b, paths)
paths = [list(reversed(p)) for p in paths] cand.unit_id = 0
core = {a, b} if best is None or len(cand.member_ids()) > len(best.member_ids()):
for p in paths: best = cand
core.update(p) if best is not None:
units = [ units = [best]
DualUnit(
portal_a=a,
portal_b=b,
paths=paths,
tails=_collect_tails(state, core),
unit_id=0,
)
]
if not units: if not units:
return OpResult( return OpResult(
state=state, state=state,
@ -513,8 +922,15 @@ def layout_dual_unit(
note="no_dual_unit", note="no_dual_unit",
) )
# If multiple units detected on one canvas, layout the largest by node count def _pick_key(x: DualUnit) -> tuple:
u = max(units, key=lambda x: len(x.member_ids())) # 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) pos = layout_dual_unit_positions(state, u, params)
out = state.copy() out = state.copy()
for n, xy in pos.items(): for n, xy in pos.items():
@ -535,21 +951,30 @@ def layout_dual_unit(
members = u.member_ids() members = u.member_ids()
unit_links = [e for e in out.links if e[0] in members and e[1] in members] 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} 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) 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 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] cand.positions[u.portal_a] = pos[u.portal_a]
if u.portal_b in pos:
cand.positions[u.portal_b] = pos[u.portal_b] cand.positions[u.portal_b] = pos[u.portal_b]
x_after = count_edge_crossings(cand.positions, unit_links) for n in pinned:
if x_after == 0: if n in pos:
stf = cand cand.positions[n] = pos[n]
# else keep pre-overlap geometry 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_unit = count_edge_crossings(stf.positions, unit_links)
x = count_edge_crossings(stf.positions, stf.links) x = count_edge_crossings(stf.positions, stf.links)
@ -564,8 +989,10 @@ def layout_dual_unit(
"unit": u.as_dict(state.names), "unit": u.as_dict(state.names),
"unit_crossings": x_unit, "unit_crossings": x_unit,
"parked": parked, "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( return OpResult(
state=stf, state=stf,
moved=moved, moved=moved,
@ -576,11 +1003,11 @@ def layout_dual_unit(
"global_crossings": x, "global_crossings": x,
"accepted": accepted, "accepted": accepted,
"parked": parked, "parked": parked,
"require_zero_cross": require_zero_cross,
"zero_cross": x_unit == 0,
}, },
note=( note=(
f"layout_dual_unit:paths={len(u.paths)} x=0" f"layout_dual_unit:paths={len(u.paths)} nodes={len(members)} x={x_unit}"
if accepted
else f"dual_unit_crossings={x_unit}"
), ),
) )
@ -603,10 +1030,11 @@ def dual_units_report(
"uncovered_nodes": max(0, graph_n - len(covered)), "uncovered_nodes": max(0, graph_n - len(covered)),
"units": [u.as_dict(state.names) for u in units], "units": [u.as_dict(state.names) for u in units],
"tip": ( "tip": (
"Dual-portal eye units: ≥2 interior-disjoint corridors between " "Dual-portal eyes walk top→down (core–core → core–agg → agg–agg), "
"portals; layout with action=layout_dual_unit (require crossings=0). " "then maximize membership. Petal objective = fewer crossings with "
"Portals may be shared across units; compose merges same node ids. " "readable spread (ellipse bands; hollow mid; low overlap). "
"Leftovers (uncovered_nodes) go to misc unit canvases." "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) v = overrides.get(key)
if v is not None and str(v).strip(): if v is not None and str(v).strip():
out[key] = 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 return out

View file

@ -2,32 +2,17 @@
from __future__ import annotations from __future__ import annotations
import re
from collections import deque from collections import deque
from netx_topology_mcp.layout_ops.level_util import infer_layer
_ROLE_TO_LAYER = { __all__ = [
"core": "core", "infer_layer",
"cn": "core", "bbox",
"aggregation": "agg", "connected_components",
"aggregate": "agg", "order_ans",
"agg": "agg", "chain_order",
"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()]
def bbox(pos: dict[str, tuple[float, float]]) -> tuple[float, float, float, float]: 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 ) -> "LayoutState": # noqa: F821
from netx_topology_mcp.layout_ops.state import LayoutState from netx_topology_mcp.layout_ops.state import LayoutState
from netx_topology_mcp.layout_metrics import collapse_links from netx_topology_mcp.layout_metrics import collapse_links
from netx_topology_mcp.layout_ops.level_util import _parse_level
names: dict[str, str] = {} names: dict[str, str] = {}
layers: dict[str, str] = {} layers: dict[str, str] = {}
levels: dict[str, float] = {}
ids: list[str] = [] ids: list[str] = []
for n in nodes: for n in nodes:
if not isinstance(n, dict): 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) nm = str(n.get("name") or n.get("label") or fid)
ids.append(fid) ids.append(fid)
names[fid] = nm 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} adj: dict[str, set[str]] = {i: set() for i in ids}
links = collapse_links(edges) links = collapse_links(edges)
@ -203,6 +193,7 @@ def build_state_from_nodes_edges(
positions=positions, positions=positions,
names=names, names=names,
layers=layers, layers=layers,
levels=levels,
links=[(a, b) for a, b in links if a in adj and b in adj], links=[(a, b) for a, b in links if a in adj and b in adj],
adj=adj, adj=adj,
meta={"ids": ids}, 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_moves": 40,
"cross_max_sweeps": 6, "cross_max_sweeps": 6,
"cross_cand_cap": 220, "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, "skip_dual_full_x": False,
"untangle_rounds": 120, "untangle_rounds": 120,
"untangle_moves": 5, "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( def press_hot_edges(
state: LayoutState, state: LayoutState,
params: LayoutParams | None = None, params: LayoutParams | None = None,
@ -586,17 +608,47 @@ def polish_crossings(
top_n: int | None = None, top_n: int | None = None,
max_moves: int | None = None, max_moves: int | None = None,
max_sweeps: int | None = None, max_sweeps: int | None = None,
preserve_dual_eye: bool | None = None,
) -> OpResult: ) -> 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 from netx_topology_mcp.layout_jobs import raise_if_cancelled, report_progress
params = params or LayoutParams() params = params or LayoutParams()
st = state.copy() st = state.copy()
park_phantom_nodes(st) park_phantom_nodes(st)
budget = _large_graph_budget(len(st.links)) 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). # Giant graphs: never allow straighten even if caller asks (stalls for minutes).
if len(st.links) >= 800: if len(st.links) >= 800:
do_straighten = False do_straighten = False
elif petal_eye and straighten is not True:
do_straighten = False
else: else:
do_straighten = ( do_straighten = (
bool(budget["straighten"]) if straighten is None else bool(straighten) bool(budget["straighten"]) if straighten is None else bool(straighten)
@ -720,6 +772,8 @@ def polish_crossings(
"focus_n": len(focus), "focus_n": len(focus),
"budget": budget, "budget": budget,
"straighten": do_straighten, "straighten": do_straighten,
"preserve_dual_eye": preserve,
"petal_eye_detected": petal_eye,
"untangle_rounds": untangle_rounds, "untangle_rounds": untangle_rounds,
} }
return OpResult( return OpResult(
@ -753,6 +807,13 @@ def press_params_from_overrides(overrides: dict[str, Any] | None) -> dict[str, A
if isinstance(v, bool) if isinstance(v, bool)
else str(v).strip().lower() in {"1", "true", "yes", "on"} 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): 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)] 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): 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]: 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) 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) 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( def select_dual_unit_batch(
@ -31,53 +31,118 @@ def select_dual_unit_batch(
*, *,
max_units: int = 3, max_units: int = 3,
min_nodes: int = 8, min_nodes: int = 8,
max_nodes: int = 80, max_nodes: int = 300,
max_batch_nodes: int = 120, max_batch_nodes: int = 400,
exclude_ids: set[str] | None = None, 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]: ) -> 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() 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) share = _portal_share_counts(units)
candidates: list[DualUnit] = [] candidates: list[DualUnit] = []
for u in units: for u in units:
members = u.member_ids() members = u.member_ids()
if not members: if not members:
continue 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) n = len(members)
if n < int(min_nodes) or n > int(max_nodes): if n < int(min_nodes) or n > int(max_nodes):
continue continue
# Skip units already fully present on sink (nothing new to move). if members and members.issubset(exclude_ids | keep_ids):
if members and members.issubset(exclude_ids): continue
# Need enough *movable* mass for this level phase.
if len(movable) < max(2, min(4, int(min_nodes) // 2)):
continue continue
candidates.append(u) 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] = [] picked: list[DualUnit] = []
claimed: set[str] = set() claimed: set[str] = set()
for u in candidates: for u in candidates:
if len(picked) >= int(max_units): if len(picked) >= int(max_units):
break break
members = u.member_ids() movable = _movable(u)
# Prefer units whose interiors are not already claimed this batch. interior = movable - {u.portal_a, u.portal_b}
interior = members - {u.portal_a, u.portal_b}
if interior & claimed: if interior & claimed:
continue continue
next_ids = claimed | members next_ids = claimed | movable
if len(next_ids) > int(max_batch_nodes): if len(next_ids) > int(max_batch_nodes):
continue continue
picked.append(u) picked.append(u)
claimed |= members claimed |= movable
return picked 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] = [] out: list[str] = []
seen: set[str] = set() seen: set[str] = set()
for u in units: for u in units:
for nid in sorted(u.member_ids()): for nid in sorted(u.member_ids()):
if nid and nid not in seen: if not nid or nid in seen or nid in keep_ids:
seen.add(nid) continue
out.append(nid) if sink_ids is not None and nid not in sink_ids:
continue
seen.add(nid)
out.append(nid)
return out return out
@ -86,15 +151,20 @@ def leftover_batch_ids(
*, *,
max_batch_nodes: int = 120, max_batch_nodes: int = 120,
exclude_ids: set[str] | None = None, exclude_ids: set[str] | None = None,
keep_ids: set[str] | None = None,
sink_ids: set[str] | None = None,
) -> list[str]: ) -> list[str]:
"""When dual_units are exhausted, take a plain leftover chunk.""" """When dual_units are exhausted, take a plain leftover chunk."""
exclude_ids = exclude_ids or set() exclude_ids = exclude_ids or set()
keep_ids = keep_ids or set()
out: list[str] = [] out: list[str] = []
for nid in source_ids: for nid in source_ids:
sid = str(nid or "").strip() sid = str(nid or "").strip()
if not sid or sid.startswith("region:"): if not sid or sid.startswith("region:"):
continue 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 continue
out.append(sid) out.append(sid)
if len(out) >= int(max_batch_nodes): if len(out) >= int(max_batch_nodes):

View file

@ -43,6 +43,8 @@ class LayoutState:
pinned: set[str] = field(default_factory=set) pinned: set[str] = field(default_factory=set)
names: dict[str, str] = field(default_factory=dict) names: dict[str, str] = field(default_factory=dict)
layers: 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) links: list[tuple[str, str]] = field(default_factory=list)
adj: dict[str, set[str]] = field(default_factory=dict) adj: dict[str, set[str]] = field(default_factory=dict)
spine: set[str] = field(default_factory=set) spine: set[str] = field(default_factory=set)
@ -57,6 +59,7 @@ class LayoutState:
pinned=set(self.pinned), pinned=set(self.pinned),
names=dict(self.names), names=dict(self.names),
layers=dict(self.layers), layers=dict(self.layers),
levels=dict(self.levels),
links=list(self.links), links=list(self.links),
adj={k: set(v) for k, v in self.adj.items()}, adj={k: set(v) for k, v in self.adj.items()},
spine=set(self.spine), 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_LO = 140.0
NN_SWEET_HI = 220.0 NN_SWEET_HI = 220.0
# Ideal space_utilization band (n * rec_tile / bbox_area). # 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 UTIL_SWEET_HI = 0.45
# Ideal median undirected edge length / recommended pitch. # Ideal median undirected edge length / recommended pitch.
EDGE_SWEET_LO = 0.7 EDGE_SWEET_LO = 0.7
EDGE_SWEET_HI = 2.2 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]]: def _convex_hull(points: list[tuple[float, float]]) -> list[tuple[float, float]]:
"""Andrew monotone chain; returns hull CCW, or [] if < 3 unique points.""" """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]. """Weighted sub-scores in [0,1] + total in [0,100].
Hard gate: any footprint/label overlap → total capped near 0 (still report parts). 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) n = int(metrics.get("node_count") or 0)
overlaps = int(metrics.get("footprint_overlap_pairs") 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 if metrics.get("edge_axis_score") is not None
else 1.0 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 # Crossing: UME mid-size ~0.16; 0 → 1.0, 0.30 → 0
if n <= 50: 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 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) 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) # Eye sinks: blend bbox util with hull util so long corridors don't auto-fail.
hull_s = _band_score(hull_u, UTIL_SWEET_LO, UTIL_SWEET_HI + 0.1, hard_lo=0.02, hard_hi=1.5) if profile == "eye":
grid_s = _band_score(grid_occ, 0.25, 0.75, hard_lo=0.02, hard_hi=1.0) 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) 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)) chain_s = max(0.0, min(1.0, chain_s))
rings_s = max(0.0, min(1.0, rings_s)) rings_s = max(0.0, min(1.0, rings_s))
edge_clr_s = max(0.0, min(1.0, edge_clr_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)) 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 = { parts = {
"overlap": round(overlap_s, 4), "overlap": round(overlap_s, 4),
"crossing": round(cross_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), "nn": round(nn_s, 4),
"hull": round(hull_s, 4), "hull": round(hull_s, 4),
"stretch": round(stretch_s, 4), "stretch": round(stretch_s, 4),
# Diagnostic only — not in weights (avoid double-count with util/grid).
"compactness": round(white_s, 4), "compactness": round(white_s, 4),
} }
# compactness folded into util/grid already; keep as diagnostic
total = sum(parts[k] * weights[k] for k in weights) total = sum(parts[k] * weights[k] for k in weights)
if overlap_s < 1.0: if overlap_s < 1.0:
total *= 0.15 # hard gate: overlaps wreck the score 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, "total": total_100,
"parts": parts, "parts": parts,
"weights": weights, "weights": weights,
"score_profile": profile,
"targets": { "targets": {
"nn_sweet": [NN_SWEET_LO, NN_SWEET_HI], "nn_sweet": [NN_SWEET_LO, NN_SWEET_HI],
"util_sweet": [UTIL_SWEET_LO, UTIL_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, -total_100,
int(metrics.get("edge_crossings") or 0), int(metrics.get("edge_crossings") or 0),
-util, -util,
-edge_clr_s,
-chain_s, -chain_s,
-rings_s, -rings_s,
-edge_clr_s,
-edge_axis_s, -edge_axis_s,
], ],
"hint": ( "hint": (
"total∈[0,100]. Overlaps hard-gate the score. " f"total∈[0,100] profile={profile}. Overlaps hard-gate. "
"Mid-tier: chain=直链成一体、rings=最小环不被穿(各权 0.10);" "Clearance blends nodes_hit + hits/link. "
"edge_clearance=网元勿贴非关联边(权 0.08);" "Eye profile softens axis (arc bands) and rings; raises util/clearance. "
"edge_axis=边宜水平/垂直且水平优先(权 0.06). " "compactness is diagnostic only (not weighted)."
"Raise utilization/grid_occupancy without overlaps; "
"keep crossings_per_link near ~0.16 (mid-size reference) and nn_p50 in 140–220."
), ),
} }
@ -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: 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" 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 "warn"
return "ok" return "ok"
@ -594,8 +657,9 @@ def build_layout_report(metrics: dict[str, Any]) -> dict[str, Any]:
"只看本工具即可验收:verdict.total∈[0,100];" "只看本工具即可验收:verdict.total∈[0,100];"
"overlap/crossing/spacing/sparsity/edges/chains/rings/" "overlap/crossing/spacing/sparsity/edges/chains/rings/"
"edge_clearance/edge_axis 各有 status。" "edge_clearance/edge_axis 各有 status。"
"中档:chains/rings 各权 0.10;edge_clearance=网元贴边(0.08);" f"score_profile={score.get('score_profile') or 'default'}:"
"edge_axis=水平/垂直边且水平优先(0.06)。" "eye 下调 axis/rings、上调 util/clearance;"
"贴边分=节点命中∪hits/link;compactness 仅诊断不加权。"
"扫参用 score.rank_key(先零重叠,再高 total)。" "扫参用 score.rank_key(先零重叠,再高 total)。"
), ),
}, },
@ -609,11 +673,13 @@ def analyze_layout_stats(
with_meta: bool = False, with_meta: bool = False,
ume_reference: bool = False, ume_reference: bool = False,
fast: bool = False, fast: bool = False,
score_profile: str | None = "auto",
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Full stats: flat metrics + composite score + unified report. """Full stats: flat metrics + composite score + unified report.
``fast=True`` skips ring-pierce (expensive on giant metro canvases) and ``fast=True`` skips ring-pierce (expensive on giant metro canvases) and
still scores overlap/crossing/util/chains for apply gates + agent QA. 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) base = analyze_positions(nodes, edges, with_meta=with_meta)
pos: dict[str, tuple[float, float]] = {} 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_tip"] = clr_q.get("edge_clearance_tip")
merged["edge_clearance_thr"] = clr_q.get("edge_clearance_thr") merged["edge_clearance_thr"] = clr_q.get("edge_clearance_thr")
merged["edge_clearance_skipped"] = clr_q.get("edge_clearance_skipped") 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["edge_axis_score"] = axis_q.get("edge_axis_score", 1.0)
merged["axis_frac"] = axis_q.get("axis_frac") merged["axis_frac"] = axis_q.get("axis_frac")
merged["horiz_frac"] = axis_q.get("horiz_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_tip"] = axis_q.get("edge_axis_tip")
merged["edge_axis_tol_deg"] = axis_q.get("edge_axis_tol_deg") merged["edge_axis_tol_deg"] = axis_q.get("edge_axis_tol_deg")
merged["edge_axis_tol_px"] = axis_q.get("edge_axis_tol_px") 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) grade = grade_layout(merged, ume_reference=ume_reference)
packed = { packed = {
**merged, **merged,
@ -703,6 +771,7 @@ def analyze_layout_stats(
"summary": { "summary": {
"total": score["total"], "total": score["total"],
"overall": grade.get("overall"), "overall": grade.get("overall"),
"score_profile": score.get("score_profile"),
"crossings": merged.get("edge_crossings"), "crossings": merged.get("edge_crossings"),
"cpl": merged.get("crossings_per_link"), "cpl": merged.get("crossings_per_link"),
"overlaps": merged.get("footprint_overlap_pairs"), "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) nm = str(n.get("name") or n.get("label") or fid)
ids.append(fid) ids.append(fid)
names[fid] = nm 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} adj: dict[str, set[str]] = {i: set() for i in ids}
links = collapse_links(edges) links = collapse_links(edges)
@ -211,7 +211,9 @@ def analyze_graph_structure(
deg = {i: len(adj[i]) for i in ids} deg = {i: len(adj[i]) for i in ids}
access = {i for i in ids if layers.get(i) == "access"} 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: if layer_known < max(3, len(ids) // 5) and ids:
ranked = sorted(ids, key=lambda i: (-deg[i], names[i])) ranked = sorted(ids, key=lambda i: (-deg[i], names[i]))
hub_budget = max(2, min(8, len(ids) // 15 + 2)) hub_budget = max(2, min(8, len(ids) // 15 + 2))
@ -317,6 +319,7 @@ def analyze_graph_structure(
} }
layer_block = { layer_block = {
"external": layer_stats("external"),
"core": layer_stats("core"), "core": layer_stats("core"),
"agg": layer_stats("agg"), "agg": layer_stats("agg"),
"access": { "access": {

View file

@ -42,11 +42,24 @@ from netx_topology_mcp.layout_ops.clear_edge_hits import (
clear_edge_hits, clear_edge_hits,
clear_edge_params_from_overrides, 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 ( from netx_topology_mcp.layout_ops.orbit_sweep import (
apply_orbit_pick, apply_orbit_pick,
orbit_params_from_overrides, orbit_params_from_overrides,
orbit_sweep_node, orbit_sweep_node,
orbit_sweep_round, 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 # Public recipe names → internal multipass ids
@ -137,7 +150,11 @@ ACTIONS = (
"layout_dual_unit", "layout_dual_unit",
"polish_crossings", "polish_crossings",
"clear_edge_hits", "clear_edge_hits",
"compact_bbox",
"pull_far_chains",
"align_reference",
"orbit_sweep", "orbit_sweep",
"level_bands",
) )
@ -431,6 +448,7 @@ def run_layout_on_graph(
unit_id=knobs.get("unit_id"), unit_id=knobs.get("unit_id"),
portal_a=knobs.get("portal_a"), portal_a=knobs.get("portal_a"),
portal_b=knobs.get("portal_b"), portal_b=knobs.get("portal_b"),
require_zero_cross=bool(knobs.get("require_zero_cross", False)),
) )
accepted = bool(op.params.get("accepted", False)) accepted = bool(op.params.get("accepted", False))
# Always return best-effort dual geometry (even if accepted=False). # Always return best-effort dual geometry (even if accepted=False).
@ -452,16 +470,67 @@ def run_layout_on_graph(
"note": op.note, "note": op.note,
"accepted": accepted, "accepted": accepted,
"meta": st.meta.get("layout_dual_unit"), "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": if action_key == "orbit_sweep":
knobs = orbit_params_from_overrides(params) knobs = orbit_params_from_overrides(params)
do_round = bool(knobs.get("round")) do_round = bool(knobs.get("round"))
do_until = bool(knobs.get("until_limit"))
node_id = str(knobs.get("node_id") or "").strip() node_id = str(knobs.get("node_id") or "").strip()
frozen = knobs.get("frozen_ids") frozen = knobs.get("frozen_ids")
protect = knobs.get("protect_rigid", "off") 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: if do_round:
op = orbit_sweep_round( op = orbit_sweep_round(
st0, st0,
@ -474,6 +543,8 @@ def run_layout_on_graph(
min_angle_sep=float(knobs.get("min_angle_sep") or 35.0), min_angle_sep=float(knobs.get("min_angle_sep") or 35.0),
protect_rigid=protect, protect_rigid=protect,
frozen_ids=frozen, frozen_ids=frozen,
freeze_layers=knobs.get("freeze_layers"),
freeze_levels=knobs.get("freeze_levels"),
focus_ids=knobs.get("focus_ids"), focus_ids=knobs.get("focus_ids"),
y_min=knobs.get("y_min"), y_min=knobs.get("y_min"),
y_max=knobs.get("y_max"), y_max=knobs.get("y_max"),
@ -500,8 +571,8 @@ def run_layout_on_graph(
) )
if not node_id: if not node_id:
raise ValueError( raise ValueError(
"orbit_sweep_requires_node_id_or_round:" "orbit_sweep_requires_node_id_or_round_or_until_limit:"
"params.node_id=… or params.round=true" "params.node_id=… or params.round=true or params.until_limit=true"
) )
sweep = orbit_sweep_node( sweep = orbit_sweep_node(
st0, st0,
@ -514,6 +585,8 @@ def run_layout_on_graph(
cand_cap=int(knobs.get("cand_cap") or 280), cand_cap=int(knobs.get("cand_cap") or 280),
protect_rigid=protect, protect_rigid=protect,
frozen_ids=frozen, frozen_ids=frozen,
freeze_layers=knobs.get("freeze_layers"),
freeze_levels=knobs.get("freeze_levels"),
top_k=int(knobs.get("top_k") or 3), top_k=int(knobs.get("top_k") or 3),
y_min=knobs.get("y_min"), y_min=knobs.get("y_min"),
y_max=knobs.get("y_max"), y_max=knobs.get("y_max"),
@ -599,6 +672,8 @@ def run_layout_on_graph(
pitch=knobs.get("pitch"), pitch=knobs.get("pitch"),
side=knobs.get("side"), side=knobs.get("side"),
rounds=int(knobs.get("rounds") or (6 if preserve else 1)), 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 st = normalize_origin(op.state, base_params).state
from netx_topology_mcp.layout_metrics import count_edge_crossings as _cx 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": if action_key == "polish_crossings":
knobs = press_params_from_overrides(params) knobs = press_params_from_overrides(params)
# Omit knobs so polish can auto-scale budgets on large E (MCP timeout). # Omit knobs so polish can auto-scale budgets on large E (MCP timeout).
@ -639,6 +808,7 @@ def run_layout_on_graph(
base_params, base_params,
portal_ids=knobs.get("portal_ids"), portal_ids=knobs.get("portal_ids"),
straighten=knobs.get("straighten"), straighten=knobs.get("straighten"),
preserve_dual_eye=knobs.get("preserve_dual_eye"),
max_degree=int(knobs.get("max_degree") or 9), max_degree=int(knobs.get("max_degree") or 9),
untangle_rounds=knobs.get("untangle_rounds"), untangle_rounds=knobs.get("untangle_rounds"),
top_n=knobs.get("top_n"), top_n=knobs.get("top_n"),
@ -750,13 +920,35 @@ def list_layout_catalog() -> dict[str, Any]:
), ),
"clear_edge_hits": ( "clear_edge_hits": (
"把贴在非关联边上的网元沿垂直方向弹开(直角偏好 H/V);" "把贴在非关联边上的网元沿垂直方向弹开(直角偏好 H/V);"
"门控:不增交叉、不增重叠。" "门控:不增交叉、不增重叠(preserve_axis 亦不放宽交叉)。"
"params: top_n/thr/margin/max_moves" "眼 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": ( "orbit_sweep": (
"压交叉(默认可动门户):以网元为圆心不定长扫角 top-3;" "压交叉:以网元为圆心不定长扫角;"
"preview+node_id / apply+pick / round=true;" "preview+node_id / apply+pick;"
"protect_rigid 默认 off(portals/all 可恢复刚体冻)" "until_limit=true 单点循环到 stall(默认冻 portals、objective=crossing|total);"
"round=true 一批 top_n 自动 pick#1(眼 sink 禁用);"
"单点/round 默认 protect_rigid=off;bundle 默认开"
), ),
"job_status": ( "job_status": (
"轮询后台 job:params.job_id;返回 progress.phase/pct、elapsed_ms、" "轮询后台 job:params.job_id;返回 progress.phase/pct、elapsed_ms、"
@ -789,9 +981,15 @@ def list_layout_catalog() -> dict[str, Any]:
"apply": "PATCH 到 view_id(有残留重叠则拒绝落笔)", "apply": "PATCH 到 view_id(有残留重叠则拒绝落笔)",
}, },
"workflow": ( "workflow": (
"主路径:analyze(structure) → layout_dual_unit(或小图 layout " "主路径:analyze(structure) → sinkTopologyDualUnits(max_units=1) → "
"compact|corridor|rings)→ sinkTopologyDualUnits / move_nodes(park) → " "suggestSinkHubs/move_nodes(park) → "
"orbit_sweep(round) → polish_crossings → clear_edge_hits → " "orbit_sweep(until_limit crossing→total) → "
"手拖微调。禁止临时 py 算坐标。" "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 int(m["edge_clearance_hits"] or 0) >= 1
assert float(m["edge_clearance_score"]) < 1.0 assert float(m["edge_clearance_score"]) < 1.0
assert "edge_clearance" in m["score"]["parts"] 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"} assert m["report"]["edge_clearance"]["status"] in {"warn", "fail"}
top = m.get("top_edge_hits") or [] top = m.get("top_edge_hits") or []
assert any(r.get("fabric_node_id") == "mksr" for r in top) 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, "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"]["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(s["weights"]["nn"] - 0.04) < 1e-9
assert abs(sum(s["weights"].values()) - 1.0) < 1e-9 assert abs(sum(s["weights"].values()) - 1.0) < 1e-9
good = score_layout_components( 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 from __future__ import annotations
@ -42,6 +42,39 @@ def _eye_graph():
return nodes, edges 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: def test_actions_include_layout_dual_unit() -> None:
assert "layout_dual_unit" in ACTIONS 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 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: def test_layout_dual_unit_zero_crossings() -> None:
nodes, edges = _eye_graph() nodes, edges = _eye_graph()
st = build_state_from_nodes_edges(nodes, edges) 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 []) 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] 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) x = count_edge_crossings(op.state.positions, unit_links)
# Path-planar 3-corridor eye should stay clean under ellipse bands.
assert x == 0 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 def test_petal_ellipse_hollow_axis_portals_only() -> None:
assert out["action"] == "layout_dual_unit" """Shared portal-adj nodes stay off the mid-chord (CN gravity / hollow eye)."""
loc = out.get("local") or {} nodes = [
assert loc.get("accepted") is True {"fabric_node_id": "p1", "name": "P1", "role": "cn", "x": 0, "y": 0},
assert int((loc.get("op") or {}).get("unit_crossings") or 0) == 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: def test_structure_reports_dual_units() -> None:
@ -85,13 +255,9 @@ def test_structure_reports_dual_units() -> None:
report = analyze_graph_structure(nodes, edges) report = analyze_graph_structure(nodes, edges)
du = report.get("dual_units") or {} du = report.get("dual_units") or {}
assert int(du.get("unit_count") or 0) >= 1 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: def test_shared_portal_compose_preserves_relative() -> None:
"""Two units sharing portal p2 → one world coord for p2; B rigidly glued."""
a = ComposeBlock( a = ComposeBlock(
key="unit-p1-p2", key="unit-p1-p2",
positions={ 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) merged, meta = strip_pack_blocks([a, b], pad=100.0, merge_shared=True)
assert meta.get("merge_shared") is True assert meta.get("merge_shared") is True
assert "p2" in merged and "p1" in merged and "p3" in merged 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). # Shared portal merge keeps both blocks; relative spans stay order-of-magnitude.
dx = merged["p3"][0] - merged["p2"][0] span_p = ((merged["p3"][0] - merged["p2"][0]) ** 2 + (merged["p3"][1] - merged["p2"][1]) ** 2) ** 0.5
dy = merged["p3"][1] - merged["p2"][1] span_b = ((merged["b1"][0] - merged["p2"][0]) ** 2 + (merged["b1"][1] - merged["p2"][1]) ** 2) ** 0.5
assert abs(dx - 200.0) < 1e-6 assert abs(span_p - 200.0) < 10.0
assert abs(dy) < 1e-6 assert abs(span_b - ((100.0**2 + 60.0**2) ** 0.5)) < 10.0
# 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

View file

@ -262,9 +262,52 @@ def test_tools_for_scopes_filters_write() -> None:
assert "queryTopologyEdges" in read_only assert "queryTopologyEdges" in read_only
assert "queryTopologyFabricNodes" in read_only assert "queryTopologyFabricNodes" in read_only
assert "createTopologyFolder" not 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"])} write = {str(t.get("name") or "") for t in tools_for_scopes(["ne:read", "ne:write"])}
assert "createTopologyView" not in write assert "createTopologyView" not in write
assert "createTopologyFolder" 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: 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 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: def test_verdict_partial_weights_match_layout_stats() -> None:
from netx_topology_mcp.layout_ops.orbit_sweep import ( from netx_topology_mcp.layout_ops.orbit_sweep import (
_W_CLR, _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} names = {str(t.get("name") or "") for t in HTTP_MCP_TOOLS}
assert "sinkTopologyDualUnits" in names assert "sinkTopologyDualUnits" in names
assert "copyTopologyViewNodes" 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_clearance" in s["parts"]
assert "edge_axis" in s["parts"] assert "edge_axis" in s["parts"]
assert abs(s["weights"]["chain"] - 0.10) < 1e-9 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 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( good = score_layout_components(
{ {
"node_count": 40, "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) prev = clf.preview_classify(self.db)
self.assertGreaterEqual(prev.level_matched, 4)
self.assertGreaterEqual(prev.role_matched, 4) self.assertGreaterEqual(prev.role_matched, 4)
applied = clf.apply_classify(self.db) applied = clf.apply_classify(self.db)
self.assertGreaterEqual(applied.level_updated, 4)
self.assertGreaterEqual(applied.role_updated, 4) self.assertGreaterEqual(applied.role_updated, 4)
self.db.refresh(nodes[0]) self.db.refresh(nodes[0])
self.assertEqual(nodes[0].role, "core") self.assertEqual(nodes[0].role, "core")
self.assertEqual(nodes[0].level, 1.0)
self.assertEqual(nodes[0].region_folder_id, region.id) self.assertEqual(nodes[0].region_folder_id, region.id)
dry = clf.generate_slices( dry = clf.generate_slices(
@ -1026,6 +1029,11 @@ class FabricTopologyTests(unittest.TestCase):
), ),
) )
self.assertEqual(bulk.updated, 1) 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: def test_project_neighbors_respects_max_nodes(self) -> None:
suffix = uuid4().hex[:8] suffix = uuid4().hex[:8]

View file

@ -138,14 +138,14 @@ src/
- MCP:以 `queryTopologyEdges` 为主查询 Fabric;画布编辑走 Web - MCP:以 `queryTopologyEdges` 为主查询 Fabric;画布编辑走 Web
- BGP / 隧道 / L2VPN:`layer` 预留,实现 TODO - BGP / 隧道 / L2VPN:`layer` 预留,实现 TODO
## 分类与切片(清单打标) ## 分类(清单打标)
- Fabric 标签:`topo_fabric_node.role` / `region_folder_id`(`role_source` / `region_source`) - Fabric 主键:`topo_fabric_node.level`(major.minor,越小越靠外);`role` 为 `floor(level)` 同步别名
- 主流程:网元清单表(Fabric 全量)+ 临时正则查找 → 确认后批量写角色/区域;支持单行编辑 - 预设:0 外部 / 1 核心 / 2 汇聚 / 3 接入;子层如 1.1、2.1
- 关联状态:`link_status`=`managed|ume|both|orphaned`;筛选 `link_status=linked|orphaned|…` - 主流程:清单 + 正则 → 批量写 level/区域;支持单行编辑
- API:`GET /fabric/nodes`(keyword/role/region/unmatched/link_status)、`POST /fabric/nodes/match`、`POST /fabric/nodes/tags/bulk`、`PATCH /fabric/nodes/{id}/tags` - API:`GET /fabric/nodes`(level / level_major / role / unmatched=level)、`POST …/match`、`POST …/tags/bulk`(`level`)、`PATCH …/tags`
- 切片:`POST /v1/topology/slices/generate`(`core_only` / `core_agg` / `agg_access`;dry_run;可重叠上图) - Agent:`classifyTopologyFabricNodes`(netx-topology MCP)
- 前端:网络管理 →「分类与切片」 - 前端:网络管理 →「分类」
## WebCRT ## 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, collectionEligibleNeAll: ["collectionEligibleNe"] as const,
collectionEligibleNe: (page: number, keyword: string) => ["collectionEligibleNe", page, keyword] as const, collectionEligibleNe: (page: number, keyword: string) => ["collectionEligibleNe", page, keyword] as const,
neCollectionsAll: ["neCollections"] 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, neCollectionDashboard: ["neCollectionDashboard"] as const,
neCollectionPolicy: ["neCollectionPolicy"] as const,
neCollectionDetail: (jobId: string) => ["neCollection", jobId] as const, neCollectionDetail: (jobId: string) => ["neCollection", jobId] as const,
neCollectionRunsAll: ["neCollectionRuns"] as const, neCollectionRunsAll: ["neCollectionRuns"] as const,
neCollectionRuns: (jobId: string, page: number, status: string, keyword: string) => neCollectionRuns: (jobId: string, page: number, pageSize: number, status: string, keyword: string) =>
["neCollectionRuns", jobId, page, status, keyword] as const, ["neCollectionRuns", jobId, page, pageSize, status, keyword] as const,
cliMeta: ["cliMeta"] as const, cliMeta: ["cliMeta"] as const,
cliProfiles: ["cliProfiles"] as const, cliProfiles: ["cliProfiles"] as const,
cliTargetsAll: ["cliTargets"] as const, cliTargetsAll: ["cliTargets"] as const,
@ -56,7 +58,9 @@ export const queryKeys = {
unmatched: string, unmatched: string,
linkStatus: string, linkStatus: string,
page: number, 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, fabricNodeSearch: (q: string, page: number) => ["fabricNodeSearch", q, page] as const,
topologyViewGraph: (viewId: string) => ["topologyViewGraph", viewId] as const, topologyViewGraph: (viewId: string) => ["topologyViewGraph", viewId] as const,
fabricSummary: ["fabricSummary"] as const, fabricSummary: ["fabricSummary"] as const,
@ -66,26 +70,35 @@ export const queryKeys = {
["topologyViewGraph", viewId, focusFolderId] as const, ["topologyViewGraph", viewId, focusFolderId] as const,
lldpCollectDashboard: ["lldpCollectDashboard"] as const, lldpCollectDashboard: ["lldpCollectDashboard"] as const,
lldpCollectJobsAll: ["lldpCollectJobs"] 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, 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, fabricEdgesAll: ["fabricEdges"] as const,
fabricEdges: (status: string, keyword: string, page: number) => fabricEdges: (status: string, keyword: string, page: number, pageSize = 20, source = "") =>
["fabricEdges", status, keyword, page] as const, ["fabricEdges", status, keyword, page, pageSize, source] as const,
configSyncDashboard: ["configSyncDashboard"] as const, configSyncDashboard: ["configSyncDashboard"] as const,
configSyncPolicy: ["configSyncPolicy"] as const, configSyncPolicy: ["configSyncPolicy"] as const,
configSyncCyclesAll: ["configSyncCycles"] 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, configSyncCycleTasksAll: ["configSyncCycleTasks"] as const,
configSyncCycleTasks: (cycleId: string, page: number, status: string, keyword: string) => configSyncCycleTasks: (
["configSyncCycleTasks", cycleId, page, status, keyword] as const, cycleId: string,
page: number,
pageSize = 20,
status = "",
keyword = "",
) => ["configSyncCycleTasks", cycleId, page, pageSize, status, keyword] as const,
networkConfigsAll: ["networkConfigs"] as const, networkConfigsAll: ["networkConfigs"] as const,
networkConfigs: (page: number, keyword: string, source: string, vendor: string) => networkConfigs: (page: number, keyword: string, source: string, vendor: string, pageSize = 20) =>
["networkConfigs", page, keyword, source, vendor] as const, ["networkConfigs", page, keyword, source, vendor, pageSize] as const,
networkConfigDetail: (source: string, id: string) => ["networkConfigDetail", source, id] as const, networkConfigDetail: (source: string, id: string) => ["networkConfigDetail", source, id] as const,
portTrafficDashboard: ["portTrafficDashboard"] as const, portTrafficDashboard: ["portTrafficDashboard"] as const,
portTrafficDevicesAll: ["portTrafficDevices"] 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, portTrafficTargets: (deviceId: string) => ["portTrafficTargets", deviceId] as const,
portTrafficEvents: (deviceId: string) => ["portTrafficEvents", deviceId] as const, portTrafficEvents: (deviceId: string) => ["portTrafficEvents", deviceId] as const,
portTrafficBoards: ["portTrafficBoards"] 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", nextPage: "Next",
perPage: "{{n}}/page", perPage: "{{n}}/page",
pagerMeta: "{{total}} items · page {{page}}/{{pages}}", 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", opFailed: "Operation failed",
empty: "-", empty: "-",
cancel: "Cancel", cancel: "Cancel",
@ -100,7 +107,7 @@ const en = {
devices: "Device list", devices: "Device list",
topology: "Topology", topology: "Topology",
lldpLinks: "LLDP", lldpLinks: "LLDP",
topoClassify: "Classify / slices", topoClassify: "Classify",
alarms: "Alarms", alarms: "Alarms",
configs: "Configurations", configs: "Configurations",
webcrt: "WebCRT", webcrt: "WebCRT",
@ -130,10 +137,15 @@ const en = {
statusRunning: "Running", statusRunning: "Running",
statusPaused: "Paused", statusPaused: "Paused",
statusStopped: "Stopped", statusStopped: "Stopped",
statusDraft: "Draft",
collecting: "Collecting", collecting: "Collecting",
deleted: "Deleted", deleted: "Deleted",
confirmDelete: "Delete this device monitoring and its samples?", confirmDelete: "Delete this device monitoring and its samples?",
empty: "No devices monitored yet. Pick a NE and interfaces to start.", 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", wall: "Ops wall",
wallTitle: "Port traffic wall", wallTitle: "Port traffic wall",
toDevices: "Manage devices", toDevices: "Manage devices",
@ -354,6 +366,14 @@ const en = {
edgeStatusAll: "All statuses", edgeStatusAll: "All statuses",
edgeStatusActive: "Active", edgeStatusActive: "Active",
edgeStatusMissing: "Missing", 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", missCount: "Miss cycles",
replacedBy: "Replaced", replacedBy: "Replaced",
kpi: { kpi: {
@ -392,6 +412,7 @@ const en = {
done: "Done", done: "Done",
failed: "Failed", failed: "Failed",
cancelled: "Cancelled", cancelled: "Cancelled",
stopped: "Stopped",
}, },
triggerMode: { triggerMode: {
manual: "Manual", manual: "Manual",
@ -444,7 +465,11 @@ const en = {
cyclesEmpty: "No sync cycles yet", cyclesEmpty: "No sync cycles yet",
tasksTitle: "Cycle tasks", tasksTitle: "Cycle tasks",
taskKeywordPh: "Name / IP / message", taskKeywordPh: "Name / IP / message",
keywordPh: "ID / trigger / message",
statusAll: "All statuses",
allStatus: "All statuses", allStatus: "All statuses",
exportCyclesName: "config-sync-cycles",
exportTasksName: "config-sync-tasks",
expand: "Details", expand: "Details",
collapse: "Collapse", collapse: "Collapse",
kpi: { kpi: {
@ -472,12 +497,14 @@ const en = {
keywordPh: "Name / IP / ID", keywordPh: "Name / IP / ID",
allSource: "All sources", allSource: "All sources",
empty: "No network elements.", empty: "No network elements.",
exportName: "network-devices",
col: { col: {
source: "Source", source: "Source",
name: "Name", name: "Name",
vendor: "Vendor", vendor: "Vendor",
deviceType: "Device type", deviceType: "Device type",
connect: "Connect", connect: "Connect",
actions: "Actions",
}, },
}, },
networkConfigs: { networkConfigs: {
@ -490,6 +517,7 @@ const en = {
export: "Export", export: "Export",
exportBoth: "Export all", exportBoth: "Export all",
exportOk: "Config exported", exportOk: "Config exported",
exportListName: "network-configs",
close: "Close", close: "Close",
tabSet: "Set format", tabSet: "Set format",
tabHier: "Hierarchical", tabHier: "Hierarchical",
@ -626,6 +654,28 @@ const en = {
passwordMustChange: "New password must differ from the default/old password", passwordMustChange: "New password must differ from the default/old password",
}, },
collect: { 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: { create: {
title: "New collection job", title: "New collection job",
hint: "Configure commands and NEs, then create the job. Click Start in the job list to run collection.", 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?", confirmDelete: "Delete this collection job and its log files?",
jobs: { jobs: {
title: "Collection jobs", title: "Collection jobs",
listTitle: "Job list",
col: { col: {
title: "Job", title: "Job",
status: "Status", status: "Status",
@ -705,6 +756,7 @@ const en = {
idle: "Idle", idle: "Idle",
last: "Last job", last: "Last job",
active: "Active jobs", active: "Active jobs",
nextDue: "Next due",
}, },
runs: { runs: {
status: "Status", status: "Status",
@ -1152,10 +1204,20 @@ const en = {
keywordPh: "keyword(alarm key/cause/ne_name/host_name/ip…)", keywordPh: "keyword(alarm key/cause/ne_name/host_name/ip…)",
hostNamePh: "host_name (contains)", hostNamePh: "host_name (contains)",
allSeverity: "All severities", allSeverity: "All severities",
clearedAll: "is_cleared: all", clearedAll: "Cleared: all",
clearedYes: "Cleared",
clearedNo: "Active",
clearTitle: "Clear filters and reset to page 1", clearTitle: "Clear filters and reset to page 1",
filterByHost: "Filter by this host", filterByHost: "Filter by this host",
noHostName: "No host_name (sync inventory first)", noHostName: "No host_name (sync inventory first)",
col: {
time: "Time",
severity: "Severity",
neId: "NE ID",
hostName: "Host",
neType: "NE type",
cause: "Cause",
},
}, },
}, },
webcrt: { webcrt: {
@ -1397,6 +1459,7 @@ const en = {
treeLoadFailed: "Failed to load topology tree", treeLoadFailed: "Failed to load topology tree",
treeRetry: "Retry", treeRetry: "Retry",
graphLoading: "Loading topology…", graphLoading: "Loading topology…",
graphLoadFailed: "Failed to load topology",
graphRefreshing: "Refreshing…", graphRefreshing: "Refreshing…",
canvasEmpty: "No NEs on this map yet", canvasEmpty: "No NEs on this map yet",
canvasEmptyHint: "Use Add NE, or pick a region that already has devices.", 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.", "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", edgeDeleted: "Queued {{count}} link delete(s) — Save to apply",
discoverCancelled: "Discovery cancelled", discoverCancelled: "Discovery cancelled",
truncatedMembership: "Membership cap reached; some neighbors were not placed. Use classify/slices or 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 in classify/slices or create 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.", 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.", truncatedEdges: "Too many edges; display truncated. Filter status or create another map.",
truncatedGeneric: "Graph data truncated. Narrow scope 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?", unsavedConfirm: "You have unsaved changes. Discard and switch maps?",
saving: "Saving…", saving: "Saving…",
saved: "Topology saved", 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", discover: "Discover links",
discovering: "Discovering…", discovering: "Discovering…",
discoverOne: "Discover links", 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)", connectHint: "Connect mode: drag from the NE center anchor to the target (Esc or Select to exit)",
}, },
topoClassify: { topoClassify: {
title: "Classify & slices", title: "Classify",
openTopo: "Topology", openTopo: "Topology",
inventory: "NE inventory", inventory: "NE inventory",
keywordPh: "Filter by name/IP", keywordPh: "Filter by name/IP",
filterRoleAll: "All roles", filterLevelAll: "All levels",
filterRegionAll: "All regions", filterRegionAll: "All regions",
filterUnmatchedOff: "No unmatched filter", filterUnmatchedOff: "No unmatched filter",
kindAny: "Role or region empty", kindAny: "Missing level or region",
kindRole: "Role empty", kindLevel: "Missing level",
kindRegion: "Region empty", kindRegion: "Missing region",
filterLinkAll: "All link states", filterLinkAll: "All link states",
filterLinkLinked: "Linked inventory", filterLinkLinked: "Linked inventory",
filterLinkOrphaned: "Detached", filterLinkOrphaned: "Detached",
@ -1715,10 +1783,14 @@ const en = {
linkUme: "UME", linkUme: "UME",
linkBoth: "Managed+UME", linkBoth: "Managed+UME",
linkOrphaned: "Detached", linkOrphaned: "Detached",
roleCore: "Core", levelExternal: "L0 external",
roleAggregation: "Aggregation", levelCore: "L1 core",
roleAccess: "Access", levelCoreSub: "L1.1 core′",
roleUnknown: "Unknown", 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", sourceManual: "Manual",
sourceUmeSync: "UME sync", sourceUmeSync: "UME sync",
sourceLldp: "LLDP placeholder", sourceLldp: "LLDP placeholder",
@ -1731,9 +1803,9 @@ const en = {
findMatches: "Find matches", findMatches: "Find matches",
matchOk: "Matched {{count}}; selected", matchOk: "Matched {{count}}; selected",
matchHint: "{{count}} matched", matchHint: "{{count}} matched",
assignRole: "Role only", assignLevel: "Level only",
assignRegion: "Region only", assignRegion: "Region only",
assignBoth: "Role + region", assignBoth: "Level + region",
clearRegion: "Clear region", clearRegion: "Clear region",
confirmAssign: "Confirm assign", confirmAssign: "Confirm assign",
bulkConfirm: "Write tags to {{count}} selected/matched NE(s)?", bulkConfirm: "Write tags to {{count}} selected/matched NE(s)?",
@ -1741,7 +1813,7 @@ const en = {
selectedCount: "{{count}} selected", selectedCount: "{{count}} selected",
selectPage: "Select page", selectPage: "Select page",
colNe: "NE", colNe: "NE",
colRole: "Role", colLevel: "Level",
colRegion: "Region", colRegion: "Region",
colActions: "Actions", colActions: "Actions",
edit: "Edit", edit: "Edit",
@ -1758,20 +1830,6 @@ const en = {
rowSaved: "Saved", rowSaved: "Saved",
loading: "Loading…", loading: "Loading…",
empty: "No NEs.", 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: { audit: {
title: "Audit & ops", title: "Audit & ops",

View file

@ -9,6 +9,13 @@ const zh = {
nextPage: "下一页", nextPage: "下一页",
perPage: "{{n}}/页", perPage: "{{n}}/页",
pagerMeta: "共 {{total}} 条 · 第 {{page}}/{{pages}} 页", pagerMeta: "共 {{total}} 条 · 第 {{page}}/{{pages}} 页",
jumpPage: "跳转到页",
pageSize: "每页条数",
exportCsv: "导出 CSV",
exporting: "导出中…",
exportOk: "已导出 {{count}} 条",
exportTruncated: "已导出前 {{count}} 条(共 {{total}},已截断)",
exportFailed: "导出失败",
opFailed: "操作失败", opFailed: "操作失败",
empty: "-", empty: "-",
cancel: "取消", cancel: "取消",
@ -100,7 +107,7 @@ const zh = {
devices: "设备列表", devices: "设备列表",
topology: "拓扑信息", topology: "拓扑信息",
lldpLinks: "LLDP", lldpLinks: "LLDP",
topoClassify: "分类切片", topoClassify: "分类",
alarms: "告警信息", alarms: "告警信息",
configs: "配置信息", configs: "配置信息",
webcrt: "WebCRT", webcrt: "WebCRT",
@ -130,10 +137,15 @@ const zh = {
statusRunning: "运行中", statusRunning: "运行中",
statusPaused: "已暂停", statusPaused: "已暂停",
statusStopped: "已停止", statusStopped: "已停止",
statusDraft: "草稿",
collecting: "采集中", collecting: "采集中",
deleted: "已删除", deleted: "已删除",
confirmDelete: "删除该设备监控及其采样数据?", confirmDelete: "删除该设备监控及其采样数据?",
empty: "暂无设备监控。请先选择网元并勾选接口。", empty: "暂无设备监控。请先选择网元并勾选接口。",
keywordPh: "名称 / IP / 备注 / ID",
allStatus: "全部状态",
exportDevicesName: "port-traffic-devices",
exportEventsName: "port-traffic-events",
wall: "监控大屏", wall: "监控大屏",
wallTitle: "端口流量大屏", wallTitle: "端口流量大屏",
toDevices: "设备管理", toDevices: "设备管理",
@ -350,6 +362,14 @@ const zh = {
edgeStatusAll: "全部状态", edgeStatusAll: "全部状态",
edgeStatusActive: "活跃", edgeStatusActive: "活跃",
edgeStatusMissing: "未发现", edgeStatusMissing: "未发现",
edgeSourceAll: "全部来源",
exportEdgesName: "lldp-fabric-edges",
exportJobsName: "lldp-collect-jobs",
exportItemsName: "lldp-job-items",
statusAll: "全部状态",
jobKeywordPh: "任务 ID / 错误 / 范围",
itemKeywordPh: "网元名称 / IP / 错误",
itemResultAll: "全部结果",
missCount: "未发现周期", missCount: "未发现周期",
replacedBy: "已被替换", replacedBy: "已被替换",
kpi: { kpi: {
@ -388,6 +408,7 @@ const zh = {
done: "已完成", done: "已完成",
failed: "失败", failed: "失败",
cancelled: "已取消", cancelled: "已取消",
stopped: "已停止",
}, },
triggerMode: { triggerMode: {
manual: "手动", manual: "手动",
@ -440,7 +461,11 @@ const zh = {
cyclesEmpty: "暂无同步周期", cyclesEmpty: "暂无同步周期",
tasksTitle: "周期任务", tasksTitle: "周期任务",
taskKeywordPh: "名称 / IP / 消息", taskKeywordPh: "名称 / IP / 消息",
keywordPh: "ID / 触发 / 消息",
statusAll: "全部状态",
allStatus: "全部状态", allStatus: "全部状态",
exportCyclesName: "config-sync-cycles",
exportTasksName: "config-sync-tasks",
expand: "详情", expand: "详情",
collapse: "收起", collapse: "收起",
kpi: { kpi: {
@ -468,12 +493,14 @@ const zh = {
keywordPh: "名称 / IP / ID", keywordPh: "名称 / IP / ID",
allSource: "全部来源", allSource: "全部来源",
empty: "暂无网元。", empty: "暂无网元。",
exportName: "network-devices",
col: { col: {
source: "来源", source: "来源",
name: "名称", name: "名称",
vendor: "厂商", vendor: "厂商",
deviceType: "设备类型", deviceType: "设备类型",
connect: "连通状态", connect: "连通状态",
actions: "操作",
}, },
}, },
networkConfigs: { networkConfigs: {
@ -486,6 +513,7 @@ const zh = {
export: "导出", export: "导出",
exportBoth: "导出全部", exportBoth: "导出全部",
exportOk: "配置已导出", exportOk: "配置已导出",
exportListName: "network-configs",
close: "关闭", close: "关闭",
tabSet: "Set 格式", tabSet: "Set 格式",
tabHier: "层级格式", tabHier: "层级格式",
@ -621,6 +649,27 @@ const zh = {
passwordMustChange: "新密码不能与默认/旧密码相同", passwordMustChange: "新密码不能与默认/旧密码相同",
}, },
collect: { 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: { create: {
title: "新建采集任务", title: "新建采集任务",
hint: "配置命令与网元后创建任务;创建后需在任务列表中点击「开始」执行采集。", hint: "配置命令与网元后创建任务;创建后需在任务列表中点击「开始」执行采集。",
@ -671,6 +720,7 @@ const zh = {
confirmDelete: "确定删除该采集任务?相关日志文件将一并删除。", confirmDelete: "确定删除该采集任务?相关日志文件将一并删除。",
jobs: { jobs: {
title: "采集任务", title: "采集任务",
listTitle: "任务列表",
col: { col: {
title: "任务", title: "任务",
status: "状态", status: "状态",
@ -700,6 +750,7 @@ const zh = {
idle: "空闲", idle: "空闲",
last: "最近任务", last: "最近任务",
active: "活跃任务", active: "活跃任务",
nextDue: "下次周期",
}, },
runs: { runs: {
status: "状态", status: "状态",
@ -1145,10 +1196,20 @@ const zh = {
keywordPh: "keyword(告警键/原因/ne_name/host_name/ip 等)", keywordPh: "keyword(告警键/原因/ne_name/host_name/ip 等)",
hostNamePh: "host_name(含匹配)", hostNamePh: "host_name(含匹配)",
allSeverity: "全部级别", allSeverity: "全部级别",
clearedAll: "is_cleared: all", clearedAll: "清除状态:全部",
clearedYes: "已清除",
clearedNo: "未清除",
clearTitle: "清空 keyword、host_name、级别、is_cleared,回到第 1 页", clearTitle: "清空 keyword、host_name、级别、is_cleared,回到第 1 页",
filterByHost: "按该主机名筛选", filterByHost: "按该主机名筛选",
noHostName: "无 host_name(需先同步网元)", noHostName: "无 host_name(需先同步网元)",
col: {
time: "告警时间",
severity: "级别",
neId: "网元 ID",
hostName: "主机名",
neType: "网元类型",
cause: "原因",
},
}, },
}, },
webcrt: { webcrt: {
@ -1388,6 +1449,7 @@ const zh = {
treeLoadFailed: "拓扑树加载失败", treeLoadFailed: "拓扑树加载失败",
treeRetry: "重试", treeRetry: "重试",
graphLoading: "正在加载拓扑图…", graphLoading: "正在加载拓扑图…",
graphLoadFailed: "拓扑图加载失败",
graphRefreshing: "刷新中…", graphRefreshing: "刷新中…",
canvasEmpty: "这张图上还没有网元", canvasEmpty: "这张图上还没有网元",
canvasEmptyHint: "可用「添加网元」上图,或从左侧换到有设备的区域。", canvasEmptyHint: "可用「添加网元」上图,或从左侧换到有设备的区域。",
@ -1464,8 +1526,8 @@ const zh = {
deleteEdgeConfirm: "从画布移除选中链路?仅在点击「保存」后才会从 Fabric 删除;LLDP 下次采集若仍存在可能再次出现。", deleteEdgeConfirm: "从画布移除选中链路?仅在点击「保存」后才会从 Fabric 删除;LLDP 下次采集若仍存在可能再次出现。",
edgeDeleted: "已标记删除 {{count}} 条链路(保存后生效)", edgeDeleted: "已标记删除 {{count}} 条链路(保存后生效)",
discoverCancelled: "发现已取消", discoverCancelled: "发现已取消",
truncatedMembership: "视图已达成员上限,部分邻居未上图。可去分类/切片或新建视图。", truncatedMembership: "视图已达成员上限,部分邻居未上图。可去分类页或新建视图。",
truncatedFrozen: "当前视图已冻结成员,无法投影邻居。请在分类/切片中解冻或新建视图。", truncatedFrozen: "当前视图已冻结成员,无法投影邻居。请在分类流程中处理或新建视图。",
truncatedNodes: "画布节点过多已截断显示。建议新建视图或收紧成员范围。", truncatedNodes: "画布节点过多已截断显示。建议新建视图或收紧成员范围。",
truncatedEdges: "画布链路过多已截断显示。建议筛选状态或新建视图。", truncatedEdges: "画布链路过多已截断显示。建议筛选状态或新建视图。",
truncatedGeneric: "图数据已截断显示。建议收紧范围或新建视图。", truncatedGeneric: "图数据已截断显示。建议收紧范围或新建视图。",
@ -1494,6 +1556,11 @@ const zh = {
unsavedConfirm: "有未保存的更改,确定丢弃并切换吗?", unsavedConfirm: "有未保存的更改,确定丢弃并切换吗?",
saving: "保存中…", saving: "保存中…",
saved: "拓扑已保存", saved: "拓扑已保存",
saveFailed: "保存失败:{{detail}}",
readOnlyBanner: "只读模式(需要 ne:write 权限才能编辑)",
readOnlyHint: "只读模式,无编辑权限",
discoverTimeout: "发现任务超时,请稍后在 LLDP 任务页查看进度",
policySyncFailed: "同步 LLDP 策略失败,本地开关仍对本页发现生效",
discover: "发现链路", discover: "发现链路",
discovering: "发现中…", discovering: "发现中…",
discoverOne: "发现链路", discoverOne: "发现链路",
@ -1680,15 +1747,15 @@ const zh = {
connectHint: "连线模式:从网元中心锚点拖到目标网元(Esc 或切回选择退出)", connectHint: "连线模式:从网元中心锚点拖到目标网元(Esc 或切回选择退出)",
}, },
topoClassify: { topoClassify: {
title: "分类与切片", title: "分类",
openTopo: "拓扑画布", openTopo: "拓扑画布",
inventory: "网元清单", inventory: "网元清单",
keywordPh: "关键字筛选名称/IP", keywordPh: "关键字筛选名称/IP",
filterRoleAll: "全部角色", filterLevelAll: "全部层级",
filterRegionAll: "全部区域", filterRegionAll: "全部区域",
filterUnmatchedOff: "不过滤未分类", filterUnmatchedOff: "不过滤未分类",
kindAny: "角色或区域未填", kindAny: "层级或区域未填",
kindRole: "角色未填", kindLevel: "层级未填",
kindRegion: "区域未填", kindRegion: "区域未填",
filterLinkAll: "全部关联", filterLinkAll: "全部关联",
filterLinkLinked: "已关联库存", filterLinkLinked: "已关联库存",
@ -1698,10 +1765,14 @@ const zh = {
linkUme: "UME", linkUme: "UME",
linkBoth: "托管+UME", linkBoth: "托管+UME",
linkOrphaned: "已解绑", linkOrphaned: "已解绑",
roleCore: "核心", levelExternal: "L0 外部",
roleAggregation: "汇聚", levelCore: "L1 核心",
roleAccess: "接入", levelCoreSub: "L1.1 次核心",
roleUnknown: "未知", levelAgg: "L2 汇聚",
levelAggSub: "L2.1 二级汇聚",
levelAccess: "L3 接入",
levelAccessSub: "L3.1 更深接入",
levelInvalid: "层级无效(0–99.9,一位小数)",
sourceManual: "手动", sourceManual: "手动",
sourceUmeSync: "UME 同步", sourceUmeSync: "UME 同步",
sourceLldp: "LLDP 占位", sourceLldp: "LLDP 占位",
@ -1714,9 +1785,9 @@ const zh = {
findMatches: "查找匹配", findMatches: "查找匹配",
matchOk: "匹配 {{count}} 条,已勾选", matchOk: "匹配 {{count}} 条,已勾选",
matchHint: "当前匹配 {{count}} 条", matchHint: "当前匹配 {{count}} 条",
assignRole: "只改角色", assignLevel: "只改层级",
assignRegion: "只改区域", assignRegion: "只改区域",
assignBoth: "角色+区域", assignBoth: "层级+区域",
clearRegion: "清空区域", clearRegion: "清空区域",
confirmAssign: "确认写入", confirmAssign: "确认写入",
bulkConfirm: "将给已选/匹配的 {{count}} 条网元写入分类,确定?", bulkConfirm: "将给已选/匹配的 {{count}} 条网元写入分类,确定?",
@ -1724,7 +1795,7 @@ const zh = {
selectedCount: "已选 {{count}}", selectedCount: "已选 {{count}}",
selectPage: "全选本页", selectPage: "全选本页",
colNe: "网元", colNe: "网元",
colRole: "角色", colLevel: "层级",
colRegion: "区域", colRegion: "区域",
colActions: "操作", colActions: "操作",
edit: "编辑", edit: "编辑",
@ -1739,20 +1810,6 @@ const zh = {
rowSaved: "已保存", rowSaved: "已保存",
loading: "加载中…", loading: "加载中…",
empty: "没有网元。", 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: { audit: {
title: "操作审计", title: "操作审计",

View file

@ -2208,6 +2208,36 @@ pre {
min-width: 88px; 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 { @keyframes shimmer {
0% { 0% {
background-position: 200% 0; background-position: 200% 0;
@ -7591,6 +7621,26 @@ pre {
pointer-events: none; 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, .app-main .topo-toolbar__outside-peers,
.topo-toolbar__outside-peers { .topo-toolbar__outside-peers {
margin-left: auto; margin-left: auto;
@ -8506,12 +8556,32 @@ html.login-page--paused .login-page__flare {
/* —— Network Management shell (left nav + main) —— /* —— 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 { .network-shell {
display: flex; display: flex;
height: calc(100vh - 48px); flex: 1 1 auto;
min-height: 480px; width: 100%;
margin: -20px -28px -32px; min-height: 0;
height: auto;
margin: 0;
background: var(--nm-chrome); background: var(--nm-chrome);
overflow: hidden; 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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useSearchParams } from "react-router-dom";
import { ListPager } from "../components/ListPager";
import { import {
createNeCollection, createNeCollection,
deleteCollectionJob, deleteCollectionJob,
@ -8,31 +10,51 @@ import {
fetchCollectionRuns, fetchCollectionRuns,
fetchEligibleNe, fetchEligibleNe,
fetchNeCollections, fetchNeCollections,
formatErr,
pauseCollectionJob, pauseCollectionJob,
startCollectionJob, startCollectionJob,
startCollectionFromPolicy,
retryFailedCollectionJob, retryFailedCollectionJob,
updateCollectionPolicy,
collectionJobDownloadUrl, collectionJobDownloadUrl,
collectionRunDownloadUrl, collectionRunDownloadUrl,
} from "../services/api"; } from "../services/api";
import { queryKeys } from "../constants/queryKeys"; import { queryKeys } from "../constants/queryKeys";
import { useDebouncedValue } from "../hooks/useDebouncedValue";
import { useI18n } from "../i18n"; import { useI18n } from "../i18n";
import { useToast } from "../hooks/useToast"; 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 { pageCount } from "../utils/display";
import { formatSystemTime } from "../utils/time"; import { formatSystemTime } from "../utils/time";
const POLL_MS = 2000; const POLL_MS = 2000;
const ELIGIBLE_PAGE_SIZE = 20; 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 { function eligibleKey(row: Pick<EligibleNeItem, "id" | "source">): string {
const src = String(row.source || "managed").trim().toLowerCase() || "managed"; const src = String(row.source || "managed").trim().toLowerCase() || "managed";
return `${src}:${row.id}`; 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 { function collectionErrorMessage(err: unknown, t: (key: string) => string): string {
const raw = String(err); const raw = String(err);
if (raw.includes("collection_ne_busy")) return t("collect.neBusy"); if (raw.includes("collection_ne_busy")) return t("collect.neBusy");
if (raw.includes("collection_job_running")) return t("collect.jobRunning"); 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; return raw;
} }
@ -40,6 +62,7 @@ export function CollectPage() {
const { t } = useI18n(); const { t } = useI18n();
const { showOk, showError } = useToast(); const { showOk, showError } = useToast();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [searchParams, setSearchParams] = useSearchParams();
const [commands, setCommands] = useState(""); const [commands, setCommands] = useState("");
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
@ -48,12 +71,46 @@ export function CollectPage() {
const [neKeyword, setNeKeyword] = useState(""); const [neKeyword, setNeKeyword] = useState("");
const [nePage, setNePage] = useState(1); const [nePage, setNePage] = useState(1);
const [jobPage, setJobPage] = 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 [expandedJobId, setExpandedJobId] = useState("");
const [createOpen, setCreateOpen] = useState(false); 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 selectedIds = useMemo(() => Object.keys(selectedMap), [selectedMap]);
const selectedList = useMemo(() => Object.values(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({ const eligibleQuery = useQuery({
queryKey: queryKeys.collectionEligibleNe(nePage, neKeyword), queryKey: queryKeys.collectionEligibleNe(nePage, neKeyword),
queryFn: () => queryFn: () =>
@ -63,8 +120,14 @@ export function CollectPage() {
}); });
const jobsQuery = useQuery({ const jobsQuery = useQuery({
queryKey: queryKeys.neCollections(jobPage), queryKey: queryKeys.neCollections(jobPage, jobPageSize, jobStatus, debouncedJobKeyword),
queryFn: () => fetchNeCollections({ page: jobPage, pageSize: 20 }), queryFn: () =>
fetchNeCollections({
page: jobPage,
pageSize: jobPageSize,
status: jobStatus,
keyword: debouncedJobKeyword,
}),
staleTime: 1000, staleTime: 1000,
refetchInterval: (q) => { refetchInterval: (q) => {
const items = q.state.data?.items ?? []; 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({ const detailQuery = useQuery({
queryKey: queryKeys.neCollectionDetail(expandedJobId), queryKey: queryKeys.neCollectionDetail(expandedJobId),
queryFn: () => fetchCollectionJob(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) => { const invalidateJobs = async (jobId?: string) => {
await queryClient.invalidateQueries({ queryKey: queryKeys.neCollectionsAll }); await queryClient.invalidateQueries({ queryKey: queryKeys.neCollectionsAll });
await queryClient.invalidateQueries({ queryKey: queryKeys.neCollectionDashboard }); await queryClient.invalidateQueries({ queryKey: queryKeys.neCollectionDashboard });
@ -231,7 +386,58 @@ export function CollectPage() {
const nePages = pageCount(neTotal, ELIGIBLE_PAGE_SIZE); const nePages = pageCount(neTotal, ELIGIBLE_PAGE_SIZE);
const jobTotal = jobsQuery.data?.total ?? 0; 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( const commandLines = useMemo(
() => () =>
@ -262,10 +468,25 @@ export function CollectPage() {
) : null} ) : null}
</div> </div>
<div className="btn-row"> <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}> <button type="button" onClick={refreshAll} disabled={jobsQuery.isFetching || dashQuery.isFetching}>
{jobsQuery.isFetching || dashQuery.isFetching ? t("common.refreshing") : t("common.refresh")} {jobsQuery.isFetching || dashQuery.isFetching ? t("common.refreshing") : t("common.refresh")}
</button> </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")} {t("collect.create.expand")}
</button> </button>
{running?.status === "running" || running?.status === "pending" ? ( {running?.status === "running" || running?.status === "pending" ? (
@ -294,10 +515,6 @@ export function CollectPage() {
: t("collect.kpi.idle")} : t("collect.kpi.idle")}
</div> </div>
</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">
<div className="pt-list-kpi__label">{t("collect.kpi.last")}</div> <div className="pt-list-kpi__label">{t("collect.kpi.last")}</div>
<div className="pt-list-kpi__value" style={{ fontSize: 15 }}> <div className="pt-list-kpi__value" style={{ fontSize: 15 }}>
@ -306,10 +523,225 @@ export function CollectPage() {
: t("common.empty")} : t("common.empty")}
</div> </div>
</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>
</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="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 ? ( {!(jobsQuery.data?.items ?? []).length && !jobsQuery.isLoading ? (
<div className="pt-list-empty"> <div className="pt-list-empty">
<p>{t("common.empty")}</p> <p>{t("common.empty")}</p>
@ -347,23 +779,19 @@ export function CollectPage() {
</table> </table>
</div> </div>
)} )}
<div className="pager pt-list-pager"> <ListPager
<div className="pager__meta muted"> page={jobPage}
{t("common.pagerMeta", { pages={jobPages}
total: String(jobTotal), total={jobTotal}
page: String(jobPage), pageSize={jobPageSize}
pages: String(jobPages), pageSizeOptions={JOB_PAGE_SIZE_OPTIONS}
})} onPageChange={setJobPage}
</div> onPageSizeChange={(size) => {
<div className="pager__controls btn-row"> setJobPageSize(size);
<button className="pager__btn" disabled={jobPage <= 1} onClick={() => setJobPage(jobPage - 1)}> setJobPage(1);
{t("common.prevPage")} }}
</button> disabled={jobsQuery.isLoading}
<button className="pager__btn" disabled={jobPage >= jobPages} onClick={() => setJobPage(jobPage + 1)}> />
{t("common.nextPage")}
</button>
</div>
</div>
</div> </div>
</section> </section>
@ -764,7 +1192,6 @@ function JobRow({
); );
} }
const RUN_PAGE_SIZE = 20;
const RUN_STATUS_OPTIONS = ["pending", "running", "success", "fail", "cancelled"] as const; const RUN_STATUS_OPTIONS = ["pending", "running", "success", "fail", "cancelled"] as const;
function JobRunsPanel({ function JobRunsPanel({
@ -783,19 +1210,23 @@ function JobRunsPanel({
retryPending: boolean; retryPending: boolean;
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
const { showOk, showError } = useToast();
const [runPage, setRunPage] = useState(1); const [runPage, setRunPage] = useState(1);
const [runPageSize, setRunPageSize] = useState(20);
const [runStatus, setRunStatus] = useState(""); const [runStatus, setRunStatus] = useState("");
const [runKeyword, setRunKeyword] = useState(""); const [runKeyword, setRunKeyword] = useState("");
const [exportingRuns, setExportingRuns] = useState(false);
const debouncedRunKeyword = useDebouncedValue(runKeyword, 300);
const runsQuery = useQuery({ const runsQuery = useQuery({
queryKey: queryKeys.neCollectionRuns(jobId, runPage, runStatus, runKeyword), queryKey: queryKeys.neCollectionRuns(jobId, runPage, runPageSize, runStatus, debouncedRunKeyword),
queryFn: () => queryFn: () =>
fetchCollectionRuns({ fetchCollectionRuns({
jobId, jobId,
page: runPage, page: runPage,
pageSize: RUN_PAGE_SIZE, pageSize: runPageSize,
status: runStatus, status: runStatus,
keyword: runKeyword, keyword: debouncedRunKeyword,
}), }),
staleTime: 500, staleTime: 500,
refetchInterval: jobStatus === "running" ? 2000 : false, refetchInterval: jobStatus === "running" ? 2000 : false,
@ -808,8 +1239,57 @@ function JobRunsPanel({
}; };
const runTotal = runsQuery.data?.total ?? 0; const runTotal = runsQuery.data?.total ?? 0;
const runPages = pageCount(runTotal, RUN_PAGE_SIZE); const runPages = pageCount(runTotal, runPageSize);
const runs = runsQuery.data?.items ?? []; 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 ( return (
<> <>
@ -841,11 +1321,14 @@ function JobRunsPanel({
</option> </option>
))} ))}
</select> </select>
{runStatus || runKeyword ? ( {hasRunFilters ? (
<button type="button" onClick={clearRunFilters}> <button type="button" onClick={clearRunFilters}>
{t("common.clearFilters")} {t("common.clearFilters")}
</button> </button>
) : null} ) : null}
<button type="button" disabled={exportingRuns || runTotal === 0} onClick={() => void exportRunsCsv()}>
{exportingRuns ? t("common.exporting") : t("common.exportCsv")}
</button>
{jobStatus !== "running" && failCount > 0 ? ( {jobStatus !== "running" && failCount > 0 ? (
<button type="button" disabled={retryPending} onClick={onRetryFailed}> <button type="button" disabled={retryPending} onClick={onRetryFailed}>
{t("collect.jobs.retryFailed")} {t("collect.jobs.retryFailed")}
@ -907,21 +1390,19 @@ function JobRunsPanel({
</div> </div>
</div> </div>
<div className="ops-detail-modal__foot"> <div className="ops-detail-modal__foot">
<span className="muted"> <ListPager
{t("common.pagerMeta", { page={runPage}
total: String(runTotal), pages={runPages}
page: String(runPage), total={runTotal}
pages: String(runPages), pageSize={runPageSize}
})} pageSizeOptions={RUN_PAGE_SIZE_OPTIONS}
</span> onPageChange={setRunPage}
<div className="btn-row"> onPageSizeChange={(size) => {
<button type="button" disabled={runPage <= 1} onClick={() => setRunPage(runPage - 1)}> setRunPageSize(size);
{t("common.prevPage")} setRunPage(1);
</button> }}
<button type="button" disabled={runPage >= runPages} onClick={() => setRunPage(runPage + 1)}> disabled={runsQuery.isLoading}
{t("common.nextPage")} />
</button>
</div>
</div> </div>
</> </>
); );

View file

@ -1,20 +1,25 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 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 { import {
createConfigSyncCycle, createConfigSyncCycle,
fetchCliTargets, fetchCliTargets,
fetchConfigSyncCycleTasks, fetchConfigSyncCycleTasks,
fetchConfigSyncCycles, fetchConfigSyncCycles,
fetchConfigSyncDashboard, fetchConfigSyncDashboard,
formatErr,
pauseConfigSyncCycle, pauseConfigSyncCycle,
resumeConfigSyncCycle, resumeConfigSyncCycle,
stopConfigSyncCycle, stopConfigSyncCycle,
updateConfigSyncPolicy, updateConfigSyncPolicy,
} from "../services/api"; } from "../services/api";
import { queryKeys } from "../constants/queryKeys"; import type { CliTargetItem, ConfigSyncCycle, ConfigSyncTargetRef, ConfigSyncTask } from "../types";
import { useI18n } from "../i18n"; import { downloadCsv, fetchAllPages } from "../utils/csvExport";
import { useToast } from "../hooks/useToast";
import type { CliTargetItem, ConfigSyncTargetRef } from "../types";
import { pageCount } from "../utils/display"; import { pageCount } from "../utils/display";
import { formatSystemTime } from "../utils/time"; import { formatSystemTime } from "../utils/time";
@ -25,12 +30,22 @@ export function ConfigSyncPage() {
const { t } = useI18n(); const { t } = useI18n();
const { showOk, showError } = useToast(); const { showOk, showError } = useToast();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [searchParams, setSearchParams] = useSearchParams();
const [cyclePage, setCyclePage] = useState(1); 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 [expandedCycleId, setExpandedCycleId] = useState("");
const [taskPage, setTaskPage] = useState(1); const [taskPage, setTaskPage] = useState(1);
const [taskPageSize, setTaskPageSize] = useState(20);
const [taskStatus, setTaskStatus] = useState(""); const [taskStatus, setTaskStatus] = useState("");
const [taskKeyword, setTaskKeyword] = 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 [enabled, setEnabled] = useState(false);
const [intervalDays, setIntervalDays] = useState(3); const [intervalDays, setIntervalDays] = useState(3);
@ -73,22 +88,52 @@ export function ConfigSyncPage() {
setPolicyHydrated(true); setPolicyHydrated(true);
}, [dashQuery.data, policyHydrated]); }, [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({ const cyclesQuery = useQuery({
queryKey: queryKeys.configSyncCycles(cyclePage), queryKey: queryKeys.configSyncCycles(cyclePage, cyclePageSize, cycleStatus, debouncedCycleKeyword),
queryFn: () => fetchConfigSyncCycles({ page: cyclePage, pageSize: 10 }), queryFn: () =>
fetchConfigSyncCycles({
page: cyclePage,
pageSize: cyclePageSize,
status: cycleStatus,
keyword: debouncedCycleKeyword,
}),
staleTime: 1000, staleTime: 1000,
refetchInterval: () => (dashQuery.data?.running_cycle ? POLL_MS : false), refetchInterval: () => (dashQuery.data?.running_cycle ? POLL_MS : false),
}); });
const tasksQuery = useQuery({ const tasksQuery = useQuery({
queryKey: queryKeys.configSyncCycleTasks(expandedCycleId, taskPage, taskStatus, taskKeyword), queryKey: queryKeys.configSyncCycleTasks(
expandedCycleId,
taskPage,
taskPageSize,
taskStatus,
debouncedTaskKeyword,
),
queryFn: () => queryFn: () =>
fetchConfigSyncCycleTasks({ fetchConfigSyncCycleTasks({
cycleId: expandedCycleId, cycleId: expandedCycleId,
page: taskPage, page: taskPage,
pageSize: 20, pageSize: taskPageSize,
status: taskStatus, status: taskStatus,
keyword: taskKeyword, keyword: debouncedTaskKeyword,
}), }),
enabled: Boolean(expandedCycleId), enabled: Boolean(expandedCycleId),
staleTime: 800, staleTime: 800,
@ -144,7 +189,7 @@ export function ConfigSyncPage() {
showOk(t("configSync.policySaved")); showOk(t("configSync.policySaved"));
await refresh(); await refresh();
}, },
onError: (err) => showError(String(err)), onError: (err) => showError(formatErr(err)),
}); });
const startMut = useMutation({ const startMut = useMutation({
@ -153,7 +198,7 @@ export function ConfigSyncPage() {
showOk(t("configSync.started")); showOk(t("configSync.started"));
await refresh(); await refresh();
}, },
onError: (err) => showError(String(err)), onError: (err) => showError(formatErr(err)),
}); });
const pauseMut = useMutation({ const pauseMut = useMutation({
@ -162,7 +207,7 @@ export function ConfigSyncPage() {
showOk(t("configSync.paused")); showOk(t("configSync.paused"));
await refresh(); await refresh();
}, },
onError: (err) => showError(String(err)), onError: (err) => showError(formatErr(err)),
}); });
const resumeMut = useMutation({ const resumeMut = useMutation({
@ -171,7 +216,7 @@ export function ConfigSyncPage() {
showOk(t("configSync.resumed")); showOk(t("configSync.resumed"));
await refresh(); await refresh();
}, },
onError: (err) => showError(String(err)), onError: (err) => showError(formatErr(err)),
}); });
const stopMut = useMutation({ const stopMut = useMutation({
@ -180,7 +225,7 @@ export function ConfigSyncPage() {
showOk(t("configSync.stopped")); showOk(t("configSync.stopped"));
await refresh(); await refresh();
}, },
onError: (err) => showError(String(err)), onError: (err) => showError(formatErr(err)),
}); });
const dash = dashQuery.data; const dash = dashQuery.data;
@ -188,8 +233,110 @@ export function ConfigSyncPage() {
const last = dash?.last_cycle; const last = dash?.last_cycle;
const cycles = cyclesQuery.data?.items ?? []; const cycles = cyclesQuery.data?.items ?? [];
const cycleTotal = Number(cyclesQuery.data?.total || 0); 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 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 toggleTarget = (row: CliTargetItem) => {
const source = row.source === "ume" ? "ume" : "managed"; const source = row.source === "ume" ? "ume" : "managed";
@ -416,114 +563,155 @@ export function ConfigSyncPage() {
</div> </div>
<h3>{t("configSync.cyclesTitle")}</h3> <h3>{t("configSync.cyclesTitle")}</h3>
<div className="pt-list-table-wrap"> <div className="pt-list">
<table className="data-table pt-list-table"> <div className="filter-inline">
<thead> <input
<tr> value={cycleKeyword}
<th /> placeholder={t("configSync.keywordPh")}
<th>ID</th> onChange={(e) => {
<th>{t("configSync.col.trigger")}</th> setCycleKeyword(e.target.value);
<th>{t("configSync.col.status")}</th> setCyclePage(1);
<th>{t("configSync.col.progress")}</th> }}
<th>{t("configSync.col.started")}</th> />
<th>{t("configSync.col.ended")}</th> <select
<th>{t("configSync.col.actions")}</th> value={cycleStatus}
</tr> onChange={(e) => {
</thead> setCycleStatus(e.target.value);
<tbody> setCyclePage(1);
{cycles.map((c) => ( }}
<tr key={c.id}> >
<td> <option value="">{t("configSync.statusAll")}</option>
<button <option value="pending">pending</option>
type="button" <option value="running">running</option>
className="btn btn--sm btn--ghost" <option value="paused">paused</option>
onClick={() => { <option value="success">success</option>
setExpandedCycleId(c.id); <option value="completed">completed</option>
setTaskPage(1); <option value="failed">failed</option>
setTaskKeyword(""); <option value="stopped">stopped</option>
setTaskStatus(""); </select>
}} <button
> type="button"
{t("configSync.expand")} disabled={!hasCycleFilters}
</button> onClick={() => {
</td> setCycleKeyword("");
<td title={c.id} className="pt-list-num">{c.id.slice(0, 8)}</td> setCycleStatus("");
<td>{c.trigger_mode}</td> setCyclePage(1);
<td> }}
<span >
className={`pt-list-status ${ {t("common.clearFilters")}
c.status === "running" || c.status === "pending" </button>
? "pt-list-status--running" <button
: c.status === "paused" type="button"
? "pt-list-status--paused" disabled={exportingCycles || cycleTotal === 0}
: c.status === "failed" onClick={() => void exportCyclesCsv()}
? "pt-list-status--failed" >
: c.status === "success" || c.status === "completed" {exportingCycles ? t("common.exporting") : t("common.exportCsv")}
? "pt-list-status--ok" </button>
: "pt-list-status--other" </div>
}`}
> {cyclesQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
{c.status} {cyclesQuery.isError ? <p className="error-text">{formatErr(cyclesQuery.error)}</p> : null}
</span>
</td> <div className="pt-list-table-wrap">
<td> <table className="data-table pt-list-table">
{c.success_count}/{c.planned_count} · fail {c.fail_count} <thead>
</td> <tr>
<td>{c.started_at ? formatSystemTime(c.started_at) : "-"}</td> <th />
<td>{c.ended_at ? formatSystemTime(c.ended_at) : "-"}</td> <th>ID</th>
<td> <th>{t("configSync.col.trigger")}</th>
<div className="btn-row"> <th>{t("configSync.col.status")}</th>
{c.status === "running" || c.status === "pending" ? ( <th>{t("configSync.col.progress")}</th>
<button type="button" onClick={() => pauseMut.mutate(c.id)} disabled={pauseMut.isPending}> <th>{t("configSync.col.started")}</th>
{t("configSync.pause")} <th>{t("configSync.col.ended")}</th>
</button> <th>{t("configSync.col.actions")}</th>
) : null} </tr>
{c.status === "paused" ? ( </thead>
<button type="button" onClick={() => resumeMut.mutate(c.id)} disabled={resumeMut.isPending}> <tbody>
{t("configSync.resume")} {cycles.map((c) => (
</button> <tr key={c.id}>
) : null} <td>
{c.status === "running" || c.status === "paused" || c.status === "pending" ? (
<button <button
type="button" type="button"
onClick={() => { className="btn btn--sm btn--ghost"
if (window.confirm(t("configSync.confirmStop"))) stopMut.mutate(c.id); onClick={() => openCycleTasks(c.id)}
}}
disabled={stopMut.isPending}
> >
{t("configSync.stop")} {t("configSync.expand")}
</button> </button>
) : null} </td>
</div> <td title={c.id} className="pt-list-num">{c.id.slice(0, 8)}</td>
</td> <td>{c.trigger_mode}</td>
</tr> <td>
))} <span
{!cycles.length ? ( className={`pt-list-status ${
<tr> c.status === "running" || c.status === "pending"
<td colSpan={8} className="muted"> ? "pt-list-status--running"
{t("configSync.cyclesEmpty")} : c.status === "paused"
</td> ? "pt-list-status--paused"
</tr> : c.status === "failed"
) : null} ? "pt-list-status--failed"
</tbody> : c.status === "success" || c.status === "completed"
</table> ? "pt-list-status--ok"
</div> : "pt-list-status--other"
<div className="pager pt-list-pager"> }`}
<span className="muted"> >
{t("common.pagerMeta", { {c.status}
total: String(cycleTotal), </span>
page: String(cyclePage), </td>
pages: String(cyclePages), <td>
})} {c.success_count}/{c.planned_count} · fail {c.fail_count}
</span> </td>
<div className="btn-row"> <td>{c.started_at ? formatSystemTime(c.started_at) : "-"}</td>
<button type="button" disabled={cyclePage <= 1} onClick={() => setCyclePage((p) => p - 1)}> <td>{c.ended_at ? formatSystemTime(c.ended_at) : "-"}</td>
{t("common.prevPage")} <td>
</button> <div className="btn-row">
<button type="button" disabled={cyclePage >= cyclePages} onClick={() => setCyclePage((p) => p + 1)}> {c.status === "running" || c.status === "pending" ? (
{t("common.nextPage")} <button type="button" onClick={() => pauseMut.mutate(c.id)} disabled={pauseMut.isPending}>
</button> {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> </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> </div>
{expandedCycleId ? ( {expandedCycleId ? (
@ -553,6 +741,13 @@ export function ConfigSyncPage() {
</p> </p>
</div> </div>
<div className="btn-row ops-detail-modal__actions"> <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("")}> <button type="button" onClick={() => setExpandedCycleId("")}>
{t("networkConfigs.close")} {t("networkConfigs.close")}
</button> </button>
@ -575,7 +770,7 @@ export function ConfigSyncPage() {
setTaskPage(1); setTaskPage(1);
}} }}
> >
<option value="">{t("configSync.allStatus")}</option> <option value="">{t("configSync.statusAll")}</option>
<option value="pending">pending</option> <option value="pending">pending</option>
<option value="running">running</option> <option value="running">running</option>
<option value="success">success</option> <option value="success">success</option>
@ -586,9 +781,7 @@ export function ConfigSyncPage() {
{tasksQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null} {tasksQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
{tasksQuery.isError ? ( {tasksQuery.isError ? (
<p className="ops-detail-modal__error"> <p className="ops-detail-modal__error">{formatErr(tasksQuery.error)}</p>
{tasksQuery.error instanceof Error ? tasksQuery.error.message : String(tasksQuery.error)}
</p>
) : null} ) : null}
<div className="ops-detail-modal__scroll"> <div className="ops-detail-modal__scroll">
@ -628,25 +821,19 @@ export function ConfigSyncPage() {
</div> </div>
<div className="ops-detail-modal__foot"> <div className="ops-detail-modal__foot">
<span className="muted"> <ListPager
{t("common.pagerMeta", { page={taskPage}
total: String(tasksQuery.data?.total ?? 0), pages={taskPages}
page: String(taskPage), total={taskTotal}
pages: String(pageCount(Number(tasksQuery.data?.total || 0), 20)), pageSize={taskPageSize}
})} pageSizeOptions={[20, 50, 100, 200]}
</span> onPageChange={setTaskPage}
<div className="btn-row"> onPageSizeChange={(size) => {
<button type="button" disabled={taskPage <= 1} onClick={() => setTaskPage((p) => p - 1)}> setTaskPageSize(size);
{t("common.prevPage")} setTaskPage(1);
</button> }}
<button disabled={tasksQuery.isLoading}
type="button" />
disabled={taskPage >= pageCount(Number(tasksQuery.data?.total || 0), 20)}
onClick={() => setTaskPage((p) => p + 1)}
>
{t("common.nextPage")}
</button>
</div>
</div> </div>
</div> </div>
</div> </div>

View file

@ -89,6 +89,8 @@ import {
import { queryKeys } from "../constants/queryKeys"; import { queryKeys } from "../constants/queryKeys";
import { useI18n } from "../i18n"; import { useI18n } from "../i18n";
import { useToast } from "../hooks/useToast"; import { useToast } from "../hooks/useToast";
import { useAuth } from "../auth/AuthContext";
import { formatErr } from "../services/api";
import { openOrFocusModule } from "../utils/moduleWindows"; import { openOrFocusModule } from "../utils/moduleWindows";
import type { import type {
FabricNodeSearchHit, FabricNodeSearchHit,
@ -114,6 +116,8 @@ const TopologyReactFlowView = lazy(() => import("./topology/TopologyReactFlowVie
export function TopologyPage() { export function TopologyPage() {
const { t, locale } = useI18n(); const { t, locale } = useI18n();
const { showOk, showError } = useToast(); const { showOk, showError } = useToast();
const { hasScope, isAdmin } = useAuth();
const canWriteTopology = isAdmin || hasScope("ne:write");
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const [keyword, setKeyword] = useState(""); const [keyword, setKeyword] = useState("");
@ -295,7 +299,13 @@ export function TopologyPage() {
return () => window.removeEventListener("beforeunload", onBeforeUnload); 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({ const canvas = useTopologyCanvas({
mapId, mapId,
@ -354,6 +364,8 @@ export function TopologyPage() {
worldScatter, worldScatter,
showWorldScatter, showWorldScatter,
canvasGraphLoading, canvasGraphLoading,
canvasGraphError,
canvasGraphErrorMsg,
canvasGraphEmpty, canvasGraphEmpty,
worldNeedsApply, worldNeedsApply,
canvasGraphRefreshing, canvasGraphRefreshing,
@ -537,7 +549,10 @@ export function TopologyPage() {
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyMaps }); await queryClient.invalidateQueries({ queryKey: queryKeys.topologyMaps });
showOk(t("topology.saved")); 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( const isValidConnection = useCallback(
@ -545,14 +560,18 @@ export function TopologyPage() {
const source = String(connection.source || ""); const source = String(connection.source || "");
const target = String(connection.target || ""); const target = String(connection.target || "");
if (!source || !target || source === target) return false; 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], [edges],
); );
const onConnect = useCallback( const onConnect = useCallback(
(connection: Connection) => { (connection: Connection) => {
if (!mapId || !isValidConnection(connection)) return; if (!canWriteTopology || !mapId || !isValidConnection(connection)) return;
const source = String(connection.source || ""); const source = String(connection.source || "");
const target = String(connection.target || ""); const target = String(connection.target || "");
if (!source || !target) return; if (!source || !target) return;
@ -586,11 +605,12 @@ export function TopologyPage() {
setEdges((es) => [...es, edge]); setEdges((es) => [...es, edge]);
markDirty(); markDirty();
}, },
[mapId, isValidConnection, pushHistory, edgeDefaults, setEdges, markDirty], [mapId, isValidConnection, pushHistory, edgeDefaults, setEdges, markDirty, canWriteTopology],
); );
const addPaletteItems = useCallback( const addPaletteItems = useCallback(
async (items: PaletteItem[], origin?: { x: number; y: number }): Promise<boolean> => { async (items: PaletteItem[], origin?: { x: number; y: number }): Promise<boolean> => {
if (!canWriteTopology) return false;
if (!mapId) { if (!mapId) {
showError(t("topology.selectMap")); showError(t("topology.selectMap"));
return false; return false;
@ -700,6 +720,7 @@ export function TopologyPage() {
const onCanvasDrop = (e: React.DragEvent) => { const onCanvasDrop = (e: React.DragEvent) => {
e.preventDefault(); e.preventDefault();
if (!canWriteTopology) return;
const raw = e.dataTransfer.getData(PALETTE_DND); const raw = e.dataTransfer.getData(PALETTE_DND);
if (!raw || !rfRef.current) return; if (!raw || !rfRef.current) return;
try { try {
@ -1076,7 +1097,7 @@ export function TopologyPage() {
} }
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "s") { if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "s") {
e.preventDefault(); e.preventDefault();
if (mapId && dirtyRef.current && !saveMut.isPending) saveMut.mutate(); if (canWriteTopology && mapId && dirtyRef.current && !saveMut.isPending) saveMut.mutate();
return; return;
} }
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "z" && !e.shiftKey) { if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "z" && !e.shiftKey) {
@ -1095,6 +1116,7 @@ export function TopologyPage() {
return; return;
} }
if (e.key === "Delete" || e.key === "Backspace") { if (e.key === "Delete" || e.key === "Backspace") {
if (!canWriteTopology) return;
if (nodes.some((n) => n.selected) || edges.some((ed) => ed.selected)) { if (nodes.some((n) => n.selected) || edges.some((ed) => ed.selected)) {
e.preventDefault(); e.preventDefault();
void removeSelected(); void removeSelected();
@ -1103,7 +1125,7 @@ export function TopologyPage() {
}; };
window.addEventListener("keydown", onKey); window.addEventListener("keydown", onKey);
return () => window.removeEventListener("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(() => { useEffect(() => {
if (!ctxMenu) return; if (!ctxMenu) return;
@ -1366,6 +1388,10 @@ export function TopologyPage() {
const discoverOneFor = (node: Node<NeNodeData> | null) => { const discoverOneFor = (node: Node<NeNodeData> | null) => {
closeCtxMenu(); closeCtxMenu();
if (!canWriteTopology) {
showError(t("topology.readOnlyHint"));
return;
}
const id = String(node?.data.managed_ne_id || node?.data.ume_ne_id || "").trim(); const id = String(node?.data.managed_ne_id || node?.data.ume_ne_id || "").trim();
if (!id) { if (!id) {
showError(t("topology.discoverOneNeedNe")); showError(t("topology.discoverOneNeedNe"));
@ -1917,6 +1943,7 @@ export function TopologyPage() {
<main className="topo-main"> <main className="topo-main">
{canvasMode ? ( {canvasMode ? (
<TopologyToolbar <TopologyToolbar
readOnly={!canWriteTopology}
breadcrumbFolders={breadcrumbFolders} breadcrumbFolders={breadcrumbFolders}
activeView={activeView} activeView={activeView}
activeRegion={activeRegion} activeRegion={activeRegion}
@ -2051,6 +2078,11 @@ export function TopologyPage() {
{truncateBannerText} {truncateBannerText}
</div> </div>
) : null} ) : null}
{!canWriteTopology && mapId ? (
<div className="topo-readonly-banner" role="status">
{t("topology.readOnlyBanner")}
</div>
) : null}
<TopologyViewTools <TopologyViewTools
fullscreen={fullscreen} fullscreen={fullscreen}
toolbarSlot={viewToolsToolbarSlot} toolbarSlot={viewToolsToolbarSlot}
@ -2248,6 +2280,23 @@ export function TopologyPage() {
</button> </button>
</div> </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 ? ( {treeRoot && canvasGraphLoading ? (
<div className="topo-canvas__overlay topo-canvas__overlay--loading" role="status"> <div className="topo-canvas__overlay topo-canvas__overlay--loading" role="status">
<span className="topo-loading-spinner topo-loading-spinner--lg" aria-hidden="true" /> <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 { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useSearchParams } from "react-router-dom"; 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 { import {
fetchCliTargets, fetchCliTargets,
fetchFabricEdges, fetchFabricEdges,
fetchLldpCollectDashboard, fetchLldpCollectDashboard,
fetchLldpCollectJob, fetchLldpCollectJob,
fetchLldpCollectJobs, fetchLldpCollectJobs,
formatErr,
pauseLldpCollectJob, pauseLldpCollectJob,
resumeLldpCollectJob, resumeLldpCollectJob,
startLldpCollect, startLldpCollect,
stopLldpCollectJob, stopLldpCollectJob,
updateLldpCollectPolicy, updateLldpCollectPolicy,
} from "../../services/api"; } from "../../services/api";
import { queryKeys } from "../../constants/queryKeys"; import type {
import { HelpHint } from "../../components/HelpHint"; CliTargetItem,
import { useI18n } from "../../i18n"; ConfigSyncTargetRef,
import { useToast } from "../../hooks/useToast"; FabricEdge,
import type { CliTargetItem, ConfigSyncTargetRef, TopologyDiscoverJobItem } from "../../types"; LldpCollectJobSummary,
TopologyDiscoverJobItem,
} from "../../types";
import { downloadCsv, fetchAllPages } from "../../utils/csvExport";
import { pageCount } from "../../utils/display"; import { pageCount } from "../../utils/display";
import { formatSystemTime } from "../../utils/time"; import { formatSystemTime } from "../../utils/time";
@ -89,15 +99,27 @@ export function LldpLinksPage() {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const [jobPage, setJobPage] = 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 debouncedJobKeyword = useDebouncedValue(jobKeyword, 300);
const [expandedJobId, setExpandedJobId] = useState(""); const [expandedJobId, setExpandedJobId] = useState("");
const [itemPage, setItemPage] = useState(1); 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 [itemDetail, setItemDetail] = useState<TopologyDiscoverJobItem | null>(null);
const ITEM_PAGE_SIZE = 20;
const [edgeStatus, setEdgeStatus] = useState<"all" | "active" | "missing">("all"); const [edgeStatus, setEdgeStatus] = useState<"all" | "active" | "missing">("all");
const [edgeSource, setEdgeSource] = useState("");
const [edgeKeyword, setEdgeKeyword] = useState(""); const [edgeKeyword, setEdgeKeyword] = useState("");
const [edgePage, setEdgePage] = useState(1); 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 [enabled, setEnabled] = useState(false);
const [intervalValue, setIntervalValue] = useState(1); const [intervalValue, setIntervalValue] = useState(1);
@ -156,6 +178,8 @@ export function LldpLinksPage() {
if (!jobId) return; if (!jobId) return;
setItemDetail(null); setItemDetail(null);
setItemPage(1); setItemPage(1);
setItemStatus("");
setItemKeyword("");
setExpandedJobId(jobId); setExpandedJobId(jobId);
setSearchParams( setSearchParams(
(prev) => { (prev) => {
@ -168,15 +192,33 @@ export function LldpLinksPage() {
}, [searchParams, setSearchParams]); }, [searchParams, setSearchParams]);
const jobsQuery = useQuery({ const jobsQuery = useQuery({
queryKey: queryKeys.lldpCollectJobs(jobPage), queryKey: queryKeys.lldpCollectJobs(jobPage, jobPageSize, jobStatus, debouncedJobKeyword),
queryFn: () => fetchLldpCollectJobs({ page: jobPage, pageSize: 10 }), queryFn: () =>
fetchLldpCollectJobs({
page: jobPage,
pageSize: jobPageSize,
status: jobStatus,
keyword: debouncedJobKeyword,
}),
staleTime: 1000, staleTime: 1000,
refetchInterval: () => (dashQuery.data?.running_job ? POLL_MS : false), refetchInterval: () => (dashQuery.data?.running_job ? POLL_MS : false),
}); });
const jobDetailQuery = useQuery({ const jobDetailQuery = useQuery({
queryKey: queryKeys.lldpCollectJob(expandedJobId, itemPage), queryKey: queryKeys.lldpCollectJob(
queryFn: () => fetchLldpCollectJob(expandedJobId, { page: itemPage, pageSize: ITEM_PAGE_SIZE }), expandedJobId,
itemPage,
itemPageSize,
itemStatus,
debouncedItemKeyword,
),
queryFn: () =>
fetchLldpCollectJob(expandedJobId, {
page: itemPage,
pageSize: itemPageSize,
status: itemStatus,
keyword: debouncedItemKeyword,
}),
enabled: Boolean(expandedJobId), enabled: Boolean(expandedJobId),
staleTime: 800, staleTime: 800,
refetchInterval: () => (dashQuery.data?.running_job?.id === expandedJobId ? POLL_MS : false), refetchInterval: () => (dashQuery.data?.running_job?.id === expandedJobId ? POLL_MS : false),
@ -191,13 +233,20 @@ export function LldpLinksPage() {
}); });
const edgesQuery = useQuery({ const edgesQuery = useQuery({
queryKey: queryKeys.fabricEdges(edgeStatus, edgeKeyword, edgePage), queryKey: queryKeys.fabricEdges(
edgeStatus,
debouncedEdgeKeyword,
edgePage,
edgePageSize,
edgeSource,
),
queryFn: () => queryFn: () =>
fetchFabricEdges({ fetchFabricEdges({
status: edgeStatus === "all" ? "" : edgeStatus, status: edgeStatus === "all" ? "" : edgeStatus,
keyword: edgeKeyword, keyword: debouncedEdgeKeyword,
source: edgeSource || undefined,
page: edgePage, page: edgePage,
pageSize: EDGE_PAGE_SIZE, pageSize: edgePageSize,
}), }),
staleTime: 2000, staleTime: 2000,
refetchInterval: () => (dashQuery.data?.running_job ? POLL_MS : false), refetchInterval: () => (dashQuery.data?.running_job ? POLL_MS : false),
@ -302,14 +351,192 @@ export function LldpLinksPage() {
const last = dash?.last_job; const last = dash?.last_job;
const jobs = jobsQuery.data?.items ?? []; const jobs = jobsQuery.data?.items ?? [];
const jobTotal = Number(jobsQuery.data?.total || 0); 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 selectedCount = useMemo(() => Object.keys(selectedMap).length, [selectedMap]);
const detailItems = jobDetailQuery.data?.items ?? []; const detailItems = jobDetailQuery.data?.items ?? [];
const itemTotal = Number(jobDetailQuery.data?.items_total ?? detailItems.length); 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 edgeItems = edgesQuery.data?.items ?? [];
const edgeTotal = Number(edgesQuery.data?.total || 0); 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 toggleTarget = (row: CliTargetItem) => {
const source = row.source === "ume" ? "ume" : "managed"; const source = row.source === "ume" ? "ume" : "managed";
@ -576,6 +803,17 @@ export function LldpLinksPage() {
<option value="active">{t("lldpLinks.edgeStatusActive")}</option> <option value="active">{t("lldpLinks.edgeStatusActive")}</option>
<option value="missing">{t("lldpLinks.edgeStatusMissing")}</option> <option value="missing">{t("lldpLinks.edgeStatusMissing")}</option>
</select> </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 <input
value={edgeKeyword} value={edgeKeyword}
placeholder={t("lldpLinks.edgeKeywordPh")} placeholder={t("lldpLinks.edgeKeywordPh")}
@ -584,6 +822,13 @@ export function LldpLinksPage() {
setEdgePage(1); setEdgePage(1);
}} }}
/> />
<button
type="button"
disabled={exportingEdges || edgeTotal === 0}
onClick={() => void exportEdgesCsv()}
>
{exportingEdges ? t("common.exporting") : t("common.exportCsv")}
</button>
</div> </div>
<div className="pt-list-table-wrap"> <div className="pt-list-table-wrap">
<table className="data-table pt-list-table"> <table className="data-table pt-list-table">
@ -643,27 +888,54 @@ export function LldpLinksPage() {
</tbody> </tbody>
</table> </table>
</div> </div>
<div className="pager"> <ListPager
<button type="button" disabled={edgePage <= 1} onClick={() => setEdgePage((p) => p - 1)}> page={edgePage}
{t("common.prevPage")} pages={edgePages}
</button> total={edgeTotal}
<span className="muted"> pageSize={edgePageSize}
{t("common.pagerMeta", { pageSizeOptions={[20, 50, 100, 200]}
total: String(edgeTotal), onPageChange={setEdgePage}
page: String(edgePage), onPageSizeChange={(size) => {
pages: String(edgePages), setEdgePageSize(size);
})} setEdgePage(1);
</span> }}
<button disabled={edgesQuery.isLoading}
type="button" />
disabled={edgePage >= edgePages}
onClick={() => setEdgePage((p) => p + 1)}
>
{t("common.nextPage")}
</button>
</div>
<h3 style={{ marginTop: 24 }}>{t("lldpLinks.jobsTitle")}</h3> <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"> <div className="pt-list-table-wrap">
<table className="data-table pt-list-table"> <table className="data-table pt-list-table">
<thead> <thead>
@ -691,6 +963,8 @@ export function LldpLinksPage() {
className="btn btn--sm btn--ghost" className="btn btn--sm btn--ghost"
onClick={() => { onClick={() => {
setItemDetail(null); setItemDetail(null);
setItemStatus("");
setItemKeyword("");
setExpandedJobId(job.id); setExpandedJobId(job.id);
setItemPage(1); setItemPage(1);
}} }}
@ -774,21 +1048,19 @@ export function LldpLinksPage() {
</tbody> </tbody>
</table> </table>
</div> </div>
<div className="pager"> <ListPager
<button type="button" disabled={jobPage <= 1} onClick={() => setJobPage((p) => p - 1)}> page={jobPage}
{t("common.prevPage")} pages={jobPages}
</button> total={jobTotal}
<span className="muted"> pageSize={jobPageSize}
{t("common.pagerMeta", { pageSizeOptions={[10, 20, 50, 100]}
total: String(jobTotal), onPageChange={setJobPage}
page: String(jobPage), onPageSizeChange={(size) => {
pages: String(jobPages), setJobPageSize(size);
})} setJobPage(1);
</span> }}
<button type="button" disabled={jobPage >= jobPages} onClick={() => setJobPage((p) => p + 1)}> disabled={jobsQuery.isLoading}
{t("common.nextPage")} />
</button>
</div>
{expandedJobId ? ( {expandedJobId ? (
<div <div
@ -823,11 +1095,21 @@ export function LldpLinksPage() {
</p> </p>
</div> </div>
<div className="btn-row ops-detail-modal__actions"> <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 <button
type="button" type="button"
onClick={() => { onClick={() => {
setExpandedJobId(""); setExpandedJobId("");
setItemDetail(null); setItemDetail(null);
setItemStatus("");
setItemKeyword("");
setItemPage(1);
}} }}
> >
{t("networkConfigs.close")} {t("networkConfigs.close")}
@ -835,6 +1117,29 @@ export function LldpLinksPage() {
</div> </div>
</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.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
{jobDetailQuery.data?.error ? ( {jobDetailQuery.data?.error ? (
<p className="ops-detail-modal__error">{jobDetailQuery.data.error}</p> <p className="ops-detail-modal__error">{jobDetailQuery.data.error}</p>
@ -897,25 +1202,19 @@ export function LldpLinksPage() {
</div> </div>
<div className="ops-detail-modal__foot"> <div className="ops-detail-modal__foot">
<span className="muted"> <ListPager
{t("common.pagerMeta", { page={itemPage}
total: String(itemTotal), pages={itemPages}
page: String(itemPage), total={itemTotal}
pages: String(itemPages), pageSize={itemPageSize}
})} pageSizeOptions={[20, 50, 100]}
</span> onPageChange={setItemPage}
<div className="btn-row"> onPageSizeChange={(size) => {
<button type="button" disabled={itemPage <= 1} onClick={() => setItemPage((p) => p - 1)}> setItemPageSize(size);
{t("common.prevPage")} setItemPage(1);
</button> }}
<button disabled={jobDetailQuery.isLoading}
type="button" />
disabled={itemPage >= itemPages}
onClick={() => setItemPage((p) => p + 1)}
>
{t("common.nextPage")}
</button>
</div>
</div> </div>
</div> </div>
</div> </div>

View file

@ -1,8 +1,13 @@
import { useState } from "react"; import { useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { fetchUmeCurrentAlarms } from "../../services/api"; import { ListPager } from "../../components/ListPager";
import { queryKeys } from "../../constants/queryKeys"; import { queryKeys } from "../../constants/queryKeys";
import { useDebouncedValue } from "../../hooks/useDebouncedValue";
import { useToast } from "../../hooks/useToast";
import { useI18n } from "../../i18n"; 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 { pageCount } from "../../utils/display";
import { formatSystemTime } from "../../utils/time"; import { formatSystemTime } from "../../utils/time";
@ -19,19 +24,24 @@ function severityClass(sev: string | null | undefined): string {
/** Current UME alarms query view for Network Management. */ /** Current UME alarms query view for Network Management. */
export function NetworkAlarmsPage() { export function NetworkAlarmsPage() {
const { t } = useI18n(); const { t } = useI18n();
const { showOk, showError } = useToast();
const [curSeverity, setCurSeverity] = useState(""); const [curSeverity, setCurSeverity] = useState("");
const [curCleared, setCurCleared] = useState(""); const [curCleared, setCurCleared] = useState("");
const [curHostName, setCurHostName] = useState(""); const [curHostName, setCurHostName] = useState("");
const [curKeyword, setCurKeyword] = useState(""); const [curKeyword, setCurKeyword] = useState("");
const [curPage, setCurPage] = useState(1); const [curPage, setCurPage] = useState(1);
const [curPageSize, setCurPageSize] = useState(50); const [curPageSize, setCurPageSize] = useState(50);
const [exporting, setExporting] = useState(false);
const debouncedKeyword = useDebouncedValue(curKeyword, 300);
const debouncedHost = useDebouncedValue(curHostName, 300);
const currentQuery = useQuery({ const currentQuery = useQuery({
queryKey: queryKeys.umeCurrentAlarms( queryKey: queryKeys.umeCurrentAlarms(
curSeverity, curSeverity,
curCleared, curCleared,
curHostName, debouncedHost,
curKeyword, debouncedKeyword,
curPage, curPage,
curPageSize, curPageSize,
), ),
@ -39,8 +49,8 @@ export function NetworkAlarmsPage() {
fetchUmeCurrentAlarms({ fetchUmeCurrentAlarms({
severity: curSeverity, severity: curSeverity,
isCleared: curCleared, isCleared: curCleared,
hostName: curHostName, hostName: debouncedHost,
keyword: curKeyword, keyword: debouncedKeyword,
page: curPage, page: curPage,
pageSize: curPageSize, pageSize: curPageSize,
}), }),
@ -50,13 +60,57 @@ export function NetworkAlarmsPage() {
const items = currentQuery.data?.items || []; const items = currentQuery.data?.items || [];
const curTotal = Number(currentQuery.data?.total || 0); const curTotal = Number(currentQuery.data?.total || 0);
const curPages = pageCount(curTotal, curPageSize); 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 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 ( return (
<section className="panel"> <section className="panel">
<div className="panel__toolbar"> <div className="panel__toolbar">
<h2>{t("ume.alarms.title")}</h2> <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>
<div className="pt-list"> <div className="pt-list">
@ -99,8 +153,8 @@ export function NetworkAlarmsPage() {
}} }}
> >
<option value="">{t("ume.alarms.clearedAll")}</option> <option value="">{t("ume.alarms.clearedAll")}</option>
<option value="true">true</option> <option value="false">{t("ume.alarms.clearedNo")}</option>
<option value="false">false</option> <option value="true">{t("ume.alarms.clearedYes")}</option>
</select> </select>
<button <button
type="button" type="button"
@ -130,12 +184,12 @@ export function NetworkAlarmsPage() {
<table className="data-table pt-list-table"> <table className="data-table pt-list-table">
<thead> <thead>
<tr> <tr>
<th>time_created</th> <th>{t("ume.alarms.col.time")}</th>
<th>severity</th> <th>{t("ume.alarms.col.severity")}</th>
<th>ne_id</th> <th>{t("ume.alarms.col.neId")}</th>
<th>host_name</th> <th>{t("ume.alarms.col.hostName")}</th>
<th>ne_type</th> <th>{t("ume.alarms.col.neType")}</th>
<th>cause</th> <th>{t("ume.alarms.col.cause")}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -177,46 +231,19 @@ export function NetworkAlarmsPage() {
</div> </div>
)} )}
<div className="pager pt-list-pager"> <ListPager
<div className="pager__meta muted"> page={curPage}
{t("common.pagerMeta", { pages={curPages}
total: String(curTotal), total={curTotal}
page: String(curPage), pageSize={curPageSize}
pages: String(curPages), pageSizeOptions={[50, 100, 200, 500]}
})} onPageChange={setCurPage}
</div> onPageSizeChange={(size) => {
<div className="pager__controls btn-row"> setCurPageSize(size);
<button setCurPage(1);
className="pager__btn" }}
type="button" disabled={currentQuery.isLoading}
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>
</div> </div>
</section> </section>
); );

View file

@ -1,15 +1,21 @@
import { useState } from "react"; import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query"; 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 { import {
downloadNeConfigSnapshot, downloadNeConfigSnapshot,
fetchNeConfigSnapshotDetail, fetchNeConfigSnapshotDetail,
fetchNeConfigSnapshots, fetchNeConfigSnapshots,
formatErr,
} from "../../services/api"; } from "../../services/api";
import { queryKeys } from "../../constants/queryKeys"; import type { NeConfigSnapshotMeta } from "../../types";
import { useI18n } from "../../i18n"; import { downloadCsv, fetchAllPages } from "../../utils/csvExport";
import { useToast } from "../../hooks/useToast";
import { openNewModuleWindow } from "../../utils/moduleWindows";
import { pageCount } from "../../utils/display"; import { pageCount } from "../../utils/display";
import { openNewModuleWindow } from "../../utils/moduleWindows";
import { formatSystemTime } from "../../utils/time"; import { formatSystemTime } from "../../utils/time";
function fmtBytes(n: number): string { function fmtBytes(n: number): string {
@ -22,18 +28,45 @@ function fmtBytes(n: number): string {
export function NetworkConfigsPage() { export function NetworkConfigsPage() {
const { t } = useI18n(); const { t } = useI18n();
const { showOk, showError } = useToast(); const { showOk, showError } = useToast();
const [searchParams, setSearchParams] = useSearchParams();
const [page, setPage] = useState(1); 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 [source, setSource] = useState("");
const [vendor, setVendor] = useState(""); const [vendor, setVendor] = useState("");
const [selected, setSelected] = useState<{ source: string; id: string } | null>(null); const [selected, setSelected] = useState<{ source: string; id: string } | null>(null);
const [tab, setTab] = useState<"primary" | "alt">("primary"); const [tab, setTab] = useState<"primary" | "alt">("primary");
const [exporting, setExporting] = useState(""); 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({ const listQuery = useQuery({
queryKey: queryKeys.networkConfigs(page, keyword, source, vendor), queryKey: queryKeys.networkConfigs(page, debouncedKeyword, source, debouncedVendor, pageSize),
queryFn: () => queryFn: () =>
fetchNeConfigSnapshots({ page, pageSize: 20, keyword, source, vendor }), fetchNeConfigSnapshots({
page,
pageSize,
keyword: debouncedKeyword,
source,
vendor: debouncedVendor,
}),
staleTime: 5000, staleTime: 5000,
}); });
@ -46,7 +79,7 @@ export function NetworkConfigsPage() {
const items = listQuery.data?.items ?? []; const items = listQuery.data?.items ?? [];
const total = Number(listQuery.data?.total || 0); const total = Number(listQuery.data?.total || 0);
const pages = pageCount(total, 20); const pages = pageCount(total, pageSize);
const detail = detailQuery.data; const detail = detailQuery.data;
const showAlt = Boolean(detail?.has_alt); const showAlt = Boolean(detail?.has_alt);
const hasFilters = Boolean(keyword || source || vendor); const hasFilters = Boolean(keyword || source || vendor);
@ -62,16 +95,69 @@ export function NetworkConfigsPage() {
await downloadNeConfigSnapshot(src, id, field); await downloadNeConfigSnapshot(src, id, field);
showOk(t("networkConfigs.exportOk")); showOk(t("networkConfigs.exportOk"));
} catch (err) { } catch (err) {
showError(String(err)); showError(formatErr(err));
} finally { } finally {
setExporting(""); 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 ( return (
<section className="panel"> <section className="panel">
<div className="panel__toolbar"> <div className="panel__toolbar">
<h2>{t("networkConfigs.title")}</h2> <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>
<div className="pt-list"> <div className="pt-list">
@ -118,6 +204,7 @@ export function NetworkConfigsPage() {
</div> </div>
{listQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null} {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 ? ( {!items.length && !listQuery.isLoading ? (
<div className="pt-list-empty"> <div className="pt-list-empty">
@ -199,23 +286,19 @@ export function NetworkConfigsPage() {
</div> </div>
)} )}
<div className="pager pt-list-pager"> <ListPager
<span className="muted"> page={page}
{t("common.pagerMeta", { pages={pages}
total: String(total), total={total}
page: String(page), pageSize={pageSize}
pages: String(pages), pageSizeOptions={[20, 50, 100]}
})} onPageChange={setPage}
</span> onPageSizeChange={(size) => {
<div className="btn-row"> setPageSize(size);
<button type="button" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}> setPage(1);
{t("common.prevPage")} }}
</button> disabled={listQuery.isLoading}
<button type="button" disabled={page >= pages} onClick={() => setPage((p) => p + 1)}> />
{t("common.nextPage")}
</button>
</div>
</div>
</div> </div>
{selected ? ( {selected ? (

View file

@ -1,11 +1,15 @@
import { useState } from "react"; import { useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { fetchCliTargets } from "../../services/api"; import { ListPager } from "../../components/ListPager";
import { queryKeys } from "../../constants/queryKeys"; import { queryKeys } from "../../constants/queryKeys";
import { useDebouncedValue } from "../../hooks/useDebouncedValue";
import { useToast } from "../../hooks/useToast";
import { useI18n } from "../../i18n"; 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"; import { pageCount } from "../../utils/display";
import { openNewModuleWindow } from "../../utils/moduleWindows";
const PAGE_SIZE = 50;
function connectStatusClass(status: string | null | undefined): string { function connectStatusClass(status: string | null | undefined): string {
const s = String(status || "").trim().toLowerCase(); const s = String(status || "").trim().toLowerCase();
@ -17,28 +21,80 @@ function connectStatusClass(status: string | null | undefined): string {
return "pt-list-status--other"; 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() { export function NetworkDevicesPage() {
const { t } = useI18n(); const { t } = useI18n();
const { showOk, showError } = useToast();
const [keyword, setKeyword] = useState(""); const [keyword, setKeyword] = useState("");
const [source, setSource] = useState<"all" | "managed" | "ume">("all"); const [source, setSource] = useState<"all" | "managed" | "ume">("all");
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(50);
const [exporting, setExporting] = useState(false);
const debouncedKeyword = useDebouncedValue(keyword, 300);
const listQuery = useQuery({ const listQuery = useQuery({
queryKey: queryKeys.cliTargets(`${source}:${keyword}`, page, PAGE_SIZE), queryKey: queryKeys.cliTargets(`${source}:${debouncedKeyword}`, page, pageSize),
queryFn: () => fetchCliTargets({ source, keyword, page, pageSize: PAGE_SIZE }), queryFn: () => fetchCliTargets({ source, keyword: debouncedKeyword, page, pageSize }),
staleTime: 5000, staleTime: 5000,
}); });
const items = listQuery.data?.items ?? []; const items = listQuery.data?.items ?? [];
const total = Number(listQuery.data?.total || 0); 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 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 ( return (
<section className="panel"> <section className="panel">
<div className="panel__toolbar"> <div className="panel__toolbar">
<h2>{t("networkDevices.title")}</h2> <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>
<div className="pt-list"> <div className="pt-list">
@ -76,6 +132,7 @@ export function NetworkDevicesPage() {
</div> </div>
{listQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null} {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 ? ( {!items.length && !listQuery.isLoading ? (
<div className="pt-list-empty"> <div className="pt-list-empty">
@ -92,6 +149,7 @@ export function NetworkDevicesPage() {
<th>{t("networkDevices.col.vendor")}</th> <th>{t("networkDevices.col.vendor")}</th>
<th>{t("networkDevices.col.deviceType")}</th> <th>{t("networkDevices.col.deviceType")}</th>
<th>{t("networkDevices.col.connect")}</th> <th>{t("networkDevices.col.connect")}</th>
<th>{t("networkDevices.col.actions")}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -107,6 +165,24 @@ export function NetworkDevicesPage() {
{row.connect_status || "—"} {row.connect_status || "—"}
</span> </span>
</td> </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> </tr>
))} ))}
</tbody> </tbody>
@ -114,23 +190,19 @@ export function NetworkDevicesPage() {
</div> </div>
)} )}
<div className="pager pt-list-pager"> <ListPager
<span className="muted"> page={page}
{t("common.pagerMeta", { pages={pages}
total: String(total), total={total}
page: String(page), pageSize={pageSize}
pages: String(pages), pageSizeOptions={[20, 50, 100, 200]}
})} onPageChange={setPage}
</span> onPageSizeChange={(size) => {
<div className="btn-row"> setPageSize(size);
<button type="button" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}> setPage(1);
{t("common.prevPage")} }}
</button> disabled={listQuery.isLoading}
<button type="button" disabled={page >= pages} onClick={() => setPage((p) => p + 1)}> />
{t("common.nextPage")}
</button>
</div>
</div>
</div> </div>
</section> </section>
); );

View file

@ -1,6 +1,7 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useNavigate, useSearchParams } from "react-router-dom"; import { useNavigate, useSearchParams } from "react-router-dom";
import { ListPager } from "../../components/ListPager";
import { import {
createPortTrafficDevice, createPortTrafficDevice,
deletePortTrafficDevice, deletePortTrafficDevice,
@ -10,6 +11,7 @@ import {
fetchPortTrafficDevices, fetchPortTrafficDevices,
fetchPortTrafficEvents, fetchPortTrafficEvents,
fetchPortTrafficTargets, fetchPortTrafficTargets,
formatErr,
pausePortTrafficDevice, pausePortTrafficDevice,
putPortTrafficInterfaces, putPortTrafficInterfaces,
rebindPortTrafficDevice, rebindPortTrafficDevice,
@ -19,13 +21,16 @@ import {
} from "../../services/api"; } from "../../services/api";
import { queryKeys } from "../../constants/queryKeys"; import { queryKeys } from "../../constants/queryKeys";
import { useI18n } from "../../i18n"; import { useI18n } from "../../i18n";
import { useDebouncedValue } from "../../hooks/useDebouncedValue";
import { useToast } from "../../hooks/useToast"; import { useToast } from "../../hooks/useToast";
import type { import type {
CliTargetItem, CliTargetItem,
PortTrafficDevice, PortTrafficDevice,
PortTrafficDiscoverPort, PortTrafficDiscoverPort,
PortTrafficEvent,
PortTrafficIfaceIn, PortTrafficIfaceIn,
} from "../../types"; } from "../../types";
import { downloadCsv, fetchAllPages } from "../../utils/csvExport";
import { pageCount } from "../../utils/display"; import { pageCount } from "../../utils/display";
import { formatSystemTime } from "../../utils/time"; import { formatSystemTime } from "../../utils/time";
@ -34,10 +39,11 @@ const TARGET_PAGE_SIZE = 20;
type ViewMode = "list" | "wizard" | "edit"; 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 === "running") return "running";
if (status === "paused") return "paused"; if (status === "paused") return "paused";
if (status === "stopped") return "stopped"; if (status === "stopped") return "stopped";
if (status === "draft") return "draft";
return "other"; return "other";
} }
@ -122,6 +128,11 @@ export function PortTrafficPage() {
const [view, setView] = useState<ViewMode>("list"); const [view, setView] = useState<ViewMode>("list");
const [listPage, setListPage] = useState(1); 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 // Wizard / edit
const [editDeviceId, setEditDeviceId] = useState(""); const [editDeviceId, setEditDeviceId] = useState("");
@ -151,6 +162,7 @@ export function PortTrafficPage() {
const [wizardStep, setWizardStep] = useState<1 | 2 | 3>(1); const [wizardStep, setWizardStep] = useState<1 | 2 | 3>(1);
const [logDeviceId, setLogDeviceId] = useState(""); const [logDeviceId, setLogDeviceId] = useState("");
const [logDeviceLabel, setLogDeviceLabel] = useState(""); const [logDeviceLabel, setLogDeviceLabel] = useState("");
const [logKeyword, setLogKeyword] = useState("");
const [rebindDevice, setRebindDevice] = useState<PortTrafficDevice | null>(null); const [rebindDevice, setRebindDevice] = useState<PortTrafficDevice | null>(null);
const [rebindKeyword, setRebindKeyword] = useState(""); const [rebindKeyword, setRebindKeyword] = useState("");
const [rebindPage, setRebindPage] = useState(1); const [rebindPage, setRebindPage] = useState(1);
@ -169,8 +181,14 @@ export function PortTrafficPage() {
}); });
const devicesQuery = useQuery({ const devicesQuery = useQuery({
queryKey: queryKeys.portTrafficDevices(listPage), queryKey: queryKeys.portTrafficDevices(listPage, listPageSize, listStatus, debouncedListKeyword),
queryFn: () => fetchPortTrafficDevices({ page: listPage, pageSize: 10 }), queryFn: () =>
fetchPortTrafficDevices({
page: listPage,
pageSize: listPageSize,
status: listStatus || undefined,
keyword: debouncedListKeyword || undefined,
}),
staleTime: 5000, staleTime: 5000,
refetchInterval: (q) => { refetchInterval: (q) => {
const items = q.state.data?.items || []; const items = q.state.data?.items || [];
@ -570,17 +588,109 @@ export function PortTrafficPage() {
const dash = dashQuery.data; const dash = dashQuery.data;
const devices = devicesQuery.data?.items || []; 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 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 statusText = (status: string) => {
const tone = statusTone(status); const tone = statusTone(status);
if (tone === "running") return t("portTraffic.statusRunning"); if (tone === "running") return t("portTraffic.statusRunning");
if (tone === "paused") return t("portTraffic.statusPaused"); if (tone === "paused") return t("portTraffic.statusPaused");
if (tone === "stopped") return t("portTraffic.statusStopped"); if (tone === "stopped") return t("portTraffic.statusStopped");
if (tone === "draft") return t("portTraffic.statusDraft");
return status; 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 ( return (
<section className="panel"> <section className="panel">
<div className="panel__toolbar"> <div className="panel__toolbar">
@ -601,6 +711,13 @@ export function PortTrafficPage() {
<button type="button" onClick={() => navigate("/network/tasks/port-traffic/wall")}> <button type="button" onClick={() => navigate("/network/tasks/port-traffic/wall")}>
{t("portTraffic.wall")} {t("portTraffic.wall")}
</button> </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}> <button type="button" className="btn-primary" onClick={openWizard}>
{t("portTraffic.create")} {t("portTraffic.create")}
</button> </button>
@ -634,157 +751,189 @@ export function PortTrafficPage() {
</div> </div>
</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"> <div className="pt-list-empty">
<p>{t("portTraffic.empty")}</p> <p>{t("portTraffic.empty")}</p>
<button type="button" className="btn-primary" onClick={openWizard}> {!hasListFilters ? (
{t("portTraffic.create")} <button type="button" className="btn-primary" onClick={openWizard}>
</button> {t("portTraffic.create")}
</button>
) : null}
</div> </div>
) : ( ) : devices.length ? (
<> <div className="pt-list-table-wrap">
<div className="pt-list-table-wrap"> <table className="data-table pt-list-table">
<table className="data-table pt-list-table"> <thead>
<thead> <tr>
<tr> <th>{t("portTraffic.col.device")}</th>
<th>{t("portTraffic.col.device")}</th> <th>{t("portTraffic.col.status")}</th>
<th>{t("portTraffic.col.status")}</th> <th>{t("portTraffic.col.ports")}</th>
<th>{t("portTraffic.col.ports")}</th> <th>{t("portTraffic.col.interval")}</th>
<th>{t("portTraffic.col.interval")}</th> <th>{t("portTraffic.col.last")}</th>
<th>{t("portTraffic.col.last")}</th> <th>{t("portTraffic.col.actions")}</th>
<th>{t("portTraffic.col.actions")}</th> </tr>
</tr> </thead>
</thead> <tbody>
<tbody> {devices.map((row) => {
{devices.map((row) => { const tone = statusTone(row.status);
const tone = statusTone(row.status); return (
return ( <tr key={row.id}>
<tr key={row.id}> <td>
<td> <div className="pt-list-task-name">{deviceLabel(row)}</div>
<div className="pt-list-task-name">{deviceLabel(row)}</div> <div className="muted" style={{ fontSize: 12 }}>
<div className="muted" style={{ fontSize: 12 }}> {row.ne_ip || row.ne_id}
{row.ne_ip || row.ne_id} {row.vendor ? ` · ${row.vendor}` : ""}
{row.vendor ? ` · ${row.vendor}` : ""} {row.note ? ` · ${row.note}` : ""}
{row.note ? ` · ${row.note}` : ""} </div>
</div> </td>
</td> <td>
<td> <span className={`pt-list-status pt-list-status--${tone}`}>
<span className={`pt-list-status pt-list-status--${tone}`}> {statusText(row.status)}
{statusText(row.status)} </span>
{row.collect_running ? (
<span className="pt-list-status pt-list-status--collect">
{t("portTraffic.collecting")}
</span> </span>
{row.collect_running ? ( ) : null}
<span className="pt-list-status pt-list-status--collect"> </td>
{t("portTraffic.collecting")} <td className="pt-list-num">
</span> {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} ) : null}
</td> {row.status !== "running" ? (
<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 <button
type="button" type="button"
className="btn-primary" disabled={startMut.isPending}
onClick={() => openWallList()} onClick={() => startMut.mutate(row.id)}
> >
{t("portTraffic.wall")} {t("portTraffic.start")}
</button> </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 <button
type="button" type="button"
className={row.last_error ? "pt-list-log-btn--error" : undefined} disabled={pauseMut.isPending}
onClick={() => { onClick={() => pauseMut.mutate(row.id)}
setLogDeviceId(row.id);
setLogDeviceLabel(deviceLabel(row));
}}
> >
{t("portTraffic.log")} {t("portTraffic.pause")}
</button> </button>
)}
{row.status !== "stopped" ? (
<button <button
type="button" type="button"
className="btn--danger" disabled={stopMut.isPending}
disabled={deleteMut.isPending} onClick={() => stopMut.mutate(row.id)}
onClick={() => {
if (window.confirm(t("portTraffic.confirmDelete"))) {
deleteMut.mutate(row.id);
}
}}
> >
{t("portTraffic.delete")} {t("portTraffic.stop")}
</button> </button>
</div> ) : null}
</td> <button
</tr> type="button"
); className={row.last_error ? "pt-list-log-btn--error" : undefined}
})} onClick={() => {
</tbody> setLogDeviceId(row.id);
</table> setLogDeviceLabel(deviceLabel(row));
</div> setLogKeyword("");
<div className="pager pt-list-pager"> }}
<button >
type="button" {t("portTraffic.log")}
className="pager__btn" </button>
disabled={listPage <= 1} <button
onClick={() => setListPage((p) => p - 1)} type="button"
> className="btn--danger"
‹ disabled={deleteMut.isPending}
</button> onClick={() => {
<span className="muted"> if (window.confirm(t("portTraffic.confirmDelete"))) {
{listPage}/{Math.max(1, pages)} deleteMut.mutate(row.id);
</span> }
<button }}
type="button" >
className="pager__btn" {t("portTraffic.delete")}
disabled={listPage >= pages} </button>
onClick={() => setListPage((p) => p + 1)} </div>
> </td>
› </tr>
</button> );
</div> })}
</> </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> </div>
) : null} ) : null}
@ -1179,14 +1328,7 @@ export function PortTrafficPage() {
) : null} ) : null}
{logDeviceId ? ( {logDeviceId ? (
<div <div className="modal-backdrop" role="presentation" onClick={closeLog}>
className="modal-backdrop"
role="presentation"
onClick={() => {
setLogDeviceId("");
setLogDeviceLabel("");
}}
>
<div <div
className="modal modal--wide pt-log-modal" className="modal modal--wide pt-log-modal"
role="dialog" role="dialog"
@ -1201,23 +1343,35 @@ export function PortTrafficPage() {
{t("portTraffic.logDevice")}: {logDeviceLabel || "—"} {t("portTraffic.logDevice")}: {logDeviceLabel || "—"}
</p> </p>
</div> </div>
<button <div className="btn-row">
type="button" <button
onClick={() => { type="button"
setLogDeviceId(""); disabled={!filteredLogEvents.length}
setLogDeviceLabel(""); onClick={exportEventsCsv}
}} >
> {t("common.exportCsv")}
{t("portTraffic.logClose")} </button>
</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> </div>
{logQuery.isLoading ? ( {logQuery.isLoading ? (
<p className="muted">…</p> <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> <p className="muted">{t("portTraffic.logEmpty")}</p>
) : ( ) : (
<div className="pt-log-modal__list"> <div className="pt-log-modal__list">
{(logQuery.data?.items || []).map((ev) => { {filteredLogEvents.map((ev) => {
const level = String(ev.level || "error").toLowerCase(); const level = String(ev.level || "error").toLowerCase();
const tone = const tone =
level === "warn" || level === "warning" level === "warn" || level === "warning"

View file

@ -6,7 +6,6 @@ import {
deleteFabricNodes, deleteFabricNodes,
fetchFabricNodes, fetchFabricNodes,
fetchTopologyTree, fetchTopologyTree,
generateTopologySlices,
matchFabricNodes, matchFabricNodes,
patchFabricNodeTags, patchFabricNodeTags,
} from "../../services/api"; } from "../../services/api";
@ -14,7 +13,7 @@ import { queryKeys } from "../../constants/queryKeys";
import { useI18n } from "../../i18n"; import { useI18n } from "../../i18n";
import { useToast } from "../../hooks/useToast"; import { useToast } from "../../hooks/useToast";
import { openOrFocusModule } from "../../utils/moduleWindows"; import { openOrFocusModule } from "../../utils/moduleWindows";
import type { FabricNodeSearchHit, SliceGenerateResult, TopologyTreeFolderItem } from "../../types"; import type { FabricNodeSearchHit, TopologyTreeFolderItem } from "../../types";
const PAGE_SIZE = 50; const PAGE_SIZE = 50;
@ -37,15 +36,42 @@ function isFabricNodeDeletable(n: FabricNodeSearchHit): boolean {
return src === "lldp" || src === "topology" || src === "webcrt"; 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 FILTER_MAJOR = [
const s = String(role || "").trim().toLowerCase(); { value: "", labelKey: "topoClassify.filterLevelAll" },
if (s === "core") return t("topoClassify.roleCore"); { value: "0", labelKey: "topoClassify.levelExternal" },
if (s === "aggregation") return t("topoClassify.roleAggregation"); { value: "1", labelKey: "topoClassify.levelCore" },
if (s === "access") return t("topoClassify.roleAccess"); { value: "2", labelKey: "topoClassify.levelAgg" },
if (s === "unknown") return t("topoClassify.roleUnknown"); { value: "3", labelKey: "topoClassify.levelAccess" },
return role || "-"; ] 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 { function managedSourceLabel(t: (k: string) => string, source: string): string {
@ -64,7 +90,7 @@ export function TopologyClassifyPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [keyword, setKeyword] = useState(""); const [keyword, setKeyword] = useState("");
const [filterRole, setFilterRole] = useState(""); const [filterMajor, setFilterMajor] = useState("");
const [filterRegion, setFilterRegion] = useState(""); const [filterRegion, setFilterRegion] = useState("");
const [unmatched, setUnmatched] = useState(""); const [unmatched, setUnmatched] = useState("");
const [linkStatus, setLinkStatus] = useState(""); const [linkStatus, setLinkStatus] = useState("");
@ -89,23 +115,15 @@ export function TopologyClassifyPage() {
const [matchField, setMatchField] = useState<"name" | "ip" | "name_ip">("name"); const [matchField, setMatchField] = useState<"name" | "ip" | "name_ip">("name");
const [matchedIds, setMatchedIds] = useState<string[]>([]); const [matchedIds, setMatchedIds] = useState<string[]>([]);
const [matchTotal, setMatchTotal] = useState(0); const [matchTotal, setMatchTotal] = useState(0);
const [assignRole, setAssignRole] = useState("core"); const [assignLevel, setAssignLevel] = useState("1");
const [assignRegion, setAssignRegion] = useState(""); 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 [selected, setSelected] = useState<Record<string, boolean>>({});
const [editingId, setEditingId] = useState(""); const [editingId, setEditingId] = useState("");
const [editRole, setEditRole] = useState(""); const [editLevel, setEditLevel] = useState("");
const [editRegion, setEditRegion] = 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({ const treeQuery = useQuery({
queryKey: queryKeys.topologyTree, queryKey: queryKeys.topologyTree,
queryFn: fetchTopologyTree, queryFn: fetchTopologyTree,
@ -120,16 +138,17 @@ export function TopologyClassifyPage() {
const listQuery = useQuery({ const listQuery = useQuery({
queryKey: queryKeys.fabricNodeInventory( queryKey: queryKeys.fabricNodeInventory(
keyword, keyword,
filterRole, "",
filterRegion, filterRegion,
unmatched, unmatched,
linkStatus, linkStatus,
page, page,
filterMajor,
), ),
queryFn: () => queryFn: () =>
fetchFabricNodes({ fetchFabricNodes({
keyword, keyword,
role: filterRole, levelMajor: filterMajor || undefined,
regionFolderId: filterRegion, regionFolderId: filterRegion,
unmatched, unmatched,
linkStatus, linkStatus,
@ -163,7 +182,9 @@ export function TopologyClassifyPage() {
fabric_node_ids: ids.length ? ids : matchedIds, fabric_node_ids: ids.length ? ids : matchedIds,
dry_run: false, 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") { if (assignWhat === "region" || assignWhat === "both") {
body.region_folder_id = assignRegion || ""; body.region_folder_id = assignRegion || "";
} }
@ -180,11 +201,18 @@ export function TopologyClassifyPage() {
}); });
const patchMut = useMutation({ const patchMut = useMutation({
mutationFn: () => mutationFn: () => {
patchFabricNodeTags(editingId, { let level: number | null;
role: editRole, try {
level = parseLevelInput(editLevel);
} catch {
throw new Error(t("topoClassify.levelInvalid"));
}
return patchFabricNodeTags(editingId, {
level,
region_folder_id: editRegion || "", region_folder_id: editRegion || "",
}), });
},
onSuccess: async () => { onSuccess: async () => {
showOk(t("topoClassify.rowSaved")); showOk(t("topoClassify.rowSaved"));
setEditingId(""); setEditingId("");
@ -213,36 +241,6 @@ export function TopologyClassifyPage() {
onError: (err) => showError(String(err)), 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 items = listQuery.data?.items || [];
const deletableSelected = items const deletableSelected = items
.filter((n) => selected[n.id] && isFabricNodeDeletable(n)) .filter((n) => selected[n.id] && isFabricNodeDeletable(n))
@ -254,7 +252,7 @@ export function TopologyClassifyPage() {
const startEdit = (n: FabricNodeSearchHit) => { const startEdit = (n: FabricNodeSearchHit) => {
setEditingId(n.id); setEditingId(n.id);
setEditRole(n.role || ""); setEditLevel(n.level == null ? "" : formatLevel(n.level));
setEditRegion(n.region_folder_id || ""); setEditRegion(n.region_folder_id || "");
}; };
@ -278,7 +276,17 @@ export function TopologyClassifyPage() {
<button <button
type="button" type="button"
className="btn-primary" 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")} {t("topoClassify.openTopo")}
</button> </button>
@ -299,16 +307,15 @@ export function TopologyClassifyPage() {
/> />
<select <select
className="input" className="input"
value={filterRole} value={filterMajor}
onChange={(e) => { onChange={(e) => {
setFilterRole(e.target.value); setFilterMajor(e.target.value);
setPage(1); setPage(1);
}} }}
> >
<option value="">{t("topoClassify.filterRoleAll")}</option> {FILTER_MAJOR.map((r) => (
{ROLE_OPTIONS.map((r) => ( <option key={r.value || "all"} value={r.value}>
<option key={r} value={r}> {t(r.labelKey)}
{roleLabel(t, r)}
</option> </option>
))} ))}
</select> </select>
@ -337,7 +344,7 @@ export function TopologyClassifyPage() {
> >
<option value="">{t("topoClassify.filterUnmatchedOff")}</option> <option value="">{t("topoClassify.filterUnmatchedOff")}</option>
<option value="any">{t("topoClassify.kindAny")}</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> <option value="region">{t("topoClassify.kindRegion")}</option>
</select> </select>
<select <select
@ -390,26 +397,26 @@ export function TopologyClassifyPage() {
<select <select
className="input" className="input"
value={assignWhat} 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="region">{t("topoClassify.assignRegion")}</option>
<option value="both">{t("topoClassify.assignBoth")}</option> <option value="both">{t("topoClassify.assignBoth")}</option>
</select> </select>
{assignWhat !== "region" ? ( {assignWhat !== "region" ? (
<select <select
className="input" className="input"
value={assignRole} value={assignLevel}
onChange={(e) => setAssignRole(e.target.value)} onChange={(e) => setAssignLevel(e.target.value)}
> >
{ROLE_OPTIONS.map((r) => ( {LEVEL_PRESETS.map((r) => (
<option key={r} value={r}> <option key={r.value} value={r.value}>
{roleLabel(t, r)} {levelPresetLabel(t, r.value)}
</option> </option>
))} ))}
</select> </select>
) : null} ) : null}
{assignWhat !== "role" ? ( {assignWhat !== "level" ? (
<select <select
className="input" className="input"
value={assignRegion} value={assignRegion}
@ -475,7 +482,7 @@ export function TopologyClassifyPage() {
<th>{t("topoClassify.colNe")}</th> <th>{t("topoClassify.colNe")}</th>
<th>IP</th> <th>IP</th>
<th>{t("topoClassify.colLink")}</th> <th>{t("topoClassify.colLink")}</th>
<th>{t("topoClassify.colRole")}</th> <th>{t("topoClassify.colLevel")}</th>
<th>{t("topoClassify.colRegion")}</th> <th>{t("topoClassify.colRegion")}</th>
<th>{t("topoClassify.colActions")}</th> <th>{t("topoClassify.colActions")}</th>
</tr> </tr>
@ -503,20 +510,14 @@ export function TopologyClassifyPage() {
</td> </td>
<td> <td>
{editing ? ( {editing ? (
<select <input
className="input" className="input"
value={editRole} value={editLevel}
onChange={(e) => setEditRole(e.target.value)} placeholder="1 / 1.1 / 2.1"
> onChange={(e) => setEditLevel(e.target.value)}
<option value="">-</option> />
{ROLE_OPTIONS.map((r) => (
<option key={r} value={r}>
{roleLabel(t, r)}
</option>
))}
</select>
) : ( ) : (
roleLabel(t, n.role || "") formatLevel(n.level)
)} )}
</td> </td>
<td> <td>
@ -627,95 +628,6 @@ export function TopologyClassifyPage() {
</button> </button>
</div> </div>
</section> </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> </div>
); );
} }

View file

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

View file

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

View file

@ -1,9 +1,10 @@
import type { RefObject } from "react";
import { createPortal } from "react-dom";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import type { RefObject } from "react";
import type { Node } from "@xyflow/react"; import type { Node } from "@xyflow/react";
import { createPortal } from "react-dom";
import { queryKeys } from "../../constants/queryKeys"; import { queryKeys } from "../../constants/queryKeys";
import { useI18n } from "../../i18n"; import { useI18n } from "../../i18n";
import { useToast } from "../../hooks/useToast";
import { updateLldpCollectPolicy } from "../../services/api"; import { updateLldpCollectPolicy } from "../../services/api";
import { import {
DEFAULT_CANVAS_BG, DEFAULT_CANVAS_BG,
@ -87,6 +88,7 @@ export type TopologyViewToolsProps = {
export function TopologyViewTools(props: TopologyViewToolsProps) { export function TopologyViewTools(props: TopologyViewToolsProps) {
const { t } = useI18n(); const { t } = useI18n();
const { showError } = useToast();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { const {
fullscreen, fullscreen,
@ -225,7 +227,7 @@ export function TopologyViewTools(props: TopologyViewToolsProps) {
void queryClient.invalidateQueries({ queryKey: queryKeys.lldpCollectDashboard }); void queryClient.invalidateQueries({ queryKey: queryKeys.lldpCollectDashboard });
}) })
.catch(() => { .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 { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useQuery, type QueryClient } from "@tanstack/react-query"; import { useQuery, type QueryClient } from "@tanstack/react-query";
import type { Edge, Node, ReactFlowInstance } from "@xyflow/react"; 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 { queryKeys } from "../../../constants/queryKeys";
import type { TopologyTreeViewItem, TopologyViewGraph, TopologyWorldTransform } from "../../../types"; import type { TopologyTreeViewItem, TopologyViewGraph, TopologyWorldTransform } from "../../../types";
import { import {
@ -493,6 +493,8 @@ export function useTopologyCanvas(opts: UseTopologyCanvasOptions) {
const showWorldScatter = const showWorldScatter =
isWorldFlatCanvas && worldVisualLod !== "full" && worldScatter.length > 0; isWorldFlatCanvas && worldVisualLod !== "full" && worldScatter.length > 0;
const canvasGraphLoading = Boolean(mapId) && (graphQuery.isPending || !graphQuery.data); const canvasGraphLoading = Boolean(mapId) && (graphQuery.isPending || !graphQuery.data);
const canvasGraphError = Boolean(mapId) && graphQuery.isError;
const canvasGraphErrorMsg = graphQuery.error ? formatErr(graphQuery.error) : "";
const canvasGraphEmpty = const canvasGraphEmpty =
Boolean(mapId) && Boolean(mapId) &&
graphQuery.isSuccess && graphQuery.isSuccess &&
@ -551,6 +553,8 @@ export function useTopologyCanvas(opts: UseTopologyCanvasOptions) {
worldDockMe, worldDockMe,
showWorldScatter, showWorldScatter,
canvasGraphLoading, canvasGraphLoading,
canvasGraphError,
canvasGraphErrorMsg,
canvasGraphEmpty, canvasGraphEmpty,
worldNeedsApply, worldNeedsApply,
canvasGraphRefreshing, canvasGraphRefreshing,

View file

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

View file

@ -2,6 +2,7 @@ import type {
CollectionJobDetail, CollectionJobDetail,
CollectionJobItem, CollectionJobItem,
CollectionDashboard, CollectionDashboard,
CollectionPolicy,
CollectionRunList, CollectionRunList,
EligibleNeItem, EligibleNeItem,
IntegrationStatus, 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 { export function formatApiDetail(detail: unknown): string {
if (typeof detail === "string") return detail; if (typeof detail === "string") return detail;
if (Array.isArray(detail)) { if (Array.isArray(detail)) {
@ -175,16 +183,18 @@ export function formatApiDetail(detail: unknown): string {
} }
export const apiGet = async <T,>(path: string): Promise<T> => { 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())) { 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) { if (res.status === 401) {
handleUnauthorized(path); handleUnauthorized(path);
throw new Error("401 unauthorized"); throw new ApiRequestError(401, data.detail || "unauthorized");
} }
if (!res.ok) throw new Error(`${res.status} ${path}`); if (!res.ok) throw new ApiRequestError(res.status, data.detail || `${res.status} ${path}`);
return (await res.json()) as T; return data as T;
}; };
export const apiPost = async <T,>(path: string, body: unknown): Promise<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); handleUnauthorized(path);
throw new Error("unauthorized"); throw new Error("unauthorized");
} }
const text = await res.text(); const data = await parseApiResponse(res);
const data = text ? JSON.parse(text) : {}; if (res.status === 401) {
if (!res.ok) throw new Error(String((data as { detail?: string }).detail || `${res.status} import`)); 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; return data as ManagedNeImportResult;
}; };
@ -624,10 +637,17 @@ export const createNeCollection = (body: {
}) => }) =>
apiPost<CollectionJobItem>("/v1/ne-collections", 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(); const p = new URLSearchParams();
p.set("page", String(Math.max(1, params.page))); p.set("page", String(Math.max(1, params.page)));
p.set("page_size", String(Math.max(1, Math.min(100, params.pageSize)))); 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[] }>( return apiGet<{ total: number; page: number; page_size: number; items: CollectionJobItem[] }>(
`/v1/ne-collections?${p.toString()}`, `/v1/ne-collections?${p.toString()}`,
); );
@ -636,6 +656,14 @@ export const fetchNeCollections = (params: { page: number; pageSize: number }) =
export const fetchCollectionDashboard = () => export const fetchCollectionDashboard = () =>
apiGet<CollectionDashboard>("/v1/ne-collections/dashboard"); 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 fetchCollectionJob = (jobId: string) => apiGet<CollectionJobDetail>(`/v1/ne-collections/${jobId}`);
export const fetchCollectionRuns = (params: { export const fetchCollectionRuns = (params: {
@ -1233,6 +1261,8 @@ export const fetchFabricSummary = () => apiGet<FabricSummary>("/v1/topology/fabr
export const fetchFabricNodes = (params?: { export const fetchFabricNodes = (params?: {
keyword?: string; keyword?: string;
role?: string; role?: string;
level?: string;
levelMajor?: string;
regionFolderId?: string; regionFolderId?: string;
unmatched?: string; unmatched?: string;
linkStatus?: string; linkStatus?: string;
@ -1242,6 +1272,8 @@ export const fetchFabricNodes = (params?: {
const p = new URLSearchParams(); const p = new URLSearchParams();
if (params?.keyword) p.set("keyword", params.keyword); if (params?.keyword) p.set("keyword", params.keyword);
if (params?.role) p.set("role", params.role); 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?.regionFolderId) p.set("region_folder_id", params.regionFolderId);
if (params?.unmatched) p.set("unmatched", params.unmatched); if (params?.unmatched) p.set("unmatched", params.unmatched);
if (params?.linkStatus) p.set("link_status", params.linkStatus); if (params?.linkStatus) p.set("link_status", params.linkStatus);
@ -1272,6 +1304,7 @@ export const bulkTagFabricNodes = (body: {
fabric_node_ids?: string[]; fabric_node_ids?: string[];
pattern?: string; pattern?: string;
match_field?: string; match_field?: string;
level?: number | null;
role?: string | null; role?: string | null;
region_folder_id?: string | null; region_folder_id?: string | null;
dry_run?: boolean; dry_run?: boolean;
@ -1280,6 +1313,7 @@ export const bulkTagFabricNodes = (body: {
dry_run: boolean; dry_run: boolean;
matched: number; matched: number;
updated: number; updated: number;
level?: number | null;
role?: string | null; role?: string | null;
region_folder_id?: string | null; region_folder_id?: string | null;
samples: Array<Record<string, string>>; samples: Array<Record<string, string>>;
@ -1287,7 +1321,7 @@ export const bulkTagFabricNodes = (body: {
export const patchFabricNodeTags = ( export const patchFabricNodeTags = (
fabricNodeId: string, 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>( apiPatch<import("../types").FabricNodeSearchHit>(
`/v1/topology/fabric/nodes/${encodeURIComponent(fabricNodeId)}/tags`, `/v1/topology/fabric/nodes/${encodeURIComponent(fabricNodeId)}/tags`,
@ -1317,14 +1351,6 @@ export const purgePlaceholderFabricNodes = (fabricNodeIds: string[]) =>
fabric_node_ids: fabricNodeIds, 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 }) => { export const searchFabricNodes = (params?: { q?: string; page?: number; pageSize?: number }) => {
const p = new URLSearchParams(); const p = new URLSearchParams();
if (params?.q) p.set("q", params.q); if (params?.q) p.set("q", params.q);
@ -1399,10 +1425,17 @@ export const resumeLldpCollectJob = (jobId: string) =>
export const stopLldpCollectJob = (jobId: string) => export const stopLldpCollectJob = (jobId: string) =>
apiPost<TopologyDiscoverJob>(`/v1/topology/lldp-collect/jobs/${encodeURIComponent(jobId)}/stop`, {}); 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(); const p = new URLSearchParams();
p.set("page", String(Math.max(1, Number(params.page || 1)))); 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))))); 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[] }>( return apiGet<{ total: number; page: number; page_size: number; items: LldpCollectJobSummary[] }>(
`/v1/topology/lldp-collect/jobs?${p.toString()}`, `/v1/topology/lldp-collect/jobs?${p.toString()}`,
); );
@ -1410,11 +1443,13 @@ export const fetchLldpCollectJobs = (params: { page?: number; pageSize?: number
export const fetchLldpCollectJob = ( export const fetchLldpCollectJob = (
jobId: string, jobId: string,
params?: { page?: number; pageSize?: number }, params?: { page?: number; pageSize?: number; status?: string; keyword?: string },
) => { ) => {
const p = new URLSearchParams(); const p = new URLSearchParams();
p.set("page", String(Math.max(1, Number(params?.page || 1)))); 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))))); 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>( return apiGet<TopologyDiscoverJob>(
`/v1/topology/lldp-collect/jobs/${encodeURIComponent(jobId)}?${p.toString()}`, `/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>) => export const updateConfigSyncPolicy = (body: Partial<ConfigSyncPolicy>) =>
apiPut<ConfigSyncPolicy>("/v1/config-sync/policy", body); 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(); const p = new URLSearchParams();
p.set("page", String(Math.max(1, Number(params.page || 1)))); 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))))); 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[] }>( return apiGet<{ total: number; page: number; page_size: number; items: ConfigSyncCycle[] }>(
`/v1/config-sync/cycles?${p.toString()}`, `/v1/config-sync/cycles?${p.toString()}`,
); );
@ -1554,10 +1596,17 @@ export const downloadNeConfigSnapshot = async (
export const fetchPortTrafficDashboard = () => export const fetchPortTrafficDashboard = () =>
apiGet<PortTrafficDashboard>("/v1/port-traffic/dashboard"); 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(); const p = new URLSearchParams();
p.set("page", String(Math.max(1, Number(params.page || 1)))); 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))))); 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[] }>( return apiGet<{ total: number; page: number; page_size: number; items: PortTrafficDevice[] }>(
`/v1/port-traffic/devices?${p.toString()}`, `/v1/port-traffic/devices?${p.toString()}`,
); );

View file

@ -320,6 +320,7 @@ export type CollectionJobItem = {
id: string; id: string;
title: string; title: string;
commands: string; commands: string;
trigger_mode?: string;
status: string; status: string;
ne_count: number; ne_count: number;
success_count: number; success_count: number;
@ -336,6 +337,7 @@ export type CollectionJobSummary = {
id: string; id: string;
title: string; title: string;
status: string; status: string;
trigger_mode?: string;
ne_count: number; ne_count: number;
success_count: number; success_count: number;
fail_count: number; fail_count: number;
@ -345,11 +347,30 @@ export type CollectionJobSummary = {
last_run_at?: string | null; 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 = { export type CollectionDashboard = {
job_count: number; job_count: number;
active_count: number; active_count: number;
running_job: CollectionJobSummary | null; running_job: CollectionJobSummary | null;
last_job: CollectionJobSummary | null; last_job: CollectionJobSummary | null;
next_due_at?: string | null;
policy?: CollectionPolicy | null;
}; };
export type CollectionRunItem = { export type CollectionRunItem = {
@ -480,6 +501,8 @@ export type FabricNodeSearchHit = {
vendor: string; vendor: string;
managed_ne_id?: string; managed_ne_id?: string;
ume_ne_id?: string; ume_ne_id?: string;
/** Layout rank major.minor; null/undefined = unclassified */
level?: number | null;
role?: string; role?: string;
region_folder_id?: string | null; region_folder_id?: string | null;
world_x?: number | 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 = { export type TopologyViewNodeItem = {
fabric_node_id: string; fabric_node_id: string;
managed_ne_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);
}