diff --git a/docs/reviews/assets/biz-state-batches.png b/docs/reviews/assets/biz-state-batches.png new file mode 100644 index 0000000..e6b7248 Binary files /dev/null and b/docs/reviews/assets/biz-state-batches.png differ diff --git a/docs/reviews/assets/biz-state-config-mobile.png b/docs/reviews/assets/biz-state-config-mobile.png new file mode 100644 index 0000000..19244fc Binary files /dev/null and b/docs/reviews/assets/biz-state-config-mobile.png differ diff --git a/docs/reviews/assets/biz-state-config.png b/docs/reviews/assets/biz-state-config.png new file mode 100644 index 0000000..a4eb2e0 Binary files /dev/null and b/docs/reviews/assets/biz-state-config.png differ diff --git a/docs/reviews/assets/biz-state-error.png b/docs/reviews/assets/biz-state-error.png new file mode 100644 index 0000000..62cd975 Binary files /dev/null and b/docs/reviews/assets/biz-state-error.png differ diff --git a/docs/reviews/assets/biz-state-tasks.png b/docs/reviews/assets/biz-state-tasks.png new file mode 100644 index 0000000..be971dc Binary files /dev/null and b/docs/reviews/assets/biz-state-tasks.png differ diff --git a/docs/reviews/assets/biz-state-workbook-mobile.png b/docs/reviews/assets/biz-state-workbook-mobile.png new file mode 100644 index 0000000..5b2a506 Binary files /dev/null and b/docs/reviews/assets/biz-state-workbook-mobile.png differ diff --git a/docs/reviews/assets/biz-state-workbook.png b/docs/reviews/assets/biz-state-workbook.png new file mode 100644 index 0000000..cad4dee Binary files /dev/null and b/docs/reviews/assets/biz-state-workbook.png differ diff --git a/docs/reviews/biz-state-review-2026-10-11.md b/docs/reviews/biz-state-review-2026-10-11.md new file mode 100644 index 0000000..7ca0e8e --- /dev/null +++ b/docs/reviews/biz-state-review-2026-10-11.md @@ -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 +``` diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index bad9db9..0f9dce2 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -174,6 +174,20 @@ const en = { }, bizState: { 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", createHint: "Pick any valid NE (managed / UME); search supported", createModeDevice: "Pick device", @@ -225,6 +239,7 @@ const en = { statusCollecting: "Collecting", detail: "Details", interval: "Interval", + intervalUnit: "Interval unit", intervalUnitDays: "days", intervalUnitHours: "hours", intervalUnitSeconds: "seconds", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index b7e1a1f..23778f9 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -174,6 +174,20 @@ const zh = { }, bizState: { title: "业务监控", + loadedTasks: "已载入任务", + loadedBatches: "最近批次 · 已载入 {{n}} 条(最多 200)", + collectErrors: "最近采集异常", + retry: "重试", + taskLoading: "正在读取任务配置…", + tasksLoadError: "任务列表读取失败", + taskLoadError: "任务配置读取失败", + batchLoadError: "批次读取失败", + sheetLoadError: "结构化数据读取失败", + statusQueued: "排队中", + batchSuccess: "采集成功", + batchPartial: "部分完成", + batchFailed: "采集失败", + batchCancelled: "已取消", create: "创建任务", createHint: "从托管 / UME 等有效网元中选择(支持查找)", createModeDevice: "选择设备", @@ -225,6 +239,7 @@ const zh = { statusCollecting: "采集中", detail: "详情", interval: "采集周期", + intervalUnit: "周期单位", intervalUnitDays: "天", intervalUnitHours: "小时", intervalUnitSeconds: "秒", diff --git a/web/src/index.css b/web/src/index.css index 4ce1361..b60b7f0 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -12444,6 +12444,145 @@ html.login-page--paused .login-page__flare { .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) */ .bs-cmp-board.is-fullscreen, .bs-cmp-board:fullscreen { diff --git a/web/src/pages/network/BizStatePage.tsx b/web/src/pages/network/BizStatePage.tsx index d421669..0be8378 100644 --- a/web/src/pages/network/BizStatePage.tsx +++ b/web/src/pages/network/BizStatePage.tsx @@ -1,5 +1,6 @@ import { Button, Input, Modal } from "@heroui/react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { KeyboardEvent as ReactKeyboardEvent } from "react"; import { ListPager } from "../../components/ListPager"; import { AppModalShell } from "../../components/ui/AppModalShell"; import { FieldSelect } from "../../components/ui/FieldSelect"; @@ -20,6 +21,7 @@ import { bizStateGetBatch, bizStateGetBatchCommand, bizStateGetTask, + bizStateGetTaskProgress, bizStateImportLog, bizStateImportLogStandalone, bizStateListBatches, @@ -166,7 +168,7 @@ function intervalUnitMax(unit: "days" | "hours" | "seconds") { } function formatCmdCollectStats( - t: (key: string, vars?: Record) => string, + t: (key: string, vars?: Record) => string, lines: number, rows: number, declared?: number, @@ -221,13 +223,13 @@ function neSourceOf(row: CliTargetItem): "managed" | "ume" { } function columnsFromRows(rows: Record[]): SheetCol[] { - const keys: string[] = []; + const keys = new Set(); for (const row of rows) { for (const k of Object.keys(row || {})) { - if (!keys.includes(k)) keys.push(k); + keys.add(k); } } - return keys.map((k) => ({ key: k, header: k })); + return [...keys].map((k) => ({ key: k, header: k })); } function buildSheetTabs(batch: any, t: (k: string) => string): SheetTab[] { @@ -236,7 +238,7 @@ function buildSheetTabs(batch: any, t: (k: string) => string): SheetTab[] { if (cmds.length) { tabs.push({ id: "commands", - title: "Commands", + title: t("bizState.sheetCommands"), rowCount: cmds.length, commands: cmds, }); @@ -260,6 +262,17 @@ export function BizStatePage() { const { showOk, showError } = useToast(); const [tasks, setTasks] = useState([]); + const [tasksLoading, setTasksLoading] = useState(true); + const [tasksError, setTasksError] = useState(""); + const tasksRequestRef = useRef(0); + const taskRequestRef = useRef(0); + const taskIdRef = useRef(""); + const batchRequestRef = useRef(0); + const sheetRequestRef = useRef(0); + const collectRequestRef = useRef(0); + const rawRequestRef = useRef(0); + const discoverRequestRef = useRef(0); + const pollInFlightRef = useRef(false); const [busy, setBusy] = useState(false); const [collectingIds, setCollectingIds] = useState>({}); /** Track that we observed collect_running=true so we don't clear the chip before enqueue lands. */ @@ -267,7 +280,10 @@ export function BizStatePage() { const collectStartedAtRef = useRef>({}); const [listKeyword, setListKeyword] = useState(""); const debouncedListKw = useDebouncedValue(listKeyword, 250); + const [taskPage, setTaskPage] = useState(1); + const [taskPageSize, setTaskPageSize] = useState(50); const [purposeFilter, setPurposeFilter] = useState<"all" | "portrait" | "cutover_hf">("all"); + const purposeRef = useRef(purposeFilter); // create-task modal (all valid CLI targets) const [createOpen, setCreateOpen] = useState(false); @@ -284,6 +300,8 @@ export function BizStatePage() { // task modal const [taskId, setTaskId] = useState(""); const [detail, setDetail] = useState(null); + const [taskLoading, setTaskLoading] = useState(false); + const [taskError, setTaskError] = useState(""); const [profiles, setProfiles] = useState([]); const [batches, setBatches] = useState([]); const [taskTab, setTaskTab] = useState("profiles"); @@ -311,6 +329,9 @@ export function BizStatePage() { // batch workbook modal (summary + lazy-paged metric sheets) const [batchDetail, setBatchDetail] = useState(null); + const [batchTargetId, setBatchTargetId] = useState(""); + const [batchLoading, setBatchLoading] = useState(false); + const [batchError, setBatchError] = useState(""); const [sheetId, setSheetId] = useState(""); const [sheetKeyword, setSheetKeyword] = useState(""); const [sheetColumn, setSheetColumn] = useState(""); @@ -321,12 +342,15 @@ export function BizStatePage() { const [sheetColumns, setSheetColumns] = useState([]); const [sheetTotal, setSheetTotal] = useState(0); const [sheetLoading, setSheetLoading] = useState(false); + const [sheetError, setSheetError] = useState(""); + const [sheetRetry, setSheetRetry] = useState(0); const [rawLogOpen, setRawLogOpen] = useState(false); const [rawLogLoading, setRawLogLoading] = useState(false); const [rawLogCmd, setRawLogCmd] = useState(""); const [rawLogText, setRawLogText] = useState(""); const [rawLogMeta, setRawLogMeta] = useState(""); const [rawLogCommandId, setRawLogCommandId] = useState(""); + const [rawLogBatchId, setRawLogBatchId] = useState(""); // Manual log import into an existing task (append batch) const [importOpen, setImportOpen] = useState(false); @@ -346,18 +370,22 @@ export function BizStatePage() { const [collectDetailLoading, setCollectDetailLoading] = useState(false); const refreshTasks = useCallback(async () => { + if (purposeFilter !== purposeRef.current) return []; + const request = ++tasksRequestRef.current; const purpose = purposeFilter === "all" ? "" : purposeFilter === "portrait" ? "portrait" : "cutover_hf"; const key = `bizState:tasks:${purpose || "all"}`; const res = await cutoverCachedGet(key, () => bizStateListTasks(purpose), { force: true }); const items = (res.items || []) as TaskRow[]; - setTasks(items); + if (request === tasksRequestRef.current && purposeFilter === purposeRef.current) setTasks(items); return items; }, [purposeFilter]); /** Lightweight poll: task flags + batch counters only (no profile reload). */ - const refreshTaskProgress = useCallback(async (id: string) => { - const task = await bizStateGetTask(id); + const refreshTaskProgress = useCallback(async (id: string, isCurrent: () => boolean = () => true) => { + const request = taskRequestRef.current; + const [task, b] = await Promise.all([bizStateGetTaskProgress(id), bizStateListBatches(id, 200)]); + if (request !== taskRequestRef.current || taskIdRef.current !== id || !isCurrent()) return task as TaskRow; setDetail((prev: any) => { if (!prev || prev.id !== id) return prev; return { @@ -369,47 +397,91 @@ export function BizStatePage() { status: task.status, }; }); - const b = await bizStateListBatches(id, 50); setBatches((b.items || []) as BatchRow[]); + const deletable = new Set((b.items || []).filter((row) => !row.protected && !["queued", "running"].includes(String(row.status))).map((row) => row.id)); + setSelectedBatchIds((prev) => prev.filter((bid) => deletable.has(bid))); return task as TaskRow; }, []); + const reloadTasks = async () => { + setTasksLoading(true); + setTasksError(""); + try { await refreshTasks(); } + catch (e) { if (purposeFilter === purposeRef.current) setTasksError(formatErr(e)); } + finally { if (purposeFilter === purposeRef.current) setTasksLoading(false); } + }; + useEffect(() => { + purposeRef.current = purposeFilter; + const request = ++tasksRequestRef.current; + let cancelled = false; void (async () => { try { const purpose = purposeFilter === "all" ? "" : purposeFilter === "portrait" ? "portrait" : "cutover_hf"; const key = `bizState:tasks:${purpose || "all"}`; const apply = (res: Awaited>) => { + if (cancelled || request !== tasksRequestRef.current) return; setTasks((res.items || []) as TaskRow[]); }; apply(await cutoverCachedGetSWR(key, () => bizStateListTasks(purpose), apply)); } catch (e) { - showError(formatErr(e)); + if (!cancelled && request === tasksRequestRef.current) setTasksError(formatErr(e)); + } finally { + if (!cancelled && request === tasksRequestRef.current) setTasksLoading(false); } })(); - // eslint-disable-next-line react-hooks/exhaustive-deps -- refresh when purpose filter changes + return () => { cancelled = true; }; }, [purposeFilter]); + useEffect(() => () => { + tasksRequestRef.current += 1; + taskRequestRef.current += 1; + taskIdRef.current = ""; + batchRequestRef.current += 1; + sheetRequestRef.current += 1; + collectRequestRef.current += 1; + rawRequestRef.current += 1; + discoverRequestRef.current += 1; + }, []); + + const activeTaskIds = tasks.filter((row) => row.collect_running).map((row) => row.id).sort().join(","); + const hasScheduledTask = tasks.some((row) => row.status === "running"); + // Progress poll while any collect is running (list chips and/or open task). // Does NOT block navigation; cleans up on unmount / when nothing is collecting. useEffect(() => { const watching = new Set(Object.keys(collectingIds)); + for (const id of activeTaskIds.split(",").filter(Boolean)) watching.add(id); if (taskId && detail?.collect_running) watching.add(taskId); - if (!watching.size) return; + if (!watching.size && !hasScheduledTask && !taskId) return; let cancelled = false; + let timer: number | undefined; + const delay = watching.size ? 4000 : 15000; const tick = async () => { if (cancelled) return; + if (pollInFlightRef.current) { + timer = window.setTimeout(() => void tick(), delay); + return; + } + pollInFlightRef.current = true; try { const items = await refreshTasks(); if (cancelled) return; + // A purpose-filtered list may omit a manually started task. Read its flags directly. + const known = new Map(items.map((row) => [row.id, row])); + await Promise.all(Object.keys(collectingIds).filter((id) => !known.has(id)).map(async (id) => { + const row = await bizStateGetTaskProgress(id); + known.set(id, row as TaskRow); + })); + if (cancelled) return; setCollectingIds((prev) => { let changed = false; const next = { ...prev }; const now = Date.now(); for (const id of Object.keys(next)) { - const row = items.find((x) => x.id === id); + const row = known.get(id); if (row?.collect_running) { seenCollectRunningRef.current[id] = true; continue; @@ -426,20 +498,23 @@ export function BizStatePage() { } return changed ? next : prev; }); - if (taskId && watching.has(taskId)) { - await refreshTaskProgress(taskId); + if (taskId) { + await refreshTaskProgress(taskId, () => !cancelled); } } catch { /* ignore transient poll errors */ + } finally { + pollInFlightRef.current = false; + if (!cancelled) timer = window.setTimeout(() => void tick(), delay); } }; - const timer = window.setInterval(() => void tick(), 4000); - void tick(); + if (watching.size) void tick(); + else timer = window.setTimeout(() => void tick(), delay); return () => { cancelled = true; - window.clearInterval(timer); + window.clearTimeout(timer); }; - }, [collectingIds, taskId, detail?.collect_running, refreshTasks, refreshTaskProgress]); + }, [collectingIds, activeTaskIds, hasScheduledTask, taskId, detail?.collect_running, refreshTasks, refreshTaskProgress]); useEffect(() => { if (!createOpen) return; @@ -529,6 +604,15 @@ export function BizStatePage() { [t], ); + const filteredCommands = useMemo(() => { + const commands = (batchDetail?.commands || []) as Record[]; + const kw = debouncedSheetKw.trim().toLowerCase(); + if (!kw) return commands; + const keys = sheetColumn ? [sheetColumn] + : ["raw_command", "metric_id", "parse_status", "raw_line_count", "row_count", "message"]; + return commands.filter((row) => keys.some((key) => cellText(row[key]).toLowerCase().includes(kw))); + }, [batchDetail?.commands, debouncedSheetKw, sheetColumn]); + const displayColumns = useMemo(() => { if (!activeSheet) return []; if (activeSheet.id === "commands") return commandsSheetColumns; @@ -538,25 +622,13 @@ export function BizStatePage() { const displayRows = useMemo(() => { if (!activeSheet) return []; if (activeSheet.id === "commands") { - const cmds = (batchDetail?.commands || []) as Record[]; - const kw = debouncedSheetKw.trim().toLowerCase(); - const filtered = !kw - ? cmds - : cmds.filter((row) => { - const keys = sheetColumn - ? [sheetColumn] - : ["raw_command", "metric_id", "parse_status", "raw_line_count", "row_count", "message"]; - return keys.some((k) => cellText(row[k]).toLowerCase().includes(kw)); - }); const start = (sheetPage - 1) * sheetPageSize; - return filtered.slice(start, start + sheetPageSize).map((c) => ({ ...c })); + return filteredCommands.slice(start, start + sheetPageSize); } return sheetRows; }, [ activeSheet, - batchDetail, - debouncedSheetKw, - sheetColumn, + filteredCommands, sheetPage, sheetPageSize, sheetRows, @@ -565,18 +637,10 @@ export function BizStatePage() { const displayTotal = useMemo(() => { if (!activeSheet) return 0; if (activeSheet.id === "commands") { - const cmds = (batchDetail?.commands || []) as Record[]; - const kw = debouncedSheetKw.trim().toLowerCase(); - if (!kw) return cmds.length; - return cmds.filter((row) => { - const keys = sheetColumn - ? [sheetColumn] - : ["raw_command", "metric_id", "parse_status", "raw_line_count", "row_count", "message"]; - return keys.some((k) => cellText(row[k]).toLowerCase().includes(kw)); - }).length; + return filteredCommands.length; } return sheetTotal; - }, [activeSheet, batchDetail, debouncedSheetKw, sheetColumn, sheetTotal]); + }, [activeSheet, filteredCommands, sheetTotal]); const collectDetailCmdSummary = useMemo(() => { const stats = collectDetail?.command_stats as @@ -611,7 +675,10 @@ export function BizStatePage() { const loadSheetPage = useCallback( async (batchId: string, metricId: string, page: number, pageSize: number, kw: string, column: string) => { if (!batchId || !metricId || metricId === "commands") return; + const request = ++sheetRequestRef.current; setSheetLoading(true); + setSheetError(""); + setSheetRows([]); try { const res = await bizStateListBatchMetricRows({ batchId, @@ -621,6 +688,7 @@ export function BizStatePage() { kw, column, }); + if (request !== sheetRequestRef.current) return; setSheetRows(res.items || []); setSheetTotal(Number(res.total || 0)); const cols = (res.columns || []).map((c) => ({ @@ -630,33 +698,50 @@ export function BizStatePage() { })); setSheetColumns(cols.length ? cols : columnsFromRows(res.items || [])); } catch (e) { - showError(formatErr(e)); + if (request !== sheetRequestRef.current) return; + setSheetError(formatErr(e)); setSheetRows([]); setSheetTotal(0); } finally { - setSheetLoading(false); + if (request === sheetRequestRef.current) setSheetLoading(false); } }, - [showError], + [], ); + const activeMetricId = activeSheet?.id || ""; useEffect(() => { - if (!batchDetail?.id || !activeSheet || activeSheet.id === "commands") return; + if (!batchDetail?.id || !activeMetricId || activeMetricId === "commands") { + sheetRequestRef.current += 1; + setSheetLoading(false); + setSheetError(""); + return; + } + if (sheetKeyword.trim() !== debouncedSheetKw.trim()) { + sheetRequestRef.current += 1; + setSheetLoading(true); + setSheetError(""); + setSheetRows([]); + return; + } void loadSheetPage( String(batchDetail.id), - activeSheet.id, + activeMetricId, sheetPage, sheetPageSize, debouncedSheetKw, sheetColumn, ); + return () => { sheetRequestRef.current += 1; }; }, [ batchDetail?.id, - activeSheet, + activeMetricId, sheetPage, sheetPageSize, debouncedSheetKw, + sheetKeyword, sheetColumn, + sheetRetry, loadSheetPage, ]); @@ -682,30 +767,49 @@ export function BizStatePage() { }; const loadTask = async (id: string) => { - const task = await bizStateGetTask(id); - setDetail(task); - const ui = secToIntervalUi(Number(task.interval_sec || 3600)); - setIntervalValue(ui.value); - setIntervalUnit(ui.unit); - setRetentionDays(Math.max(1, Number(task.retention_days || 30))); - setDailyKeepEnabled(Boolean(task.daily_keep_enabled)); - setDailyKeepCount(Math.max(1, Number(task.daily_keep_count || 10))); - const b = await bizStateListBatches(id, 200); - setBatches((b.items || []) as BatchRow[]); - setSelectedBatchIds([]); - const p = await bizStateListProfiles({ - vendor: task.vendor || "", - device_type: task.device_type || "", - }); - setProfiles((p.items || []) as Profile[]); + if (taskIdRef.current !== id) return; + const request = ++taskRequestRef.current; + setTaskLoading(true); + setTaskError(""); + try { + const task = await bizStateGetTask(id); + if (request !== taskRequestRef.current) return; + const [b, p] = await Promise.all([ + bizStateListBatches(id, 200), + cutoverCachedGet(`bizState:profiles:${String(task.vendor || "")}:${String(task.device_type || "")}`, + () => bizStateListProfiles({vendor: String(task.vendor || ""), device_type: String(task.device_type || "")}), {ttlMs: 30000}), + ]); + if (request !== taskRequestRef.current) return; + setDetail(task); + const ui = secToIntervalUi(Number(task.interval_sec || 3600)); + setIntervalValue(ui.value); + setIntervalUnit(ui.unit); + setRetentionDays(Math.max(1, Number(task.retention_days || 30))); + setDailyKeepEnabled(Boolean(task.daily_keep_enabled)); + setDailyKeepCount(Math.max(1, Number(task.daily_keep_count || 10))); + setBatches((b.items || []) as BatchRow[]); + setSelectedBatchIds([]); + setProfiles((p.items || []) as Profile[]); + } catch (e) { + if (request === taskRequestRef.current) setTaskError(formatErr(e)); + } finally { + if (request === taskRequestRef.current) setTaskLoading(false); + } }; const openTask = async (id: string, tab: TaskTab = "profiles") => { + taskRequestRef.current += 1; + taskIdRef.current = id; setTaskId(id); + setDetail(null); + setProfiles([]); + setBatches([]); + setSelectedBatchIds([]); setTaskTab(tab); setBindItemId(""); setCandidates([]); - setBatchDetail(null); + closeBatch(); + closeCollectDetail(); try { await loadTask(id); } catch (e) { @@ -715,6 +819,13 @@ export function BizStatePage() { }; const closeTask = () => { + taskRequestRef.current += 1; + taskIdRef.current = ""; + setTaskLoading(false); + setTaskError(""); + closeBatch(); + closeCollectDetail(); + closeBindModal(); setTaskId(""); setDetail(null); setBindItemId(""); @@ -828,7 +939,7 @@ export function BizStatePage() { }; const selectDeletableBatches = () => { - setSelectedBatchIds(batches.filter((b) => !b.protected).map((b) => b.id)); + setSelectedBatchIds(batches.filter((b) => !b.protected && !["running", "queued"].includes(b.status)).map((b) => b.id)); }; const markBaseline = async (batchId: string, marked: boolean) => { @@ -852,7 +963,7 @@ export function BizStatePage() { showOk(t("bizState.aliasSaved")); if (taskId) await loadTask(taskId); if (batchDetail?.id === batchId) { - setBatchDetail((prev: any) => (prev ? { ...prev, alias: next.trim() } : prev)); + setBatchDetail((prev: any) => (prev?.id === batchId ? { ...prev, alias: next.trim() } : prev)); } } catch (e) { showError(formatErr(e)); @@ -1166,6 +1277,7 @@ export function BizStatePage() { }; const closeBindModal = () => { + discoverRequestRef.current += 1; setBindItemId(""); setCandidates([]); setSelectedVrfs([]); @@ -1177,6 +1289,7 @@ export function BizStatePage() { const startDiscover = async (item: any, forceRefresh = false) => { if (!taskId) return; + const request = ++discoverRequestRef.current; const prof = profiles.find((p) => p.profile_id === item.source_profile_id); const phs = prof?.placeholders || []; const ph = phs[0]; @@ -1201,6 +1314,7 @@ export function BizStatePage() { placeholder: sharedDisc ? "" : ph.name, force_refresh: forceRefresh, }); + if (request !== discoverRequestRef.current) return; if (!res.ok) { const err = res.error || t("bizState.discoverFailed"); setDiscoverError(err); @@ -1244,11 +1358,12 @@ export function BizStatePage() { setDiscoverError(t("bizState.discoverEmpty")); } } catch (e) { + if (request !== discoverRequestRef.current) return; const err = formatErr(e); setDiscoverError(err); showError(err); } finally { - setDiscoverLoading(false); + if (request === discoverRequestRef.current) setDiscoverLoading(false); setBusy(false); } }; @@ -1287,8 +1402,15 @@ export function BizStatePage() { }; const openBatch = async (batchId: string) => { + const request = ++batchRequestRef.current; + sheetRequestRef.current += 1; + setBatchTargetId(batchId); + setBatchDetail(null); + setBatchLoading(true); + setBatchError(""); try { const d = await bizStateGetBatch(batchId); + if (request !== batchRequestRef.current) return; setBatchDetail(d); setSheetKeyword(""); setSheetColumn(""); @@ -1303,11 +1425,20 @@ export function BizStatePage() { built[0]; setSheetId(prefer?.id || ""); } catch (e) { - showError(formatErr(e)); + if (request === batchRequestRef.current) setBatchError(formatErr(e)); + } finally { + if (request === batchRequestRef.current) setBatchLoading(false); } }; const closeBatch = () => { + batchRequestRef.current += 1; + sheetRequestRef.current += 1; + setBatchTargetId(""); + setBatchLoading(false); + setBatchError(""); + setSheetLoading(false); + setSheetError(""); setBatchDetail(null); setSheetId(""); setSheetKeyword(""); @@ -1316,25 +1447,28 @@ export function BizStatePage() { setSheetRows([]); setSheetColumns([]); setSheetTotal(0); - setRawLogOpen(false); + closeRawLog(); setRawLogText(""); setRawLogCommandId(""); }; const openCollectDetail = async (batchId: string) => { + const request = ++collectRequestRef.current; setCollectDetailLoading(true); setCollectDetail(null); try { const d = await bizStateGetBatch(batchId); + if (request !== collectRequestRef.current) return; setCollectDetail(d); } catch (e) { - showError(formatErr(e)); + if (request === collectRequestRef.current) showError(formatErr(e)); } finally { - setCollectDetailLoading(false); + if (request === collectRequestRef.current) setCollectDetailLoading(false); } }; const closeCollectDetail = () => { + collectRequestRef.current += 1; setCollectDetail(null); setCollectDetailLoading(false); }; @@ -1342,6 +1476,8 @@ export function BizStatePage() { const openRawLog = async (commandId: string, fromBatchId?: string) => { const bid = fromBatchId || (batchDetail?.id ? String(batchDetail.id) : "") || (collectDetail?.id ? String(collectDetail.id) : ""); if (!bid || !commandId) return; + const request = ++rawRequestRef.current; + setRawLogBatchId(bid); setRawLogOpen(true); setRawLogLoading(true); setRawLogText(""); @@ -1354,6 +1490,7 @@ export function BizStatePage() { setRawLogMessage(""); try { const d = await bizStateGetBatchCommand(bid, commandId); + if (request !== rawRequestRef.current) return; setRawLogCmd(String(d.raw_command || "")); setRawLogText(String(d.raw_text || "")); const lines = Number(d.raw_line_count ?? 0); @@ -1371,18 +1508,24 @@ export function BizStatePage() { ].filter(Boolean); setRawLogMeta(bits.join(" · ")); } catch (e) { + if (request !== rawRequestRef.current) return; showError(formatErr(e)); - setRawLogOpen(false); + closeRawLog(); } finally { - setRawLogLoading(false); + if (request === rawRequestRef.current) setRawLogLoading(false); } }; + const closeRawLog = () => { + rawRequestRef.current += 1; + setRawLogOpen(false); + setRawLogLoading(false); + setRawLogBatchId(""); + setRawLogCommandId(""); + }; + const exportRawLog = async () => { - const bid = - (batchDetail?.id && String(batchDetail.id)) || - (collectDetail?.id && String(collectDetail.id)) || - ""; + const bid = rawLogBatchId; if (!bid || !rawLogCommandId) { // Fallback: download already-loaded text if (!rawLogText) return; @@ -1408,6 +1551,9 @@ export function BizStatePage() { }; const selectSheet = (id: string) => { + sheetRequestRef.current += 1; + setSheetLoading(false); + setSheetError(""); setSheetId(id); setSheetKeyword(""); setSheetColumn(""); @@ -1418,9 +1564,31 @@ export function BizStatePage() { }; const runningCount = tasks.filter((x) => x.status === "running").length; + const taskPages = pageCount(filteredTasks.length, taskPageSize); + const visibleTaskPage = Math.min(taskPage, taskPages); + const visibleTasks = filteredTasks.slice((visibleTaskPage - 1) * taskPageSize, visibleTaskPage * taskPageSize); + const collectingCount = tasks.filter((x) => x.collect_running || collectingIds[x.id]).length; + const taskNotReady = busy || taskLoading || !detail || Boolean(taskError); + const batchStatusLabel = (status: string) => { + const labels: Record = { + success: "bizState.batchSuccess", running: "bizState.statusCollecting", queued: "bizState.statusQueued", + partial: "bizState.batchPartial", failed: "bizState.batchFailed", cancelled: "bizState.batchCancelled", + }; + return labels[status] ? t(labels[status]) : status || "—"; + }; + const handleSheetTabKey = (event: ReactKeyboardEvent) => { + const current = sheetTabs.findIndex((row) => row.id === activeMetricId); + const next = event.key === "ArrowRight" ? (current + 1) % sheetTabs.length + : event.key === "ArrowLeft" ? (current + sheetTabs.length - 1) % sheetTabs.length + : event.key === "Home" ? 0 : event.key === "End" ? sheetTabs.length - 1 : -1; + if (next < 0 || !sheetTabs.length) return; + event.preventDefault(); + selectSheet(sheetTabs[next].id); + event.currentTarget.querySelectorAll('[role="tab"]')[next]?.focus(); + }; return ( -
+

{t("bizState.title")}

@@ -1433,34 +1601,53 @@ export function BizStatePage() {
-
{t("bizState.colNe")}
+
{t("bizState.loadedTasks")}
{tasks.length}
{t("bizState.scheduleOn")}
{runningCount}
+
+
{t("bizState.statusCollecting")}
+
{collectingCount}
+
+
+
{t("bizState.collectErrors")}
+
{tasks.filter((row) => row.last_error).length}
+
setListKeyword(e.target.value)} + onChange={(e) => { setListKeyword(e.target.value); setTaskPage(1); }} /> - setPurposeFilter(e.target.value as "all" | "portrait" | "cutover_hf") - } + onChange={(e) => { + const value = e.target.value as "all" | "portrait" | "cutover_hf"; + if (value === purposeFilter) return; + purposeRef.current = value; + setTasksLoading(true); + setTasksError(""); + setTasks([]); + setTaskPage(1); + setPurposeFilter(value); + }} aria-label={t("bizState.purposeFilter")} > +
+ {tasksError ?
{t("bizState.tasksLoadError")} · {tasksError}
: null} +
@@ -1477,7 +1664,7 @@ export function BizStatePage() { - {filteredTasks.map((row) => ( + {visibleTasks.map((row) => ( ))} - {!filteredTasks.length ? ( + {!filteredTasks.length && !tasksError ? ( - ) : null}
{row.ne_name || row.ne_ip || "—"}
@@ -1611,8 +1798,9 @@ export function BizStatePage() { ) : null}
-
{t("bizState.empty")}
+
+
{tasksLoading ? t("bizState.sheetLoading") : t("bizState.empty")}
+ { setTaskPageSize(size); setTaskPage(1); }} />
{/* Create task: pick NE or offline import (unified entry) */} - + {t("bizState.create")} @@ -1825,14 +2016,18 @@ export function BizStatePage() { {/* Task detail modal */} - + - - {detail?.ne_name || detail?.ne_ip || t("bizState.detail")} + + TASK / {taskId.slice(0, 12)} + {detail?.ne_name || detail?.ne_ip || t("bizState.detail")} - + + {taskLoading ?
{t("bizState.taskLoading")}
: null} + {taskError ?
{t("bizState.taskLoadError")} · {taskError}
: null} +
{detail?.source !== "import" ? ( -
: null} {batchDetail ? (
- {String(batchDetail.status || "—")} + {batchStatusLabel(String(batchDetail.status || ""))} {batchDetail.alias ? ( {String(batchDetail.alias)} @@ -2638,13 +2849,15 @@ export function BizStatePage() {
) : null} -
+
{sheetTabs.map((s) => (
) : (
{ setSheetKeyword(e.target.value); @@ -2814,7 +3030,7 @@ export function BizStatePage() { )} {activeSheet.id !== "commands" ? ( -
+
@@ -2865,7 +3081,7 @@ export function BizStatePage() { ))} ))} - {!displayRows.length && !sheetLoading ? ( + {!displayRows.length && !sheetLoading && !sheetError ? (
{t("bizState.sheetEmpty")}
@@ -2884,13 +3100,15 @@ export function BizStatePage() { ) : null} + {sheetError ?
{t("bizState.sheetLoadError")} · {sheetError}
: null} + { setSheetPageSize(n); @@ -2899,12 +3117,12 @@ export function BizStatePage() { /> ) : ( -
{t("bizState.sheetEmpty")}
+ !batchLoading && !batchError ?
{t("bizState.sheetEmpty")}
: null )} {batchDetail ? ( - ) : null} @@ -2917,9 +3135,9 @@ export function BizStatePage() { {/* Raw CLI log viewer */} setRawLogOpen(false)} + onClose={closeRawLog} size="lg" - className="app-heroui-modal--lg bs-rawlog-modal" + className="app-heroui-modal--lg bs-rawlog-modal bs-monitor-modal" > {t("bizState.rawLogTitle")} @@ -2954,7 +3172,7 @@ export function BizStatePage() { > {t("bizState.exportRawLog")} - @@ -2965,7 +3183,7 @@ export function BizStatePage() { open={Boolean(collectDetail) || collectDetailLoading} onClose={closeCollectDetail} size="lg" - className="app-heroui-modal--lg bs-collect-detail-modal" + className="app-heroui-modal--lg bs-monitor-modal bs-collect-detail-modal" > {t("bizState.batchCollectDetail")} diff --git a/web/src/services/api.ts b/web/src/services/api.ts index c4aec6b..a8f8561 100644 --- a/web/src/services/api.ts +++ b/web/src/services/api.ts @@ -1944,6 +1944,9 @@ export const bizStatePurgeTask = (taskId: string) => export const bizStateGetBatch = (batchId: string) => apiGet>(`/v1/biz-state/batches/${encodeURIComponent(batchId)}`); +export const bizStateGetTaskProgress = (taskId: string) => + apiGet>(`/v1/biz-state/tasks/${encodeURIComponent(taskId)}/progress`); + export const bizStateListBatchMetricRows = (params: { batchId: string; metricId: string; diff --git a/web/tests/bizState.browser.mjs b/web/tests/bizState.browser.mjs new file mode 100644 index 0000000..ed74c28 --- /dev/null +++ b/web/tests/bizState.browser.mjs @@ -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(); }