Refine business monitoring UI and protect asynchronous navigation
BIN
docs/reviews/assets/biz-state-batches.png
Normal file
|
After Width: | Height: | Size: 120 KiB |
BIN
docs/reviews/assets/biz-state-config-mobile.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
docs/reviews/assets/biz-state-config.png
Normal file
|
After Width: | Height: | Size: 102 KiB |
BIN
docs/reviews/assets/biz-state-error.png
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
docs/reviews/assets/biz-state-tasks.png
Normal file
|
After Width: | Height: | Size: 148 KiB |
BIN
docs/reviews/assets/biz-state-workbook-mobile.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
docs/reviews/assets/biz-state-workbook.png
Normal file
|
After Width: | Height: | Size: 77 KiB |
54
docs/reviews/biz-state-review-2026-10-11.md
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
# 业务监控分析与优化记录
|
||||||
|
|
||||||
|
检查日期:2026-10-11。范围:网络管理 → 割接对比 → 业务监控,包含任务配置、周期采集状态、批次保护、工作簿、原始日志、筛选与导出。
|
||||||
|
|
||||||
|
本次修改项目代码并使用内存 SQLite 和固定浏览器 API 响应验证,没有连接设备执行采集,也没有修改生产数据库。未新增解析模板或数据库字段。前后端应一起更新,前端轮询使用新增的 `/v1/biz-state/tasks/{id}/progress` 接口。
|
||||||
|
|
||||||
|
## 已修复的问题
|
||||||
|
|
||||||
|
1. **旧请求覆盖当前内容。** 任务、用途筛选、批次工作簿、指标表、采集详情、参数发现和原始日志增加请求序号或取消保护。关闭弹窗、切换任务/表后,慢响应不能重新打开弹窗或覆盖新内容。原始日志下载保留其实际批次 ID,避免根据当前另一层弹窗推断来源。
|
||||||
|
2. **后台采集不能及时反映、慢轮询叠加。** 同时观察服务端 `collect_running` 与当前页面启动的任务;周期任务和打开的任务保留低频查询,能够发现后台启动的采集。活动采集每轮完成后等待 4 秒,空闲等待 15 秒,再发起下一轮。跨 effect 重建共享在途标记,避免慢请求叠加。用途筛选隐藏的本地采集任务单独读取进度,避免误报采集结束。
|
||||||
|
3. **刷新覆盖未保存配置、批次数突然缩减。** 轮询只合并运行标志和时间,不重新写入周期、保留策略或监控项。进度刷新和首次加载统一读取最多 200 个最近批次,界面明确显示已载入数量与上限。
|
||||||
|
4. **加载失败看起来像空表或上一份数据。** 任务列表、任务配置、批次和指标表增加加载/错误/重试状态。指标查询失败清除旧行,不显示“本表无数据”;配置未就绪时操作区域不可交互。列表刷新失败保留当前行并显示错误。
|
||||||
|
5. **正在采集的批次可能被删除或清理。** `queued/running` 批次加入 `active_collection` 保护,单条删除、批量删除、保留清理均保留它们;采集中的任务返回 409。前端同步禁用选择与删除,保留基线、业务对比及割接引用保护。
|
||||||
|
6. **查询与导出边界错误。** `%`、`_`、反斜杠按字面值搜索;JSON 单列提取和 LLDP 分页排序更稳定。早于主命令保存的辅助命令失败保持可见。CSV 保留 `0`、`False`,正确引用逗号、双引号和 CR/LF;重复 `seq` 的导出不遗漏跨分块行,完整保留后续行才出现的列。
|
||||||
|
|
||||||
|
## 性能改动与验证范围
|
||||||
|
|
||||||
|
- 任务详情将逐监控项读取绑定改为一次批量读取。测试中 30 个监控项、60 个绑定由原来的 32 次查询降为 **3 次**;轻量进度接口为 **1 次**,不返回监控项和绑定。
|
||||||
|
- 批次保护只读取引用 ID 列,按本次批次范围限制查询,不再加载所有对比/割接运行的配置和汇总 JSON。
|
||||||
|
- 工作簿摘要不将原始 CLI 日志传输到 Python;只读取 SQL 计算的行数与是否存在日志。已有完整行数优先使用,原始日志在查看或导出时加载。回归使用 1 MB 日志,确认摘要结果集没有原文列。
|
||||||
|
- 通用指标 CSV 先分块扫描列名,再按 `(seq, id)` 游标每块 1000 行写入 ZIP;LLDP 分批写入。减少指标表的 ORM 对象、字典和完整 CSV 同时驻留。**最终 ZIP 仍驻留内存,命令原文导出仍需要原文;这不是端到端流式下载。** 两次扫描保留完整列名,正在写入的批次仍不保证导出为一致性快照。
|
||||||
|
- 同一厂商/型号的采集模板缓存 30 秒;轮询不重新读取模板。任务列表默认每页渲染 50 行,可选 20/50/100/200;列名去重使用 Set,命令搜索只计算一次。
|
||||||
|
- 指标搜索先回到第一页并等待防抖值一致,避免先请求旧页码、旧关键字。
|
||||||
|
|
||||||
|
以上是查询次数、请求并发及读取方式的验证,没有测量真实设备或生产 PostgreSQL 的延迟与内存。任务列表 API 仍返回当前用途下的完整任务集合;客户端分页减少 DOM,不减少列表响应大小。JSON 全文模糊搜索、旧日志的 SQL 行数计算、非常大的 ZIP 内存仍是后续基准重点。
|
||||||
|
|
||||||
|
## 界面调整
|
||||||
|
|
||||||
|
统一深色工业风:连续指标栏、细边框、小圆角、等宽任务标识、紧凑表头及明确的异常提示。任务配置使用响应式网格;采集周期数值与单位并排;工作簿横向页签、过滤工具栏和表格支持窄屏滚动;底部按钮可换行,避免被裁切。任务和工作簿页签支持方向键、Home/End,过滤控件增加可访问名称。
|
||||||
|
|
||||||
|
“采集成功 / 部分完成 / 采集失败”描述采集流程,不能据此判断业务验收通过;业务状态需要查看数据表或业务对比结果。
|
||||||
|
|
||||||
|
- [任务列表](assets/biz-state-tasks.png)
|
||||||
|
- [任务配置](assets/biz-state-config.png) · [390px 配置](assets/biz-state-config-mobile.png)
|
||||||
|
- [采集批次](assets/biz-state-batches.png)
|
||||||
|
- [工作簿](assets/biz-state-workbook.png) · [390px 工作簿](assets/biz-state-workbook-mobile.png)
|
||||||
|
- [查询失败与重试](assets/biz-state-error.png)
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
- 后端相关回归 **124 项通过**:新增监控服务、保留策略、日志导入、命令导出,以及停止/恢复/收尾/认领采集、双车道、spool、解析池与业务对比相关回归。
|
||||||
|
- `web/tests/bizState.browser.mjs`:**30 项回归通过**。固定 120 个任务、54 个批次、120 行指标,覆盖分页、配置保存、模板缓存、过期响应、搜索、重试、日志下载、后台采集和窄屏布局。4500ms 列表延迟下最大并发轮询为 1;轮询阶段任务完整详情仅首次打开请求 1 次,未保存的周期值保持不变。全部 `/v1` 请求被固定响应拦截。
|
||||||
|
- `node node_modules/vite/bin/vite.js build` 通过。项目原有主包超过 500 kB 提示仍在。
|
||||||
|
- `node node_modules/typescript/bin/tsc -b`:本页已有类型问题已修复;全项目仍被 `BizMigrationPage.tsx` 的 4 个既有错误阻断(两处未使用变量、两处字符串传给数值控件)。
|
||||||
|
- 本页 ESLint 从 16 个错误降为 15 个,保留既有 `any` 和 effect 状态同步问题及 1 个依赖警告;不宣称全项目 lint 通过。
|
||||||
|
|
||||||
|
浏览器回归运行方法(先启动 Vite,可通过环境变量指定 URL、截图目录和浏览器):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd web
|
||||||
|
node node_modules/vite/bin/vite.js --host 127.0.0.1 --port 5179
|
||||||
|
# 另一个终端;Playwright 已安装时直接运行,否则设置 NETX_PLAYWRIGHT_MODULE 为其模块路径。
|
||||||
|
node tests/bizState.browser.mjs
|
||||||
|
```
|
||||||
|
|
@ -174,6 +174,20 @@ const en = {
|
||||||
},
|
},
|
||||||
bizState: {
|
bizState: {
|
||||||
title: "Business monitor",
|
title: "Business monitor",
|
||||||
|
loadedTasks: "Loaded tasks",
|
||||||
|
loadedBatches: "Recent batches · {{n}} loaded (up to 200)",
|
||||||
|
collectErrors: "Recent collection errors",
|
||||||
|
retry: "Retry",
|
||||||
|
taskLoading: "Loading task configuration…",
|
||||||
|
tasksLoadError: "Could not load tasks",
|
||||||
|
taskLoadError: "Could not load task configuration",
|
||||||
|
batchLoadError: "Could not load batch",
|
||||||
|
sheetLoadError: "Could not load structured data",
|
||||||
|
statusQueued: "Queued",
|
||||||
|
batchSuccess: "Collected",
|
||||||
|
batchPartial: "Partially completed",
|
||||||
|
batchFailed: "Collection failed",
|
||||||
|
batchCancelled: "Cancelled",
|
||||||
create: "Create task",
|
create: "Create task",
|
||||||
createHint: "Pick any valid NE (managed / UME); search supported",
|
createHint: "Pick any valid NE (managed / UME); search supported",
|
||||||
createModeDevice: "Pick device",
|
createModeDevice: "Pick device",
|
||||||
|
|
@ -225,6 +239,7 @@ const en = {
|
||||||
statusCollecting: "Collecting",
|
statusCollecting: "Collecting",
|
||||||
detail: "Details",
|
detail: "Details",
|
||||||
interval: "Interval",
|
interval: "Interval",
|
||||||
|
intervalUnit: "Interval unit",
|
||||||
intervalUnitDays: "days",
|
intervalUnitDays: "days",
|
||||||
intervalUnitHours: "hours",
|
intervalUnitHours: "hours",
|
||||||
intervalUnitSeconds: "seconds",
|
intervalUnitSeconds: "seconds",
|
||||||
|
|
|
||||||
|
|
@ -174,6 +174,20 @@ const zh = {
|
||||||
},
|
},
|
||||||
bizState: {
|
bizState: {
|
||||||
title: "业务监控",
|
title: "业务监控",
|
||||||
|
loadedTasks: "已载入任务",
|
||||||
|
loadedBatches: "最近批次 · 已载入 {{n}} 条(最多 200)",
|
||||||
|
collectErrors: "最近采集异常",
|
||||||
|
retry: "重试",
|
||||||
|
taskLoading: "正在读取任务配置…",
|
||||||
|
tasksLoadError: "任务列表读取失败",
|
||||||
|
taskLoadError: "任务配置读取失败",
|
||||||
|
batchLoadError: "批次读取失败",
|
||||||
|
sheetLoadError: "结构化数据读取失败",
|
||||||
|
statusQueued: "排队中",
|
||||||
|
batchSuccess: "采集成功",
|
||||||
|
batchPartial: "部分完成",
|
||||||
|
batchFailed: "采集失败",
|
||||||
|
batchCancelled: "已取消",
|
||||||
create: "创建任务",
|
create: "创建任务",
|
||||||
createHint: "从托管 / UME 等有效网元中选择(支持查找)",
|
createHint: "从托管 / UME 等有效网元中选择(支持查找)",
|
||||||
createModeDevice: "选择设备",
|
createModeDevice: "选择设备",
|
||||||
|
|
@ -225,6 +239,7 @@ const zh = {
|
||||||
statusCollecting: "采集中",
|
statusCollecting: "采集中",
|
||||||
detail: "详情",
|
detail: "详情",
|
||||||
interval: "采集周期",
|
interval: "采集周期",
|
||||||
|
intervalUnit: "周期单位",
|
||||||
intervalUnitDays: "天",
|
intervalUnitDays: "天",
|
||||||
intervalUnitHours: "小时",
|
intervalUnitHours: "小时",
|
||||||
intervalUnitSeconds: "秒",
|
intervalUnitSeconds: "秒",
|
||||||
|
|
|
||||||
|
|
@ -12444,6 +12444,145 @@ html.login-page--paused .login-page__flare {
|
||||||
.bs-cmp-job-modal .is-indeterminate { animation: none; }
|
.bs-cmp-job-modal .is-indeterminate { animation: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Business monitoring uses the same compact operations surfaces as comparison. */
|
||||||
|
.bs-monitor-workspace,
|
||||||
|
.bs-monitor-modal {
|
||||||
|
--mon-line: #2b3748;
|
||||||
|
--mon-muted: #9aa9bc;
|
||||||
|
--mon-accent: #6ba9ec;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel.bs-monitor-workspace,
|
||||||
|
.bs-monitor-modal .modal__dialog {
|
||||||
|
border-color: var(--mon-line);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #0c1521;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bs-monitor-workspace .button,
|
||||||
|
.bs-monitor-modal .button,
|
||||||
|
.bs-monitor-workspace .input,
|
||||||
|
.bs-monitor-modal .input,
|
||||||
|
.bs-monitor-modal .ui-field__select {
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bs-monitor-workspace .pt-list-kpis {
|
||||||
|
gap: 0;
|
||||||
|
border: 1px solid var(--mon-line);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #111c2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bs-monitor-workspace .pt-list-kpi {
|
||||||
|
border: 0;
|
||||||
|
border-right: 1px solid var(--mon-line);
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bs-monitor-workspace .pt-list-kpi:last-child { border-right: 0; }
|
||||||
|
.bs-monitor-workspace .pt-list-kpi__value { font-family: ui-monospace, Consolas, monospace; }
|
||||||
|
.bs-monitor-workspace .pt-list-kpi__label { font-size: 11px; color: var(--mon-muted); }
|
||||||
|
|
||||||
|
.bs-monitor-workspace .filter-inline,
|
||||||
|
.bs-monitor-modal .filter-inline {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bs-monitor-workspace > .pt-list > .pt-list-table-wrap,
|
||||||
|
.bs-monitor-modal .pt-list-table-wrap {
|
||||||
|
border-color: var(--mon-line);
|
||||||
|
border-radius: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bs-monitor-workspace > .pt-list > .pt-list-table-wrap .pt-list-table { min-width: 1120px; }
|
||||||
|
.bs-monitor-batches .pt-list-table { min-width: 1100px; }
|
||||||
|
.bs-monitor-workspace .pt-list-table th,
|
||||||
|
.bs-monitor-modal .pt-list-table th { background: #162232; font-size: 11px; }
|
||||||
|
.bs-monitor-workspace .pt-list-table td,
|
||||||
|
.bs-monitor-modal .pt-list-table td { border-bottom-color: var(--mon-line); }
|
||||||
|
.bs-monitor-workspace .pt-list-table td { font-size: 12px; }
|
||||||
|
.bs-monitor-workspace .pt-list-task-name + .muted { font-size: 11px; }
|
||||||
|
|
||||||
|
.bs-monitor-heading { display: flex; flex-direction: column; gap: 5px; min-width: 0; }
|
||||||
|
.bs-monitor-id { color: var(--mon-muted); font: 10px/1.4 ui-monospace, Consolas, monospace; }
|
||||||
|
.bs-monitor-heading > span:last-child { font-size: 16px; overflow-wrap: anywhere; }
|
||||||
|
.bs-monitor-task-content { display: flex; flex-direction: column; gap: 14px; min-width: 0; }
|
||||||
|
.bs-monitor-task-content[hidden] { display: none; }
|
||||||
|
.bs-monitor-task-content[inert] { opacity: 0.5; }
|
||||||
|
|
||||||
|
.bs-monitor-task .bs-schedule-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
align-items: start;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid var(--mon-line);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #111c2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bs-monitor-task .bs-schedule-row > .muted { grid-column: 1 / -1; font-size: 11px; line-height: 1.6; }
|
||||||
|
.bs-monitor-task .config-sync-policy-field { min-width: 0; flex-wrap: wrap; }
|
||||||
|
.bs-monitor-task .config-sync-policy-field .input { min-width: 0; width: 100%; }
|
||||||
|
.bs-monitor-task .config-sync-policy-field > select { min-width: 0; border-radius: 4px; }
|
||||||
|
.bs-monitor-task .bs-monitor-interval-field { display: grid; grid-template-columns: minmax(0, 1fr) auto; }
|
||||||
|
.bs-monitor-task .bs-monitor-interval-field > span { grid-column: 1 / -1; }
|
||||||
|
.bs-monitor-modal .modal__footer { min-width: 0; max-width: 100%; flex-wrap: wrap; }
|
||||||
|
.bs-monitor-task .bs-monitor-task-tabs { gap: 0; margin: 0; border-bottom: 1px solid var(--mon-line); }
|
||||||
|
.bs-monitor-task-tabs .button { border: 0; border-radius: 0; border-bottom: 2px solid transparent; background: transparent; color: var(--mon-muted); }
|
||||||
|
.bs-monitor-task .bs-monitor-task-tabs .button.is-active { border-bottom-color: var(--mon-accent); color: #edf5ff; background: rgba(107,169,236,0.08); outline: none; box-shadow: none; }
|
||||||
|
.bs-monitor-task-tabs .button:focus-visible { outline: 2px solid var(--mon-accent); outline-offset: -2px; }
|
||||||
|
.bs-monitor-history-count { flex: 1 1 240px; font-size: 11px; }
|
||||||
|
.bs-monitor-batches > .btn-row { padding: 10px; }
|
||||||
|
.bs-monitor-workspace .bs-monitor-delete,
|
||||||
|
.bs-monitor-modal .bs-monitor-delete { color: #fca5a5; }
|
||||||
|
|
||||||
|
.bs-monitor-loading { padding: 16px; color: var(--mon-muted); font-size: 12px; }
|
||||||
|
.bs-monitor-error {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid #7f4343;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #fecaca;
|
||||||
|
background: rgba(127,67,67,0.1);
|
||||||
|
font-size: 12px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bs-monitor-modal .bs-workbook-meta,
|
||||||
|
.bs-monitor-modal .bs-sheet-cmd-bar,
|
||||||
|
.bs-monitor-modal .bs-sheet-cmd-row,
|
||||||
|
.bs-monitor-modal .bs-sheet-tab { border-radius: 4px; background-image: none; }
|
||||||
|
.bs-monitor-modal .bs-sheet-tabs { min-width: 0; flex-wrap: nowrap; overflow-x: auto; }
|
||||||
|
.bs-monitor-modal .bs-sheet-tab { flex: 0 0 auto; }
|
||||||
|
.bs-monitor-modal .bs-sheet-filter { gap: 8px; }
|
||||||
|
.bs-monitor-modal .bs-sheet-filter .input { flex: 1 1 200px; min-width: 0; }
|
||||||
|
.bs-monitor-modal .bs-sheet-filter .ui-field { min-width: 0; max-width: 100%; }
|
||||||
|
.bs-monitor-modal .bs-sheet-filter .ui-field__select { max-width: 100%; }
|
||||||
|
.bs-monitor-modal .bs-sheet-count { margin-left: auto; }
|
||||||
|
|
||||||
|
@media (max-width: 800px) {
|
||||||
|
.bs-monitor-workspace .pt-list-kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
.bs-monitor-workspace .pt-list-kpi:nth-child(2) { border-right: 0; }
|
||||||
|
.bs-monitor-workspace .pt-list-kpi:nth-child(-n+2) { border-bottom: 1px solid var(--mon-line); }
|
||||||
|
.bs-monitor-task .bs-schedule-row { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.bs-monitor-task .bs-schedule-row { grid-template-columns: minmax(0, 1fr); }
|
||||||
|
.bs-monitor-modal .bs-sheet-filter > .input { flex-basis: 100%; }
|
||||||
|
}
|
||||||
|
|
||||||
/* Native :fullscreen + CSS immersive fallback (class only when FS API blocked) */
|
/* Native :fullscreen + CSS immersive fallback (class only when FS API blocked) */
|
||||||
.bs-cmp-board.is-fullscreen,
|
.bs-cmp-board.is-fullscreen,
|
||||||
.bs-cmp-board:fullscreen {
|
.bs-cmp-board:fullscreen {
|
||||||
|
|
|
||||||
|
|
@ -1944,6 +1944,9 @@ export const bizStatePurgeTask = (taskId: string) =>
|
||||||
export const bizStateGetBatch = (batchId: string) =>
|
export const bizStateGetBatch = (batchId: string) =>
|
||||||
apiGet<Record<string, unknown>>(`/v1/biz-state/batches/${encodeURIComponent(batchId)}`);
|
apiGet<Record<string, unknown>>(`/v1/biz-state/batches/${encodeURIComponent(batchId)}`);
|
||||||
|
|
||||||
|
export const bizStateGetTaskProgress = (taskId: string) =>
|
||||||
|
apiGet<Record<string, unknown>>(`/v1/biz-state/tasks/${encodeURIComponent(taskId)}/progress`);
|
||||||
|
|
||||||
export const bizStateListBatchMetricRows = (params: {
|
export const bizStateListBatchMetricRows = (params: {
|
||||||
batchId: string;
|
batchId: string;
|
||||||
metricId: string;
|
metricId: string;
|
||||||
|
|
|
||||||
274
web/tests/bizState.browser.mjs
Normal file
|
|
@ -0,0 +1,274 @@
|
||||||
|
// Fixture-only browser regression: never contacts a device or real database.
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { createRequire } from "node:module";
|
||||||
|
import { mkdir } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const { chromium } = require(process.env.NETX_PLAYWRIGHT_MODULE || "playwright");
|
||||||
|
const output = path.resolve(process.env.NETX_TEST_OUTPUT || "../docs/reviews/assets");
|
||||||
|
await mkdir(output, { recursive: true });
|
||||||
|
const browser = await chromium.launch({ headless: true, channel: process.env.NETX_TEST_BROWSER || "chrome" });
|
||||||
|
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
|
||||||
|
const errors = [];
|
||||||
|
page.on("pageerror", (error) => errors.push(error.message));
|
||||||
|
const base = process.env.NETX_TEST_URL || "http://127.0.0.1:5179";
|
||||||
|
let active = false;
|
||||||
|
let failTasks = false;
|
||||||
|
let failTask = false;
|
||||||
|
let failSheet = false;
|
||||||
|
let slowPurpose = false;
|
||||||
|
let listDelay = 0;
|
||||||
|
let inFlight = 0;
|
||||||
|
let maxInFlight = 0;
|
||||||
|
let counts = { lists: 0, detail: 0, progress: 0, profiles: 0 };
|
||||||
|
const metricRequests = [];
|
||||||
|
let lastDownload = "";
|
||||||
|
let task = { id: "t", source: "managed", ne_name: "PE-FAST", ne_ip: "10.10.0.1", vendor: "ZTE",
|
||||||
|
device_type: "ZXROS", status: "paused", collect_running: false, last_error: "", interval_sec: 3600,
|
||||||
|
retention_days: 30, daily_keep_enabled: false, daily_keep_count: 10,
|
||||||
|
items: [{ id: "i-bgp", source_profile_id: "zte.bgp", enabled: true, bindings: [] },
|
||||||
|
{ id: "i-arp", source_profile_id: "zte.arp", enabled: true, bindings: [] }] };
|
||||||
|
const profiles = [
|
||||||
|
{ profile_id: "zte.bgp", title: "BGP 邻居", metric_id: "bgp_peer", kind: "collect", collect_lane: "light", command_template: "show bgp summary", placeholders: [] },
|
||||||
|
{ profile_id: "zte.arp", title: "ARP 条目", metric_id: "arp", kind: "collect", collect_lane: "heavy", command_template: "show arp", placeholders: [] },
|
||||||
|
];
|
||||||
|
const rows = Array.from({ length: 120 }, (_, n) => ({ neighbor: `10.2.1.${n}`, vrf: "core", state: n % 10 ? "Established" : "Idle" }));
|
||||||
|
const batch = (id, status = "success") => ({ id, status, row_count: 120, command_count: 2, message: "",
|
||||||
|
started_at: "2026-10-10T15:00:00Z", ended_at: status === "running" ? null : "2026-10-10T15:00:10Z",
|
||||||
|
protected: status === "running" || id === "b", is_baseline: id === "b", alias: id === "b" ? "割接基准" : "" });
|
||||||
|
const batches = [batch("b"), batch("partial", "partial"), batch("active", "running"), batch("slow-batch"),
|
||||||
|
...Array.from({ length: 50 }, (_, n) => batch(`history-${n}`))];
|
||||||
|
const commands = [
|
||||||
|
{ id: "c1", raw_command: "show bgp summary", metric_id: "bgp_peer", profile_id: "zte.bgp", has_raw: true, parse_status: "ok", row_count: 120, raw_line_count: 124 },
|
||||||
|
{ id: "cslow", raw_command: "show arp", metric_id: "arp", profile_id: "zte.arp", has_raw: true, parse_status: "ok", row_count: 1, raw_line_count: 4 },
|
||||||
|
];
|
||||||
|
const workbook = (id) => ({ ...batch(id), task_id: "t", commands, sheet_count: 2, sheets_with_data: 2,
|
||||||
|
sheets: [{ metric_id: "bgp_peer", title: "BGP 邻居", row_count: 120, commands: [commands[0]] },
|
||||||
|
{ metric_id: "arp", title: "ARP 条目", row_count: 1, commands: [commands[1]] }] });
|
||||||
|
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
await page.route("**/v1/**", async (route) => {
|
||||||
|
const req = route.request();
|
||||||
|
const url = new URL(req.url());
|
||||||
|
const p = url.pathname;
|
||||||
|
let data = {};
|
||||||
|
if (p === "/v1/auth/me") data = { user: { id: "test", username: "test", role: "admin", is_active: true } };
|
||||||
|
else if (p === "/v1/biz-state/tasks") {
|
||||||
|
counts.lists++;
|
||||||
|
maxInFlight = Math.max(maxInFlight, ++inFlight);
|
||||||
|
try {
|
||||||
|
const purpose = url.searchParams.get("purpose") || "";
|
||||||
|
if (slowPurpose && purpose === "portrait") await pause(900);
|
||||||
|
if (listDelay) await pause(listDelay);
|
||||||
|
if (failTasks) { await route.fulfill({ status: 503, json: { detail: "fixture: tasks unavailable" } }); return; }
|
||||||
|
const all = [{ ...task, collect_running: active }, { ...task, id: "slow", ne_name: "PE-SLOW", purpose: "cutover_hf" },
|
||||||
|
...Array.from({ length: 118 }, (_, n) => ({ ...task, id: `extra-${n}`, ne_name: `PE-${n}`, purpose: "portrait" }))];
|
||||||
|
data = { items: purpose ? all.filter((row) => purpose === "portrait" ? row.purpose !== "cutover_hf" : row.purpose === purpose) : all };
|
||||||
|
} finally { inFlight--; }
|
||||||
|
} else if (p === "/v1/biz-state/tasks/t" || p === "/v1/biz-state/tasks/slow") {
|
||||||
|
counts.detail++;
|
||||||
|
if (p.endsWith("/slow")) await pause(900);
|
||||||
|
if (failTask) { await route.fulfill({ status: 503, json: { detail: "fixture: config unavailable" } }); return; }
|
||||||
|
if (req.method() === "PATCH") task = { ...task, ...req.postDataJSON() };
|
||||||
|
data = p.endsWith("/slow") ? { ...task, id: "slow", ne_name: "PE-SLOW" } : { ...task, collect_running: active };
|
||||||
|
} else if (p.endsWith("/progress")) { counts.progress++; data = { ...task, collect_running: active }; delete data.items; }
|
||||||
|
else if (p === "/v1/biz-state/profiles") { counts.profiles++; data = { items: profiles }; }
|
||||||
|
else if (p.endsWith("/batches") && p.includes("/tasks/")) data = { items: batches.slice(0, Number(url.searchParams.get("limit") || 50)) };
|
||||||
|
else if (p.includes("/metrics/")) {
|
||||||
|
metricRequests.push(Object.fromEntries(url.searchParams));
|
||||||
|
if (p.endsWith("/arp")) await pause(900);
|
||||||
|
if (failSheet) { await route.fulfill({ status: 503, json: { detail: "fixture: metric unavailable" } }); return; }
|
||||||
|
const metric = p.split("/").at(-1);
|
||||||
|
const kw = url.searchParams.get("kw") || "";
|
||||||
|
const col = url.searchParams.get("column") || "";
|
||||||
|
const all = metric === "arp" ? [{ ip: "ARP_ONLY", interface: "gei-1/1", mac: "aa:bb" }] : rows;
|
||||||
|
const filtered = all.filter((row) => JSON.stringify(col ? row[col] : row).toLowerCase().includes(kw.toLowerCase()));
|
||||||
|
const pageN = Number(url.searchParams.get("page") || 1);
|
||||||
|
const size = Number(url.searchParams.get("page_size") || 50);
|
||||||
|
data = { items: filtered.slice((pageN - 1) * size, pageN * size), total: filtered.length, page: pageN,
|
||||||
|
columns: Object.keys(all[0]).map((key) => ({ key, header: key })) };
|
||||||
|
} else if (p.endsWith("/raw.txt")) {
|
||||||
|
lastDownload = p;
|
||||||
|
await route.fulfill({ status: 200, contentType: "text/plain", headers: { "Content-Disposition": 'attachment; filename="raw.txt"' }, body: "FIXTURE RAW" }); return;
|
||||||
|
} else if (p.includes("/commands/")) {
|
||||||
|
if (p.endsWith("/cslow")) await pause(900);
|
||||||
|
data = { ...commands.find((c) => c.id === p.split("/").at(-1)), batch_id: "b", raw_text: p.endsWith("/cslow") ? "OLD_ARP_LOG" : "CURRENT_BGP_LOG" };
|
||||||
|
} else if (p.startsWith("/v1/biz-state/batches/")) {
|
||||||
|
const id = p.split("/").at(-1);
|
||||||
|
if (id === "slow-batch") await pause(900);
|
||||||
|
data = workbook(id);
|
||||||
|
} else data = { items: [] };
|
||||||
|
await route.fulfill({ status: 200, json: data });
|
||||||
|
});
|
||||||
|
|
||||||
|
const main = page.locator(".bs-monitor-workspace");
|
||||||
|
const taskPanel = page.locator(".bs-monitor-task");
|
||||||
|
const book = page.locator(".bs-workbook-modal");
|
||||||
|
const openTask = async (name = "PE-FAST") => {
|
||||||
|
await main.getByRole("row").filter({ hasText: name }).getByRole("button", { name: "详情", exact: true }).click();
|
||||||
|
};
|
||||||
|
const historyRow = (id) => taskPanel.locator(".bs-monitor-batches tbody tr").filter({ has: page.getByRole("checkbox", { name: id === "b" ? "割接基准" : id, exact: true }) });
|
||||||
|
const passed = [];
|
||||||
|
try {
|
||||||
|
await page.goto(`${base}/network/cutover/biz-state`);
|
||||||
|
await main.getByRole("row").filter({ hasText: "PE-FAST" }).waitFor();
|
||||||
|
assert.equal(await main.locator("tbody tr").count(), 50);
|
||||||
|
await main.getByRole("button", { name: "下一页", exact: true }).click();
|
||||||
|
assert.equal(await main.locator("tbody tr").count(), 50);
|
||||||
|
await main.getByRole("button", { name: "上一页", exact: true }).click();
|
||||||
|
passed.push("bounded task rendering", "task pagination");
|
||||||
|
await page.screenshot({ path: path.join(output, "biz-state-tasks.png"), fullPage: true });
|
||||||
|
slowPurpose = true;
|
||||||
|
await main.getByLabel("任务用途", { exact: true }).selectOption("portrait");
|
||||||
|
await main.getByLabel("任务用途", { exact: true }).selectOption("cutover_hf");
|
||||||
|
await main.getByRole("row").filter({ hasText: "PE-SLOW" }).waitFor();
|
||||||
|
await pause(1100);
|
||||||
|
assert.equal(await main.locator("tbody tr").count(), 1);
|
||||||
|
slowPurpose = false;
|
||||||
|
await main.getByLabel("任务用途", { exact: true }).selectOption("all");
|
||||||
|
await main.getByRole("row").filter({ hasText: "PE-FAST" }).waitFor();
|
||||||
|
passed.push("stale purpose response ignored");
|
||||||
|
failTasks = true;
|
||||||
|
await main.getByRole("button", { name: "刷新", exact: true }).click();
|
||||||
|
await main.getByRole("alert").waitFor();
|
||||||
|
assert.equal(await main.locator("tbody tr").count(), 50);
|
||||||
|
failTasks = false;
|
||||||
|
await main.getByRole("button", { name: "重试", exact: true }).click();
|
||||||
|
await main.getByRole("alert").waitFor({ state: "detached" });
|
||||||
|
passed.push("list error retains rows", "list retry");
|
||||||
|
await openTask("PE-SLOW");
|
||||||
|
await taskPanel.getByText("正在读取任务配置…", { exact: true }).waitFor();
|
||||||
|
assert.ok(await taskPanel.getByRole("button", { name: "立即采集", exact: true }).isDisabled());
|
||||||
|
await taskPanel.getByRole("button", { name: "关闭", exact: true }).click();
|
||||||
|
await openTask();
|
||||||
|
await taskPanel.getByRole("tab", { name: "监控项", exact: true }).waitFor();
|
||||||
|
await pause(1100);
|
||||||
|
assert.equal(await taskPanel.locator(".bs-monitor-heading > span:last-child").textContent(), "PE-FAST");
|
||||||
|
passed.push("pending config actions disabled", "stale task response ignored");
|
||||||
|
await taskPanel.getByLabel("采集周期", { exact: true }).fill("9");
|
||||||
|
await taskPanel.getByLabel("保留天数", { exact: true }).fill("20");
|
||||||
|
const saved = page.waitForRequest((req) => req.method() === "PATCH" && req.url().endsWith("/tasks/t"));
|
||||||
|
await taskPanel.getByRole("button", { name: "保存周期", exact: true }).click();
|
||||||
|
const body = (await saved).postDataJSON();
|
||||||
|
assert.equal(body.interval_sec, 32400);
|
||||||
|
assert.equal(body.retention_days, 20);
|
||||||
|
await taskPanel.getByLabel("采集周期", { exact: true }).waitFor();
|
||||||
|
await taskPanel.locator(".bs-monitor-task-content").waitFor({ state: "visible" });
|
||||||
|
await page.waitForFunction(() => !document.querySelector(".bs-monitor-task-content")?.hasAttribute("inert"));
|
||||||
|
const profilesBefore = counts.profiles;
|
||||||
|
await taskPanel.locator(".bs-profiles-table input[type=checkbox]").first().click();
|
||||||
|
await page.waitForFunction(() => !document.querySelector(".bs-monitor-task-content")?.hasAttribute("inert")
|
||||||
|
&& document.querySelector(".bs-profiles-table input[type=checkbox]")?.checked === false);
|
||||||
|
assert.ok(!await taskPanel.locator(".bs-profiles-table input[type=checkbox]").first().isChecked());
|
||||||
|
assert.equal(counts.profiles, profilesBefore);
|
||||||
|
passed.push("schedule save payload", "profile toggle", "profile cache");
|
||||||
|
await page.screenshot({ path: path.join(output, "biz-state-config.png"), fullPage: true });
|
||||||
|
await page.setViewportSize({ width: 390, height: 1000 });
|
||||||
|
await pause(300);
|
||||||
|
assert.ok(await taskPanel.locator(".bs-schedule-row").evaluate((el) => el.scrollWidth <= el.clientWidth + 1));
|
||||||
|
assert.ok(await taskPanel.locator(".modal__footer").evaluate((el) => [...el.querySelectorAll("button")].every((button) => {
|
||||||
|
const bounds = button.getBoundingClientRect();
|
||||||
|
return bounds.left >= 0 && bounds.right <= window.innerWidth;
|
||||||
|
})), "configuration footer stays inside viewport");
|
||||||
|
await page.screenshot({ path: path.join(output, "biz-state-config-mobile.png"), fullPage: true });
|
||||||
|
await page.setViewportSize({ width: 1440, height: 1000 });
|
||||||
|
await taskPanel.getByRole("tab", { name: "监控项", exact: true }).focus();
|
||||||
|
await page.keyboard.press("ArrowRight");
|
||||||
|
assert.equal(await taskPanel.getByRole("tab", { name: "采集批次", exact: true }).getAttribute("aria-selected"), "true");
|
||||||
|
assert.equal(await taskPanel.locator(".bs-monitor-batches tbody tr").count(), 54);
|
||||||
|
assert.ok(await taskPanel.locator(".bs-monitor-batches tbody tr").first().getByRole("checkbox").isDisabled());
|
||||||
|
assert.ok(await taskPanel.locator(".bs-monitor-batches tbody tr").nth(2).getByRole("checkbox").isDisabled());
|
||||||
|
passed.push("390px configuration", "keyboard task tabs", "active and baseline batches protected");
|
||||||
|
await page.screenshot({ path: path.join(output, "biz-state-batches.png"), fullPage: true });
|
||||||
|
await historyRow("slow-batch").getByRole("button", { name: "详情", exact: true }).click();
|
||||||
|
await page.locator(".bs-collect-detail-modal").getByRole("button", { name: "关闭", exact: true }).click();
|
||||||
|
await pause(1100);
|
||||||
|
assert.equal(await page.locator(".bs-collect-detail-modal").count(), 0);
|
||||||
|
passed.push("closed collection detail stays closed");
|
||||||
|
await historyRow("slow-batch").getByRole("button", { name: "查看", exact: true }).click();
|
||||||
|
await book.getByRole("button", { name: "关闭", exact: true }).click();
|
||||||
|
await historyRow("b").getByRole("button", { name: "查看", exact: true }).click();
|
||||||
|
await book.locator(".bs-sheet-table tbody tr").first().waitFor();
|
||||||
|
await pause(1100);
|
||||||
|
assert.ok((await book.locator(".bs-id-row__value").textContent()) === "b");
|
||||||
|
await book.getByRole("tab", { name: /ARP 条目/ }).click();
|
||||||
|
await book.getByRole("tab", { name: /BGP 邻居/ }).click();
|
||||||
|
await book.locator(".bs-sheet-table tbody tr").first().waitFor();
|
||||||
|
await pause(1100);
|
||||||
|
assert.equal(await book.getByText("ARP_ONLY", { exact: true }).count(), 0);
|
||||||
|
passed.push("stale workbook response ignored", "stale metric response ignored");
|
||||||
|
await book.getByRole("button", { name: "下一页", exact: true }).click();
|
||||||
|
await book.getByRole("cell", { name: "10.2.1.50", exact: true }).waitFor();
|
||||||
|
metricRequests.length = 0;
|
||||||
|
await book.getByLabel("筛选当前表…", { exact: true }).fill("10.2.1.2");
|
||||||
|
await book.getByRole("cell", { name: "10.2.1.2", exact: true }).waitFor();
|
||||||
|
assert.ok(metricRequests.every((request) => request.page === "1"));
|
||||||
|
passed.push("search resets page without stale-page request");
|
||||||
|
failSheet = true;
|
||||||
|
await book.getByLabel("筛选当前表…", { exact: true }).fill("故障");
|
||||||
|
await book.getByRole("alert").waitFor();
|
||||||
|
assert.equal(await book.locator(".bs-sheet-table tbody tr td").count(), 0);
|
||||||
|
await page.screenshot({ path: path.join(output, "biz-state-error.png"), fullPage: true });
|
||||||
|
failSheet = false;
|
||||||
|
await book.getByRole("button", { name: "重试", exact: true }).click();
|
||||||
|
await book.getByText("本表无数据", { exact: true }).waitFor();
|
||||||
|
await book.getByRole("button", { name: "清除筛选", exact: true }).click();
|
||||||
|
await book.getByRole("cell", { name: "10.2.1.0", exact: true }).waitFor();
|
||||||
|
passed.push("sheet error clears rows", "sheet retry", "clear filters");
|
||||||
|
await page.screenshot({ path: path.join(output, "biz-state-workbook.png"), fullPage: true });
|
||||||
|
await page.setViewportSize({ width: 390, height: 1000 });
|
||||||
|
await pause(300);
|
||||||
|
assert.ok(await book.locator(".bs-sheet-filter").evaluate((el) => el.scrollWidth <= el.clientWidth + 1));
|
||||||
|
assert.ok(await book.locator(".modal__footer").evaluate((el) => [...el.querySelectorAll("button")].every((button) => {
|
||||||
|
const bounds = button.getBoundingClientRect();
|
||||||
|
return bounds.left >= 0 && bounds.right <= window.innerWidth;
|
||||||
|
})), "workbook footer stays inside viewport");
|
||||||
|
await page.screenshot({ path: path.join(output, "biz-state-workbook-mobile.png"), fullPage: true });
|
||||||
|
await page.setViewportSize({ width: 1440, height: 1000 });
|
||||||
|
passed.push("390px workbook");
|
||||||
|
await book.getByRole("tab", { name: /^命令/ }).click();
|
||||||
|
await book.locator(".bs-commands-card").nth(1).getByRole("button", { name: "原始日志", exact: true }).click();
|
||||||
|
const raw = page.locator(".bs-rawlog-modal");
|
||||||
|
await raw.getByRole("button", { name: "关闭", exact: true }).click();
|
||||||
|
await book.locator(".bs-commands-card").first().getByRole("button", { name: "原始日志", exact: true }).click();
|
||||||
|
await raw.getByText("CURRENT_BGP_LOG", { exact: true }).waitFor();
|
||||||
|
await pause(1100);
|
||||||
|
assert.equal(await raw.getByText("OLD_ARP_LOG", { exact: true }).count(), 0);
|
||||||
|
const download = page.waitForEvent("download");
|
||||||
|
await raw.getByRole("button", { name: "导出为文本", exact: true }).click();
|
||||||
|
await download;
|
||||||
|
assert.equal(lastDownload, "/v1/biz-state/batches/b/commands/c1/raw.txt");
|
||||||
|
passed.push("stale log response ignored", "log export source");
|
||||||
|
await raw.getByRole("button", { name: "关闭", exact: true }).click();
|
||||||
|
await book.getByRole("button", { name: "关闭", exact: true }).click();
|
||||||
|
await taskPanel.getByRole("button", { name: "关闭", exact: true }).click();
|
||||||
|
failTask = true;
|
||||||
|
await openTask();
|
||||||
|
await taskPanel.getByRole("alert").waitFor();
|
||||||
|
failTask = false;
|
||||||
|
await taskPanel.getByRole("button", { name: "重试", exact: true }).click();
|
||||||
|
await taskPanel.getByRole("tab", { name: "监控项", exact: true }).waitFor();
|
||||||
|
passed.push("task config retry");
|
||||||
|
await taskPanel.getByRole("button", { name: "关闭", exact: true }).click();
|
||||||
|
active = true;
|
||||||
|
listDelay = 4500;
|
||||||
|
await page.reload();
|
||||||
|
await main.getByRole("row").filter({ hasText: "PE-FAST" }).waitFor();
|
||||||
|
counts.detail = counts.progress = 0;
|
||||||
|
maxInFlight = inFlight;
|
||||||
|
await openTask();
|
||||||
|
await taskPanel.getByLabel("采集周期", { exact: true }).fill("7");
|
||||||
|
await pause(14500);
|
||||||
|
assert.ok(maxInFlight <= 1, `overlapping task polls: ${maxInFlight}`);
|
||||||
|
assert.equal(counts.detail, 1, "polling does not reload task items");
|
||||||
|
assert.ok(counts.progress >= 1);
|
||||||
|
assert.equal(await taskPanel.getByLabel("采集周期", { exact: true }).inputValue(), "7");
|
||||||
|
await taskPanel.getByRole("tab", { name: "采集批次", exact: true }).click();
|
||||||
|
assert.equal(await taskPanel.locator(".bs-monitor-batches tbody tr").count(), 54);
|
||||||
|
assert.deepEqual(errors, []);
|
||||||
|
passed.push("server-started collection polling", "non-overlapping slow polls", "lightweight progress", "unsaved config preserved", "history limit stays 200", "no runtime errors");
|
||||||
|
console.log(JSON.stringify({ passed, counts, maxInFlight, output }));
|
||||||
|
} catch (error) {
|
||||||
|
await page.screenshot({ path: path.join(output, "biz-state-test-failure.png"), fullPage: true });
|
||||||
|
throw error;
|
||||||
|
} finally { await browser.close(); }
|
||||||