mirror of
https://github.com/hansjone/netx.git
synced 2026-10-17 21:53:22 +08:00
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:
parent
b1aee86701
commit
b81e5869a6
91 changed files with 10395 additions and 1513 deletions
|
|
@ -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;勿收到 <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 写拓扑。
|
||||||
|
|
|
||||||
54
alembic/versions/20260811_fabric_level.py
Normal file
54
alembic/versions/20260811_fabric_level.py
Normal 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
|
||||||
|
|
@ -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` **迁出**到本包,避免重复。
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
265
netx_api/collection_policy.py
Normal file
265
netx_api/collection_policy.py
Normal 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
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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]}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
):
|
):
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
113
netx_api/ne_collect_scheduler.py
Normal file
113
netx_api/ne_collect_scheduler.py
Normal 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),
|
||||||
|
}
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 ''",
|
||||||
|
|
|
||||||
|
|
@ -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()
|
|
||||||
role_updated += 1
|
|
||||||
elif not str(n.role or "").strip() and str(n.role_source or "") != "manual":
|
|
||||||
n.role = "unknown"
|
|
||||||
n.role_source = "rule"
|
|
||||||
n.updated_at = _utcnow()
|
n.updated_at = _utcnow()
|
||||||
|
level_updated += 1
|
||||||
|
|
||||||
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)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
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)
|
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
138
netx_api/topology_level.py
Normal 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()]
|
||||||
|
|
@ -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 ''",
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -467,6 +467,16 @@ def _apply_fabric_filters(
|
||||||
)
|
)
|
||||||
role_v = str(role or "").strip().lower()
|
role_v = str(role or "").strip().lower()
|
||||||
if role_v:
|
if 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)
|
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:
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
}
|
||||||
|
|
@ -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",
|
||||||
|
)
|
||||||
|
|
@ -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 = [
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
|
@ -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,67 +244,57 @@ 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):
|
||||||
|
|
@ -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 beautify_dual_unit_positions(
|
def _place_petal_bands(
|
||||||
state: LayoutState,
|
paths: list[list[str]],
|
||||||
unit: DualUnit,
|
*,
|
||||||
params: LayoutParams | None = None,
|
a: str,
|
||||||
|
b: str,
|
||||||
|
rx: float,
|
||||||
|
ry_step: float,
|
||||||
|
chord_gap: float = 160.0,
|
||||||
) -> dict[str, tuple[float, float]]:
|
) -> dict[str, tuple[float, float]]:
|
||||||
"""Beautify one unit: multi-corridor→H/V lanes; chains→straight (no 回字).
|
"""Nested half-ellipse bands; chord = portals only (hollow eye interior).
|
||||||
|
|
||||||
Local coords; portals on x-axis. dual_mass aligns onto world portals.
|
Short corridors inner, longer outer. Shared nodes claimed by first path.
|
||||||
Multi-corridor (kind=petal): parallel horizontal lanes with vertical
|
Soft apex offset so single-mid corridors do not stack on the mid vertical.
|
||||||
stubs at portal x — no ellipse arcs.
|
``chord_gap`` kept for call-site compat (unused).
|
||||||
"""
|
"""
|
||||||
params = params or LayoutParams()
|
del chord_gap
|
||||||
pitch = max(float(params.pitch), 170.0)
|
pos: dict[str, tuple[float, float]] = {a: (-rx, 0.0), b: (rx, 0.0)}
|
||||||
ry = max(float(params.lane), float(params.side), 220.0)
|
|
||||||
a, b = unit.portal_a, unit.portal_b
|
|
||||||
paths = _normalize_paths(unit)
|
|
||||||
kind = classify_dual_unit(unit)
|
|
||||||
max_mid = max((len(p) - 2 for p in paths), default=0)
|
|
||||||
half = max(pitch * (max_mid + 1) * 0.5, pitch * 4.0, 700.0)
|
|
||||||
pos: dict[str, tuple[float, float]] = {a: (-half, 0.0), b: (half, 0.0)}
|
|
||||||
|
|
||||||
if kind == "petal":
|
|
||||||
# Parallel H/V lanes: first/last mid share portal x → V stub + H spine.
|
|
||||||
band_i = 0
|
band_i = 0
|
||||||
for p in paths:
|
for p in paths:
|
||||||
mid = p[1:-1]
|
mid = p[1:-1]
|
||||||
if not mid:
|
if not mid:
|
||||||
continue
|
continue
|
||||||
side = 1 if band_i % 2 == 0 else -1
|
side = 1 if band_i % 2 == 0 else -1
|
||||||
amp = ry * (0.85 + 0.35 * (band_i // 2))
|
nest = band_i // 2
|
||||||
|
ry = ry_step * (nest + 1)
|
||||||
band_i += 1
|
band_i += 1
|
||||||
n_mid = len(mid)
|
m = len(p)
|
||||||
for k, n in enumerate(mid):
|
|
||||||
|
# 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:
|
if n in pos:
|
||||||
continue
|
continue
|
||||||
if n_mid == 1:
|
t = j / (m - 1) if m > 1 else 0.5
|
||||||
pos[n] = (0.0, side * amp)
|
ang = math.pi * (1.0 - t)
|
||||||
|
if side < 0:
|
||||||
|
ang = -ang
|
||||||
|
x = rx * math.cos(ang)
|
||||||
|
y = ry * math.sin(ang)
|
||||||
|
# Soft hollow: do not sit on the exact mid vertical (visual spine).
|
||||||
|
if abs(x) < rx * 0.08:
|
||||||
|
x = math.copysign(rx * 0.08, x if abs(x) > 1e-9 else float(side))
|
||||||
|
# Keep roughly on the ellipse by scaling y down slightly.
|
||||||
|
y = y * 0.98
|
||||||
|
pos[n] = (x, y)
|
||||||
|
return pos
|
||||||
|
|
||||||
|
|
||||||
|
def _unit_member_links(
|
||||||
|
paths: list[list[str]],
|
||||||
|
a: str,
|
||||||
|
b: str,
|
||||||
|
state: LayoutState,
|
||||||
|
) -> list[tuple[str, str]]:
|
||||||
|
members = {a, b}
|
||||||
|
for p in paths:
|
||||||
|
members.update(p)
|
||||||
|
return [e for e in state.links if e[0] in members and e[1] in members]
|
||||||
|
|
||||||
|
|
||||||
|
def _best_path_order_for_crossings(
|
||||||
|
paths: list[list[str]],
|
||||||
|
*,
|
||||||
|
a: str,
|
||||||
|
b: str,
|
||||||
|
rx: float,
|
||||||
|
ry_step: float,
|
||||||
|
state: LayoutState,
|
||||||
|
names: dict[str, str],
|
||||||
|
chord_gap: float = 160.0,
|
||||||
|
) -> list[list[str]]:
|
||||||
|
"""Pick nest order: barycenter seed + adjacent same-side swaps to cut x."""
|
||||||
|
base = _order_paths_for_nest(paths, names)
|
||||||
|
if len(base) <= 2:
|
||||||
|
return base
|
||||||
|
|
||||||
|
links = _unit_member_links(base, a, b, state)
|
||||||
|
|
||||||
|
def score(order: list[list[str]]) -> int:
|
||||||
|
pos = _place_petal_bands(
|
||||||
|
order, a=a, b=b, rx=rx, ry_step=ry_step, chord_gap=chord_gap
|
||||||
|
)
|
||||||
|
return count_edge_crossings(pos, links)
|
||||||
|
|
||||||
|
best = list(base)
|
||||||
|
best_x = score(best)
|
||||||
|
# Adjacent swaps within the interleaved list (preserves ±y nesting pattern).
|
||||||
|
improved = True
|
||||||
|
rounds = 0
|
||||||
|
while improved and rounds < 24:
|
||||||
|
improved = False
|
||||||
|
rounds += 1
|
||||||
|
for i in range(len(best) - 1):
|
||||||
|
trial = list(best)
|
||||||
|
trial[i], trial[i + 1] = trial[i + 1], trial[i]
|
||||||
|
x = score(trial)
|
||||||
|
if x < best_x:
|
||||||
|
best, best_x = trial, x
|
||||||
|
improved = True
|
||||||
|
break
|
||||||
|
return best
|
||||||
|
|
||||||
|
|
||||||
|
def beautify_dual_unit_positions(
|
||||||
|
state: LayoutState,
|
||||||
|
unit: DualUnit,
|
||||||
|
params: LayoutParams | None = None,
|
||||||
|
) -> dict[str, tuple[float, float]]:
|
||||||
|
"""Beautify one unit: petal→nested ellipse arcs; chains→outward fans.
|
||||||
|
|
||||||
|
Goal: fewer crossings, readable spread, **hollow eye interior**, low
|
||||||
|
overlap. Portals on x-axis only; corridors on ± ellipse bands; **long
|
||||||
|
tails park outside the eye** (do not pierce nested rings).
|
||||||
|
"""
|
||||||
|
params = params or LayoutParams()
|
||||||
|
pitch = max(float(params.pitch), 170.0)
|
||||||
|
a, b = unit.portal_a, unit.portal_b
|
||||||
|
paths = _normalize_paths(unit)
|
||||||
|
kind = classify_dual_unit(unit)
|
||||||
|
|
||||||
|
if kind == "petal":
|
||||||
|
max_mid = max((len(p) - 2 for p in paths), default=0)
|
||||||
|
rx = max(
|
||||||
|
float(params.an_gap) * 2.5,
|
||||||
|
pitch * max(7.0, float(max_mid) + 2.0),
|
||||||
|
1100.0,
|
||||||
|
)
|
||||||
|
ry_step = max(float(params.side) * 1.8, float(params.lane) * 1.15, pitch * 1.15, 320.0)
|
||||||
|
ordered = _best_path_order_for_crossings(
|
||||||
|
paths,
|
||||||
|
a=a,
|
||||||
|
b=b,
|
||||||
|
rx=rx,
|
||||||
|
ry_step=ry_step,
|
||||||
|
state=state,
|
||||||
|
names=state.names,
|
||||||
|
chord_gap=max(pitch * 0.85, 150.0),
|
||||||
|
)
|
||||||
|
pos = _place_petal_bands(
|
||||||
|
ordered,
|
||||||
|
a=a,
|
||||||
|
b=b,
|
||||||
|
rx=rx,
|
||||||
|
ry_step=ry_step,
|
||||||
|
chord_gap=max(pitch * 0.85, 150.0),
|
||||||
|
)
|
||||||
|
half = rx
|
||||||
|
band_n = sum(1 for p in ordered if p[1:-1])
|
||||||
|
ry_park = ry_step * max(1, (band_n + 1) // 2 + 1)
|
||||||
else:
|
else:
|
||||||
t = k / (n_mid - 1)
|
max_mid = max((len(p) - 2 for p in paths), default=0)
|
||||||
x = -half + 2.0 * half * t
|
half = max(pitch * (max_mid + 1) * 0.5, pitch * 4.0, 700.0)
|
||||||
pos[n] = (x, side * amp)
|
pos = {a: (-half, 0.0), b: (half, 0.0)}
|
||||||
else:
|
|
||||||
# Single corridor / chain body — always straight between portals.
|
|
||||||
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]
|
||||||
|
for n in pinned:
|
||||||
|
if n in pos:
|
||||||
|
cand.positions[n] = pos[n]
|
||||||
x_after = count_edge_crossings(cand.positions, unit_links)
|
x_after = count_edge_crossings(cand.positions, unit_links)
|
||||||
if x_after == 0:
|
if x_after <= x_unit:
|
||||||
stf = cand
|
stf = cand
|
||||||
# else keep pre-overlap geometry
|
|
||||||
|
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -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},
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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)),
|
||||||
|
}
|
||||||
|
|
@ -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,51 +31,116 @@ 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:
|
||||||
|
continue
|
||||||
|
if sink_ids is not None and nid not in sink_ids:
|
||||||
|
continue
|
||||||
seen.add(nid)
|
seen.add(nid)
|
||||||
out.append(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):
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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])
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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 → 手拖或改初布;勿指望金标对齐。"
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
55
packages/netx-topology-mcp/tests/test_align_reference.py
Normal file
55
packages/netx-topology-mcp/tests/test_align_reference.py
Normal 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
|
||||||
185
packages/netx-topology-mcp/tests/test_bundle_orbit.py
Normal file
185
packages/netx-topology-mcp/tests/test_bundle_orbit.py
Normal 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
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
40
packages/netx-topology-mcp/tests/test_compact_bbox.py
Normal file
40
packages/netx-topology-mcp/tests/test_compact_bbox.py
Normal 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)
|
||||||
|
|
@ -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
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
84
packages/netx-topology-mcp/tests/test_pull_far_chains.py
Normal file
84
packages/netx-topology-mcp/tests/test_pull_far_chains.py
Normal 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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
146
packages/netx-topology-mcp/tests/test_suggest_sink_hubs.py
Normal file
146
packages/netx-topology-mcp/tests/test_suggest_sink_hubs.py
Normal 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"
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
71
tests/test_ne_collection_policy.py
Normal file
71
tests/test_ne_collection_policy.py
Normal 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()
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
14
web/WEB.md
14
web/WEB.md
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
116
web/src/components/ListPager.tsx
Normal file
116
web/src/components/ListPager.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
11
web/src/hooks/useDebouncedValue.ts
Normal file
11
web/src/hooks/useDebouncedValue.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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: "操作审计",
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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,6 +563,55 @@ export function ConfigSyncPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3>{t("configSync.cyclesTitle")}</h3>
|
<h3>{t("configSync.cyclesTitle")}</h3>
|
||||||
|
<div className="pt-list">
|
||||||
|
<div className="filter-inline">
|
||||||
|
<input
|
||||||
|
value={cycleKeyword}
|
||||||
|
placeholder={t("configSync.keywordPh")}
|
||||||
|
onChange={(e) => {
|
||||||
|
setCycleKeyword(e.target.value);
|
||||||
|
setCyclePage(1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={cycleStatus}
|
||||||
|
onChange={(e) => {
|
||||||
|
setCycleStatus(e.target.value);
|
||||||
|
setCyclePage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">{t("configSync.statusAll")}</option>
|
||||||
|
<option value="pending">pending</option>
|
||||||
|
<option value="running">running</option>
|
||||||
|
<option value="paused">paused</option>
|
||||||
|
<option value="success">success</option>
|
||||||
|
<option value="completed">completed</option>
|
||||||
|
<option value="failed">failed</option>
|
||||||
|
<option value="stopped">stopped</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!hasCycleFilters}
|
||||||
|
onClick={() => {
|
||||||
|
setCycleKeyword("");
|
||||||
|
setCycleStatus("");
|
||||||
|
setCyclePage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("common.clearFilters")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={exportingCycles || cycleTotal === 0}
|
||||||
|
onClick={() => void exportCyclesCsv()}
|
||||||
|
>
|
||||||
|
{exportingCycles ? t("common.exporting") : t("common.exportCsv")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{cyclesQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
|
||||||
|
{cyclesQuery.isError ? <p className="error-text">{formatErr(cyclesQuery.error)}</p> : null}
|
||||||
|
|
||||||
<div className="pt-list-table-wrap">
|
<div className="pt-list-table-wrap">
|
||||||
<table className="data-table pt-list-table">
|
<table className="data-table pt-list-table">
|
||||||
<thead>
|
<thead>
|
||||||
|
|
@ -437,12 +633,7 @@ export function ConfigSyncPage() {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn--sm btn--ghost"
|
className="btn btn--sm btn--ghost"
|
||||||
onClick={() => {
|
onClick={() => openCycleTasks(c.id)}
|
||||||
setExpandedCycleId(c.id);
|
|
||||||
setTaskPage(1);
|
|
||||||
setTaskKeyword("");
|
|
||||||
setTaskStatus("");
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{t("configSync.expand")}
|
{t("configSync.expand")}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -498,7 +689,7 @@ export function ConfigSyncPage() {
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{!cycles.length ? (
|
{!cycles.length && !cyclesQuery.isLoading ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={8} className="muted">
|
<td colSpan={8} className="muted">
|
||||||
{t("configSync.cyclesEmpty")}
|
{t("configSync.cyclesEmpty")}
|
||||||
|
|
@ -508,22 +699,19 @@ export function ConfigSyncPage() {
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div className="pager pt-list-pager">
|
<ListPager
|
||||||
<span className="muted">
|
page={cyclePage}
|
||||||
{t("common.pagerMeta", {
|
pages={cyclePages}
|
||||||
total: String(cycleTotal),
|
total={cycleTotal}
|
||||||
page: String(cyclePage),
|
pageSize={cyclePageSize}
|
||||||
pages: String(cyclePages),
|
pageSizeOptions={[20, 50, 100]}
|
||||||
})}
|
onPageChange={setCyclePage}
|
||||||
</span>
|
onPageSizeChange={(size) => {
|
||||||
<div className="btn-row">
|
setCyclePageSize(size);
|
||||||
<button type="button" disabled={cyclePage <= 1} onClick={() => setCyclePage((p) => p - 1)}>
|
setCyclePage(1);
|
||||||
{t("common.prevPage")}
|
}}
|
||||||
</button>
|
disabled={cyclesQuery.isLoading}
|
||||||
<button type="button" disabled={cyclePage >= cyclePages} onClick={() => setCyclePage((p) => p + 1)}>
|
/>
|
||||||
{t("common.nextPage")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</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>
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
className="pager__btn"
|
|
||||||
type="button"
|
|
||||||
onClick={() => setCurPage(Math.max(1, curPage - 1))}
|
|
||||||
disabled={curPage <= 1}
|
|
||||||
>
|
|
||||||
{t("common.prevPage")}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="pager__btn"
|
|
||||||
type="button"
|
|
||||||
onClick={() => setCurPage(curPage + 1)}
|
|
||||||
disabled={curPage >= curPages}
|
|
||||||
>
|
|
||||||
{t("common.nextPage")}
|
|
||||||
</button>
|
|
||||||
<select
|
|
||||||
className="pager__size"
|
|
||||||
value={String(curPageSize)}
|
|
||||||
onChange={(e) => {
|
|
||||||
setCurPageSize(Number(e.target.value) || 50);
|
|
||||||
setCurPage(1);
|
setCurPage(1);
|
||||||
}}
|
}}
|
||||||
>
|
disabled={currentQuery.isLoading}
|
||||||
<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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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 ? (
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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,15 +751,54 @@ 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>
|
||||||
|
{!hasListFilters ? (
|
||||||
<button type="button" className="btn-primary" onClick={openWizard}>
|
<button type="button" className="btn-primary" onClick={openWizard}>
|
||||||
{t("portTraffic.create")}
|
{t("portTraffic.create")}
|
||||||
</button>
|
</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>
|
||||||
|
|
@ -738,6 +894,7 @@ export function PortTrafficPage() {
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setLogDeviceId(row.id);
|
setLogDeviceId(row.id);
|
||||||
setLogDeviceLabel(deviceLabel(row));
|
setLogDeviceLabel(deviceLabel(row));
|
||||||
|
setLogKeyword("");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t("portTraffic.log")}
|
{t("portTraffic.log")}
|
||||||
|
|
@ -762,29 +919,21 @@ export function PortTrafficPage() {
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div className="pager pt-list-pager">
|
) : null}
|
||||||
<button
|
|
||||||
type="button"
|
<ListPager
|
||||||
className="pager__btn"
|
page={listPage}
|
||||||
disabled={listPage <= 1}
|
pages={pages}
|
||||||
onClick={() => setListPage((p) => p - 1)}
|
total={listTotal}
|
||||||
>
|
pageSize={listPageSize}
|
||||||
‹
|
pageSizeOptions={[20, 50, 100]}
|
||||||
</button>
|
onPageChange={setListPage}
|
||||||
<span className="muted">
|
onPageSizeChange={(size) => {
|
||||||
{listPage}/{Math.max(1, pages)}
|
setListPageSize(size);
|
||||||
</span>
|
setListPage(1);
|
||||||
<button
|
}}
|
||||||
type="button"
|
disabled={devicesQuery.isLoading}
|
||||||
className="pager__btn"
|
/>
|
||||||
disabled={listPage >= pages}
|
|
||||||
onClick={() => setListPage((p) => p + 1)}
|
|
||||||
>
|
|
||||||
›
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</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>
|
||||||
|
<div className="btn-row">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
disabled={!filteredLogEvents.length}
|
||||||
setLogDeviceId("");
|
onClick={exportEventsCsv}
|
||||||
setLogDeviceLabel("");
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
|
{t("common.exportCsv")}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={closeLog}>
|
||||||
{t("portTraffic.logClose")}
|
{t("portTraffic.logClose")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="filter-inline" style={{ marginBottom: 10 }}>
|
||||||
|
<input
|
||||||
|
value={logKeyword}
|
||||||
|
placeholder={t("portTraffic.keywordPh")}
|
||||||
|
onChange={(e) => setLogKeyword(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
{logQuery.isLoading ? (
|
{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"
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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"));
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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()}`,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
62
web/src/utils/csvExport.ts
Normal file
62
web/src/utils/csvExport.ts
Normal 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);
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue