diff --git a/docs/reviews/assets/biz-compare-config-mobile.png b/docs/reviews/assets/biz-compare-config-mobile.png index 9752405..29539dc 100644 Binary files a/docs/reviews/assets/biz-compare-config-mobile.png and b/docs/reviews/assets/biz-compare-config-mobile.png differ diff --git a/docs/reviews/assets/biz-compare-config.png b/docs/reviews/assets/biz-compare-config.png index b5be99c..98fe764 100644 Binary files a/docs/reviews/assets/biz-compare-config.png and b/docs/reviews/assets/biz-compare-config.png differ diff --git a/docs/reviews/assets/biz-compare-desktop.png b/docs/reviews/assets/biz-compare-desktop.png index 92feb94..20904ac 100644 Binary files a/docs/reviews/assets/biz-compare-desktop.png and b/docs/reviews/assets/biz-compare-desktop.png differ diff --git a/docs/reviews/assets/biz-compare-error.png b/docs/reviews/assets/biz-compare-error.png index d030ede..4396388 100644 Binary files a/docs/reviews/assets/biz-compare-error.png and b/docs/reviews/assets/biz-compare-error.png differ diff --git a/docs/reviews/assets/biz-compare-jobs.png b/docs/reviews/assets/biz-compare-jobs.png index f0f37f4..ace1467 100644 Binary files a/docs/reviews/assets/biz-compare-jobs.png and b/docs/reviews/assets/biz-compare-jobs.png differ diff --git a/docs/reviews/assets/biz-compare-runs-mobile.png b/docs/reviews/assets/biz-compare-runs-mobile.png index 1056b23..9e53762 100644 Binary files a/docs/reviews/assets/biz-compare-runs-mobile.png and b/docs/reviews/assets/biz-compare-runs-mobile.png differ diff --git a/docs/reviews/assets/biz-compare-runs.png b/docs/reviews/assets/biz-compare-runs.png index 0044588..9042eca 100644 Binary files a/docs/reviews/assets/biz-compare-runs.png and b/docs/reviews/assets/biz-compare-runs.png differ diff --git a/docs/reviews/assets/compare-template-editor-mobile.png b/docs/reviews/assets/compare-template-editor-mobile.png new file mode 100644 index 0000000..3dc4212 Binary files /dev/null and b/docs/reviews/assets/compare-template-editor-mobile.png differ diff --git a/docs/reviews/assets/compare-template-editor.png b/docs/reviews/assets/compare-template-editor.png new file mode 100644 index 0000000..a6419a9 Binary files /dev/null and b/docs/reviews/assets/compare-template-editor.png differ diff --git a/docs/reviews/compare-templates-review-2026-10-11.md b/docs/reviews/compare-templates-review-2026-10-11.md new file mode 100644 index 0000000..6c62f0d --- /dev/null +++ b/docs/reviews/compare-templates-review-2026-10-11.md @@ -0,0 +1,46 @@ +# 对比模板与业务对比评估、优化记录 + +日期:2026-10-11。范围:网络管理 → 割接对比 → 对比模板、业务对比。延续此前的业务对比和业务监控优化。本轮使用内存 SQLite、SQL 编译断言和本地浏览器固定 API 响应验证,没有连接真实设备,也没有改动生产数据库。 + +## 修复的结果准确性问题 + +1. **模板导入可能只保存有效部分。** 前后端检查全部检查项,空 Key、重复 ID、无效规则不再被静默丢弃。服务器在修改模板之前校验,错误响应包括配置路径和原因。执行时也检查历史模板的原始检查项,防止旧的无效配置被归一化后跳过。 +2. **错误过滤规则可能放行数据。** 新写入与执行入口拒绝未知运算符、混合分组/叶节点、空分组、无效正则及超过 12 层的嵌套。集合过滤要求非空数组;绝对/相对容差必须为有限非负数。无效归一模式、重复字段规则和接口别名同样报错。 +3. **复杂嵌套过滤被可视化编辑器截断。** 编辑器只接管能够完整表达的 OR/AND 结构,其余结构完整保留并显示 JSON。保存、导入、导出不删除嵌套条件。修改复杂结构可导出 JSON 后编辑并重新导入。 +4. **端口变化策略在 API 和导入导出中丢失。** `ignore_port_changes` 贯通请求模型、兼容单表配置、编辑、复制、保存、导入与导出。界面明确提供自动判断、接口必须匹配、允许端口变化三种选项;显式 `false` 不再丢失。 +5. **零、布尔和空值混淆。** Python 行键与过滤保留 `0`、`False`,布尔文本与 JSON 提取一致;数值比对不再把空值当成 0。Python/SQL 使用同一数值格式,支持 `+.5`、`1.`、科学计数法;解析不了的值按文本比较。 +6. **SQL 过滤与行键误匹配。** `contains` 的 `%`、`_`、反斜杠按字面值处理,空包含条件与 Python 一致。复合键改为 JSON 数组文本,避免 `('a|b','c')` 和 `('a','b|c')` 被拼成相同键。 +7. **任务范围失效导致隐式扩大或缩小对比。** 编辑任务时保留已移除的检查项 ID,显示错误并禁用保存、执行;用户可明确重新选择或启用当前全部检查项。服务器在保存和执行时都检查 ID,部分有效、部分失效的范围也会被拒绝。 +8. **不完整采集被解释成业务变化。** 比对入口只接受两侧状态为 `success` 的源批次;排队、执行中、失败、取消和部分完成批次返回 `source_batch_not_complete`。界面禁用这些选项。成功批次没有某指标或没有行的原有处理保持不变;本轮没有新增逐指标采集完备性判定。 +9. **删除破坏引用或在途对比。** 已被任务引用的模板不能删除;存在排队/执行中记录的任务及活动记录不能删除。模板页显示任务引用数量。这些是 API 状态检查,未新增数据库外键或锁,不宣称消除跨请求的全部并发窗口。 +10. **重新启用忽略字段仍然无效。** 勾选参与比对时清除遗留的 ignore/skip/off 规则。字段表保留模板配置的自定义字段;过滤专用字段也能显示。兼容数值、相对偏差和归一规则别名的显示。 + +## 性能与界面 + +- 检查项排序从逐表、逐批次查询改为 **一次按批次/metric 聚合**。相同 metric 的拆分表共享统计,SQL 不投影原始 CLI 日志;回归验证查询次数为 1。 +- `batch_metric_collect_ok` 只读取解析状态和行数,不加载命令原文。 +- 模板引用数量由一次 grouped 查询计算,避免每行请求。默认模板初始化/升级仍执行原来的查询。 +- 模板页面只请求模板和字段目录,省去业务监控任务、端口映射、对比任务三个列表;字段目录缓存 60 秒。业务对比与模板列表使用独立缓存键。 +- 两个列表默认每页渲染 50 行,可选 20/50/100/200;模板 ID 查找使用 Map。**API 仍返回完整列表,客户端分页减少 DOM,不减少响应体。** +- 模板编辑器使用深色平面面板、细边框、小圆角、蓝色选中态、等宽字段和固定表头。接口归一化折叠,检查项导航与规则区并排;窄屏导航横向滚动,操作按钮独立一行,过滤控件可换行,底部保存始终可见。 +- 导航和规则页签支持方向键、Home/End;规则控件增加可访问名称。保存期间编辑区不可交互,并阻止关闭后打开新模板造成保存响应串扰。 + +没有测量生产 PostgreSQL 的耗时或内存;本轮 SQL 变更由生成 SQL/绑定参数断言验证,未执行真实 PostgreSQL 集成回归。极大数值指数的 SQL 浮点转换、Python 与 PostgreSQL 正则方言差异、全文 JSON 搜索和大型 ZIP 内存仍需单独基准。既有回退机制不等于所有 SQL/Python 语义已完全一致。 + +## 验证与预览 + +- 后端相关回归:**120 项通过,1 项跳过**。跳过的是需显式开启的百万行压力测试。覆盖模板校验、原子更新、旧记录执行保护、端口策略、过滤、数值、复合键、查询次数、删除保护、范围失效、源批次状态,以及既有对比、搜索、配置快照、导出、恢复与内存相关回归。 +- Node 测试:**14 项通过**(8 项模板模型、6 项缓存)。 +- 浏览器:**53 个场景通过**(原业务对比 27 项、模板新增 26 项)。全部 `/v1` 请求使用固定响应,120 个模板列表验证默认 50 行上限;覆盖导入导出、复杂过滤、端口策略、负容差、忽略字段恢复、保存期间交互锁定、范围修复、键盘和 390px 布局。 +- Vite 生产构建通过;原有主包超过 500 kB 提示仍在。 +- 全项目 TypeScript 检查仍有 `BizMigrationPage.tsx` 的 4 个既有错误:2 个未使用变量、2 处字符串传给数值控件;本轮没有新增错误。 +- 新模板模型 ESLint 通过;业务对比页保留原来的 **23 个错误**(17 个 any、6 个 effect 状态同步),与修改前数量一致。不宣称全项目 lint 通过。 + +前后端应一起更新;新增 API 字段为模板引用数量与端口策略,无数据库迁移。原来使用标量 `in/not_in`、未知规则、负容差的模板需要修正再保存/执行;部分完成的源批次需要重新完成采集后再对比。 + +- [桌面模板编辑器](assets/compare-template-editor.png) +- [390px 模板编辑器](assets/compare-template-editor-mobile.png) +- [业务对比列表](assets/biz-compare-jobs.png) +- [此前业务对比记录](biz-compare-review-2026-10-10.md) + +复现:先在 `web` 启动 `node node_modules/vite/bin/vite.js --host 127.0.0.1 --port 5179`,另一个终端运行 `node tests/bizCompareTemplates.browser.mjs` 和 `node tests/bizCompare.browser.mjs`。可用 `NETX_PLAYWRIGHT_MODULE` 指定已有 Playwright,`NETX_TEST_URL` 指定本地地址,`NETX_TEST_OUTPUT` 指定截图目录。 diff --git a/netx_api/biz_state/compare_service.py b/netx_api/biz_state/compare_service.py index 4ac8ddd..286c29b 100644 --- a/netx_api/biz_state/compare_service.py +++ b/netx_api/biz_state/compare_service.py @@ -2237,9 +2237,15 @@ def _validate_compare_job( if any(b.status != "success" for b in source_batches): raise HTTPException(status_code=409, detail="source_batch_not_complete") + # Existing records may predate write validation. Do not normalize invalid + # sheets away or run unknown rules as successful comparisons. + if tpl.metrics_json: + validate_template_body({"metrics": tpl.metrics_json, + "iface_normalize_rules": tpl.iface_normalize_json}) sheets_cfg = template_metrics(tpl) if not sheets_cfg: raise HTTPException(status_code=400, detail="template_has_no_metrics") + _validate_job_sheets(db, tpl.id, _str_list(j.enabled_sheet_ids)) sheets_cfg = _filter_enabled_sheets(sheets_cfg, getattr(j, "enabled_sheet_ids", None)) if not sheets_cfg: raise HTTPException(status_code=400, detail="no_enabled_sheets") diff --git a/tests/test_biz_compare_templates.py b/tests/test_biz_compare_templates.py index 2db9109..a1f76f0 100644 --- a/tests/test_biz_compare_templates.py +++ b/tests/test_biz_compare_templates.py @@ -157,3 +157,34 @@ def test_compare_rejects_incomplete_sources_before_enqueue(db, status): svc._validate_compare_job(db, "j") assert exc.value.detail == "source_batch_not_complete" assert db.query(BizCompareRun).count() == 0 + + +def test_compare_rejects_partially_stale_sheet_scope_after_template_edit(db): + tpl = svc.create_template(db, body()) + db.add_all([BizStateBatch(id="before", status="success"), BizStateBatch(id="after", status="success"), + BizCompareJob(id="j", template_id=tpl["id"], before_batch_id="before", after_batch_id="after", enabled_sheet_ids=["s", "removed"])]) + db.commit() + with pytest.raises(HTTPException) as exc: + svc._validate_compare_job(db, "j") + assert exc.value.detail == {"error": "unknown_enabled_sheets", "sheet_ids": ["removed"]} + assert db.query(BizCompareRun).count() == 0 + + +@pytest.mark.parametrize("bad", [ + {"key_fields": []}, + {"row_filters": [{"field": "id", "op": "typo", "value": "a"}]}, + {"row_filters": [{"field": "id", "op": "regex", "value": "["}]}, + {"field_rules": [{"field": "count", "compare": "typo"}]}, +]) +def test_legacy_invalid_template_cannot_execute_as_partial_or_fail_open(db, bad): + tpl = svc.create_template(db, body()) + row = db.get(BizCompareTemplate, tpl["id"]) + row.metrics_json = [*row.metrics_json, {**body()["metrics"][0], "sheet_id": "invalid", **bad}] + db.add_all([BizStateBatch(id="before", status="success"), BizStateBatch(id="after", status="success"), + BizCompareJob(id="j", template_id=tpl["id"], before_batch_id="before", after_batch_id="after")]) + db.commit() + with pytest.raises(HTTPException) as exc: + svc._validate_compare_job(db, "j") + assert exc.value.detail["error"] == "invalid_template" + assert "metrics[1]" in exc.value.detail["path"] + assert db.query(BizCompareRun).count() == 0 diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 0f9dce2..71324ab 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -386,6 +386,17 @@ const en = { templateExported: "Template exported", templateImported: "Template imported", templateImportInvalid: "Invalid file: need name and metrics", + templateValidationFailed: "Template validation failed; correct the indicated settings", + filterOperator: "Filter operator", + complexFiltersPreserved: "These nested filters are preserved in full. Export, edit the JSON and import it to update them.", + portPolicy: "Port change policy", + portPolicyHint: "Port mappings take priority. This policy controls whether interface identity fields participate in matching without a mapping.", + portPolicyAuto: "Automatic", + portPolicyStrict: "Interfaces must match", + portPolicyIgnore: "Allow port changes", + missingSheets: "Configured checks were removed or renamed; select the scope again", + enableCurrentSheets: "Enable all current checks", + templateUsage: "Used by {{n}} jobs", templateImportHint: "Import/export JSON to share templates privately (no job/batch data)", templateHint: "Pick metrics, then set Key / interface / compare / display. Compare modes: exact, abs delta, or relative %. Display can include context-only columns. Empty compare = presence-only.", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 23778f9..1406fb1 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -386,6 +386,17 @@ const zh = { templateExported: "模板已导出", templateImported: "模板已导入", templateImportInvalid: "导入文件无效:需要 name 与 metrics", + templateValidationFailed: "模板校验失败,请修正标明的配置项", + filterOperator: "过滤运算符", + complexFiltersPreserved: "该过滤规则包含复杂嵌套,已完整保留。可通过导出、修改 JSON 后重新导入来更新。", + portPolicy: "端口变化策略", + portPolicyHint: "存在端口映射时按映射匹配;此策略控制无映射时接口身份字段是否参与配对。", + portPolicyAuto: "自动判断", + portPolicyStrict: "接口必须匹配", + portPolicyIgnore: "允许端口变化", + missingSheets: "配置中的检查项已被移除或改名,请重新选择检查范围", + enableCurrentSheets: "启用当前全部检查项", + templateUsage: "已被 {{n}} 个任务引用", templateImportHint: "导入/导出 JSON,便于私下传递模板(不含任务/批次数据)", templateHint: "先勾选监控项,再配置 Key / 接口 / 比对 / 展示。比对可设完全相等、绝对容差或相对偏差%;展示列可含不参与比对的上下文。不勾比对字段则只校验条目存在性。", diff --git a/web/src/index.css b/web/src/index.css index b60b7f0..614894b 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -12583,6 +12583,54 @@ html.login-page--paused .login-page__flare { .bs-monitor-modal .bs-sheet-filter > .input { flex-basis: 100%; } } +/* Compare templates: compact operations editor and safe nested-rule readout. */ +.bs-template-editor .modal__dialog { border-radius: 6px; border-color: #2b3748; background: #0c1521; } +.bs-template-editor .button, +.bs-template-editor .input, +.bs-template-editor textarea, +.bs-template-editor .mt-select, +.bs-template-editor .ui-field__select { border-radius: 4px; box-shadow: none; } +.bs-template-editor .modal__footer { flex-wrap: wrap; } +.bs-template-editor .ct-editor__norm, +.bs-template-editor .ct-editor__pick, +.bs-template-editor .ct-editor__nav, +.bs-template-editor .ct-editor__pane { border: 1px solid #2b3748; border-radius: 4px; background: #111c2a; min-width: 0; } +.bs-template-editor .ct-editor__norm { padding: 10px; } +.bs-template-editor .ct-editor__norm summary { cursor: pointer; color: #c4ddfa; } +.bs-template-editor .ct-editor__norm-area { width: 100%; max-width: 100%; resize: vertical; font: 12px/1.6 ui-monospace, Consolas, monospace; } +.bs-template-editor .ct-editor__main { grid-template-columns: 190px minmax(0, 1fr); min-width: 0; } +.bs-template-editor .ct-editor__nav { max-height: 560px; } +.bs-template-editor .ct-editor__nav-item { border-radius: 3px; font-size: 12px; } +.bs-template-editor .ct-editor__nav-item.is-active { background: #1b2c43; border-color: #526f94; } +.bs-template-editor .ct-editor__meta { font: 11px/1.6 ui-monospace, Consolas, monospace; overflow-wrap: anywhere; } +.bs-template-editor .mt-rule-tab.is-active { color: #9cc7f7; border-color: #526f94; background: #1b2c43; } +.bs-template-editor .ct-field-table-wrap { min-width: 0; overflow: auto; border: 1px solid #2b3748; border-radius: 4px; } +.bs-template-editor .ct-field-table { min-width: 800px; } +.bs-template-editor .ct-field-table td { font-size: 12px; } +.bs-template-editor .ct-field-table th { font-size: 11px; } +.bs-template-editor .ct-field-table .input { max-width: 100px; } +.bs-template-editor .ct-filter-row { min-width: 0; } +.bs-template-editor .ct-filter-row .mt-select { min-width: 0; max-width: 100%; } +.bs-template-editor .ct-complex-filters pre { max-height: 360px; overflow: auto; background: #0c1521; border: 1px solid #2b3748; border-radius: 4px; padding: 10px; font: 11px/1.6 ui-monospace, Consolas, monospace; } +.bs-template-editor button:focus-visible { outline: 2px solid #6ba9ec; outline-offset: 2px; } +.bs-template-workspace .pt-list-table { min-width: 720px; } +.bs-template-workspace .bs-cmp-metric-chips { max-height: 84px; overflow: auto; } +@media (max-width: 900px) { + .bs-template-editor .ct-editor__main { grid-template-columns: minmax(0, 1fr); } + .bs-template-editor .ct-editor__nav-list { flex-direction: row; overflow-x: auto; } + .bs-template-editor .ct-editor__nav-item { flex: 0 0 160px; } + .bs-template-editor .ct-editor__nav { max-height: none; } +} +@media (max-width: 600px) { + .bs-template-editor .ct-editor__pane { padding: 10px; } + .bs-template-editor .ct-editor__pane-head { flex-direction: column; } + .bs-template-editor .ct-editor__pane-head > div:first-child { width: 100%; } + .bs-template-editor .ct-editor__pane-head > .btn-row { align-self: flex-end; } + .bs-template-editor .ct-filter-row { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto; } + .bs-template-editor .ct-filter-row > .input { grid-column: 1 / 3; grid-row: 2; } + .bs-template-editor .ct-filter-row > button { grid-column: 3; grid-row: 1 / 3; } +} + /* 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/BizComparePage.tsx b/web/src/pages/network/BizComparePage.tsx index e28d22c..8c215e1 100644 --- a/web/src/pages/network/BizComparePage.tsx +++ b/web/src/pages/network/BizComparePage.tsx @@ -1,6 +1,8 @@ import { Button, Input, Modal } from "@heroui/react"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import type { KeyboardEvent as ReactKeyboardEvent } from "react"; +import { parseTemplateImport, parseIfaceNormText, toOrGroups, fromOrGroups, validateTemplateDraft } from "./compareTemplateModel"; +import type { MetricSheet, FieldRule, RowFilter } from "./compareTemplateModel"; import { ListPager } from "../../components/ListPager"; import { AppModalShell } from "../../components/ui/AppModalShell"; import { FieldSelect } from "../../components/ui/FieldSelect"; @@ -61,34 +63,6 @@ type MetricField = { }; type MetricSchema = { metric_id: string; fields: MetricField[] }; -type MetricSheet = { - sheet_id?: string; - title?: string; - metric_id: string; - key_fields: string[]; - iface_fields: string[]; - compare_fields: string[]; - display_fields?: string[]; - row_filters?: RowFilter[]; - field_rules?: FieldRule[]; -}; - -type FieldRule = { - field: string; - compare?: string; - normalize?: string; - ignore?: boolean; - tolerance?: number; -}; - -type RowFilter = { - field?: string; - op?: string; - value?: string | string[]; - any?: RowFilter[]; - all?: RowFilter[]; -}; - type Template = { id: string; name: string; @@ -100,6 +74,7 @@ type Template = { compare_fields: string[]; iface_normalize_rules?: { from: string; to: string }[]; note: string; + job_count?: number; }; type Mapping = { id: string; name: string; rows: { before_if: string; after_if: string }[] }; @@ -549,6 +524,7 @@ function cloneSheet(s: MetricSheet): MetricSheet { display_fields: display, row_filters: JSON.parse(JSON.stringify(s.row_filters || [])), field_rules: JSON.parse(JSON.stringify(s.field_rules || [])), + ignore_port_changes: s.ignore_port_changes, }; } @@ -569,6 +545,7 @@ function templateExportPayload(tpl: Template) { : [...(s.key_fields || []), ...(s.compare_fields || [])], row_filters: JSON.parse(JSON.stringify(s.row_filters || [])), field_rules: JSON.parse(JSON.stringify(s.field_rules || [])), + ignore_port_changes: s.ignore_port_changes, })); return { format: TEMPLATE_EXPORT_FORMAT, @@ -581,105 +558,10 @@ function templateExportPayload(tpl: Template) { }; } -function parseTemplateImport(raw: unknown): { - name: string; - note: string; - metrics: MetricSheet[]; - iface_normalize_rules?: { from: string; to: string }[]; -} | null { - if (!raw || typeof raw !== "object") return null; - const o = raw as Record; - // Accept our export shape or a bare {name, metrics} / TemplateIn body - const name = String(o.name || "").trim(); - const note = String(o.note || ""); - let metricsRaw = o.metrics; - if (!Array.isArray(metricsRaw) && o.metric_id) { - metricsRaw = [ - { - metric_id: o.metric_id, - key_fields: o.key_fields || [], - iface_fields: o.iface_fields || [], - compare_fields: o.compare_fields || [], - display_fields: o.display_fields || [], - row_filters: o.row_filters || [], - field_rules: o.field_rules || [], - }, - ]; - } - if (!name || !Array.isArray(metricsRaw) || !metricsRaw.length) return null; - const metrics: MetricSheet[] = []; - for (const item of metricsRaw) { - if (!item || typeof item !== "object") continue; - const m = item as Record; - const mid = String(m.metric_id || "").trim(); - const keys = Array.isArray(m.key_fields) - ? (m.key_fields as unknown[]).map((x) => String(x).trim()).filter(Boolean) - : []; - if (!mid || !keys.length) continue; - metrics.push({ - sheet_id: String(m.sheet_id || mid).trim() || mid, - title: String(m.title || m.sheet_id || mid).trim() || mid, - metric_id: mid, - key_fields: keys, - iface_fields: Array.isArray(m.iface_fields) - ? (m.iface_fields as unknown[]).map((x) => String(x).trim()).filter(Boolean) - : [], - compare_fields: Array.isArray(m.compare_fields) - ? (m.compare_fields as unknown[]).map((x) => String(x).trim()).filter(Boolean) - : [], - display_fields: Array.isArray(m.display_fields) - ? (m.display_fields as unknown[]).map((x) => String(x).trim()).filter(Boolean) - : undefined, - row_filters: Array.isArray(m.row_filters) ? (m.row_filters as RowFilter[]) : [], - field_rules: Array.isArray(m.field_rules) ? (m.field_rules as FieldRule[]) : [], - }); - } - if (!metrics.length) return null; - const normRaw = o.iface_normalize_rules; - const iface_normalize_rules: { from: string; to: string }[] = []; - if (Array.isArray(normRaw)) { - for (const item of normRaw) { - if (!item || typeof item !== "object") continue; - const r = item as Record; - const fr = String(r.from || "").trim(); - const to = String(r.to || "").trim(); - if (fr && to) iface_normalize_rules.push({ from: fr, to }); - } - } - return { name, note, metrics, iface_normalize_rules }; -} - function ifaceNormRulesToText(rules?: { from: string; to: string }[]): string { return (rules || []).map((r) => `${r.from},${r.to}`).join("\n"); } -function parseIfaceNormText(text: string): { from: string; to: string }[] { - const out: { from: string; to: string }[] = []; - for (const line of text.split(/\r?\n/)) { - const s = line.trim(); - if (!s || s.startsWith("#")) continue; - let fr = ""; - let to = ""; - if (s.includes(",")) { - const i = s.indexOf(","); - fr = s.slice(0, i).trim(); - to = s.slice(i + 1).trim(); - } else if (s.includes("\t")) { - const i = s.indexOf("\t"); - fr = s.slice(0, i).trim(); - to = s.slice(i + 1).trim(); - } else { - const parts = s.split(/\s+/); - if (parts.length >= 2) { - fr = parts[0]; - to = parts.slice(1).join(" "); - } - } - if (fr && to) out.push({ from: fr, to }); - } - return out; -} - function downloadJsonFile(filename: string, data: unknown) { const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" }); const url = URL.createObjectURL(blob); @@ -713,51 +595,6 @@ function upsertFieldRule(sheet: MetricSheet, field: string, patch: Partial { - if (g?.all && Array.isArray(g.all)) return g.all.filter(isLeafFilter); - if (isLeafFilter(g)) return [g]; - return [] as RowFilter[]; - }) - .filter((g) => g.length); - } - if (list.every(isLeafFilter)) return [list.map((f) => ({ ...f }))]; - // Mixed / opaque: keep editable leaves only - const leaves = list.filter(isLeafFilter); - return leaves.length ? [leaves] : []; -} - -/** Serialize OR-groups back to engine row_filters (AND of leaves, or single any-of). */ -function fromOrGroups(groups: RowFilter[][]): RowFilter[] { - const clean = groups - .map((g) => - g - .map((f) => ({ - field: f.field || "", - op: f.op || "eq", - value: f.value ?? "", - })) - .filter((f) => f.field), - ) - .filter((g) => g.length); - if (!clean.length) return []; - if (clean.length === 1) return clean[0]; - return [ - { - any: clean.map((g) => (g.length === 1 ? g[0] : { all: g })), - }, - ]; -} - function emptyLeaf(field = ""): RowFilter { return { field, op: "eq", value: "" }; } @@ -827,6 +664,7 @@ function TplRowFiltersEditor({
{ const next = groups.map((g) => g.map((x) => ({ ...x }))); const op = next[gi][fi]?.op || "eq"; let value: string | string[] = e.target.value; - if (op === "in" || op === "not_in") { + if (["in", "not_in", "nin"].includes(op)) { value = e.target.value .split(",") .map((x) => x.trim()) @@ -930,10 +776,13 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage const [mappings, setMappings] = useState([]); const [jobs, setJobs] = useState([]); const [listKw, setListKw] = useState(""); + const [listPage, setListPage] = useState(1); + const [listPageSize, setListPageSize] = useState(50); const debouncedListKw = useDebouncedValue(listKw, 250); // template editor const [tplOpen, setTplOpen] = useState(false); + const [tplError, setTplError] = useState(""); const [tplEditId, setTplEditId] = useState(""); const [tplName, setTplName] = useState(""); const [tplNote, setTplNote] = useState(""); @@ -1026,11 +875,11 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage }; const fetchBundle = async (): Promise => { const [taskRes, tpl, maps, j, met] = await Promise.all([ - bizStateListTasks(), + pageMode === "templates" ? Promise.resolve({items: []}) : bizStateListTasks(), bizCompareListTemplates(), - bizCompareListMappings(), - bizCompareListJobs(), - bizCompareListMetrics(), + pageMode === "templates" ? Promise.resolve({items: []}) : bizCompareListMappings(), + pageMode === "templates" ? Promise.resolve({items: []}) : bizCompareListJobs(), + cutoverCachedGet("bizCompare:metrics", () => bizCompareListMetrics(), {ttlMs: 60000}), ]); return { taskRes, tpl, maps, j, met }; }; @@ -1043,11 +892,11 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage setMetrics((b.met.items || []) as MetricSchema[]); }; if (opts?.force) { - apply(await cutoverCachedGet("bizCompare:lists", fetchBundle, { force: true })); + apply(await cutoverCachedGet(`bizCompare:lists:${pageMode}`, fetchBundle, { force: true })); return; } - apply(await cutoverCachedGetSWR("bizCompare:lists", fetchBundle, apply)); - }, []); + apply(await cutoverCachedGetSWR(`bizCompare:lists:${pageMode}`, fetchBundle, apply)); + }, [pageMode]); useEffect(() => { void (async () => { @@ -1140,10 +989,13 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage return `${x.name} ${mids} ${x.note}`.toLowerCase().includes(kw); }); }, [templates, debouncedListKw]); + const listTotal = activeTab === "templates" ? filteredTemplates.length : filteredJobs.length; + const listPages = Math.max(1, Math.ceil(listTotal / listPageSize)); + const visibleListPage = Math.min(listPage, listPages); const selectedJobTemplate = useMemo( - () => templates.find((x) => x.id === templateId) || null, - [templates, templateId], + () => templatesById.get(templateId) || null, + [templatesById, templateId], ); const jobTemplateSheets = useMemo( @@ -1193,23 +1045,33 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage const setJobTemplateAndSheets = useCallback( (nextTplId: string, presetIds?: string[] | null) => { setTemplateId(nextTplId); - const tpl = templates.find((x) => x.id === nextTplId); - const all = tpl ? templateSheets(tpl).map((s) => sheetIdentity(s)).filter(Boolean) : []; if (presetIds && presetIds.length) { - const kept = presetIds.filter((id) => all.includes(id)); - setEnabledSheetIds(kept.length === all.length ? [] : kept); + // Keep stale IDs visible as a configuration error; [] would enable every sheet. + setEnabledSheetIds([...presetIds]); } else { setEnabledSheetIds([]); } }, - [templates], + [], ); const activeTplSheet = tplSheets[tplSheetIdx] || null; const activeTplFields = useMemo(() => { if (!activeTplSheet) return []; - return metrics.find((m) => m.metric_id === activeTplSheet.metric_id)?.fields || []; + const fields = [...(metrics.find((m) => m.metric_id === activeTplSheet.metric_id)?.fields || [])]; + const seen = new Set(fields.map((f) => f.name)); + for (const name of [...activeTplSheet.key_fields, ...activeTplSheet.iface_fields, + ...activeTplSheet.compare_fields, ...(activeTplSheet.display_fields || []), + ...(activeTplSheet.field_rules || []).map((r) => r.field)]) { + if (!seen.has(name)) { + fields.push({name, display_name: name, dtype: "string", is_key: false, is_interface: false, role: "meta"}); + seen.add(name); + } + } + return fields; }, [metrics, activeTplSheet]); + const activeTplFilterGroups = activeTplSheet ? toOrGroups(activeTplSheet.row_filters || []) : []; + const missingJobSheets = enabledSheetIds.filter((id) => !jobSheetAllIds.includes(id)); const runSheets: RunSheet[] = useMemo(() => { const sheets = (runDetail?.sheets || []) as RunSheet[]; @@ -1593,6 +1455,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage }; const updateActiveSheet = (patch: Partial) => { + setTplError(""); setTplSheets((prev) => prev.map((s, i) => { if (i !== tplSheetIdx) return s; @@ -1630,6 +1493,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage }; const openNewTemplate = () => { + setTplError(""); setTplEditId(""); setTplName(""); setTplNote(""); @@ -1642,6 +1506,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage }; const openEditTemplate = (tpl: Template) => { + setTplError(""); const sheets = templateSheets(tpl); setTplEditId(tpl.id); setTplName(tpl.name); @@ -1713,6 +1578,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage }; const saveTemplate = async () => { + setTplError(""); if (!tplSheets.length) { showError(t("bizCompare.metricsRequired")); return; @@ -1734,6 +1600,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage } setBusy(true); try { + validateTemplateDraft(tplSheets); const body = { name: tplName || tplSheets.map((s) => sheetLabel(s)).join("+"), note: tplNote, @@ -1748,6 +1615,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage display_fields: s.display_fields || [...s.key_fields, ...s.compare_fields], row_filters: s.row_filters || [], field_rules: s.field_rules || [], + ignore_port_changes: s.ignore_port_changes, })), }; if (tplEditId) await bizCompareUpdateTemplate(tplEditId, body); @@ -1758,6 +1626,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage invalidateCutoverCache("bizMigration:"); await refresh({ force: true }); } catch (e) { + setTplError(formatErr(e)); showError(formatErr(e)); } finally { setBusy(false); @@ -1817,6 +1686,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage display_fields: s.display_fields || [...s.key_fields, ...s.compare_fields], row_filters: s.row_filters || [], field_rules: s.field_rules || [], + ignore_port_changes: s.ignore_port_changes, })), }); showOk(t("bizCompare.templateImported")); @@ -1913,12 +1783,9 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage const tplId = preset?.template_id || templates[0]?.id || ""; setName(preset?.name || t("bizCompare.defaultJobName")); setTemplateId(tplId); - const tpl = templates.find((x) => x.id === tplId); - const all = tpl ? templateSheets(tpl).map((s) => sheetIdentity(s)).filter(Boolean) : []; const presetIds = (preset?.enabled_sheet_ids || []).map(String).filter(Boolean); if (presetIds.length) { - const kept = presetIds.filter((id) => all.includes(id)); - setEnabledSheetIds(kept.length === all.length ? [] : kept); + setEnabledSheetIds(presetIds); } else { setEnabledSheetIds([]); } @@ -2129,6 +1996,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage const saveJobConfig = async () => { if (!jobId) return; + if (missingJobSheets.length) { showError(t("bizCompare.missingSheets")); return; } if (!enabledJobSheetCount) { showError(t("bizCompare.needSheets")); return; @@ -2160,6 +2028,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage const runNow = async () => { if (!jobId) return; + if (missingJobSheets.length) { showError(t("bizCompare.missingSheets")); return; } if (!enabledJobSheetCount) { showError(t("bizCompare.needSheets")); return; @@ -2321,6 +2190,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage

{t("bizCompare.enabledSheetsHint")}

+ {missingJobSheets.length ?
{t("bizCompare.missingSheets")} · {missingJobSheets.join(", ")}
: null}
{jobTemplateSheets.map((s) => { const sid = sheetIdentity(s); @@ -2441,7 +2311,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage > {beforeBatches.map((b) => ( - ))} @@ -2482,7 +2352,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage > {afterBatches.map((b) => ( - ))} @@ -2555,7 +2425,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage ); return ( -
+

{pageMode === "templates" ? t("bizCompare.templates") : t("bizCompare.title")} @@ -2581,7 +2451,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage > {t("bizCompare.importTemplate")} - @@ -2622,7 +2492,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage placeholder={ activeTab === "jobs" ? t("bizCompare.jobFilterPh") : t("bizCompare.templateFilterPh") } - onChange={(e) => setListKw(e.target.value)} + onChange={(e) => { setListKw(e.target.value); setListPage(1); }} />

@@ -2641,12 +2511,13 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage - {filteredTemplates.map((tpl) => { + {filteredTemplates.slice((visibleListPage - 1) * listPageSize, visibleListPage * listPageSize).map((tpl) => { const sheets = templateSheets(tpl); return (
{tpl.name}
+ {tpl.job_count ?
{t("bizCompare.templateUsage", {n: tpl.job_count})}
: null}
@@ -2664,7 +2535,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage {tpl.note || "—"}
-
)} + { setListPageSize(n); setListPage(1); }} />
{/* Template editor */} - setTplOpen(false)} size="cover"> + { if (!busy) setTplOpen(false); }} dismissible={!busy} size="cover" className="app-heroui-modal--xl bs-template-editor"> {tplEditId ? t("bizCompare.editTemplate") : t("bizCompare.newTemplate")} - + - + + {tplError ?
{t("bizCompare.templateValidationFailed")} · {tplError}
: null}
-
-
{t("bizCompare.ifaceNormalize")}
+
+ {t("bizCompare.ifaceNormalize")}

{t("bizCompare.ifaceNormalizeHint")}