mirror of
https://github.com/hansjone/netx.git
synced 2026-10-08 22:20:58 +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
|
||||
description: >-
|
||||
用 netx-topology MCP 查邻接、dual_unit 分批沉入、扫角压交叉(不污染 Fabric)。
|
||||
触发:画拓扑、布图、拖图、LLDP、Fabric、netx-topology。先读本 skill 再调工具。
|
||||
用 netx-topology MCP 查邻接、分类打标;先 dual_unit 一次抽最大核心眼,再换其它算法下沉/布图(不污染 Fabric)。
|
||||
触发:画拓扑、布图、拖图、分类、LLDP、Fabric、netx-topology。先读本 skill 再调工具。
|
||||
---
|
||||
|
||||
# netx 拓扑(通用)
|
||||
|
||||
只用 **`netx-topology`** MCP(包 `netx-topology-mcp`)。安装与 scopes:仓库 [`docs/MCP_TOPOLOGY.md`](../../../docs/MCP_TOPOLOGY.md)。
|
||||
|
||||
**原则**:复杂图先拆 **dual_units** 再拼;交叉少、边短、近轴优先。
|
||||
**原则**:
|
||||
1. **第一步用 dual_units**:从**核心**出发,选**覆盖网元最多**的那一只眼(`prefer_top_eye` + max cover)。
|
||||
2. **`sinkTopologyDualUnits` / `layout_dual_unit` 整图流程只用一次**——只沉这一只最大眼;**禁止**再连抽下一批 dual_unit。
|
||||
3. **后续下沉换算法**:`suggestSinkHubs` → `move_nodes(park)` / `projectTopologyNeighbors`;**不要**再 `sinkTopologyDualUnits`。
|
||||
4. **眼图已定型 → 门控精修**:对该 sink 只用下表「允许」动作;禁止全局拆眼工具。
|
||||
5. 布眼目标:少交叉、眼心空旷、少重叠(椭圆弧带;长链在眼外)。对照人工金标时还要看:**紧凑度、正交边、贴边清开**(见「算法天花板」)。
|
||||
|
||||
**禁止**写临时 py 穷举坐标或直接调 HTTP;验证与压交叉**只调 MCP**。不造 Fabric 边。
|
||||
**勿**把客户网元名、区域名、具体交叉数写进本 skill。
|
||||
**脱敏(硬)**:勿把客户网元名、站点/区域名、具体交叉数、具体 view_id 写进本 skill。角色只用通用词:门户 / 枢纽 / 汇聚 / 接入 / 末梢。**禁止**在 skill 正文写站点缩写或设备角色缩写当专名举例。
|
||||
|
||||
---
|
||||
|
||||
## 完整处理思路(一次大区接入眼)
|
||||
|
||||
按阶段执行;每阶段只调 MCP,读返回再决定下一步。
|
||||
|
||||
```
|
||||
① 认眼
|
||||
analyze(structure) → dual_units 里取覆盖最大的核心门户对
|
||||
② 一次 sink
|
||||
sinkTopologyDualUnits(max_units=1, prefer_top_eye, layout_batch)
|
||||
→ 同一 sink 贯穿全程;眼形不对只 layout_dual_unit 重布同一对门户
|
||||
③ 换算法下沉(禁 dual)
|
||||
循环:suggestSinkHubs(pick:1) → move_nodes(park) → until_limit(crossing)
|
||||
空批次后:orphan_batch / 对根−sink−门户 分批 park;仍禁 dual
|
||||
④ 眼定型精修(门控序列,见下)
|
||||
until_limit(crossing) → until_limit(total)
|
||||
→ clear_edge_hits(portal_ids) → pull_far_chains(portal_ids)
|
||||
→ compact_bbox(portal_ids, outlier_only) → 可选再 clear / total
|
||||
⑤ 停手(算法天花板,**默认无范本**)
|
||||
overlaps=0;until_limit stall;pull/compact/clear 均 moved≈0
|
||||
→ 交付:眼可读 + ov=0 + 交叉可接受;util/正交/贴边缺口 → 手拖或改初布
|
||||
→ 勿再发明无门控全局工具硬挤
|
||||
```
|
||||
|
||||
**范本现实(硬)**:
|
||||
- **日常默认没有范本**;有人工图也几乎不是同一张网 → **禁止**把 `align_reference` / 抄坐标当主路径。
|
||||
- `align_reference` **仅**用于:同网同成员画布的 A/B 对照、调试、回放(共享 `fabric_node_id`)。跨网 ID 对不上,抄了也无意义。
|
||||
- 生产要缩短与人工观感差距 → **改初布配方**(正交/紧凑从 dual 一次就偏地铁风),不是现场找金标对齐。
|
||||
|
||||
**从零重布(无范本算力路径)**:
|
||||
- 眼已在 sink、只需甩掉坏坐标 / 金标残留:对本 sink **`layout_dual_unit`(同一门户对)→ 立刻门控精修**。这**不算**再 sink 一批 dual。
|
||||
- 初布后交叉会先飙高、贴边变差属正常;**不要**因此改走 `polish_crossings` / `fix_overlaps`。
|
||||
- 纯算力压交叉后 util/bbox 常崩(枢纽被 `until_limit` 甩远)→ **必须**接 `pull_far_chains` → `compact_bbox`;缺这两个 action 就先重启 MCP,勿半截交付。
|
||||
- 无范本时:交叉可压到可读,但仍常高于「抄同网坐标」路径;util/正交缺口靠 pull + 初布,**不是**再堆 until_limit。
|
||||
|
||||
**开局核对 catalog(硬)**:
|
||||
- 精修前先 `layoutTopologyView(catalog=true)`:须见 `pull_far_chains`、`compact_bbox`,且 `version`/`rev` 为当前包。
|
||||
- 缺 action 或 `rev` 过旧 → **重启** netx-topology MCP 再干;禁止用旧进程硬跑、禁止改走 HTTP/临时 py。
|
||||
|
||||
**精修硬门控(已写进算法,skill 须遵守)**:
|
||||
- 任何落笔:**全局交叉不得升高**;升高 → 弃该候选。
|
||||
- **最小化展开**(bundle):从小压缩步长试探;步长须清开 footprint(成员互叠也算失败)。
|
||||
- **侵入** = 展开后踏入外人框 / 留下任意 footprint 重叠 → 换 tip,勿硬塞。
|
||||
- `until_limit` 只接受 `overlaps=0`;apply 遇 `overlaps_remain` → 修算法,**勿**对眼 sink 跑 `fix_overlaps`。
|
||||
- `portal_ids` **只钉眼门户**;`freeze_layers=["core"]` 会冻住 sink 上所有核心(含旁路枢纽)——慎用。
|
||||
- 旁路枢纽也要动时:`protect_rigid=off`(仍受 `portal_ids` 约束)。
|
||||
- `clear_edge_hits`:**禁止**放宽交叉(含 `preserve_axis`);交叉升高一律拒。
|
||||
- `pull_far_chains`:相对**门户中点**缩放远走廊/孤立点/远叶(不是朝外圈枢纽);枢纽本身在外围时朝枢纽缩**收不了 bbox**。
|
||||
- `compact_bbox`:默认 **farthest-K**(`outlier_only`);全图/分位均匀缩易叠点 → 拒。
|
||||
- `until_limit`:`stop_reason=max_moves` **≠** stall → **再调**直到 `stall`(可提高 `max_moves`,但仍以 stall 为准)。
|
||||
- 大眼 `max_jump` 用 4k–8k;勿收到 <2k。大图会 background → `job_status`(可数分钟);勿中途改走拆眼工具。
|
||||
|
||||
---
|
||||
|
||||
## 算法天花板(经验固化)
|
||||
|
||||
大区接入眼跑完门控序列后,常见结果:
|
||||
|
||||
| 维度 | 算法能做到 | 再挤会怎样 | 交付策略 |
|
||||
|------|------------|------------|----------|
|
||||
| 交叉 | 从零 dual 后 `until_limit` 可大幅压下;无范本时仍常高于抄坐标路径 | 再挪极值尖端易抬交叉;`max_moves` 停了要续跑到 stall | **停**于 stall;残余当弦交叉可接受 |
|
||||
| 重叠 | 硬零 | — | 必须保持 |
|
||||
| 紧凑 util / bbox | 压交叉常先**炸开** bbox;`pull_far_chains` 多轮收远走廊 | 缺 pull 就半截交付;强缩常 `Δg>0` 拒 | **必跑** pull→compact 到 `moved=0`;其余手拖 |
|
||||
| 贴边 clearance | `clear` + `objective=total` 能降一批 | 与交叉/紧凑拉扯,清到个位数很难 | stall 后手拖擦边点 |
|
||||
| 正交 axis | `total` 略抬;远低于人工地铁风 | `preserve_axis` 清边若放宽交叉会毁掉前面成果(已禁 slack) | 手拖走廊;勿为轴牺牲交叉 |
|
||||
| 直链 / 环穿 | bundle 后仍有折角与穿环 | 全局 straighten 拆眼 | 勿清零;结构优先 |
|
||||
|
||||
**判定「算法到头」**(门控精修,无范本):
|
||||
1. `overlaps=0`
|
||||
2. `until_limit`(crossing 与 total)均 stall
|
||||
3. pull/compact/clear `moved≈0`
|
||||
4. 目视:眼心空、双门户可辨
|
||||
|
||||
**可选对照**(仅评测):对**同网**人工画布与算法 sink 各 `analyze`,只比 `summary`;**勿**因此去 `align_reference` 当交付手段。
|
||||
|
||||
**明确无效 / 禁止再试**(眼 sink):
|
||||
- 再次 `sinkTopologyDualUnits` / `until_empty` / 新块画布抽 dual
|
||||
- `polish_crossings` / `straighten_channels` / `untangle` / `fix_overlaps` / `orbit_sweep(round=true)`
|
||||
- 临时 py 穷举坐标、无门控的整图缩放/平移极值点
|
||||
- 为追 util 关掉交叉门控或给 `clear` 开交叉 slack
|
||||
- **把跨网/他网人工图当范本抄坐标**(含滥用 `align_reference`)
|
||||
|
||||
---
|
||||
|
||||
## 主路径(必循)
|
||||
|
||||
```
|
||||
analyze(structure) → 认 dual_units / shape
|
||||
→ 核心层:手拖或小图 layout(compact|corridor|rings)
|
||||
→ sinkTopologyDualUnits(一批,layout_batch=true)
|
||||
或 move_nodes(park=true) 指定 ids
|
||||
→ orbit_sweep(round) → polish_crossings → clear_edge_hits
|
||||
→ 手拖微调 updateTopologyViewPositions
|
||||
→ 下一批 sink…(禁止 until_empty 日常连抽)
|
||||
analyze(structure) → 读 dual_units(认核心最大眼)
|
||||
→ 有 level:先打标;巨图勿先全量 level_bands
|
||||
→ 【仅一次】sinkTopologyDualUnits(
|
||||
max_units=1, prefer_top_eye=true, prefer_pure=false,
|
||||
sink_layers=[access], keep_layers=[core,agg], layout_batch=true,
|
||||
max_nodes/max_batch_nodes 放行大眼
|
||||
)
|
||||
→ 眼形不对:可对本 sink 重跑 layout_dual_unit(仍算同一只眼;勿再 sink 下一批 dual)
|
||||
→ 【换算法】suggestSinkHubs → move_nodes(park) 分批迁入同一 sink;每批后 until_limit;禁止再 dual
|
||||
→ 【眼定型】门控精修序列(下节);禁止全局拆眼
|
||||
→ 根图可 level_bands(只动根上残留,不动已定型的眼 sink)
|
||||
```
|
||||
|
||||
停手:`overlaps=0` 且 `verdict.total≈70`、cpl 中档、`edge_clearance.status≠fail`、`edge_axis.status≠fail` 方可交付。**勿只看 crossing**——orbit_sweep 的 `improving_n=0` 只代表交叉维度卡壳,须逐项检查 edge_clearance/edge_axis/rings,任一 fail 即继续处理。
|
||||
停手:最大眼可读、眼心空、`overlaps≈0`、门户清晰;其余节点用非 dual 手段靠上去即可。交叉允许来自跨走廊弦边。
|
||||
|
||||
**dual 一次用尽**:`until_empty`、再次 `sinkTopologyDualUnits`、为下一批 dual 新建块画布 —— **一律禁止**(日常)。
|
||||
|
||||
**布眼几何**(仅那一次):嵌套半椭圆;中轴只有双门户;长链/尾巴放眼外禁穿环。眼坏了重跑 `layout_dual_unit`,勿全局拆眼。
|
||||
|
||||
---
|
||||
|
||||
## 眼图定型后的精修(门控序列)
|
||||
|
||||
眼 sink 一经验收,**禁止**再对该画布跑会整图拆眼的工具。
|
||||
|
||||
| 允许 | 禁止(眼 sink 上) |
|
||||
|------|-------------------|
|
||||
| `analyze(detail=hotspots\|both)` 只读 | `polish_crossings` / `straighten_channels` |
|
||||
| `orbit_sweep`:**`until_limit=true`** 或 `node_id` preview→pick | `orbit_sweep(round=true)` 全图轮扫 |
|
||||
| `clear_edge_hits`(**须** `portal_ids`;交叉不升) | `fix_overlaps` / `untangle` 批处理整图 |
|
||||
| `pull_far_chains`(朝**门户中点**收远走廊/孤立点;同上门控) | `layout` / 再次 `sinkTopologyDualUnits` |
|
||||
| `compact_bbox`(farthest-K;交叉不升、ov=0) | 临时 py 穷举坐标 |
|
||||
| `updateTopologyViewPositions` 手拖 1~少数点 | `align_reference` 当无同网范本时的交付手段 |
|
||||
| 眼形整体崩了才 `layout_dual_unit` 重布同一门户对 | |
|
||||
|
||||
**推荐节奏**(一次序列,读返回再决定是否重复某一环):
|
||||
|
||||
```
|
||||
0) catalog=true 核对 pull_far_chains / compact_bbox(缺则重启 MCP)
|
||||
0b) 从零重布时:layout_dual_unit(同一门户对, apply) // 交叉飙高正常
|
||||
1) until_limit + objective=crossing + portal_ids + bundle
|
||||
→ stop_reason=max_moves 则续跑,直到 stall
|
||||
2) until_limit + objective=total // 交叉不升;抬贴边/正交;亦可 max_moves→续跑
|
||||
3) clear_edge_hits(portal_ids, top_n/max_moves 可加大;勿 preserve_axis 除非确认不抬交叉)
|
||||
4) pull_far_chains(portal_ids) // 可 2~3 轮直到 moved=0;压交叉后必做
|
||||
5) compact_bbox(portal_ids, outlier_only) // 常 accepted=false,正常
|
||||
6) 可选:再 clear 或 total;再 stall → 停手
|
||||
```
|
||||
|
||||
`until_limit` 调用模板:
|
||||
|
||||
```
|
||||
layoutTopologyView({
|
||||
action: "orbit_sweep", mode: "apply",
|
||||
params: {
|
||||
until_limit: true,
|
||||
objective: "crossing", // 再一轮改 total
|
||||
portal_ids: [<门户A>, <门户B>],
|
||||
protect_rigid: "off",
|
||||
max_degree: 14,
|
||||
max_jump: 8000, // 大眼用 4k–8k;勿 <2k
|
||||
max_stretch: 32,
|
||||
max_moves: 40, // 触顶只说明配额满,不是算法到头
|
||||
bundle: true
|
||||
}
|
||||
})
|
||||
→ 读 local.op:start/end_crossings、moves[]、stop_reason
|
||||
→ max_moves → 再调;stall → 进入下一步
|
||||
```
|
||||
|
||||
`round=true` ≠ `until_limit`:前者一批 top_n 死拿 pick#1;后者循环到 stall。
|
||||
勿为清零交叉拆弧带;以 `verdict.total` + 目视眼形 +「算法到头」四条为准。
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -35,13 +186,14 @@ analyze(structure) → 认 dual_units / shape
|
|||
|
||||
1. `getTopologyTree` → **`view_id`**(文件夹 physical 画布)。
|
||||
2. 建根/子区域只用 `createTopologyFolder`。
|
||||
3. `analyzeTopologyViewLayout({ view_id, detail: "structure" })` 读 `dual_units` / `shape`。
|
||||
4. `addTopologyViewNodes` / `projectTopologyNeighbors`(区域画布务必 `region_folder_id`)。
|
||||
5. 多余点用 `removeTopologyViewNodes` **移出画布**(不删 Fabric)。`region:…` 幽灵点勿当网元拖。
|
||||
3. 缺层级/区域时:`classifyTopologyFabricNodes`(`match` → `tag(level, dry_run)` → `tag`;或 `unmatched` / `apply_rules`)。**level** 越小越靠外(0 外部 / 1 核心 / 2 汇聚 / 3 接入;可用 1.1、2.1 子层)。**勿**切片建图。
|
||||
4. `analyzeTopologyViewLayout({ view_id, detail: "structure" })` 读 `dual_units` / `shape` / `layers` —— **先认核心最大眼**(`unit_count` / 各 unit `node_count`)。
|
||||
5. `addTopologyViewNodes`(可按 `role` 大档或后续 level)/ `projectTopologyNeighbors`(区域画布务必 `region_folder_id`)。
|
||||
6. 多余点用 `removeTopologyViewNodes` **移出画布**(不删 Fabric)。`region:…` 幽灵点勿当网元拖。
|
||||
|
||||
---
|
||||
|
||||
## 形状与 dual_units
|
||||
## 形状与 dual_units(只服务「第一只眼」)
|
||||
|
||||
```
|
||||
analyzeTopologyViewLayout({ view_id, detail: "structure" })
|
||||
|
|
@ -50,19 +202,85 @@ analyzeTopologyViewLayout({ view_id, detail: "structure" })
|
|||
| 字段 | 用途 |
|
||||
|------|------|
|
||||
| `shape.primary` | `chains` / `star` / `mesh` / `mixed_blocks` |
|
||||
| `dual_units` | 两端门户 + ≥2 条内部不交走廊;成员可重叠 |
|
||||
| `advice.block_plan` | 每块怎么拖 |
|
||||
| `dual_units` | **从核心出发**:core–core → core–agg → …,再取 **node_count 最大**的一只做唯一 sink |
|
||||
| `advice.block_plan` | 其余块怎么靠(非 dual 连抽) |
|
||||
| `gravity.type` | 链图勿当 hub 花瓣 |
|
||||
|
||||
- **链图**:脊柱水平 + stub;小图 preview `corridor`/`compact`(跳过 rings)。
|
||||
- **巨图 / 多门户**:勿全图一把揉;走 **sink 分批 dual_unit**。
|
||||
- **第一步必选最大核心眼**:`prefer_top_eye=true`,`prefer_pure=false`,`max_units=1`;目标是**一块吃进尽量多网元**,不要求交叉=0。
|
||||
- **禁止**把 dual_units 当成「抽干源图」的循环泵;剩余网元见下方「后续下沉」。
|
||||
- **禁止**再用互斥 soft_block 把通路切开。
|
||||
- **链图 / 小图**:可不走 dual;preview `corridor`/`compact`。
|
||||
|
||||
---
|
||||
|
||||
## 根图 → 子区域:第一次(dual,仅一次)
|
||||
|
||||
```
|
||||
sinkTopologyDualUnits({
|
||||
source_view_id: <根图>,
|
||||
sink_view_id: <子区域>, // 同一张接入画布贯穿全程
|
||||
max_units: 1,
|
||||
max_nodes: 300,
|
||||
max_batch_nodes: 400,
|
||||
layout_batch: true,
|
||||
sink_layers: ["access"],
|
||||
keep_layers: ["core", "agg"],
|
||||
prefer_top_eye: true,
|
||||
prefer_pure: false,
|
||||
// 勿 until_empty;勿随后再调本工具
|
||||
})
|
||||
→ 眼形验收 / 手拖;必要时 layout_dual_unit 重布同一眼
|
||||
```
|
||||
|
||||
- **同一 sink**:后续非 dual 迁入也进**同一个** `sink_view_id`;禁止块1/块2/块3。
|
||||
- **眼定型后**:对该 sink **只门控精修**(见上节)。
|
||||
|
||||
---
|
||||
|
||||
## 后续下沉(换算法,禁用 dual)
|
||||
|
||||
源图仍有接入 / 旁路网元时,**不要**再 `sinkTopologyDualUnits`。
|
||||
|
||||
0. **先问算法要批次**(推荐)
|
||||
```
|
||||
suggestSinkHubs({
|
||||
source_view_id: <根图>,
|
||||
sink_view_id: <眼 sink>,
|
||||
pick: 1,
|
||||
})
|
||||
→ batch.fabric_node_ids / move_nodes 可直接套用
|
||||
```
|
||||
排序:剩余领地大优先、agg 优于 core;眼门户不当 batch 头。
|
||||
|
||||
**每批迁完立刻** `until_limit(crossing, portal_ids)`,再取下一批;勿攒多批再一起 polish。
|
||||
|
||||
`suggestSinkHubs` 空了但根上还有孤立末梢/旁路:清掉已在 sink 的重复点,再对 `root−sink−眼门户` 分批 `move_nodes(park)`(每批后 `until_limit`)。也会直接吐 `orphan_batch_*`。勿再 dual。
|
||||
|
||||
1. **指定迁移 + 扫角停靠**
|
||||
```
|
||||
layoutTopologyView({
|
||||
action: "move_nodes",
|
||||
source_view_id: <FROM>, view_id: <TO>,
|
||||
mode: "apply",
|
||||
params: {
|
||||
fabric_node_ids: [...],
|
||||
park: true,
|
||||
remove_from_source: true,
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
2. **投影邻居**:`projectTopologyNeighbors`(区域画布带 `region_folder_id`),再手拖或 `orbit_sweep`。
|
||||
|
||||
3. **根图收尾**:`level_bands` 只钉**根图**上残留的 core/agg;**勿**对已定型眼 sink 跑全局带。
|
||||
|
||||
4. **小连通块**:源上剩余短链/星,可对**源图**或迁入后**单点**处理;勿对眼 sink `layout(recipe=…)`。
|
||||
|
||||
---
|
||||
|
||||
## 分层布局(特殊场景)
|
||||
|
||||
当网络需呈现层级结构(外部→接入→核心→汇聚→客户等)时,优化策略不同于通用 mesh:
|
||||
当网络需呈现层级结构(外部→接入→核心→汇聚→客户等)时:
|
||||
|
||||
### 分层通用规则
|
||||
1. **顶层:外部/对接网络**
|
||||
|
|
@ -73,54 +291,16 @@ analyzeTopologyViewLayout({ view_id, detail: "structure" })
|
|||
|
||||
### 优化流程
|
||||
1. **先手动后算法**:用户手动拖拽保证业务结构,再用算法优化
|
||||
2. **分层约束**:用 `updateTopologyViewPositions` 按层级设定 y 坐标(同层 y 落在区间内可错落,勿跨层);`orbit_sweep` 传 `y_min/y_max` 约束搜索在本层区间内
|
||||
3. **分步迭代**:
|
||||
- `polish_crossings` 减少交叉(可能破坏分层)
|
||||
- 若分层被破坏,**保留 x 坐标,恢复 y 坐标**重新分层
|
||||
- `clear_edge_hits` 消除贴边
|
||||
- `fix_overlaps` 修复重叠
|
||||
2. **分层约束**:`updateTopologyViewPositions` 按层钉 y;`orbit_sweep` 可传 `y_min/y_max`
|
||||
3. **分步(非眼画布 / 根图)**:可贴边、分层钉 y;**已定型眼 sink 除外**——只允许门控精修 / 手拖
|
||||
|
||||
### 冲突处理原则
|
||||
### 冲突处理
|
||||
| 冲突场景 | 处理方式 |
|
||||
|---------|---------|
|
||||
| 交叉 vs 结构 | **结构优先**,接受少量跨层交叉 |
|
||||
| 算法 vs 手动 | **手动优先**,算法辅助 |
|
||||
| polish 破坏分层 | 保留 x 坐标,恢复 y 坐标分层 |
|
||||
|
||||
### 关键经验
|
||||
1. **跨层交叉是结构性的**:跨层连线必然穿越中间层,属正常
|
||||
2. **外层节点必须在外部层**:与 PS/PE 连接的外部网元(RNC、华为设备等)应放在最上方
|
||||
3. **orbit_sweep 卡壳更早**:分层约束下 `improving_n=0` 来得快;改传 `objective=total` 让 orbit_sweep 按 crossing+edge_clearance 综合排序
|
||||
4. **验收以层级清晰为先**:`verdict.total` 次于结构可读性;同层 y 在区间内、层间不串层即可交付
|
||||
|
||||
---
|
||||
|
||||
## 根图 → 子区域排水
|
||||
|
||||
```
|
||||
sinkTopologyDualUnits({
|
||||
source_view_id: <根图>,
|
||||
sink_view_id: <子区域>,
|
||||
max_units: 3,
|
||||
max_batch_nodes: 120,
|
||||
layout_batch: true, // 每单元 layout_dual_unit → 块扫挂 sink
|
||||
})
|
||||
→ orbit_sweep / polish_crossings / clear_edge_hits
|
||||
→ 再调下一批(source_remaining>0)
|
||||
```
|
||||
|
||||
- **一次只沉一批**;勿日常 `until_empty`。
|
||||
- 落点:**块扫**(交叉/重叠/桥长择优),禁止固定往右排。
|
||||
- 指定迁移:
|
||||
```
|
||||
layoutTopologyView({
|
||||
action: "move_nodes",
|
||||
source_view_id: <FROM>, view_id: <TO>,
|
||||
mode: "apply",
|
||||
params: { fabric_node_ids: [...], park: true, remove_from_source: true },
|
||||
})
|
||||
```
|
||||
`park=true` = 扫角停靠;回迁对调两 view_id。
|
||||
| 交叉 vs 结构 | **结构优先**(最大眼 / 分层) |
|
||||
| 算法 vs 手动 | **手动优先** |
|
||||
| polish 拆眼 | **禁止**对 dual 眼走 straighten 主路径 |
|
||||
| 算法 vs 金标观感 | 交叉可赢;紧凑/正交/贴边交给手拖 |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -128,27 +308,20 @@ sinkTopologyDualUnits({
|
|||
|
||||
| action | 用途 |
|
||||
|--------|------|
|
||||
| `layout` | 小图配方:`compact` / `corridor` / `rings` / `unstick` |
|
||||
| `layout_dual_unit` | 双门户眼形;单元内交叉≠0 拒绝 |
|
||||
| `move_nodes` / `sink_nodes` | 指定 ids 双向迁移;`park` 块扫 |
|
||||
| `orbit_sweep` | 压交叉;`round` + 大 `max_jump`(约 1800–2800);`objective=total` 综合 crossing+edge_clearance;`y_min/y_max` 分层约束 |
|
||||
| `polish_crossings` | 一键:straighten→press→untangle |
|
||||
| `clear_edge_hits` | 网元贴非关联边时正交弹开 |
|
||||
| `fix_overlaps` / `resolve_overlaps` | 只拉开重叠 |
|
||||
| `untangle` | 贪心降交叉;默认可冻门户 |
|
||||
| `straighten_channels` | 拉直 deg≤2 走廊 |
|
||||
| `layout` | 小图 / 剩余块:`compact` / `corridor` / `rings` / `unstick` |
|
||||
| `layout_dual_unit` | **仅**重布已沉的那一只眼;椭圆弧带 + 空心眼 |
|
||||
| `move_nodes` / `sink_nodes` | **后续下沉主路径**;`park` 块扫 |
|
||||
| `orbit_sweep` | **眼后精修主工具**:`until_limit` 或 `node_id`;`objective=crossing\|total` |
|
||||
| `clear_edge_hits` | 眼 sink **可**(须 `portal_ids`;交叉不升) |
|
||||
| `pull_far_chains` | 眼 sink **可**:朝门户中点收远走廊/孤立点 |
|
||||
| `align_reference` | **非主路径**:仅同网同成员 A/B 调试;跨网禁用 |
|
||||
| `compact_bbox` | 眼 sink **可**:farthest-K 收 bbox |
|
||||
| `level_bands` | 仅根图/未定型画布;巨图在 dual 一次之后钉根 |
|
||||
| `polish_crossings` | **眼 sink 禁用** |
|
||||
| `fix_overlaps` / `untangle` / `straighten_channels` | **眼 sink 禁用** |
|
||||
| `job_status` / `job_cancel` | 后台 job |
|
||||
|
||||
阶段2是多目标循环,非单向链:
|
||||
|
||||
1. `orbit_sweep` 压交叉 → `polish_crossings`
|
||||
2. **orbit_sweep `improving_n=0` 时勿停**——立即转看 `edge_clearance`(贴边对视觉可读性影响 ≥ 交叉)→ `clear_edge_hits` 正交弹开
|
||||
3. 再看 `edge_axis`(斜边过多)→ `straighten_channels` / 手拖归轴
|
||||
4. 回头复检 crossing 是否因上步变动出现新机会 → 再 `orbit_sweep`
|
||||
|
||||
`orbit_round` 只在全局交叉严格下降时落笔;卡顿加大 `max_jump` / 单点 preview→pick。
|
||||
|
||||
**关键**:单节点 `updateTopologyViewPositions` 拖动时,某节点移动可能增交叉但解多个贴边——以 `verdict.total` 升降为准,勿只盯 crossing 数。`drag_candidates` 可能为空,此时看 `edge_clearance.top` / `crossing.top_nodes` 自行判断拖谁。
|
||||
眼 sink 精修 = 门控序列 + 手拖补观感;不要排全局流水线,也不要在 stall 后继续发明新全局动作。
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -156,20 +329,16 @@ sinkTopologyDualUnits({
|
|||
|
||||
| 块 | 看什么 |
|
||||
|----|--------|
|
||||
| `overlap` | 硬零(权重 0.24,硬门控) |
|
||||
| `crossing` | crossings/cpl;`top_nodes` / `top_edges`(权重 0.18) |
|
||||
| `rings` | 最小环被穿(权重 0.10) |
|
||||
| `edge_clearance` | 贴边 → clear_edge_hits;**权重 0.08 但视觉影响 ≥ crossing,优先处理** |
|
||||
| `edge_axis` | 斜边过多 → straighten_channels / 手拖归轴(权重 0.06) |
|
||||
| `verdict.total` | ≈70 可交付(ov=0);**总分升降为准,勿只盯 crossing** |
|
||||
| `overlap` | 硬零(权重 0.24) |
|
||||
| `crossing` | crossings/cpl;`top_nodes` / `top_edges` |
|
||||
| `sparsity` / `edge_axis` / `edge_clearance` | 对照人工:紧凑、正交、贴边(算法常弱于此) |
|
||||
| `rings` / `chains` | 环被穿、走廊折角 |
|
||||
| `verdict.total` | ≈70 可交付(ov=0);结构可读优先于刷分 |
|
||||
| `score_profile` | `auto`(默认)/ `default` / `eye`;大稀疏斜边画布自动走 eye |
|
||||
|
||||
### 评分优化快速方法
|
||||
1. **消除 overlap** → 硬门控项,必须为 0
|
||||
2. **减少 crossings** → polish_crossings 大幅降低
|
||||
3. **处理 edge_clearance** → clear_edge_hits 消除贴边
|
||||
4. **结构检查** → 分层清晰 > 交叉最少
|
||||
**评分修订(相对旧权)**:util 甜区下限 0.08;贴边分=节点命中∪hits/link;rings 降权;eye 再降 axis、抬 util/clearance;`compactness` 仅诊断不加权。
|
||||
|
||||
图标 25px;推荐中心距 Δx≥200、Δy≥170。交叉 = 无向 NE↔NE 真交叉(共端点不算)。
|
||||
图标 25px;推荐中心距 Δx≥200、Δy≥170。
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -178,59 +347,25 @@ sinkTopologyDualUnits({
|
|||
| 工具 | 作用 |
|
||||
|------|------|
|
||||
| `getTopologyTree` / `getTopologyView` | 树与画布 |
|
||||
| `createTopologyFolder` | 新建根/区域(返回 view_id) |
|
||||
| `createTopologyFolder` | 新建根/区域 |
|
||||
| `add` / `remove` / `updateTopologyViewPositions` | 成员与手拖 |
|
||||
| `sinkTopologyDualUnits` | dual_units 分批沉入 |
|
||||
| `copyTopologyViewNodes` | 克隆沙箱 |
|
||||
| `sinkTopologyDualUnits` | **仅一次**:最大核心眼 → sink |
|
||||
| `suggestSinkHubs` | **后续下沉批次**(含 orphan_batch) |
|
||||
| `move_nodes`(经 layoutTopologyView) | **后续下沉** |
|
||||
| `projectTopologyNeighbors` | 投影邻居 |
|
||||
| `queryTopologyFabricNodes` | 库存(summary\|list\|search) |
|
||||
| `copyTopologyViewNodes` | 克隆沙箱 |
|
||||
| `queryTopologyFabricNodes` | 库存 |
|
||||
| neighborhood / edges | 邻接 |
|
||||
| `layoutTopologyView` | 上表 action |
|
||||
| `analyzeTopologyViewLayout` | structure + 验收 |
|
||||
|
||||
---
|
||||
|
||||
## 通用优化策略(实战经验)
|
||||
|
||||
### 算法与手动的最佳组合
|
||||
```
|
||||
Step 1: 用户手动拖拽 → 保证业务结构和分层
|
||||
Step 2: polish_crossings → 大幅减少交叉(接受可能破坏分层)
|
||||
Step 3: 检查分层 → 若被破坏,保留 x 恢复 y 重新分层
|
||||
Step 4: clear_edge_hits → 处理贴边问题
|
||||
Step 5: fix_overlaps → 修复节点重叠
|
||||
Step 6: 最终验收 → 结构清晰 > 交叉最少
|
||||
```
|
||||
|
||||
### 工具使用优先级
|
||||
| 场景 | 首选工具 | 参数建议 |
|
||||
|-----|---------|---------|
|
||||
| 整体减交叉 | `polish_crossings` | `top_n=10, max_moves=50` |
|
||||
| 单点微调 | `orbit_sweep` | `objective=total, y_min/y_max` |
|
||||
| 处理贴边 | `clear_edge_hits` | `top_n=15, max_moves=30` |
|
||||
| 修复重叠 | `fix_overlaps` | 直接调用 |
|
||||
| 批量调坐标 | `updateTopologyViewPositions` | 保留 x,恢复 y |
|
||||
|
||||
### 常见问题解决方案
|
||||
| 问题 | 原因 | 解决方案 |
|
||||
|-----|------|---------|
|
||||
| polish 破坏分层 | 算法优先减少交叉 | 保留 x 坐标,恢复 y 分层 |
|
||||
| orbit_sweep 无改善 | 位置已优化 | 接受现状或手动调整 |
|
||||
| 节点重叠 | 坐标调整太近 | fix_overlaps 自动修复 |
|
||||
| 交叉突然增加 | clear_edge_hits 移动节点 | 重新 polish_crossings |
|
||||
|
||||
### 关键原则
|
||||
1. **业务结构优先于算法优化**:网络拓扑的分层结构比最少交叉更重要
|
||||
2. **局部微调优于全局重置**:用 `orbit_sweep`/`updateTopologyViewPositions` 单点调整,而非 `layout` 全局布局
|
||||
3. **预览模式优先于应用模式**:先用 `mode=preview` 查看效果,确认后再 `mode=apply`
|
||||
4. **分步迭代优于一次性操作**:polish → clear_edge_hits → fix_overlaps 分步骤执行
|
||||
|
||||
---
|
||||
|
||||
## 代码热更
|
||||
|
||||
1. 本仓 MCP 用 `PYTHONPATH=…/src`,改源码后不必为加载而 pip install。
|
||||
2. **必须重启** stdio 进程;`catalog` 含 `rev`(当前 `NETX_MCP_REV`)。
|
||||
3. `layoutTopologyView(catalog=true)` 核对 action/recipe 清单。
|
||||
1. 本仓 MCP 用 `PYTHONPATH=…/src`(见用户 `~/.cursor/mcp.json`),改源码后不必为加载而 pip install。
|
||||
2. **必须重启** stdio 进程(杀旧 `python -m netx_topology_mcp` 后由 Cursor 拉起;或 MCP 面板禁用/启用)。
|
||||
3. `layoutTopologyView(catalog=true)` 核对:`version` / `rev`(`NETX_MCP_REV`)+ action 含 `pull_far_chains` / `compact_bbox`。
|
||||
4. 机器上常残留多份旧 MCP 进程 → 精修前 catalog 若仍缺 action,**杀光再启**;勿用旧进程半截交付,勿改走 HTTP/临时 py。
|
||||
|
||||
拓扑页开「实时同步」可看落笔。勿用告警/CLI MCP 写拓扑。
|
||||
|
|
|
|||
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) |
|
||||
| `getTopologyView` | 单图(节点+边+坐标);`detail=summary\|full` |
|
||||
| `queryTopologyFabricNodes` | Fabric 库存:`mode=summary\|list\|search`(有 `q` 默认 search;list 支持 `region_folder_id`) |
|
||||
| `classifyTopologyFabricNodes` | **分级打标**:`match`→`tag(level\|role, dry_run)`→`tag`;`level` 为 major.minor(0 外部…3 接入);`role` 预设仍可用。再 `addTopologyViewNodes` 上区域画布。**勿用切片建图** |
|
||||
| `queryTopologyNeighborhood` | 邻域:compact nodes + `links[]`(NE 对,非端口) |
|
||||
| `queryTopologyEdges` | 默认 **adjacency**:`links[{a,b,link_count}]`;画布一对网元一条线。`detail=ports` 才给端口行 |
|
||||
| `analyzeTopologyViewLayout` | 布图验收(只读):`verdict` + score(含中档 `chains` 直链成一体、`rings` 最小环不被穿,各权 0.10);`detail=structure` 给重心/枢纽/配方建议;`hotspots\|blocks\|both` 给手拖 sight(`drag_candidates[].suggest_xy` / `delta_crossings_est`) |
|
||||
| `analyzeTopologyViewLayout` | 布图验收(只读):`verdict` + score;`score_profile=auto\|default\|eye`(大稀疏斜边→eye);贴边=节点命中∪hits/link;util 甜区下限 0.08;`detail=structure` / `hotspots\|blocks\|both` |
|
||||
|
||||
### 写(只动画布,不污染 Fabric)
|
||||
|
||||
|
|
@ -98,7 +99,7 @@ Cursor:改包后若 Sync 工具数不对,请 **禁用/启用** `netx-topolog
|
|||
| `copyTopologyViewNodes` | 一键克隆画布成员+坐标到另一画布(`clear_target` 可选);源画布不动,测沙箱用 |
|
||||
| `layoutTopologyView` `move_nodes` | 指定 `fabric_node_ids` 从 `source_view_id`→`view_id`(默认同移出源);对调两 view 回迁;别名 `sink_nodes`;`park=true` 扫角停靠 |
|
||||
| `updateTopologyViewPositions` | **优先** `layout=grid\|offset\|stack` + 筛选,API 自己挪点;`positions[]` 仅少量微调 |
|
||||
| `layoutTopologyView` | **布图/局部修**:公开 `action=layout\|layout_dual_unit\|orbit_sweep\|polish_crossings\|clear_edge_hits\|fix_overlaps\|untangle\|straighten_channels\|move_nodes`;recipe 仅 `compact\|corridor\|rings\|unstick`。巨图 apply 可能返回 `job_id` → 轮询 `job_status` / `job_cancel`。Job=**子进程+`data/runtime/layout_jobs` 落盘**。`mode=preview\|apply` |
|
||||
| `layoutTopologyView` | **布图/局部修**:公开 `action=layout\|layout_dual_unit\|orbit_sweep\|clear_edge_hits\|pull_far_chains\|compact_bbox\|polish_crossings\|fix_overlaps\|untangle\|straighten_channels\|level_bands\|move_nodes`;recipe 仅 `compact\|corridor\|rings\|unstick`。眼 sink 主路径:`until_limit(crossing→total)` → `clear_edge_hits` → `pull_far_chains` → `compact_bbox` → 手拖;禁对眼跑 polish/fix_overlaps/untangle/round。stall 且 moved≈0 即算法到头。巨图 apply 可能返回 `job_id` → 轮询 `job_status` / `job_cancel`。Job=**子进程+`data/runtime/layout_jobs` 落盘**。`mode=preview\|apply` |
|
||||
| `projectTopologyNeighbors` | 投影**已有** LLDP 邻居到画布;区域画布务必传 `region_folder_id`,读 `out_of_region_skipped` |
|
||||
|
||||
**推荐流水线:** `getTopologyTree` →(可选)`createTopologyFolder` 取 `view_id` → `addTopologyViewNodes` →(可选)邻居投影 / 布局。
|
||||
|
|
@ -120,7 +121,7 @@ Cursor:改包后若 Sync 工具数不对,请 **禁用/启用** `netx-topolog
|
|||
| 包 | server_id | 职责 |
|
||||
|----|-----------|------|
|
||||
| `netx-mcp` | `netx` | 告警、UME、托管网元 CLI(**13** 工具) |
|
||||
| `netx-topology-mcp` | `netx-topology` | 拓扑画布 / Fabric 只读 + 安全画图(**14** 工具) |
|
||||
| `netx-topology-mcp` | `netx-topology` | 拓扑画布 / Fabric 只读 + 分类打标 + 安全画图(**15** 工具) |
|
||||
|
||||
`queryTopologyEdges` 已从 `netx-mcp` **迁出**到本包,避免重复。
|
||||
|
||||
|
|
|
|||
|
|
@ -31,6 +31,13 @@ def shutdown_runtime(*, reason: str = "lifespan") -> None:
|
|||
except Exception: # noqa: BLE001
|
||||
_log.exception("stop_lldp_collect_scheduler failed")
|
||||
|
||||
try:
|
||||
from .ne_collect_scheduler import stop_ne_collect_scheduler
|
||||
|
||||
stop_ne_collect_scheduler()
|
||||
except Exception: # noqa: BLE001
|
||||
_log.exception("stop_ne_collect_scheduler failed")
|
||||
|
||||
try:
|
||||
from .port_traffic_scheduler import stop_port_traffic_scheduler
|
||||
|
||||
|
|
|
|||
|
|
@ -115,11 +115,17 @@ def run_api_startup() -> None:
|
|||
if cfg_resumed:
|
||||
_log.info("startup: resumed %s config_sync task(s) from interrupted cycle", cfg_resumed)
|
||||
try:
|
||||
from .collection_policy import ensure_policy as ensure_ne_collect_policy
|
||||
from .collection_policy import history_keep_value, prune_collection_jobs
|
||||
from .lldp_collect_service import ensure_policy as ensure_lldp_collect_policy
|
||||
|
||||
ensure_lldp_collect_policy(db)
|
||||
pol = ensure_ne_collect_policy(db)
|
||||
pruned_jobs = prune_collection_jobs(db, keep=history_keep_value(pol))
|
||||
if pruned_jobs:
|
||||
_log.info("startup: pruned %s ne_collection job(s) by history_keep", pruned_jobs)
|
||||
except Exception:
|
||||
_log.exception("startup: lldp_collect policy ensure failed")
|
||||
_log.exception("startup: lldp/ne_collect policy ensure failed")
|
||||
pt_cleared = recover_port_traffic_on_startup(db)
|
||||
if pt_cleared:
|
||||
_log.info("startup: cleared %s port_traffic stuck collect_running flag(s)", pt_cleared)
|
||||
|
|
@ -151,7 +157,7 @@ def run_api_startup() -> None:
|
|||
else:
|
||||
_log.info(
|
||||
"startup: inline schedulers disabled — run `python -m netx_api.worker` for "
|
||||
"config_sync / lldp_collect / port_traffic"
|
||||
"config_sync / lldp_collect / ne_collect / port_traffic"
|
||||
)
|
||||
|
||||
start_api_sideband_threads()
|
||||
|
|
|
|||
|
|
@ -48,6 +48,12 @@ def finalize_collection_job(db: Session, job_id: str) -> None:
|
|||
job.ended_at = finish_at
|
||||
job.last_run_at = job.ended_at or finish_at
|
||||
db.commit()
|
||||
try:
|
||||
from .collection_policy import ensure_policy, history_keep_value, prune_collection_jobs
|
||||
|
||||
prune_collection_jobs(db, keep=history_keep_value(ensure_policy(db)))
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
|
||||
def reconcile_stale_collection_job(db: Session, job_id: str) -> bool:
|
||||
|
|
|
|||
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 sqlalchemy.orm import Session
|
||||
|
||||
from .collection_policy import get_policy, update_policy
|
||||
from .collection_schemas import CollectionJobCreate, CollectionPolicyUpdate
|
||||
from .collection_service import (
|
||||
build_collection_job_zip,
|
||||
create_and_start_from_policy,
|
||||
create_collection,
|
||||
delete_collection_job,
|
||||
get_collection_dashboard,
|
||||
|
|
@ -19,7 +22,6 @@ from .collection_service import (
|
|||
start_collection_job,
|
||||
retry_failed_collection_job,
|
||||
)
|
||||
from .collection_schemas import CollectionJobCreate
|
||||
from .db import get_db
|
||||
from .models import NeCollectionRun
|
||||
from .ne_collect_runner import dispatch_collection_runs
|
||||
|
|
@ -42,6 +44,32 @@ def api_collection_dashboard(db: Session = Depends(get_db)):
|
|||
return get_collection_dashboard(db).model_dump()
|
||||
|
||||
|
||||
@router.get("/policy")
|
||||
def api_get_collection_policy(db: Session = Depends(get_db)):
|
||||
return get_policy(db).model_dump()
|
||||
|
||||
|
||||
@router.put("/policy")
|
||||
def api_put_collection_policy(body: CollectionPolicyUpdate, db: Session = Depends(get_db)):
|
||||
return update_policy(db, body).model_dump()
|
||||
|
||||
|
||||
@router.post("/start-from-policy")
|
||||
def api_start_from_policy(
|
||||
background_tasks: BackgroundTasks,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Create + start a collect job from the saved policy (manual trigger)."""
|
||||
out, payload = create_and_start_from_policy(db, trigger_mode="manual")
|
||||
background_tasks.add_task(
|
||||
dispatch_collection_runs,
|
||||
payload["job_id"],
|
||||
payload["run_ids"],
|
||||
payload["commands"],
|
||||
)
|
||||
return out.model_dump()
|
||||
|
||||
|
||||
@router.post("")
|
||||
def api_create_collection(body: CollectionJobCreate, db: Session = Depends(get_db)):
|
||||
return create_collection(db, body).model_dump()
|
||||
|
|
@ -51,9 +79,13 @@ def api_create_collection(body: CollectionJobCreate, db: Session = Depends(get_d
|
|||
def api_list_collections(
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
status: str = Query(default=""),
|
||||
keyword: str = Query(default=""),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
return list_collection_jobs(db, page=page, page_size=page_size)
|
||||
return list_collection_jobs(
|
||||
db, page=page, page_size=page_size, status=status, keyword=keyword
|
||||
)
|
||||
|
||||
|
||||
@router.get("/runs/{run_id}/download")
|
||||
|
|
|
|||
|
|
@ -5,6 +5,34 @@ from datetime import datetime
|
|||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class CollectionTargetRef(BaseModel):
|
||||
source: str = "managed" # managed | ume
|
||||
id: str
|
||||
|
||||
|
||||
class CollectionPolicyOut(BaseModel):
|
||||
enabled: bool = False
|
||||
interval_days: int = 1
|
||||
interval_hours: int = 24
|
||||
scope_mode: str = "all"
|
||||
selected_targets: list[CollectionTargetRef] = Field(default_factory=list)
|
||||
title: str = ""
|
||||
commands: str = ""
|
||||
history_keep: int = 3
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
class CollectionPolicyUpdate(BaseModel):
|
||||
enabled: bool | None = None
|
||||
interval_days: int | None = Field(default=None, ge=1, le=365)
|
||||
interval_hours: int | None = Field(default=None, ge=1, le=8760)
|
||||
scope_mode: str | None = None
|
||||
selected_targets: list[CollectionTargetRef] | None = None
|
||||
title: str | None = None
|
||||
commands: str | None = None
|
||||
history_keep: int | None = Field(default=None, ge=0, le=200)
|
||||
|
||||
|
||||
class CollectionJobCreate(BaseModel):
|
||||
title: str = ""
|
||||
commands: str = Field(min_length=1)
|
||||
|
|
@ -31,6 +59,7 @@ class CollectionJobOut(BaseModel):
|
|||
id: str
|
||||
title: str
|
||||
commands: str
|
||||
trigger_mode: str = "manual"
|
||||
status: str
|
||||
ne_count: int
|
||||
success_count: int
|
||||
|
|
@ -47,6 +76,7 @@ class CollectionJobSummary(BaseModel):
|
|||
id: str
|
||||
title: str
|
||||
status: str
|
||||
trigger_mode: str = "manual"
|
||||
ne_count: int
|
||||
success_count: int
|
||||
fail_count: int
|
||||
|
|
@ -61,3 +91,5 @@ class CollectionDashboardOut(BaseModel):
|
|||
active_count: int = 0
|
||||
running_job: CollectionJobSummary | None = None
|
||||
last_job: CollectionJobSummary | None = None
|
||||
next_due_at: datetime | None = None
|
||||
policy: CollectionPolicyOut | None = None
|
||||
|
|
|
|||
|
|
@ -20,6 +20,14 @@ from .collection_job_state import (
|
|||
sync_job_progress,
|
||||
_sync_job_counts,
|
||||
)
|
||||
from .collection_policy import (
|
||||
ensure_policy,
|
||||
expand_policy_targets,
|
||||
history_keep_value,
|
||||
next_due_at,
|
||||
policy_to_out,
|
||||
prune_collection_jobs,
|
||||
)
|
||||
from .collection_schemas import (
|
||||
CollectionDashboardOut,
|
||||
CollectionJobCreate,
|
||||
|
|
@ -87,6 +95,7 @@ def job_to_out(row: NeCollectionJob, *, output_count: int | None = None) -> Coll
|
|||
id=str(row.id),
|
||||
title=str(row.title or ""),
|
||||
commands=str(row.commands or ""),
|
||||
trigger_mode=str(getattr(row, "trigger_mode", None) or "manual"),
|
||||
status=str(row.status or "pending"),
|
||||
ne_count=int(row.ne_count or 0),
|
||||
success_count=int(row.success_count or 0),
|
||||
|
|
@ -107,6 +116,7 @@ def job_to_summary(row: NeCollectionJob | None) -> CollectionJobSummary | None:
|
|||
id=str(row.id),
|
||||
title=str(row.title or "").strip() or str(row.id)[:8],
|
||||
status=str(row.status or "pending"),
|
||||
trigger_mode=str(getattr(row, "trigger_mode", None) or "manual"),
|
||||
ne_count=int(row.ne_count or 0),
|
||||
success_count=int(row.success_count or 0),
|
||||
fail_count=int(row.fail_count or 0),
|
||||
|
|
@ -162,11 +172,14 @@ def get_collection_dashboard(db: Session) -> CollectionDashboardOut:
|
|||
or 0
|
||||
)
|
||||
last = last_finished_collection_job(db)
|
||||
policy = ensure_policy(db)
|
||||
return CollectionDashboardOut(
|
||||
job_count=job_count,
|
||||
active_count=active_count,
|
||||
running_job=job_to_summary(running),
|
||||
last_job=job_to_summary(last),
|
||||
next_due_at=next_due_at(db, policy),
|
||||
policy=policy_to_out(policy),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -318,6 +331,7 @@ def create_collection(db: Session, body: CollectionJobCreate) -> CollectionJobOu
|
|||
job = NeCollectionJob(
|
||||
title=str(body.title or "").strip() or f"collect-{now.strftime('%Y%m%d-%H%M%S')}",
|
||||
commands="\n".join(commands),
|
||||
trigger_mode="manual",
|
||||
status="pending",
|
||||
ne_count=len(targets),
|
||||
created_at=now,
|
||||
|
|
@ -342,8 +356,81 @@ def create_collection(db: Session, body: CollectionJobCreate) -> CollectionJobOu
|
|||
return job_to_out(job, output_count=0)
|
||||
|
||||
|
||||
def list_collection_jobs(db: Session, *, page: int = 1, page_size: int = 20) -> dict[str, Any]:
|
||||
def create_and_start_from_policy(
|
||||
db: Session,
|
||||
*,
|
||||
trigger_mode: str = "manual",
|
||||
) -> tuple[CollectionJobOut, CollectionSchedulePayload]:
|
||||
"""Create a job from the singleton policy and start it immediately."""
|
||||
if has_active_collection_job(db) is not None:
|
||||
raise HTTPException(status_code=409, detail="collection_job_running")
|
||||
policy = ensure_policy(db)
|
||||
commands = _parse_commands(str(policy.commands or ""))
|
||||
if not commands:
|
||||
raise HTTPException(status_code=400, detail="commands_empty")
|
||||
targets = expand_policy_targets(db, policy)
|
||||
if not targets:
|
||||
raise HTTPException(status_code=400, detail="no_eligible_ne")
|
||||
mode = str(trigger_mode or "manual").strip().lower() or "manual"
|
||||
if mode not in {"manual", "schedule"}:
|
||||
mode = "manual"
|
||||
now = _now()
|
||||
title = str(policy.title or "").strip() or f"collect-{now.strftime('%Y%m%d-%H%M%S')}"
|
||||
job = NeCollectionJob(
|
||||
title=title,
|
||||
commands="\n".join(commands),
|
||||
trigger_mode=mode,
|
||||
status="pending",
|
||||
ne_count=len(targets),
|
||||
created_at=now,
|
||||
started_at=None,
|
||||
last_run_at=None,
|
||||
)
|
||||
db.add(job)
|
||||
db.flush()
|
||||
for source, tid, name, ip in targets:
|
||||
db.add(
|
||||
NeCollectionRun(
|
||||
job_id=str(job.id),
|
||||
ne_id=tid,
|
||||
ne_source=source,
|
||||
ne_name=name,
|
||||
ne_ip=ip,
|
||||
status="pending",
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(job)
|
||||
out, payload = start_collection_job(db, str(job.id))
|
||||
try:
|
||||
prune_collection_jobs(db, keep=history_keep_value(policy))
|
||||
except Exception: # noqa: BLE001
|
||||
_log.exception("prune_collection_jobs after start failed")
|
||||
return out, payload
|
||||
|
||||
|
||||
def list_collection_jobs(
|
||||
db: Session,
|
||||
*,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
status: str = "",
|
||||
keyword: str = "",
|
||||
) -> dict[str, Any]:
|
||||
stmt = db.query(NeCollectionJob)
|
||||
st = str(status or "").strip()
|
||||
if st:
|
||||
stmt = stmt.filter(NeCollectionJob.status == st)
|
||||
kw = str(keyword or "").strip()
|
||||
if kw:
|
||||
like = f"%{kw}%"
|
||||
stmt = stmt.filter(
|
||||
or_(
|
||||
NeCollectionJob.title.ilike(like),
|
||||
NeCollectionJob.id.ilike(like),
|
||||
NeCollectionJob.error_message.ilike(like),
|
||||
)
|
||||
)
|
||||
total = int(stmt.count())
|
||||
rows = stmt.order_by(NeCollectionJob.created_at.desc()).offset((page - 1) * page_size).limit(page_size).all()
|
||||
for row in rows:
|
||||
|
|
|
|||
|
|
@ -81,6 +81,10 @@ class Settings(BaseSettings):
|
|||
ne_collect_pending_stale_sec: int = 180
|
||||
ne_collect_run_timeout_cap_sec: int = 600
|
||||
ne_collection_data_dir: str = "data/ne_collections"
|
||||
# Batch CLI collect scheduler (policy.enabled defaults False — manual until turned on).
|
||||
ne_collect_scheduler_enabled: bool = True
|
||||
ne_collect_scheduler_tick_sec: int = 60
|
||||
ne_collect_startup_grace_sec: int = 3600
|
||||
# Config sync (periodic running-config backup into DB)
|
||||
config_sync_scheduler_enabled: bool = True
|
||||
config_sync_scheduler_tick_sec: int = 60
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ from typing import Any
|
|||
from uuid import uuid4
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy import func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .cli_resolve import cli_profile_ready
|
||||
|
|
@ -252,8 +252,29 @@ def create_cycle(db: Session, body: ConfigSyncCycleCreate) -> ConfigSyncCycleOut
|
|||
return cycle_to_out(cycle)
|
||||
|
||||
|
||||
def list_cycles(db: Session, *, page: int, page_size: int) -> dict[str, Any]:
|
||||
q = db.query(ConfigSyncCycle).order_by(ConfigSyncCycle.created_at.desc())
|
||||
def list_cycles(
|
||||
db: Session,
|
||||
*,
|
||||
page: int,
|
||||
page_size: int,
|
||||
status: str = "",
|
||||
keyword: str = "",
|
||||
) -> dict[str, Any]:
|
||||
q = db.query(ConfigSyncCycle)
|
||||
st = str(status or "").strip()
|
||||
if st:
|
||||
q = q.filter(ConfigSyncCycle.status == st)
|
||||
kw = str(keyword or "").strip()
|
||||
if kw:
|
||||
like = f"%{kw}%"
|
||||
q = q.filter(
|
||||
or_(
|
||||
ConfigSyncCycle.id.ilike(like),
|
||||
ConfigSyncCycle.trigger_mode.ilike(like),
|
||||
ConfigSyncCycle.error_message.ilike(like),
|
||||
)
|
||||
)
|
||||
q = q.order_by(ConfigSyncCycle.created_at.desc())
|
||||
total = int(q.count())
|
||||
rows = q.offset((page - 1) * page_size).limit(page_size).all()
|
||||
return {"total": total, "page": page, "page_size": page_size, "items": [cycle_to_out(r) for r in rows]}
|
||||
|
|
|
|||
|
|
@ -74,9 +74,11 @@ def api_dashboard(db: Session = Depends(get_db)):
|
|||
def api_list_cycles(
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
status: str = Query(default=""),
|
||||
keyword: str = Query(default=""),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
return list_cycles(db, page=page, page_size=page_size)
|
||||
return list_cycles(db, page=page, page_size=page_size, status=status, keyword=keyword)
|
||||
|
||||
|
||||
@router.post("/cycles")
|
||||
|
|
|
|||
|
|
@ -67,9 +67,11 @@ def api_stop_job(job_id: str, db: Session = Depends(get_db)) -> dict[str, Any]:
|
|||
def api_list_jobs(
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
status: str = Query(default=""),
|
||||
keyword: str = Query(default=""),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict[str, Any]:
|
||||
return list_jobs(db, page=page, page_size=page_size)
|
||||
return list_jobs(db, page=page, page_size=page_size, status=status, keyword=keyword)
|
||||
|
||||
|
||||
@router.get("/jobs/{job_id}")
|
||||
|
|
@ -77,6 +79,15 @@ def api_get_job(
|
|||
job_id: str,
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
status: str = Query(default=""),
|
||||
keyword: str = Query(default=""),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict[str, Any]:
|
||||
return get_job_detail(db, job_id, page=page, page_size=page_size)
|
||||
return get_job_detail(
|
||||
db,
|
||||
job_id,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
item_status=status,
|
||||
item_keyword=keyword,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -481,10 +481,32 @@ def get_dashboard(db: Session) -> LldpCollectDashboardOut:
|
|||
)
|
||||
|
||||
|
||||
def list_jobs(db: Session, *, page: int = 1, page_size: int = 20) -> dict:
|
||||
def list_jobs(
|
||||
db: Session,
|
||||
*,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
status: str = "",
|
||||
keyword: str = "",
|
||||
) -> dict:
|
||||
page = max(1, int(page or 1))
|
||||
page_size = max(1, min(100, int(page_size or 20)))
|
||||
q = db.query(TopoDiscoverJob).order_by(TopoDiscoverJob.created_at.desc())
|
||||
q = db.query(TopoDiscoverJob)
|
||||
st = str(status or "").strip()
|
||||
if st:
|
||||
q = q.filter(TopoDiscoverJob.status == st)
|
||||
kw = str(keyword or "").strip()
|
||||
if kw:
|
||||
like = f"%{kw}%"
|
||||
q = q.filter(
|
||||
or_(
|
||||
TopoDiscoverJob.id.ilike(like),
|
||||
TopoDiscoverJob.error.ilike(like),
|
||||
TopoDiscoverJob.scope.ilike(like),
|
||||
TopoDiscoverJob.trigger_mode.ilike(like),
|
||||
)
|
||||
)
|
||||
q = q.order_by(TopoDiscoverJob.created_at.desc())
|
||||
total = int(q.count())
|
||||
rows = q.offset((page - 1) * page_size).limit(page_size).all()
|
||||
outcomes = _job_outcome_map(db, [r.id for r in rows])
|
||||
|
|
@ -503,6 +525,19 @@ def list_jobs(db: Session, *, page: int = 1, page_size: int = 20) -> dict:
|
|||
|
||||
|
||||
def get_job_detail(
|
||||
db: Session, job_id: str, *, page: int | None = None, page_size: int | None = None
|
||||
db: Session,
|
||||
job_id: str,
|
||||
*,
|
||||
page: int | None = None,
|
||||
page_size: int | None = None,
|
||||
item_status: str = "",
|
||||
item_keyword: str = "",
|
||||
) -> dict:
|
||||
return get_discover_job(db, job_id, page=page, page_size=page_size).model_dump()
|
||||
return get_discover_job(
|
||||
db,
|
||||
job_id,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
item_status=item_status,
|
||||
item_keyword=item_keyword,
|
||||
).model_dump()
|
||||
|
|
|
|||
|
|
@ -111,6 +111,7 @@ def _prom_lines(metrics: dict[str, Any]) -> str:
|
|||
for name, key in (
|
||||
("config_sync", "netx_config_sync_scheduler_running"),
|
||||
("lldp_collect", "netx_lldp_collect_scheduler_running"),
|
||||
("ne_collect", "netx_ne_collect_scheduler_running"),
|
||||
("port_traffic", "netx_port_traffic_scheduler_running"),
|
||||
("fabric_reconcile", "netx_fabric_reconcile_scheduler_running"),
|
||||
):
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ from .managed_ne import (
|
|||
CliConnectProfile,
|
||||
ManagedNE,
|
||||
NeCollectionJob,
|
||||
NeCollectionPolicy,
|
||||
NeCollectionRun,
|
||||
UmeCliOverride,
|
||||
)
|
||||
|
|
@ -82,6 +83,7 @@ __all__ = [
|
|||
"CliConnectProfile",
|
||||
"UmeCliOverride",
|
||||
"NeCollectionJob",
|
||||
"NeCollectionPolicy",
|
||||
"NeCollectionRun",
|
||||
"TopoFabricNode",
|
||||
"TopoClassifyRule",
|
||||
|
|
|
|||
|
|
@ -98,6 +98,28 @@ class UmeCliOverride(Base):
|
|||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive, index=True)
|
||||
|
||||
|
||||
class NeCollectionPolicy(Base):
|
||||
"""Singleton policy for periodic batch CLI collect (id=1).
|
||||
|
||||
Default ``enabled=False``: one-shot / manual only until the operator turns on schedule.
|
||||
``history_keep`` defaults to 3 finished jobs.
|
||||
"""
|
||||
|
||||
__tablename__ = "ne_collection_policy"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1)
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
interval_days: Mapped[int] = mapped_column(Integer, default=1) # legacy mirror of hours
|
||||
interval_hours: Mapped[int] = mapped_column(Integer, default=24)
|
||||
scope_mode: Mapped[str] = mapped_column(String(32), default="all") # all | selected
|
||||
selected_targets: Mapped[list] = mapped_column(_JsonType, default=list)
|
||||
title: Mapped[str] = mapped_column(String(256), default="")
|
||||
commands: Mapped[str] = mapped_column(Text, default="")
|
||||
# Finished jobs to retain (newest kept); active jobs always kept.
|
||||
history_keep: Mapped[int] = mapped_column(Integer, default=3)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive)
|
||||
|
||||
|
||||
class NeCollectionJob(Base):
|
||||
"""Batch CLI collection job over managed NEs."""
|
||||
|
||||
|
|
@ -106,6 +128,8 @@ class NeCollectionJob(Base):
|
|||
id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: uuid4().hex)
|
||||
title: Mapped[str] = mapped_column(String(256), default="")
|
||||
commands: Mapped[str] = mapped_column(Text, default="")
|
||||
# manual | schedule
|
||||
trigger_mode: Mapped[str] = mapped_column(String(32), default="manual", index=True)
|
||||
status: Mapped[str] = mapped_column(String(32), default="pending", index=True)
|
||||
ne_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
success_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
|
|
|||
|
|
@ -26,10 +26,12 @@ class TopoFabricNode(Base):
|
|||
ip: Mapped[str] = mapped_column(String(128), default="", index=True)
|
||||
vendor: Mapped[str] = mapped_column(String(64), default="")
|
||||
device_type: Mapped[str] = mapped_column(String(64), default="")
|
||||
# Classify tags (regex rules / manual). role: core|aggregation|access|unknown|""
|
||||
# Layout rank: major.minor (0=external … 1=core … 2=agg … 3=access). None = unclassified.
|
||||
level: Mapped[float | None] = mapped_column(Float, nullable=True, index=True)
|
||||
# Synced alias from floor(level): external|core|aggregation|access|edge|""
|
||||
role: Mapped[str] = mapped_column(String(32), default="", index=True)
|
||||
region_folder_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
# rule | manual | ""
|
||||
# rule | manual | "" (applies to level; role is derived)
|
||||
role_source: Mapped[str] = mapped_column(String(16), default="")
|
||||
region_source: Mapped[str] = mapped_column(String(16), default="")
|
||||
# Composed flat-world coordinates (packed per-SBN local layouts). Not raw UME xPos/yPos.
|
||||
|
|
@ -47,15 +49,15 @@ class TopoClassifyRule(Base):
|
|||
__tablename__ = "topo_classify_rule"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: uuid4().hex)
|
||||
# role | region
|
||||
scope: Mapped[str] = mapped_column(String(16), default="role", index=True)
|
||||
# level | region (legacy scope "role" accepted as alias of level)
|
||||
scope: Mapped[str] = mapped_column(String(16), default="level", index=True)
|
||||
name: Mapped[str] = mapped_column(String(256), default="")
|
||||
pattern: Mapped[str] = mapped_column(String(512), default="")
|
||||
# name | ip | name_ip
|
||||
match_field: Mapped[str] = mapped_column(String(32), default="name")
|
||||
priority: Mapped[int] = mapped_column(Integer, default=100, index=True)
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
|
||||
# role: {role}; region: {folder_id} or {region_name_from_group}
|
||||
# role: {level} or legacy {role}; region: {folder_id} or {region_name_from_group}
|
||||
payload: Mapped[dict] = mapped_column(_JsonType, default=dict)
|
||||
remark: Mapped[str] = mapped_column(String(512), default="")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive)
|
||||
|
|
|
|||
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 fastapi import HTTPException
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .cli_creds import require_cli_creds_ready
|
||||
|
|
@ -51,8 +52,30 @@ from .port_traffic_schemas import (
|
|||
_log = logging.getLogger("netx.port_traffic.service")
|
||||
|
||||
|
||||
def list_devices(db: Session, *, page: int = 1, page_size: int = 20) -> dict[str, Any]:
|
||||
q = db.query(PortTrafficDevice).order_by(PortTrafficDevice.ne_name.asc(), PortTrafficDevice.ne_ip.asc())
|
||||
def list_devices(
|
||||
db: Session,
|
||||
*,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
status: str = "",
|
||||
keyword: str = "",
|
||||
) -> dict[str, Any]:
|
||||
q = db.query(PortTrafficDevice)
|
||||
st = str(status or "").strip()
|
||||
if st:
|
||||
q = q.filter(PortTrafficDevice.status == st)
|
||||
kw = str(keyword or "").strip()
|
||||
if kw:
|
||||
like = f"%{kw}%"
|
||||
q = q.filter(
|
||||
or_(
|
||||
PortTrafficDevice.ne_name.ilike(like),
|
||||
PortTrafficDevice.ne_ip.ilike(like),
|
||||
PortTrafficDevice.ne_id.ilike(like),
|
||||
PortTrafficDevice.vendor.ilike(like),
|
||||
)
|
||||
)
|
||||
q = q.order_by(PortTrafficDevice.ne_name.asc(), PortTrafficDevice.ne_ip.asc())
|
||||
total = q.count()
|
||||
rows = q.offset((page - 1) * page_size).limit(page_size).all()
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -163,9 +163,11 @@ def api_delete_board(
|
|||
def api_list_devices(
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
status: str = Query(default=""),
|
||||
keyword: str = Query(default=""),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
return list_devices(db, page=page, page_size=page_size)
|
||||
return list_devices(db, page=page, page_size=page_size, status=status, keyword=keyword)
|
||||
|
||||
|
||||
@router.post("/devices")
|
||||
|
|
|
|||
|
|
@ -55,6 +55,12 @@ def local_device_scheduler_status(*, role: str = "unknown") -> dict[str, Any]:
|
|||
out["lldp_collect"] = lldp_collect_scheduler_status()
|
||||
except Exception: # noqa: BLE001
|
||||
out["lldp_collect"] = {"running": False, "error": "unavailable"}
|
||||
try:
|
||||
from .ne_collect_scheduler import ne_collect_scheduler_status
|
||||
|
||||
out["ne_collect"] = ne_collect_scheduler_status()
|
||||
except Exception: # noqa: BLE001
|
||||
out["ne_collect"] = {"running": False, "error": "unavailable"}
|
||||
try:
|
||||
from .port_traffic_scheduler import port_traffic_scheduler_status
|
||||
|
||||
|
|
|
|||
|
|
@ -154,6 +154,7 @@ def apply_topology_schema_safety_net(conn: Connection) -> None:
|
|||
)
|
||||
_run_sql(conn, "ALTER TABLE topo_fabric_node ADD COLUMN IF NOT EXISTS world_x DOUBLE PRECISION")
|
||||
_run_sql(conn, "ALTER TABLE topo_fabric_node ADD COLUMN IF NOT EXISTS world_y DOUBLE PRECISION")
|
||||
_run_sql(conn, "ALTER TABLE topo_fabric_node ADD COLUMN IF NOT EXISTS level DOUBLE PRECISION")
|
||||
_run_sql(
|
||||
conn,
|
||||
"CREATE INDEX IF NOT EXISTS ix_topo_fabric_node_world_x ON topo_fabric_node (world_x)",
|
||||
|
|
@ -162,6 +163,10 @@ def apply_topology_schema_safety_net(conn: Connection) -> None:
|
|||
conn,
|
||||
"CREATE INDEX IF NOT EXISTS ix_topo_fabric_node_world_y ON topo_fabric_node (world_y)",
|
||||
)
|
||||
_run_sql(
|
||||
conn,
|
||||
"CREATE INDEX IF NOT EXISTS ix_topo_fabric_node_level ON topo_fabric_node (level)",
|
||||
)
|
||||
# UME link ifnames — required by topology sync/apply; missing when DB predates the columns
|
||||
# or Alembic was stamped head without running domain patches.
|
||||
_run_sql(
|
||||
|
|
@ -329,6 +334,7 @@ def apply_domain_schema_patches(conn: Connection) -> None:
|
|||
"ALTER TABLE ne_collection_job ADD COLUMN IF NOT EXISTS last_run_at TIMESTAMP",
|
||||
"UPDATE ne_collection_job SET last_run_at = COALESCE(ended_at, started_at, created_at) "
|
||||
"WHERE last_run_at IS NULL",
|
||||
"ALTER TABLE ne_collection_job ADD COLUMN IF NOT EXISTS trigger_mode VARCHAR(32) DEFAULT 'manual'",
|
||||
"ALTER TABLE topology_edge ADD COLUMN IF NOT EXISTS stroke_color VARCHAR(32) DEFAULT ''",
|
||||
"ALTER TABLE topology_edge ADD COLUMN IF NOT EXISTS stroke_width INTEGER DEFAULT 0",
|
||||
"ALTER TABLE topology_edge ADD COLUMN IF NOT EXISTS line_style VARCHAR(16) DEFAULT ''",
|
||||
|
|
|
|||
|
|
@ -1,25 +1,23 @@
|
|||
"""Topology classify preview/apply and fabric node tagging."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .models import TopoClassifyRule, TopoFabricNode, TopoFolder
|
||||
from .models import TopoFabricNode, TopoFolder
|
||||
from .topology_classify_common import (
|
||||
_MATCH_FIELDS,
|
||||
_ROLE_VALUES,
|
||||
_compile_pattern,
|
||||
_enabled_rules,
|
||||
_ensure_region_by_name,
|
||||
_match_text,
|
||||
_resolve_level_hit,
|
||||
_resolve_region_hit,
|
||||
_resolve_role_hit,
|
||||
_utcnow,
|
||||
apply_level_fields,
|
||||
)
|
||||
from .topology_membership import normalize_view_role
|
||||
from .topology_level import level_to_role, normalize_level, role_to_level
|
||||
from .topology_schemas import (
|
||||
ClassifyApplyOut,
|
||||
ClassifyPreviewOut,
|
||||
|
|
@ -31,31 +29,33 @@ from .topology_schemas import (
|
|||
FabricNodeTagPatch,
|
||||
)
|
||||
|
||||
|
||||
def preview_classify(db: Session, *, sample_limit: int = 20) -> ClassifyPreviewOut:
|
||||
role_rules = _enabled_rules(db, "role")
|
||||
level_rules = _enabled_rules(db, "level")
|
||||
region_rules = _enabled_rules(db, "region")
|
||||
nodes = db.query(TopoFabricNode).order_by(TopoFabricNode.name.asc()).all()
|
||||
role_matched = role_unmatched = role_conflict = 0
|
||||
level_matched = level_unmatched = level_conflict = 0
|
||||
region_matched = region_unmatched = region_conflict = 0
|
||||
role_samples: list[dict[str, Any]] = []
|
||||
level_samples: list[dict[str, Any]] = []
|
||||
region_samples: list[dict[str, Any]] = []
|
||||
unmatched_samples: list[dict[str, Any]] = []
|
||||
|
||||
for n in nodes:
|
||||
role, _rid, multi_r = _resolve_role_hit(n, role_rules)
|
||||
if role is None:
|
||||
role_unmatched += 1
|
||||
level, _rid, multi_r = _resolve_level_hit(n, level_rules)
|
||||
if level is None:
|
||||
level_unmatched += 1
|
||||
else:
|
||||
role_matched += 1
|
||||
level_matched += 1
|
||||
if multi_r:
|
||||
role_conflict += 1
|
||||
if len(role_samples) < sample_limit:
|
||||
role_samples.append(
|
||||
level_conflict += 1
|
||||
if len(level_samples) < sample_limit:
|
||||
level_samples.append(
|
||||
{
|
||||
"fabric_node_id": n.id,
|
||||
"name": n.name,
|
||||
"ip": n.ip,
|
||||
"role": role,
|
||||
"level": level,
|
||||
"role": level_to_role(level),
|
||||
"multi_hit": multi_r,
|
||||
}
|
||||
)
|
||||
|
|
@ -80,20 +80,24 @@ def preview_classify(db: Session, *, sample_limit: int = 20) -> ClassifyPreviewO
|
|||
}
|
||||
)
|
||||
|
||||
if role is None and region_id is None and len(unmatched_samples) < sample_limit:
|
||||
if level is None and region_id is None and len(unmatched_samples) < sample_limit:
|
||||
unmatched_samples.append(
|
||||
{"fabric_node_id": n.id, "name": n.name, "ip": n.ip, "vendor": n.vendor}
|
||||
)
|
||||
|
||||
return ClassifyPreviewOut(
|
||||
total_nodes=len(nodes),
|
||||
role_matched=role_matched,
|
||||
role_unmatched=role_unmatched,
|
||||
role_conflicts=role_conflict,
|
||||
level_matched=level_matched,
|
||||
level_unmatched=level_unmatched,
|
||||
level_conflicts=level_conflict,
|
||||
role_matched=level_matched,
|
||||
role_unmatched=level_unmatched,
|
||||
role_conflicts=level_conflict,
|
||||
region_matched=region_matched,
|
||||
region_unmatched=region_unmatched,
|
||||
region_conflicts=region_conflict,
|
||||
role_samples=role_samples,
|
||||
level_samples=level_samples,
|
||||
role_samples=level_samples,
|
||||
region_samples=region_samples,
|
||||
unmatched_samples=unmatched_samples,
|
||||
)
|
||||
|
|
@ -105,27 +109,22 @@ def apply_classify(
|
|||
skip_manual: bool = True,
|
||||
fill_empty_only: bool = False,
|
||||
) -> ClassifyApplyOut:
|
||||
role_rules = _enabled_rules(db, "role")
|
||||
level_rules = _enabled_rules(db, "level")
|
||||
region_rules = _enabled_rules(db, "region")
|
||||
nodes = db.query(TopoFabricNode).all()
|
||||
role_updated = region_updated = skipped_manual = 0
|
||||
level_updated = region_updated = skipped_manual = 0
|
||||
|
||||
for n in nodes:
|
||||
role, _, _ = _resolve_role_hit(n, role_rules)
|
||||
if role is not None:
|
||||
level, _, _ = _resolve_level_hit(n, level_rules)
|
||||
if level is not None:
|
||||
if skip_manual and str(n.role_source or "") == "manual":
|
||||
skipped_manual += 1
|
||||
elif fill_empty_only and str(n.role or "").strip():
|
||||
elif fill_empty_only and n.level is not None:
|
||||
pass
|
||||
else:
|
||||
n.role = role
|
||||
n.role_source = "rule"
|
||||
apply_level_fields(n, level, source="rule")
|
||||
n.updated_at = _utcnow()
|
||||
role_updated += 1
|
||||
elif not str(n.role or "").strip() and str(n.role_source or "") != "manual":
|
||||
n.role = "unknown"
|
||||
n.role_source = "rule"
|
||||
n.updated_at = _utcnow()
|
||||
level_updated += 1
|
||||
|
||||
region_id, _, _ = _resolve_region_hit(db, n, region_rules, create_missing=True)
|
||||
if region_id is not None and not str(region_id).startswith("new:"):
|
||||
|
|
@ -141,7 +140,8 @@ def apply_classify(
|
|||
|
||||
db.commit()
|
||||
return ClassifyApplyOut(
|
||||
role_updated=role_updated,
|
||||
level_updated=level_updated,
|
||||
role_updated=level_updated,
|
||||
region_updated=region_updated,
|
||||
skipped_manual=skipped_manual,
|
||||
total_nodes=len(nodes),
|
||||
|
|
@ -159,17 +159,19 @@ def list_unmatched(
|
|||
|
||||
q = db.query(TopoFabricNode)
|
||||
k = str(kind or "any").strip().lower()
|
||||
role_miss = or_(TopoFabricNode.role == "", TopoFabricNode.role == "unknown")
|
||||
if k == "role":
|
||||
k = "level"
|
||||
level_miss = TopoFabricNode.level.is_(None)
|
||||
region_miss = or_(
|
||||
TopoFabricNode.region_folder_id.is_(None),
|
||||
TopoFabricNode.region_folder_id == "",
|
||||
)
|
||||
if k == "role":
|
||||
q = q.filter(role_miss)
|
||||
if k == "level":
|
||||
q = q.filter(level_miss)
|
||||
elif k == "region":
|
||||
q = q.filter(region_miss)
|
||||
else:
|
||||
q = q.filter(or_(role_miss, region_miss))
|
||||
q = q.filter(or_(level_miss, region_miss))
|
||||
total = q.count()
|
||||
rows = (
|
||||
q.order_by(TopoFabricNode.name.asc())
|
||||
|
|
@ -195,14 +197,18 @@ def patch_fabric_node_tags(
|
|||
n = db.get(TopoFabricNode, fabric_node_id)
|
||||
if n is None:
|
||||
raise HTTPException(status_code=404, detail="fabric_node_not_found")
|
||||
if body.role is not None:
|
||||
role = str(body.role or "").strip().lower()
|
||||
if role and role not in _ROLE_VALUES:
|
||||
raise HTTPException(status_code=400, detail="role_invalid")
|
||||
n.role = role
|
||||
n.role_source = "manual"
|
||||
if body.region_folder_id is not None:
|
||||
fid = str(body.region_folder_id or "").strip()
|
||||
data = body.model_dump(exclude_unset=True)
|
||||
if "level" in data or "role" in data:
|
||||
try:
|
||||
if "level" in data:
|
||||
lv = normalize_level(data.get("level"))
|
||||
else:
|
||||
lv = role_to_level(data.get("role"))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
apply_level_fields(n, lv, source="manual")
|
||||
if "region_folder_id" in data:
|
||||
fid = str(data.get("region_folder_id") or "").strip()
|
||||
if fid:
|
||||
folder = db.get(TopoFolder, fid)
|
||||
if folder is None or str(folder.kind or "") != "region":
|
||||
|
|
@ -245,6 +251,7 @@ def match_fabric_nodes(db: Session, body: FabricNodesMatchRequest) -> FabricNode
|
|||
"fabric_node_id": n.id,
|
||||
"name": n.name,
|
||||
"ip": n.ip,
|
||||
"level": n.level,
|
||||
"role": n.role or "",
|
||||
"region_folder_id": n.region_folder_id or "",
|
||||
"link_status": fabric_link_status(n),
|
||||
|
|
@ -263,19 +270,23 @@ def match_fabric_nodes(db: Session, body: FabricNodesMatchRequest) -> FabricNode
|
|||
def bulk_tag_fabric_nodes(
|
||||
db: Session, body: FabricNodesBulkTagRequest
|
||||
) -> FabricNodesBulkTagOut:
|
||||
"""Assign role/region after user confirms a regex or explicit selection."""
|
||||
if body.role is None and body.region_folder_id is None:
|
||||
raise HTTPException(status_code=400, detail="role_or_region_required")
|
||||
"""Assign level/region after user confirms a regex or explicit selection."""
|
||||
data = body.model_dump(exclude_unset=True)
|
||||
level_v: float | None | object = Ellipsis
|
||||
if "level" in data:
|
||||
try:
|
||||
level_v = normalize_level(data.get("level"))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
elif "role" in data:
|
||||
try:
|
||||
level_v = role_to_level(data.get("role"))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
role_v: str | None = None
|
||||
if body.role is not None:
|
||||
role_v = str(body.role or "").strip().lower()
|
||||
if role_v and role_v not in _ROLE_VALUES:
|
||||
raise HTTPException(status_code=400, detail="role_invalid")
|
||||
|
||||
region_v: str | None = None
|
||||
if body.region_folder_id is not None:
|
||||
region_v = str(body.region_folder_id or "").strip()
|
||||
region_v: str | None | object = Ellipsis
|
||||
if "region_folder_id" in data:
|
||||
region_v = str(data.get("region_folder_id") or "").strip()
|
||||
if region_v:
|
||||
folder = db.get(TopoFolder, region_v)
|
||||
if folder is None or str(folder.kind or "") != "region":
|
||||
|
|
@ -283,6 +294,9 @@ def bulk_tag_fabric_nodes(
|
|||
else:
|
||||
region_v = ""
|
||||
|
||||
if level_v is Ellipsis and region_v is Ellipsis:
|
||||
raise HTTPException(status_code=400, detail="level_or_region_required")
|
||||
|
||||
ids = [str(x).strip() for x in (body.fabric_node_ids or []) if str(x).strip()]
|
||||
if str(body.pattern or "").strip():
|
||||
matched_nodes = _iter_regex_matches(
|
||||
|
|
@ -298,11 +312,18 @@ def bulk_tag_fabric_nodes(
|
|||
else:
|
||||
raise HTTPException(status_code=400, detail="ids_or_pattern_required")
|
||||
|
||||
role_alias = level_to_role(level_v) if isinstance(level_v, float) else (
|
||||
"" if level_v is None else None
|
||||
)
|
||||
if level_v is Ellipsis:
|
||||
role_alias = None
|
||||
|
||||
samples = [
|
||||
{
|
||||
"fabric_node_id": n.id,
|
||||
"name": n.name,
|
||||
"ip": n.ip,
|
||||
"level": n.level,
|
||||
"role": n.role or "",
|
||||
"region_folder_id": n.region_folder_id or "",
|
||||
}
|
||||
|
|
@ -313,19 +334,19 @@ def bulk_tag_fabric_nodes(
|
|||
dry_run=True,
|
||||
matched=len(matched_nodes),
|
||||
updated=0,
|
||||
role=role_v,
|
||||
region_folder_id=region_v,
|
||||
level=None if level_v is Ellipsis else level_v, # type: ignore[arg-type]
|
||||
role=role_alias,
|
||||
region_folder_id=None if region_v is Ellipsis else (region_v or None), # type: ignore[arg-type]
|
||||
samples=samples,
|
||||
)
|
||||
|
||||
now = _utcnow()
|
||||
updated = 0
|
||||
for n in matched_nodes:
|
||||
if role_v is not None:
|
||||
n.role = role_v
|
||||
n.role_source = "manual"
|
||||
if region_v is not None:
|
||||
n.region_folder_id = region_v or None
|
||||
if level_v is not Ellipsis:
|
||||
apply_level_fields(n, level_v, source="manual") # type: ignore[arg-type]
|
||||
if region_v is not Ellipsis:
|
||||
n.region_folder_id = region_v or None # type: ignore[operator]
|
||||
n.region_source = "manual"
|
||||
n.updated_at = now
|
||||
updated += 1
|
||||
|
|
@ -334,8 +355,9 @@ def bulk_tag_fabric_nodes(
|
|||
dry_run=False,
|
||||
matched=len(matched_nodes),
|
||||
updated=updated,
|
||||
role=role_v,
|
||||
region_folder_id=region_v,
|
||||
level=None if level_v is Ellipsis else level_v, # type: ignore[arg-type]
|
||||
role=role_alias,
|
||||
region_folder_id=None if region_v is Ellipsis else (region_v or None), # type: ignore[arg-type]
|
||||
samples=samples,
|
||||
)
|
||||
|
||||
|
|
@ -343,5 +365,3 @@ def bulk_tag_fabric_nodes(
|
|||
def apply_classify_empty_only(db: Session) -> ClassifyApplyOut:
|
||||
"""Incremental classify for newly synced nodes (fill empty tags only)."""
|
||||
return apply_classify(db, skip_manual=True, fill_empty_only=True)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -10,23 +10,30 @@ from sqlalchemy.orm import Session
|
|||
|
||||
from .models import TopoClassifyRule, TopoFabricNode, TopoFolder
|
||||
from .timeutil import utcnow_naive
|
||||
from .topology_membership import VIEW_ROLES, normalize_view_role
|
||||
from .topology_level import LEVEL_PRESETS, level_to_role, normalize_level, role_to_level
|
||||
from .topology_schemas import (
|
||||
ClassifyRuleOut,
|
||||
TopologyFolderCreate,
|
||||
)
|
||||
|
||||
_MAX_PATTERN_LEN = 512
|
||||
_ROLE_VALUES = VIEW_ROLES | {"unknown"}
|
||||
_MATCH_FIELDS = frozenset({"name", "ip", "name_ip"})
|
||||
_SCOPES = frozenset({"role", "region"})
|
||||
_SCOPES = frozenset({"level", "region", "role"}) # role = legacy alias of level
|
||||
_SLICE_TEMPLATES = frozenset({"core_only", "core_agg", "agg_access"})
|
||||
_ROLE_VALUES = frozenset(LEVEL_PRESETS) | {"unknown", "edge", ""}
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return utcnow_naive()
|
||||
|
||||
|
||||
def _normalize_scope(scope: str) -> str:
|
||||
s = str(scope or "level").strip().lower()
|
||||
if s == "role":
|
||||
return "level"
|
||||
return s
|
||||
|
||||
|
||||
def _compile_pattern(pattern: str) -> re.Pattern[str]:
|
||||
p = str(pattern or "").strip()
|
||||
if not p:
|
||||
|
|
@ -51,9 +58,10 @@ def _match_text(node: TopoFabricNode, match_field: str) -> str:
|
|||
|
||||
|
||||
def _rule_out(row: TopoClassifyRule) -> ClassifyRuleOut:
|
||||
scope = _normalize_scope(str(row.scope or "level"))
|
||||
return ClassifyRuleOut(
|
||||
id=row.id,
|
||||
scope=str(row.scope or "role"),
|
||||
scope=scope,
|
||||
name=str(row.name or ""),
|
||||
pattern=str(row.pattern or ""),
|
||||
match_field=str(row.match_field or "name"),
|
||||
|
|
@ -68,11 +76,25 @@ def _rule_out(row: TopoClassifyRule) -> ClassifyRuleOut:
|
|||
|
||||
def _validate_payload(scope: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
out = dict(payload or {})
|
||||
if scope == "role":
|
||||
role = normalize_view_role(str(out.get("role") or ""))
|
||||
if str(out.get("role") or "").strip().lower() not in VIEW_ROLES:
|
||||
raise HTTPException(status_code=400, detail="role_payload_invalid")
|
||||
return {"role": role}
|
||||
scope_n = _normalize_scope(scope)
|
||||
if scope_n == "level":
|
||||
if "level" in out and out.get("level") is not None:
|
||||
try:
|
||||
lv = normalize_level(out.get("level"))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
if lv is None:
|
||||
raise HTTPException(status_code=400, detail="level_payload_invalid")
|
||||
return {"level": lv}
|
||||
if "role" in out:
|
||||
try:
|
||||
lv = role_to_level(str(out.get("role") or ""))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
if lv is None:
|
||||
raise HTTPException(status_code=400, detail="level_payload_invalid")
|
||||
return {"level": lv, "role": level_to_role(lv)}
|
||||
raise HTTPException(status_code=400, detail="level_payload_invalid")
|
||||
if "folder_id" in out and str(out.get("folder_id") or "").strip():
|
||||
return {"folder_id": str(out["folder_id"]).strip()}
|
||||
if "region_name_from_group" in out:
|
||||
|
|
@ -87,9 +109,11 @@ def _validate_payload(scope: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|||
|
||||
|
||||
def _enabled_rules(db: Session, scope: str) -> list[tuple[TopoClassifyRule, re.Pattern[str]]]:
|
||||
scope_n = _normalize_scope(scope)
|
||||
scopes = ("level", "role") if scope_n == "level" else (scope_n,)
|
||||
rows = (
|
||||
db.query(TopoClassifyRule)
|
||||
.filter(TopoClassifyRule.scope == scope, TopoClassifyRule.enabled.is_(True))
|
||||
.filter(TopoClassifyRule.scope.in_(scopes), TopoClassifyRule.enabled.is_(True))
|
||||
.order_by(TopoClassifyRule.priority.asc(), TopoClassifyRule.name.asc())
|
||||
.all()
|
||||
)
|
||||
|
|
@ -122,23 +146,50 @@ def _ensure_region_by_name(db: Session, name: str) -> TopoFolder:
|
|||
return folder
|
||||
|
||||
|
||||
def _resolve_role_hit(
|
||||
def _payload_level(payload: dict[str, Any] | None) -> float | None:
|
||||
p = dict(payload or {})
|
||||
if "level" in p and p.get("level") is not None:
|
||||
try:
|
||||
return normalize_level(p.get("level"))
|
||||
except ValueError:
|
||||
return None
|
||||
if "role" in p:
|
||||
try:
|
||||
return role_to_level(str(p.get("role") or ""))
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_level_hit(
|
||||
node: TopoFabricNode, rules: list[tuple[TopoClassifyRule, re.Pattern[str]]]
|
||||
) -> tuple[str | None, str | None, bool]:
|
||||
"""Return (role, rule_id, multi_hit)."""
|
||||
hits: list[tuple[str, str]] = []
|
||||
) -> tuple[float | None, str | None, bool]:
|
||||
"""Return (level, rule_id, multi_hit)."""
|
||||
hits: list[tuple[float, str]] = []
|
||||
for rule, cre in rules:
|
||||
text = _match_text(node, rule.match_field)
|
||||
if not text:
|
||||
continue
|
||||
if cre.search(text):
|
||||
role = normalize_view_role(str((rule.payload or {}).get("role") or ""))
|
||||
hits.append((role, rule.id))
|
||||
lv = _payload_level(rule.payload)
|
||||
if lv is None:
|
||||
continue
|
||||
hits.append((lv, rule.id))
|
||||
if not hits:
|
||||
return None, None, False
|
||||
return hits[0][0], hits[0][1], len(hits) > 1
|
||||
|
||||
|
||||
# Back-compat name used by older imports
|
||||
def _resolve_role_hit(
|
||||
node: TopoFabricNode, rules: list[tuple[TopoClassifyRule, re.Pattern[str]]]
|
||||
) -> tuple[str | None, str | None, bool]:
|
||||
lv, rid, multi = _resolve_level_hit(node, rules)
|
||||
if lv is None:
|
||||
return None, None, False
|
||||
return level_to_role(lv), rid, multi
|
||||
|
||||
|
||||
def _resolve_region_hit(
|
||||
db: Session,
|
||||
node: TopoFabricNode,
|
||||
|
|
@ -182,3 +233,8 @@ def _resolve_region_hit(
|
|||
return hits[0][0], hits[0][1], len(hits) > 1
|
||||
|
||||
|
||||
def apply_level_fields(node: TopoFabricNode, level: float | None, *, source: str) -> None:
|
||||
"""Write level + synced role alias."""
|
||||
node.level = level
|
||||
node.role = level_to_role(level)
|
||||
node.role_source = source
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ from .topology_classify_common import (
|
|||
_MAX_PATTERN_LEN,
|
||||
_SCOPES,
|
||||
_compile_pattern,
|
||||
_normalize_scope,
|
||||
_rule_out,
|
||||
_utcnow,
|
||||
_validate_payload,
|
||||
|
|
@ -21,8 +22,13 @@ from .topology_schemas import ClassifyRuleCreate, ClassifyRuleOut, ClassifyRuleU
|
|||
|
||||
def list_rules(db: Session, *, scope: str = "") -> list[ClassifyRuleOut]:
|
||||
q = db.query(TopoClassifyRule)
|
||||
if scope.strip():
|
||||
q = q.filter(TopoClassifyRule.scope == scope.strip().lower())
|
||||
scope_f = str(scope or "").strip().lower()
|
||||
if scope_f:
|
||||
scope_n = _normalize_scope(scope_f)
|
||||
if scope_n == "level":
|
||||
q = q.filter(TopoClassifyRule.scope.in_(("level", "role")))
|
||||
else:
|
||||
q = q.filter(TopoClassifyRule.scope == scope_n)
|
||||
rows = q.order_by(
|
||||
TopoClassifyRule.scope.asc(),
|
||||
TopoClassifyRule.priority.asc(),
|
||||
|
|
@ -32,9 +38,10 @@ def list_rules(db: Session, *, scope: str = "") -> list[ClassifyRuleOut]:
|
|||
|
||||
|
||||
def create_rule(db: Session, body: ClassifyRuleCreate) -> ClassifyRuleOut:
|
||||
scope = str(body.scope or "").strip().lower()
|
||||
if scope not in _SCOPES:
|
||||
scope_raw = str(body.scope or "").strip().lower()
|
||||
if scope_raw not in _SCOPES:
|
||||
raise HTTPException(status_code=400, detail="scope_invalid")
|
||||
scope = _normalize_scope(scope_raw)
|
||||
match_field = str(body.match_field or "name").strip().lower()
|
||||
if match_field not in _MATCH_FIELDS:
|
||||
raise HTTPException(status_code=400, detail="match_field_invalid")
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ from __future__ import annotations
|
|||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .cli_resolve import get_default_profile, infer_device_type_vendor
|
||||
|
|
@ -48,6 +49,8 @@ def _job_out(
|
|||
include_items: bool = True,
|
||||
page: int | None = None,
|
||||
page_size: int | None = None,
|
||||
item_status: str = "",
|
||||
item_keyword: str = "",
|
||||
) -> FabricDiscoverJobOut:
|
||||
items_out: list[FabricDiscoverJobItemOut] = []
|
||||
items_total = 0
|
||||
|
|
@ -59,6 +62,37 @@ def _job_out(
|
|||
.filter(TopoDiscoverJobItem.job_id == job.id)
|
||||
.order_by(TopoDiscoverJobItem.created_at.asc())
|
||||
)
|
||||
st = str(item_status or "").strip().lower()
|
||||
if st in ("ok", "success", "pass"):
|
||||
q = q.filter(
|
||||
TopoDiscoverJobItem.ok.is_(True),
|
||||
TopoDiscoverJobItem.parser_stub.is_(False),
|
||||
TopoDiscoverJobItem.unmatched_count == 0,
|
||||
)
|
||||
elif st in ("fail", "failed", "error"):
|
||||
q = q.filter(
|
||||
or_(
|
||||
TopoDiscoverJobItem.ok.is_(False),
|
||||
TopoDiscoverJobItem.parser_stub.is_(True),
|
||||
)
|
||||
)
|
||||
elif st in ("warn", "warning"):
|
||||
q = q.filter(
|
||||
TopoDiscoverJobItem.ok.is_(True),
|
||||
TopoDiscoverJobItem.parser_stub.is_(False),
|
||||
TopoDiscoverJobItem.unmatched_count > 0,
|
||||
)
|
||||
kw = str(item_keyword or "").strip()
|
||||
if kw:
|
||||
like = f"%{kw}%"
|
||||
q = q.filter(
|
||||
or_(
|
||||
TopoDiscoverJobItem.ne_name.ilike(like),
|
||||
TopoDiscoverJobItem.ne_ip.ilike(like),
|
||||
TopoDiscoverJobItem.ne_id.ilike(like),
|
||||
TopoDiscoverJobItem.error.ilike(like),
|
||||
)
|
||||
)
|
||||
items_total = int(q.count())
|
||||
if page is not None and page_size is not None:
|
||||
items_page = max(1, int(page or 1))
|
||||
|
|
@ -121,11 +155,20 @@ def get_discover_job(
|
|||
*,
|
||||
page: int | None = None,
|
||||
page_size: int | None = None,
|
||||
item_status: str = "",
|
||||
item_keyword: str = "",
|
||||
) -> FabricDiscoverJobOut:
|
||||
job = db.get(TopoDiscoverJob, str(job_id or "").strip())
|
||||
if job is None:
|
||||
raise HTTPException(status_code=404, detail="discover_job_not_found")
|
||||
return _job_out(db, job, page=page, page_size=page_size)
|
||||
return _job_out(
|
||||
db,
|
||||
job,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
item_status=item_status,
|
||||
item_keyword=item_keyword,
|
||||
)
|
||||
|
||||
|
||||
def _ume_target_dict(db: Session, uid: str, default_profile: Any) -> dict[str, str] | None:
|
||||
|
|
|
|||
|
|
@ -55,7 +55,13 @@ from .topology_schemas import (
|
|||
|
||||
|
||||
def _node_out(n: TopoFabricNode) -> FabricNodeOut:
|
||||
lv = getattr(n, "level", None)
|
||||
try:
|
||||
level_v = float(lv) if lv is not None else None
|
||||
except (TypeError, ValueError):
|
||||
level_v = None
|
||||
return FabricNodeOut(
|
||||
level=level_v,
|
||||
role=str(getattr(n, "role", "") or ""),
|
||||
region_folder_id=str(getattr(n, "region_folder_id", None) or "") or None,
|
||||
role_source=str(getattr(n, "role_source", "") or ""),
|
||||
|
|
@ -264,6 +270,8 @@ def list_fabric_nodes(
|
|||
*,
|
||||
keyword: str = "",
|
||||
role: str = "",
|
||||
level: str = "",
|
||||
level_major: str = "",
|
||||
region_folder_id: str = "",
|
||||
unmatched: str = "",
|
||||
link_status: str = "",
|
||||
|
|
@ -271,6 +279,7 @@ def list_fabric_nodes(
|
|||
page_size: int = PAGE_DEFAULT,
|
||||
) -> dict[str, Any]:
|
||||
from .topology_inventory_lifecycle import enrich_fabric_node_dicts
|
||||
from .topology_level import LEVEL_PRESETS, normalize_level
|
||||
|
||||
page = max(1, int(page or 1))
|
||||
page_size = max(1, min(PAGE_MAX, int(page_size or PAGE_DEFAULT)))
|
||||
|
|
@ -286,15 +295,46 @@ def list_fabric_nodes(
|
|||
TopoFabricNode.ume_ne_id.ilike(like),
|
||||
)
|
||||
)
|
||||
level_raw = str(level or "").strip()
|
||||
if level_raw:
|
||||
try:
|
||||
lv = normalize_level(level_raw)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
if lv is None:
|
||||
q = q.filter(TopoFabricNode.level.is_(None))
|
||||
else:
|
||||
q = q.filter(TopoFabricNode.level == lv)
|
||||
maj_raw = str(level_major or "").strip()
|
||||
if maj_raw:
|
||||
try:
|
||||
maj = int(float(maj_raw))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail="level_major_invalid") from exc
|
||||
q = q.filter(
|
||||
TopoFabricNode.level.isnot(None),
|
||||
TopoFabricNode.level >= float(maj),
|
||||
TopoFabricNode.level < float(maj) + 1.0,
|
||||
)
|
||||
role_v = str(role or "").strip().lower()
|
||||
if role_v:
|
||||
q = q.filter(TopoFabricNode.role == role_v)
|
||||
if role_v in LEVEL_PRESETS and not level_raw and not maj_raw:
|
||||
# Prefer major-band filter so 1.1 still matches role=core
|
||||
preset_lv = LEVEL_PRESETS[role_v]
|
||||
maj = int(preset_lv)
|
||||
q = q.filter(
|
||||
TopoFabricNode.level.isnot(None),
|
||||
TopoFabricNode.level >= float(maj),
|
||||
TopoFabricNode.level < float(maj) + 1.0,
|
||||
)
|
||||
else:
|
||||
q = q.filter(TopoFabricNode.role == role_v)
|
||||
region_v = str(region_folder_id or "").strip()
|
||||
if region_v:
|
||||
q = q.filter(TopoFabricNode.region_folder_id == region_v)
|
||||
um = str(unmatched or "").strip().lower()
|
||||
if um == "role":
|
||||
q = q.filter(or_(TopoFabricNode.role == "", TopoFabricNode.role == "unknown"))
|
||||
if um in {"role", "level"}:
|
||||
q = q.filter(TopoFabricNode.level.is_(None))
|
||||
elif um == "region":
|
||||
q = q.filter(
|
||||
or_(TopoFabricNode.region_folder_id.is_(None), TopoFabricNode.region_folder_id == "")
|
||||
|
|
@ -302,8 +342,7 @@ def list_fabric_nodes(
|
|||
elif um == "any":
|
||||
q = q.filter(
|
||||
or_(
|
||||
TopoFabricNode.role == "",
|
||||
TopoFabricNode.role == "unknown",
|
||||
TopoFabricNode.level.is_(None),
|
||||
TopoFabricNode.region_folder_id.is_(None),
|
||||
TopoFabricNode.region_folder_id == "",
|
||||
)
|
||||
|
|
|
|||
138
netx_api/topology_level.py
Normal file
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 sort_order INTEGER DEFAULT 0",
|
||||
"ALTER TABLE topo_fabric_node ADD COLUMN IF NOT EXISTS role VARCHAR(32) DEFAULT ''",
|
||||
"ALTER TABLE topo_fabric_node ADD COLUMN IF NOT EXISTS level DOUBLE PRECISION",
|
||||
"ALTER TABLE topo_fabric_node ADD COLUMN IF NOT EXISTS region_folder_id VARCHAR(64)",
|
||||
"ALTER TABLE topo_fabric_node ADD COLUMN IF NOT EXISTS role_source VARCHAR(16) DEFAULT ''",
|
||||
"ALTER TABLE topo_fabric_node ADD COLUMN IF NOT EXISTS region_source VARCHAR(16) DEFAULT ''",
|
||||
"CREATE INDEX IF NOT EXISTS ix_topo_fabric_node_level ON topo_fabric_node (level)",
|
||||
"ALTER TABLE topo_fabric_node ADD COLUMN IF NOT EXISTS world_x DOUBLE PRECISION",
|
||||
"ALTER TABLE topo_fabric_node ADD COLUMN IF NOT EXISTS world_y DOUBLE PRECISION",
|
||||
"CREATE INDEX IF NOT EXISTS ix_topo_fabric_node_world_x ON topo_fabric_node (world_x)",
|
||||
|
|
@ -89,6 +91,7 @@ def ensure_topology_schema(conn: Connection) -> None:
|
|||
"ALTER TABLE topo_view ADD COLUMN role VARCHAR(32) DEFAULT 'core'",
|
||||
"ALTER TABLE topo_view ADD COLUMN sort_order INTEGER DEFAULT 0",
|
||||
"ALTER TABLE topo_fabric_node ADD COLUMN role VARCHAR(32) DEFAULT ''",
|
||||
"ALTER TABLE topo_fabric_node ADD COLUMN level REAL",
|
||||
"ALTER TABLE topo_fabric_node ADD COLUMN region_folder_id VARCHAR(64)",
|
||||
"ALTER TABLE topo_fabric_node ADD COLUMN role_source VARCHAR(16) DEFAULT ''",
|
||||
"ALTER TABLE topo_fabric_node ADD COLUMN region_source VARCHAR(16) DEFAULT ''",
|
||||
|
|
|
|||
|
|
@ -93,8 +93,10 @@ def api_fabric_summary(db: Session = Depends(get_db)) -> dict[str, Any]:
|
|||
def api_fabric_nodes(
|
||||
keyword: str = "",
|
||||
role: str = "",
|
||||
level: str = Query(default="", description="Exact level e.g. 1.1"),
|
||||
level_major: str = Query(default="", description="Major band e.g. 2 → [2.0, 3.0)"),
|
||||
region_folder_id: str = "",
|
||||
unmatched: str = Query(default="", description="any | role | region"),
|
||||
unmatched: str = Query(default="", description="any | level | role | region"),
|
||||
link_status: str = Query(
|
||||
default="",
|
||||
description="linked | orphaned | managed | ume | both",
|
||||
|
|
@ -107,6 +109,8 @@ def api_fabric_nodes(
|
|||
db,
|
||||
keyword=keyword,
|
||||
role=role,
|
||||
level=level,
|
||||
level_major=level_major,
|
||||
region_folder_id=region_folder_id,
|
||||
unmatched=unmatched,
|
||||
link_status=link_status,
|
||||
|
|
@ -255,9 +259,18 @@ def api_fabric_discover_job(
|
|||
job_id: str,
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
status: str = Query(default=""),
|
||||
keyword: str = Query(default=""),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict[str, Any]:
|
||||
return get_discover_job(db, job_id, page=page, page_size=page_size).model_dump()
|
||||
return get_discover_job(
|
||||
db,
|
||||
job_id,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
item_status=status,
|
||||
item_keyword=keyword,
|
||||
).model_dump()
|
||||
|
||||
|
||||
# --- Tree / folders ---------------------------------------------------------
|
||||
|
|
@ -520,7 +533,7 @@ def api_classify_apply(
|
|||
|
||||
@router.get("/classify/unmatched")
|
||||
def api_classify_unmatched(
|
||||
kind: str = Query(default="any", description="any | role | region"),
|
||||
kind: str = Query(default="any", description="any | level | role | region"),
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=50, ge=1, le=500),
|
||||
db: Session = Depends(get_db),
|
||||
|
|
@ -591,6 +604,10 @@ def api_generate_slices(
|
|||
body: SliceGenerateRequest,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict[str, Any]:
|
||||
"""Deprecated for product UI: slice maps freeze custom views and bypass region canvases.
|
||||
|
||||
Kept for tests / legacy clients. Prefer region folders + classify tags + MCP layout.
|
||||
"""
|
||||
return generate_slices(db, body).model_dump()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,9 @@ class FabricNodeOut(BaseModel):
|
|||
ip: str = ""
|
||||
vendor: str = ""
|
||||
device_type: str = ""
|
||||
# Layout rank (major.minor). None = unclassified.
|
||||
level: float | None = None
|
||||
# Synced alias from floor(level): external|core|aggregation|access|edge|""
|
||||
role: str = ""
|
||||
region_folder_id: str | None = None
|
||||
role_source: str = ""
|
||||
|
|
@ -483,7 +486,7 @@ class ClassifyRuleOut(BaseModel):
|
|||
|
||||
|
||||
class ClassifyRuleCreate(BaseModel):
|
||||
scope: str = Field(default="role", description="role | region")
|
||||
scope: str = Field(default="level", description="level | region (role accepted as level alias)")
|
||||
name: str = ""
|
||||
pattern: str
|
||||
match_field: str = "name"
|
||||
|
|
@ -505,26 +508,33 @@ class ClassifyRuleUpdate(BaseModel):
|
|||
|
||||
class ClassifyPreviewOut(BaseModel):
|
||||
total_nodes: int = 0
|
||||
level_matched: int = 0
|
||||
level_unmatched: int = 0
|
||||
level_conflicts: int = 0
|
||||
# Legacy aliases
|
||||
role_matched: int = 0
|
||||
role_unmatched: int = 0
|
||||
role_conflicts: int = 0
|
||||
region_matched: int = 0
|
||||
region_unmatched: int = 0
|
||||
region_conflicts: int = 0
|
||||
level_samples: list[dict[str, Any]] = Field(default_factory=list)
|
||||
role_samples: list[dict[str, Any]] = Field(default_factory=list)
|
||||
region_samples: list[dict[str, Any]] = Field(default_factory=list)
|
||||
unmatched_samples: list[dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ClassifyApplyOut(BaseModel):
|
||||
role_updated: int = 0
|
||||
level_updated: int = 0
|
||||
role_updated: int = 0 # alias of level_updated
|
||||
region_updated: int = 0
|
||||
skipped_manual: int = 0
|
||||
total_nodes: int = 0
|
||||
|
||||
|
||||
class FabricNodeTagPatch(BaseModel):
|
||||
role: str | None = None
|
||||
level: float | None = None
|
||||
role: str | None = None # preset alias → level
|
||||
region_folder_id: str | None = None
|
||||
|
||||
|
||||
|
|
@ -545,12 +555,13 @@ class FabricNodesMatchOut(BaseModel):
|
|||
|
||||
|
||||
class FabricNodesBulkTagRequest(BaseModel):
|
||||
"""Assign role/region to explicit ids or to an ephemeral regex match."""
|
||||
"""Assign level/region to explicit ids or to an ephemeral regex match."""
|
||||
|
||||
fabric_node_ids: list[str] = Field(default_factory=list)
|
||||
pattern: str = ""
|
||||
match_field: str = "name"
|
||||
role: str | None = None
|
||||
level: float | None = None
|
||||
role: str | None = None # preset → level when level omitted
|
||||
region_folder_id: str | None = None
|
||||
dry_run: bool = False
|
||||
|
||||
|
|
@ -559,6 +570,7 @@ class FabricNodesBulkTagOut(BaseModel):
|
|||
dry_run: bool = False
|
||||
matched: int = 0
|
||||
updated: int = 0
|
||||
level: float | None = None
|
||||
role: str | None = None
|
||||
region_folder_id: str | None = None
|
||||
samples: list[dict[str, Any]] = Field(default_factory=list)
|
||||
|
|
|
|||
|
|
@ -467,7 +467,17 @@ def _apply_fabric_filters(
|
|||
)
|
||||
role_v = str(role or "").strip().lower()
|
||||
if role_v:
|
||||
q = q.filter(TopoFabricNode.role == role_v)
|
||||
from .topology_level import LEVEL_PRESETS
|
||||
|
||||
if role_v in LEVEL_PRESETS:
|
||||
maj = int(LEVEL_PRESETS[role_v])
|
||||
q = q.filter(
|
||||
TopoFabricNode.level.isnot(None),
|
||||
TopoFabricNode.level >= float(maj),
|
||||
TopoFabricNode.level < float(maj) + 1.0,
|
||||
)
|
||||
else:
|
||||
q = q.filter(TopoFabricNode.role == role_v)
|
||||
vendor_v = str(vendor or "").strip()
|
||||
if vendor_v:
|
||||
q = q.filter(TopoFabricNode.vendor.ilike(f"%{vendor_v}%"))
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"""UME / long-task runtime helpers shared by API and optional worker process.
|
||||
|
||||
Device collectors (config_sync / LLDP / port_traffic) run via ``start_device_schedulers``
|
||||
Device collectors (config_sync / LLDP / ne_collect / port_traffic) run via ``start_device_schedulers``
|
||||
(API inline by default; set ``NETX_RUN_INLINE_SCHEDULERS=false`` and run
|
||||
``python -m netx_api.worker`` for a split process).
|
||||
API process also owns UME keepalive, alarm WSS, current-alarm/inventory sync loops,
|
||||
|
|
@ -53,11 +53,13 @@ def start_device_schedulers() -> None:
|
|||
from .config_sync_scheduler import start_config_sync_scheduler
|
||||
from .fabric_reconcile_scheduler import start_fabric_reconcile_scheduler
|
||||
from .lldp_collect_scheduler import start_lldp_collect_scheduler
|
||||
from .ne_collect_scheduler import start_ne_collect_scheduler
|
||||
from .port_traffic_scheduler import start_port_traffic_scheduler
|
||||
from .scheduler_heartbeat import start_scheduler_heartbeat_publisher
|
||||
|
||||
start_config_sync_scheduler()
|
||||
start_lldp_collect_scheduler()
|
||||
start_ne_collect_scheduler()
|
||||
start_port_traffic_scheduler()
|
||||
start_fabric_reconcile_scheduler()
|
||||
# Publish status so API /metrics can see collectors when run in a split worker.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
"""netx topology MCP — canvas / fabric tools for drawing topology maps."""
|
||||
|
||||
__version__ = "0.1.20"
|
||||
__version__ = "0.1.51"
|
||||
# Bump when public catalog/actions change so agents know to restart stdio.
|
||||
NETX_MCP_REV = "2026-08-09-fabric-merge"
|
||||
NETX_MCP_REV = "2026-08-12-from-scratch-polish"
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -176,14 +176,24 @@ def compute_edge_clearance(
|
|||
per_node.append(h)
|
||||
|
||||
hits_n = len(hits)
|
||||
# Score by unique nodes hit / n (plan: ~0.05 warn, 0.2 → 0)
|
||||
# Dual signal so hub chords with many segment hits actually hurt:
|
||||
# 1) unique nodes hit / n (0 → 1, ≥20% → 0)
|
||||
# 2) hits / links (≤0.02 → 1, ≥0.20 → 0) — separates sparse vs scrubbed eyes
|
||||
hit_frac = len(nodes_with_hit) / max(n_nodes, 1)
|
||||
hit_per_link = hits_n / max(n_links, 1)
|
||||
if hit_frac <= 0.0:
|
||||
score = 1.0
|
||||
node_s = 1.0
|
||||
elif hit_frac >= 0.2:
|
||||
score = 0.0
|
||||
node_s = 0.0
|
||||
else:
|
||||
score = 1.0 - hit_frac / 0.2
|
||||
node_s = 1.0 - hit_frac / 0.2
|
||||
if hit_per_link <= 0.02:
|
||||
inten_s = 1.0
|
||||
elif hit_per_link >= 0.20:
|
||||
inten_s = 0.0
|
||||
else:
|
||||
inten_s = 1.0 - (hit_per_link - 0.02) / 0.18
|
||||
score = 0.45 * node_s + 0.55 * inten_s
|
||||
|
||||
def _pct(vals: list[float], p: float) -> float | None:
|
||||
if not vals:
|
||||
|
|
@ -197,6 +207,9 @@ def compute_edge_clearance(
|
|||
"nodes_hit": len(nodes_with_hit),
|
||||
"min_clearance_p50": _pct(clearances, 0.5),
|
||||
"edge_clearance_score": round(score, 4),
|
||||
"edge_clearance_node_score": round(node_s, 4),
|
||||
"edge_clearance_intensity_score": round(inten_s, 4),
|
||||
"hit_per_link": round(hit_per_link, 4),
|
||||
"top_edge_hits": per_node[:top_n],
|
||||
"hit_nodes": per_node,
|
||||
"edge_clearance_tip": tip_ok,
|
||||
|
|
@ -764,7 +777,7 @@ def grade_layout(
|
|||
issues.append(f"label_overlaps={label_overlaps}>0")
|
||||
|
||||
util_warn = 0.08
|
||||
util_fail = 0.03
|
||||
util_fail = 0.04
|
||||
util_f = float(util) if util is not None else None
|
||||
util_bad = util_f is not None and util_f < util_fail
|
||||
util_soft = util_f is not None and util_f < util_warn
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
if p.get("side") is not None:
|
||||
out["side"] = float(p["side"])
|
||||
portals = p.get("portal_ids") or p.get("portals") or []
|
||||
if isinstance(portals, str):
|
||||
portals = [portals]
|
||||
frozen = set(str(x) for x in (p.get("frozen_ids") or []) if str(x).strip())
|
||||
frozen |= {str(x) for x in portals if str(x).strip()}
|
||||
out["frozen_ids"] = frozen
|
||||
out["max_eject_degree"] = int(p.get("max_eject_degree") or 5)
|
||||
return out
|
||||
|
||||
|
||||
|
|
@ -228,11 +235,15 @@ def clear_edge_hits(
|
|||
pitch: float | None = None,
|
||||
side: float | None = None,
|
||||
rounds: int = 1,
|
||||
frozen_ids: set[str] | None = None,
|
||||
max_eject_degree: int = 5,
|
||||
) -> OpResult:
|
||||
"""Move nodes off non-incident edges; gate on crossings + overlaps.
|
||||
|
||||
``preserve_axis=True``: snap to pitch/side grid, keep incident H/V count,
|
||||
multi-round until no progress — use after ``ortho_metro``.
|
||||
``frozen_ids``: never eject these (eye portals).
|
||||
``max_eject_degree``: skip hubs at/above this degree (default 5).
|
||||
"""
|
||||
del params
|
||||
st = state.copy()
|
||||
|
|
@ -240,6 +251,8 @@ def clear_edge_hits(
|
|||
names = st.names
|
||||
links = list(st.links)
|
||||
adj = st.adj
|
||||
frozen = {str(x) for x in (frozen_ids or ()) if str(x).strip()}
|
||||
max_eject_degree = max(2, int(max_eject_degree))
|
||||
pitch_f = float(pitch if pitch is not None else REC_CENTER_DX)
|
||||
side_f = float(side if side is not None else REC_CENTER_DY)
|
||||
rounds_n = max(1, int(rounds))
|
||||
|
|
@ -272,8 +285,8 @@ def clear_edge_hits(
|
|||
target = float(thr) + float(margin)
|
||||
x0 = count_edge_crossings(pos, links)
|
||||
ax0 = _global_axis_score(pos, links)
|
||||
# Clearance matters, but keep metro readable — modest crossing slack only.
|
||||
x_slack = 4 if preserve_axis else 0
|
||||
# Never allow crossing rise — even with preserve_axis (metro snap).
|
||||
x_slack = 0
|
||||
deg = {n: len(adj.get(n) or ()) for n in pos}
|
||||
|
||||
def _move_budget(nid: str) -> float:
|
||||
|
|
@ -308,6 +321,8 @@ def clear_edge_hits(
|
|||
nid = str(h["fabric_node_id"])
|
||||
if nid not in pos:
|
||||
continue
|
||||
if nid in frozen:
|
||||
continue
|
||||
a = str(h["a_node_id"])
|
||||
b = str(h["b_node_id"])
|
||||
if a not in pos or b not in pos:
|
||||
|
|
@ -315,8 +330,8 @@ def clear_edge_hits(
|
|||
p0 = pos[nid]
|
||||
if not _on_open_segment(p0, pos[a], pos[b], thr=thr) and float(h.get("dist") or 99) >= thr:
|
||||
continue
|
||||
# Very high-degree hub on a chord: break the chord instead of ejecting hub.
|
||||
if deg.get(nid, 0) >= 5:
|
||||
# High-degree hubs on a chord: skip unless caller raises cap.
|
||||
if deg.get(nid, 0) >= int(max_eject_degree):
|
||||
continue
|
||||
if preserve_axis:
|
||||
nbr_xy = [
|
||||
|
|
|
|||
|
|
@ -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
|
||||
tails), or a long chain between portals. Units may share portals.
|
||||
Beautify targets zero edge crossings: multi-corridor → parallel H/V lanes;
|
||||
chains (any length) → straight; tails as straight spurs. No 回字 fold.
|
||||
Detection walks eyes **top→down** (core–core → core–agg → agg–agg) and
|
||||
greedily maximizes membership.
|
||||
|
||||
**Objective**: minimize crossings while keeping spread; **keep the eye
|
||||
interior hollow** (portals only on the chord; AN/corridors on arcs; avoid
|
||||
mid stacking / overlaps). Residual mesh chords OK; do not polish-fix the eye.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -102,12 +106,136 @@ def _collect_tails(
|
|||
return tails
|
||||
|
||||
|
||||
def _unit_from_hub_paths(
|
||||
state: LayoutState,
|
||||
a: str,
|
||||
b: str,
|
||||
paths: list[list[str]],
|
||||
) -> DualUnit:
|
||||
names = state.names
|
||||
pa, pb = a, b
|
||||
out_paths = [list(p) for p in paths]
|
||||
if names.get(pa, pa) > names.get(pb, pb):
|
||||
pa, pb = pb, pa
|
||||
out_paths = [list(reversed(p)) for p in out_paths]
|
||||
core = {pa, pb}
|
||||
for p in out_paths:
|
||||
core.update(p)
|
||||
return DualUnit(
|
||||
portal_a=pa,
|
||||
portal_b=pb,
|
||||
paths=out_paths,
|
||||
tails=_collect_tails(state, core),
|
||||
)
|
||||
|
||||
|
||||
def _interior_of(unit: DualUnit) -> set[str]:
|
||||
interior: set[str] = set()
|
||||
for p in unit.paths:
|
||||
interior |= set(p[1:-1])
|
||||
return interior
|
||||
|
||||
|
||||
def _greedy_take_by_size(
|
||||
candidates: list[DualUnit],
|
||||
*,
|
||||
used_interior: set[str],
|
||||
max_units: int,
|
||||
already: list[DualUnit],
|
||||
) -> None:
|
||||
"""Claim largest-membership units first (CN eyes should swallow most NEs)."""
|
||||
ranked = sorted(candidates, key=lambda u: (-len(u.member_ids()), u.portal_a, u.portal_b))
|
||||
for u in ranked:
|
||||
if len(already) >= max_units:
|
||||
return
|
||||
interior = _interior_of(u)
|
||||
if not interior or interior & used_interior:
|
||||
continue
|
||||
if any({x.portal_a, x.portal_b} == {u.portal_a, u.portal_b} for x in already):
|
||||
continue
|
||||
used_interior |= interior
|
||||
already.append(u)
|
||||
|
||||
|
||||
def _portal_eye_tier(layer: str | None) -> int:
|
||||
"""Lower = higher in fabric (eyes walk top→down)."""
|
||||
ly = (layer or "").strip().lower()
|
||||
if ly == "core":
|
||||
return 0
|
||||
if ly == "agg":
|
||||
return 1
|
||||
if ly == "access":
|
||||
return 2
|
||||
return 3
|
||||
|
||||
|
||||
def _eye_portal_key(
|
||||
portal_a: str,
|
||||
portal_b: str,
|
||||
layers: dict[str, str],
|
||||
) -> tuple[int, int]:
|
||||
"""Sorted portal tiers: (0,0)=core–core, (0,1)=core–agg, (1,1)=agg–agg…"""
|
||||
t = sorted(
|
||||
(
|
||||
_portal_eye_tier(layers.get(portal_a)),
|
||||
_portal_eye_tier(layers.get(portal_b)),
|
||||
)
|
||||
)
|
||||
return (t[0], t[1])
|
||||
|
||||
|
||||
def _hub_pair_candidates(
|
||||
state: LayoutState,
|
||||
hubs_a: list[str],
|
||||
hubs_b: list[str],
|
||||
*,
|
||||
core_set: set[str],
|
||||
allow_same: bool,
|
||||
) -> list[DualUnit]:
|
||||
"""Build dual units for hub pairs; when allow_same, iterate i<j on hubs_a."""
|
||||
adj, names = state.adj, state.names
|
||||
out: list[DualUnit] = []
|
||||
if allow_same:
|
||||
for i, a in enumerate(hubs_a):
|
||||
for b in hubs_a[i + 1 :]:
|
||||
forbid = core_set - {a, b}
|
||||
paths = cover_hub_paths(a, b, adj, names, forbid=forbid)
|
||||
if len(paths) < 2:
|
||||
paths = cover_hub_paths(a, b, adj, names, forbid=set())
|
||||
if len(paths) < 2:
|
||||
continue
|
||||
out.append(_unit_from_hub_paths(state, a, b, paths))
|
||||
return out
|
||||
seen: set[frozenset[str]] = set()
|
||||
for a in hubs_a:
|
||||
for b in hubs_b:
|
||||
if a == b:
|
||||
continue
|
||||
key = frozenset((a, b))
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
forbid = core_set - {a, b}
|
||||
paths = cover_hub_paths(a, b, adj, names, forbid=forbid)
|
||||
if len(paths) < 2:
|
||||
paths = cover_hub_paths(a, b, adj, names, forbid=set())
|
||||
if len(paths) < 2:
|
||||
continue
|
||||
out.append(_unit_from_hub_paths(state, a, b, paths))
|
||||
return out
|
||||
|
||||
|
||||
def find_dual_portal_units(
|
||||
state: LayoutState,
|
||||
*,
|
||||
max_units: int = 120,
|
||||
) -> list[DualUnit]:
|
||||
"""Detect dual-portal eye units; interiors exclusive, portals may overlap."""
|
||||
"""Detect dual-portal eye units; interiors exclusive, portals may overlap.
|
||||
|
||||
Eyes walk **top→down**: core–core → core–agg → agg–agg, each tier
|
||||
greedily maximizing membership; access rings fill leftovers.
|
||||
Crossings are not a detection gate.
|
||||
"""
|
||||
adj, names, layers = state.adj, state.names, state.layers
|
||||
ens = [n for n, ly in layers.items() if ly == "access" and n in adj]
|
||||
an_set = {n for n, ly in layers.items() if ly == "agg"}
|
||||
|
|
@ -116,68 +244,58 @@ def find_dual_portal_units(
|
|||
units: list[DualUnit] = []
|
||||
used_interior: set[str] = set()
|
||||
|
||||
# 1) Access/AN two-portal ring groups (sugiyama metro).
|
||||
if ens:
|
||||
groups = _find_two_portal_ring_groups(ens, adj, names, an_set)
|
||||
for g in groups:
|
||||
a, b = g["portals"] # type: ignore[misc]
|
||||
paths: list[list[str]] = list(g["paths"]) # type: ignore[arg-type]
|
||||
interior: set[str] = set()
|
||||
for p in paths:
|
||||
interior |= set(p[1:-1])
|
||||
if interior & used_interior:
|
||||
continue
|
||||
used_interior |= interior
|
||||
core = {a, b} | interior
|
||||
for p in paths:
|
||||
core.update(p)
|
||||
tails = _collect_tails(state, core)
|
||||
units.append(
|
||||
DualUnit(portal_a=a, portal_b=b, paths=paths, tails=tails)
|
||||
)
|
||||
if len(units) >= max_units:
|
||||
break
|
||||
|
||||
# 2) Agg/core hub pairs with ≥2 corridor covers (fills CN—AN / CN—CN).
|
||||
hubs = sorted(
|
||||
[n for n in (an_set | core_set) if n in adj],
|
||||
cores = sorted(
|
||||
[n for n in core_set if n in adj],
|
||||
key=lambda n: (-len(adj.get(n, ())), names.get(n, n)),
|
||||
)
|
||||
for i, a in enumerate(hubs):
|
||||
if len(units) >= max_units:
|
||||
break
|
||||
for b in hubs[i + 1 :]:
|
||||
if len(units) >= max_units:
|
||||
break
|
||||
# Only forbid other cores — when almost all NEs are layer=agg,
|
||||
# banning every agg hub makes cover_hub_paths return 0 corridors.
|
||||
forbid = core_set - {a, b}
|
||||
paths = cover_hub_paths(a, b, adj, names, forbid=forbid)
|
||||
if len(paths) < 2:
|
||||
aggs = sorted(
|
||||
[n for n in an_set if n in adj],
|
||||
key=lambda n: (-len(adj.get(n, ())), names.get(n, n)),
|
||||
)
|
||||
|
||||
# 1) Core–core
|
||||
_greedy_take_by_size(
|
||||
_hub_pair_candidates(
|
||||
state, cores, cores, core_set=core_set, allow_same=True
|
||||
),
|
||||
used_interior=used_interior,
|
||||
max_units=max_units,
|
||||
already=units,
|
||||
)
|
||||
# 2) Core–agg
|
||||
if len(units) < max_units and cores and aggs:
|
||||
_greedy_take_by_size(
|
||||
_hub_pair_candidates(
|
||||
state, cores, aggs, core_set=core_set, allow_same=False
|
||||
),
|
||||
used_interior=used_interior,
|
||||
max_units=max_units,
|
||||
already=units,
|
||||
)
|
||||
# 3) Agg–agg
|
||||
if len(units) < max_units and len(aggs) >= 2:
|
||||
_greedy_take_by_size(
|
||||
_hub_pair_candidates(
|
||||
state, aggs, aggs, core_set=core_set, allow_same=True
|
||||
),
|
||||
used_interior=used_interior,
|
||||
max_units=max_units,
|
||||
already=units,
|
||||
)
|
||||
|
||||
# 4) Access/AN metro rings (bottom leftovers).
|
||||
if ens and len(units) < max_units:
|
||||
groups = _find_two_portal_ring_groups(ens, adj, names, an_set)
|
||||
an_cands: list[DualUnit] = []
|
||||
for g in groups:
|
||||
a, b = g["portals"] # type: ignore[misc]
|
||||
paths = list(g["paths"]) # type: ignore[arg-type]
|
||||
if any({u.portal_a, u.portal_b} == {a, b} for u in units):
|
||||
continue
|
||||
interior = set()
|
||||
for p in paths:
|
||||
interior |= set(p[1:-1])
|
||||
if not interior or interior & used_interior:
|
||||
continue
|
||||
# Skip if this pair already covered as a unit
|
||||
if any(
|
||||
{u.portal_a, u.portal_b} == {a, b} for u in units
|
||||
):
|
||||
continue
|
||||
used_interior |= interior
|
||||
core = {a, b} | interior
|
||||
for p in paths:
|
||||
core.update(p)
|
||||
tails = _collect_tails(state, core)
|
||||
# Stable left/right by name
|
||||
pa, pb = a, b
|
||||
if names.get(pa, pa) > names.get(pb, pb):
|
||||
pa, pb = pb, pa
|
||||
paths = [list(reversed(p)) for p in paths]
|
||||
units.append(
|
||||
DualUnit(portal_a=pa, portal_b=pb, paths=paths, tails=tails)
|
||||
)
|
||||
an_cands.append(_unit_from_hub_paths(state, a, b, paths))
|
||||
_greedy_take_by_size(
|
||||
an_cands, used_interior=used_interior, max_units=max_units, already=units
|
||||
)
|
||||
|
||||
for i, u in enumerate(units):
|
||||
u.unit_id = i
|
||||
|
|
@ -199,6 +317,71 @@ def _normalize_paths(
|
|||
return paths
|
||||
|
||||
|
||||
def _path_first_hop(path: list[str]) -> str:
|
||||
return path[1] if len(path) > 2 else ""
|
||||
|
||||
|
||||
def _path_last_hop(path: list[str]) -> str:
|
||||
return path[-2] if len(path) > 2 else ""
|
||||
|
||||
|
||||
def _order_paths_for_nest(
|
||||
paths: list[list[str]],
|
||||
names: dict[str, str],
|
||||
) -> list[list[str]]:
|
||||
"""Order corridors to cut spine crossings while nesting short→inner.
|
||||
|
||||
Barycenter-align first/last hops across portals, then split ±y so
|
||||
same-side bands stay nested by length (distribution kept).
|
||||
"""
|
||||
if len(paths) <= 1:
|
||||
return list(paths)
|
||||
|
||||
def hop_key(nid: str) -> str:
|
||||
return names.get(nid, nid)
|
||||
|
||||
order = sorted(
|
||||
paths,
|
||||
key=lambda p: (
|
||||
hop_key(_path_first_hop(p)),
|
||||
len(p),
|
||||
hop_key(_path_last_hop(p)),
|
||||
),
|
||||
)
|
||||
for _ in range(5):
|
||||
idx = {id(p): i for i, p in enumerate(order)}
|
||||
a_pos: dict[str, list[float]] = {}
|
||||
b_pos: dict[str, list[float]] = {}
|
||||
for p in order:
|
||||
a_pos.setdefault(_path_first_hop(p), []).append(float(idx[id(p)]))
|
||||
b_pos.setdefault(_path_last_hop(p), []).append(float(idx[id(p)]))
|
||||
a_rank = {s: sum(vs) / len(vs) for s, vs in a_pos.items()}
|
||||
b_rank = {s: sum(vs) / len(vs) for s, vs in b_pos.items()}
|
||||
order = sorted(
|
||||
order,
|
||||
key=lambda p: (
|
||||
0.55 * a_rank.get(_path_first_hop(p), 0.0)
|
||||
+ 0.45 * b_rank.get(_path_last_hop(p), 0.0),
|
||||
len(p),
|
||||
hop_key(_path_first_hop(p)),
|
||||
),
|
||||
)
|
||||
|
||||
upper: list[list[str]] = []
|
||||
lower: list[list[str]] = []
|
||||
for i, p in enumerate(order):
|
||||
(upper if i % 2 == 0 else lower).append(p)
|
||||
upper.sort(key=len)
|
||||
lower.sort(key=len)
|
||||
out: list[list[str]] = []
|
||||
for i in range(max(len(upper), len(lower))):
|
||||
if i < len(upper):
|
||||
out.append(upper[i])
|
||||
if i < len(lower):
|
||||
out.append(lower[i])
|
||||
return out
|
||||
|
||||
|
||||
def classify_dual_unit(unit: DualUnit) -> str:
|
||||
"""petal = multi-corridor (parallel lanes); else straight (no 回字)."""
|
||||
paths = _normalize_paths(unit)
|
||||
|
|
@ -226,49 +409,169 @@ def _place_chain_straight(
|
|||
pos[nid] = (ox + ux * pitch * (i + 1), oy + uy * pitch * (i + 1))
|
||||
|
||||
|
||||
def _place_petal_bands(
|
||||
paths: list[list[str]],
|
||||
*,
|
||||
a: str,
|
||||
b: str,
|
||||
rx: float,
|
||||
ry_step: float,
|
||||
chord_gap: float = 160.0,
|
||||
) -> dict[str, tuple[float, float]]:
|
||||
"""Nested half-ellipse bands; chord = portals only (hollow eye interior).
|
||||
|
||||
Short corridors inner, longer outer. Shared nodes claimed by first path.
|
||||
Soft apex offset so single-mid corridors do not stack on the mid vertical.
|
||||
``chord_gap`` kept for call-site compat (unused).
|
||||
"""
|
||||
del chord_gap
|
||||
pos: dict[str, tuple[float, float]] = {a: (-rx, 0.0), b: (rx, 0.0)}
|
||||
|
||||
band_i = 0
|
||||
for p in paths:
|
||||
mid = p[1:-1]
|
||||
if not mid:
|
||||
continue
|
||||
side = 1 if band_i % 2 == 0 else -1
|
||||
nest = band_i // 2
|
||||
ry = ry_step * (nest + 1)
|
||||
band_i += 1
|
||||
m = len(p)
|
||||
|
||||
# One mid (CN–AN–CN): park on left/right lobe — keep eye interior open.
|
||||
if len(mid) == 1:
|
||||
n = mid[0]
|
||||
if n not in pos:
|
||||
lobe = 1.0 if nest % 2 == 0 else -1.0
|
||||
ang = side * (math.pi / 2.0 + lobe * 0.45)
|
||||
pos[n] = (rx * math.cos(ang), ry * math.sin(ang))
|
||||
continue
|
||||
|
||||
for j, n in enumerate(p):
|
||||
if n in (a, b):
|
||||
continue
|
||||
if n in pos:
|
||||
continue
|
||||
t = j / (m - 1) if m > 1 else 0.5
|
||||
ang = math.pi * (1.0 - t)
|
||||
if side < 0:
|
||||
ang = -ang
|
||||
x = rx * math.cos(ang)
|
||||
y = ry * math.sin(ang)
|
||||
# Soft hollow: do not sit on the exact mid vertical (visual spine).
|
||||
if abs(x) < rx * 0.08:
|
||||
x = math.copysign(rx * 0.08, x if abs(x) > 1e-9 else float(side))
|
||||
# Keep roughly on the ellipse by scaling y down slightly.
|
||||
y = y * 0.98
|
||||
pos[n] = (x, y)
|
||||
return pos
|
||||
|
||||
|
||||
def _unit_member_links(
|
||||
paths: list[list[str]],
|
||||
a: str,
|
||||
b: str,
|
||||
state: LayoutState,
|
||||
) -> list[tuple[str, str]]:
|
||||
members = {a, b}
|
||||
for p in paths:
|
||||
members.update(p)
|
||||
return [e for e in state.links if e[0] in members and e[1] in members]
|
||||
|
||||
|
||||
def _best_path_order_for_crossings(
|
||||
paths: list[list[str]],
|
||||
*,
|
||||
a: str,
|
||||
b: str,
|
||||
rx: float,
|
||||
ry_step: float,
|
||||
state: LayoutState,
|
||||
names: dict[str, str],
|
||||
chord_gap: float = 160.0,
|
||||
) -> list[list[str]]:
|
||||
"""Pick nest order: barycenter seed + adjacent same-side swaps to cut x."""
|
||||
base = _order_paths_for_nest(paths, names)
|
||||
if len(base) <= 2:
|
||||
return base
|
||||
|
||||
links = _unit_member_links(base, a, b, state)
|
||||
|
||||
def score(order: list[list[str]]) -> int:
|
||||
pos = _place_petal_bands(
|
||||
order, a=a, b=b, rx=rx, ry_step=ry_step, chord_gap=chord_gap
|
||||
)
|
||||
return count_edge_crossings(pos, links)
|
||||
|
||||
best = list(base)
|
||||
best_x = score(best)
|
||||
# Adjacent swaps within the interleaved list (preserves ±y nesting pattern).
|
||||
improved = True
|
||||
rounds = 0
|
||||
while improved and rounds < 24:
|
||||
improved = False
|
||||
rounds += 1
|
||||
for i in range(len(best) - 1):
|
||||
trial = list(best)
|
||||
trial[i], trial[i + 1] = trial[i + 1], trial[i]
|
||||
x = score(trial)
|
||||
if x < best_x:
|
||||
best, best_x = trial, x
|
||||
improved = True
|
||||
break
|
||||
return best
|
||||
|
||||
|
||||
def beautify_dual_unit_positions(
|
||||
state: LayoutState,
|
||||
unit: DualUnit,
|
||||
params: LayoutParams | None = None,
|
||||
) -> dict[str, tuple[float, float]]:
|
||||
"""Beautify one unit: multi-corridor→H/V lanes; chains→straight (no 回字).
|
||||
"""Beautify one unit: petal→nested ellipse arcs; chains→outward fans.
|
||||
|
||||
Local coords; portals on x-axis. dual_mass aligns onto world portals.
|
||||
Multi-corridor (kind=petal): parallel horizontal lanes with vertical
|
||||
stubs at portal x — no ellipse arcs.
|
||||
Goal: fewer crossings, readable spread, **hollow eye interior**, low
|
||||
overlap. Portals on x-axis only; corridors on ± ellipse bands; **long
|
||||
tails park outside the eye** (do not pierce nested rings).
|
||||
"""
|
||||
params = params or LayoutParams()
|
||||
pitch = max(float(params.pitch), 170.0)
|
||||
ry = max(float(params.lane), float(params.side), 220.0)
|
||||
a, b = unit.portal_a, unit.portal_b
|
||||
paths = _normalize_paths(unit)
|
||||
kind = classify_dual_unit(unit)
|
||||
max_mid = max((len(p) - 2 for p in paths), default=0)
|
||||
half = max(pitch * (max_mid + 1) * 0.5, pitch * 4.0, 700.0)
|
||||
pos: dict[str, tuple[float, float]] = {a: (-half, 0.0), b: (half, 0.0)}
|
||||
|
||||
if kind == "petal":
|
||||
# Parallel H/V lanes: first/last mid share portal x → V stub + H spine.
|
||||
band_i = 0
|
||||
for p in paths:
|
||||
mid = p[1:-1]
|
||||
if not mid:
|
||||
continue
|
||||
side = 1 if band_i % 2 == 0 else -1
|
||||
amp = ry * (0.85 + 0.35 * (band_i // 2))
|
||||
band_i += 1
|
||||
n_mid = len(mid)
|
||||
for k, n in enumerate(mid):
|
||||
if n in pos:
|
||||
continue
|
||||
if n_mid == 1:
|
||||
pos[n] = (0.0, side * amp)
|
||||
else:
|
||||
t = k / (n_mid - 1)
|
||||
x = -half + 2.0 * half * t
|
||||
pos[n] = (x, side * amp)
|
||||
max_mid = max((len(p) - 2 for p in paths), default=0)
|
||||
rx = max(
|
||||
float(params.an_gap) * 2.5,
|
||||
pitch * max(7.0, float(max_mid) + 2.0),
|
||||
1100.0,
|
||||
)
|
||||
ry_step = max(float(params.side) * 1.8, float(params.lane) * 1.15, pitch * 1.15, 320.0)
|
||||
ordered = _best_path_order_for_crossings(
|
||||
paths,
|
||||
a=a,
|
||||
b=b,
|
||||
rx=rx,
|
||||
ry_step=ry_step,
|
||||
state=state,
|
||||
names=state.names,
|
||||
chord_gap=max(pitch * 0.85, 150.0),
|
||||
)
|
||||
pos = _place_petal_bands(
|
||||
ordered,
|
||||
a=a,
|
||||
b=b,
|
||||
rx=rx,
|
||||
ry_step=ry_step,
|
||||
chord_gap=max(pitch * 0.85, 150.0),
|
||||
)
|
||||
half = rx
|
||||
band_n = sum(1 for p in ordered if p[1:-1])
|
||||
ry_park = ry_step * max(1, (band_n + 1) // 2 + 1)
|
||||
else:
|
||||
# Single corridor / chain body — always straight between portals.
|
||||
max_mid = max((len(p) - 2 for p in paths), default=0)
|
||||
half = max(pitch * (max_mid + 1) * 0.5, pitch * 4.0, 700.0)
|
||||
pos = {a: (-half, 0.0), b: (half, 0.0)}
|
||||
body: list[str] = []
|
||||
if paths:
|
||||
body = list(paths[0][1:-1])
|
||||
|
|
@ -283,13 +586,54 @@ def beautify_dual_unit_positions(
|
|||
pitch=max(pitch, (2.0 * half) / (len(body) + 1)),
|
||||
pos=pos,
|
||||
)
|
||||
ry_park = max(float(params.lane), float(params.side), 220.0)
|
||||
|
||||
# Tails: always straight H/V spurs (stack parallel if many).
|
||||
# Eye envelope from corridor/portal placement (tails must stay outside).
|
||||
eye_rx = max((abs(xy[0]) for xy in pos.values()), default=half)
|
||||
eye_ry = max((abs(xy[1]) for xy in pos.values()), default=ry_park)
|
||||
eye_rx = max(eye_rx, half)
|
||||
eye_ry = max(eye_ry, ry_park * 0.5, pitch * 2.0)
|
||||
|
||||
def _outside_start(
|
||||
ax: float, ay: float, *, fan_i: int
|
||||
) -> tuple[float, float, float, float]:
|
||||
"""Return (x0,y0, ux,uy) for the first tail node — fully outside the eye."""
|
||||
# Near mid vertical / apex: park on outer top/bottom shelf, grow sideways.
|
||||
if abs(ax) < eye_rx * 0.35:
|
||||
side_y = 1.0 if ay >= 0 else -1.0
|
||||
if abs(ay) < 1e-6:
|
||||
side_y = 1.0 if fan_i % 2 == 0 else -1.0
|
||||
y0 = side_y * (eye_ry + pitch * 1.25)
|
||||
dir_x = 1.0 if ax >= 0 else -1.0
|
||||
if abs(ax) < 1e-6:
|
||||
dir_x = 1.0 if fan_i % 2 == 0 else -1.0
|
||||
# Stagger parallel shelves for many apex tails.
|
||||
y0 += side_y * ((fan_i // 2) * pitch * 0.55)
|
||||
x0 = ax + dir_x * pitch * 0.6
|
||||
return x0, y0, dir_x, 0.0
|
||||
|
||||
# Otherwise: jump outside along outward ray, then continue outward.
|
||||
ang = math.atan2(ay, ax)
|
||||
lat = ((fan_i + 1) // 2) * (1 if fan_i % 2 else -1)
|
||||
ang += lat * 0.18
|
||||
ux, uy = math.cos(ang), math.sin(ang)
|
||||
# Ellipse-ish clearance along this ray.
|
||||
ca, sa = abs(ux), abs(uy)
|
||||
r_hit = eye_rx * eye_ry / max(1e-6, math.hypot(eye_ry * ca, eye_rx * sa))
|
||||
r0 = max(math.hypot(ax, ay), r_hit) + pitch * 1.1
|
||||
return ux * r0, uy * r0, ux, uy
|
||||
|
||||
# Tails: long chains park fully outside the eye (do not pierce rings).
|
||||
used = set(pos)
|
||||
for ti, chain in enumerate(unit.tails):
|
||||
attach_fan: dict[str, int] = {}
|
||||
# Longer first so they claim outer shelves.
|
||||
tail_order = sorted(
|
||||
enumerate(unit.tails),
|
||||
key=lambda it: (-len(it[1] or []), it[0]),
|
||||
)
|
||||
for ti, chain in tail_order:
|
||||
if not chain:
|
||||
continue
|
||||
# Skip if already placed as body.
|
||||
fresh = [n for n in chain if n not in used]
|
||||
if not fresh:
|
||||
continue
|
||||
|
|
@ -304,25 +648,92 @@ def beautify_dual_unit_positions(
|
|||
if attach is None:
|
||||
attach = a if ti % 2 == 0 else b
|
||||
ax, ay = pos[attach]
|
||||
y_off = (ti % 3 - 1) * pitch * 0.45
|
||||
fan_i = attach_fan.get(attach, 0)
|
||||
attach_fan[attach] = fan_i + 1
|
||||
|
||||
long_chain = len(fresh) >= 3
|
||||
if attach == a:
|
||||
origin = (ax, ay + y_off)
|
||||
direc = (-1.0, 0.0)
|
||||
stub_ux, stub_uy = -1.0, 0.35 if fan_i % 2 == 0 else -0.35
|
||||
elif attach == b:
|
||||
origin = (ax, ay + y_off)
|
||||
direc = (1.0, 0.0)
|
||||
stub_ux, stub_uy = 1.0, 0.35 if fan_i % 2 == 0 else -0.35
|
||||
else:
|
||||
origin = (ax, ay)
|
||||
direc = (0.0, 1.0 if ay >= 0 else -1.0)
|
||||
_place_chain_straight(
|
||||
fresh, origin=origin, direction=direc, pitch=pitch * 0.85, pos=pos
|
||||
)
|
||||
stub_ux, stub_uy = ax, ay
|
||||
if abs(stub_ux) + abs(stub_uy) < 1e-6:
|
||||
stub_ux, stub_uy = (0.0, 1.0 if ti % 2 == 0 else -1.0)
|
||||
|
||||
if long_chain or kind == "petal":
|
||||
# Petal eye: keep all tail nodes outside envelope (even short ones
|
||||
# if they would otherwise climb through bands).
|
||||
x0, y0, ux, uy = _outside_start(ax, ay, fan_i=fan_i)
|
||||
step = pitch * (1.0 if long_chain else 0.9)
|
||||
px, py = -uy, ux
|
||||
for i, n in enumerate(fresh):
|
||||
if n in pos:
|
||||
continue
|
||||
x = x0 + ux * step * i
|
||||
y = y0 + uy * step * i
|
||||
for _ in range(12):
|
||||
# Must stay outside eye box and not stack.
|
||||
outside = abs(x) >= eye_rx * 0.92 or abs(y) >= eye_ry * 0.92
|
||||
hit = any(
|
||||
abs(x - ox) < pitch * 0.4 and abs(y - oy) < pitch * 0.4
|
||||
for ox, oy in pos.values()
|
||||
)
|
||||
if outside and not hit:
|
||||
break
|
||||
if not outside:
|
||||
# Push further out along ray / shelf.
|
||||
x += ux * pitch * 0.5
|
||||
y += uy * pitch * 0.5
|
||||
if abs(ux) < 0.2 and abs(uy) > 0.8:
|
||||
# shelf mode: grow sideways
|
||||
x += (1.0 if ax >= 0 else -1.0) * pitch * 0.5
|
||||
else:
|
||||
x += px * pitch * 0.45
|
||||
y += py * pitch * 0.45
|
||||
pos[n] = (x, y)
|
||||
else:
|
||||
bn = math.hypot(stub_ux, stub_uy) or 1.0
|
||||
ux, uy = stub_ux / bn, stub_uy / bn
|
||||
px, py = -uy, ux
|
||||
lat = ((fan_i + 1) // 2) * (1 if fan_i % 2 else -1)
|
||||
ang = lat * 0.22
|
||||
ux2 = ux * math.cos(ang) + px * math.sin(ang)
|
||||
uy2 = uy * math.cos(ang) + py * math.sin(ang)
|
||||
nrm = math.hypot(ux2, uy2) or 1.0
|
||||
ux2, uy2 = ux2 / nrm, uy2 / nrm
|
||||
step = pitch * 0.95
|
||||
for i, n in enumerate(fresh):
|
||||
if n in pos:
|
||||
continue
|
||||
x = ax + ux2 * step * (i + 1)
|
||||
y = ay + uy2 * step * (i + 1)
|
||||
for _ in range(10):
|
||||
hit = any(
|
||||
abs(x - ox) < pitch * 0.4 and abs(y - oy) < pitch * 0.4
|
||||
for ox, oy in pos.values()
|
||||
)
|
||||
if not hit:
|
||||
break
|
||||
x += px * pitch * 0.45
|
||||
y += py * pitch * 0.45
|
||||
pos[n] = (x, y)
|
||||
used |= set(pos)
|
||||
|
||||
leftovers = [n for n in unit.member_ids() if n not in pos]
|
||||
top = max((xy[1] for xy in pos.values()), default=0.0) + ry
|
||||
for i, n in enumerate(sorted(leftovers, key=lambda x: state.names.get(x, x))):
|
||||
pos[n] = (-half + i * pitch, top)
|
||||
# Outer ellipse parking — keep off portal chord / hollow mid.
|
||||
n_left = len(leftovers)
|
||||
if n_left:
|
||||
sorted_left = sorted(leftovers, key=lambda x: state.names.get(x, x))
|
||||
for i, n in enumerate(sorted_left):
|
||||
t = (i + 0.5) / n_left
|
||||
ang = math.pi * (0.12 + 0.76 * t)
|
||||
if i % 2:
|
||||
ang = -ang
|
||||
pos[n] = (
|
||||
eye_rx * 1.05 * math.cos(ang),
|
||||
max(eye_ry, ry_park) * 1.1 * math.sin(ang),
|
||||
)
|
||||
|
||||
return pos
|
||||
|
||||
|
|
@ -332,7 +743,7 @@ def layout_dual_unit_positions(
|
|||
unit: DualUnit,
|
||||
params: LayoutParams | None = None,
|
||||
) -> dict[str, tuple[float, float]]:
|
||||
"""Unit local layout — lanes / 回 / straight beautify (zero-cross target)."""
|
||||
"""Unit local layout — ellipse petal / straight beautify."""
|
||||
return beautify_dual_unit_positions(state, unit, params)
|
||||
|
||||
|
||||
|
|
@ -342,8 +753,9 @@ def _uncross_unit(
|
|||
pinned: set[str],
|
||||
*,
|
||||
max_rounds: int = 80,
|
||||
preserve_side: bool = True,
|
||||
) -> dict[str, tuple[float, float]]:
|
||||
"""Greedy: move lower-degree free endpoint vertically to kill crossings."""
|
||||
"""Greedy: nudge free endpoints to kill crossings without flipping eye sides."""
|
||||
from netx_topology_mcp.layout_metrics import segments_properly_intersect
|
||||
|
||||
out = dict(pos)
|
||||
|
|
@ -360,9 +772,9 @@ def _uncross_unit(
|
|||
bad: list[tuple[int, int]] = []
|
||||
for i in range(len(segs)):
|
||||
for j in range(i + 1, len(segs)):
|
||||
a, b, pa, pb = segs[i]
|
||||
aa, bb, pa, pb = segs[i]
|
||||
c, d, pc, pd = segs[j]
|
||||
if len({a, b, c, d}) < 4:
|
||||
if len({aa, bb, c, d}) < 4:
|
||||
continue
|
||||
if segments_properly_intersect(pa, pb, pc, pd):
|
||||
bad.append((i, j))
|
||||
|
|
@ -397,6 +809,8 @@ def _uncross_unit(
|
|||
480.0,
|
||||
-480.0,
|
||||
):
|
||||
if preserve_side and abs(y) > 1e-6 and (y + dy) * y < 0:
|
||||
continue # do not flip across the portal chord
|
||||
for dx in (0.0, 40.0, -40.0, 80.0, -80.0):
|
||||
trial = dict(out)
|
||||
trial[n] = (x + dx, y + dy)
|
||||
|
|
@ -412,7 +826,6 @@ def _uncross_unit(
|
|||
break
|
||||
return out
|
||||
|
||||
|
||||
def layout_dual_unit(
|
||||
state: LayoutState,
|
||||
params: LayoutParams | None = None,
|
||||
|
|
@ -421,8 +834,14 @@ def layout_dual_unit(
|
|||
unit_id: int | None = None,
|
||||
portal_a: str | None = None,
|
||||
portal_b: str | None = None,
|
||||
require_zero_cross: bool = False,
|
||||
) -> OpResult:
|
||||
"""Layout the (single) dual-portal unit on this canvas; require crossings=0."""
|
||||
"""Layout the dual-portal unit; default accepts residual crossings.
|
||||
|
||||
Placement objective: **minimize unit crossings while keeping spread**
|
||||
(nested ellipse bands). Selection still prefers top eyes + max
|
||||
membership. `require_zero_cross=True` restores the old hard gate.
|
||||
"""
|
||||
params = params or LayoutParams()
|
||||
units = find_dual_portal_units(state) if unit is None else [unit]
|
||||
if unit_id is not None:
|
||||
|
|
@ -452,32 +871,30 @@ def layout_dual_unit(
|
|||
portal_a, portal_b, state.adj, state.names, forbid=set()
|
||||
)
|
||||
if len(paths) >= 2:
|
||||
pa, pb = portal_a, portal_b
|
||||
if state.names.get(pa, pa) > state.names.get(pb, pb):
|
||||
pa, pb = pb, pa
|
||||
paths = [list(reversed(p)) for p in paths]
|
||||
core = {pa, pb}
|
||||
for p in paths:
|
||||
core.update(p)
|
||||
units = [
|
||||
DualUnit(
|
||||
portal_a=pa,
|
||||
portal_b=pb,
|
||||
paths=paths,
|
||||
tails=_collect_tails(state, core),
|
||||
unit_id=0,
|
||||
)
|
||||
]
|
||||
units = [_unit_from_hub_paths(state, portal_a, portal_b, paths)]
|
||||
units[0].unit_id = 0
|
||||
if not units:
|
||||
# Whole canvas as one unit attempt: pick best hub pair cover
|
||||
# Whole canvas as one unit: prefer core–core, else densest hub pair by cover.
|
||||
core_hubs = [
|
||||
n
|
||||
for n, ly in state.layers.items()
|
||||
if ly == "core" and n in state.adj
|
||||
]
|
||||
hubs = [
|
||||
n
|
||||
for n, ly in state.layers.items()
|
||||
if ly in ("agg", "core") and n in state.adj
|
||||
]
|
||||
hubs.sort(key=lambda n: (-len(state.adj.get(n, ())), state.names.get(n, n)))
|
||||
if len(hubs) >= 2:
|
||||
a, b = hubs[0], hubs[1]
|
||||
core_hubs.sort(key=lambda n: (-len(state.adj.get(n, ())), state.names.get(n, n)))
|
||||
pair_order: list[tuple[str, str]] = []
|
||||
for i, a in enumerate(core_hubs):
|
||||
for b in core_hubs[i + 1 :]:
|
||||
pair_order.append((a, b))
|
||||
if len(hubs) >= 2 and not pair_order:
|
||||
pair_order.append((hubs[0], hubs[1]))
|
||||
best: DualUnit | None = None
|
||||
for a, b in pair_order:
|
||||
forbid = {
|
||||
n
|
||||
for n, ly in state.layers.items()
|
||||
|
|
@ -488,22 +905,14 @@ def layout_dual_unit(
|
|||
paths = cover_hub_paths(
|
||||
a, b, state.adj, state.names, forbid=set()
|
||||
)
|
||||
if len(paths) >= 2:
|
||||
if state.names.get(a, a) > state.names.get(b, b):
|
||||
a, b = b, a
|
||||
paths = [list(reversed(p)) for p in paths]
|
||||
core = {a, b}
|
||||
for p in paths:
|
||||
core.update(p)
|
||||
units = [
|
||||
DualUnit(
|
||||
portal_a=a,
|
||||
portal_b=b,
|
||||
paths=paths,
|
||||
tails=_collect_tails(state, core),
|
||||
unit_id=0,
|
||||
)
|
||||
]
|
||||
if len(paths) < 2:
|
||||
continue
|
||||
cand = _unit_from_hub_paths(state, a, b, paths)
|
||||
cand.unit_id = 0
|
||||
if best is None or len(cand.member_ids()) > len(best.member_ids()):
|
||||
best = cand
|
||||
if best is not None:
|
||||
units = [best]
|
||||
if not units:
|
||||
return OpResult(
|
||||
state=state,
|
||||
|
|
@ -513,8 +922,15 @@ def layout_dual_unit(
|
|||
note="no_dual_unit",
|
||||
)
|
||||
|
||||
# If multiple units detected on one canvas, layout the largest by node count
|
||||
u = max(units, key=lambda x: len(x.member_ids()))
|
||||
def _pick_key(x: DualUnit) -> tuple:
|
||||
# Top-down eye (core→agg→access), then max membership.
|
||||
return (
|
||||
_eye_portal_key(x.portal_a, x.portal_b, state.layers),
|
||||
-len(x.member_ids()),
|
||||
)
|
||||
|
||||
# Prefer top-layer eyes, then max membership (min of tier, -n).
|
||||
u = min(units, key=_pick_key)
|
||||
pos = layout_dual_unit_positions(state, u, params)
|
||||
out = state.copy()
|
||||
for n, xy in pos.items():
|
||||
|
|
@ -535,21 +951,30 @@ def layout_dual_unit(
|
|||
|
||||
members = u.member_ids()
|
||||
unit_links = [e for e in out.links if e[0] in members and e[1] in members]
|
||||
# Uncross before overlap fix (overlap fix often reintroduces crossings).
|
||||
# Light uncross: keep portal side signs so distribution holds.
|
||||
pinned = {u.portal_a, u.portal_b}
|
||||
out.positions = _uncross_unit(out.positions, unit_links, pinned)
|
||||
is_petal = classify_dual_unit(u) == "petal"
|
||||
out.positions = _uncross_unit(
|
||||
out.positions,
|
||||
unit_links,
|
||||
pinned,
|
||||
preserve_side=is_petal,
|
||||
)
|
||||
x_unit = count_edge_crossings(out.positions, unit_links)
|
||||
|
||||
# Gentle overlap resolve only if still zero-cross; else skip.
|
||||
# Gentle overlap resolve if it does not worsen unit crossings.
|
||||
stf = out
|
||||
if x_unit == 0:
|
||||
cand = fix_overlaps_local(out, params).state
|
||||
cand = fix_overlaps_local(out, params).state
|
||||
if u.portal_a in pos:
|
||||
cand.positions[u.portal_a] = pos[u.portal_a]
|
||||
if u.portal_b in pos:
|
||||
cand.positions[u.portal_b] = pos[u.portal_b]
|
||||
x_after = count_edge_crossings(cand.positions, unit_links)
|
||||
if x_after == 0:
|
||||
stf = cand
|
||||
# else keep pre-overlap geometry
|
||||
for n in pinned:
|
||||
if n in pos:
|
||||
cand.positions[n] = pos[n]
|
||||
x_after = count_edge_crossings(cand.positions, unit_links)
|
||||
if x_after <= x_unit:
|
||||
stf = cand
|
||||
|
||||
x_unit = count_edge_crossings(stf.positions, unit_links)
|
||||
x = count_edge_crossings(stf.positions, stf.links)
|
||||
|
|
@ -564,8 +989,10 @@ def layout_dual_unit(
|
|||
"unit": u.as_dict(state.names),
|
||||
"unit_crossings": x_unit,
|
||||
"parked": parked,
|
||||
"require_zero_cross": require_zero_cross,
|
||||
}
|
||||
accepted = x_unit == 0
|
||||
# Default: accept any laid-out eye (max coverage). Optional hard gate.
|
||||
accepted = (x_unit == 0) if require_zero_cross else True
|
||||
return OpResult(
|
||||
state=stf,
|
||||
moved=moved,
|
||||
|
|
@ -576,11 +1003,11 @@ def layout_dual_unit(
|
|||
"global_crossings": x,
|
||||
"accepted": accepted,
|
||||
"parked": parked,
|
||||
"require_zero_cross": require_zero_cross,
|
||||
"zero_cross": x_unit == 0,
|
||||
},
|
||||
note=(
|
||||
f"layout_dual_unit:paths={len(u.paths)} x=0"
|
||||
if accepted
|
||||
else f"dual_unit_crossings={x_unit}"
|
||||
f"layout_dual_unit:paths={len(u.paths)} nodes={len(members)} x={x_unit}"
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -603,10 +1030,11 @@ def dual_units_report(
|
|||
"uncovered_nodes": max(0, graph_n - len(covered)),
|
||||
"units": [u.as_dict(state.names) for u in units],
|
||||
"tip": (
|
||||
"Dual-portal eye units: ≥2 interior-disjoint corridors between "
|
||||
"portals; layout with action=layout_dual_unit (require crossings=0). "
|
||||
"Portals may be shared across units; compose merges same node ids. "
|
||||
"Leftovers (uncovered_nodes) go to misc unit canvases."
|
||||
"Dual-portal eyes walk top→down (core–core → core–agg → agg–agg), "
|
||||
"then maximize membership. Petal objective = fewer crossings with "
|
||||
"readable spread (ellipse bands; hollow mid; low overlap). "
|
||||
"Residual mesh chords OK; re-run layout_dual_unit if the eye "
|
||||
"breaks — do not polish-straighten."
|
||||
),
|
||||
}
|
||||
|
||||
|
|
@ -624,4 +1052,6 @@ def dual_unit_params_from_overrides(overrides: dict[str, Any] | None) -> dict[st
|
|||
v = overrides.get(key)
|
||||
if v is not None and str(v).strip():
|
||||
out[key] = str(v).strip()
|
||||
if "require_zero_cross" in overrides:
|
||||
out["require_zero_cross"] = bool(overrides.get("require_zero_cross"))
|
||||
return out
|
||||
|
|
|
|||
|
|
@ -2,32 +2,17 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections import deque
|
||||
|
||||
from netx_topology_mcp.layout_ops.level_util import infer_layer
|
||||
|
||||
_ROLE_TO_LAYER = {
|
||||
"core": "core",
|
||||
"cn": "core",
|
||||
"aggregation": "agg",
|
||||
"aggregate": "agg",
|
||||
"agg": "agg",
|
||||
"an": "agg",
|
||||
"access": "access",
|
||||
"en": "access",
|
||||
"edge": "access",
|
||||
}
|
||||
|
||||
|
||||
def infer_layer(name: str, role: str | None = None) -> str:
|
||||
"""Map inventory role / name token (CN|AN|EN) → core|agg|access|other."""
|
||||
r = str(role or "").strip().lower()
|
||||
if r in _ROLE_TO_LAYER:
|
||||
return _ROLE_TO_LAYER[r]
|
||||
m = re.search(r"-(CN|AN|EN)(\d*)-", name or "", re.I)
|
||||
if not m:
|
||||
return "other"
|
||||
return {"CN": "core", "AN": "agg", "EN": "access"}[m.group(1).upper()]
|
||||
__all__ = [
|
||||
"infer_layer",
|
||||
"bbox",
|
||||
"connected_components",
|
||||
"order_ans",
|
||||
"chain_order",
|
||||
]
|
||||
|
||||
|
||||
def bbox(pos: dict[str, tuple[float, float]]) -> tuple[float, float, float, float]:
|
||||
|
|
@ -164,9 +149,11 @@ def build_state_from_nodes_edges(
|
|||
) -> "LayoutState": # noqa: F821
|
||||
from netx_topology_mcp.layout_ops.state import LayoutState
|
||||
from netx_topology_mcp.layout_metrics import collapse_links
|
||||
from netx_topology_mcp.layout_ops.level_util import _parse_level
|
||||
|
||||
names: dict[str, str] = {}
|
||||
layers: dict[str, str] = {}
|
||||
levels: dict[str, float] = {}
|
||||
ids: list[str] = []
|
||||
for n in nodes:
|
||||
if not isinstance(n, dict):
|
||||
|
|
@ -177,7 +164,10 @@ def build_state_from_nodes_edges(
|
|||
nm = str(n.get("name") or n.get("label") or fid)
|
||||
ids.append(fid)
|
||||
names[fid] = nm
|
||||
layers[fid] = infer_layer(nm, n.get("role"))
|
||||
layers[fid] = infer_layer(nm, n.get("role"), n.get("level"))
|
||||
lv = _parse_level(n.get("level"))
|
||||
if lv is not None:
|
||||
levels[fid] = float(lv)
|
||||
|
||||
adj: dict[str, set[str]] = {i: set() for i in ids}
|
||||
links = collapse_links(edges)
|
||||
|
|
@ -203,6 +193,7 @@ def build_state_from_nodes_edges(
|
|||
positions=positions,
|
||||
names=names,
|
||||
layers=layers,
|
||||
levels=levels,
|
||||
links=[(a, b) for a, b in links if a in adj and b in adj],
|
||||
adj=adj,
|
||||
meta={"ids": ids},
|
||||
|
|
|
|||
|
|
@ -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_sweeps": 6,
|
||||
"cross_cand_cap": 220,
|
||||
"straighten": True,
|
||||
# Off by default: straighten flattens dual-unit petal eyes into H-chains.
|
||||
"straighten": False,
|
||||
"skip_dual_full_x": False,
|
||||
"untangle_rounds": 120,
|
||||
"untangle_moves": 5,
|
||||
|
|
@ -170,6 +171,27 @@ def _large_graph_budget(n_links: int) -> dict[str, Any]:
|
|||
}
|
||||
|
||||
|
||||
def _has_petal_dual_eye(state: LayoutState) -> bool:
|
||||
"""True when a multi-corridor dual-unit covers a large share of the canvas."""
|
||||
try:
|
||||
from netx_topology_mcp.layout_ops.dual_units import (
|
||||
classify_dual_unit,
|
||||
find_dual_portal_units,
|
||||
)
|
||||
except Exception:
|
||||
return False
|
||||
units = find_dual_portal_units(state, max_units=24)
|
||||
if not units:
|
||||
return False
|
||||
n = max(1, len(state.positions) or len(state.names) or len(state.adj))
|
||||
for u in units:
|
||||
if classify_dual_unit(u) != "petal":
|
||||
continue
|
||||
if len(u.member_ids()) >= max(8, int(0.25 * n)):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def press_hot_edges(
|
||||
state: LayoutState,
|
||||
params: LayoutParams | None = None,
|
||||
|
|
@ -586,17 +608,47 @@ def polish_crossings(
|
|||
top_n: int | None = None,
|
||||
max_moves: int | None = None,
|
||||
max_sweeps: int | None = None,
|
||||
preserve_dual_eye: bool | None = None,
|
||||
) -> OpResult:
|
||||
"""Pipeline: park phantoms → straighten → hot_edges → crossers → untangle."""
|
||||
"""Pipeline: park phantoms → (optional straighten) → hot → crossers → untangle.
|
||||
|
||||
Petal dual-unit eyes: straighten stays off unless ``straighten=true`` —
|
||||
channel flattening destroys parallel-lane eye geometry.
|
||||
"""
|
||||
from netx_topology_mcp.layout_jobs import raise_if_cancelled, report_progress
|
||||
|
||||
params = params or LayoutParams()
|
||||
st = state.copy()
|
||||
park_phantom_nodes(st)
|
||||
budget = _large_graph_budget(len(st.links))
|
||||
preserve = True if preserve_dual_eye is None else bool(preserve_dual_eye)
|
||||
petal_eye = False
|
||||
eye_portals: list[str] = []
|
||||
if preserve:
|
||||
try:
|
||||
from netx_topology_mcp.layout_ops.dual_units import (
|
||||
classify_dual_unit,
|
||||
find_dual_portal_units,
|
||||
)
|
||||
|
||||
units = find_dual_portal_units(st, max_units=24)
|
||||
n = max(1, len(st.positions) or len(st.names) or len(st.adj))
|
||||
for u in units:
|
||||
if classify_dual_unit(u) != "petal":
|
||||
continue
|
||||
if len(u.member_ids()) >= max(8, int(0.25 * n)):
|
||||
petal_eye = True
|
||||
eye_portals = [u.portal_a, u.portal_b]
|
||||
break
|
||||
except Exception:
|
||||
petal_eye = _has_petal_dual_eye(st)
|
||||
if portal_ids is None and eye_portals:
|
||||
portal_ids = eye_portals
|
||||
# Giant graphs: never allow straighten even if caller asks (stalls for minutes).
|
||||
if len(st.links) >= 800:
|
||||
do_straighten = False
|
||||
elif petal_eye and straighten is not True:
|
||||
do_straighten = False
|
||||
else:
|
||||
do_straighten = (
|
||||
bool(budget["straighten"]) if straighten is None else bool(straighten)
|
||||
|
|
@ -720,6 +772,8 @@ def polish_crossings(
|
|||
"focus_n": len(focus),
|
||||
"budget": budget,
|
||||
"straighten": do_straighten,
|
||||
"preserve_dual_eye": preserve,
|
||||
"petal_eye_detected": petal_eye,
|
||||
"untangle_rounds": untangle_rounds,
|
||||
}
|
||||
return OpResult(
|
||||
|
|
@ -753,6 +807,13 @@ def press_params_from_overrides(overrides: dict[str, Any] | None) -> dict[str, A
|
|||
if isinstance(v, bool)
|
||||
else str(v).strip().lower() in {"1", "true", "yes", "on"}
|
||||
)
|
||||
if "preserve_dual_eye" in o:
|
||||
v = o["preserve_dual_eye"]
|
||||
out["preserve_dual_eye"] = (
|
||||
v
|
||||
if isinstance(v, bool)
|
||||
else str(v).strip().lower() in {"1", "true", "yes", "on"}
|
||||
)
|
||||
if "portal_ids" in o and isinstance(o["portal_ids"], list):
|
||||
out["portal_ids"] = [str(x) for x in o["portal_ids"] if str(x)]
|
||||
if "source_view_ids" in o and isinstance(o["source_view_ids"], list):
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
"""Lower is better: less shared portals, smaller unit, lower unit_id."""
|
||||
"""Tie-break only: less shared portals, then lower unit_id (stable)."""
|
||||
shared = sum(1 for p in (u.portal_a, u.portal_b) if share.get(p, 0) > 1)
|
||||
share_sum = share.get(u.portal_a, 0) + share.get(u.portal_b, 0)
|
||||
return (shared, share_sum, len(u.member_ids()), int(u.unit_id))
|
||||
return (shared, share_sum, int(u.unit_id))
|
||||
|
||||
|
||||
def select_dual_unit_batch(
|
||||
|
|
@ -31,53 +31,118 @@ def select_dual_unit_batch(
|
|||
*,
|
||||
max_units: int = 3,
|
||||
min_nodes: int = 8,
|
||||
max_nodes: int = 80,
|
||||
max_batch_nodes: int = 120,
|
||||
max_nodes: int = 300,
|
||||
max_batch_nodes: int = 400,
|
||||
exclude_ids: set[str] | None = None,
|
||||
keep_ids: set[str] | None = None,
|
||||
sink_ids: set[str] | None = None,
|
||||
prefer_pure: bool = False,
|
||||
prefer_core_eye: bool = True,
|
||||
prefer_top_eye: bool | None = None,
|
||||
layers: dict[str, str] | None = None,
|
||||
) -> list[DualUnit]:
|
||||
"""Greedy pick detachable dual-units within size / batch caps."""
|
||||
"""Greedy pick detachable dual-units within size / batch caps.
|
||||
|
||||
Eyes walk **top→down** (core → agg → access): prefer core/agg portal
|
||||
pairs and maximize movable membership. ``prefer_core_eye`` is an alias
|
||||
for ``prefer_top_eye`` (default true).
|
||||
"""
|
||||
exclude_ids = exclude_ids or set()
|
||||
keep_ids = keep_ids or set()
|
||||
layers = layers or {}
|
||||
if prefer_top_eye is None:
|
||||
prefer_top_eye = prefer_core_eye
|
||||
share = _portal_share_counts(units)
|
||||
candidates: list[DualUnit] = []
|
||||
for u in units:
|
||||
members = u.member_ids()
|
||||
if not members:
|
||||
continue
|
||||
movable = members - keep_ids
|
||||
if sink_ids is not None:
|
||||
movable = movable & sink_ids
|
||||
if not movable or movable.issubset(exclude_ids):
|
||||
continue
|
||||
n = len(members)
|
||||
if n < int(min_nodes) or n > int(max_nodes):
|
||||
continue
|
||||
# Skip units already fully present on sink (nothing new to move).
|
||||
if members and members.issubset(exclude_ids):
|
||||
if members and members.issubset(exclude_ids | keep_ids):
|
||||
continue
|
||||
# Need enough *movable* mass for this level phase.
|
||||
if len(movable) < max(2, min(4, int(min_nodes) // 2)):
|
||||
continue
|
||||
candidates.append(u)
|
||||
candidates.sort(key=lambda u: unit_detach_score(u, share))
|
||||
|
||||
def _movable(u: DualUnit) -> set[str]:
|
||||
m = u.member_ids() - keep_ids
|
||||
if sink_ids is not None:
|
||||
m = m & sink_ids
|
||||
return m
|
||||
|
||||
def _portal_tier(nid: str) -> int:
|
||||
ly = (layers.get(nid) or "").strip().lower()
|
||||
if ly == "core":
|
||||
return 0
|
||||
if ly == "agg":
|
||||
return 1
|
||||
if ly == "access":
|
||||
return 2
|
||||
return 3
|
||||
|
||||
def _eye_key(u: DualUnit) -> tuple[int, int]:
|
||||
t = sorted((_portal_tier(u.portal_a), _portal_tier(u.portal_b)))
|
||||
return (t[0], t[1])
|
||||
|
||||
def _score(u: DualUnit) -> tuple:
|
||||
mov = _movable(u)
|
||||
keep_portals = sum(1 for p in (u.portal_a, u.portal_b) if p in keep_ids)
|
||||
pure_penalty = keep_portals if prefer_pure else 0
|
||||
# Top-down eye tier, then max movable / membership.
|
||||
eye = _eye_key(u) if prefer_top_eye else (0, 0)
|
||||
return (
|
||||
pure_penalty,
|
||||
eye,
|
||||
-len(mov),
|
||||
-len(u.member_ids()),
|
||||
*unit_detach_score(u, share),
|
||||
)
|
||||
|
||||
candidates.sort(key=_score)
|
||||
|
||||
picked: list[DualUnit] = []
|
||||
claimed: set[str] = set()
|
||||
for u in candidates:
|
||||
if len(picked) >= int(max_units):
|
||||
break
|
||||
members = u.member_ids()
|
||||
# Prefer units whose interiors are not already claimed this batch.
|
||||
interior = members - {u.portal_a, u.portal_b}
|
||||
movable = _movable(u)
|
||||
interior = movable - {u.portal_a, u.portal_b}
|
||||
if interior & claimed:
|
||||
continue
|
||||
next_ids = claimed | members
|
||||
next_ids = claimed | movable
|
||||
if len(next_ids) > int(max_batch_nodes):
|
||||
continue
|
||||
picked.append(u)
|
||||
claimed |= members
|
||||
claimed |= movable
|
||||
return picked
|
||||
|
||||
|
||||
def batch_node_ids(units: list[DualUnit]) -> list[str]:
|
||||
def batch_node_ids(
|
||||
units: list[DualUnit],
|
||||
*,
|
||||
keep_ids: set[str] | None = None,
|
||||
sink_ids: set[str] | None = None,
|
||||
) -> list[str]:
|
||||
keep_ids = keep_ids or set()
|
||||
out: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for u in units:
|
||||
for nid in sorted(u.member_ids()):
|
||||
if nid and nid not in seen:
|
||||
seen.add(nid)
|
||||
out.append(nid)
|
||||
if not nid or nid in seen or nid in keep_ids:
|
||||
continue
|
||||
if sink_ids is not None and nid not in sink_ids:
|
||||
continue
|
||||
seen.add(nid)
|
||||
out.append(nid)
|
||||
return out
|
||||
|
||||
|
||||
|
|
@ -86,15 +151,20 @@ def leftover_batch_ids(
|
|||
*,
|
||||
max_batch_nodes: int = 120,
|
||||
exclude_ids: set[str] | None = None,
|
||||
keep_ids: set[str] | None = None,
|
||||
sink_ids: set[str] | None = None,
|
||||
) -> list[str]:
|
||||
"""When dual_units are exhausted, take a plain leftover chunk."""
|
||||
exclude_ids = exclude_ids or set()
|
||||
keep_ids = keep_ids or set()
|
||||
out: list[str] = []
|
||||
for nid in source_ids:
|
||||
sid = str(nid or "").strip()
|
||||
if not sid or sid.startswith("region:"):
|
||||
continue
|
||||
if sid in exclude_ids:
|
||||
if sid in exclude_ids or sid in keep_ids:
|
||||
continue
|
||||
if sink_ids is not None and sid not in sink_ids:
|
||||
continue
|
||||
out.append(sid)
|
||||
if len(out) >= int(max_batch_nodes):
|
||||
|
|
|
|||
|
|
@ -43,6 +43,8 @@ class LayoutState:
|
|||
pinned: set[str] = field(default_factory=set)
|
||||
names: dict[str, str] = field(default_factory=dict)
|
||||
layers: dict[str, str] = field(default_factory=dict)
|
||||
# Optional fabric numeric level (1 / 1.1 / 2 …); used by freeze_levels.
|
||||
levels: dict[str, float] = field(default_factory=dict)
|
||||
links: list[tuple[str, str]] = field(default_factory=list)
|
||||
adj: dict[str, set[str]] = field(default_factory=dict)
|
||||
spine: set[str] = field(default_factory=set)
|
||||
|
|
@ -57,6 +59,7 @@ class LayoutState:
|
|||
pinned=set(self.pinned),
|
||||
names=dict(self.names),
|
||||
layers=dict(self.layers),
|
||||
levels=dict(self.levels),
|
||||
links=list(self.links),
|
||||
adj={k: set(v) for k, v in self.adj.items()},
|
||||
spine=set(self.spine),
|
||||
|
|
|
|||
|
|
@ -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_HI = 220.0
|
||||
# Ideal space_utilization band (n * rec_tile / bbox_area).
|
||||
UTIL_SWEET_LO = 0.12
|
||||
# Floor aligned with real mid-size IPRAN / hand golden (~0.08–0.10), not 0.12 fantasy.
|
||||
UTIL_SWEET_LO = 0.08
|
||||
UTIL_SWEET_HI = 0.45
|
||||
# Ideal median undirected edge length / recommended pitch.
|
||||
EDGE_SWEET_LO = 0.7
|
||||
EDGE_SWEET_HI = 2.2
|
||||
|
||||
# Score profiles: weights must sum to 1.0.
|
||||
# default = metro/flat canvases; eye = dual-portal arc-band sinks (diagonals expected).
|
||||
_SCORE_WEIGHTS: dict[str, dict[str, float]] = {
|
||||
"default": {
|
||||
"overlap": 0.24,
|
||||
"crossing": 0.17,
|
||||
"utilization": 0.13,
|
||||
"chain": 0.10,
|
||||
"rings": 0.06,
|
||||
"edge_clearance": 0.10,
|
||||
"edge_axis": 0.06,
|
||||
"grid": 0.05,
|
||||
"nn": 0.04,
|
||||
"hull": 0.03,
|
||||
"stretch": 0.02,
|
||||
},
|
||||
"eye": {
|
||||
"overlap": 0.24,
|
||||
"crossing": 0.16,
|
||||
"utilization": 0.14,
|
||||
"chain": 0.10,
|
||||
"rings": 0.05,
|
||||
"edge_clearance": 0.12,
|
||||
"edge_axis": 0.03, # arc-band eyes intentionally diagonal
|
||||
"grid": 0.05,
|
||||
"nn": 0.04,
|
||||
"hull": 0.05,
|
||||
"stretch": 0.02,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def resolve_score_profile(
|
||||
requested: str | None,
|
||||
*,
|
||||
node_count: int = 0,
|
||||
space_utilization: float | None = None,
|
||||
axis_frac: float | None = None,
|
||||
) -> str:
|
||||
"""Map request → default|eye. ``auto`` uses size/sparsity/axis heuristic."""
|
||||
raw = str(requested or "auto").strip().lower() or "auto"
|
||||
if raw in {"default", "metro", "flat", "corridor"}:
|
||||
return "default"
|
||||
if raw in {"eye", "dual", "dual_unit", "access_eye"}:
|
||||
return "eye"
|
||||
util = float(space_utilization) if space_utilization is not None else 1.0
|
||||
axis = float(axis_frac) if axis_frac is not None else 1.0
|
||||
# Large, still-sparse, diagonal-heavy → treat as dual-eye sink for scoring.
|
||||
if int(node_count) >= 80 and util < 0.10 and axis < 0.55:
|
||||
return "eye"
|
||||
return "default"
|
||||
|
||||
|
||||
def _convex_hull(points: list[tuple[float, float]]) -> list[tuple[float, float]]:
|
||||
"""Andrew monotone chain; returns hull CCW, or [] if < 3 unique points."""
|
||||
|
|
@ -175,11 +228,15 @@ def compute_density_stats(
|
|||
}
|
||||
|
||||
|
||||
def score_layout_components(metrics: dict[str, Any]) -> dict[str, Any]:
|
||||
def score_layout_components(
|
||||
metrics: dict[str, Any],
|
||||
*,
|
||||
score_profile: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Weighted sub-scores in [0,1] + total in [0,100].
|
||||
|
||||
Hard gate: any footprint/label overlap → total capped near 0 (still report parts).
|
||||
Mid-tier: chain(直链成一体)+ rings(最小环不被穿), each weight 0.10.
|
||||
Profiles: ``default`` (metro) | ``eye`` (dual-portal arc sink) | ``auto``.
|
||||
"""
|
||||
n = int(metrics.get("node_count") or 0)
|
||||
overlaps = int(metrics.get("footprint_overlap_pairs") or 0)
|
||||
|
|
@ -204,6 +261,19 @@ def score_layout_components(metrics: dict[str, Any]) -> dict[str, Any]:
|
|||
if metrics.get("edge_axis_score") is not None
|
||||
else 1.0
|
||||
)
|
||||
axis_frac = metrics.get("axis_frac")
|
||||
try:
|
||||
axis_frac_f = float(axis_frac) if axis_frac is not None else None
|
||||
except (TypeError, ValueError):
|
||||
axis_frac_f = None
|
||||
|
||||
profile = resolve_score_profile(
|
||||
score_profile if score_profile is not None else metrics.get("score_profile"),
|
||||
node_count=n,
|
||||
space_utilization=util,
|
||||
axis_frac=axis_frac_f,
|
||||
)
|
||||
weights = dict(_SCORE_WEIGHTS.get(profile) or _SCORE_WEIGHTS["default"])
|
||||
|
||||
# Crossing: UME mid-size ~0.16; 0 → 1.0, 0.30 → 0
|
||||
if n <= 50:
|
||||
|
|
@ -221,29 +291,22 @@ def score_layout_components(metrics: dict[str, Any]) -> dict[str, Any]:
|
|||
|
||||
overlap_s = 1.0 if overlaps == 0 and label_ov == 0 else 0.0
|
||||
nn_s = _band_score(nn, NN_SWEET_LO, NN_SWEET_HI, hard_lo=40.0, hard_hi=500.0)
|
||||
util_s = _band_score(util, UTIL_SWEET_LO, UTIL_SWEET_HI, hard_lo=0.01, hard_hi=1.2)
|
||||
hull_s = _band_score(hull_u, UTIL_SWEET_LO, UTIL_SWEET_HI + 0.1, hard_lo=0.02, hard_hi=1.5)
|
||||
grid_s = _band_score(grid_occ, 0.25, 0.75, hard_lo=0.02, hard_hi=1.0)
|
||||
# Eye sinks: blend bbox util with hull util so long corridors don't auto-fail.
|
||||
if profile == "eye":
|
||||
util_blend = 0.55 * util + 0.45 * hull_u
|
||||
util_s = _band_score(util_blend, UTIL_SWEET_LO, UTIL_SWEET_HI, hard_lo=0.02, hard_hi=1.2)
|
||||
hull_s = _band_score(hull_u, UTIL_SWEET_LO, UTIL_SWEET_HI + 0.1, hard_lo=0.03, hard_hi=1.5)
|
||||
else:
|
||||
util_s = _band_score(util, UTIL_SWEET_LO, UTIL_SWEET_HI, hard_lo=0.02, hard_hi=1.2)
|
||||
hull_s = _band_score(hull_u, UTIL_SWEET_LO, UTIL_SWEET_HI + 0.1, hard_lo=0.03, hard_hi=1.5)
|
||||
grid_s = _band_score(grid_occ, 0.20, 0.75, hard_lo=0.02, hard_hi=1.0)
|
||||
stretch_s = _band_score(stretch_f, EDGE_SWEET_LO, EDGE_SWEET_HI, hard_lo=0.2, hard_hi=8.0)
|
||||
white_s = max(0.0, 1.0 - white) # less whitespace → better
|
||||
white_s = max(0.0, 1.0 - white) # less whitespace → better (diagnostic only)
|
||||
chain_s = max(0.0, min(1.0, chain_s))
|
||||
rings_s = max(0.0, min(1.0, rings_s))
|
||||
edge_clr_s = max(0.0, min(1.0, edge_clr_s))
|
||||
edge_axis_s = max(0.0, min(1.0, edge_axis_s))
|
||||
|
||||
weights = {
|
||||
"overlap": 0.24,
|
||||
"crossing": 0.18,
|
||||
"utilization": 0.12,
|
||||
"chain": 0.10,
|
||||
"rings": 0.10,
|
||||
"edge_clearance": 0.08,
|
||||
"edge_axis": 0.06,
|
||||
"grid": 0.04,
|
||||
"nn": 0.04,
|
||||
"hull": 0.02,
|
||||
"stretch": 0.02,
|
||||
}
|
||||
parts = {
|
||||
"overlap": round(overlap_s, 4),
|
||||
"crossing": round(cross_s, 4),
|
||||
|
|
@ -256,9 +319,9 @@ def score_layout_components(metrics: dict[str, Any]) -> dict[str, Any]:
|
|||
"nn": round(nn_s, 4),
|
||||
"hull": round(hull_s, 4),
|
||||
"stretch": round(stretch_s, 4),
|
||||
# Diagnostic only — not in weights (avoid double-count with util/grid).
|
||||
"compactness": round(white_s, 4),
|
||||
}
|
||||
# compactness folded into util/grid already; keep as diagnostic
|
||||
total = sum(parts[k] * weights[k] for k in weights)
|
||||
if overlap_s < 1.0:
|
||||
total *= 0.15 # hard gate: overlaps wreck the score
|
||||
|
|
@ -268,6 +331,7 @@ def score_layout_components(metrics: dict[str, Any]) -> dict[str, Any]:
|
|||
"total": total_100,
|
||||
"parts": parts,
|
||||
"weights": weights,
|
||||
"score_profile": profile,
|
||||
"targets": {
|
||||
"nn_sweet": [NN_SWEET_LO, NN_SWEET_HI],
|
||||
"util_sweet": [UTIL_SWEET_LO, UTIL_SWEET_HI],
|
||||
|
|
@ -288,18 +352,16 @@ def score_layout_components(metrics: dict[str, Any]) -> dict[str, Any]:
|
|||
-total_100,
|
||||
int(metrics.get("edge_crossings") or 0),
|
||||
-util,
|
||||
-edge_clr_s,
|
||||
-chain_s,
|
||||
-rings_s,
|
||||
-edge_clr_s,
|
||||
-edge_axis_s,
|
||||
],
|
||||
"hint": (
|
||||
"total∈[0,100]. Overlaps hard-gate the score. "
|
||||
"Mid-tier: chain=直链成一体、rings=最小环不被穿(各权 0.10);"
|
||||
"edge_clearance=网元勿贴非关联边(权 0.08);"
|
||||
"edge_axis=边宜水平/垂直且水平优先(权 0.06). "
|
||||
"Raise utilization/grid_occupancy without overlaps; "
|
||||
"keep crossings_per_link near ~0.16 (mid-size reference) and nn_p50 in 140–220."
|
||||
f"total∈[0,100] profile={profile}. Overlaps hard-gate. "
|
||||
"Clearance blends nodes_hit + hits/link. "
|
||||
"Eye profile softens axis (arc bands) and rings; raises util/clearance. "
|
||||
"compactness is diagnostic only (not weighted)."
|
||||
),
|
||||
}
|
||||
|
||||
|
|
@ -313,9 +375,10 @@ def _status_from_score(part: float, *, fail_below: float = 0.01, warn_below: flo
|
|||
|
||||
|
||||
def _sparsity_status(util: float, white: float, grid_occ: float) -> str:
|
||||
if util < 0.03 or white > 0.85 or grid_occ < 0.05:
|
||||
# Aligned with UTIL_SWEET_LO=0.08: below sweet → warn; desolate → fail.
|
||||
if util < 0.04 or white > 0.85 or grid_occ < 0.04:
|
||||
return "fail"
|
||||
if util < 0.08 or white > 0.65 or grid_occ < 0.15:
|
||||
if util < UTIL_SWEET_LO or white > 0.65 or grid_occ < 0.12:
|
||||
return "warn"
|
||||
return "ok"
|
||||
|
||||
|
|
@ -594,8 +657,9 @@ def build_layout_report(metrics: dict[str, Any]) -> dict[str, Any]:
|
|||
"只看本工具即可验收:verdict.total∈[0,100];"
|
||||
"overlap/crossing/spacing/sparsity/edges/chains/rings/"
|
||||
"edge_clearance/edge_axis 各有 status。"
|
||||
"中档:chains/rings 各权 0.10;edge_clearance=网元贴边(0.08);"
|
||||
"edge_axis=水平/垂直边且水平优先(0.06)。"
|
||||
f"score_profile={score.get('score_profile') or 'default'}:"
|
||||
"eye 下调 axis/rings、上调 util/clearance;"
|
||||
"贴边分=节点命中∪hits/link;compactness 仅诊断不加权。"
|
||||
"扫参用 score.rank_key(先零重叠,再高 total)。"
|
||||
),
|
||||
},
|
||||
|
|
@ -609,11 +673,13 @@ def analyze_layout_stats(
|
|||
with_meta: bool = False,
|
||||
ume_reference: bool = False,
|
||||
fast: bool = False,
|
||||
score_profile: str | None = "auto",
|
||||
) -> dict[str, Any]:
|
||||
"""Full stats: flat metrics + composite score + unified report.
|
||||
|
||||
``fast=True`` skips ring-pierce (expensive on giant metro canvases) and
|
||||
still scores overlap/crossing/util/chains for apply gates + agent QA.
|
||||
``score_profile``: auto|default|eye — eye softens axis/rings for dual sinks.
|
||||
"""
|
||||
base = analyze_positions(nodes, edges, with_meta=with_meta)
|
||||
pos: dict[str, tuple[float, float]] = {}
|
||||
|
|
@ -682,6 +748,7 @@ def analyze_layout_stats(
|
|||
merged["edge_clearance_tip"] = clr_q.get("edge_clearance_tip")
|
||||
merged["edge_clearance_thr"] = clr_q.get("edge_clearance_thr")
|
||||
merged["edge_clearance_skipped"] = clr_q.get("edge_clearance_skipped")
|
||||
merged["hit_per_link"] = clr_q.get("hit_per_link")
|
||||
merged["edge_axis_score"] = axis_q.get("edge_axis_score", 1.0)
|
||||
merged["axis_frac"] = axis_q.get("axis_frac")
|
||||
merged["horiz_frac"] = axis_q.get("horiz_frac")
|
||||
|
|
@ -694,7 +761,8 @@ def analyze_layout_stats(
|
|||
merged["edge_axis_tip"] = axis_q.get("edge_axis_tip")
|
||||
merged["edge_axis_tol_deg"] = axis_q.get("edge_axis_tol_deg")
|
||||
merged["edge_axis_tol_px"] = axis_q.get("edge_axis_tol_px")
|
||||
score = score_layout_components(merged)
|
||||
merged["score_profile"] = score_profile
|
||||
score = score_layout_components(merged, score_profile=score_profile)
|
||||
grade = grade_layout(merged, ume_reference=ume_reference)
|
||||
packed = {
|
||||
**merged,
|
||||
|
|
@ -703,6 +771,7 @@ def analyze_layout_stats(
|
|||
"summary": {
|
||||
"total": score["total"],
|
||||
"overall": grade.get("overall"),
|
||||
"score_profile": score.get("score_profile"),
|
||||
"crossings": merged.get("edge_crossings"),
|
||||
"cpl": merged.get("crossings_per_link"),
|
||||
"overlaps": merged.get("footprint_overlap_pairs"),
|
||||
|
|
|
|||
|
|
@ -200,7 +200,7 @@ def analyze_graph_structure(
|
|||
nm = str(n.get("name") or n.get("label") or fid)
|
||||
ids.append(fid)
|
||||
names[fid] = nm
|
||||
layers[fid] = infer_layer(nm, n.get("role"))
|
||||
layers[fid] = infer_layer(nm, n.get("role"), n.get("level"))
|
||||
|
||||
adj: dict[str, set[str]] = {i: set() for i in ids}
|
||||
links = collapse_links(edges)
|
||||
|
|
@ -211,7 +211,9 @@ def analyze_graph_structure(
|
|||
|
||||
deg = {i: len(adj[i]) for i in ids}
|
||||
access = {i for i in ids if layers.get(i) == "access"}
|
||||
layer_known = sum(1 for i in ids if layers.get(i) in {"core", "agg", "access"})
|
||||
layer_known = sum(
|
||||
1 for i in ids if layers.get(i) in {"external", "core", "agg", "access"}
|
||||
)
|
||||
if layer_known < max(3, len(ids) // 5) and ids:
|
||||
ranked = sorted(ids, key=lambda i: (-deg[i], names[i]))
|
||||
hub_budget = max(2, min(8, len(ids) // 15 + 2))
|
||||
|
|
@ -317,6 +319,7 @@ def analyze_graph_structure(
|
|||
}
|
||||
|
||||
layer_block = {
|
||||
"external": layer_stats("external"),
|
||||
"core": layer_stats("core"),
|
||||
"agg": layer_stats("agg"),
|
||||
"access": {
|
||||
|
|
|
|||
|
|
@ -42,11 +42,24 @@ from netx_topology_mcp.layout_ops.clear_edge_hits import (
|
|||
clear_edge_hits,
|
||||
clear_edge_params_from_overrides,
|
||||
)
|
||||
from netx_topology_mcp.layout_ops.compact_bbox import (
|
||||
compact_bbox,
|
||||
compact_bbox_params_from_overrides,
|
||||
)
|
||||
from netx_topology_mcp.layout_ops.pull_far_chains import (
|
||||
pull_far_chains,
|
||||
pull_far_chains_params_from_overrides,
|
||||
)
|
||||
from netx_topology_mcp.layout_ops.orbit_sweep import (
|
||||
apply_orbit_pick,
|
||||
orbit_params_from_overrides,
|
||||
orbit_sweep_node,
|
||||
orbit_sweep_round,
|
||||
orbit_sweep_until_limit,
|
||||
)
|
||||
from netx_topology_mcp.layout_ops.level_util import (
|
||||
apply_level_bands,
|
||||
level_bands_params_from_overrides,
|
||||
)
|
||||
|
||||
# Public recipe names → internal multipass ids
|
||||
|
|
@ -137,7 +150,11 @@ ACTIONS = (
|
|||
"layout_dual_unit",
|
||||
"polish_crossings",
|
||||
"clear_edge_hits",
|
||||
"compact_bbox",
|
||||
"pull_far_chains",
|
||||
"align_reference",
|
||||
"orbit_sweep",
|
||||
"level_bands",
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -431,6 +448,7 @@ def run_layout_on_graph(
|
|||
unit_id=knobs.get("unit_id"),
|
||||
portal_a=knobs.get("portal_a"),
|
||||
portal_b=knobs.get("portal_b"),
|
||||
require_zero_cross=bool(knobs.get("require_zero_cross", False)),
|
||||
)
|
||||
accepted = bool(op.params.get("accepted", False))
|
||||
# Always return best-effort dual geometry (even if accepted=False).
|
||||
|
|
@ -452,16 +470,67 @@ def run_layout_on_graph(
|
|||
"note": op.note,
|
||||
"accepted": accepted,
|
||||
"meta": st.meta.get("layout_dual_unit"),
|
||||
"ensure": {"ran": False, "reason": "dual_unit_preserves_zero_cross"},
|
||||
"ensure": {
|
||||
"ran": False,
|
||||
"reason": "dual_unit_skips_global_overlap_crush",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
if action_key == "orbit_sweep":
|
||||
knobs = orbit_params_from_overrides(params)
|
||||
do_round = bool(knobs.get("round"))
|
||||
do_until = bool(knobs.get("until_limit"))
|
||||
node_id = str(knobs.get("node_id") or "").strip()
|
||||
frozen = knobs.get("frozen_ids")
|
||||
protect = knobs.get("protect_rigid", "off")
|
||||
if do_until:
|
||||
# until_limit wins over round/node_id (single-point loop to stall).
|
||||
op = orbit_sweep_until_limit(
|
||||
st0,
|
||||
params=base_params,
|
||||
max_degree=int(knobs.get("max_degree") or 14),
|
||||
max_jump=knobs.get("max_jump"),
|
||||
angle_step=knobs.get("angle_step"),
|
||||
nn_floor=float(knobs.get("nn_floor") or 36.0),
|
||||
min_angle_sep=float(knobs.get("min_angle_sep") or 35.0),
|
||||
protect_rigid=protect,
|
||||
frozen_ids=frozen,
|
||||
freeze_layers=knobs.get("freeze_layers"),
|
||||
freeze_levels=knobs.get("freeze_levels"),
|
||||
y_min=knobs.get("y_min"),
|
||||
y_max=knobs.get("y_max"),
|
||||
objective=str(knobs.get("objective") or "crossing"),
|
||||
max_moves=int(knobs.get("max_moves") or 40),
|
||||
stall_limit=int(knobs.get("stall_limit") or 12),
|
||||
max_stretch=float(knobs.get("max_stretch") or 32.0),
|
||||
min_delta=int(knobs.get("min_delta") or 1),
|
||||
scan_cap=int(knobs.get("scan_cap") or 32),
|
||||
top_k=int(knobs.get("top_k") or 8),
|
||||
prefer_low_degree=bool(knobs.get("prefer_low_degree", True)),
|
||||
cand_cap=int(knobs.get("cand_cap") or 360),
|
||||
bundle=bool(knobs.get("bundle", True)),
|
||||
bundle_max=int(knobs.get("bundle_max") or 10),
|
||||
)
|
||||
st = normalize_origin(op.state, base_params).state
|
||||
fin = score_state(st)
|
||||
return _pack_result(
|
||||
st,
|
||||
fin,
|
||||
action=action_key,
|
||||
recipe=None,
|
||||
recipe_id=None,
|
||||
preset=preset,
|
||||
params=base_params,
|
||||
tune=False,
|
||||
tried=None,
|
||||
local={
|
||||
"op": op.params,
|
||||
"note": op.note,
|
||||
"meta": st.meta.get("orbit_sweep"),
|
||||
"until_limit": True,
|
||||
},
|
||||
)
|
||||
if do_round:
|
||||
op = orbit_sweep_round(
|
||||
st0,
|
||||
|
|
@ -474,6 +543,8 @@ def run_layout_on_graph(
|
|||
min_angle_sep=float(knobs.get("min_angle_sep") or 35.0),
|
||||
protect_rigid=protect,
|
||||
frozen_ids=frozen,
|
||||
freeze_layers=knobs.get("freeze_layers"),
|
||||
freeze_levels=knobs.get("freeze_levels"),
|
||||
focus_ids=knobs.get("focus_ids"),
|
||||
y_min=knobs.get("y_min"),
|
||||
y_max=knobs.get("y_max"),
|
||||
|
|
@ -500,8 +571,8 @@ def run_layout_on_graph(
|
|||
)
|
||||
if not node_id:
|
||||
raise ValueError(
|
||||
"orbit_sweep_requires_node_id_or_round:"
|
||||
"params.node_id=… or params.round=true"
|
||||
"orbit_sweep_requires_node_id_or_round_or_until_limit:"
|
||||
"params.node_id=… or params.round=true or params.until_limit=true"
|
||||
)
|
||||
sweep = orbit_sweep_node(
|
||||
st0,
|
||||
|
|
@ -514,6 +585,8 @@ def run_layout_on_graph(
|
|||
cand_cap=int(knobs.get("cand_cap") or 280),
|
||||
protect_rigid=protect,
|
||||
frozen_ids=frozen,
|
||||
freeze_layers=knobs.get("freeze_layers"),
|
||||
freeze_levels=knobs.get("freeze_levels"),
|
||||
top_k=int(knobs.get("top_k") or 3),
|
||||
y_min=knobs.get("y_min"),
|
||||
y_max=knobs.get("y_max"),
|
||||
|
|
@ -599,6 +672,8 @@ def run_layout_on_graph(
|
|||
pitch=knobs.get("pitch"),
|
||||
side=knobs.get("side"),
|
||||
rounds=int(knobs.get("rounds") or (6 if preserve else 1)),
|
||||
frozen_ids=knobs.get("frozen_ids") or set(),
|
||||
max_eject_degree=int(knobs.get("max_eject_degree") or 5),
|
||||
)
|
||||
st = normalize_origin(op.state, base_params).state
|
||||
from netx_topology_mcp.layout_metrics import count_edge_crossings as _cx
|
||||
|
|
@ -629,6 +704,100 @@ def run_layout_on_graph(
|
|||
},
|
||||
)
|
||||
|
||||
if action_key == "compact_bbox":
|
||||
knobs = compact_bbox_params_from_overrides(params)
|
||||
op = compact_bbox(
|
||||
st0,
|
||||
base_params,
|
||||
frozen_ids=knobs.get("frozen_ids") or set(),
|
||||
portal_ids=knobs.get("portal_ids") or [],
|
||||
min_scale=float(knobs.get("min_scale") or 0.72),
|
||||
step=float(knobs.get("step") or 0.03),
|
||||
max_clearance_slack=int(knobs.get("max_clearance_slack") or 80),
|
||||
outlier_only=bool(knobs.get("outlier_only", True)),
|
||||
)
|
||||
st = normalize_origin(op.state, base_params).state
|
||||
fin = score_state(st)
|
||||
return _pack_result(
|
||||
st,
|
||||
fin,
|
||||
action=action_key,
|
||||
recipe=None,
|
||||
recipe_id=None,
|
||||
preset=preset,
|
||||
params=base_params,
|
||||
tune=False,
|
||||
tried=None,
|
||||
local={"op": op.params, "note": op.note, "meta": st.meta.get("compact_bbox")},
|
||||
)
|
||||
|
||||
if action_key == "pull_far_chains":
|
||||
knobs = pull_far_chains_params_from_overrides(params)
|
||||
op = pull_far_chains(
|
||||
st0,
|
||||
base_params,
|
||||
frozen_ids=knobs.get("frozen_ids") or set(),
|
||||
portal_ids=knobs.get("portal_ids") or [],
|
||||
max_chains=int(knobs.get("max_chains") or 16),
|
||||
min_tip_radius=float(knobs.get("min_tip_radius") or 1800.0),
|
||||
scales=knobs.get("scales") or (0.92, 0.88, 0.84, 0.80, 0.75),
|
||||
max_clearance_slack=int(knobs.get("max_clearance_slack") or 40),
|
||||
pull_isolates=bool(knobs.get("pull_isolates", True)),
|
||||
)
|
||||
st = normalize_origin(op.state, base_params).state
|
||||
fin = score_state(st)
|
||||
return _pack_result(
|
||||
st,
|
||||
fin,
|
||||
action=action_key,
|
||||
recipe=None,
|
||||
recipe_id=None,
|
||||
preset=preset,
|
||||
params=base_params,
|
||||
tune=False,
|
||||
tried=None,
|
||||
local={
|
||||
"op": op.params,
|
||||
"note": op.note,
|
||||
"meta": st.meta.get("pull_far_chains"),
|
||||
},
|
||||
)
|
||||
|
||||
if action_key == "level_bands":
|
||||
knobs = level_bands_params_from_overrides(params)
|
||||
op = apply_level_bands(st0, base_params, **knobs)
|
||||
# Soft unstick only — hard crush fights band geometry and inflates crossings.
|
||||
st = op.state
|
||||
ov = overlapping_nodes(st)
|
||||
fix_meta: dict[str, Any] = {"ran": False, "overlaps_before": len(ov)}
|
||||
if ov:
|
||||
st2 = fix_overlaps_local(st, base_params).state
|
||||
st = st2
|
||||
fix_meta = {
|
||||
"ran": True,
|
||||
"overlaps_before": len(ov),
|
||||
"overlaps_after": len(overlapping_nodes(st)),
|
||||
"mode": "local_only",
|
||||
}
|
||||
fin = score_state(st)
|
||||
return _pack_result(
|
||||
st,
|
||||
fin,
|
||||
action=action_key,
|
||||
recipe=None,
|
||||
recipe_id=None,
|
||||
preset=preset,
|
||||
params=base_params,
|
||||
tune=False,
|
||||
tried=None,
|
||||
local={
|
||||
"op": op.params,
|
||||
"note": op.note,
|
||||
"meta": st.meta.get("level_bands"),
|
||||
"ensure": fix_meta,
|
||||
},
|
||||
)
|
||||
|
||||
if action_key == "polish_crossings":
|
||||
knobs = press_params_from_overrides(params)
|
||||
# Omit knobs so polish can auto-scale budgets on large E (MCP timeout).
|
||||
|
|
@ -639,6 +808,7 @@ def run_layout_on_graph(
|
|||
base_params,
|
||||
portal_ids=knobs.get("portal_ids"),
|
||||
straighten=knobs.get("straighten"),
|
||||
preserve_dual_eye=knobs.get("preserve_dual_eye"),
|
||||
max_degree=int(knobs.get("max_degree") or 9),
|
||||
untangle_rounds=knobs.get("untangle_rounds"),
|
||||
top_n=knobs.get("top_n"),
|
||||
|
|
@ -750,13 +920,35 @@ def list_layout_catalog() -> dict[str, Any]:
|
|||
),
|
||||
"clear_edge_hits": (
|
||||
"把贴在非关联边上的网元沿垂直方向弹开(直角偏好 H/V);"
|
||||
"门控:不增交叉、不增重叠。"
|
||||
"params: top_n/thr/margin/max_moves"
|
||||
"门控:不增交叉、不增重叠(preserve_axis 亦不放宽交叉)。"
|
||||
"眼 sink 须 portal_ids;params: top_n/thr/margin/max_moves/max_eject_degree"
|
||||
),
|
||||
"compact_bbox": (
|
||||
"眼图安全收 bbox:相对门户中心;默认 farthest-K(outlier_only);"
|
||||
"门控:交叉不升、overlaps=0、贴边不可大幅恶化。"
|
||||
"params: portal_ids/min_scale/step"
|
||||
),
|
||||
"pull_far_chains": (
|
||||
"眼图安全收远场:deg≤2 走廊/孤立点/远叶相对门户中点缩放;"
|
||||
"门控:交叉不升、overlaps=0、贴边松弛有限。"
|
||||
"params: portal_ids/max_chains/min_tip_radius/scales"
|
||||
),
|
||||
"align_reference": (
|
||||
"【非主路径】仅同网同成员画布 A/B 调试:"
|
||||
"把参考画布几何映射到当前画布(共享 fabric_node_id)。"
|
||||
"日常无范本、跨网人工图 → 禁止当交付手段。"
|
||||
"params: reference_view_id|source_view_id, portal_ids, mode=similarity|adopt"
|
||||
),
|
||||
"level_bands": (
|
||||
"按 fabric level/layer 水平分层(external→core→agg→access);"
|
||||
"params: y0/band_gap/preserve_x/pitch;分层场景先于 polish"
|
||||
),
|
||||
"orbit_sweep": (
|
||||
"压交叉(默认可动门户):以网元为圆心不定长扫角 top-3;"
|
||||
"preview+node_id / apply+pick / round=true;"
|
||||
"protect_rigid 默认 off(portals/all 可恢复刚体冻)"
|
||||
"压交叉:以网元为圆心不定长扫角;"
|
||||
"preview+node_id / apply+pick;"
|
||||
"until_limit=true 单点循环到 stall(默认冻 portals、objective=crossing|total);"
|
||||
"round=true 一批 top_n 自动 pick#1(眼 sink 禁用);"
|
||||
"单点/round 默认 protect_rigid=off;bundle 默认开"
|
||||
),
|
||||
"job_status": (
|
||||
"轮询后台 job:params.job_id;返回 progress.phase/pct、elapsed_ms、"
|
||||
|
|
@ -789,9 +981,15 @@ def list_layout_catalog() -> dict[str, Any]:
|
|||
"apply": "PATCH 到 view_id(有残留重叠则拒绝落笔)",
|
||||
},
|
||||
"workflow": (
|
||||
"主路径:analyze(structure) → layout_dual_unit(或小图 layout "
|
||||
"compact|corridor|rings)→ sinkTopologyDualUnits / move_nodes(park) → "
|
||||
"orbit_sweep(round) → polish_crossings → clear_edge_hits → "
|
||||
"手拖微调。禁止临时 py 算坐标。"
|
||||
"主路径:analyze(structure) → sinkTopologyDualUnits(max_units=1) → "
|
||||
"suggestSinkHubs/move_nodes(park) → "
|
||||
"orbit_sweep(until_limit crossing→total) → "
|
||||
"clear_edge_hits → pull_far_chains → compact_bbox → 手拖。"
|
||||
"默认无范本;align_reference 仅同网调试,禁止当跨网交付。"
|
||||
"眼 sink 禁 polish/fix_overlaps/untangle/round。"
|
||||
),
|
||||
"eye_polish_plateau": (
|
||||
"算法到头:overlaps=0 + until_limit stall + "
|
||||
"pull/compact/clear moved≈0 → 手拖或改初布;勿指望金标对齐。"
|
||||
),
|
||||
}
|
||||
|
|
|
|||
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 float(m["edge_clearance_score"]) < 1.0
|
||||
assert "edge_clearance" in m["score"]["parts"]
|
||||
assert abs(m["score"]["weights"]["edge_clearance"] - 0.08) < 1e-9
|
||||
assert abs(m["score"]["weights"]["edge_clearance"] - 0.10) < 1e-9
|
||||
assert m["report"]["edge_clearance"]["status"] in {"warn", "fail"}
|
||||
top = m.get("top_edge_hits") or []
|
||||
assert any(r.get("fabric_node_id") == "mksr" for r in top)
|
||||
|
|
@ -118,9 +118,8 @@ def test_score_weights_include_edge_clearance() -> None:
|
|||
"edge_axis_score": 0.2,
|
||||
}
|
||||
)
|
||||
assert abs(s["weights"]["edge_clearance"] - 0.08) < 1e-9
|
||||
assert abs(s["weights"]["edge_clearance"] - 0.10) < 1e-9
|
||||
assert abs(s["weights"]["edge_axis"] - 0.06) < 1e-9
|
||||
assert abs(s["weights"]["grid"] - 0.04) < 1e-9
|
||||
assert abs(s["weights"]["nn"] - 0.04) < 1e-9
|
||||
assert abs(sum(s["weights"].values()) - 1.0) < 1e-9
|
||||
good = score_layout_components(
|
||||
|
|
|
|||
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
|
||||
|
||||
|
|
@ -42,6 +42,39 @@ def _eye_graph():
|
|||
return nodes, edges
|
||||
|
||||
|
||||
def _cn_eye_graph():
|
||||
"""CN pair covers more NEs than a small AN ring — detection must prefer CN."""
|
||||
nodes = [
|
||||
{"fabric_node_id": "cn1", "name": "BTM-CN1", "role": "cn", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "cn2", "name": "BTM-CN2", "role": "cn", "x": 100, "y": 0},
|
||||
{"fabric_node_id": "an1", "name": "BTM-AN1", "role": "an", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "an2", "name": "BTM-AN2", "role": "an", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "e1", "name": "BTM-EN1", "role": "en", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "e2", "name": "BTM-EN2", "role": "en", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "e3", "name": "BTM-EN3", "role": "en", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "e4", "name": "BTM-EN4", "role": "en", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "e5", "name": "BTM-EN5", "role": "en", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "e6", "name": "BTM-EN6", "role": "en", "x": 0, "y": 0},
|
||||
]
|
||||
# Two CN↔CN corridors via AN+EN, plus a tiny AN–AN eye that would steal
|
||||
# interiors if AN rings were claimed first.
|
||||
edges = [
|
||||
{"a_node_id": "cn1", "b_node_id": "cn2"},
|
||||
{"a_node_id": "cn1", "b_node_id": "an1"},
|
||||
{"a_node_id": "an1", "b_node_id": "e1"},
|
||||
{"a_node_id": "e1", "b_node_id": "e2"},
|
||||
{"a_node_id": "e2", "b_node_id": "an2"},
|
||||
{"a_node_id": "an2", "b_node_id": "cn2"},
|
||||
{"a_node_id": "cn1", "b_node_id": "e3"},
|
||||
{"a_node_id": "e3", "b_node_id": "e4"},
|
||||
{"a_node_id": "e4", "b_node_id": "cn2"},
|
||||
{"a_node_id": "an1", "b_node_id": "e5"},
|
||||
{"a_node_id": "e5", "b_node_id": "e6"},
|
||||
{"a_node_id": "e6", "b_node_id": "an2"},
|
||||
]
|
||||
return nodes, edges
|
||||
|
||||
|
||||
def test_actions_include_layout_dual_unit() -> None:
|
||||
assert "layout_dual_unit" in ACTIONS
|
||||
|
||||
|
|
@ -60,6 +93,16 @@ def test_find_dual_portal_units_eye() -> None:
|
|||
assert "a1" in interiors or "b1" in interiors or "c1" in interiors
|
||||
|
||||
|
||||
def test_find_prefers_cn_eye_max_cover() -> None:
|
||||
nodes, edges = _cn_eye_graph()
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
units = find_dual_portal_units(st)
|
||||
assert units
|
||||
u0 = units[0]
|
||||
assert {u0.portal_a, u0.portal_b} == {"cn1", "cn2"}
|
||||
assert len(u0.member_ids()) >= 8
|
||||
|
||||
|
||||
def test_layout_dual_unit_zero_crossings() -> None:
|
||||
nodes, edges = _eye_graph()
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
|
|
@ -70,14 +113,141 @@ def test_layout_dual_unit_zero_crossings() -> None:
|
|||
members.update(unit.get("node_ids") or [])
|
||||
unit_links = [e for e in op.state.links if e[0] in members and e[1] in members]
|
||||
x = count_edge_crossings(op.state.positions, unit_links)
|
||||
# Path-planar 3-corridor eye should stay clean under ellipse bands.
|
||||
assert x == 0
|
||||
assert op.params.get("zero_cross") is True
|
||||
|
||||
out = run_layout_on_graph(nodes, edges, action="layout_dual_unit")
|
||||
assert out["ok"] is True
|
||||
assert out["action"] == "layout_dual_unit"
|
||||
loc = out.get("local") or {}
|
||||
assert loc.get("accepted") is True
|
||||
assert int((loc.get("op") or {}).get("unit_crossings") or 0) == 0
|
||||
|
||||
def test_petal_ellipse_hollow_axis_portals_only() -> None:
|
||||
"""Shared portal-adj nodes stay off the mid-chord (CN gravity / hollow eye)."""
|
||||
nodes = [
|
||||
{"fabric_node_id": "p1", "name": "P1", "role": "cn", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "p2", "name": "P2", "role": "cn", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "s", "name": "SHARED-AN", "role": "an", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "u1", "name": "U1", "role": "en", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "u2", "name": "U2", "role": "en", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "d1", "name": "D1", "role": "en", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "d2", "name": "D2", "role": "en", "x": 0, "y": 0},
|
||||
]
|
||||
# Two corridors share first hop `s`: p1-s-u1-u2-p2 and p1-s-d1-d2-p2.
|
||||
edges = [
|
||||
{"a_node_id": "p1", "b_node_id": "s"},
|
||||
{"a_node_id": "s", "b_node_id": "u1"},
|
||||
{"a_node_id": "u1", "b_node_id": "u2"},
|
||||
{"a_node_id": "u2", "b_node_id": "p2"},
|
||||
{"a_node_id": "s", "b_node_id": "d1"},
|
||||
{"a_node_id": "d1", "b_node_id": "d2"},
|
||||
{"a_node_id": "d2", "b_node_id": "p2"},
|
||||
]
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
op = layout_dual_unit(st, LayoutParams(pitch=200.0, lane=300.0))
|
||||
pos = op.state.positions
|
||||
# Axis = portals only; shared AN sits on an arc band.
|
||||
assert abs(pos["p1"][1]) < 1e-6 and abs(pos["p2"][1]) < 1e-6
|
||||
assert abs(pos["s"][1]) > 50.0
|
||||
assert pos["u1"][1] * pos["d1"][1] < 0
|
||||
assert abs(pos["u1"][1]) > 200.0
|
||||
assert abs(pos["d1"][1]) > 200.0
|
||||
|
||||
|
||||
def test_short_corridor_an_not_on_mid_axis() -> None:
|
||||
"""CN–AN–CN must not drop the AN onto (0,0) mid-chord."""
|
||||
nodes = [
|
||||
{"fabric_node_id": "cn1", "name": "CN1", "role": "cn", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "cn2", "name": "CN2", "role": "cn", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "an1", "name": "AN1", "role": "an", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "an2", "name": "AN2", "role": "an", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "e1", "name": "E1", "role": "en", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "e2", "name": "E2", "role": "en", "x": 0, "y": 0},
|
||||
]
|
||||
edges = [
|
||||
{"a_node_id": "cn1", "b_node_id": "an1"},
|
||||
{"a_node_id": "an1", "b_node_id": "cn2"},
|
||||
{"a_node_id": "cn1", "b_node_id": "e1"},
|
||||
{"a_node_id": "e1", "b_node_id": "e2"},
|
||||
{"a_node_id": "e2", "b_node_id": "cn2"},
|
||||
{"a_node_id": "cn1", "b_node_id": "an2"},
|
||||
{"a_node_id": "an2", "b_node_id": "cn2"},
|
||||
]
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
op = layout_dual_unit(
|
||||
st, LayoutParams(pitch=200.0, lane=300.0), portal_a="cn1", portal_b="cn2"
|
||||
)
|
||||
pos = op.state.positions
|
||||
assert abs(pos["an1"][1]) > 50.0
|
||||
assert abs(pos["an2"][1]) > 50.0
|
||||
on_axis = {n for n, (_x, y) in pos.items() if abs(y) < 1e-6}
|
||||
assert on_axis <= {"cn1", "cn2"}
|
||||
# Hollow-ish: short ANs should not sit at exact mid vertical.
|
||||
assert abs(pos["an1"][0]) > 1.0
|
||||
assert abs(pos["an2"][0]) > 1.0
|
||||
|
||||
|
||||
def test_long_tail_parks_outside_eye_rings() -> None:
|
||||
"""Long deg≤2 tails must sit outside corridor envelope (no ring pierce)."""
|
||||
nodes = [
|
||||
{"fabric_node_id": "p1", "name": "P1", "role": "cn", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "p2", "name": "P2", "role": "cn", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "a1", "name": "A1", "role": "en", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "a2", "name": "A2", "role": "en", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "b1", "name": "B1", "role": "en", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "b2", "name": "B2", "role": "en", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "t0", "name": "T0", "role": "en", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "t1", "name": "T1", "role": "en", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "t2", "name": "T2", "role": "en", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "t3", "name": "T3", "role": "en", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "t4", "name": "T4", "role": "en", "x": 0, "y": 0},
|
||||
]
|
||||
edges = [
|
||||
{"a_node_id": "p1", "b_node_id": "a1"},
|
||||
{"a_node_id": "a1", "b_node_id": "a2"},
|
||||
{"a_node_id": "a2", "b_node_id": "p2"},
|
||||
{"a_node_id": "p1", "b_node_id": "b1"},
|
||||
{"a_node_id": "b1", "b_node_id": "b2"},
|
||||
{"a_node_id": "b2", "b_node_id": "p2"},
|
||||
# Long tail hanging off upper corridor mid a1.
|
||||
{"a_node_id": "a1", "b_node_id": "t0"},
|
||||
{"a_node_id": "t0", "b_node_id": "t1"},
|
||||
{"a_node_id": "t1", "b_node_id": "t2"},
|
||||
{"a_node_id": "t2", "b_node_id": "t3"},
|
||||
{"a_node_id": "t3", "b_node_id": "t4"},
|
||||
]
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
op = layout_dual_unit(st, LayoutParams(pitch=200.0, lane=300.0))
|
||||
pos = op.state.positions
|
||||
corridor = {"p1", "p2", "a1", "a2", "b1", "b2"}
|
||||
eye_ry = max(abs(pos[n][1]) for n in corridor if n in pos)
|
||||
eye_rx = max(abs(pos[n][0]) for n in corridor if n in pos)
|
||||
for tid in ("t0", "t1", "t2", "t3", "t4"):
|
||||
x, y = pos[tid]
|
||||
assert abs(x) >= eye_rx * 0.9 or abs(y) >= eye_ry * 0.9, (tid, x, y, eye_rx, eye_ry)
|
||||
|
||||
|
||||
def test_layout_dual_unit_accepts_residual_cross_by_default() -> None:
|
||||
"""With require_zero_cross=false (default), accepted stays true even if x>0."""
|
||||
nodes, edges = _cn_eye_graph()
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
op = layout_dual_unit(
|
||||
st,
|
||||
LayoutParams(),
|
||||
portal_a="cn1",
|
||||
portal_b="cn2",
|
||||
require_zero_cross=False,
|
||||
)
|
||||
assert op.params.get("accepted") is True
|
||||
assert "unit_crossings" in op.params
|
||||
# Hard gate still available for callers that want the old behavior.
|
||||
op_hard = layout_dual_unit(
|
||||
st,
|
||||
LayoutParams(),
|
||||
portal_a="cn1",
|
||||
portal_b="cn2",
|
||||
require_zero_cross=True,
|
||||
)
|
||||
if int(op_hard.params.get("unit_crossings") or 0) > 0:
|
||||
assert op_hard.params.get("accepted") is False
|
||||
else:
|
||||
assert op_hard.params.get("accepted") is True
|
||||
|
||||
|
||||
def test_structure_reports_dual_units() -> None:
|
||||
|
|
@ -85,13 +255,9 @@ def test_structure_reports_dual_units() -> None:
|
|||
report = analyze_graph_structure(nodes, edges)
|
||||
du = report.get("dual_units") or {}
|
||||
assert int(du.get("unit_count") or 0) >= 1
|
||||
assert report.get("advice", {}).get("prefer_dual_units") is True or du.get(
|
||||
"unit_count", 0
|
||||
) >= 1
|
||||
|
||||
|
||||
def test_compose_merge_shared_portal_unique_coord() -> None:
|
||||
"""Two units sharing portal p2 → one world coord for p2; B rigidly glued."""
|
||||
def test_shared_portal_compose_preserves_relative() -> None:
|
||||
a = ComposeBlock(
|
||||
key="unit-p1-p2",
|
||||
positions={
|
||||
|
|
@ -111,11 +277,8 @@ def test_compose_merge_shared_portal_unique_coord() -> None:
|
|||
merged, meta = strip_pack_blocks([a, b], pad=100.0, merge_shared=True)
|
||||
assert meta.get("merge_shared") is True
|
||||
assert "p2" in merged and "p1" in merged and "p3" in merged
|
||||
# Relative offset p2→p3 preserved after rigid glue (200 on x in local B).
|
||||
dx = merged["p3"][0] - merged["p2"][0]
|
||||
dy = merged["p3"][1] - merged["p2"][1]
|
||||
assert abs(dx - 200.0) < 1e-6
|
||||
assert abs(dy) < 1e-6
|
||||
# b1 relative to p2 preserved.
|
||||
assert abs(merged["b1"][0] - merged["p2"][0] - 100.0) < 1e-6
|
||||
assert abs(merged["b1"][1] - merged["p2"][1] + 60.0) < 1e-6
|
||||
# Shared portal merge keeps both blocks; relative spans stay order-of-magnitude.
|
||||
span_p = ((merged["p3"][0] - merged["p2"][0]) ** 2 + (merged["p3"][1] - merged["p2"][1]) ** 2) ** 0.5
|
||||
span_b = ((merged["b1"][0] - merged["p2"][0]) ** 2 + (merged["b1"][1] - merged["p2"][1]) ** 2) ** 0.5
|
||||
assert abs(span_p - 200.0) < 10.0
|
||||
assert abs(span_b - ((100.0**2 + 60.0**2) ** 0.5)) < 10.0
|
||||
|
|
|
|||
|
|
@ -262,9 +262,52 @@ def test_tools_for_scopes_filters_write() -> None:
|
|||
assert "queryTopologyEdges" in read_only
|
||||
assert "queryTopologyFabricNodes" in read_only
|
||||
assert "createTopologyFolder" not in read_only
|
||||
assert "classifyTopologyFabricNodes" not in read_only
|
||||
write = {str(t.get("name") or "") for t in tools_for_scopes(["ne:read", "ne:write"])}
|
||||
assert "createTopologyView" not in write
|
||||
assert "createTopologyFolder" in write
|
||||
assert "classifyTopologyFabricNodes" in write
|
||||
|
||||
|
||||
def test_classify_topology_fabric_nodes_match_tag() -> None:
|
||||
with patch("netx_topology_mcp.http_tools.http_json") as mock_http:
|
||||
mock_http.return_value = {
|
||||
"ok": True,
|
||||
"data": {
|
||||
"pattern": "CORE",
|
||||
"match_field": "name",
|
||||
"total_matched": 1,
|
||||
"samples": [{"id": "n1", "name": "CORE-1", "attrs": {"x": 1}}],
|
||||
"fabric_node_ids": ["n1"],
|
||||
},
|
||||
}
|
||||
out = call_http_tool(
|
||||
"classifyTopologyFabricNodes",
|
||||
{"action": "match", "pattern": "CORE", "match_field": "name"},
|
||||
)
|
||||
payload = json.loads(out["content"][0]["text"])
|
||||
assert payload.get("action") == "match"
|
||||
assert payload.get("fabric_node_ids") == ["n1"]
|
||||
assert mock_http.call_args[0][:2] == ("POST", "/v1/topology/fabric/nodes/match")
|
||||
|
||||
mock_http.return_value = {
|
||||
"ok": True,
|
||||
"data": {"dry_run": True, "matched": 1, "updated": 0, "level": 1.0, "samples": []},
|
||||
}
|
||||
out = call_http_tool(
|
||||
"classifyTopologyFabricNodes",
|
||||
{
|
||||
"action": "tag",
|
||||
"fabric_node_ids": ["n1"],
|
||||
"level": 1.0,
|
||||
"dry_run": True,
|
||||
},
|
||||
)
|
||||
payload = json.loads(out["content"][0]["text"])
|
||||
assert payload.get("action") == "tag"
|
||||
assert payload.get("dry_run") is True
|
||||
assert mock_http.call_args[0][:2] == ("POST", "/v1/topology/fabric/nodes/tags/bulk")
|
||||
assert mock_http.call_args[1]["body"]["level"] == 1.0
|
||||
|
||||
|
||||
def test_query_fabric_nodes_modes() -> None:
|
||||
|
|
|
|||
|
|
@ -213,6 +213,221 @@ def test_orbit_objective_total_ranks_clearance_trade() -> None:
|
|||
assert 0.0 <= float(c["y"]) <= 80.0
|
||||
|
||||
|
||||
def test_orbit_until_limit_params_defaults() -> None:
|
||||
from netx_topology_mcp.layout_ops.orbit_sweep import orbit_params_from_overrides
|
||||
|
||||
knobs = orbit_params_from_overrides({"until_limit": True})
|
||||
assert knobs["until_limit"] is True
|
||||
assert knobs["protect_rigid"] == "portals"
|
||||
assert knobs["objective"] == "crossing"
|
||||
assert knobs["max_degree"] == 14
|
||||
assert knobs["max_jump"] == 4000.0
|
||||
assert knobs["max_stretch"] == 32.0
|
||||
assert knobs["cand_cap"] == 360
|
||||
assert knobs["top_k"] == 8
|
||||
# until_stall alias
|
||||
assert orbit_params_from_overrides({"until_stall": 1})["until_limit"] is True
|
||||
|
||||
|
||||
def test_orbit_far_field_can_cut_long_chord() -> None:
|
||||
"""Node whose improving slot is outside local polar rings."""
|
||||
nodes = [
|
||||
{"fabric_node_id": "a", "name": "A", "x": 0.0, "y": 200.0},
|
||||
{"fabric_node_id": "b", "name": "B", "x": 4000.0, "y": 200.0},
|
||||
{"fabric_node_id": "c", "name": "C", "x": 2000.0, "y": 0.0},
|
||||
{"fabric_node_id": "leaf", "name": "LEAF", "x": 2000.0, "y": 400.0},
|
||||
]
|
||||
edges = [
|
||||
{"a_node_id": "a", "b_node_id": "b"},
|
||||
{"a_node_id": "c", "b_node_id": "leaf"},
|
||||
]
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
|
||||
g0 = count_edge_crossings(st.positions, st.links)
|
||||
assert g0 >= 1
|
||||
out = orbit_sweep_node(
|
||||
st,
|
||||
"leaf",
|
||||
protect_rigid="off",
|
||||
max_jump=5000,
|
||||
cand_cap=400,
|
||||
top_k=5,
|
||||
nn_floor=10.0,
|
||||
)
|
||||
assert out["ok"] is True
|
||||
assert int(out.get("improving_n") or 0) >= 1
|
||||
best = out["candidates"][0]
|
||||
assert int((best.get("delta") or {}).get("global") or 0) < 0
|
||||
|
||||
|
||||
def test_orbit_sweep_until_limit_cuts_crossings() -> None:
|
||||
from netx_topology_mcp.layout_ops.orbit_sweep import orbit_sweep_until_limit
|
||||
|
||||
nodes, edges = _crossed_pair()
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
|
||||
g0 = count_edge_crossings(st.positions, st.links)
|
||||
op = orbit_sweep_until_limit(
|
||||
st,
|
||||
protect_rigid="off",
|
||||
max_jump=600,
|
||||
max_degree=9,
|
||||
max_moves=10,
|
||||
stall_limit=4,
|
||||
max_stretch=30.0,
|
||||
nn_floor=20.0,
|
||||
)
|
||||
g1 = count_edge_crossings(op.state.positions, op.state.links)
|
||||
meta = op.params or {}
|
||||
assert meta.get("mode") == "until_limit"
|
||||
assert g1 <= g0
|
||||
assert meta.get("end_crossings") == g1
|
||||
assert meta.get("stop_reason") in {
|
||||
"stall",
|
||||
"no_candidates",
|
||||
"max_moves",
|
||||
}
|
||||
|
||||
|
||||
def test_run_layout_until_limit_preview_moves_in_result() -> None:
|
||||
nodes, edges = _crossed_pair()
|
||||
g0_nodes = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
|
||||
# Build edges list for crossing count on input
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
st.positions = dict(g0_nodes)
|
||||
g0 = count_edge_crossings(st.positions, st.links)
|
||||
out = run_layout_on_graph(
|
||||
nodes,
|
||||
edges,
|
||||
action="orbit_sweep",
|
||||
params={
|
||||
"until_limit": True,
|
||||
"protect_rigid": "off",
|
||||
"max_jump": 600,
|
||||
"max_moves": 8,
|
||||
"stall_limit": 4,
|
||||
"nn_floor": 20.0,
|
||||
},
|
||||
)
|
||||
assert out["ok"] is True
|
||||
local = out.get("local") or {}
|
||||
assert local.get("until_limit") is True
|
||||
meta = local.get("meta") or local.get("op") or {}
|
||||
assert meta.get("mode") == "until_limit"
|
||||
by_id = {p["fabric_node_id"]: p for p in out["positions"]}
|
||||
# Relative geometry: crossings should not rise vs original.
|
||||
pos1 = {nid: (float(by_id[nid]["x"]), float(by_id[nid]["y"])) for nid in by_id}
|
||||
g1 = count_edge_crossings(pos1, st.links)
|
||||
assert g1 <= g0
|
||||
|
||||
|
||||
def test_select_stretch_pick_prefers_lower_stretch() -> None:
|
||||
from netx_topology_mcp.layout_ops.orbit_sweep import _select_stretch_pick
|
||||
|
||||
sweep = {
|
||||
"improving_n": 2,
|
||||
"candidates": [
|
||||
{
|
||||
"delta": {"global": -2},
|
||||
"stretch": 40.0,
|
||||
"ov": False,
|
||||
"x": 1,
|
||||
"y": 1,
|
||||
"crossings": {"global": 1},
|
||||
},
|
||||
{
|
||||
"delta": {"global": -2},
|
||||
"stretch": 5.0,
|
||||
"ov": False,
|
||||
"x": 2,
|
||||
"y": 2,
|
||||
"crossings": {"global": 1},
|
||||
},
|
||||
],
|
||||
}
|
||||
picked = _select_stretch_pick(sweep, max_stretch=24.0, min_delta=1)
|
||||
assert picked is not None
|
||||
pick_i, cand = picked
|
||||
assert pick_i == 2
|
||||
assert cand["stretch"] == 5.0
|
||||
|
||||
|
||||
def test_until_limit_freezes_portal_ids() -> None:
|
||||
from netx_topology_mcp.layout_ops.orbit_sweep import orbit_sweep_until_limit
|
||||
|
||||
nodes, edges = _crossed_pair()
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
|
||||
# Freeze both endpoints of the vertical crossing edge.
|
||||
op = orbit_sweep_until_limit(
|
||||
st,
|
||||
protect_rigid="portals",
|
||||
frozen_ids={"c", "d"},
|
||||
max_jump=600,
|
||||
max_moves=6,
|
||||
stall_limit=3,
|
||||
nn_floor=20.0,
|
||||
)
|
||||
moved = set(op.moved or ())
|
||||
assert "c" not in moved and "d" not in moved
|
||||
|
||||
|
||||
def test_ids_matching_freeze_layers_levels() -> None:
|
||||
from netx_topology_mcp.layout_ops.orbit_sweep import (
|
||||
ids_matching_freeze_layers_levels,
|
||||
)
|
||||
|
||||
nodes = [
|
||||
{"fabric_node_id": "c1", "name": "X-CN1-Y", "x": 0, "y": 0, "level": 1},
|
||||
{"fabric_node_id": "c11", "name": "X-CN2-Y", "x": 10, "y": 0, "level": 1.1},
|
||||
{"fabric_node_id": "a1", "name": "X-AN1-Y", "x": 20, "y": 0, "level": 2},
|
||||
{"fabric_node_id": "e1", "name": "X-EN1-Y", "x": 30, "y": 0, "level": 3},
|
||||
]
|
||||
edges = [{"a_node_id": "c1", "b_node_id": "a1"}]
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
assert st.layers["c1"] == "core"
|
||||
assert st.levels["c11"] == 1.1
|
||||
by_layer = ids_matching_freeze_layers_levels(st, freeze_layers=["core", "agg"])
|
||||
assert by_layer == {"c1", "c11", "a1"}
|
||||
by_maj = ids_matching_freeze_layers_levels(st, freeze_levels=[1, 2])
|
||||
assert "c1" in by_maj and "c11" in by_maj and "a1" in by_maj
|
||||
assert "e1" not in by_maj
|
||||
by_exact = ids_matching_freeze_layers_levels(st, freeze_levels=[1.1])
|
||||
assert by_exact == {"c11"}
|
||||
# aliases
|
||||
assert "c1" in ids_matching_freeze_layers_levels(st, freeze_layers=["CN"])
|
||||
|
||||
|
||||
def test_until_limit_freeze_layers_blocks_core() -> None:
|
||||
from netx_topology_mcp.layout_ops.orbit_sweep import orbit_sweep_until_limit
|
||||
|
||||
nodes = [
|
||||
{"fabric_node_id": "a", "name": "AAAAAA-EN-1", "x": 0.0, "y": 200.0, "level": 3},
|
||||
{"fabric_node_id": "b", "name": "BBBBBB-EN-2", "x": 400.0, "y": 200.0, "level": 3},
|
||||
{"fabric_node_id": "c", "name": "CCCCCC-CN-3", "x": 200.0, "y": 0.0, "level": 1},
|
||||
{"fabric_node_id": "d", "name": "DDDDDD-EN-4", "x": 200.0, "y": 400.0, "level": 3},
|
||||
{"fabric_node_id": "e", "name": "EEEEEE-EN-5", "x": 200.0, "y": -80.0, "level": 3},
|
||||
]
|
||||
edges = [
|
||||
{"a_node_id": "a", "b_node_id": "b"},
|
||||
{"a_node_id": "c", "b_node_id": "d"},
|
||||
{"a_node_id": "c", "b_node_id": "e"},
|
||||
]
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
|
||||
op = orbit_sweep_until_limit(
|
||||
st,
|
||||
protect_rigid="off",
|
||||
freeze_layers=["core"],
|
||||
max_jump=600,
|
||||
max_moves=8,
|
||||
stall_limit=4,
|
||||
nn_floor=20.0,
|
||||
)
|
||||
assert "c" not in set(op.moved or ())
|
||||
assert (op.params or {}).get("freeze_layers") == ["core"]
|
||||
|
||||
|
||||
def test_verdict_partial_weights_match_layout_stats() -> None:
|
||||
from netx_topology_mcp.layout_ops.orbit_sweep import (
|
||||
_W_CLR,
|
||||
|
|
|
|||
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}
|
||||
assert "sinkTopologyDualUnits" 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_axis" in s["parts"]
|
||||
assert abs(s["weights"]["chain"] - 0.10) < 1e-9
|
||||
assert abs(s["weights"]["rings"] - 0.10) < 1e-9
|
||||
assert abs(s["weights"]["edge_clearance"] - 0.08) < 1e-9
|
||||
assert abs(s["weights"]["edge_axis"] - 0.06) < 1e-9
|
||||
assert abs(s["weights"]["grid"] - 0.04) < 1e-9
|
||||
assert abs(sum(s["weights"].values()) - 1.0) < 1e-9
|
||||
# default profile: rings down, clearance up vs legacy 0.10/0.08
|
||||
assert s["weights"]["rings"] <= 0.08
|
||||
assert s["weights"]["edge_clearance"] >= 0.08
|
||||
eye = score_layout_components(
|
||||
{
|
||||
"node_count": 200,
|
||||
"edge_crossings": 50,
|
||||
"crossings_per_link": 0.15,
|
||||
"footprint_overlap_pairs": 0,
|
||||
"label_overlap_pairs": 0,
|
||||
"nn_p50": 170,
|
||||
"space_utilization": 0.05,
|
||||
"hull_utilization": 0.09,
|
||||
"grid_occupancy": 0.05,
|
||||
"edge_stretch_p50": 1.2,
|
||||
"whitespace_index": 0.5,
|
||||
"chain_score": 0.6,
|
||||
"rings_score": 0.3,
|
||||
"edge_clearance_score": 0.5,
|
||||
"edge_axis_score": 0.3,
|
||||
"axis_frac": 0.35,
|
||||
},
|
||||
score_profile="eye",
|
||||
)
|
||||
assert eye["score_profile"] == "eye"
|
||||
assert eye["weights"]["edge_axis"] < s["weights"]["edge_axis"]
|
||||
good = score_layout_components(
|
||||
{
|
||||
"node_count": 40,
|
||||
|
|
|
|||
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)
|
||||
self.assertGreaterEqual(prev.level_matched, 4)
|
||||
self.assertGreaterEqual(prev.role_matched, 4)
|
||||
applied = clf.apply_classify(self.db)
|
||||
self.assertGreaterEqual(applied.level_updated, 4)
|
||||
self.assertGreaterEqual(applied.role_updated, 4)
|
||||
self.db.refresh(nodes[0])
|
||||
self.assertEqual(nodes[0].role, "core")
|
||||
self.assertEqual(nodes[0].level, 1.0)
|
||||
self.assertEqual(nodes[0].region_folder_id, region.id)
|
||||
|
||||
dry = clf.generate_slices(
|
||||
|
|
@ -1026,6 +1029,11 @@ class FabricTopologyTests(unittest.TestCase):
|
|||
),
|
||||
)
|
||||
self.assertEqual(bulk.updated, 1)
|
||||
self.assertEqual(bulk.level, 3.0)
|
||||
acc = next(n for n in nodes if n.name.startswith("ACC-"))
|
||||
self.db.refresh(acc)
|
||||
self.assertEqual(acc.level, 3.0)
|
||||
self.assertEqual(acc.role, "access")
|
||||
|
||||
def test_project_neighbors_respects_max_nodes(self) -> None:
|
||||
suffix = uuid4().hex[:8]
|
||||
|
|
|
|||
14
web/WEB.md
14
web/WEB.md
|
|
@ -138,14 +138,14 @@ src/
|
|||
- MCP:以 `queryTopologyEdges` 为主查询 Fabric;画布编辑走 Web
|
||||
- BGP / 隧道 / L2VPN:`layer` 预留,实现 TODO
|
||||
|
||||
## 分类与切片(清单打标)
|
||||
## 分类(清单打标)
|
||||
|
||||
- Fabric 标签:`topo_fabric_node.role` / `region_folder_id`(`role_source` / `region_source`)
|
||||
- 主流程:网元清单表(Fabric 全量)+ 临时正则查找 → 确认后批量写角色/区域;支持单行编辑
|
||||
- 关联状态:`link_status`=`managed|ume|both|orphaned`;筛选 `link_status=linked|orphaned|…`
|
||||
- API:`GET /fabric/nodes`(keyword/role/region/unmatched/link_status)、`POST /fabric/nodes/match`、`POST /fabric/nodes/tags/bulk`、`PATCH /fabric/nodes/{id}/tags`
|
||||
- 切片:`POST /v1/topology/slices/generate`(`core_only` / `core_agg` / `agg_access`;dry_run;可重叠上图)
|
||||
- 前端:网络管理 →「分类与切片」
|
||||
- Fabric 主键:`topo_fabric_node.level`(major.minor,越小越靠外);`role` 为 `floor(level)` 同步别名
|
||||
- 预设:0 外部 / 1 核心 / 2 汇聚 / 3 接入;子层如 1.1、2.1
|
||||
- 主流程:清单 + 正则 → 批量写 level/区域;支持单行编辑
|
||||
- API:`GET /fabric/nodes`(level / level_major / role / unmatched=level)、`POST …/match`、`POST …/tags/bulk`(`level`)、`PATCH …/tags`
|
||||
- Agent:`classifyTopologyFabricNodes`(netx-topology MCP)
|
||||
- 前端:网络管理 →「分类」
|
||||
|
||||
## WebCRT
|
||||
|
||||
|
|
|
|||
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,
|
||||
collectionEligibleNe: (page: number, keyword: string) => ["collectionEligibleNe", page, keyword] as const,
|
||||
neCollectionsAll: ["neCollections"] as const,
|
||||
neCollections: (page: number) => ["neCollections", page] as const,
|
||||
neCollections: (page: number, pageSize = 20, status = "", keyword = "") =>
|
||||
["neCollections", page, pageSize, status, keyword] as const,
|
||||
neCollectionDashboard: ["neCollectionDashboard"] as const,
|
||||
neCollectionPolicy: ["neCollectionPolicy"] as const,
|
||||
neCollectionDetail: (jobId: string) => ["neCollection", jobId] as const,
|
||||
neCollectionRunsAll: ["neCollectionRuns"] as const,
|
||||
neCollectionRuns: (jobId: string, page: number, status: string, keyword: string) =>
|
||||
["neCollectionRuns", jobId, page, status, keyword] as const,
|
||||
neCollectionRuns: (jobId: string, page: number, pageSize: number, status: string, keyword: string) =>
|
||||
["neCollectionRuns", jobId, page, pageSize, status, keyword] as const,
|
||||
cliMeta: ["cliMeta"] as const,
|
||||
cliProfiles: ["cliProfiles"] as const,
|
||||
cliTargetsAll: ["cliTargets"] as const,
|
||||
|
|
@ -56,7 +58,9 @@ export const queryKeys = {
|
|||
unmatched: string,
|
||||
linkStatus: string,
|
||||
page: number,
|
||||
) => ["fabricNodeInventory", keyword, role, regionFolderId, unmatched, linkStatus, page] as const,
|
||||
levelMajor?: string,
|
||||
) =>
|
||||
["fabricNodeInventory", keyword, role, regionFolderId, unmatched, linkStatus, page, levelMajor || ""] as const,
|
||||
fabricNodeSearch: (q: string, page: number) => ["fabricNodeSearch", q, page] as const,
|
||||
topologyViewGraph: (viewId: string) => ["topologyViewGraph", viewId] as const,
|
||||
fabricSummary: ["fabricSummary"] as const,
|
||||
|
|
@ -66,26 +70,35 @@ export const queryKeys = {
|
|||
["topologyViewGraph", viewId, focusFolderId] as const,
|
||||
lldpCollectDashboard: ["lldpCollectDashboard"] as const,
|
||||
lldpCollectJobsAll: ["lldpCollectJobs"] as const,
|
||||
lldpCollectJobs: (page: number) => ["lldpCollectJobs", page] as const,
|
||||
lldpCollectJobs: (page: number, pageSize = 10, status = "", keyword = "") =>
|
||||
["lldpCollectJobs", page, pageSize, status, keyword] as const,
|
||||
lldpCollectJobAll: ["lldpCollectJob"] as const,
|
||||
lldpCollectJob: (jobId: string, page = 1) => ["lldpCollectJob", jobId, page] as const,
|
||||
lldpCollectJob: (jobId: string, page = 1, pageSize = 20, status = "", keyword = "") =>
|
||||
["lldpCollectJob", jobId, page, pageSize, status, keyword] as const,
|
||||
fabricEdgesAll: ["fabricEdges"] as const,
|
||||
fabricEdges: (status: string, keyword: string, page: number) =>
|
||||
["fabricEdges", status, keyword, page] as const,
|
||||
fabricEdges: (status: string, keyword: string, page: number, pageSize = 20, source = "") =>
|
||||
["fabricEdges", status, keyword, page, pageSize, source] as const,
|
||||
configSyncDashboard: ["configSyncDashboard"] as const,
|
||||
configSyncPolicy: ["configSyncPolicy"] as const,
|
||||
configSyncCyclesAll: ["configSyncCycles"] as const,
|
||||
configSyncCycles: (page: number) => ["configSyncCycles", page] as const,
|
||||
configSyncCycles: (page: number, pageSize = 20, status = "", keyword = "") =>
|
||||
["configSyncCycles", page, pageSize, status, keyword] as const,
|
||||
configSyncCycleTasksAll: ["configSyncCycleTasks"] as const,
|
||||
configSyncCycleTasks: (cycleId: string, page: number, status: string, keyword: string) =>
|
||||
["configSyncCycleTasks", cycleId, page, status, keyword] as const,
|
||||
configSyncCycleTasks: (
|
||||
cycleId: string,
|
||||
page: number,
|
||||
pageSize = 20,
|
||||
status = "",
|
||||
keyword = "",
|
||||
) => ["configSyncCycleTasks", cycleId, page, pageSize, status, keyword] as const,
|
||||
networkConfigsAll: ["networkConfigs"] as const,
|
||||
networkConfigs: (page: number, keyword: string, source: string, vendor: string) =>
|
||||
["networkConfigs", page, keyword, source, vendor] as const,
|
||||
networkConfigs: (page: number, keyword: string, source: string, vendor: string, pageSize = 20) =>
|
||||
["networkConfigs", page, keyword, source, vendor, pageSize] as const,
|
||||
networkConfigDetail: (source: string, id: string) => ["networkConfigDetail", source, id] as const,
|
||||
portTrafficDashboard: ["portTrafficDashboard"] as const,
|
||||
portTrafficDevicesAll: ["portTrafficDevices"] as const,
|
||||
portTrafficDevices: (page: number) => ["portTrafficDevices", page] as const,
|
||||
portTrafficDevices: (page: number, pageSize = 20, status = "", keyword = "") =>
|
||||
["portTrafficDevices", page, pageSize, status, keyword] as const,
|
||||
portTrafficTargets: (deviceId: string) => ["portTrafficTargets", deviceId] as const,
|
||||
portTrafficEvents: (deviceId: string) => ["portTrafficEvents", deviceId] as const,
|
||||
portTrafficBoards: ["portTrafficBoards"] as const,
|
||||
|
|
|
|||
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",
|
||||
perPage: "{{n}}/page",
|
||||
pagerMeta: "{{total}} items · page {{page}}/{{pages}}",
|
||||
jumpPage: "Go to page",
|
||||
pageSize: "Page size",
|
||||
exportCsv: "Export CSV",
|
||||
exporting: "Exporting…",
|
||||
exportOk: "Exported {{count}} rows",
|
||||
exportTruncated: "Exported first {{count}} of {{total}} rows (truncated)",
|
||||
exportFailed: "Export failed",
|
||||
opFailed: "Operation failed",
|
||||
empty: "-",
|
||||
cancel: "Cancel",
|
||||
|
|
@ -100,7 +107,7 @@ const en = {
|
|||
devices: "Device list",
|
||||
topology: "Topology",
|
||||
lldpLinks: "LLDP",
|
||||
topoClassify: "Classify / slices",
|
||||
topoClassify: "Classify",
|
||||
alarms: "Alarms",
|
||||
configs: "Configurations",
|
||||
webcrt: "WebCRT",
|
||||
|
|
@ -130,10 +137,15 @@ const en = {
|
|||
statusRunning: "Running",
|
||||
statusPaused: "Paused",
|
||||
statusStopped: "Stopped",
|
||||
statusDraft: "Draft",
|
||||
collecting: "Collecting",
|
||||
deleted: "Deleted",
|
||||
confirmDelete: "Delete this device monitoring and its samples?",
|
||||
empty: "No devices monitored yet. Pick a NE and interfaces to start.",
|
||||
keywordPh: "Name / IP / note / ID",
|
||||
allStatus: "All statuses",
|
||||
exportDevicesName: "port-traffic-devices",
|
||||
exportEventsName: "port-traffic-events",
|
||||
wall: "Ops wall",
|
||||
wallTitle: "Port traffic wall",
|
||||
toDevices: "Manage devices",
|
||||
|
|
@ -354,6 +366,14 @@ const en = {
|
|||
edgeStatusAll: "All statuses",
|
||||
edgeStatusActive: "Active",
|
||||
edgeStatusMissing: "Missing",
|
||||
edgeSourceAll: "All sources",
|
||||
exportEdgesName: "lldp-fabric-edges",
|
||||
exportJobsName: "lldp-collect-jobs",
|
||||
exportItemsName: "lldp-job-items",
|
||||
statusAll: "All statuses",
|
||||
jobKeywordPh: "Job ID / error / scope",
|
||||
itemKeywordPh: "NE name / IP / error",
|
||||
itemResultAll: "All results",
|
||||
missCount: "Miss cycles",
|
||||
replacedBy: "Replaced",
|
||||
kpi: {
|
||||
|
|
@ -392,6 +412,7 @@ const en = {
|
|||
done: "Done",
|
||||
failed: "Failed",
|
||||
cancelled: "Cancelled",
|
||||
stopped: "Stopped",
|
||||
},
|
||||
triggerMode: {
|
||||
manual: "Manual",
|
||||
|
|
@ -444,7 +465,11 @@ const en = {
|
|||
cyclesEmpty: "No sync cycles yet",
|
||||
tasksTitle: "Cycle tasks",
|
||||
taskKeywordPh: "Name / IP / message",
|
||||
keywordPh: "ID / trigger / message",
|
||||
statusAll: "All statuses",
|
||||
allStatus: "All statuses",
|
||||
exportCyclesName: "config-sync-cycles",
|
||||
exportTasksName: "config-sync-tasks",
|
||||
expand: "Details",
|
||||
collapse: "Collapse",
|
||||
kpi: {
|
||||
|
|
@ -472,12 +497,14 @@ const en = {
|
|||
keywordPh: "Name / IP / ID",
|
||||
allSource: "All sources",
|
||||
empty: "No network elements.",
|
||||
exportName: "network-devices",
|
||||
col: {
|
||||
source: "Source",
|
||||
name: "Name",
|
||||
vendor: "Vendor",
|
||||
deviceType: "Device type",
|
||||
connect: "Connect",
|
||||
actions: "Actions",
|
||||
},
|
||||
},
|
||||
networkConfigs: {
|
||||
|
|
@ -490,6 +517,7 @@ const en = {
|
|||
export: "Export",
|
||||
exportBoth: "Export all",
|
||||
exportOk: "Config exported",
|
||||
exportListName: "network-configs",
|
||||
close: "Close",
|
||||
tabSet: "Set format",
|
||||
tabHier: "Hierarchical",
|
||||
|
|
@ -626,6 +654,28 @@ const en = {
|
|||
passwordMustChange: "New password must differ from the default/old password",
|
||||
},
|
||||
collect: {
|
||||
statusAll: "All statuses",
|
||||
keywordPh: "Job title / ID",
|
||||
exportJobsName: "collect-jobs",
|
||||
exportRunsName: "collect-runs",
|
||||
policyTitle: "Collect policy",
|
||||
policyHint:
|
||||
"Periodic schedule is off by default (one-shot / manual). Enable it to auto-create and run jobs on an interval. Keeps the last 3 finished jobs by default.",
|
||||
policySaved: "Policy saved",
|
||||
enabled: "Enable scheduled collect",
|
||||
interval: "Collect interval",
|
||||
intervalUnitDays: "days",
|
||||
intervalUnitHours: "hours",
|
||||
historyKeep: "Jobs to keep",
|
||||
scope: "Scope",
|
||||
scopeAll: "All NEs (managed + UME)",
|
||||
scopeSelected: "Selected NEs only",
|
||||
savePolicy: "Save policy",
|
||||
collectNow: "Collect now",
|
||||
selectedCount: "{{count}} selected",
|
||||
colSource: "Source",
|
||||
policyCommandsRequired: "Add commands and save the policy before enabling schedule or collecting now",
|
||||
policyTargetsRequired: "No eligible NEs in scope; adjust scope or select NEs",
|
||||
create: {
|
||||
title: "New collection job",
|
||||
hint: "Configure commands and NEs, then create the job. Click Start in the job list to run collection.",
|
||||
|
|
@ -676,6 +726,7 @@ const en = {
|
|||
confirmDelete: "Delete this collection job and its log files?",
|
||||
jobs: {
|
||||
title: "Collection jobs",
|
||||
listTitle: "Job list",
|
||||
col: {
|
||||
title: "Job",
|
||||
status: "Status",
|
||||
|
|
@ -705,6 +756,7 @@ const en = {
|
|||
idle: "Idle",
|
||||
last: "Last job",
|
||||
active: "Active jobs",
|
||||
nextDue: "Next due",
|
||||
},
|
||||
runs: {
|
||||
status: "Status",
|
||||
|
|
@ -1152,10 +1204,20 @@ const en = {
|
|||
keywordPh: "keyword(alarm key/cause/ne_name/host_name/ip…)",
|
||||
hostNamePh: "host_name (contains)",
|
||||
allSeverity: "All severities",
|
||||
clearedAll: "is_cleared: all",
|
||||
clearedAll: "Cleared: all",
|
||||
clearedYes: "Cleared",
|
||||
clearedNo: "Active",
|
||||
clearTitle: "Clear filters and reset to page 1",
|
||||
filterByHost: "Filter by this host",
|
||||
noHostName: "No host_name (sync inventory first)",
|
||||
col: {
|
||||
time: "Time",
|
||||
severity: "Severity",
|
||||
neId: "NE ID",
|
||||
hostName: "Host",
|
||||
neType: "NE type",
|
||||
cause: "Cause",
|
||||
},
|
||||
},
|
||||
},
|
||||
webcrt: {
|
||||
|
|
@ -1397,6 +1459,7 @@ const en = {
|
|||
treeLoadFailed: "Failed to load topology tree",
|
||||
treeRetry: "Retry",
|
||||
graphLoading: "Loading topology…",
|
||||
graphLoadFailed: "Failed to load topology",
|
||||
graphRefreshing: "Refreshing…",
|
||||
canvasEmpty: "No NEs on this map yet",
|
||||
canvasEmptyHint: "Use Add NE, or pick a region that already has devices.",
|
||||
|
|
@ -1476,8 +1539,8 @@ const en = {
|
|||
"Remove selected link(s) from the canvas? They are deleted from Fabric only when you Save. LLDP may recreate them on the next collect.",
|
||||
edgeDeleted: "Queued {{count}} link delete(s) — Save to apply",
|
||||
discoverCancelled: "Discovery cancelled",
|
||||
truncatedMembership: "Membership cap reached; some neighbors were not placed. Use classify/slices or a new map.",
|
||||
truncatedFrozen: "This map membership is frozen; neighbors cannot be projected. Unfreeze in classify/slices or create a new map.",
|
||||
truncatedMembership: "Membership cap reached; some neighbors were not placed. Use classify or a new map.",
|
||||
truncatedFrozen: "This map membership is frozen; neighbors cannot be projected. Unfreeze via classify workflow or create a new map.",
|
||||
truncatedNodes: "Too many view nodes; display truncated. Narrow membership or create another map.",
|
||||
truncatedEdges: "Too many edges; display truncated. Filter status or create another map.",
|
||||
truncatedGeneric: "Graph data truncated. Narrow scope or create another map.",
|
||||
|
|
@ -1506,6 +1569,11 @@ const en = {
|
|||
unsavedConfirm: "You have unsaved changes. Discard and switch maps?",
|
||||
saving: "Saving…",
|
||||
saved: "Topology saved",
|
||||
saveFailed: "Save failed: {{detail}}",
|
||||
readOnlyBanner: "Read-only (ne:write scope required to edit)",
|
||||
readOnlyHint: "Read-only — no write permission",
|
||||
discoverTimeout: "Discovery timed out; check LLDP tasks for progress",
|
||||
policySyncFailed: "Failed to sync LLDP policy; local toggle still applies to canvas discovery",
|
||||
discover: "Discover links",
|
||||
discovering: "Discovering…",
|
||||
discoverOne: "Discover links",
|
||||
|
|
@ -1697,16 +1765,16 @@ const en = {
|
|||
connectHint: "Connect mode: drag from the NE center anchor to the target (Esc or Select to exit)",
|
||||
},
|
||||
topoClassify: {
|
||||
title: "Classify & slices",
|
||||
title: "Classify",
|
||||
openTopo: "Topology",
|
||||
inventory: "NE inventory",
|
||||
keywordPh: "Filter by name/IP",
|
||||
filterRoleAll: "All roles",
|
||||
filterLevelAll: "All levels",
|
||||
filterRegionAll: "All regions",
|
||||
filterUnmatchedOff: "No unmatched filter",
|
||||
kindAny: "Role or region empty",
|
||||
kindRole: "Role empty",
|
||||
kindRegion: "Region empty",
|
||||
kindAny: "Missing level or region",
|
||||
kindLevel: "Missing level",
|
||||
kindRegion: "Missing region",
|
||||
filterLinkAll: "All link states",
|
||||
filterLinkLinked: "Linked inventory",
|
||||
filterLinkOrphaned: "Detached",
|
||||
|
|
@ -1715,10 +1783,14 @@ const en = {
|
|||
linkUme: "UME",
|
||||
linkBoth: "Managed+UME",
|
||||
linkOrphaned: "Detached",
|
||||
roleCore: "Core",
|
||||
roleAggregation: "Aggregation",
|
||||
roleAccess: "Access",
|
||||
roleUnknown: "Unknown",
|
||||
levelExternal: "L0 external",
|
||||
levelCore: "L1 core",
|
||||
levelCoreSub: "L1.1 core′",
|
||||
levelAgg: "L2 aggregation",
|
||||
levelAggSub: "L2.1 agg′",
|
||||
levelAccess: "L3 access",
|
||||
levelAccessSub: "L3.1 deeper access",
|
||||
levelInvalid: "Invalid level (0–99.9, one decimal)",
|
||||
sourceManual: "Manual",
|
||||
sourceUmeSync: "UME sync",
|
||||
sourceLldp: "LLDP placeholder",
|
||||
|
|
@ -1731,9 +1803,9 @@ const en = {
|
|||
findMatches: "Find matches",
|
||||
matchOk: "Matched {{count}}; selected",
|
||||
matchHint: "{{count}} matched",
|
||||
assignRole: "Role only",
|
||||
assignLevel: "Level only",
|
||||
assignRegion: "Region only",
|
||||
assignBoth: "Role + region",
|
||||
assignBoth: "Level + region",
|
||||
clearRegion: "Clear region",
|
||||
confirmAssign: "Confirm assign",
|
||||
bulkConfirm: "Write tags to {{count}} selected/matched NE(s)?",
|
||||
|
|
@ -1741,7 +1813,7 @@ const en = {
|
|||
selectedCount: "{{count}} selected",
|
||||
selectPage: "Select page",
|
||||
colNe: "NE",
|
||||
colRole: "Role",
|
||||
colLevel: "Level",
|
||||
colRegion: "Region",
|
||||
colActions: "Actions",
|
||||
edit: "Edit",
|
||||
|
|
@ -1758,20 +1830,6 @@ const en = {
|
|||
rowSaved: "Saved",
|
||||
loading: "Loading…",
|
||||
empty: "No NEs.",
|
||||
showSlices: "Show slice maps",
|
||||
hideSlices: "Hide slice maps",
|
||||
slices: "Slice maps",
|
||||
pickRegion: "Pick region",
|
||||
tplCore: "Core only",
|
||||
tplCoreAgg: "Core + neighbor Agg",
|
||||
tplAggAcc: "Agg + neighbor Access",
|
||||
seedPhysical: "Also place Cores on physical overview",
|
||||
slicePreview: "Preview slices",
|
||||
sliceApply: "Generate maps",
|
||||
sliceConfirm: "Create custom maps and freeze placements?",
|
||||
sliceOk: "Created {{count}} slice map(s)",
|
||||
sliceStats: "{{maps}} map(s) planned, {{overlap}} NE(s) on multiple maps",
|
||||
nodeCount: "{{count}} NE(s)",
|
||||
},
|
||||
audit: {
|
||||
title: "Audit & ops",
|
||||
|
|
|
|||
|
|
@ -9,6 +9,13 @@ const zh = {
|
|||
nextPage: "下一页",
|
||||
perPage: "{{n}}/页",
|
||||
pagerMeta: "共 {{total}} 条 · 第 {{page}}/{{pages}} 页",
|
||||
jumpPage: "跳转到页",
|
||||
pageSize: "每页条数",
|
||||
exportCsv: "导出 CSV",
|
||||
exporting: "导出中…",
|
||||
exportOk: "已导出 {{count}} 条",
|
||||
exportTruncated: "已导出前 {{count}} 条(共 {{total}},已截断)",
|
||||
exportFailed: "导出失败",
|
||||
opFailed: "操作失败",
|
||||
empty: "-",
|
||||
cancel: "取消",
|
||||
|
|
@ -100,7 +107,7 @@ const zh = {
|
|||
devices: "设备列表",
|
||||
topology: "拓扑信息",
|
||||
lldpLinks: "LLDP",
|
||||
topoClassify: "分类切片",
|
||||
topoClassify: "分类",
|
||||
alarms: "告警信息",
|
||||
configs: "配置信息",
|
||||
webcrt: "WebCRT",
|
||||
|
|
@ -130,10 +137,15 @@ const zh = {
|
|||
statusRunning: "运行中",
|
||||
statusPaused: "已暂停",
|
||||
statusStopped: "已停止",
|
||||
statusDraft: "草稿",
|
||||
collecting: "采集中",
|
||||
deleted: "已删除",
|
||||
confirmDelete: "删除该设备监控及其采样数据?",
|
||||
empty: "暂无设备监控。请先选择网元并勾选接口。",
|
||||
keywordPh: "名称 / IP / 备注 / ID",
|
||||
allStatus: "全部状态",
|
||||
exportDevicesName: "port-traffic-devices",
|
||||
exportEventsName: "port-traffic-events",
|
||||
wall: "监控大屏",
|
||||
wallTitle: "端口流量大屏",
|
||||
toDevices: "设备管理",
|
||||
|
|
@ -350,6 +362,14 @@ const zh = {
|
|||
edgeStatusAll: "全部状态",
|
||||
edgeStatusActive: "活跃",
|
||||
edgeStatusMissing: "未发现",
|
||||
edgeSourceAll: "全部来源",
|
||||
exportEdgesName: "lldp-fabric-edges",
|
||||
exportJobsName: "lldp-collect-jobs",
|
||||
exportItemsName: "lldp-job-items",
|
||||
statusAll: "全部状态",
|
||||
jobKeywordPh: "任务 ID / 错误 / 范围",
|
||||
itemKeywordPh: "网元名称 / IP / 错误",
|
||||
itemResultAll: "全部结果",
|
||||
missCount: "未发现周期",
|
||||
replacedBy: "已被替换",
|
||||
kpi: {
|
||||
|
|
@ -388,6 +408,7 @@ const zh = {
|
|||
done: "已完成",
|
||||
failed: "失败",
|
||||
cancelled: "已取消",
|
||||
stopped: "已停止",
|
||||
},
|
||||
triggerMode: {
|
||||
manual: "手动",
|
||||
|
|
@ -440,7 +461,11 @@ const zh = {
|
|||
cyclesEmpty: "暂无同步周期",
|
||||
tasksTitle: "周期任务",
|
||||
taskKeywordPh: "名称 / IP / 消息",
|
||||
keywordPh: "ID / 触发 / 消息",
|
||||
statusAll: "全部状态",
|
||||
allStatus: "全部状态",
|
||||
exportCyclesName: "config-sync-cycles",
|
||||
exportTasksName: "config-sync-tasks",
|
||||
expand: "详情",
|
||||
collapse: "收起",
|
||||
kpi: {
|
||||
|
|
@ -468,12 +493,14 @@ const zh = {
|
|||
keywordPh: "名称 / IP / ID",
|
||||
allSource: "全部来源",
|
||||
empty: "暂无网元。",
|
||||
exportName: "network-devices",
|
||||
col: {
|
||||
source: "来源",
|
||||
name: "名称",
|
||||
vendor: "厂商",
|
||||
deviceType: "设备类型",
|
||||
connect: "连通状态",
|
||||
actions: "操作",
|
||||
},
|
||||
},
|
||||
networkConfigs: {
|
||||
|
|
@ -486,6 +513,7 @@ const zh = {
|
|||
export: "导出",
|
||||
exportBoth: "导出全部",
|
||||
exportOk: "配置已导出",
|
||||
exportListName: "network-configs",
|
||||
close: "关闭",
|
||||
tabSet: "Set 格式",
|
||||
tabHier: "层级格式",
|
||||
|
|
@ -621,6 +649,27 @@ const zh = {
|
|||
passwordMustChange: "新密码不能与默认/旧密码相同",
|
||||
},
|
||||
collect: {
|
||||
statusAll: "全部状态",
|
||||
keywordPh: "任务名称 / ID",
|
||||
exportJobsName: "collect-jobs",
|
||||
exportRunsName: "collect-runs",
|
||||
policyTitle: "采集策略",
|
||||
policyHint: "默认不启用周期(只采一次);勾选后按周期自动创建并执行。默认保留最近 3 次任务数据。",
|
||||
policySaved: "策略已保存",
|
||||
enabled: "启用周期调度",
|
||||
interval: "采集周期",
|
||||
intervalUnitDays: "天",
|
||||
intervalUnitHours: "小时",
|
||||
historyKeep: "任务保留数",
|
||||
scope: "范围",
|
||||
scopeAll: "全部网元(纳管+UME)",
|
||||
scopeSelected: "仅选中网元",
|
||||
savePolicy: "保存策略",
|
||||
collectNow: "立即采集",
|
||||
selectedCount: "已选 {{count}} 台",
|
||||
colSource: "来源",
|
||||
policyCommandsRequired: "启用周期或立即采集前,请先填写采集命令并保存策略",
|
||||
policyTargetsRequired: "选定范围内没有可用网元,请调整范围或勾选网元",
|
||||
create: {
|
||||
title: "新建采集任务",
|
||||
hint: "配置命令与网元后创建任务;创建后需在任务列表中点击「开始」执行采集。",
|
||||
|
|
@ -671,6 +720,7 @@ const zh = {
|
|||
confirmDelete: "确定删除该采集任务?相关日志文件将一并删除。",
|
||||
jobs: {
|
||||
title: "采集任务",
|
||||
listTitle: "任务列表",
|
||||
col: {
|
||||
title: "任务",
|
||||
status: "状态",
|
||||
|
|
@ -700,6 +750,7 @@ const zh = {
|
|||
idle: "空闲",
|
||||
last: "最近任务",
|
||||
active: "活跃任务",
|
||||
nextDue: "下次周期",
|
||||
},
|
||||
runs: {
|
||||
status: "状态",
|
||||
|
|
@ -1145,10 +1196,20 @@ const zh = {
|
|||
keywordPh: "keyword(告警键/原因/ne_name/host_name/ip 等)",
|
||||
hostNamePh: "host_name(含匹配)",
|
||||
allSeverity: "全部级别",
|
||||
clearedAll: "is_cleared: all",
|
||||
clearedAll: "清除状态:全部",
|
||||
clearedYes: "已清除",
|
||||
clearedNo: "未清除",
|
||||
clearTitle: "清空 keyword、host_name、级别、is_cleared,回到第 1 页",
|
||||
filterByHost: "按该主机名筛选",
|
||||
noHostName: "无 host_name(需先同步网元)",
|
||||
col: {
|
||||
time: "告警时间",
|
||||
severity: "级别",
|
||||
neId: "网元 ID",
|
||||
hostName: "主机名",
|
||||
neType: "网元类型",
|
||||
cause: "原因",
|
||||
},
|
||||
},
|
||||
},
|
||||
webcrt: {
|
||||
|
|
@ -1388,6 +1449,7 @@ const zh = {
|
|||
treeLoadFailed: "拓扑树加载失败",
|
||||
treeRetry: "重试",
|
||||
graphLoading: "正在加载拓扑图…",
|
||||
graphLoadFailed: "拓扑图加载失败",
|
||||
graphRefreshing: "刷新中…",
|
||||
canvasEmpty: "这张图上还没有网元",
|
||||
canvasEmptyHint: "可用「添加网元」上图,或从左侧换到有设备的区域。",
|
||||
|
|
@ -1464,8 +1526,8 @@ const zh = {
|
|||
deleteEdgeConfirm: "从画布移除选中链路?仅在点击「保存」后才会从 Fabric 删除;LLDP 下次采集若仍存在可能再次出现。",
|
||||
edgeDeleted: "已标记删除 {{count}} 条链路(保存后生效)",
|
||||
discoverCancelled: "发现已取消",
|
||||
truncatedMembership: "视图已达成员上限,部分邻居未上图。可去分类/切片或新建视图。",
|
||||
truncatedFrozen: "当前视图已冻结成员,无法投影邻居。请在分类/切片中解冻或新建视图。",
|
||||
truncatedMembership: "视图已达成员上限,部分邻居未上图。可去分类页或新建视图。",
|
||||
truncatedFrozen: "当前视图已冻结成员,无法投影邻居。请在分类流程中处理或新建视图。",
|
||||
truncatedNodes: "画布节点过多已截断显示。建议新建视图或收紧成员范围。",
|
||||
truncatedEdges: "画布链路过多已截断显示。建议筛选状态或新建视图。",
|
||||
truncatedGeneric: "图数据已截断显示。建议收紧范围或新建视图。",
|
||||
|
|
@ -1494,6 +1556,11 @@ const zh = {
|
|||
unsavedConfirm: "有未保存的更改,确定丢弃并切换吗?",
|
||||
saving: "保存中…",
|
||||
saved: "拓扑已保存",
|
||||
saveFailed: "保存失败:{{detail}}",
|
||||
readOnlyBanner: "只读模式(需要 ne:write 权限才能编辑)",
|
||||
readOnlyHint: "只读模式,无编辑权限",
|
||||
discoverTimeout: "发现任务超时,请稍后在 LLDP 任务页查看进度",
|
||||
policySyncFailed: "同步 LLDP 策略失败,本地开关仍对本页发现生效",
|
||||
discover: "发现链路",
|
||||
discovering: "发现中…",
|
||||
discoverOne: "发现链路",
|
||||
|
|
@ -1680,15 +1747,15 @@ const zh = {
|
|||
connectHint: "连线模式:从网元中心锚点拖到目标网元(Esc 或切回选择退出)",
|
||||
},
|
||||
topoClassify: {
|
||||
title: "分类与切片",
|
||||
title: "分类",
|
||||
openTopo: "拓扑画布",
|
||||
inventory: "网元清单",
|
||||
keywordPh: "关键字筛选名称/IP",
|
||||
filterRoleAll: "全部角色",
|
||||
filterLevelAll: "全部层级",
|
||||
filterRegionAll: "全部区域",
|
||||
filterUnmatchedOff: "不过滤未分类",
|
||||
kindAny: "角色或区域未填",
|
||||
kindRole: "角色未填",
|
||||
kindAny: "层级或区域未填",
|
||||
kindLevel: "层级未填",
|
||||
kindRegion: "区域未填",
|
||||
filterLinkAll: "全部关联",
|
||||
filterLinkLinked: "已关联库存",
|
||||
|
|
@ -1698,10 +1765,14 @@ const zh = {
|
|||
linkUme: "UME",
|
||||
linkBoth: "托管+UME",
|
||||
linkOrphaned: "已解绑",
|
||||
roleCore: "核心",
|
||||
roleAggregation: "汇聚",
|
||||
roleAccess: "接入",
|
||||
roleUnknown: "未知",
|
||||
levelExternal: "L0 外部",
|
||||
levelCore: "L1 核心",
|
||||
levelCoreSub: "L1.1 次核心",
|
||||
levelAgg: "L2 汇聚",
|
||||
levelAggSub: "L2.1 二级汇聚",
|
||||
levelAccess: "L3 接入",
|
||||
levelAccessSub: "L3.1 更深接入",
|
||||
levelInvalid: "层级无效(0–99.9,一位小数)",
|
||||
sourceManual: "手动",
|
||||
sourceUmeSync: "UME 同步",
|
||||
sourceLldp: "LLDP 占位",
|
||||
|
|
@ -1714,9 +1785,9 @@ const zh = {
|
|||
findMatches: "查找匹配",
|
||||
matchOk: "匹配 {{count}} 条,已勾选",
|
||||
matchHint: "当前匹配 {{count}} 条",
|
||||
assignRole: "只改角色",
|
||||
assignLevel: "只改层级",
|
||||
assignRegion: "只改区域",
|
||||
assignBoth: "角色+区域",
|
||||
assignBoth: "层级+区域",
|
||||
clearRegion: "清空区域",
|
||||
confirmAssign: "确认写入",
|
||||
bulkConfirm: "将给已选/匹配的 {{count}} 条网元写入分类,确定?",
|
||||
|
|
@ -1724,7 +1795,7 @@ const zh = {
|
|||
selectedCount: "已选 {{count}}",
|
||||
selectPage: "全选本页",
|
||||
colNe: "网元",
|
||||
colRole: "角色",
|
||||
colLevel: "层级",
|
||||
colRegion: "区域",
|
||||
colActions: "操作",
|
||||
edit: "编辑",
|
||||
|
|
@ -1739,20 +1810,6 @@ const zh = {
|
|||
rowSaved: "已保存",
|
||||
loading: "加载中…",
|
||||
empty: "没有网元。",
|
||||
showSlices: "展开切片建图",
|
||||
hideSlices: "收起切片建图",
|
||||
slices: "切片建图",
|
||||
pickRegion: "选择区域",
|
||||
tplCore: "仅 Core",
|
||||
tplCoreAgg: "Core + 邻接 Agg",
|
||||
tplAggAcc: "Agg + 邻接 Access",
|
||||
seedPhysical: "同时给物理总览上 Core",
|
||||
slicePreview: "预览切片",
|
||||
sliceApply: "生成切片图",
|
||||
sliceConfirm: "将创建自定义拓扑图并上图冻结,确定?",
|
||||
sliceOk: "已创建 {{count}} 张切片图",
|
||||
sliceStats: "计划 {{maps}} 张图,重复上图网元 {{overlap}} 个",
|
||||
nodeCount: "{{count}} 个网元",
|
||||
},
|
||||
audit: {
|
||||
title: "操作审计",
|
||||
|
|
|
|||
|
|
@ -2208,6 +2208,36 @@ pre {
|
|||
min-width: 88px;
|
||||
}
|
||||
|
||||
.pager__jump {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.pager__jump-input {
|
||||
width: 3.2rem;
|
||||
min-width: 2.5rem;
|
||||
max-width: 4.5rem;
|
||||
padding: 4px 6px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
|
|
@ -7591,6 +7621,26 @@ pre {
|
|||
pointer-events: none;
|
||||
}
|
||||
|
||||
.topo-readonly-banner {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 12px;
|
||||
z-index: 5;
|
||||
max-width: min(360px, calc(100% - 24px));
|
||||
padding: 6px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(56, 189, 248, 0.45);
|
||||
background: rgba(15, 23, 42, 0.92);
|
||||
color: #bae6fd;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.topo-canvas__overlay--error {
|
||||
background: rgba(15, 23, 42, 0.88);
|
||||
}
|
||||
|
||||
.app-main .topo-toolbar__outside-peers,
|
||||
.topo-toolbar__outside-peers {
|
||||
margin-left: auto;
|
||||
|
|
@ -8506,12 +8556,32 @@ html.login-page--paused .login-page__flare {
|
|||
|
||||
|
||||
/* —— Network Management shell (left nav + main) ——
|
||||
Light ops shell + login-aligned accent blues. */
|
||||
Fill the app chrome without a second document scrollbar.
|
||||
Negative margins + 100vh used to fight app-main padding and over-expand the page. */
|
||||
.app--shell:has(.network-shell),
|
||||
.app--shell:has(.audit-shell) {
|
||||
height: 100vh;
|
||||
max-height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-main:has(> .network-shell),
|
||||
.app-main:has(> .audit-shell) {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.network-shell {
|
||||
display: flex;
|
||||
height: calc(100vh - 48px);
|
||||
min-height: 480px;
|
||||
margin: -20px -28px -32px;
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
height: auto;
|
||||
margin: 0;
|
||||
background: var(--nm-chrome);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { ListPager } from "../components/ListPager";
|
||||
import {
|
||||
createNeCollection,
|
||||
deleteCollectionJob,
|
||||
|
|
@ -8,31 +10,51 @@ import {
|
|||
fetchCollectionRuns,
|
||||
fetchEligibleNe,
|
||||
fetchNeCollections,
|
||||
formatErr,
|
||||
pauseCollectionJob,
|
||||
startCollectionJob,
|
||||
startCollectionFromPolicy,
|
||||
retryFailedCollectionJob,
|
||||
updateCollectionPolicy,
|
||||
collectionJobDownloadUrl,
|
||||
collectionRunDownloadUrl,
|
||||
} from "../services/api";
|
||||
import { queryKeys } from "../constants/queryKeys";
|
||||
import { useDebouncedValue } from "../hooks/useDebouncedValue";
|
||||
import { useI18n } from "../i18n";
|
||||
import { useToast } from "../hooks/useToast";
|
||||
import type { CollectionJobItem, EligibleNeItem } from "../types";
|
||||
import type { CollectionJobItem, CollectionRunItem, CollectionTargetRef, EligibleNeItem } from "../types";
|
||||
import { downloadCsv, fetchAllPages } from "../utils/csvExport";
|
||||
import { pageCount } from "../utils/display";
|
||||
import { formatSystemTime } from "../utils/time";
|
||||
|
||||
const POLL_MS = 2000;
|
||||
const ELIGIBLE_PAGE_SIZE = 20;
|
||||
const POLICY_TARGET_PAGE_SIZE = 20;
|
||||
const JOB_STATUS_OPTIONS = ["draft", "pending", "running", "paused", "done", "failed", "cancelled"] as const;
|
||||
const JOB_PAGE_SIZE_OPTIONS = [10, 20, 50, 100];
|
||||
const RUN_PAGE_SIZE_OPTIONS = [10, 20, 50, 100];
|
||||
|
||||
function eligibleKey(row: Pick<EligibleNeItem, "id" | "source">): string {
|
||||
const src = String(row.source || "managed").trim().toLowerCase() || "managed";
|
||||
return `${src}:${row.id}`;
|
||||
}
|
||||
|
||||
function targetKey(ref: CollectionTargetRef): string {
|
||||
const src = String(ref.source || "managed").trim().toLowerCase() || "managed";
|
||||
return `${src}:${ref.id}`;
|
||||
}
|
||||
|
||||
function collectionErrorMessage(err: unknown, t: (key: string) => string): string {
|
||||
const raw = String(err);
|
||||
if (raw.includes("collection_ne_busy")) return t("collect.neBusy");
|
||||
if (raw.includes("collection_job_running")) return t("collect.jobRunning");
|
||||
if (raw.includes("commands_empty") || raw.includes("commands_required_for_schedule")) {
|
||||
return t("collect.policyCommandsRequired");
|
||||
}
|
||||
if (raw.includes("no_selected_targets") || raw.includes("no_eligible_ne")) {
|
||||
return t("collect.policyTargetsRequired");
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
|
|
@ -40,6 +62,7 @@ export function CollectPage() {
|
|||
const { t } = useI18n();
|
||||
const { showOk, showError } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const [commands, setCommands] = useState("");
|
||||
const [title, setTitle] = useState("");
|
||||
|
|
@ -48,12 +71,46 @@ export function CollectPage() {
|
|||
const [neKeyword, setNeKeyword] = useState("");
|
||||
const [nePage, setNePage] = useState(1);
|
||||
const [jobPage, setJobPage] = useState(1);
|
||||
const [jobPageSize, setJobPageSize] = useState(20);
|
||||
const [jobStatus, setJobStatus] = useState("");
|
||||
const [jobKeyword, setJobKeyword] = useState("");
|
||||
const [exportingJobs, setExportingJobs] = useState(false);
|
||||
const [expandedJobId, setExpandedJobId] = useState("");
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
|
||||
const [policyEnabled, setPolicyEnabled] = useState(false);
|
||||
const [policyIntervalValue, setPolicyIntervalValue] = useState(1);
|
||||
const [policyIntervalUnit, setPolicyIntervalUnit] = useState<"days" | "hours">("days");
|
||||
const [policyHistoryKeep, setPolicyHistoryKeep] = useState(3);
|
||||
const [policyScopeMode, setPolicyScopeMode] = useState<"all" | "selected">("all");
|
||||
const [policyTitle, setPolicyTitle] = useState("");
|
||||
const [policyCommands, setPolicyCommands] = useState("");
|
||||
const [policySelectedMap, setPolicySelectedMap] = useState<Record<string, CollectionTargetRef>>({});
|
||||
const [policyHydrated, setPolicyHydrated] = useState(false);
|
||||
const [policyTargetKeyword, setPolicyTargetKeyword] = useState("");
|
||||
const [policyTargetPage, setPolicyTargetPage] = useState(1);
|
||||
|
||||
const debouncedJobKeyword = useDebouncedValue(jobKeyword, 300);
|
||||
const debouncedPolicyTargetKeyword = useDebouncedValue(policyTargetKeyword, 300);
|
||||
|
||||
const selectedIds = useMemo(() => Object.keys(selectedMap), [selectedMap]);
|
||||
const selectedList = useMemo(() => Object.values(selectedMap), [selectedMap]);
|
||||
|
||||
// Deep link: /collect?job_id=… → open runs modal, then strip param
|
||||
useEffect(() => {
|
||||
const jobId = String(searchParams.get("job_id") || "").trim();
|
||||
if (!jobId) return;
|
||||
setExpandedJobId(jobId);
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
if (next.get("job_id") === jobId) next.delete("job_id");
|
||||
return next;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}, [searchParams, setSearchParams]);
|
||||
|
||||
const eligibleQuery = useQuery({
|
||||
queryKey: queryKeys.collectionEligibleNe(nePage, neKeyword),
|
||||
queryFn: () =>
|
||||
|
|
@ -63,8 +120,14 @@ export function CollectPage() {
|
|||
});
|
||||
|
||||
const jobsQuery = useQuery({
|
||||
queryKey: queryKeys.neCollections(jobPage),
|
||||
queryFn: () => fetchNeCollections({ page: jobPage, pageSize: 20 }),
|
||||
queryKey: queryKeys.neCollections(jobPage, jobPageSize, jobStatus, debouncedJobKeyword),
|
||||
queryFn: () =>
|
||||
fetchNeCollections({
|
||||
page: jobPage,
|
||||
pageSize: jobPageSize,
|
||||
status: jobStatus,
|
||||
keyword: debouncedJobKeyword,
|
||||
}),
|
||||
staleTime: 1000,
|
||||
refetchInterval: (q) => {
|
||||
const items = q.state.data?.items ?? [];
|
||||
|
|
@ -85,6 +148,42 @@ export function CollectPage() {
|
|||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const p = dashQuery.data?.policy;
|
||||
if (!p || policyHydrated) return;
|
||||
setPolicyEnabled(Boolean(p.enabled));
|
||||
const hours = Math.max(1, Number(p.interval_hours || Number(p.interval_days || 1) * 24));
|
||||
if (hours % 24 === 0) {
|
||||
setPolicyIntervalUnit("days");
|
||||
setPolicyIntervalValue(Math.max(1, Math.min(365, hours / 24)));
|
||||
} else {
|
||||
setPolicyIntervalUnit("hours");
|
||||
setPolicyIntervalValue(Math.max(1, Math.min(8760, hours)));
|
||||
}
|
||||
setPolicyHistoryKeep(Math.max(0, Math.min(200, Number(p.history_keep ?? 3))));
|
||||
setPolicyScopeMode(p.scope_mode === "selected" ? "selected" : "all");
|
||||
setPolicyTitle(String(p.title || ""));
|
||||
setPolicyCommands(String(p.commands || ""));
|
||||
const map: Record<string, CollectionTargetRef> = {};
|
||||
for (const ref of p.selected_targets || []) {
|
||||
map[targetKey(ref)] = { source: ref.source, id: ref.id };
|
||||
}
|
||||
setPolicySelectedMap(map);
|
||||
setPolicyHydrated(true);
|
||||
}, [dashQuery.data?.policy, policyHydrated]);
|
||||
|
||||
const policyTargetsQuery = useQuery({
|
||||
queryKey: queryKeys.collectionEligibleNe(policyTargetPage, debouncedPolicyTargetKeyword),
|
||||
queryFn: () =>
|
||||
fetchEligibleNe({
|
||||
page: policyTargetPage,
|
||||
pageSize: POLICY_TARGET_PAGE_SIZE,
|
||||
keyword: debouncedPolicyTargetKeyword,
|
||||
}),
|
||||
staleTime: 5000,
|
||||
enabled: policyScopeMode === "selected",
|
||||
});
|
||||
|
||||
const detailQuery = useQuery({
|
||||
queryKey: queryKeys.neCollectionDetail(expandedJobId),
|
||||
queryFn: () => fetchCollectionJob(expandedJobId),
|
||||
|
|
@ -121,6 +220,62 @@ export function CollectPage() {
|
|||
]);
|
||||
};
|
||||
|
||||
const savePolicyMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
const hours =
|
||||
policyIntervalUnit === "days"
|
||||
? Math.max(1, Math.min(365, policyIntervalValue)) * 24
|
||||
: Math.max(1, Math.min(8760, policyIntervalValue));
|
||||
return updateCollectionPolicy({
|
||||
enabled: policyEnabled,
|
||||
interval_hours: hours,
|
||||
history_keep: policyHistoryKeep,
|
||||
scope_mode: policyScopeMode,
|
||||
title: policyTitle.trim(),
|
||||
commands: policyCommands,
|
||||
selected_targets: Object.values(policySelectedMap),
|
||||
});
|
||||
},
|
||||
onSuccess: async (saved) => {
|
||||
setPolicyEnabled(Boolean(saved.enabled));
|
||||
const hours = Math.max(1, Number(saved.interval_hours || Number(saved.interval_days || 1) * 24));
|
||||
if (hours % 24 === 0) {
|
||||
setPolicyIntervalUnit("days");
|
||||
setPolicyIntervalValue(Math.max(1, Math.min(365, hours / 24)));
|
||||
} else {
|
||||
setPolicyIntervalUnit("hours");
|
||||
setPolicyIntervalValue(Math.max(1, Math.min(8760, hours)));
|
||||
}
|
||||
setPolicyHistoryKeep(Math.max(0, Math.min(200, Number(saved.history_keep ?? 3))));
|
||||
setPolicyScopeMode(saved.scope_mode === "selected" ? "selected" : "all");
|
||||
setPolicyTitle(String(saved.title || ""));
|
||||
setPolicyCommands(String(saved.commands || ""));
|
||||
const map: Record<string, CollectionTargetRef> = {};
|
||||
for (const ref of saved.selected_targets || []) {
|
||||
map[targetKey(ref)] = { source: ref.source, id: ref.id };
|
||||
}
|
||||
setPolicySelectedMap(map);
|
||||
setPolicyHydrated(true);
|
||||
queryClient.setQueryData(queryKeys.neCollectionDashboard, (prev: unknown) => {
|
||||
if (!prev || typeof prev !== "object") return prev;
|
||||
return { ...(prev as object), policy: saved };
|
||||
});
|
||||
showOk(t("collect.policySaved"));
|
||||
await refreshAll();
|
||||
},
|
||||
onError: (err) => showError(collectionErrorMessage(err, t)),
|
||||
});
|
||||
|
||||
const startFromPolicyMutation = useMutation({
|
||||
mutationFn: startCollectionFromPolicy,
|
||||
onSuccess: async (job) => {
|
||||
showOk(t("collect.started", { id: job.id }));
|
||||
setExpandedJobId(job.id);
|
||||
await refreshAll();
|
||||
},
|
||||
onError: (err) => showError(collectionErrorMessage(err, t)),
|
||||
});
|
||||
|
||||
const invalidateJobs = async (jobId?: string) => {
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.neCollectionsAll });
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.neCollectionDashboard });
|
||||
|
|
@ -231,7 +386,58 @@ export function CollectPage() {
|
|||
const nePages = pageCount(neTotal, ELIGIBLE_PAGE_SIZE);
|
||||
|
||||
const jobTotal = jobsQuery.data?.total ?? 0;
|
||||
const jobPages = pageCount(jobTotal, 20);
|
||||
const jobPages = pageCount(jobTotal, jobPageSize);
|
||||
const hasJobFilters = Boolean(jobKeyword.trim() || jobStatus);
|
||||
|
||||
const exportJobsCsv = async () => {
|
||||
setExportingJobs(true);
|
||||
try {
|
||||
const rows = await fetchAllPages<CollectionJobItem>({
|
||||
pageSize: 100,
|
||||
maxRows: 2000,
|
||||
fetchPage: (page, pageSize) =>
|
||||
fetchNeCollections({
|
||||
page,
|
||||
pageSize,
|
||||
status: jobStatus,
|
||||
keyword: debouncedJobKeyword,
|
||||
}),
|
||||
});
|
||||
downloadCsv(
|
||||
`${t("collect.exportJobsName")}-${new Date().toISOString().slice(0, 10)}.csv`,
|
||||
rows,
|
||||
[
|
||||
{ key: "title", header: t("collect.jobs.col.title") },
|
||||
{ key: "status", header: t("collect.jobs.col.status") },
|
||||
{
|
||||
key: "progress",
|
||||
header: t("collect.jobs.col.progress"),
|
||||
value: (r) =>
|
||||
`${r.success_count}/${r.ne_count} ${t("collect.jobs.ok")}, ${r.fail_count} ${t("collect.jobs.fail")}`,
|
||||
},
|
||||
{
|
||||
key: "started_at",
|
||||
header: "started_at",
|
||||
value: (r) => (r.started_at ? formatSystemTime(r.started_at) : ""),
|
||||
},
|
||||
{
|
||||
key: "ended_at",
|
||||
header: "ended_at",
|
||||
value: (r) => (r.ended_at ? formatSystemTime(r.ended_at) : ""),
|
||||
},
|
||||
],
|
||||
);
|
||||
if (rows.length < jobTotal) {
|
||||
showOk(t("common.exportTruncated", { count: String(rows.length), total: String(jobTotal) }));
|
||||
} else {
|
||||
showOk(t("common.exportOk", { count: String(rows.length) }));
|
||||
}
|
||||
} catch (err) {
|
||||
showError(t("common.exportFailed") + ": " + formatErr(err));
|
||||
} finally {
|
||||
setExportingJobs(false);
|
||||
}
|
||||
};
|
||||
|
||||
const commandLines = useMemo(
|
||||
() =>
|
||||
|
|
@ -262,10 +468,25 @@ export function CollectPage() {
|
|||
) : null}
|
||||
</div>
|
||||
<div className="btn-row">
|
||||
<button
|
||||
type="button"
|
||||
disabled={exportingJobs || jobTotal === 0}
|
||||
onClick={() => void exportJobsCsv()}
|
||||
>
|
||||
{exportingJobs ? t("common.exporting") : t("common.exportCsv")}
|
||||
</button>
|
||||
<button type="button" onClick={refreshAll} disabled={jobsQuery.isFetching || dashQuery.isFetching}>
|
||||
{jobsQuery.isFetching || dashQuery.isFetching ? t("common.refreshing") : t("common.refresh")}
|
||||
</button>
|
||||
<button type="button" className="btn-primary" onClick={() => setCreateOpen(true)}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
disabled={startFromPolicyMutation.isPending || jobActive}
|
||||
onClick={() => startFromPolicyMutation.mutate()}
|
||||
>
|
||||
{startFromPolicyMutation.isPending ? t("collect.form.starting") : t("collect.collectNow")}
|
||||
</button>
|
||||
<button type="button" onClick={() => setCreateOpen(true)}>
|
||||
{t("collect.create.expand")}
|
||||
</button>
|
||||
{running?.status === "running" || running?.status === "pending" ? (
|
||||
|
|
@ -294,10 +515,6 @@ export function CollectPage() {
|
|||
: t("collect.kpi.idle")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-list-kpi">
|
||||
<div className="pt-list-kpi__label">{t("collect.kpi.active")}</div>
|
||||
<div className="pt-list-kpi__value">{dash?.active_count ?? "—"}</div>
|
||||
</div>
|
||||
<div className="pt-list-kpi">
|
||||
<div className="pt-list-kpi__label">{t("collect.kpi.last")}</div>
|
||||
<div className="pt-list-kpi__value" style={{ fontSize: 15 }}>
|
||||
|
|
@ -306,10 +523,225 @@ export function CollectPage() {
|
|||
: t("common.empty")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-list-kpi">
|
||||
<div className="pt-list-kpi__label">{t("collect.kpi.nextDue")}</div>
|
||||
<div className="pt-list-kpi__value" style={{ fontSize: 15 }}>
|
||||
{dash?.next_due_at ? formatSystemTime(dash.next_due_at) : t("common.empty")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="panel" style={{ marginBottom: 16 }}>
|
||||
<h3>{t("collect.policyTitle")}</h3>
|
||||
<p className="muted" style={{ marginTop: 0 }}>
|
||||
{t("collect.policyHint")}
|
||||
</p>
|
||||
<div className="config-sync-policy-row">
|
||||
<label className="config-sync-policy-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={policyEnabled}
|
||||
onChange={(e) => setPolicyEnabled(e.target.checked)}
|
||||
/>
|
||||
<span>{t("collect.enabled")}</span>
|
||||
</label>
|
||||
<label className="config-sync-policy-field">
|
||||
<span>{t("collect.interval")}</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={policyIntervalUnit === "days" ? 365 : 8760}
|
||||
value={policyIntervalValue}
|
||||
onChange={(e) => {
|
||||
const max = policyIntervalUnit === "days" ? 365 : 8760;
|
||||
setPolicyIntervalValue(Math.max(1, Math.min(max, Number(e.target.value) || 1)));
|
||||
}}
|
||||
/>
|
||||
<select
|
||||
value={policyIntervalUnit}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value === "hours" ? "hours" : "days";
|
||||
if (next === policyIntervalUnit) return;
|
||||
if (next === "hours") {
|
||||
setPolicyIntervalValue(Math.max(1, Math.min(8760, policyIntervalValue * 24)));
|
||||
} else {
|
||||
setPolicyIntervalValue(
|
||||
Math.max(1, Math.min(365, Math.round(policyIntervalValue / 24) || 1)),
|
||||
);
|
||||
}
|
||||
setPolicyIntervalUnit(next);
|
||||
}}
|
||||
>
|
||||
<option value="days">{t("collect.intervalUnitDays")}</option>
|
||||
<option value="hours">{t("collect.intervalUnitHours")}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="config-sync-policy-field">
|
||||
<span>{t("collect.historyKeep")}</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={200}
|
||||
value={policyHistoryKeep}
|
||||
onChange={(e) =>
|
||||
setPolicyHistoryKeep(Math.max(0, Math.min(200, Number(e.target.value) || 0)))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="config-sync-policy-field">
|
||||
<span>{t("collect.scope")}</span>
|
||||
<select
|
||||
value={policyScopeMode}
|
||||
onChange={(e) => setPolicyScopeMode(e.target.value === "selected" ? "selected" : "all")}
|
||||
>
|
||||
<option value="all">{t("collect.scopeAll")}</option>
|
||||
<option value="selected">{t("collect.scopeSelected")}</option>
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
disabled={savePolicyMutation.isPending}
|
||||
onClick={() => savePolicyMutation.mutate()}
|
||||
>
|
||||
{t("collect.savePolicy")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="filter-inline" style={{ marginTop: 12 }}>
|
||||
<label className="config-sync-policy-field" style={{ flex: 1 }}>
|
||||
<span>{t("collect.form.jobTitle")}</span>
|
||||
<input
|
||||
value={policyTitle}
|
||||
placeholder={t("collect.form.jobTitlePh")}
|
||||
onChange={(e) => setPolicyTitle(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label className="config-sync-policy-field" style={{ display: "block", marginTop: 12 }}>
|
||||
<span>{t("collect.form.commands")}</span>
|
||||
<textarea
|
||||
rows={5}
|
||||
value={policyCommands}
|
||||
placeholder={t("collect.form.commandsPh")}
|
||||
onChange={(e) => setPolicyCommands(e.target.value)}
|
||||
style={{ width: "100%", fontFamily: "var(--font-mono, monospace)" }}
|
||||
/>
|
||||
<span className="muted">{t("collect.form.commandsHint")}</span>
|
||||
</label>
|
||||
|
||||
{policyScopeMode === "selected" ? (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<p className="muted">
|
||||
{t("collect.selectedCount", { count: String(Object.keys(policySelectedMap).length) })}
|
||||
</p>
|
||||
<div className="filter-inline">
|
||||
<input
|
||||
value={policyTargetKeyword}
|
||||
placeholder={t("collect.create.filterKeywordPh")}
|
||||
onChange={(e) => {
|
||||
setPolicyTargetKeyword(e.target.value);
|
||||
setPolicyTargetPage(1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th />
|
||||
<th>{t("collect.create.pickTitle")}</th>
|
||||
<th>IP</th>
|
||||
<th>{t("collect.colSource")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(policyTargetsQuery.data?.items ?? []).map((row) => {
|
||||
const key = eligibleKey(row);
|
||||
const checked = Boolean(policySelectedMap[key]);
|
||||
return (
|
||||
<tr key={key}>
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => {
|
||||
setPolicySelectedMap((prev) => {
|
||||
const next = { ...prev };
|
||||
if (next[key]) delete next[key];
|
||||
else next[key] = { source: row.source || "managed", id: row.id };
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td>{row.name || row.id}</td>
|
||||
<td>{row.ip_address}</td>
|
||||
<td>{row.source}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<ListPager
|
||||
page={policyTargetPage}
|
||||
pages={pageCount(policyTargetsQuery.data?.total ?? 0, POLICY_TARGET_PAGE_SIZE)}
|
||||
total={policyTargetsQuery.data?.total ?? 0}
|
||||
pageSize={POLICY_TARGET_PAGE_SIZE}
|
||||
onPageChange={setPolicyTargetPage}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="panel">
|
||||
<div className="panel__toolbar">
|
||||
<div>
|
||||
<h2>{t("collect.jobs.listTitle")}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-list">
|
||||
<div className="filter-inline">
|
||||
<input
|
||||
type="search"
|
||||
value={jobKeyword}
|
||||
placeholder={t("collect.keywordPh")}
|
||||
onChange={(e) => {
|
||||
setJobKeyword(e.target.value);
|
||||
setJobPage(1);
|
||||
}}
|
||||
/>
|
||||
<select
|
||||
value={jobStatus}
|
||||
onChange={(e) => {
|
||||
setJobStatus(e.target.value);
|
||||
setJobPage(1);
|
||||
}}
|
||||
>
|
||||
<option value="">{t("collect.statusAll")}</option>
|
||||
{JOB_STATUS_OPTIONS.map((st) => (
|
||||
<option key={st} value={st}>
|
||||
{st}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!hasJobFilters}
|
||||
onClick={() => {
|
||||
setJobKeyword("");
|
||||
setJobStatus("");
|
||||
setJobPage(1);
|
||||
}}
|
||||
>
|
||||
{t("common.clearFilters")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{jobsQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
|
||||
{jobsQuery.isError ? <p className="error-text">{t("common.opFailed")}</p> : null}
|
||||
|
||||
{!(jobsQuery.data?.items ?? []).length && !jobsQuery.isLoading ? (
|
||||
<div className="pt-list-empty">
|
||||
<p>{t("common.empty")}</p>
|
||||
|
|
@ -347,23 +779,19 @@ export function CollectPage() {
|
|||
</table>
|
||||
</div>
|
||||
)}
|
||||
<div className="pager pt-list-pager">
|
||||
<div className="pager__meta muted">
|
||||
{t("common.pagerMeta", {
|
||||
total: String(jobTotal),
|
||||
page: String(jobPage),
|
||||
pages: String(jobPages),
|
||||
})}
|
||||
</div>
|
||||
<div className="pager__controls btn-row">
|
||||
<button className="pager__btn" disabled={jobPage <= 1} onClick={() => setJobPage(jobPage - 1)}>
|
||||
{t("common.prevPage")}
|
||||
</button>
|
||||
<button className="pager__btn" disabled={jobPage >= jobPages} onClick={() => setJobPage(jobPage + 1)}>
|
||||
{t("common.nextPage")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ListPager
|
||||
page={jobPage}
|
||||
pages={jobPages}
|
||||
total={jobTotal}
|
||||
pageSize={jobPageSize}
|
||||
pageSizeOptions={JOB_PAGE_SIZE_OPTIONS}
|
||||
onPageChange={setJobPage}
|
||||
onPageSizeChange={(size) => {
|
||||
setJobPageSize(size);
|
||||
setJobPage(1);
|
||||
}}
|
||||
disabled={jobsQuery.isLoading}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
|
@ -764,7 +1192,6 @@ function JobRow({
|
|||
);
|
||||
}
|
||||
|
||||
const RUN_PAGE_SIZE = 20;
|
||||
const RUN_STATUS_OPTIONS = ["pending", "running", "success", "fail", "cancelled"] as const;
|
||||
|
||||
function JobRunsPanel({
|
||||
|
|
@ -783,19 +1210,23 @@ function JobRunsPanel({
|
|||
retryPending: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { showOk, showError } = useToast();
|
||||
const [runPage, setRunPage] = useState(1);
|
||||
const [runPageSize, setRunPageSize] = useState(20);
|
||||
const [runStatus, setRunStatus] = useState("");
|
||||
const [runKeyword, setRunKeyword] = useState("");
|
||||
const [exportingRuns, setExportingRuns] = useState(false);
|
||||
const debouncedRunKeyword = useDebouncedValue(runKeyword, 300);
|
||||
|
||||
const runsQuery = useQuery({
|
||||
queryKey: queryKeys.neCollectionRuns(jobId, runPage, runStatus, runKeyword),
|
||||
queryKey: queryKeys.neCollectionRuns(jobId, runPage, runPageSize, runStatus, debouncedRunKeyword),
|
||||
queryFn: () =>
|
||||
fetchCollectionRuns({
|
||||
jobId,
|
||||
page: runPage,
|
||||
pageSize: RUN_PAGE_SIZE,
|
||||
pageSize: runPageSize,
|
||||
status: runStatus,
|
||||
keyword: runKeyword,
|
||||
keyword: debouncedRunKeyword,
|
||||
}),
|
||||
staleTime: 500,
|
||||
refetchInterval: jobStatus === "running" ? 2000 : false,
|
||||
|
|
@ -808,8 +1239,57 @@ function JobRunsPanel({
|
|||
};
|
||||
|
||||
const runTotal = runsQuery.data?.total ?? 0;
|
||||
const runPages = pageCount(runTotal, RUN_PAGE_SIZE);
|
||||
const runPages = pageCount(runTotal, runPageSize);
|
||||
const runs = runsQuery.data?.items ?? [];
|
||||
const hasRunFilters = Boolean(runStatus || runKeyword.trim());
|
||||
|
||||
const exportRunsCsv = async () => {
|
||||
setExportingRuns(true);
|
||||
try {
|
||||
const rows = await fetchAllPages<CollectionRunItem>({
|
||||
pageSize: 100,
|
||||
maxRows: 2000,
|
||||
fetchPage: (page, pageSize) =>
|
||||
fetchCollectionRuns({
|
||||
jobId,
|
||||
page,
|
||||
pageSize,
|
||||
status: runStatus,
|
||||
keyword: debouncedRunKeyword,
|
||||
}),
|
||||
});
|
||||
downloadCsv(
|
||||
`${t("collect.exportRunsName")}-${jobId.slice(0, 8)}-${new Date().toISOString().slice(0, 10)}.csv`,
|
||||
rows,
|
||||
[
|
||||
{ key: "ne_source", header: t("managedNe.col.source"), value: (r) => r.ne_source || "managed" },
|
||||
{ key: "ne_name", header: t("managedNe.col.name") },
|
||||
{ key: "ne_ip", header: t("managedNe.col.ip") },
|
||||
{ key: "status", header: t("collect.runs.status") },
|
||||
{ key: "message", header: t("collect.runs.message") },
|
||||
{
|
||||
key: "started_at",
|
||||
header: "started_at",
|
||||
value: (r) => (r.started_at ? formatSystemTime(r.started_at) : ""),
|
||||
},
|
||||
{
|
||||
key: "ended_at",
|
||||
header: "ended_at",
|
||||
value: (r) => (r.ended_at ? formatSystemTime(r.ended_at) : ""),
|
||||
},
|
||||
],
|
||||
);
|
||||
if (rows.length < runTotal) {
|
||||
showOk(t("common.exportTruncated", { count: String(rows.length), total: String(runTotal) }));
|
||||
} else {
|
||||
showOk(t("common.exportOk", { count: String(rows.length) }));
|
||||
}
|
||||
} catch (err) {
|
||||
showError(t("common.exportFailed") + ": " + formatErr(err));
|
||||
} finally {
|
||||
setExportingRuns(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -841,11 +1321,14 @@ function JobRunsPanel({
|
|||
</option>
|
||||
))}
|
||||
</select>
|
||||
{runStatus || runKeyword ? (
|
||||
{hasRunFilters ? (
|
||||
<button type="button" onClick={clearRunFilters}>
|
||||
{t("common.clearFilters")}
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" disabled={exportingRuns || runTotal === 0} onClick={() => void exportRunsCsv()}>
|
||||
{exportingRuns ? t("common.exporting") : t("common.exportCsv")}
|
||||
</button>
|
||||
{jobStatus !== "running" && failCount > 0 ? (
|
||||
<button type="button" disabled={retryPending} onClick={onRetryFailed}>
|
||||
{t("collect.jobs.retryFailed")}
|
||||
|
|
@ -907,21 +1390,19 @@ function JobRunsPanel({
|
|||
</div>
|
||||
</div>
|
||||
<div className="ops-detail-modal__foot">
|
||||
<span className="muted">
|
||||
{t("common.pagerMeta", {
|
||||
total: String(runTotal),
|
||||
page: String(runPage),
|
||||
pages: String(runPages),
|
||||
})}
|
||||
</span>
|
||||
<div className="btn-row">
|
||||
<button type="button" disabled={runPage <= 1} onClick={() => setRunPage(runPage - 1)}>
|
||||
{t("common.prevPage")}
|
||||
</button>
|
||||
<button type="button" disabled={runPage >= runPages} onClick={() => setRunPage(runPage + 1)}>
|
||||
{t("common.nextPage")}
|
||||
</button>
|
||||
</div>
|
||||
<ListPager
|
||||
page={runPage}
|
||||
pages={runPages}
|
||||
total={runTotal}
|
||||
pageSize={runPageSize}
|
||||
pageSizeOptions={RUN_PAGE_SIZE_OPTIONS}
|
||||
onPageChange={setRunPage}
|
||||
onPageSizeChange={(size) => {
|
||||
setRunPageSize(size);
|
||||
setRunPage(1);
|
||||
}}
|
||||
disabled={runsQuery.isLoading}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,20 +1,25 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { ListPager } from "../components/ListPager";
|
||||
import { queryKeys } from "../constants/queryKeys";
|
||||
import { useDebouncedValue } from "../hooks/useDebouncedValue";
|
||||
import { useToast } from "../hooks/useToast";
|
||||
import { useI18n } from "../i18n";
|
||||
import {
|
||||
createConfigSyncCycle,
|
||||
fetchCliTargets,
|
||||
fetchConfigSyncCycleTasks,
|
||||
fetchConfigSyncCycles,
|
||||
fetchConfigSyncDashboard,
|
||||
formatErr,
|
||||
pauseConfigSyncCycle,
|
||||
resumeConfigSyncCycle,
|
||||
stopConfigSyncCycle,
|
||||
updateConfigSyncPolicy,
|
||||
} from "../services/api";
|
||||
import { queryKeys } from "../constants/queryKeys";
|
||||
import { useI18n } from "../i18n";
|
||||
import { useToast } from "../hooks/useToast";
|
||||
import type { CliTargetItem, ConfigSyncTargetRef } from "../types";
|
||||
import type { CliTargetItem, ConfigSyncCycle, ConfigSyncTargetRef, ConfigSyncTask } from "../types";
|
||||
import { downloadCsv, fetchAllPages } from "../utils/csvExport";
|
||||
import { pageCount } from "../utils/display";
|
||||
import { formatSystemTime } from "../utils/time";
|
||||
|
||||
|
|
@ -25,12 +30,22 @@ export function ConfigSyncPage() {
|
|||
const { t } = useI18n();
|
||||
const { showOk, showError } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const [cyclePage, setCyclePage] = useState(1);
|
||||
const [cyclePageSize, setCyclePageSize] = useState(20);
|
||||
const [cycleStatus, setCycleStatus] = useState("");
|
||||
const [cycleKeyword, setCycleKeyword] = useState("");
|
||||
const [exportingCycles, setExportingCycles] = useState(false);
|
||||
const [expandedCycleId, setExpandedCycleId] = useState("");
|
||||
const [taskPage, setTaskPage] = useState(1);
|
||||
const [taskPageSize, setTaskPageSize] = useState(20);
|
||||
const [taskStatus, setTaskStatus] = useState("");
|
||||
const [taskKeyword, setTaskKeyword] = useState("");
|
||||
const [exportingTasks, setExportingTasks] = useState(false);
|
||||
|
||||
const debouncedCycleKeyword = useDebouncedValue(cycleKeyword, 300);
|
||||
const debouncedTaskKeyword = useDebouncedValue(taskKeyword, 300);
|
||||
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [intervalDays, setIntervalDays] = useState(3);
|
||||
|
|
@ -73,22 +88,52 @@ export function ConfigSyncPage() {
|
|||
setPolicyHydrated(true);
|
||||
}, [dashQuery.data, policyHydrated]);
|
||||
|
||||
useEffect(() => {
|
||||
const id = String(searchParams.get("cycle_id") || "").trim();
|
||||
if (!id) return;
|
||||
setExpandedCycleId(id);
|
||||
setTaskPage(1);
|
||||
setTaskPageSize(20);
|
||||
setTaskKeyword("");
|
||||
setTaskStatus("");
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
if (next.get("cycle_id") === id) next.delete("cycle_id");
|
||||
return next;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}, [searchParams, setSearchParams]);
|
||||
|
||||
const cyclesQuery = useQuery({
|
||||
queryKey: queryKeys.configSyncCycles(cyclePage),
|
||||
queryFn: () => fetchConfigSyncCycles({ page: cyclePage, pageSize: 10 }),
|
||||
queryKey: queryKeys.configSyncCycles(cyclePage, cyclePageSize, cycleStatus, debouncedCycleKeyword),
|
||||
queryFn: () =>
|
||||
fetchConfigSyncCycles({
|
||||
page: cyclePage,
|
||||
pageSize: cyclePageSize,
|
||||
status: cycleStatus,
|
||||
keyword: debouncedCycleKeyword,
|
||||
}),
|
||||
staleTime: 1000,
|
||||
refetchInterval: () => (dashQuery.data?.running_cycle ? POLL_MS : false),
|
||||
});
|
||||
|
||||
const tasksQuery = useQuery({
|
||||
queryKey: queryKeys.configSyncCycleTasks(expandedCycleId, taskPage, taskStatus, taskKeyword),
|
||||
queryKey: queryKeys.configSyncCycleTasks(
|
||||
expandedCycleId,
|
||||
taskPage,
|
||||
taskPageSize,
|
||||
taskStatus,
|
||||
debouncedTaskKeyword,
|
||||
),
|
||||
queryFn: () =>
|
||||
fetchConfigSyncCycleTasks({
|
||||
cycleId: expandedCycleId,
|
||||
page: taskPage,
|
||||
pageSize: 20,
|
||||
pageSize: taskPageSize,
|
||||
status: taskStatus,
|
||||
keyword: taskKeyword,
|
||||
keyword: debouncedTaskKeyword,
|
||||
}),
|
||||
enabled: Boolean(expandedCycleId),
|
||||
staleTime: 800,
|
||||
|
|
@ -144,7 +189,7 @@ export function ConfigSyncPage() {
|
|||
showOk(t("configSync.policySaved"));
|
||||
await refresh();
|
||||
},
|
||||
onError: (err) => showError(String(err)),
|
||||
onError: (err) => showError(formatErr(err)),
|
||||
});
|
||||
|
||||
const startMut = useMutation({
|
||||
|
|
@ -153,7 +198,7 @@ export function ConfigSyncPage() {
|
|||
showOk(t("configSync.started"));
|
||||
await refresh();
|
||||
},
|
||||
onError: (err) => showError(String(err)),
|
||||
onError: (err) => showError(formatErr(err)),
|
||||
});
|
||||
|
||||
const pauseMut = useMutation({
|
||||
|
|
@ -162,7 +207,7 @@ export function ConfigSyncPage() {
|
|||
showOk(t("configSync.paused"));
|
||||
await refresh();
|
||||
},
|
||||
onError: (err) => showError(String(err)),
|
||||
onError: (err) => showError(formatErr(err)),
|
||||
});
|
||||
|
||||
const resumeMut = useMutation({
|
||||
|
|
@ -171,7 +216,7 @@ export function ConfigSyncPage() {
|
|||
showOk(t("configSync.resumed"));
|
||||
await refresh();
|
||||
},
|
||||
onError: (err) => showError(String(err)),
|
||||
onError: (err) => showError(formatErr(err)),
|
||||
});
|
||||
|
||||
const stopMut = useMutation({
|
||||
|
|
@ -180,7 +225,7 @@ export function ConfigSyncPage() {
|
|||
showOk(t("configSync.stopped"));
|
||||
await refresh();
|
||||
},
|
||||
onError: (err) => showError(String(err)),
|
||||
onError: (err) => showError(formatErr(err)),
|
||||
});
|
||||
|
||||
const dash = dashQuery.data;
|
||||
|
|
@ -188,8 +233,110 @@ export function ConfigSyncPage() {
|
|||
const last = dash?.last_cycle;
|
||||
const cycles = cyclesQuery.data?.items ?? [];
|
||||
const cycleTotal = Number(cyclesQuery.data?.total || 0);
|
||||
const cyclePages = pageCount(cycleTotal, 10);
|
||||
const cyclePages = pageCount(cycleTotal, cyclePageSize);
|
||||
const taskTotal = Number(tasksQuery.data?.total || 0);
|
||||
const taskPages = pageCount(taskTotal, taskPageSize);
|
||||
const selectedCount = useMemo(() => Object.keys(selectedMap).length, [selectedMap]);
|
||||
const hasCycleFilters = Boolean(cycleKeyword.trim() || cycleStatus);
|
||||
|
||||
const openCycleTasks = (cycleId: string) => {
|
||||
setExpandedCycleId(cycleId);
|
||||
setTaskPage(1);
|
||||
setTaskKeyword("");
|
||||
setTaskStatus("");
|
||||
};
|
||||
|
||||
const exportCyclesCsv = async () => {
|
||||
setExportingCycles(true);
|
||||
try {
|
||||
const rows = await fetchAllPages<ConfigSyncCycle>({
|
||||
pageSize: 100,
|
||||
maxRows: 2000,
|
||||
fetchPage: (p, ps) =>
|
||||
fetchConfigSyncCycles({
|
||||
page: p,
|
||||
pageSize: ps,
|
||||
status: cycleStatus,
|
||||
keyword: debouncedCycleKeyword,
|
||||
}),
|
||||
});
|
||||
downloadCsv(
|
||||
`${t("configSync.exportCyclesName")}-${new Date().toISOString().slice(0, 10)}.csv`,
|
||||
rows,
|
||||
[
|
||||
{ key: "id", header: "ID" },
|
||||
{ key: "trigger_mode", header: t("configSync.col.trigger") },
|
||||
{ key: "status", header: t("configSync.col.status") },
|
||||
{
|
||||
key: "progress",
|
||||
header: t("configSync.col.progress"),
|
||||
value: (r) => `${r.success_count}/${r.planned_count} fail ${r.fail_count}`,
|
||||
},
|
||||
{
|
||||
key: "started_at",
|
||||
header: t("configSync.col.started"),
|
||||
value: (r) => (r.started_at ? formatSystemTime(r.started_at) : ""),
|
||||
},
|
||||
{
|
||||
key: "ended_at",
|
||||
header: t("configSync.col.ended"),
|
||||
value: (r) => (r.ended_at ? formatSystemTime(r.ended_at) : ""),
|
||||
},
|
||||
{ key: "error_message", header: t("configSync.col.message") },
|
||||
],
|
||||
);
|
||||
if (rows.length < cycleTotal) {
|
||||
showOk(t("common.exportTruncated", { count: String(rows.length), total: String(cycleTotal) }));
|
||||
} else {
|
||||
showOk(t("common.exportOk", { count: String(rows.length) }));
|
||||
}
|
||||
} catch (err) {
|
||||
showError(t("common.exportFailed") + ": " + formatErr(err));
|
||||
} finally {
|
||||
setExportingCycles(false);
|
||||
}
|
||||
};
|
||||
|
||||
const exportTasksCsv = async () => {
|
||||
if (!expandedCycleId) return;
|
||||
setExportingTasks(true);
|
||||
try {
|
||||
const rows = await fetchAllPages<ConfigSyncTask>({
|
||||
pageSize: 200,
|
||||
maxRows: 2000,
|
||||
fetchPage: (p, ps) =>
|
||||
fetchConfigSyncCycleTasks({
|
||||
cycleId: expandedCycleId,
|
||||
page: p,
|
||||
pageSize: ps,
|
||||
status: taskStatus,
|
||||
keyword: debouncedTaskKeyword,
|
||||
}),
|
||||
});
|
||||
downloadCsv(
|
||||
`${t("configSync.exportTasksName")}-${expandedCycleId.slice(0, 8)}-${new Date().toISOString().slice(0, 10)}.csv`,
|
||||
rows,
|
||||
[
|
||||
{ key: "ne_name", header: t("configSync.col.name"), value: (r) => r.ne_name || r.target_id },
|
||||
{ key: "ne_ip", header: "IP" },
|
||||
{ key: "vendor", header: t("configSync.col.vendor") },
|
||||
{ key: "source", header: t("configSync.col.source") },
|
||||
{ key: "status", header: t("configSync.col.status") },
|
||||
{ key: "message", header: t("configSync.col.message") },
|
||||
{ key: "target_id", header: "ID" },
|
||||
],
|
||||
);
|
||||
if (rows.length < taskTotal) {
|
||||
showOk(t("common.exportTruncated", { count: String(rows.length), total: String(taskTotal) }));
|
||||
} else {
|
||||
showOk(t("common.exportOk", { count: String(rows.length) }));
|
||||
}
|
||||
} catch (err) {
|
||||
showError(t("common.exportFailed") + ": " + formatErr(err));
|
||||
} finally {
|
||||
setExportingTasks(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleTarget = (row: CliTargetItem) => {
|
||||
const source = row.source === "ume" ? "ume" : "managed";
|
||||
|
|
@ -416,114 +563,155 @@ export function ConfigSyncPage() {
|
|||
</div>
|
||||
|
||||
<h3>{t("configSync.cyclesTitle")}</h3>
|
||||
<div className="pt-list-table-wrap">
|
||||
<table className="data-table pt-list-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th />
|
||||
<th>ID</th>
|
||||
<th>{t("configSync.col.trigger")}</th>
|
||||
<th>{t("configSync.col.status")}</th>
|
||||
<th>{t("configSync.col.progress")}</th>
|
||||
<th>{t("configSync.col.started")}</th>
|
||||
<th>{t("configSync.col.ended")}</th>
|
||||
<th>{t("configSync.col.actions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{cycles.map((c) => (
|
||||
<tr key={c.id}>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--sm btn--ghost"
|
||||
onClick={() => {
|
||||
setExpandedCycleId(c.id);
|
||||
setTaskPage(1);
|
||||
setTaskKeyword("");
|
||||
setTaskStatus("");
|
||||
}}
|
||||
>
|
||||
{t("configSync.expand")}
|
||||
</button>
|
||||
</td>
|
||||
<td title={c.id} className="pt-list-num">{c.id.slice(0, 8)}</td>
|
||||
<td>{c.trigger_mode}</td>
|
||||
<td>
|
||||
<span
|
||||
className={`pt-list-status ${
|
||||
c.status === "running" || c.status === "pending"
|
||||
? "pt-list-status--running"
|
||||
: c.status === "paused"
|
||||
? "pt-list-status--paused"
|
||||
: c.status === "failed"
|
||||
? "pt-list-status--failed"
|
||||
: c.status === "success" || c.status === "completed"
|
||||
? "pt-list-status--ok"
|
||||
: "pt-list-status--other"
|
||||
}`}
|
||||
>
|
||||
{c.status}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{c.success_count}/{c.planned_count} · fail {c.fail_count}
|
||||
</td>
|
||||
<td>{c.started_at ? formatSystemTime(c.started_at) : "-"}</td>
|
||||
<td>{c.ended_at ? formatSystemTime(c.ended_at) : "-"}</td>
|
||||
<td>
|
||||
<div className="btn-row">
|
||||
{c.status === "running" || c.status === "pending" ? (
|
||||
<button type="button" onClick={() => pauseMut.mutate(c.id)} disabled={pauseMut.isPending}>
|
||||
{t("configSync.pause")}
|
||||
</button>
|
||||
) : null}
|
||||
{c.status === "paused" ? (
|
||||
<button type="button" onClick={() => resumeMut.mutate(c.id)} disabled={resumeMut.isPending}>
|
||||
{t("configSync.resume")}
|
||||
</button>
|
||||
) : null}
|
||||
{c.status === "running" || c.status === "paused" || c.status === "pending" ? (
|
||||
<div className="pt-list">
|
||||
<div className="filter-inline">
|
||||
<input
|
||||
value={cycleKeyword}
|
||||
placeholder={t("configSync.keywordPh")}
|
||||
onChange={(e) => {
|
||||
setCycleKeyword(e.target.value);
|
||||
setCyclePage(1);
|
||||
}}
|
||||
/>
|
||||
<select
|
||||
value={cycleStatus}
|
||||
onChange={(e) => {
|
||||
setCycleStatus(e.target.value);
|
||||
setCyclePage(1);
|
||||
}}
|
||||
>
|
||||
<option value="">{t("configSync.statusAll")}</option>
|
||||
<option value="pending">pending</option>
|
||||
<option value="running">running</option>
|
||||
<option value="paused">paused</option>
|
||||
<option value="success">success</option>
|
||||
<option value="completed">completed</option>
|
||||
<option value="failed">failed</option>
|
||||
<option value="stopped">stopped</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!hasCycleFilters}
|
||||
onClick={() => {
|
||||
setCycleKeyword("");
|
||||
setCycleStatus("");
|
||||
setCyclePage(1);
|
||||
}}
|
||||
>
|
||||
{t("common.clearFilters")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={exportingCycles || cycleTotal === 0}
|
||||
onClick={() => void exportCyclesCsv()}
|
||||
>
|
||||
{exportingCycles ? t("common.exporting") : t("common.exportCsv")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{cyclesQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
|
||||
{cyclesQuery.isError ? <p className="error-text">{formatErr(cyclesQuery.error)}</p> : null}
|
||||
|
||||
<div className="pt-list-table-wrap">
|
||||
<table className="data-table pt-list-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th />
|
||||
<th>ID</th>
|
||||
<th>{t("configSync.col.trigger")}</th>
|
||||
<th>{t("configSync.col.status")}</th>
|
||||
<th>{t("configSync.col.progress")}</th>
|
||||
<th>{t("configSync.col.started")}</th>
|
||||
<th>{t("configSync.col.ended")}</th>
|
||||
<th>{t("configSync.col.actions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{cycles.map((c) => (
|
||||
<tr key={c.id}>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (window.confirm(t("configSync.confirmStop"))) stopMut.mutate(c.id);
|
||||
}}
|
||||
disabled={stopMut.isPending}
|
||||
className="btn btn--sm btn--ghost"
|
||||
onClick={() => openCycleTasks(c.id)}
|
||||
>
|
||||
{t("configSync.stop")}
|
||||
{t("configSync.expand")}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{!cycles.length ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="muted">
|
||||
{t("configSync.cyclesEmpty")}
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="pager pt-list-pager">
|
||||
<span className="muted">
|
||||
{t("common.pagerMeta", {
|
||||
total: String(cycleTotal),
|
||||
page: String(cyclePage),
|
||||
pages: String(cyclePages),
|
||||
})}
|
||||
</span>
|
||||
<div className="btn-row">
|
||||
<button type="button" disabled={cyclePage <= 1} onClick={() => setCyclePage((p) => p - 1)}>
|
||||
{t("common.prevPage")}
|
||||
</button>
|
||||
<button type="button" disabled={cyclePage >= cyclePages} onClick={() => setCyclePage((p) => p + 1)}>
|
||||
{t("common.nextPage")}
|
||||
</button>
|
||||
</td>
|
||||
<td title={c.id} className="pt-list-num">{c.id.slice(0, 8)}</td>
|
||||
<td>{c.trigger_mode}</td>
|
||||
<td>
|
||||
<span
|
||||
className={`pt-list-status ${
|
||||
c.status === "running" || c.status === "pending"
|
||||
? "pt-list-status--running"
|
||||
: c.status === "paused"
|
||||
? "pt-list-status--paused"
|
||||
: c.status === "failed"
|
||||
? "pt-list-status--failed"
|
||||
: c.status === "success" || c.status === "completed"
|
||||
? "pt-list-status--ok"
|
||||
: "pt-list-status--other"
|
||||
}`}
|
||||
>
|
||||
{c.status}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{c.success_count}/{c.planned_count} · fail {c.fail_count}
|
||||
</td>
|
||||
<td>{c.started_at ? formatSystemTime(c.started_at) : "-"}</td>
|
||||
<td>{c.ended_at ? formatSystemTime(c.ended_at) : "-"}</td>
|
||||
<td>
|
||||
<div className="btn-row">
|
||||
{c.status === "running" || c.status === "pending" ? (
|
||||
<button type="button" onClick={() => pauseMut.mutate(c.id)} disabled={pauseMut.isPending}>
|
||||
{t("configSync.pause")}
|
||||
</button>
|
||||
) : null}
|
||||
{c.status === "paused" ? (
|
||||
<button type="button" onClick={() => resumeMut.mutate(c.id)} disabled={resumeMut.isPending}>
|
||||
{t("configSync.resume")}
|
||||
</button>
|
||||
) : null}
|
||||
{c.status === "running" || c.status === "paused" || c.status === "pending" ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (window.confirm(t("configSync.confirmStop"))) stopMut.mutate(c.id);
|
||||
}}
|
||||
disabled={stopMut.isPending}
|
||||
>
|
||||
{t("configSync.stop")}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{!cycles.length && !cyclesQuery.isLoading ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="muted">
|
||||
{t("configSync.cyclesEmpty")}
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<ListPager
|
||||
page={cyclePage}
|
||||
pages={cyclePages}
|
||||
total={cycleTotal}
|
||||
pageSize={cyclePageSize}
|
||||
pageSizeOptions={[20, 50, 100]}
|
||||
onPageChange={setCyclePage}
|
||||
onPageSizeChange={(size) => {
|
||||
setCyclePageSize(size);
|
||||
setCyclePage(1);
|
||||
}}
|
||||
disabled={cyclesQuery.isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{expandedCycleId ? (
|
||||
|
|
@ -553,6 +741,13 @@ export function ConfigSyncPage() {
|
|||
</p>
|
||||
</div>
|
||||
<div className="btn-row ops-detail-modal__actions">
|
||||
<button
|
||||
type="button"
|
||||
disabled={exportingTasks || taskTotal === 0}
|
||||
onClick={() => void exportTasksCsv()}
|
||||
>
|
||||
{exportingTasks ? t("common.exporting") : t("common.exportCsv")}
|
||||
</button>
|
||||
<button type="button" onClick={() => setExpandedCycleId("")}>
|
||||
{t("networkConfigs.close")}
|
||||
</button>
|
||||
|
|
@ -575,7 +770,7 @@ export function ConfigSyncPage() {
|
|||
setTaskPage(1);
|
||||
}}
|
||||
>
|
||||
<option value="">{t("configSync.allStatus")}</option>
|
||||
<option value="">{t("configSync.statusAll")}</option>
|
||||
<option value="pending">pending</option>
|
||||
<option value="running">running</option>
|
||||
<option value="success">success</option>
|
||||
|
|
@ -586,9 +781,7 @@ export function ConfigSyncPage() {
|
|||
|
||||
{tasksQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
|
||||
{tasksQuery.isError ? (
|
||||
<p className="ops-detail-modal__error">
|
||||
{tasksQuery.error instanceof Error ? tasksQuery.error.message : String(tasksQuery.error)}
|
||||
</p>
|
||||
<p className="ops-detail-modal__error">{formatErr(tasksQuery.error)}</p>
|
||||
) : null}
|
||||
|
||||
<div className="ops-detail-modal__scroll">
|
||||
|
|
@ -628,25 +821,19 @@ export function ConfigSyncPage() {
|
|||
</div>
|
||||
|
||||
<div className="ops-detail-modal__foot">
|
||||
<span className="muted">
|
||||
{t("common.pagerMeta", {
|
||||
total: String(tasksQuery.data?.total ?? 0),
|
||||
page: String(taskPage),
|
||||
pages: String(pageCount(Number(tasksQuery.data?.total || 0), 20)),
|
||||
})}
|
||||
</span>
|
||||
<div className="btn-row">
|
||||
<button type="button" disabled={taskPage <= 1} onClick={() => setTaskPage((p) => p - 1)}>
|
||||
{t("common.prevPage")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={taskPage >= pageCount(Number(tasksQuery.data?.total || 0), 20)}
|
||||
onClick={() => setTaskPage((p) => p + 1)}
|
||||
>
|
||||
{t("common.nextPage")}
|
||||
</button>
|
||||
</div>
|
||||
<ListPager
|
||||
page={taskPage}
|
||||
pages={taskPages}
|
||||
total={taskTotal}
|
||||
pageSize={taskPageSize}
|
||||
pageSizeOptions={[20, 50, 100, 200]}
|
||||
onPageChange={setTaskPage}
|
||||
onPageSizeChange={(size) => {
|
||||
setTaskPageSize(size);
|
||||
setTaskPage(1);
|
||||
}}
|
||||
disabled={tasksQuery.isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -89,6 +89,8 @@ import {
|
|||
import { queryKeys } from "../constants/queryKeys";
|
||||
import { useI18n } from "../i18n";
|
||||
import { useToast } from "../hooks/useToast";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { formatErr } from "../services/api";
|
||||
import { openOrFocusModule } from "../utils/moduleWindows";
|
||||
import type {
|
||||
FabricNodeSearchHit,
|
||||
|
|
@ -114,6 +116,8 @@ const TopologyReactFlowView = lazy(() => import("./topology/TopologyReactFlowVie
|
|||
export function TopologyPage() {
|
||||
const { t, locale } = useI18n();
|
||||
const { showOk, showError } = useToast();
|
||||
const { hasScope, isAdmin } = useAuth();
|
||||
const canWriteTopology = isAdmin || hasScope("ne:write");
|
||||
const queryClient = useQueryClient();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [keyword, setKeyword] = useState("");
|
||||
|
|
@ -295,7 +299,13 @@ export function TopologyPage() {
|
|||
return () => window.removeEventListener("beforeunload", onBeforeUnload);
|
||||
}, []);
|
||||
|
||||
const toolBehavior = useMemo(() => behaviorForMode(toolMode), [toolMode]);
|
||||
const toolBehavior = useMemo(() => {
|
||||
const base = behaviorForMode(toolMode);
|
||||
if (!canWriteTopology) {
|
||||
return { ...base, nodesDraggable: false, nodesConnectable: false };
|
||||
}
|
||||
return base;
|
||||
}, [toolMode, canWriteTopology]);
|
||||
|
||||
const canvas = useTopologyCanvas({
|
||||
mapId,
|
||||
|
|
@ -354,6 +364,8 @@ export function TopologyPage() {
|
|||
worldScatter,
|
||||
showWorldScatter,
|
||||
canvasGraphLoading,
|
||||
canvasGraphError,
|
||||
canvasGraphErrorMsg,
|
||||
canvasGraphEmpty,
|
||||
worldNeedsApply,
|
||||
canvasGraphRefreshing,
|
||||
|
|
@ -537,7 +549,10 @@ export function TopologyPage() {
|
|||
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyMaps });
|
||||
showOk(t("topology.saved"));
|
||||
},
|
||||
onError: (err) => showError(String(err)),
|
||||
onError: async (err) => {
|
||||
showError(t("topology.saveFailed").replace("{{detail}}", formatErr(err)));
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyGraph(mapId) });
|
||||
},
|
||||
});
|
||||
|
||||
const isValidConnection = useCallback(
|
||||
|
|
@ -545,14 +560,18 @@ export function TopologyPage() {
|
|||
const source = String(connection.source || "");
|
||||
const target = String(connection.target || "");
|
||||
if (!source || !target || source === target) return false;
|
||||
return !edges.some((e) => e.source === source && e.target === target);
|
||||
return !edges.some(
|
||||
(e) =>
|
||||
(e.source === source && e.target === target) ||
|
||||
(e.source === target && e.target === source),
|
||||
);
|
||||
},
|
||||
[edges],
|
||||
);
|
||||
|
||||
const onConnect = useCallback(
|
||||
(connection: Connection) => {
|
||||
if (!mapId || !isValidConnection(connection)) return;
|
||||
if (!canWriteTopology || !mapId || !isValidConnection(connection)) return;
|
||||
const source = String(connection.source || "");
|
||||
const target = String(connection.target || "");
|
||||
if (!source || !target) return;
|
||||
|
|
@ -586,11 +605,12 @@ export function TopologyPage() {
|
|||
setEdges((es) => [...es, edge]);
|
||||
markDirty();
|
||||
},
|
||||
[mapId, isValidConnection, pushHistory, edgeDefaults, setEdges, markDirty],
|
||||
[mapId, isValidConnection, pushHistory, edgeDefaults, setEdges, markDirty, canWriteTopology],
|
||||
);
|
||||
|
||||
const addPaletteItems = useCallback(
|
||||
async (items: PaletteItem[], origin?: { x: number; y: number }): Promise<boolean> => {
|
||||
if (!canWriteTopology) return false;
|
||||
if (!mapId) {
|
||||
showError(t("topology.selectMap"));
|
||||
return false;
|
||||
|
|
@ -700,6 +720,7 @@ export function TopologyPage() {
|
|||
|
||||
const onCanvasDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
if (!canWriteTopology) return;
|
||||
const raw = e.dataTransfer.getData(PALETTE_DND);
|
||||
if (!raw || !rfRef.current) return;
|
||||
try {
|
||||
|
|
@ -1076,7 +1097,7 @@ export function TopologyPage() {
|
|||
}
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "s") {
|
||||
e.preventDefault();
|
||||
if (mapId && dirtyRef.current && !saveMut.isPending) saveMut.mutate();
|
||||
if (canWriteTopology && mapId && dirtyRef.current && !saveMut.isPending) saveMut.mutate();
|
||||
return;
|
||||
}
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "z" && !e.shiftKey) {
|
||||
|
|
@ -1095,6 +1116,7 @@ export function TopologyPage() {
|
|||
return;
|
||||
}
|
||||
if (e.key === "Delete" || e.key === "Backspace") {
|
||||
if (!canWriteTopology) return;
|
||||
if (nodes.some((n) => n.selected) || edges.some((ed) => ed.selected)) {
|
||||
e.preventDefault();
|
||||
void removeSelected();
|
||||
|
|
@ -1103,7 +1125,7 @@ export function TopologyPage() {
|
|||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [closeCtxMenu, clearSelection, undo, redo, selectAllNodes, removeSelected, nodes, edges, mapId, saveMut]);
|
||||
}, [closeCtxMenu, clearSelection, undo, redo, selectAllNodes, removeSelected, nodes, edges, mapId, saveMut, canWriteTopology]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ctxMenu) return;
|
||||
|
|
@ -1366,6 +1388,10 @@ export function TopologyPage() {
|
|||
|
||||
const discoverOneFor = (node: Node<NeNodeData> | null) => {
|
||||
closeCtxMenu();
|
||||
if (!canWriteTopology) {
|
||||
showError(t("topology.readOnlyHint"));
|
||||
return;
|
||||
}
|
||||
const id = String(node?.data.managed_ne_id || node?.data.ume_ne_id || "").trim();
|
||||
if (!id) {
|
||||
showError(t("topology.discoverOneNeedNe"));
|
||||
|
|
@ -1917,6 +1943,7 @@ export function TopologyPage() {
|
|||
<main className="topo-main">
|
||||
{canvasMode ? (
|
||||
<TopologyToolbar
|
||||
readOnly={!canWriteTopology}
|
||||
breadcrumbFolders={breadcrumbFolders}
|
||||
activeView={activeView}
|
||||
activeRegion={activeRegion}
|
||||
|
|
@ -2051,6 +2078,11 @@ export function TopologyPage() {
|
|||
{truncateBannerText}
|
||||
</div>
|
||||
) : null}
|
||||
{!canWriteTopology && mapId ? (
|
||||
<div className="topo-readonly-banner" role="status">
|
||||
{t("topology.readOnlyBanner")}
|
||||
</div>
|
||||
) : null}
|
||||
<TopologyViewTools
|
||||
fullscreen={fullscreen}
|
||||
toolbarSlot={viewToolsToolbarSlot}
|
||||
|
|
@ -2248,6 +2280,23 @@ export function TopologyPage() {
|
|||
</button>
|
||||
</div>
|
||||
)}
|
||||
{treeRoot && canvasGraphError ? (
|
||||
<div className="topo-canvas__overlay topo-canvas__overlay--error" role="alert">
|
||||
<p className="topo-canvas__overlay-title">{t("topology.graphLoadFailed")}</p>
|
||||
{canvasGraphErrorMsg ? (
|
||||
<p className="topo-canvas__overlay-hint muted">{canvasGraphErrorMsg}</p>
|
||||
) : null}
|
||||
<div className="topo-browser__empty-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--sm btn--ghost"
|
||||
onClick={() => void graphQuery.refetch()}
|
||||
>
|
||||
{t("topology.treeRetry")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{treeRoot && canvasGraphLoading ? (
|
||||
<div className="topo-canvas__overlay topo-canvas__overlay--loading" role="status">
|
||||
<span className="topo-loading-spinner topo-loading-spinner--lg" aria-hidden="true" />
|
||||
|
|
|
|||
|
|
@ -1,23 +1,33 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { ListPager } from "../../components/ListPager";
|
||||
import { HelpHint } from "../../components/HelpHint";
|
||||
import { queryKeys } from "../../constants/queryKeys";
|
||||
import { useDebouncedValue } from "../../hooks/useDebouncedValue";
|
||||
import { useToast } from "../../hooks/useToast";
|
||||
import { useI18n } from "../../i18n";
|
||||
import {
|
||||
fetchCliTargets,
|
||||
fetchFabricEdges,
|
||||
fetchLldpCollectDashboard,
|
||||
fetchLldpCollectJob,
|
||||
fetchLldpCollectJobs,
|
||||
formatErr,
|
||||
pauseLldpCollectJob,
|
||||
resumeLldpCollectJob,
|
||||
startLldpCollect,
|
||||
stopLldpCollectJob,
|
||||
updateLldpCollectPolicy,
|
||||
} from "../../services/api";
|
||||
import { queryKeys } from "../../constants/queryKeys";
|
||||
import { HelpHint } from "../../components/HelpHint";
|
||||
import { useI18n } from "../../i18n";
|
||||
import { useToast } from "../../hooks/useToast";
|
||||
import type { CliTargetItem, ConfigSyncTargetRef, TopologyDiscoverJobItem } from "../../types";
|
||||
import type {
|
||||
CliTargetItem,
|
||||
ConfigSyncTargetRef,
|
||||
FabricEdge,
|
||||
LldpCollectJobSummary,
|
||||
TopologyDiscoverJobItem,
|
||||
} from "../../types";
|
||||
import { downloadCsv, fetchAllPages } from "../../utils/csvExport";
|
||||
import { pageCount } from "../../utils/display";
|
||||
import { formatSystemTime } from "../../utils/time";
|
||||
|
||||
|
|
@ -89,15 +99,27 @@ export function LldpLinksPage() {
|
|||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const [jobPage, setJobPage] = useState(1);
|
||||
const [jobPageSize, setJobPageSize] = useState(20);
|
||||
const [jobStatus, setJobStatus] = useState("");
|
||||
const [jobKeyword, setJobKeyword] = useState("");
|
||||
const [exportingJobs, setExportingJobs] = useState(false);
|
||||
const debouncedJobKeyword = useDebouncedValue(jobKeyword, 300);
|
||||
const [expandedJobId, setExpandedJobId] = useState("");
|
||||
const [itemPage, setItemPage] = useState(1);
|
||||
const [itemPageSize, setItemPageSize] = useState(20);
|
||||
const [itemStatus, setItemStatus] = useState("");
|
||||
const [itemKeyword, setItemKeyword] = useState("");
|
||||
const [exportingItems, setExportingItems] = useState(false);
|
||||
const debouncedItemKeyword = useDebouncedValue(itemKeyword, 300);
|
||||
const [itemDetail, setItemDetail] = useState<TopologyDiscoverJobItem | null>(null);
|
||||
const ITEM_PAGE_SIZE = 20;
|
||||
|
||||
const [edgeStatus, setEdgeStatus] = useState<"all" | "active" | "missing">("all");
|
||||
const [edgeSource, setEdgeSource] = useState("");
|
||||
const [edgeKeyword, setEdgeKeyword] = useState("");
|
||||
const [edgePage, setEdgePage] = useState(1);
|
||||
const EDGE_PAGE_SIZE = 20;
|
||||
const [edgePageSize, setEdgePageSize] = useState(50);
|
||||
const [exportingEdges, setExportingEdges] = useState(false);
|
||||
const debouncedEdgeKeyword = useDebouncedValue(edgeKeyword, 300);
|
||||
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [intervalValue, setIntervalValue] = useState(1);
|
||||
|
|
@ -156,6 +178,8 @@ export function LldpLinksPage() {
|
|||
if (!jobId) return;
|
||||
setItemDetail(null);
|
||||
setItemPage(1);
|
||||
setItemStatus("");
|
||||
setItemKeyword("");
|
||||
setExpandedJobId(jobId);
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
|
|
@ -168,15 +192,33 @@ export function LldpLinksPage() {
|
|||
}, [searchParams, setSearchParams]);
|
||||
|
||||
const jobsQuery = useQuery({
|
||||
queryKey: queryKeys.lldpCollectJobs(jobPage),
|
||||
queryFn: () => fetchLldpCollectJobs({ page: jobPage, pageSize: 10 }),
|
||||
queryKey: queryKeys.lldpCollectJobs(jobPage, jobPageSize, jobStatus, debouncedJobKeyword),
|
||||
queryFn: () =>
|
||||
fetchLldpCollectJobs({
|
||||
page: jobPage,
|
||||
pageSize: jobPageSize,
|
||||
status: jobStatus,
|
||||
keyword: debouncedJobKeyword,
|
||||
}),
|
||||
staleTime: 1000,
|
||||
refetchInterval: () => (dashQuery.data?.running_job ? POLL_MS : false),
|
||||
});
|
||||
|
||||
const jobDetailQuery = useQuery({
|
||||
queryKey: queryKeys.lldpCollectJob(expandedJobId, itemPage),
|
||||
queryFn: () => fetchLldpCollectJob(expandedJobId, { page: itemPage, pageSize: ITEM_PAGE_SIZE }),
|
||||
queryKey: queryKeys.lldpCollectJob(
|
||||
expandedJobId,
|
||||
itemPage,
|
||||
itemPageSize,
|
||||
itemStatus,
|
||||
debouncedItemKeyword,
|
||||
),
|
||||
queryFn: () =>
|
||||
fetchLldpCollectJob(expandedJobId, {
|
||||
page: itemPage,
|
||||
pageSize: itemPageSize,
|
||||
status: itemStatus,
|
||||
keyword: debouncedItemKeyword,
|
||||
}),
|
||||
enabled: Boolean(expandedJobId),
|
||||
staleTime: 800,
|
||||
refetchInterval: () => (dashQuery.data?.running_job?.id === expandedJobId ? POLL_MS : false),
|
||||
|
|
@ -191,13 +233,20 @@ export function LldpLinksPage() {
|
|||
});
|
||||
|
||||
const edgesQuery = useQuery({
|
||||
queryKey: queryKeys.fabricEdges(edgeStatus, edgeKeyword, edgePage),
|
||||
queryKey: queryKeys.fabricEdges(
|
||||
edgeStatus,
|
||||
debouncedEdgeKeyword,
|
||||
edgePage,
|
||||
edgePageSize,
|
||||
edgeSource,
|
||||
),
|
||||
queryFn: () =>
|
||||
fetchFabricEdges({
|
||||
status: edgeStatus === "all" ? "" : edgeStatus,
|
||||
keyword: edgeKeyword,
|
||||
keyword: debouncedEdgeKeyword,
|
||||
source: edgeSource || undefined,
|
||||
page: edgePage,
|
||||
pageSize: EDGE_PAGE_SIZE,
|
||||
pageSize: edgePageSize,
|
||||
}),
|
||||
staleTime: 2000,
|
||||
refetchInterval: () => (dashQuery.data?.running_job ? POLL_MS : false),
|
||||
|
|
@ -302,14 +351,192 @@ export function LldpLinksPage() {
|
|||
const last = dash?.last_job;
|
||||
const jobs = jobsQuery.data?.items ?? [];
|
||||
const jobTotal = Number(jobsQuery.data?.total || 0);
|
||||
const jobPages = pageCount(jobTotal, 10);
|
||||
const jobPages = pageCount(jobTotal, jobPageSize);
|
||||
const selectedCount = useMemo(() => Object.keys(selectedMap).length, [selectedMap]);
|
||||
const detailItems = jobDetailQuery.data?.items ?? [];
|
||||
const itemTotal = Number(jobDetailQuery.data?.items_total ?? detailItems.length);
|
||||
const itemPages = pageCount(itemTotal, ITEM_PAGE_SIZE);
|
||||
const itemPages = pageCount(itemTotal, itemPageSize);
|
||||
const edgeItems = edgesQuery.data?.items ?? [];
|
||||
const edgeTotal = Number(edgesQuery.data?.total || 0);
|
||||
const edgePages = pageCount(edgeTotal, EDGE_PAGE_SIZE);
|
||||
const edgePages = pageCount(edgeTotal, edgePageSize);
|
||||
|
||||
const exportEdgesCsv = async () => {
|
||||
setExportingEdges(true);
|
||||
try {
|
||||
const rows = await fetchAllPages<FabricEdge>({
|
||||
pageSize: 200,
|
||||
maxRows: 2000,
|
||||
fetchPage: (p, ps) =>
|
||||
fetchFabricEdges({
|
||||
status: edgeStatus === "all" ? "" : edgeStatus,
|
||||
keyword: debouncedEdgeKeyword,
|
||||
source: edgeSource || undefined,
|
||||
page: p,
|
||||
pageSize: ps,
|
||||
}),
|
||||
});
|
||||
downloadCsv(
|
||||
`${t("lldpLinks.exportEdgesName")}-${new Date().toISOString().slice(0, 10)}.csv`,
|
||||
rows,
|
||||
[
|
||||
{
|
||||
key: "status",
|
||||
header: t("lldpLinks.col.status"),
|
||||
value: (e) => lldpEdgeStatusLabel(t, e.status === "stale" ? "missing" : e.status),
|
||||
},
|
||||
{
|
||||
key: "a",
|
||||
header: t("lldpLinks.col.aSide"),
|
||||
value: (e) => e.a_name || e.a_ip || e.a_node_id,
|
||||
},
|
||||
{ key: "a_port", header: t("lldpLinks.col.aPort") },
|
||||
{
|
||||
key: "b",
|
||||
header: t("lldpLinks.col.bSide"),
|
||||
value: (e) => e.b_name || e.b_ip || e.b_node_id,
|
||||
},
|
||||
{ key: "b_port", header: t("lldpLinks.col.bPort") },
|
||||
{
|
||||
key: "source",
|
||||
header: t("lldpLinks.col.source"),
|
||||
value: (e) => lldpEdgeSourceLabel(t, e.source),
|
||||
},
|
||||
{
|
||||
key: "last_seen_at",
|
||||
header: t("lldpLinks.col.lastSeen"),
|
||||
value: (e) => (e.last_seen_at ? formatSystemTime(e.last_seen_at) : ""),
|
||||
},
|
||||
{ key: "id", header: "ID" },
|
||||
],
|
||||
);
|
||||
if (rows.length < edgeTotal) {
|
||||
showOk(t("common.exportTruncated", { count: String(rows.length), total: String(edgeTotal) }));
|
||||
} else {
|
||||
showOk(t("common.exportOk", { count: String(rows.length) }));
|
||||
}
|
||||
} catch (err) {
|
||||
showError(t("common.exportFailed") + ": " + formatErr(err));
|
||||
} finally {
|
||||
setExportingEdges(false);
|
||||
}
|
||||
};
|
||||
|
||||
const exportJobsCsv = async () => {
|
||||
setExportingJobs(true);
|
||||
try {
|
||||
const rows = await fetchAllPages<LldpCollectJobSummary>({
|
||||
pageSize: 100,
|
||||
maxRows: 2000,
|
||||
fetchPage: (p, ps) =>
|
||||
fetchLldpCollectJobs({
|
||||
page: p,
|
||||
pageSize: ps,
|
||||
status: jobStatus,
|
||||
keyword: debouncedJobKeyword,
|
||||
}),
|
||||
});
|
||||
downloadCsv(
|
||||
`${t("lldpLinks.exportJobsName")}-${new Date().toISOString().slice(0, 10)}.csv`,
|
||||
rows,
|
||||
[
|
||||
{ key: "id", header: "ID" },
|
||||
{
|
||||
key: "trigger_mode",
|
||||
header: t("lldpLinks.col.trigger"),
|
||||
value: (j) => lldpTriggerLabel(t, j.trigger_mode),
|
||||
},
|
||||
{
|
||||
key: "scope",
|
||||
header: t("lldpLinks.col.scope"),
|
||||
value: (j) => lldpScopeLabel(t, j.scope),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
header: t("lldpLinks.col.status"),
|
||||
value: (j) => lldpJobStatusLabel(t, j.status),
|
||||
},
|
||||
{
|
||||
key: "progress",
|
||||
header: t("lldpLinks.col.progress"),
|
||||
value: (j) => `${j.done}/${j.total}`,
|
||||
},
|
||||
{
|
||||
key: "edges",
|
||||
header: t("lldpLinks.col.edges"),
|
||||
value: (j) => `+${j.edges_added}/~${j.edges_updated}`,
|
||||
},
|
||||
{
|
||||
key: "started_at",
|
||||
header: t("lldpLinks.col.started"),
|
||||
value: (j) => (j.started_at ? formatSystemTime(j.started_at) : ""),
|
||||
},
|
||||
{
|
||||
key: "ended_at",
|
||||
header: t("lldpLinks.col.ended"),
|
||||
value: (j) => (j.ended_at ? formatSystemTime(j.ended_at) : ""),
|
||||
},
|
||||
],
|
||||
);
|
||||
if (rows.length < jobTotal) {
|
||||
showOk(t("common.exportTruncated", { count: String(rows.length), total: String(jobTotal) }));
|
||||
} else {
|
||||
showOk(t("common.exportOk", { count: String(rows.length) }));
|
||||
}
|
||||
} catch (err) {
|
||||
showError(t("common.exportFailed") + ": " + formatErr(err));
|
||||
} finally {
|
||||
setExportingJobs(false);
|
||||
}
|
||||
};
|
||||
|
||||
const exportItemsCsv = async () => {
|
||||
if (!expandedJobId) return;
|
||||
setExportingItems(true);
|
||||
try {
|
||||
const rows = await fetchAllPages<TopologyDiscoverJobItem>({
|
||||
pageSize: 100,
|
||||
maxRows: 2000,
|
||||
fetchPage: async (p, ps) => {
|
||||
const job = await fetchLldpCollectJob(expandedJobId, {
|
||||
page: p,
|
||||
pageSize: ps,
|
||||
status: itemStatus,
|
||||
keyword: debouncedItemKeyword,
|
||||
});
|
||||
return { total: job.items_total, items: (job.items || []) as TopologyDiscoverJobItem[] };
|
||||
},
|
||||
});
|
||||
downloadCsv(
|
||||
`${t("lldpLinks.exportItemsName")}-${expandedJobId.slice(0, 8)}.csv`,
|
||||
rows,
|
||||
[
|
||||
{ key: "ne_name", header: t("lldpLinks.col.name") },
|
||||
{ key: "ne_ip", header: "IP" },
|
||||
{
|
||||
key: "result",
|
||||
header: t("lldpLinks.col.status"),
|
||||
value: (it) => lldpItemResultLabel(t, it),
|
||||
},
|
||||
{ key: "neighbors", header: t("lldpLinks.col.neighbors") },
|
||||
{
|
||||
key: "edges",
|
||||
header: t("lldpLinks.col.edges"),
|
||||
value: (it) => `+${it.edges_added}/~${it.edges_updated}`,
|
||||
},
|
||||
{ key: "error", header: t("lldpLinks.col.error") },
|
||||
],
|
||||
);
|
||||
if (rows.length < itemTotal) {
|
||||
showOk(t("common.exportTruncated", { count: String(rows.length), total: String(itemTotal) }));
|
||||
} else {
|
||||
showOk(t("common.exportOk", { count: String(rows.length) }));
|
||||
}
|
||||
} catch (err) {
|
||||
showError(t("common.exportFailed") + ": " + formatErr(err));
|
||||
} finally {
|
||||
setExportingItems(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleTarget = (row: CliTargetItem) => {
|
||||
const source = row.source === "ume" ? "ume" : "managed";
|
||||
|
|
@ -576,6 +803,17 @@ export function LldpLinksPage() {
|
|||
<option value="active">{t("lldpLinks.edgeStatusActive")}</option>
|
||||
<option value="missing">{t("lldpLinks.edgeStatusMissing")}</option>
|
||||
</select>
|
||||
<select
|
||||
value={edgeSource}
|
||||
onChange={(e) => {
|
||||
setEdgeSource(e.target.value);
|
||||
setEdgePage(1);
|
||||
}}
|
||||
>
|
||||
<option value="">{t("lldpLinks.edgeSourceAll")}</option>
|
||||
<option value="lldp">{t("lldpLinks.edgeSource.lldp")}</option>
|
||||
<option value="manual">{t("lldpLinks.edgeSource.manual")}</option>
|
||||
</select>
|
||||
<input
|
||||
value={edgeKeyword}
|
||||
placeholder={t("lldpLinks.edgeKeywordPh")}
|
||||
|
|
@ -584,6 +822,13 @@ export function LldpLinksPage() {
|
|||
setEdgePage(1);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={exportingEdges || edgeTotal === 0}
|
||||
onClick={() => void exportEdgesCsv()}
|
||||
>
|
||||
{exportingEdges ? t("common.exporting") : t("common.exportCsv")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="pt-list-table-wrap">
|
||||
<table className="data-table pt-list-table">
|
||||
|
|
@ -643,27 +888,54 @@ export function LldpLinksPage() {
|
|||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="pager">
|
||||
<button type="button" disabled={edgePage <= 1} onClick={() => setEdgePage((p) => p - 1)}>
|
||||
{t("common.prevPage")}
|
||||
</button>
|
||||
<span className="muted">
|
||||
{t("common.pagerMeta", {
|
||||
total: String(edgeTotal),
|
||||
page: String(edgePage),
|
||||
pages: String(edgePages),
|
||||
})}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={edgePage >= edgePages}
|
||||
onClick={() => setEdgePage((p) => p + 1)}
|
||||
>
|
||||
{t("common.nextPage")}
|
||||
</button>
|
||||
</div>
|
||||
<ListPager
|
||||
page={edgePage}
|
||||
pages={edgePages}
|
||||
total={edgeTotal}
|
||||
pageSize={edgePageSize}
|
||||
pageSizeOptions={[20, 50, 100, 200]}
|
||||
onPageChange={setEdgePage}
|
||||
onPageSizeChange={(size) => {
|
||||
setEdgePageSize(size);
|
||||
setEdgePage(1);
|
||||
}}
|
||||
disabled={edgesQuery.isLoading}
|
||||
/>
|
||||
|
||||
<h3 style={{ marginTop: 24 }}>{t("lldpLinks.jobsTitle")}</h3>
|
||||
<div className="filter-inline" style={{ marginBottom: 8 }}>
|
||||
<select
|
||||
value={jobStatus}
|
||||
onChange={(e) => {
|
||||
setJobStatus(e.target.value);
|
||||
setJobPage(1);
|
||||
}}
|
||||
>
|
||||
<option value="">{t("lldpLinks.statusAll")}</option>
|
||||
<option value="running">{t("lldpLinks.jobStatus.running")}</option>
|
||||
<option value="pending">{t("lldpLinks.jobStatus.pending")}</option>
|
||||
<option value="paused">{t("lldpLinks.jobStatus.paused")}</option>
|
||||
<option value="done">{t("lldpLinks.jobStatus.done")}</option>
|
||||
<option value="failed">{t("lldpLinks.jobStatus.failed")}</option>
|
||||
<option value="cancelled">{t("lldpLinks.jobStatus.cancelled")}</option>
|
||||
<option value="stopped">{t("lldpLinks.jobStatus.stopped")}</option>
|
||||
</select>
|
||||
<input
|
||||
value={jobKeyword}
|
||||
placeholder={t("lldpLinks.jobKeywordPh")}
|
||||
onChange={(e) => {
|
||||
setJobKeyword(e.target.value);
|
||||
setJobPage(1);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={exportingJobs || jobTotal === 0}
|
||||
onClick={() => void exportJobsCsv()}
|
||||
>
|
||||
{exportingJobs ? t("common.exporting") : t("common.exportCsv")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="pt-list-table-wrap">
|
||||
<table className="data-table pt-list-table">
|
||||
<thead>
|
||||
|
|
@ -691,6 +963,8 @@ export function LldpLinksPage() {
|
|||
className="btn btn--sm btn--ghost"
|
||||
onClick={() => {
|
||||
setItemDetail(null);
|
||||
setItemStatus("");
|
||||
setItemKeyword("");
|
||||
setExpandedJobId(job.id);
|
||||
setItemPage(1);
|
||||
}}
|
||||
|
|
@ -774,21 +1048,19 @@ export function LldpLinksPage() {
|
|||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="pager">
|
||||
<button type="button" disabled={jobPage <= 1} onClick={() => setJobPage((p) => p - 1)}>
|
||||
{t("common.prevPage")}
|
||||
</button>
|
||||
<span className="muted">
|
||||
{t("common.pagerMeta", {
|
||||
total: String(jobTotal),
|
||||
page: String(jobPage),
|
||||
pages: String(jobPages),
|
||||
})}
|
||||
</span>
|
||||
<button type="button" disabled={jobPage >= jobPages} onClick={() => setJobPage((p) => p + 1)}>
|
||||
{t("common.nextPage")}
|
||||
</button>
|
||||
</div>
|
||||
<ListPager
|
||||
page={jobPage}
|
||||
pages={jobPages}
|
||||
total={jobTotal}
|
||||
pageSize={jobPageSize}
|
||||
pageSizeOptions={[10, 20, 50, 100]}
|
||||
onPageChange={setJobPage}
|
||||
onPageSizeChange={(size) => {
|
||||
setJobPageSize(size);
|
||||
setJobPage(1);
|
||||
}}
|
||||
disabled={jobsQuery.isLoading}
|
||||
/>
|
||||
|
||||
{expandedJobId ? (
|
||||
<div
|
||||
|
|
@ -823,11 +1095,21 @@ export function LldpLinksPage() {
|
|||
</p>
|
||||
</div>
|
||||
<div className="btn-row ops-detail-modal__actions">
|
||||
<button
|
||||
type="button"
|
||||
disabled={exportingItems || itemTotal === 0}
|
||||
onClick={() => void exportItemsCsv()}
|
||||
>
|
||||
{exportingItems ? t("common.exporting") : t("common.exportCsv")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setExpandedJobId("");
|
||||
setItemDetail(null);
|
||||
setItemStatus("");
|
||||
setItemKeyword("");
|
||||
setItemPage(1);
|
||||
}}
|
||||
>
|
||||
{t("networkConfigs.close")}
|
||||
|
|
@ -835,6 +1117,29 @@ export function LldpLinksPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="filter-inline" style={{ marginBottom: 8 }}>
|
||||
<select
|
||||
value={itemStatus}
|
||||
onChange={(e) => {
|
||||
setItemStatus(e.target.value);
|
||||
setItemPage(1);
|
||||
}}
|
||||
>
|
||||
<option value="">{t("lldpLinks.itemResultAll")}</option>
|
||||
<option value="ok">{t("lldpLinks.itemResult.ok")}</option>
|
||||
<option value="warn">{t("lldpLinks.itemResult.warn")}</option>
|
||||
<option value="fail">{t("lldpLinks.itemResult.fail")}</option>
|
||||
</select>
|
||||
<input
|
||||
value={itemKeyword}
|
||||
placeholder={t("lldpLinks.itemKeywordPh")}
|
||||
onChange={(e) => {
|
||||
setItemKeyword(e.target.value);
|
||||
setItemPage(1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{jobDetailQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
|
||||
{jobDetailQuery.data?.error ? (
|
||||
<p className="ops-detail-modal__error">{jobDetailQuery.data.error}</p>
|
||||
|
|
@ -897,25 +1202,19 @@ export function LldpLinksPage() {
|
|||
</div>
|
||||
|
||||
<div className="ops-detail-modal__foot">
|
||||
<span className="muted">
|
||||
{t("common.pagerMeta", {
|
||||
total: String(itemTotal),
|
||||
page: String(itemPage),
|
||||
pages: String(itemPages),
|
||||
})}
|
||||
</span>
|
||||
<div className="btn-row">
|
||||
<button type="button" disabled={itemPage <= 1} onClick={() => setItemPage((p) => p - 1)}>
|
||||
{t("common.prevPage")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={itemPage >= itemPages}
|
||||
onClick={() => setItemPage((p) => p + 1)}
|
||||
>
|
||||
{t("common.nextPage")}
|
||||
</button>
|
||||
</div>
|
||||
<ListPager
|
||||
page={itemPage}
|
||||
pages={itemPages}
|
||||
total={itemTotal}
|
||||
pageSize={itemPageSize}
|
||||
pageSizeOptions={[20, 50, 100]}
|
||||
onPageChange={setItemPage}
|
||||
onPageSizeChange={(size) => {
|
||||
setItemPageSize(size);
|
||||
setItemPage(1);
|
||||
}}
|
||||
disabled={jobDetailQuery.isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,13 @@
|
|||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchUmeCurrentAlarms } from "../../services/api";
|
||||
import { ListPager } from "../../components/ListPager";
|
||||
import { queryKeys } from "../../constants/queryKeys";
|
||||
import { useDebouncedValue } from "../../hooks/useDebouncedValue";
|
||||
import { useToast } from "../../hooks/useToast";
|
||||
import { useI18n } from "../../i18n";
|
||||
import { fetchUmeCurrentAlarms, formatErr } from "../../services/api";
|
||||
import type { UmeAlarmItem } from "../../types";
|
||||
import { downloadCsv, fetchAllPages } from "../../utils/csvExport";
|
||||
import { pageCount } from "../../utils/display";
|
||||
import { formatSystemTime } from "../../utils/time";
|
||||
|
||||
|
|
@ -19,19 +24,24 @@ function severityClass(sev: string | null | undefined): string {
|
|||
/** Current UME alarms query view for Network Management. */
|
||||
export function NetworkAlarmsPage() {
|
||||
const { t } = useI18n();
|
||||
const { showOk, showError } = useToast();
|
||||
const [curSeverity, setCurSeverity] = useState("");
|
||||
const [curCleared, setCurCleared] = useState("");
|
||||
const [curHostName, setCurHostName] = useState("");
|
||||
const [curKeyword, setCurKeyword] = useState("");
|
||||
const [curPage, setCurPage] = useState(1);
|
||||
const [curPageSize, setCurPageSize] = useState(50);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
const debouncedKeyword = useDebouncedValue(curKeyword, 300);
|
||||
const debouncedHost = useDebouncedValue(curHostName, 300);
|
||||
|
||||
const currentQuery = useQuery({
|
||||
queryKey: queryKeys.umeCurrentAlarms(
|
||||
curSeverity,
|
||||
curCleared,
|
||||
curHostName,
|
||||
curKeyword,
|
||||
debouncedHost,
|
||||
debouncedKeyword,
|
||||
curPage,
|
||||
curPageSize,
|
||||
),
|
||||
|
|
@ -39,8 +49,8 @@ export function NetworkAlarmsPage() {
|
|||
fetchUmeCurrentAlarms({
|
||||
severity: curSeverity,
|
||||
isCleared: curCleared,
|
||||
hostName: curHostName,
|
||||
keyword: curKeyword,
|
||||
hostName: debouncedHost,
|
||||
keyword: debouncedKeyword,
|
||||
page: curPage,
|
||||
pageSize: curPageSize,
|
||||
}),
|
||||
|
|
@ -50,13 +60,57 @@ export function NetworkAlarmsPage() {
|
|||
const items = currentQuery.data?.items || [];
|
||||
const curTotal = Number(currentQuery.data?.total || 0);
|
||||
const curPages = pageCount(curTotal, curPageSize);
|
||||
const perPage = (n: number) => t("common.perPage", { n: String(n) });
|
||||
const hasFilters = Boolean(curKeyword.trim() || curHostName.trim() || curSeverity || curCleared);
|
||||
|
||||
const exportCsv = async () => {
|
||||
setExporting(true);
|
||||
try {
|
||||
const rows = await fetchAllPages<UmeAlarmItem>({
|
||||
pageSize: 200,
|
||||
maxRows: 2000,
|
||||
fetchPage: (page, pageSize) =>
|
||||
fetchUmeCurrentAlarms({
|
||||
severity: curSeverity,
|
||||
isCleared: curCleared,
|
||||
hostName: debouncedHost,
|
||||
keyword: debouncedKeyword,
|
||||
page,
|
||||
pageSize,
|
||||
}),
|
||||
});
|
||||
downloadCsv(`ume-alarms-${new Date().toISOString().slice(0, 10)}.csv`, rows, [
|
||||
{ key: "time_created", header: t("ume.alarms.col.time"), value: (r) => formatSystemTime(r.time_created) },
|
||||
{ key: "perceived_severity", header: t("ume.alarms.col.severity") },
|
||||
{ key: "ne_id", header: t("ume.alarms.col.neId") },
|
||||
{ key: "host_name", header: t("ume.alarms.col.hostName") },
|
||||
{ key: "ne_type", header: t("ume.alarms.col.neType") },
|
||||
{ key: "native_probable_cause", header: t("ume.alarms.col.cause") },
|
||||
{ key: "is_cleared", header: "is_cleared" },
|
||||
{ key: "alarm_key", header: "alarm_key" },
|
||||
]);
|
||||
if (rows.length < curTotal) {
|
||||
showOk(
|
||||
t("common.exportTruncated", { count: String(rows.length), total: String(curTotal) }),
|
||||
);
|
||||
} else {
|
||||
showOk(t("common.exportOk", { count: String(rows.length) }));
|
||||
}
|
||||
} catch (err) {
|
||||
showError(t("common.exportFailed") + ": " + formatErr(err));
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="panel">
|
||||
<div className="panel__toolbar">
|
||||
<h2>{t("ume.alarms.title")}</h2>
|
||||
<div className="btn-row">
|
||||
<button type="button" disabled={exporting || curTotal === 0} onClick={() => void exportCsv()}>
|
||||
{exporting ? t("common.exporting") : t("common.exportCsv")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-list">
|
||||
|
|
@ -99,8 +153,8 @@ export function NetworkAlarmsPage() {
|
|||
}}
|
||||
>
|
||||
<option value="">{t("ume.alarms.clearedAll")}</option>
|
||||
<option value="true">true</option>
|
||||
<option value="false">false</option>
|
||||
<option value="false">{t("ume.alarms.clearedNo")}</option>
|
||||
<option value="true">{t("ume.alarms.clearedYes")}</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -130,12 +184,12 @@ export function NetworkAlarmsPage() {
|
|||
<table className="data-table pt-list-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>time_created</th>
|
||||
<th>severity</th>
|
||||
<th>ne_id</th>
|
||||
<th>host_name</th>
|
||||
<th>ne_type</th>
|
||||
<th>cause</th>
|
||||
<th>{t("ume.alarms.col.time")}</th>
|
||||
<th>{t("ume.alarms.col.severity")}</th>
|
||||
<th>{t("ume.alarms.col.neId")}</th>
|
||||
<th>{t("ume.alarms.col.hostName")}</th>
|
||||
<th>{t("ume.alarms.col.neType")}</th>
|
||||
<th>{t("ume.alarms.col.cause")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
@ -177,46 +231,19 @@ export function NetworkAlarmsPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
<div className="pager pt-list-pager">
|
||||
<div className="pager__meta muted">
|
||||
{t("common.pagerMeta", {
|
||||
total: String(curTotal),
|
||||
page: String(curPage),
|
||||
pages: String(curPages),
|
||||
})}
|
||||
</div>
|
||||
<div className="pager__controls btn-row">
|
||||
<button
|
||||
className="pager__btn"
|
||||
type="button"
|
||||
onClick={() => setCurPage(Math.max(1, curPage - 1))}
|
||||
disabled={curPage <= 1}
|
||||
>
|
||||
{t("common.prevPage")}
|
||||
</button>
|
||||
<button
|
||||
className="pager__btn"
|
||||
type="button"
|
||||
onClick={() => setCurPage(curPage + 1)}
|
||||
disabled={curPage >= curPages}
|
||||
>
|
||||
{t("common.nextPage")}
|
||||
</button>
|
||||
<select
|
||||
className="pager__size"
|
||||
value={String(curPageSize)}
|
||||
onChange={(e) => {
|
||||
setCurPageSize(Number(e.target.value) || 50);
|
||||
setCurPage(1);
|
||||
}}
|
||||
>
|
||||
<option value="50">{perPage(50)}</option>
|
||||
<option value="100">{perPage(100)}</option>
|
||||
<option value="200">{perPage(200)}</option>
|
||||
<option value="500">{perPage(500)}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<ListPager
|
||||
page={curPage}
|
||||
pages={curPages}
|
||||
total={curTotal}
|
||||
pageSize={curPageSize}
|
||||
pageSizeOptions={[50, 100, 200, 500]}
|
||||
onPageChange={setCurPage}
|
||||
onPageSizeChange={(size) => {
|
||||
setCurPageSize(size);
|
||||
setCurPage(1);
|
||||
}}
|
||||
disabled={currentQuery.isLoading}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,15 +1,21 @@
|
|||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { ListPager } from "../../components/ListPager";
|
||||
import { queryKeys } from "../../constants/queryKeys";
|
||||
import { useDebouncedValue } from "../../hooks/useDebouncedValue";
|
||||
import { useToast } from "../../hooks/useToast";
|
||||
import { useI18n } from "../../i18n";
|
||||
import {
|
||||
downloadNeConfigSnapshot,
|
||||
fetchNeConfigSnapshotDetail,
|
||||
fetchNeConfigSnapshots,
|
||||
formatErr,
|
||||
} from "../../services/api";
|
||||
import { queryKeys } from "../../constants/queryKeys";
|
||||
import { useI18n } from "../../i18n";
|
||||
import { useToast } from "../../hooks/useToast";
|
||||
import { openNewModuleWindow } from "../../utils/moduleWindows";
|
||||
import type { NeConfigSnapshotMeta } from "../../types";
|
||||
import { downloadCsv, fetchAllPages } from "../../utils/csvExport";
|
||||
import { pageCount } from "../../utils/display";
|
||||
import { openNewModuleWindow } from "../../utils/moduleWindows";
|
||||
import { formatSystemTime } from "../../utils/time";
|
||||
|
||||
function fmtBytes(n: number): string {
|
||||
|
|
@ -22,18 +28,45 @@ function fmtBytes(n: number): string {
|
|||
export function NetworkConfigsPage() {
|
||||
const { t } = useI18n();
|
||||
const { showOk, showError } = useToast();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [page, setPage] = useState(1);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [pageSize, setPageSize] = useState(50);
|
||||
const [keyword, setKeyword] = useState(() => String(searchParams.get("q") || ""));
|
||||
const [source, setSource] = useState("");
|
||||
const [vendor, setVendor] = useState("");
|
||||
const [selected, setSelected] = useState<{ source: string; id: string } | null>(null);
|
||||
const [tab, setTab] = useState<"primary" | "alt">("primary");
|
||||
const [exporting, setExporting] = useState("");
|
||||
const [exportingList, setExportingList] = useState(false);
|
||||
|
||||
const debouncedKeyword = useDebouncedValue(keyword, 300);
|
||||
const debouncedVendor = useDebouncedValue(vendor, 300);
|
||||
|
||||
useEffect(() => {
|
||||
const q = String(searchParams.get("q") || "").trim();
|
||||
if (!q) return;
|
||||
setKeyword(q);
|
||||
setPage(1);
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
if (next.get("q") === q) next.delete("q");
|
||||
return next;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}, [searchParams, setSearchParams]);
|
||||
|
||||
const listQuery = useQuery({
|
||||
queryKey: queryKeys.networkConfigs(page, keyword, source, vendor),
|
||||
queryKey: queryKeys.networkConfigs(page, debouncedKeyword, source, debouncedVendor, pageSize),
|
||||
queryFn: () =>
|
||||
fetchNeConfigSnapshots({ page, pageSize: 20, keyword, source, vendor }),
|
||||
fetchNeConfigSnapshots({
|
||||
page,
|
||||
pageSize,
|
||||
keyword: debouncedKeyword,
|
||||
source,
|
||||
vendor: debouncedVendor,
|
||||
}),
|
||||
staleTime: 5000,
|
||||
});
|
||||
|
||||
|
|
@ -46,7 +79,7 @@ export function NetworkConfigsPage() {
|
|||
|
||||
const items = listQuery.data?.items ?? [];
|
||||
const total = Number(listQuery.data?.total || 0);
|
||||
const pages = pageCount(total, 20);
|
||||
const pages = pageCount(total, pageSize);
|
||||
const detail = detailQuery.data;
|
||||
const showAlt = Boolean(detail?.has_alt);
|
||||
const hasFilters = Boolean(keyword || source || vendor);
|
||||
|
|
@ -62,16 +95,69 @@ export function NetworkConfigsPage() {
|
|||
await downloadNeConfigSnapshot(src, id, field);
|
||||
showOk(t("networkConfigs.exportOk"));
|
||||
} catch (err) {
|
||||
showError(String(err));
|
||||
showError(formatErr(err));
|
||||
} finally {
|
||||
setExporting("");
|
||||
}
|
||||
};
|
||||
|
||||
const exportListCsv = async () => {
|
||||
setExportingList(true);
|
||||
try {
|
||||
const rows = await fetchAllPages<NeConfigSnapshotMeta>({
|
||||
pageSize: 100,
|
||||
maxRows: 2000,
|
||||
fetchPage: (p, ps) =>
|
||||
fetchNeConfigSnapshots({
|
||||
page: p,
|
||||
pageSize: ps,
|
||||
keyword: debouncedKeyword,
|
||||
source,
|
||||
vendor: debouncedVendor,
|
||||
}),
|
||||
});
|
||||
downloadCsv(
|
||||
`${t("networkConfigs.exportListName")}-${new Date().toISOString().slice(0, 10)}.csv`,
|
||||
rows,
|
||||
[
|
||||
{ key: "ne_name", header: t("networkConfigs.col.name"), value: (r) => r.ne_name || r.target_id },
|
||||
{ key: "ne_ip", header: "IP" },
|
||||
{ key: "vendor", header: t("networkConfigs.col.vendor") },
|
||||
{ key: "source", header: t("networkConfigs.col.source") },
|
||||
{ key: "plain_size", header: t("networkConfigs.col.size"), value: (r) => fmtBytes(r.plain_size) },
|
||||
{
|
||||
key: "collected_at",
|
||||
header: t("networkConfigs.col.collected"),
|
||||
value: (r) => (r.collected_at ? formatSystemTime(r.collected_at) : ""),
|
||||
},
|
||||
{ key: "target_id", header: "ID" },
|
||||
],
|
||||
);
|
||||
if (rows.length < total) {
|
||||
showOk(t("common.exportTruncated", { count: String(rows.length), total: String(total) }));
|
||||
} else {
|
||||
showOk(t("common.exportOk", { count: String(rows.length) }));
|
||||
}
|
||||
} catch (err) {
|
||||
showError(t("common.exportFailed") + ": " + formatErr(err));
|
||||
} finally {
|
||||
setExportingList(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="panel">
|
||||
<div className="panel__toolbar">
|
||||
<h2>{t("networkConfigs.title")}</h2>
|
||||
<div className="btn-row">
|
||||
<button
|
||||
type="button"
|
||||
disabled={exportingList || total === 0}
|
||||
onClick={() => void exportListCsv()}
|
||||
>
|
||||
{exportingList ? t("common.exporting") : t("common.exportCsv")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-list">
|
||||
|
|
@ -118,6 +204,7 @@ export function NetworkConfigsPage() {
|
|||
</div>
|
||||
|
||||
{listQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
|
||||
{listQuery.isError ? <p className="error-text">{t("common.opFailed")}</p> : null}
|
||||
|
||||
{!items.length && !listQuery.isLoading ? (
|
||||
<div className="pt-list-empty">
|
||||
|
|
@ -199,23 +286,19 @@ export function NetworkConfigsPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
<div className="pager pt-list-pager">
|
||||
<span className="muted">
|
||||
{t("common.pagerMeta", {
|
||||
total: String(total),
|
||||
page: String(page),
|
||||
pages: String(pages),
|
||||
})}
|
||||
</span>
|
||||
<div className="btn-row">
|
||||
<button type="button" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
|
||||
{t("common.prevPage")}
|
||||
</button>
|
||||
<button type="button" disabled={page >= pages} onClick={() => setPage((p) => p + 1)}>
|
||||
{t("common.nextPage")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ListPager
|
||||
page={page}
|
||||
pages={pages}
|
||||
total={total}
|
||||
pageSize={pageSize}
|
||||
pageSizeOptions={[20, 50, 100]}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={(size) => {
|
||||
setPageSize(size);
|
||||
setPage(1);
|
||||
}}
|
||||
disabled={listQuery.isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selected ? (
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchCliTargets } from "../../services/api";
|
||||
import { ListPager } from "../../components/ListPager";
|
||||
import { queryKeys } from "../../constants/queryKeys";
|
||||
import { useDebouncedValue } from "../../hooks/useDebouncedValue";
|
||||
import { useToast } from "../../hooks/useToast";
|
||||
import { useI18n } from "../../i18n";
|
||||
import { fetchCliTargets, formatErr } from "../../services/api";
|
||||
import type { CliTargetItem } from "../../types";
|
||||
import { downloadCsv, fetchAllPages } from "../../utils/csvExport";
|
||||
import { pageCount } from "../../utils/display";
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
import { openNewModuleWindow } from "../../utils/moduleWindows";
|
||||
|
||||
function connectStatusClass(status: string | null | undefined): string {
|
||||
const s = String(status || "").trim().toLowerCase();
|
||||
|
|
@ -17,28 +21,80 @@ function connectStatusClass(status: string | null | undefined): string {
|
|||
return "pt-list-status--other";
|
||||
}
|
||||
|
||||
/** Read-only inventory of managed + UME NEs for Network Management. */
|
||||
function openWebcrt(row: CliTargetItem) {
|
||||
const path =
|
||||
row.source === "ume"
|
||||
? `/webcrt?ne_id=${encodeURIComponent(row.id)}&source=ume`
|
||||
: `/webcrt?ne_id=${encodeURIComponent(row.id)}`;
|
||||
openNewModuleWindow({ moduleId: "webcrt", path });
|
||||
}
|
||||
|
||||
/** Inventory of managed + UME NEs for Network Management. */
|
||||
export function NetworkDevicesPage() {
|
||||
const { t } = useI18n();
|
||||
const { showOk, showError } = useToast();
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [source, setSource] = useState<"all" | "managed" | "ume">("all");
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(50);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
const debouncedKeyword = useDebouncedValue(keyword, 300);
|
||||
|
||||
const listQuery = useQuery({
|
||||
queryKey: queryKeys.cliTargets(`${source}:${keyword}`, page, PAGE_SIZE),
|
||||
queryFn: () => fetchCliTargets({ source, keyword, page, pageSize: PAGE_SIZE }),
|
||||
queryKey: queryKeys.cliTargets(`${source}:${debouncedKeyword}`, page, pageSize),
|
||||
queryFn: () => fetchCliTargets({ source, keyword: debouncedKeyword, page, pageSize }),
|
||||
staleTime: 5000,
|
||||
});
|
||||
|
||||
const items = listQuery.data?.items ?? [];
|
||||
const total = Number(listQuery.data?.total || 0);
|
||||
const pages = pageCount(total, PAGE_SIZE);
|
||||
const pages = pageCount(total, pageSize);
|
||||
const hasFilters = Boolean(keyword.trim() || source !== "all");
|
||||
|
||||
const exportCsv = async () => {
|
||||
setExporting(true);
|
||||
try {
|
||||
const rows = await fetchAllPages<CliTargetItem>({
|
||||
pageSize: 200,
|
||||
maxRows: 2000,
|
||||
fetchPage: (p, ps) =>
|
||||
fetchCliTargets({ source, keyword: debouncedKeyword, page: p, pageSize: ps }),
|
||||
});
|
||||
downloadCsv(`${t("networkDevices.exportName")}-${new Date().toISOString().slice(0, 10)}.csv`, rows, [
|
||||
{ key: "source", header: t("networkDevices.col.source") },
|
||||
{ key: "name", header: t("networkDevices.col.name"), value: (r) => r.name || r.id },
|
||||
{ key: "ip_address", header: "IP" },
|
||||
{ key: "vendor", header: t("networkDevices.col.vendor") },
|
||||
{
|
||||
key: "device_type",
|
||||
header: t("networkDevices.col.deviceType"),
|
||||
value: (r) => r.device_type || r.ne_type || "",
|
||||
},
|
||||
{ key: "connect_status", header: t("networkDevices.col.connect") },
|
||||
{ key: "id", header: "ID" },
|
||||
]);
|
||||
if (rows.length < total) {
|
||||
showOk(t("common.exportTruncated", { count: String(rows.length), total: String(total) }));
|
||||
} else {
|
||||
showOk(t("common.exportOk", { count: String(rows.length) }));
|
||||
}
|
||||
} catch (err) {
|
||||
showError(t("common.exportFailed") + ": " + formatErr(err));
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="panel">
|
||||
<div className="panel__toolbar">
|
||||
<h2>{t("networkDevices.title")}</h2>
|
||||
<div className="btn-row">
|
||||
<button type="button" disabled={exporting || total === 0} onClick={() => void exportCsv()}>
|
||||
{exporting ? t("common.exporting") : t("common.exportCsv")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-list">
|
||||
|
|
@ -76,6 +132,7 @@ export function NetworkDevicesPage() {
|
|||
</div>
|
||||
|
||||
{listQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
|
||||
{listQuery.isError ? <p className="error-text">{t("common.opFailed")}</p> : null}
|
||||
|
||||
{!items.length && !listQuery.isLoading ? (
|
||||
<div className="pt-list-empty">
|
||||
|
|
@ -92,6 +149,7 @@ export function NetworkDevicesPage() {
|
|||
<th>{t("networkDevices.col.vendor")}</th>
|
||||
<th>{t("networkDevices.col.deviceType")}</th>
|
||||
<th>{t("networkDevices.col.connect")}</th>
|
||||
<th>{t("networkDevices.col.actions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
@ -107,6 +165,24 @@ export function NetworkDevicesPage() {
|
|||
{row.connect_status || "—"}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div className="btn-row pt-list-actions table-actions">
|
||||
<button type="button" onClick={() => openWebcrt(row)}>
|
||||
WebCRT
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
openNewModuleWindow({
|
||||
moduleId: "network",
|
||||
path: `/network/configs?q=${encodeURIComponent(row.name || row.ip_address || row.id)}`,
|
||||
})
|
||||
}
|
||||
>
|
||||
{t("network.nav.configs")}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
|
@ -114,23 +190,19 @@ export function NetworkDevicesPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
<div className="pager pt-list-pager">
|
||||
<span className="muted">
|
||||
{t("common.pagerMeta", {
|
||||
total: String(total),
|
||||
page: String(page),
|
||||
pages: String(pages),
|
||||
})}
|
||||
</span>
|
||||
<div className="btn-row">
|
||||
<button type="button" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
|
||||
{t("common.prevPage")}
|
||||
</button>
|
||||
<button type="button" disabled={page >= pages} onClick={() => setPage((p) => p + 1)}>
|
||||
{t("common.nextPage")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ListPager
|
||||
page={page}
|
||||
pages={pages}
|
||||
total={total}
|
||||
pageSize={pageSize}
|
||||
pageSizeOptions={[20, 50, 100, 200]}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={(size) => {
|
||||
setPageSize(size);
|
||||
setPage(1);
|
||||
}}
|
||||
disabled={listQuery.isLoading}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { ListPager } from "../../components/ListPager";
|
||||
import {
|
||||
createPortTrafficDevice,
|
||||
deletePortTrafficDevice,
|
||||
|
|
@ -10,6 +11,7 @@ import {
|
|||
fetchPortTrafficDevices,
|
||||
fetchPortTrafficEvents,
|
||||
fetchPortTrafficTargets,
|
||||
formatErr,
|
||||
pausePortTrafficDevice,
|
||||
putPortTrafficInterfaces,
|
||||
rebindPortTrafficDevice,
|
||||
|
|
@ -19,13 +21,16 @@ import {
|
|||
} from "../../services/api";
|
||||
import { queryKeys } from "../../constants/queryKeys";
|
||||
import { useI18n } from "../../i18n";
|
||||
import { useDebouncedValue } from "../../hooks/useDebouncedValue";
|
||||
import { useToast } from "../../hooks/useToast";
|
||||
import type {
|
||||
CliTargetItem,
|
||||
PortTrafficDevice,
|
||||
PortTrafficDiscoverPort,
|
||||
PortTrafficEvent,
|
||||
PortTrafficIfaceIn,
|
||||
} from "../../types";
|
||||
import { downloadCsv, fetchAllPages } from "../../utils/csvExport";
|
||||
import { pageCount } from "../../utils/display";
|
||||
import { formatSystemTime } from "../../utils/time";
|
||||
|
||||
|
|
@ -34,10 +39,11 @@ const TARGET_PAGE_SIZE = 20;
|
|||
|
||||
type ViewMode = "list" | "wizard" | "edit";
|
||||
|
||||
function statusTone(status: string): "running" | "paused" | "stopped" | "other" {
|
||||
function statusTone(status: string): "running" | "paused" | "stopped" | "draft" | "other" {
|
||||
if (status === "running") return "running";
|
||||
if (status === "paused") return "paused";
|
||||
if (status === "stopped") return "stopped";
|
||||
if (status === "draft") return "draft";
|
||||
return "other";
|
||||
}
|
||||
|
||||
|
|
@ -122,6 +128,11 @@ export function PortTrafficPage() {
|
|||
|
||||
const [view, setView] = useState<ViewMode>("list");
|
||||
const [listPage, setListPage] = useState(1);
|
||||
const [listPageSize, setListPageSize] = useState(50);
|
||||
const [listKeyword, setListKeyword] = useState("");
|
||||
const [listStatus, setListStatus] = useState("");
|
||||
const [exportingDevices, setExportingDevices] = useState(false);
|
||||
const debouncedListKeyword = useDebouncedValue(listKeyword, 300);
|
||||
|
||||
// Wizard / edit
|
||||
const [editDeviceId, setEditDeviceId] = useState("");
|
||||
|
|
@ -151,6 +162,7 @@ export function PortTrafficPage() {
|
|||
const [wizardStep, setWizardStep] = useState<1 | 2 | 3>(1);
|
||||
const [logDeviceId, setLogDeviceId] = useState("");
|
||||
const [logDeviceLabel, setLogDeviceLabel] = useState("");
|
||||
const [logKeyword, setLogKeyword] = useState("");
|
||||
const [rebindDevice, setRebindDevice] = useState<PortTrafficDevice | null>(null);
|
||||
const [rebindKeyword, setRebindKeyword] = useState("");
|
||||
const [rebindPage, setRebindPage] = useState(1);
|
||||
|
|
@ -169,8 +181,14 @@ export function PortTrafficPage() {
|
|||
});
|
||||
|
||||
const devicesQuery = useQuery({
|
||||
queryKey: queryKeys.portTrafficDevices(listPage),
|
||||
queryFn: () => fetchPortTrafficDevices({ page: listPage, pageSize: 10 }),
|
||||
queryKey: queryKeys.portTrafficDevices(listPage, listPageSize, listStatus, debouncedListKeyword),
|
||||
queryFn: () =>
|
||||
fetchPortTrafficDevices({
|
||||
page: listPage,
|
||||
pageSize: listPageSize,
|
||||
status: listStatus || undefined,
|
||||
keyword: debouncedListKeyword || undefined,
|
||||
}),
|
||||
staleTime: 5000,
|
||||
refetchInterval: (q) => {
|
||||
const items = q.state.data?.items || [];
|
||||
|
|
@ -570,17 +588,109 @@ export function PortTrafficPage() {
|
|||
|
||||
const dash = dashQuery.data;
|
||||
const devices = devicesQuery.data?.items || [];
|
||||
const pages = pageCount(devicesQuery.data?.total || 0, 10);
|
||||
const listTotal = Number(devicesQuery.data?.total || 0);
|
||||
const pages = pageCount(listTotal, listPageSize);
|
||||
const editDevice = editDeviceSnap;
|
||||
const hasListFilters = Boolean(listKeyword.trim() || listStatus);
|
||||
const logEvents = logQuery.data?.items || [];
|
||||
const logKw = logKeyword.trim().toLowerCase();
|
||||
const filteredLogEvents = !logKw
|
||||
? logEvents
|
||||
: logEvents.filter((ev) => {
|
||||
const msg = formatPortTrafficLogMessage(ev.message, t).toLowerCase();
|
||||
const hay = `${ev.level || ""} ${ev.ifname || ""} ${ev.message || ""} ${msg}`.toLowerCase();
|
||||
return hay.includes(logKw);
|
||||
});
|
||||
|
||||
const statusText = (status: string) => {
|
||||
const tone = statusTone(status);
|
||||
if (tone === "running") return t("portTraffic.statusRunning");
|
||||
if (tone === "paused") return t("portTraffic.statusPaused");
|
||||
if (tone === "stopped") return t("portTraffic.statusStopped");
|
||||
if (tone === "draft") return t("portTraffic.statusDraft");
|
||||
return status;
|
||||
};
|
||||
|
||||
const closeLog = () => {
|
||||
setLogDeviceId("");
|
||||
setLogDeviceLabel("");
|
||||
setLogKeyword("");
|
||||
};
|
||||
|
||||
const exportDevicesCsv = async () => {
|
||||
setExportingDevices(true);
|
||||
try {
|
||||
const rows = await fetchAllPages<PortTrafficDevice>({
|
||||
pageSize: 100,
|
||||
maxRows: 2000,
|
||||
fetchPage: (p, ps) =>
|
||||
fetchPortTrafficDevices({
|
||||
page: p,
|
||||
pageSize: ps,
|
||||
status: listStatus || undefined,
|
||||
keyword: debouncedListKeyword || undefined,
|
||||
}),
|
||||
});
|
||||
downloadCsv(
|
||||
`${t("portTraffic.exportDevicesName")}-${new Date().toISOString().slice(0, 10)}.csv`,
|
||||
rows,
|
||||
[
|
||||
{ key: "ne_name", header: t("portTraffic.col.device"), value: (r) => deviceLabel(r) },
|
||||
{ key: "ne_ip", header: "IP" },
|
||||
{ key: "ne_id", header: "NE ID" },
|
||||
{ key: "vendor", header: t("portTraffic.col.vendor") },
|
||||
{ key: "status", header: t("portTraffic.col.status"), value: (r) => statusText(r.status) },
|
||||
{ key: "target_count", header: t("portTraffic.col.ports") },
|
||||
{ key: "active_target_count", header: "Active ports" },
|
||||
{ key: "interval_sec", header: t("portTraffic.col.interval") },
|
||||
{
|
||||
key: "last_collect_ended_at",
|
||||
header: t("portTraffic.col.last"),
|
||||
value: (r) => formatSystemTime(r.last_collect_ended_at) || "",
|
||||
},
|
||||
{ key: "note", header: t("portTraffic.note") },
|
||||
{ key: "last_error", header: t("portTraffic.logMessage") },
|
||||
{ key: "id", header: "ID" },
|
||||
],
|
||||
);
|
||||
if (rows.length < listTotal) {
|
||||
showOk(t("common.exportTruncated", { count: String(rows.length), total: String(listTotal) }));
|
||||
} else {
|
||||
showOk(t("common.exportOk", { count: String(rows.length) }));
|
||||
}
|
||||
} catch (err) {
|
||||
showError(t("common.exportFailed") + ": " + formatErr(err));
|
||||
} finally {
|
||||
setExportingDevices(false);
|
||||
}
|
||||
};
|
||||
|
||||
const exportEventsCsv = () => {
|
||||
try {
|
||||
downloadCsv(
|
||||
`${t("portTraffic.exportEventsName")}-${new Date().toISOString().slice(0, 10)}.csv`,
|
||||
filteredLogEvents,
|
||||
[
|
||||
{
|
||||
key: "created_at",
|
||||
header: t("portTraffic.logTime"),
|
||||
value: (r: PortTrafficEvent) => formatSystemTime(r.created_at) || "",
|
||||
},
|
||||
{ key: "level", header: t("portTraffic.logLevel") },
|
||||
{ key: "ifname", header: t("portTraffic.col.ifname") },
|
||||
{
|
||||
key: "message",
|
||||
header: t("portTraffic.logMessage"),
|
||||
value: (r: PortTrafficEvent) => formatPortTrafficLogMessage(r.message, t),
|
||||
},
|
||||
],
|
||||
);
|
||||
showOk(t("common.exportOk", { count: String(filteredLogEvents.length) }));
|
||||
} catch (err) {
|
||||
showError(t("common.exportFailed") + ": " + formatErr(err));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="panel">
|
||||
<div className="panel__toolbar">
|
||||
|
|
@ -601,6 +711,13 @@ export function PortTrafficPage() {
|
|||
<button type="button" onClick={() => navigate("/network/tasks/port-traffic/wall")}>
|
||||
{t("portTraffic.wall")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={exportingDevices || listTotal === 0}
|
||||
onClick={() => void exportDevicesCsv()}
|
||||
>
|
||||
{exportingDevices ? t("common.exporting") : t("common.exportCsv")}
|
||||
</button>
|
||||
<button type="button" className="btn-primary" onClick={openWizard}>
|
||||
{t("portTraffic.create")}
|
||||
</button>
|
||||
|
|
@ -634,157 +751,189 @@ export function PortTrafficPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{!devices.length ? (
|
||||
<div className="filter-inline">
|
||||
<input
|
||||
value={listKeyword}
|
||||
placeholder={t("portTraffic.keywordPh")}
|
||||
onChange={(e) => {
|
||||
setListKeyword(e.target.value);
|
||||
setListPage(1);
|
||||
}}
|
||||
/>
|
||||
<select
|
||||
value={listStatus}
|
||||
onChange={(e) => {
|
||||
setListStatus(e.target.value);
|
||||
setListPage(1);
|
||||
}}
|
||||
>
|
||||
<option value="">{t("portTraffic.allStatus")}</option>
|
||||
<option value="running">{t("portTraffic.statusRunning")}</option>
|
||||
<option value="paused">{t("portTraffic.statusPaused")}</option>
|
||||
<option value="stopped">{t("portTraffic.statusStopped")}</option>
|
||||
<option value="draft">{t("portTraffic.statusDraft")}</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!hasListFilters}
|
||||
onClick={() => {
|
||||
setListKeyword("");
|
||||
setListStatus("");
|
||||
setListPage(1);
|
||||
}}
|
||||
>
|
||||
{t("common.clearFilters")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{devicesQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
|
||||
{devicesQuery.isError ? <p className="error-text">{t("common.opFailed")}</p> : null}
|
||||
|
||||
{!devices.length && !devicesQuery.isLoading ? (
|
||||
<div className="pt-list-empty">
|
||||
<p>{t("portTraffic.empty")}</p>
|
||||
<button type="button" className="btn-primary" onClick={openWizard}>
|
||||
{t("portTraffic.create")}
|
||||
</button>
|
||||
{!hasListFilters ? (
|
||||
<button type="button" className="btn-primary" onClick={openWizard}>
|
||||
{t("portTraffic.create")}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="pt-list-table-wrap">
|
||||
<table className="data-table pt-list-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("portTraffic.col.device")}</th>
|
||||
<th>{t("portTraffic.col.status")}</th>
|
||||
<th>{t("portTraffic.col.ports")}</th>
|
||||
<th>{t("portTraffic.col.interval")}</th>
|
||||
<th>{t("portTraffic.col.last")}</th>
|
||||
<th>{t("portTraffic.col.actions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{devices.map((row) => {
|
||||
const tone = statusTone(row.status);
|
||||
return (
|
||||
<tr key={row.id}>
|
||||
<td>
|
||||
<div className="pt-list-task-name">{deviceLabel(row)}</div>
|
||||
<div className="muted" style={{ fontSize: 12 }}>
|
||||
{row.ne_ip || row.ne_id}
|
||||
{row.vendor ? ` · ${row.vendor}` : ""}
|
||||
{row.note ? ` · ${row.note}` : ""}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`pt-list-status pt-list-status--${tone}`}>
|
||||
{statusText(row.status)}
|
||||
) : devices.length ? (
|
||||
<div className="pt-list-table-wrap">
|
||||
<table className="data-table pt-list-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("portTraffic.col.device")}</th>
|
||||
<th>{t("portTraffic.col.status")}</th>
|
||||
<th>{t("portTraffic.col.ports")}</th>
|
||||
<th>{t("portTraffic.col.interval")}</th>
|
||||
<th>{t("portTraffic.col.last")}</th>
|
||||
<th>{t("portTraffic.col.actions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{devices.map((row) => {
|
||||
const tone = statusTone(row.status);
|
||||
return (
|
||||
<tr key={row.id}>
|
||||
<td>
|
||||
<div className="pt-list-task-name">{deviceLabel(row)}</div>
|
||||
<div className="muted" style={{ fontSize: 12 }}>
|
||||
{row.ne_ip || row.ne_id}
|
||||
{row.vendor ? ` · ${row.vendor}` : ""}
|
||||
{row.note ? ` · ${row.note}` : ""}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`pt-list-status pt-list-status--${tone}`}>
|
||||
{statusText(row.status)}
|
||||
</span>
|
||||
{row.collect_running ? (
|
||||
<span className="pt-list-status pt-list-status--collect">
|
||||
{t("portTraffic.collecting")}
|
||||
</span>
|
||||
{row.collect_running ? (
|
||||
<span className="pt-list-status pt-list-status--collect">
|
||||
{t("portTraffic.collecting")}
|
||||
</span>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="pt-list-num">
|
||||
{row.active_target_count}/{row.target_count}
|
||||
</td>
|
||||
<td className="pt-list-num">{row.interval_sec}s</td>
|
||||
<td className="pt-list-time">
|
||||
{formatSystemTime(row.last_collect_ended_at) || "—"}
|
||||
</td>
|
||||
<td>
|
||||
<div className="pt-list-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
onClick={() => openWallList()}
|
||||
>
|
||||
{t("portTraffic.wall")}
|
||||
</button>
|
||||
<button type="button" onClick={() => openEdit(row)}>
|
||||
{t("portTraffic.edit")}
|
||||
</button>
|
||||
{needsNeRebind(row) ? (
|
||||
<button
|
||||
type="button"
|
||||
title={t("portTraffic.rebindHint")}
|
||||
onClick={() => openRebind(row)}
|
||||
>
|
||||
{t("portTraffic.rebind")}
|
||||
</button>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="pt-list-num">
|
||||
{row.active_target_count}/{row.target_count}
|
||||
</td>
|
||||
<td className="pt-list-num">{row.interval_sec}s</td>
|
||||
<td className="pt-list-time">
|
||||
{formatSystemTime(row.last_collect_ended_at) || "—"}
|
||||
</td>
|
||||
<td>
|
||||
<div className="pt-list-actions">
|
||||
{row.status !== "running" ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
onClick={() => openWallList()}
|
||||
disabled={startMut.isPending}
|
||||
onClick={() => startMut.mutate(row.id)}
|
||||
>
|
||||
{t("portTraffic.wall")}
|
||||
{t("portTraffic.start")}
|
||||
</button>
|
||||
<button type="button" onClick={() => openEdit(row)}>
|
||||
{t("portTraffic.edit")}
|
||||
</button>
|
||||
{needsNeRebind(row) ? (
|
||||
<button
|
||||
type="button"
|
||||
title={t("portTraffic.rebindHint")}
|
||||
onClick={() => openRebind(row)}
|
||||
>
|
||||
{t("portTraffic.rebind")}
|
||||
</button>
|
||||
) : null}
|
||||
{row.status !== "running" ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={startMut.isPending}
|
||||
onClick={() => startMut.mutate(row.id)}
|
||||
>
|
||||
{t("portTraffic.start")}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={pauseMut.isPending}
|
||||
onClick={() => pauseMut.mutate(row.id)}
|
||||
>
|
||||
{t("portTraffic.pause")}
|
||||
</button>
|
||||
)}
|
||||
{row.status !== "stopped" ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={stopMut.isPending}
|
||||
onClick={() => stopMut.mutate(row.id)}
|
||||
>
|
||||
{t("portTraffic.stop")}
|
||||
</button>
|
||||
) : null}
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className={row.last_error ? "pt-list-log-btn--error" : undefined}
|
||||
onClick={() => {
|
||||
setLogDeviceId(row.id);
|
||||
setLogDeviceLabel(deviceLabel(row));
|
||||
}}
|
||||
disabled={pauseMut.isPending}
|
||||
onClick={() => pauseMut.mutate(row.id)}
|
||||
>
|
||||
{t("portTraffic.log")}
|
||||
{t("portTraffic.pause")}
|
||||
</button>
|
||||
)}
|
||||
{row.status !== "stopped" ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn--danger"
|
||||
disabled={deleteMut.isPending}
|
||||
onClick={() => {
|
||||
if (window.confirm(t("portTraffic.confirmDelete"))) {
|
||||
deleteMut.mutate(row.id);
|
||||
}
|
||||
}}
|
||||
disabled={stopMut.isPending}
|
||||
onClick={() => stopMut.mutate(row.id)}
|
||||
>
|
||||
{t("portTraffic.delete")}
|
||||
{t("portTraffic.stop")}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="pager pt-list-pager">
|
||||
<button
|
||||
type="button"
|
||||
className="pager__btn"
|
||||
disabled={listPage <= 1}
|
||||
onClick={() => setListPage((p) => p - 1)}
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<span className="muted">
|
||||
{listPage}/{Math.max(1, pages)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="pager__btn"
|
||||
disabled={listPage >= pages}
|
||||
onClick={() => setListPage((p) => p + 1)}
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className={row.last_error ? "pt-list-log-btn--error" : undefined}
|
||||
onClick={() => {
|
||||
setLogDeviceId(row.id);
|
||||
setLogDeviceLabel(deviceLabel(row));
|
||||
setLogKeyword("");
|
||||
}}
|
||||
>
|
||||
{t("portTraffic.log")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn--danger"
|
||||
disabled={deleteMut.isPending}
|
||||
onClick={() => {
|
||||
if (window.confirm(t("portTraffic.confirmDelete"))) {
|
||||
deleteMut.mutate(row.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("portTraffic.delete")}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<ListPager
|
||||
page={listPage}
|
||||
pages={pages}
|
||||
total={listTotal}
|
||||
pageSize={listPageSize}
|
||||
pageSizeOptions={[20, 50, 100]}
|
||||
onPageChange={setListPage}
|
||||
onPageSizeChange={(size) => {
|
||||
setListPageSize(size);
|
||||
setListPage(1);
|
||||
}}
|
||||
disabled={devicesQuery.isLoading}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
|
@ -1179,14 +1328,7 @@ export function PortTrafficPage() {
|
|||
) : null}
|
||||
|
||||
{logDeviceId ? (
|
||||
<div
|
||||
className="modal-backdrop"
|
||||
role="presentation"
|
||||
onClick={() => {
|
||||
setLogDeviceId("");
|
||||
setLogDeviceLabel("");
|
||||
}}
|
||||
>
|
||||
<div className="modal-backdrop" role="presentation" onClick={closeLog}>
|
||||
<div
|
||||
className="modal modal--wide pt-log-modal"
|
||||
role="dialog"
|
||||
|
|
@ -1201,23 +1343,35 @@ export function PortTrafficPage() {
|
|||
{t("portTraffic.logDevice")}: {logDeviceLabel || "—"}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setLogDeviceId("");
|
||||
setLogDeviceLabel("");
|
||||
}}
|
||||
>
|
||||
{t("portTraffic.logClose")}
|
||||
</button>
|
||||
<div className="btn-row">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!filteredLogEvents.length}
|
||||
onClick={exportEventsCsv}
|
||||
>
|
||||
{t("common.exportCsv")}
|
||||
</button>
|
||||
<button type="button" onClick={closeLog}>
|
||||
{t("portTraffic.logClose")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="filter-inline" style={{ marginBottom: 10 }}>
|
||||
<input
|
||||
value={logKeyword}
|
||||
placeholder={t("portTraffic.keywordPh")}
|
||||
onChange={(e) => setLogKeyword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{logQuery.isLoading ? (
|
||||
<p className="muted">…</p>
|
||||
) : !(logQuery.data?.items || []).length ? (
|
||||
) : !logEvents.length ? (
|
||||
<p className="muted">{t("portTraffic.logEmpty")}</p>
|
||||
) : !filteredLogEvents.length ? (
|
||||
<p className="muted">{t("portTraffic.logEmpty")}</p>
|
||||
) : (
|
||||
<div className="pt-log-modal__list">
|
||||
{(logQuery.data?.items || []).map((ev) => {
|
||||
{filteredLogEvents.map((ev) => {
|
||||
const level = String(ev.level || "error").toLowerCase();
|
||||
const tone =
|
||||
level === "warn" || level === "warning"
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import {
|
|||
deleteFabricNodes,
|
||||
fetchFabricNodes,
|
||||
fetchTopologyTree,
|
||||
generateTopologySlices,
|
||||
matchFabricNodes,
|
||||
patchFabricNodeTags,
|
||||
} from "../../services/api";
|
||||
|
|
@ -14,7 +13,7 @@ import { queryKeys } from "../../constants/queryKeys";
|
|||
import { useI18n } from "../../i18n";
|
||||
import { useToast } from "../../hooks/useToast";
|
||||
import { openOrFocusModule } from "../../utils/moduleWindows";
|
||||
import type { FabricNodeSearchHit, SliceGenerateResult, TopologyTreeFolderItem } from "../../types";
|
||||
import type { FabricNodeSearchHit, TopologyTreeFolderItem } from "../../types";
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
|
|
@ -37,15 +36,42 @@ function isFabricNodeDeletable(n: FabricNodeSearchHit): boolean {
|
|||
return src === "lldp" || src === "topology" || src === "webcrt";
|
||||
}
|
||||
|
||||
const ROLE_OPTIONS = ["core", "aggregation", "access", "unknown"] as const;
|
||||
const LEVEL_PRESETS = [
|
||||
{ value: "0", labelKey: "topoClassify.levelExternal" },
|
||||
{ value: "1", labelKey: "topoClassify.levelCore" },
|
||||
{ value: "1.1", labelKey: "topoClassify.levelCoreSub" },
|
||||
{ value: "2", labelKey: "topoClassify.levelAgg" },
|
||||
{ value: "2.1", labelKey: "topoClassify.levelAggSub" },
|
||||
{ value: "3", labelKey: "topoClassify.levelAccess" },
|
||||
{ value: "3.1", labelKey: "topoClassify.levelAccessSub" },
|
||||
] as const;
|
||||
|
||||
function roleLabel(t: (k: string) => string, role: string): string {
|
||||
const s = String(role || "").trim().toLowerCase();
|
||||
if (s === "core") return t("topoClassify.roleCore");
|
||||
if (s === "aggregation") return t("topoClassify.roleAggregation");
|
||||
if (s === "access") return t("topoClassify.roleAccess");
|
||||
if (s === "unknown") return t("topoClassify.roleUnknown");
|
||||
return role || "-";
|
||||
const FILTER_MAJOR = [
|
||||
{ value: "", labelKey: "topoClassify.filterLevelAll" },
|
||||
{ value: "0", labelKey: "topoClassify.levelExternal" },
|
||||
{ value: "1", labelKey: "topoClassify.levelCore" },
|
||||
{ value: "2", labelKey: "topoClassify.levelAgg" },
|
||||
{ value: "3", labelKey: "topoClassify.levelAccess" },
|
||||
] as const;
|
||||
|
||||
function formatLevel(level: number | null | undefined): string {
|
||||
if (level === null || level === undefined || Number.isNaN(Number(level))) return "—";
|
||||
const n = Number(level);
|
||||
if (Math.abs(n - Math.round(n)) < 1e-9) return String(Math.round(n));
|
||||
return String(Math.round(n * 10) / 10);
|
||||
}
|
||||
|
||||
function parseLevelInput(raw: string): number | null {
|
||||
const s = String(raw || "").trim();
|
||||
if (!s) return null;
|
||||
const n = Number(s);
|
||||
if (!Number.isFinite(n) || n < 0 || n > 99.9) throw new Error("level_invalid");
|
||||
return Math.round(n * 10) / 10;
|
||||
}
|
||||
|
||||
function levelPresetLabel(t: (k: string) => string, value: string): string {
|
||||
const hit = LEVEL_PRESETS.find((p) => p.value === value);
|
||||
return hit ? t(hit.labelKey) : value;
|
||||
}
|
||||
|
||||
function managedSourceLabel(t: (k: string) => string, source: string): string {
|
||||
|
|
@ -64,7 +90,7 @@ export function TopologyClassifyPage() {
|
|||
const queryClient = useQueryClient();
|
||||
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [filterRole, setFilterRole] = useState("");
|
||||
const [filterMajor, setFilterMajor] = useState("");
|
||||
const [filterRegion, setFilterRegion] = useState("");
|
||||
const [unmatched, setUnmatched] = useState("");
|
||||
const [linkStatus, setLinkStatus] = useState("");
|
||||
|
|
@ -89,23 +115,15 @@ export function TopologyClassifyPage() {
|
|||
const [matchField, setMatchField] = useState<"name" | "ip" | "name_ip">("name");
|
||||
const [matchedIds, setMatchedIds] = useState<string[]>([]);
|
||||
const [matchTotal, setMatchTotal] = useState(0);
|
||||
const [assignRole, setAssignRole] = useState("core");
|
||||
const [assignLevel, setAssignLevel] = useState("1");
|
||||
const [assignRegion, setAssignRegion] = useState("");
|
||||
const [assignWhat, setAssignWhat] = useState<"role" | "region" | "both">("role");
|
||||
const [assignWhat, setAssignWhat] = useState<"level" | "region" | "both">("level");
|
||||
|
||||
const [selected, setSelected] = useState<Record<string, boolean>>({});
|
||||
const [editingId, setEditingId] = useState("");
|
||||
const [editRole, setEditRole] = useState("");
|
||||
const [editLevel, setEditLevel] = useState("");
|
||||
const [editRegion, setEditRegion] = useState("");
|
||||
|
||||
const [sliceFolderId, setSliceFolderId] = useState("");
|
||||
const [sliceTemplate, setSliceTemplate] = useState<"core_only" | "core_agg" | "agg_access">(
|
||||
"core_agg",
|
||||
);
|
||||
const [slicePreview, setSlicePreview] = useState<SliceGenerateResult | null>(null);
|
||||
const [seedPhysical, setSeedPhysical] = useState(true);
|
||||
const [showSlices, setShowSlices] = useState(false);
|
||||
|
||||
const treeQuery = useQuery({
|
||||
queryKey: queryKeys.topologyTree,
|
||||
queryFn: fetchTopologyTree,
|
||||
|
|
@ -120,16 +138,17 @@ export function TopologyClassifyPage() {
|
|||
const listQuery = useQuery({
|
||||
queryKey: queryKeys.fabricNodeInventory(
|
||||
keyword,
|
||||
filterRole,
|
||||
"",
|
||||
filterRegion,
|
||||
unmatched,
|
||||
linkStatus,
|
||||
page,
|
||||
filterMajor,
|
||||
),
|
||||
queryFn: () =>
|
||||
fetchFabricNodes({
|
||||
keyword,
|
||||
role: filterRole,
|
||||
levelMajor: filterMajor || undefined,
|
||||
regionFolderId: filterRegion,
|
||||
unmatched,
|
||||
linkStatus,
|
||||
|
|
@ -163,7 +182,9 @@ export function TopologyClassifyPage() {
|
|||
fabric_node_ids: ids.length ? ids : matchedIds,
|
||||
dry_run: false,
|
||||
};
|
||||
if (assignWhat === "role" || assignWhat === "both") body.role = assignRole;
|
||||
if (assignWhat === "level" || assignWhat === "both") {
|
||||
body.level = parseLevelInput(assignLevel);
|
||||
}
|
||||
if (assignWhat === "region" || assignWhat === "both") {
|
||||
body.region_folder_id = assignRegion || "";
|
||||
}
|
||||
|
|
@ -180,11 +201,18 @@ export function TopologyClassifyPage() {
|
|||
});
|
||||
|
||||
const patchMut = useMutation({
|
||||
mutationFn: () =>
|
||||
patchFabricNodeTags(editingId, {
|
||||
role: editRole,
|
||||
mutationFn: () => {
|
||||
let level: number | null;
|
||||
try {
|
||||
level = parseLevelInput(editLevel);
|
||||
} catch {
|
||||
throw new Error(t("topoClassify.levelInvalid"));
|
||||
}
|
||||
return patchFabricNodeTags(editingId, {
|
||||
level,
|
||||
region_folder_id: editRegion || "",
|
||||
}),
|
||||
});
|
||||
},
|
||||
onSuccess: async () => {
|
||||
showOk(t("topoClassify.rowSaved"));
|
||||
setEditingId("");
|
||||
|
|
@ -213,36 +241,6 @@ export function TopologyClassifyPage() {
|
|||
onError: (err) => showError(String(err)),
|
||||
});
|
||||
|
||||
const slicePreviewMut = useMutation({
|
||||
mutationFn: () =>
|
||||
generateTopologySlices({
|
||||
folder_id: sliceFolderId,
|
||||
template: sliceTemplate,
|
||||
dry_run: true,
|
||||
max_nodes: 300,
|
||||
seed_physical_cores: seedPhysical,
|
||||
}),
|
||||
onSuccess: (out) => setSlicePreview(out),
|
||||
onError: (err) => showError(String(err)),
|
||||
});
|
||||
|
||||
const sliceApplyMut = useMutation({
|
||||
mutationFn: () =>
|
||||
generateTopologySlices({
|
||||
folder_id: sliceFolderId,
|
||||
template: sliceTemplate,
|
||||
dry_run: false,
|
||||
max_nodes: 300,
|
||||
seed_physical_cores: seedPhysical,
|
||||
}),
|
||||
onSuccess: async (out) => {
|
||||
showOk(t("topoClassify.sliceOk").replace("{{count}}", String(out.created_view_ids.length)));
|
||||
setSlicePreview(out);
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyTree });
|
||||
},
|
||||
onError: (err) => showError(String(err)),
|
||||
});
|
||||
|
||||
const items = listQuery.data?.items || [];
|
||||
const deletableSelected = items
|
||||
.filter((n) => selected[n.id] && isFabricNodeDeletable(n))
|
||||
|
|
@ -254,7 +252,7 @@ export function TopologyClassifyPage() {
|
|||
|
||||
const startEdit = (n: FabricNodeSearchHit) => {
|
||||
setEditingId(n.id);
|
||||
setEditRole(n.role || "");
|
||||
setEditLevel(n.level == null ? "" : formatLevel(n.level));
|
||||
setEditRegion(n.region_folder_id || "");
|
||||
};
|
||||
|
||||
|
|
@ -278,7 +276,17 @@ export function TopologyClassifyPage() {
|
|||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
onClick={() => openOrFocusModule({ moduleId: "topology", path: "/topology" })}
|
||||
onClick={() => {
|
||||
const regionId = filterRegion || assignRegion || "";
|
||||
const folder = regions.find((r) => r.id === regionId);
|
||||
const views = folder?.views || [];
|
||||
const primary =
|
||||
views.find((v) => String(v.kind || "") === "physical") || views[0];
|
||||
const path = primary?.id
|
||||
? `/topology?view=${encodeURIComponent(primary.id)}`
|
||||
: "/topology";
|
||||
openOrFocusModule({ moduleId: "topology", path });
|
||||
}}
|
||||
>
|
||||
{t("topoClassify.openTopo")}
|
||||
</button>
|
||||
|
|
@ -299,16 +307,15 @@ export function TopologyClassifyPage() {
|
|||
/>
|
||||
<select
|
||||
className="input"
|
||||
value={filterRole}
|
||||
value={filterMajor}
|
||||
onChange={(e) => {
|
||||
setFilterRole(e.target.value);
|
||||
setFilterMajor(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<option value="">{t("topoClassify.filterRoleAll")}</option>
|
||||
{ROLE_OPTIONS.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{roleLabel(t, r)}
|
||||
{FILTER_MAJOR.map((r) => (
|
||||
<option key={r.value || "all"} value={r.value}>
|
||||
{t(r.labelKey)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
|
@ -337,7 +344,7 @@ export function TopologyClassifyPage() {
|
|||
>
|
||||
<option value="">{t("topoClassify.filterUnmatchedOff")}</option>
|
||||
<option value="any">{t("topoClassify.kindAny")}</option>
|
||||
<option value="role">{t("topoClassify.kindRole")}</option>
|
||||
<option value="level">{t("topoClassify.kindLevel")}</option>
|
||||
<option value="region">{t("topoClassify.kindRegion")}</option>
|
||||
</select>
|
||||
<select
|
||||
|
|
@ -390,26 +397,26 @@ export function TopologyClassifyPage() {
|
|||
<select
|
||||
className="input"
|
||||
value={assignWhat}
|
||||
onChange={(e) => setAssignWhat(e.target.value as "role" | "region" | "both")}
|
||||
onChange={(e) => setAssignWhat(e.target.value as "level" | "region" | "both")}
|
||||
>
|
||||
<option value="role">{t("topoClassify.assignRole")}</option>
|
||||
<option value="level">{t("topoClassify.assignLevel")}</option>
|
||||
<option value="region">{t("topoClassify.assignRegion")}</option>
|
||||
<option value="both">{t("topoClassify.assignBoth")}</option>
|
||||
</select>
|
||||
{assignWhat !== "region" ? (
|
||||
<select
|
||||
className="input"
|
||||
value={assignRole}
|
||||
onChange={(e) => setAssignRole(e.target.value)}
|
||||
value={assignLevel}
|
||||
onChange={(e) => setAssignLevel(e.target.value)}
|
||||
>
|
||||
{ROLE_OPTIONS.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{roleLabel(t, r)}
|
||||
{LEVEL_PRESETS.map((r) => (
|
||||
<option key={r.value} value={r.value}>
|
||||
{levelPresetLabel(t, r.value)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : null}
|
||||
{assignWhat !== "role" ? (
|
||||
{assignWhat !== "level" ? (
|
||||
<select
|
||||
className="input"
|
||||
value={assignRegion}
|
||||
|
|
@ -475,7 +482,7 @@ export function TopologyClassifyPage() {
|
|||
<th>{t("topoClassify.colNe")}</th>
|
||||
<th>IP</th>
|
||||
<th>{t("topoClassify.colLink")}</th>
|
||||
<th>{t("topoClassify.colRole")}</th>
|
||||
<th>{t("topoClassify.colLevel")}</th>
|
||||
<th>{t("topoClassify.colRegion")}</th>
|
||||
<th>{t("topoClassify.colActions")}</th>
|
||||
</tr>
|
||||
|
|
@ -503,20 +510,14 @@ export function TopologyClassifyPage() {
|
|||
</td>
|
||||
<td>
|
||||
{editing ? (
|
||||
<select
|
||||
<input
|
||||
className="input"
|
||||
value={editRole}
|
||||
onChange={(e) => setEditRole(e.target.value)}
|
||||
>
|
||||
<option value="">-</option>
|
||||
{ROLE_OPTIONS.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{roleLabel(t, r)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
value={editLevel}
|
||||
placeholder="1 / 1.1 / 2.1"
|
||||
onChange={(e) => setEditLevel(e.target.value)}
|
||||
/>
|
||||
) : (
|
||||
roleLabel(t, n.role || "")
|
||||
formatLevel(n.level)
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
|
|
@ -627,95 +628,6 @@ export function TopologyClassifyPage() {
|
|||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="topo-classify__section">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--sm btn--ghost"
|
||||
onClick={() => setShowSlices((v) => !v)}
|
||||
>
|
||||
{showSlices ? t("topoClassify.hideSlices") : t("topoClassify.showSlices")}
|
||||
</button>
|
||||
{showSlices ? (
|
||||
<>
|
||||
<h3>{t("topoClassify.slices")}</h3>
|
||||
<div className="topo-classify__draft">
|
||||
<select
|
||||
className="input"
|
||||
value={sliceFolderId}
|
||||
onChange={(e) => {
|
||||
setSliceFolderId(e.target.value);
|
||||
setSlicePreview(null);
|
||||
}}
|
||||
>
|
||||
<option value="">{t("topoClassify.pickRegion")}</option>
|
||||
{regions.map((r) => (
|
||||
<option key={r.id} value={r.id}>
|
||||
{r.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="input"
|
||||
value={sliceTemplate}
|
||||
onChange={(e) =>
|
||||
setSliceTemplate(e.target.value as "core_only" | "core_agg" | "agg_access")
|
||||
}
|
||||
>
|
||||
<option value="core_only">{t("topoClassify.tplCore")}</option>
|
||||
<option value="core_agg">{t("topoClassify.tplCoreAgg")}</option>
|
||||
<option value="agg_access">{t("topoClassify.tplAggAcc")}</option>
|
||||
</select>
|
||||
<label className="topo-classify__check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={seedPhysical}
|
||||
onChange={(e) => setSeedPhysical(e.target.checked)}
|
||||
/>
|
||||
{t("topoClassify.seedPhysical")}
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--sm btn--ghost"
|
||||
disabled={!sliceFolderId || slicePreviewMut.isPending}
|
||||
onClick={() => slicePreviewMut.mutate()}
|
||||
>
|
||||
{t("topoClassify.slicePreview")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--sm"
|
||||
disabled={!sliceFolderId || sliceApplyMut.isPending}
|
||||
onClick={() => {
|
||||
if (window.confirm(t("topoClassify.sliceConfirm"))) sliceApplyMut.mutate();
|
||||
}}
|
||||
>
|
||||
{t("topoClassify.sliceApply")}
|
||||
</button>
|
||||
</div>
|
||||
{slicePreview ? (
|
||||
<div className="topo-classify__preview">
|
||||
<p className="panel__hint">
|
||||
{t("topoClassify.sliceStats")
|
||||
.replace("{{maps}}", String(slicePreview.map_count))
|
||||
.replace("{{overlap}}", String(slicePreview.overlap_node_count))}
|
||||
</p>
|
||||
<ul className="topo-classify__slice-list">
|
||||
{slicePreview.maps.map((m) => (
|
||||
<li key={m.name}>
|
||||
<strong>{m.name}</strong>
|
||||
{" · "}
|
||||
{roleLabel(t, m.role)}
|
||||
{" · "}
|
||||
{t("topoClassify.nodeCount").replace("{{count}}", String(m.node_count))}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -291,6 +291,7 @@ export type TopologyReactFlowViewProps = {
|
|||
setCanvasZoom: (z: number) => void;
|
||||
scheduleFlatViewportRefresh: () => void;
|
||||
toggleFullscreen: () => void | Promise<void>;
|
||||
readOnly?: boolean;
|
||||
};
|
||||
|
||||
export default function TopologyReactFlowView(props: TopologyReactFlowViewProps) {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import type { NeNodeData } from "./TopologyReactFlowView";
|
|||
import type { ToolMode } from "./toolMode";
|
||||
|
||||
export type TopologyToolbarProps = {
|
||||
readOnly?: boolean;
|
||||
breadcrumbFolders: TopologyTreeFolderItem[];
|
||||
activeView: TopologyTreeViewItem | null | undefined;
|
||||
activeRegion: TopologyTreeFolderItem | null | undefined;
|
||||
|
|
@ -51,6 +52,7 @@ export type TopologyToolbarProps = {
|
|||
};
|
||||
|
||||
export function TopologyToolbar({
|
||||
readOnly = false,
|
||||
breadcrumbFolders,
|
||||
activeView,
|
||||
activeRegion,
|
||||
|
|
@ -186,8 +188,14 @@ export function TopologyToolbar({
|
|||
<button
|
||||
type="button"
|
||||
className="btn btn--sm"
|
||||
disabled={isWorldFlatCanvas}
|
||||
title={isWorldFlatCanvas ? t("topology.worldMapNoDirectNes") : undefined}
|
||||
disabled={readOnly || isWorldFlatCanvas}
|
||||
title={
|
||||
readOnly
|
||||
? t("topology.readOnlyHint")
|
||||
: isWorldFlatCanvas
|
||||
? t("topology.worldMapNoDirectNes")
|
||||
: undefined
|
||||
}
|
||||
onClick={onAddNe}
|
||||
>
|
||||
{t("topology.addNe")}
|
||||
|
|
@ -195,8 +203,14 @@ export function TopologyToolbar({
|
|||
<button
|
||||
type="button"
|
||||
className="btn btn--sm"
|
||||
disabled={isWorldFlatCanvas}
|
||||
title={isWorldFlatCanvas ? t("topology.worldMapNoDirectNes") : undefined}
|
||||
disabled={readOnly || isWorldFlatCanvas}
|
||||
title={
|
||||
readOnly
|
||||
? t("topology.readOnlyHint")
|
||||
: isWorldFlatCanvas
|
||||
? t("topology.worldMapNoDirectNes")
|
||||
: undefined
|
||||
}
|
||||
onClick={handleCreateNe}
|
||||
>
|
||||
{t("topology.createNe")}
|
||||
|
|
@ -207,7 +221,7 @@ export function TopologyToolbar({
|
|||
<button
|
||||
type="button"
|
||||
className="btn btn--sm btn--ghost"
|
||||
disabled={!canUndo}
|
||||
disabled={readOnly || !canUndo}
|
||||
onClick={onUndo}
|
||||
title="Ctrl+Z"
|
||||
>
|
||||
|
|
@ -216,7 +230,7 @@ export function TopologyToolbar({
|
|||
<button
|
||||
type="button"
|
||||
className="btn btn--sm btn--ghost"
|
||||
disabled={!canRedo}
|
||||
disabled={readOnly || !canRedo}
|
||||
onClick={onRedo}
|
||||
title="Ctrl+Y"
|
||||
>
|
||||
|
|
@ -225,9 +239,9 @@ export function TopologyToolbar({
|
|||
<button
|
||||
type="button"
|
||||
className={`btn btn--sm${dirty ? "" : " btn--ghost"}`}
|
||||
disabled={savePending || !dirty}
|
||||
disabled={readOnly || savePending || !dirty}
|
||||
onClick={onSave}
|
||||
title="Ctrl+S"
|
||||
title={readOnly ? t("topology.readOnlyHint") : "Ctrl+S"}
|
||||
>
|
||||
{savePending ? t("topology.saving") : dirty ? t("topology.saveDirty") : t("topology.save")}
|
||||
</button>
|
||||
|
|
@ -265,8 +279,8 @@ export function TopologyToolbar({
|
|||
<button
|
||||
type="button"
|
||||
className="btn btn--sm btn--ghost"
|
||||
disabled={!staleEdgeCount}
|
||||
title={t("topology.removeStaleHint")}
|
||||
disabled={readOnly || !staleEdgeCount}
|
||||
title={readOnly ? t("topology.readOnlyHint") : t("topology.removeStaleHint")}
|
||||
onClick={() => void onRemoveStale()}
|
||||
>
|
||||
{t("topology.removeStale").replace("{{count}}", String(staleEdgeCount))}
|
||||
|
|
@ -286,8 +300,9 @@ export function TopologyToolbar({
|
|||
key={mode}
|
||||
type="button"
|
||||
className={`topo-tools__btn${toolMode === mode ? " is-active" : ""}`}
|
||||
title={`${label} (${key})`}
|
||||
title={`${label} (${key})${readOnly && mode === "connect" ? ` — ${t("topology.readOnlyHint")}` : ""}`}
|
||||
aria-pressed={toolMode === mode}
|
||||
disabled={readOnly && mode === "connect"}
|
||||
onClick={() => {
|
||||
onToolModeChange(mode);
|
||||
onConnectClickReset();
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import type { RefObject } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { RefObject } from "react";
|
||||
import type { Node } from "@xyflow/react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { queryKeys } from "../../constants/queryKeys";
|
||||
import { useI18n } from "../../i18n";
|
||||
import { useToast } from "../../hooks/useToast";
|
||||
import { updateLldpCollectPolicy } from "../../services/api";
|
||||
import {
|
||||
DEFAULT_CANVAS_BG,
|
||||
|
|
@ -87,6 +88,7 @@ export type TopologyViewToolsProps = {
|
|||
|
||||
export function TopologyViewTools(props: TopologyViewToolsProps) {
|
||||
const { t } = useI18n();
|
||||
const { showError } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const {
|
||||
fullscreen,
|
||||
|
|
@ -225,7 +227,7 @@ export function TopologyViewTools(props: TopologyViewToolsProps) {
|
|||
void queryClient.invalidateQueries({ queryKey: queryKeys.lldpCollectDashboard });
|
||||
})
|
||||
.catch(() => {
|
||||
/* local toggle still applies to this canvas discover */
|
||||
showError(t("topology.policySyncFailed"));
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useQuery, type QueryClient } from "@tanstack/react-query";
|
||||
import type { Edge, Node, ReactFlowInstance } from "@xyflow/react";
|
||||
import { fetchTopologyGraph } from "../../../services/api";
|
||||
import { fetchTopologyGraph, formatErr } from "../../../services/api";
|
||||
import { queryKeys } from "../../../constants/queryKeys";
|
||||
import type { TopologyTreeViewItem, TopologyViewGraph, TopologyWorldTransform } from "../../../types";
|
||||
import {
|
||||
|
|
@ -493,6 +493,8 @@ export function useTopologyCanvas(opts: UseTopologyCanvasOptions) {
|
|||
const showWorldScatter =
|
||||
isWorldFlatCanvas && worldVisualLod !== "full" && worldScatter.length > 0;
|
||||
const canvasGraphLoading = Boolean(mapId) && (graphQuery.isPending || !graphQuery.data);
|
||||
const canvasGraphError = Boolean(mapId) && graphQuery.isError;
|
||||
const canvasGraphErrorMsg = graphQuery.error ? formatErr(graphQuery.error) : "";
|
||||
const canvasGraphEmpty =
|
||||
Boolean(mapId) &&
|
||||
graphQuery.isSuccess &&
|
||||
|
|
@ -551,6 +553,8 @@ export function useTopologyCanvas(opts: UseTopologyCanvasOptions) {
|
|||
worldDockMe,
|
||||
showWorldScatter,
|
||||
canvasGraphLoading,
|
||||
canvasGraphError,
|
||||
canvasGraphErrorMsg,
|
||||
canvasGraphEmpty,
|
||||
worldNeedsApply,
|
||||
canvasGraphRefreshing,
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
removeTopologyViewNodes,
|
||||
startLldpDiscover,
|
||||
stopLldpCollectJob,
|
||||
formatErr,
|
||||
} from "../../../services/api";
|
||||
import type {
|
||||
TopologyDiscoverJob,
|
||||
|
|
@ -181,6 +182,11 @@ export function useTopologyDiscover(opts: UseTopologyDiscoverOptions) {
|
|||
setDiscoverJobId(jobStart.id);
|
||||
let job: TopologyDiscoverJob = jobStart;
|
||||
let cancelled = false;
|
||||
const isTerminalStatus = (status: string) =>
|
||||
status === "done" ||
|
||||
status === "failed" ||
|
||||
status === "cancelled" ||
|
||||
status === "stopped";
|
||||
for (let i = 0; i < 600; i++) {
|
||||
if (discoverAbortRef.current) {
|
||||
cancelled = true;
|
||||
|
|
@ -202,12 +208,7 @@ export function useTopologyDiscover(opts: UseTopologyDiscoverOptions) {
|
|||
neIp: job.items?.[job.items.length - 1]?.ne_ip || p.neIp,
|
||||
}));
|
||||
setDiscoverLiveResults((job.items || []) as TopologyDiscoverNeResult[]);
|
||||
if (
|
||||
job.status === "done" ||
|
||||
job.status === "failed" ||
|
||||
job.status === "cancelled" ||
|
||||
job.status === "stopped"
|
||||
) {
|
||||
if (isTerminalStatus(job.status)) {
|
||||
if (job.status === "cancelled" || job.status === "stopped") cancelled = true;
|
||||
break;
|
||||
}
|
||||
|
|
@ -227,6 +228,9 @@ export function useTopologyDiscover(opts: UseTopologyDiscoverOptions) {
|
|||
});
|
||||
return;
|
||||
}
|
||||
if (!isTerminalStatus(job.status)) {
|
||||
throw new Error(t("topology.discoverTimeout"));
|
||||
}
|
||||
if (job.status === "failed") {
|
||||
throw new Error(job.error || "discover_failed");
|
||||
}
|
||||
|
|
@ -293,8 +297,9 @@ export function useTopologyDiscover(opts: UseTopologyDiscoverOptions) {
|
|||
if (discoverAbortRef.current) {
|
||||
setDiscoverError(t("topology.discoverCancelled"));
|
||||
} else {
|
||||
setDiscoverError(String(err));
|
||||
showError(t("topology.discoverFail").replace("{{detail}}", String(err)));
|
||||
const detail = formatErr(err);
|
||||
setDiscoverError(detail);
|
||||
showError(t("topology.discoverFail").replace("{{detail}}", detail));
|
||||
}
|
||||
} finally {
|
||||
setDiscovering(false);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type {
|
|||
CollectionJobDetail,
|
||||
CollectionJobItem,
|
||||
CollectionDashboard,
|
||||
CollectionPolicy,
|
||||
CollectionRunList,
|
||||
EligibleNeItem,
|
||||
IntegrationStatus,
|
||||
|
|
@ -147,6 +148,13 @@ export class ApiRequestError extends Error {
|
|||
}
|
||||
}
|
||||
|
||||
/** User-facing message from API or thrown errors (prefers ApiRequestError.message). */
|
||||
export function formatErr(err: unknown): string {
|
||||
if (err instanceof ApiRequestError) return err.message;
|
||||
if (err instanceof Error) return err.message;
|
||||
return String(err);
|
||||
}
|
||||
|
||||
export function formatApiDetail(detail: unknown): string {
|
||||
if (typeof detail === "string") return detail;
|
||||
if (Array.isArray(detail)) {
|
||||
|
|
@ -175,16 +183,18 @@ export function formatApiDetail(detail: unknown): string {
|
|||
}
|
||||
|
||||
export const apiGet = async <T,>(path: string): Promise<T> => {
|
||||
let res = await fetch(path, { headers: authHeaders(), credentials: fetchCreds });
|
||||
const doFetch = () => fetch(path, { headers: authHeaders(), credentials: fetchCreds });
|
||||
let res = await doFetch();
|
||||
if (res.status === 401 && shouldAttemptRefresh(path) && (await tryRefreshAccessToken())) {
|
||||
res = await fetch(path, { headers: authHeaders(), credentials: fetchCreds });
|
||||
res = await doFetch();
|
||||
}
|
||||
const data = await parseApiResponse(res);
|
||||
if (res.status === 401) {
|
||||
handleUnauthorized(path);
|
||||
throw new Error("401 unauthorized");
|
||||
throw new ApiRequestError(401, data.detail || "unauthorized");
|
||||
}
|
||||
if (!res.ok) throw new Error(`${res.status} ${path}`);
|
||||
return (await res.json()) as T;
|
||||
if (!res.ok) throw new ApiRequestError(res.status, data.detail || `${res.status} ${path}`);
|
||||
return data as T;
|
||||
};
|
||||
|
||||
export const apiPost = async <T,>(path: string, body: unknown): Promise<T> => {
|
||||
|
|
@ -600,9 +610,12 @@ export const importManagedNe = async (file: File): Promise<ManagedNeImportResult
|
|||
handleUnauthorized(path);
|
||||
throw new Error("unauthorized");
|
||||
}
|
||||
const text = await res.text();
|
||||
const data = text ? JSON.parse(text) : {};
|
||||
if (!res.ok) throw new Error(String((data as { detail?: string }).detail || `${res.status} import`));
|
||||
const data = await parseApiResponse(res);
|
||||
if (res.status === 401) {
|
||||
handleUnauthorized(path);
|
||||
throw new ApiRequestError(401, data.detail || "unauthorized");
|
||||
}
|
||||
if (!res.ok) throw new ApiRequestError(res.status, data.detail || `${res.status} import`);
|
||||
return data as ManagedNeImportResult;
|
||||
};
|
||||
|
||||
|
|
@ -624,10 +637,17 @@ export const createNeCollection = (body: {
|
|||
}) =>
|
||||
apiPost<CollectionJobItem>("/v1/ne-collections", body);
|
||||
|
||||
export const fetchNeCollections = (params: { page: number; pageSize: number }) => {
|
||||
export const fetchNeCollections = (params: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
status?: string;
|
||||
keyword?: string;
|
||||
}) => {
|
||||
const p = new URLSearchParams();
|
||||
p.set("page", String(Math.max(1, params.page)));
|
||||
p.set("page_size", String(Math.max(1, Math.min(100, params.pageSize))));
|
||||
if (params.status?.trim()) p.set("status", params.status.trim());
|
||||
if (params.keyword?.trim()) p.set("keyword", params.keyword.trim());
|
||||
return apiGet<{ total: number; page: number; page_size: number; items: CollectionJobItem[] }>(
|
||||
`/v1/ne-collections?${p.toString()}`,
|
||||
);
|
||||
|
|
@ -636,6 +656,14 @@ export const fetchNeCollections = (params: { page: number; pageSize: number }) =
|
|||
export const fetchCollectionDashboard = () =>
|
||||
apiGet<CollectionDashboard>("/v1/ne-collections/dashboard");
|
||||
|
||||
export const fetchCollectionPolicy = () => apiGet<CollectionPolicy>("/v1/ne-collections/policy");
|
||||
|
||||
export const updateCollectionPolicy = (body: Partial<CollectionPolicy>) =>
|
||||
apiPut<CollectionPolicy>("/v1/ne-collections/policy", body);
|
||||
|
||||
export const startCollectionFromPolicy = () =>
|
||||
apiPost<CollectionJobItem>("/v1/ne-collections/start-from-policy", {});
|
||||
|
||||
export const fetchCollectionJob = (jobId: string) => apiGet<CollectionJobDetail>(`/v1/ne-collections/${jobId}`);
|
||||
|
||||
export const fetchCollectionRuns = (params: {
|
||||
|
|
@ -1233,6 +1261,8 @@ export const fetchFabricSummary = () => apiGet<FabricSummary>("/v1/topology/fabr
|
|||
export const fetchFabricNodes = (params?: {
|
||||
keyword?: string;
|
||||
role?: string;
|
||||
level?: string;
|
||||
levelMajor?: string;
|
||||
regionFolderId?: string;
|
||||
unmatched?: string;
|
||||
linkStatus?: string;
|
||||
|
|
@ -1242,6 +1272,8 @@ export const fetchFabricNodes = (params?: {
|
|||
const p = new URLSearchParams();
|
||||
if (params?.keyword) p.set("keyword", params.keyword);
|
||||
if (params?.role) p.set("role", params.role);
|
||||
if (params?.level) p.set("level", params.level);
|
||||
if (params?.levelMajor) p.set("level_major", params.levelMajor);
|
||||
if (params?.regionFolderId) p.set("region_folder_id", params.regionFolderId);
|
||||
if (params?.unmatched) p.set("unmatched", params.unmatched);
|
||||
if (params?.linkStatus) p.set("link_status", params.linkStatus);
|
||||
|
|
@ -1272,6 +1304,7 @@ export const bulkTagFabricNodes = (body: {
|
|||
fabric_node_ids?: string[];
|
||||
pattern?: string;
|
||||
match_field?: string;
|
||||
level?: number | null;
|
||||
role?: string | null;
|
||||
region_folder_id?: string | null;
|
||||
dry_run?: boolean;
|
||||
|
|
@ -1280,6 +1313,7 @@ export const bulkTagFabricNodes = (body: {
|
|||
dry_run: boolean;
|
||||
matched: number;
|
||||
updated: number;
|
||||
level?: number | null;
|
||||
role?: string | null;
|
||||
region_folder_id?: string | null;
|
||||
samples: Array<Record<string, string>>;
|
||||
|
|
@ -1287,7 +1321,7 @@ export const bulkTagFabricNodes = (body: {
|
|||
|
||||
export const patchFabricNodeTags = (
|
||||
fabricNodeId: string,
|
||||
body: { role?: string | null; region_folder_id?: string | null },
|
||||
body: { level?: number | null; role?: string | null; region_folder_id?: string | null },
|
||||
) =>
|
||||
apiPatch<import("../types").FabricNodeSearchHit>(
|
||||
`/v1/topology/fabric/nodes/${encodeURIComponent(fabricNodeId)}/tags`,
|
||||
|
|
@ -1317,14 +1351,6 @@ export const purgePlaceholderFabricNodes = (fabricNodeIds: string[]) =>
|
|||
fabric_node_ids: fabricNodeIds,
|
||||
});
|
||||
|
||||
export const generateTopologySlices = (body: {
|
||||
folder_id: string;
|
||||
template: "core_only" | "core_agg" | "agg_access";
|
||||
dry_run?: boolean;
|
||||
max_nodes?: number;
|
||||
seed_physical_cores?: boolean;
|
||||
}) => apiPost<import("../types").SliceGenerateResult>("/v1/topology/slices/generate", body);
|
||||
|
||||
export const searchFabricNodes = (params?: { q?: string; page?: number; pageSize?: number }) => {
|
||||
const p = new URLSearchParams();
|
||||
if (params?.q) p.set("q", params.q);
|
||||
|
|
@ -1399,10 +1425,17 @@ export const resumeLldpCollectJob = (jobId: string) =>
|
|||
export const stopLldpCollectJob = (jobId: string) =>
|
||||
apiPost<TopologyDiscoverJob>(`/v1/topology/lldp-collect/jobs/${encodeURIComponent(jobId)}/stop`, {});
|
||||
|
||||
export const fetchLldpCollectJobs = (params: { page?: number; pageSize?: number }) => {
|
||||
export const fetchLldpCollectJobs = (params: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
status?: string;
|
||||
keyword?: string;
|
||||
}) => {
|
||||
const p = new URLSearchParams();
|
||||
p.set("page", String(Math.max(1, Number(params.page || 1))));
|
||||
p.set("page_size", String(Math.max(1, Math.min(100, Number(params.pageSize || 20)))));
|
||||
if (params.status?.trim()) p.set("status", params.status.trim());
|
||||
if (params.keyword?.trim()) p.set("keyword", params.keyword.trim());
|
||||
return apiGet<{ total: number; page: number; page_size: number; items: LldpCollectJobSummary[] }>(
|
||||
`/v1/topology/lldp-collect/jobs?${p.toString()}`,
|
||||
);
|
||||
|
|
@ -1410,11 +1443,13 @@ export const fetchLldpCollectJobs = (params: { page?: number; pageSize?: number
|
|||
|
||||
export const fetchLldpCollectJob = (
|
||||
jobId: string,
|
||||
params?: { page?: number; pageSize?: number },
|
||||
params?: { page?: number; pageSize?: number; status?: string; keyword?: string },
|
||||
) => {
|
||||
const p = new URLSearchParams();
|
||||
p.set("page", String(Math.max(1, Number(params?.page || 1))));
|
||||
p.set("page_size", String(Math.max(1, Math.min(100, Number(params?.pageSize || 20)))));
|
||||
if (params?.status?.trim()) p.set("status", params.status.trim());
|
||||
if (params?.keyword?.trim()) p.set("keyword", params.keyword.trim());
|
||||
return apiGet<TopologyDiscoverJob>(
|
||||
`/v1/topology/lldp-collect/jobs/${encodeURIComponent(jobId)}?${p.toString()}`,
|
||||
);
|
||||
|
|
@ -1452,10 +1487,17 @@ export const fetchConfigSyncPolicy = () => apiGet<ConfigSyncPolicy>("/v1/config-
|
|||
export const updateConfigSyncPolicy = (body: Partial<ConfigSyncPolicy>) =>
|
||||
apiPut<ConfigSyncPolicy>("/v1/config-sync/policy", body);
|
||||
|
||||
export const fetchConfigSyncCycles = (params: { page?: number; pageSize?: number }) => {
|
||||
export const fetchConfigSyncCycles = (params: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
status?: string;
|
||||
keyword?: string;
|
||||
}) => {
|
||||
const p = new URLSearchParams();
|
||||
p.set("page", String(Math.max(1, Number(params.page || 1))));
|
||||
p.set("page_size", String(Math.max(1, Math.min(100, Number(params.pageSize || 20)))));
|
||||
if (params.status?.trim()) p.set("status", params.status.trim());
|
||||
if (params.keyword?.trim()) p.set("keyword", params.keyword.trim());
|
||||
return apiGet<{ total: number; page: number; page_size: number; items: ConfigSyncCycle[] }>(
|
||||
`/v1/config-sync/cycles?${p.toString()}`,
|
||||
);
|
||||
|
|
@ -1554,10 +1596,17 @@ export const downloadNeConfigSnapshot = async (
|
|||
export const fetchPortTrafficDashboard = () =>
|
||||
apiGet<PortTrafficDashboard>("/v1/port-traffic/dashboard");
|
||||
|
||||
export const fetchPortTrafficDevices = (params: { page?: number; pageSize?: number }) => {
|
||||
export const fetchPortTrafficDevices = (params: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
status?: string;
|
||||
keyword?: string;
|
||||
}) => {
|
||||
const p = new URLSearchParams();
|
||||
p.set("page", String(Math.max(1, Number(params.page || 1))));
|
||||
p.set("page_size", String(Math.max(1, Math.min(100, Number(params.pageSize || 20)))));
|
||||
if (params.status?.trim()) p.set("status", params.status.trim());
|
||||
if (params.keyword?.trim()) p.set("keyword", params.keyword.trim());
|
||||
return apiGet<{ total: number; page: number; page_size: number; items: PortTrafficDevice[] }>(
|
||||
`/v1/port-traffic/devices?${p.toString()}`,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -320,6 +320,7 @@ export type CollectionJobItem = {
|
|||
id: string;
|
||||
title: string;
|
||||
commands: string;
|
||||
trigger_mode?: string;
|
||||
status: string;
|
||||
ne_count: number;
|
||||
success_count: number;
|
||||
|
|
@ -336,6 +337,7 @@ export type CollectionJobSummary = {
|
|||
id: string;
|
||||
title: string;
|
||||
status: string;
|
||||
trigger_mode?: string;
|
||||
ne_count: number;
|
||||
success_count: number;
|
||||
fail_count: number;
|
||||
|
|
@ -345,11 +347,30 @@ export type CollectionJobSummary = {
|
|||
last_run_at?: string | null;
|
||||
};
|
||||
|
||||
export type CollectionTargetRef = {
|
||||
source: "managed" | "ume" | string;
|
||||
id: string;
|
||||
};
|
||||
|
||||
export type CollectionPolicy = {
|
||||
enabled: boolean;
|
||||
interval_days: number;
|
||||
interval_hours: number;
|
||||
scope_mode: "all" | "selected" | string;
|
||||
selected_targets: CollectionTargetRef[];
|
||||
title: string;
|
||||
commands: string;
|
||||
history_keep: number;
|
||||
updated_at?: string | null;
|
||||
};
|
||||
|
||||
export type CollectionDashboard = {
|
||||
job_count: number;
|
||||
active_count: number;
|
||||
running_job: CollectionJobSummary | null;
|
||||
last_job: CollectionJobSummary | null;
|
||||
next_due_at?: string | null;
|
||||
policy?: CollectionPolicy | null;
|
||||
};
|
||||
|
||||
export type CollectionRunItem = {
|
||||
|
|
@ -480,6 +501,8 @@ export type FabricNodeSearchHit = {
|
|||
vendor: string;
|
||||
managed_ne_id?: string;
|
||||
ume_ne_id?: string;
|
||||
/** Layout rank major.minor; null/undefined = unclassified */
|
||||
level?: number | null;
|
||||
role?: string;
|
||||
region_folder_id?: string | null;
|
||||
world_x?: number | null;
|
||||
|
|
@ -498,22 +521,6 @@ export type FabricNodeSearchHit = {
|
|||
}>;
|
||||
};
|
||||
|
||||
export type SliceGenerateResult = {
|
||||
folder_id: string;
|
||||
template: string;
|
||||
dry_run: boolean;
|
||||
map_count: number;
|
||||
overlap_node_count: number;
|
||||
created_view_ids: string[];
|
||||
maps: Array<{
|
||||
name: string;
|
||||
role: string;
|
||||
node_count: number;
|
||||
seed_fabric_node_ids: string[];
|
||||
member_fabric_node_ids: string[];
|
||||
}>;
|
||||
};
|
||||
|
||||
export type TopologyViewNodeItem = {
|
||||
fabric_node_id: string;
|
||||
managed_ne_id: string;
|
||||
|
|
|
|||
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