feat(biz-compare): preserve templates and refine industrial editor
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 95 KiB |
BIN
docs/reviews/assets/compare-template-editor-mobile.png
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
docs/reviews/assets/compare-template-editor.png
Normal file
|
After Width: | Height: | Size: 69 KiB |
46
docs/reviews/compare-templates-review-2026-10-11.md
Normal file
|
|
@ -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` 指定截图目录。
|
||||||
|
|
@ -2237,9 +2237,15 @@ def _validate_compare_job(
|
||||||
if any(b.status != "success" for b in source_batches):
|
if any(b.status != "success" for b in source_batches):
|
||||||
raise HTTPException(status_code=409, detail="source_batch_not_complete")
|
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)
|
sheets_cfg = template_metrics(tpl)
|
||||||
if not sheets_cfg:
|
if not sheets_cfg:
|
||||||
raise HTTPException(status_code=400, detail="template_has_no_metrics")
|
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))
|
sheets_cfg = _filter_enabled_sheets(sheets_cfg, getattr(j, "enabled_sheet_ids", None))
|
||||||
if not sheets_cfg:
|
if not sheets_cfg:
|
||||||
raise HTTPException(status_code=400, detail="no_enabled_sheets")
|
raise HTTPException(status_code=400, detail="no_enabled_sheets")
|
||||||
|
|
|
||||||
|
|
@ -157,3 +157,34 @@ def test_compare_rejects_incomplete_sources_before_enqueue(db, status):
|
||||||
svc._validate_compare_job(db, "j")
|
svc._validate_compare_job(db, "j")
|
||||||
assert exc.value.detail == "source_batch_not_complete"
|
assert exc.value.detail == "source_batch_not_complete"
|
||||||
assert db.query(BizCompareRun).count() == 0
|
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
|
||||||
|
|
|
||||||
|
|
@ -386,6 +386,17 @@ const en = {
|
||||||
templateExported: "Template exported",
|
templateExported: "Template exported",
|
||||||
templateImported: "Template imported",
|
templateImported: "Template imported",
|
||||||
templateImportInvalid: "Invalid file: need name and metrics",
|
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)",
|
templateImportHint: "Import/export JSON to share templates privately (no job/batch data)",
|
||||||
templateHint:
|
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.",
|
"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.",
|
||||||
|
|
|
||||||
|
|
@ -386,6 +386,17 @@ const zh = {
|
||||||
templateExported: "模板已导出",
|
templateExported: "模板已导出",
|
||||||
templateImported: "模板已导入",
|
templateImported: "模板已导入",
|
||||||
templateImportInvalid: "导入文件无效:需要 name 与 metrics",
|
templateImportInvalid: "导入文件无效:需要 name 与 metrics",
|
||||||
|
templateValidationFailed: "模板校验失败,请修正标明的配置项",
|
||||||
|
filterOperator: "过滤运算符",
|
||||||
|
complexFiltersPreserved: "该过滤规则包含复杂嵌套,已完整保留。可通过导出、修改 JSON 后重新导入来更新。",
|
||||||
|
portPolicy: "端口变化策略",
|
||||||
|
portPolicyHint: "存在端口映射时按映射匹配;此策略控制无映射时接口身份字段是否参与配对。",
|
||||||
|
portPolicyAuto: "自动判断",
|
||||||
|
portPolicyStrict: "接口必须匹配",
|
||||||
|
portPolicyIgnore: "允许端口变化",
|
||||||
|
missingSheets: "配置中的检查项已被移除或改名,请重新选择检查范围",
|
||||||
|
enableCurrentSheets: "启用当前全部检查项",
|
||||||
|
templateUsage: "已被 {{n}} 个任务引用",
|
||||||
templateImportHint: "导入/导出 JSON,便于私下传递模板(不含任务/批次数据)",
|
templateImportHint: "导入/导出 JSON,便于私下传递模板(不含任务/批次数据)",
|
||||||
templateHint:
|
templateHint:
|
||||||
"先勾选监控项,再配置 Key / 接口 / 比对 / 展示。比对可设完全相等、绝对容差或相对偏差%;展示列可含不参与比对的上下文。不勾比对字段则只校验条目存在性。",
|
"先勾选监控项,再配置 Key / 接口 / 比对 / 展示。比对可设完全相等、绝对容差或相对偏差%;展示列可含不参与比对的上下文。不勾比对字段则只校验条目存在性。",
|
||||||
|
|
|
||||||
|
|
@ -12583,6 +12583,54 @@ html.login-page--paused .login-page__flare {
|
||||||
.bs-monitor-modal .bs-sheet-filter > .input { flex-basis: 100%; }
|
.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) */
|
/* 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 {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import { Button, Input, Modal } from "@heroui/react";
|
import { Button, Input, Modal } from "@heroui/react";
|
||||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
import type { KeyboardEvent as ReactKeyboardEvent } 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 { ListPager } from "../../components/ListPager";
|
||||||
import { AppModalShell } from "../../components/ui/AppModalShell";
|
import { AppModalShell } from "../../components/ui/AppModalShell";
|
||||||
import { FieldSelect } from "../../components/ui/FieldSelect";
|
import { FieldSelect } from "../../components/ui/FieldSelect";
|
||||||
|
|
@ -61,34 +63,6 @@ type MetricField = {
|
||||||
};
|
};
|
||||||
type MetricSchema = { metric_id: string; fields: 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 = {
|
type Template = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -100,6 +74,7 @@ type Template = {
|
||||||
compare_fields: string[];
|
compare_fields: string[];
|
||||||
iface_normalize_rules?: { from: string; to: string }[];
|
iface_normalize_rules?: { from: string; to: string }[];
|
||||||
note: string;
|
note: string;
|
||||||
|
job_count?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Mapping = { id: string; name: string; rows: { before_if: string; after_if: string }[] };
|
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,
|
display_fields: display,
|
||||||
row_filters: JSON.parse(JSON.stringify(s.row_filters || [])),
|
row_filters: JSON.parse(JSON.stringify(s.row_filters || [])),
|
||||||
field_rules: JSON.parse(JSON.stringify(s.field_rules || [])),
|
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 || [])],
|
: [...(s.key_fields || []), ...(s.compare_fields || [])],
|
||||||
row_filters: JSON.parse(JSON.stringify(s.row_filters || [])),
|
row_filters: JSON.parse(JSON.stringify(s.row_filters || [])),
|
||||||
field_rules: JSON.parse(JSON.stringify(s.field_rules || [])),
|
field_rules: JSON.parse(JSON.stringify(s.field_rules || [])),
|
||||||
|
ignore_port_changes: s.ignore_port_changes,
|
||||||
}));
|
}));
|
||||||
return {
|
return {
|
||||||
format: TEMPLATE_EXPORT_FORMAT,
|
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<string, unknown>;
|
|
||||||
// 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<string, unknown>;
|
|
||||||
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<string, unknown>;
|
|
||||||
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 {
|
function ifaceNormRulesToText(rules?: { from: string; to: string }[]): string {
|
||||||
return (rules || []).map((r) => `${r.from},${r.to}`).join("\n");
|
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) {
|
function downloadJsonFile(filename: string, data: unknown) {
|
||||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
|
|
@ -713,51 +595,6 @@ function upsertFieldRule(sheet: MetricSheet, field: string, patch: Partial<Field
|
||||||
return rules;
|
return rules;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isLeafFilter(f: RowFilter): boolean {
|
|
||||||
return !f.any && !f.all && Boolean(f.field || f.op);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Expand sheet row_filters into OR-groups of AND leaf conditions for editing. */
|
|
||||||
function toOrGroups(filters: RowFilter[]): RowFilter[][] {
|
|
||||||
const list = filters || [];
|
|
||||||
if (!list.length) return [];
|
|
||||||
if (list.length === 1 && list[0]?.any && Array.isArray(list[0].any)) {
|
|
||||||
return list[0].any
|
|
||||||
.map((g) => {
|
|
||||||
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 {
|
function emptyLeaf(field = ""): RowFilter {
|
||||||
return { field, op: "eq", value: "" };
|
return { field, op: "eq", value: "" };
|
||||||
}
|
}
|
||||||
|
|
@ -827,6 +664,7 @@ function TplRowFiltersEditor({
|
||||||
<div className="ct-filter-row">
|
<div className="ct-filter-row">
|
||||||
<select
|
<select
|
||||||
className="mt-select"
|
className="mt-select"
|
||||||
|
aria-label={`${t("bizCompare.filterField")} ${gi + 1}.${fi + 1}`}
|
||||||
value={filt.field || ""}
|
value={filt.field || ""}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const next = groups.map((g) => g.map((x) => ({ ...x })));
|
const next = groups.map((g) => g.map((x) => ({ ...x })));
|
||||||
|
|
@ -840,13 +678,19 @@ function TplRowFiltersEditor({
|
||||||
{name}
|
{name}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
|
{filt.field && !fieldOpts.includes(filt.field) ? <option value={filt.field}>{filt.field}</option> : null}
|
||||||
</select>
|
</select>
|
||||||
<select
|
<select
|
||||||
className="mt-select"
|
className="mt-select"
|
||||||
|
aria-label={`${t("bizCompare.filterOperator")} ${gi + 1}.${fi + 1}`}
|
||||||
value={filt.op || "eq"}
|
value={filt.op || "eq"}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const next = groups.map((g) => g.map((x) => ({ ...x })));
|
const next = groups.map((g) => g.map((x) => ({ ...x })));
|
||||||
next[gi][fi] = { ...next[gi][fi], op: e.target.value };
|
const op = e.target.value;
|
||||||
|
const value = next[gi][fi].value;
|
||||||
|
next[gi][fi] = { ...next[gi][fi], op, value: ["in", "not_in", "nin"].includes(op)
|
||||||
|
? (Array.isArray(value) ? value : String(value ?? "").split(",").map((x) => x.trim()).filter(Boolean))
|
||||||
|
: (Array.isArray(value) ? value.join(",") : value) };
|
||||||
setGroups(next);
|
setGroups(next);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -855,18 +699,20 @@ function TplRowFiltersEditor({
|
||||||
{op}
|
{op}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
|
{filt.op && !(FILTER_OPS as readonly string[]).includes(filt.op) ? <option value={filt.op}>{filt.op}</option> : null}
|
||||||
</select>
|
</select>
|
||||||
<Input
|
<Input
|
||||||
value={
|
value={
|
||||||
Array.isArray(filt.value) ? filt.value.join(",") : String(filt.value ?? "")
|
Array.isArray(filt.value) ? filt.value.join(",") : String(filt.value ?? "")
|
||||||
}
|
}
|
||||||
placeholder={t("bizCompare.filterValue")}
|
placeholder={t("bizCompare.filterValue")}
|
||||||
disabled={["empty", "not_empty", "age_timer"].includes(filt.op || "")}
|
aria-label={`${t("bizCompare.filterValue")} ${gi + 1}.${fi + 1}`}
|
||||||
|
disabled={["empty", "not_empty", "nonempty", "age_timer"].includes(filt.op || "")}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const next = groups.map((g) => g.map((x) => ({ ...x })));
|
const next = groups.map((g) => g.map((x) => ({ ...x })));
|
||||||
const op = next[gi][fi]?.op || "eq";
|
const op = next[gi][fi]?.op || "eq";
|
||||||
let value: string | string[] = e.target.value;
|
let value: string | string[] = e.target.value;
|
||||||
if (op === "in" || op === "not_in") {
|
if (["in", "not_in", "nin"].includes(op)) {
|
||||||
value = e.target.value
|
value = e.target.value
|
||||||
.split(",")
|
.split(",")
|
||||||
.map((x) => x.trim())
|
.map((x) => x.trim())
|
||||||
|
|
@ -930,10 +776,13 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
const [mappings, setMappings] = useState<Mapping[]>([]);
|
const [mappings, setMappings] = useState<Mapping[]>([]);
|
||||||
const [jobs, setJobs] = useState<Job[]>([]);
|
const [jobs, setJobs] = useState<Job[]>([]);
|
||||||
const [listKw, setListKw] = useState("");
|
const [listKw, setListKw] = useState("");
|
||||||
|
const [listPage, setListPage] = useState(1);
|
||||||
|
const [listPageSize, setListPageSize] = useState(50);
|
||||||
const debouncedListKw = useDebouncedValue(listKw, 250);
|
const debouncedListKw = useDebouncedValue(listKw, 250);
|
||||||
|
|
||||||
// template editor
|
// template editor
|
||||||
const [tplOpen, setTplOpen] = useState(false);
|
const [tplOpen, setTplOpen] = useState(false);
|
||||||
|
const [tplError, setTplError] = useState("");
|
||||||
const [tplEditId, setTplEditId] = useState("");
|
const [tplEditId, setTplEditId] = useState("");
|
||||||
const [tplName, setTplName] = useState("");
|
const [tplName, setTplName] = useState("");
|
||||||
const [tplNote, setTplNote] = useState("");
|
const [tplNote, setTplNote] = useState("");
|
||||||
|
|
@ -1026,11 +875,11 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
};
|
};
|
||||||
const fetchBundle = async (): Promise<Bundle> => {
|
const fetchBundle = async (): Promise<Bundle> => {
|
||||||
const [taskRes, tpl, maps, j, met] = await Promise.all([
|
const [taskRes, tpl, maps, j, met] = await Promise.all([
|
||||||
bizStateListTasks(),
|
pageMode === "templates" ? Promise.resolve({items: []}) : bizStateListTasks(),
|
||||||
bizCompareListTemplates(),
|
bizCompareListTemplates(),
|
||||||
bizCompareListMappings(),
|
pageMode === "templates" ? Promise.resolve({items: []}) : bizCompareListMappings(),
|
||||||
bizCompareListJobs(),
|
pageMode === "templates" ? Promise.resolve({items: []}) : bizCompareListJobs(),
|
||||||
bizCompareListMetrics(),
|
cutoverCachedGet("bizCompare:metrics", () => bizCompareListMetrics(), {ttlMs: 60000}),
|
||||||
]);
|
]);
|
||||||
return { taskRes, tpl, maps, j, met };
|
return { taskRes, tpl, maps, j, met };
|
||||||
};
|
};
|
||||||
|
|
@ -1043,11 +892,11 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
setMetrics((b.met.items || []) as MetricSchema[]);
|
setMetrics((b.met.items || []) as MetricSchema[]);
|
||||||
};
|
};
|
||||||
if (opts?.force) {
|
if (opts?.force) {
|
||||||
apply(await cutoverCachedGet("bizCompare:lists", fetchBundle, { force: true }));
|
apply(await cutoverCachedGet(`bizCompare:lists:${pageMode}`, fetchBundle, { force: true }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
apply(await cutoverCachedGetSWR("bizCompare:lists", fetchBundle, apply));
|
apply(await cutoverCachedGetSWR(`bizCompare:lists:${pageMode}`, fetchBundle, apply));
|
||||||
}, []);
|
}, [pageMode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
|
|
@ -1140,10 +989,13 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
return `${x.name} ${mids} ${x.note}`.toLowerCase().includes(kw);
|
return `${x.name} ${mids} ${x.note}`.toLowerCase().includes(kw);
|
||||||
});
|
});
|
||||||
}, [templates, debouncedListKw]);
|
}, [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(
|
const selectedJobTemplate = useMemo(
|
||||||
() => templates.find((x) => x.id === templateId) || null,
|
() => templatesById.get(templateId) || null,
|
||||||
[templates, templateId],
|
[templatesById, templateId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const jobTemplateSheets = useMemo(
|
const jobTemplateSheets = useMemo(
|
||||||
|
|
@ -1193,23 +1045,33 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
const setJobTemplateAndSheets = useCallback(
|
const setJobTemplateAndSheets = useCallback(
|
||||||
(nextTplId: string, presetIds?: string[] | null) => {
|
(nextTplId: string, presetIds?: string[] | null) => {
|
||||||
setTemplateId(nextTplId);
|
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) {
|
if (presetIds && presetIds.length) {
|
||||||
const kept = presetIds.filter((id) => all.includes(id));
|
// Keep stale IDs visible as a configuration error; [] would enable every sheet.
|
||||||
setEnabledSheetIds(kept.length === all.length ? [] : kept);
|
setEnabledSheetIds([...presetIds]);
|
||||||
} else {
|
} else {
|
||||||
setEnabledSheetIds([]);
|
setEnabledSheetIds([]);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[templates],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const activeTplSheet = tplSheets[tplSheetIdx] || null;
|
const activeTplSheet = tplSheets[tplSheetIdx] || null;
|
||||||
const activeTplFields = useMemo(() => {
|
const activeTplFields = useMemo(() => {
|
||||||
if (!activeTplSheet) return [];
|
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]);
|
}, [metrics, activeTplSheet]);
|
||||||
|
const activeTplFilterGroups = activeTplSheet ? toOrGroups(activeTplSheet.row_filters || []) : [];
|
||||||
|
const missingJobSheets = enabledSheetIds.filter((id) => !jobSheetAllIds.includes(id));
|
||||||
|
|
||||||
const runSheets: RunSheet[] = useMemo(() => {
|
const runSheets: RunSheet[] = useMemo(() => {
|
||||||
const sheets = (runDetail?.sheets || []) as RunSheet[];
|
const sheets = (runDetail?.sheets || []) as RunSheet[];
|
||||||
|
|
@ -1593,6 +1455,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateActiveSheet = (patch: Partial<MetricSheet>) => {
|
const updateActiveSheet = (patch: Partial<MetricSheet>) => {
|
||||||
|
setTplError("");
|
||||||
setTplSheets((prev) =>
|
setTplSheets((prev) =>
|
||||||
prev.map((s, i) => {
|
prev.map((s, i) => {
|
||||||
if (i !== tplSheetIdx) return s;
|
if (i !== tplSheetIdx) return s;
|
||||||
|
|
@ -1630,6 +1493,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
};
|
};
|
||||||
|
|
||||||
const openNewTemplate = () => {
|
const openNewTemplate = () => {
|
||||||
|
setTplError("");
|
||||||
setTplEditId("");
|
setTplEditId("");
|
||||||
setTplName("");
|
setTplName("");
|
||||||
setTplNote("");
|
setTplNote("");
|
||||||
|
|
@ -1642,6 +1506,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
};
|
};
|
||||||
|
|
||||||
const openEditTemplate = (tpl: Template) => {
|
const openEditTemplate = (tpl: Template) => {
|
||||||
|
setTplError("");
|
||||||
const sheets = templateSheets(tpl);
|
const sheets = templateSheets(tpl);
|
||||||
setTplEditId(tpl.id);
|
setTplEditId(tpl.id);
|
||||||
setTplName(tpl.name);
|
setTplName(tpl.name);
|
||||||
|
|
@ -1713,6 +1578,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveTemplate = async () => {
|
const saveTemplate = async () => {
|
||||||
|
setTplError("");
|
||||||
if (!tplSheets.length) {
|
if (!tplSheets.length) {
|
||||||
showError(t("bizCompare.metricsRequired"));
|
showError(t("bizCompare.metricsRequired"));
|
||||||
return;
|
return;
|
||||||
|
|
@ -1734,6 +1600,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
}
|
}
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
|
validateTemplateDraft(tplSheets);
|
||||||
const body = {
|
const body = {
|
||||||
name: tplName || tplSheets.map((s) => sheetLabel(s)).join("+"),
|
name: tplName || tplSheets.map((s) => sheetLabel(s)).join("+"),
|
||||||
note: tplNote,
|
note: tplNote,
|
||||||
|
|
@ -1748,6 +1615,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
display_fields: s.display_fields || [...s.key_fields, ...s.compare_fields],
|
display_fields: s.display_fields || [...s.key_fields, ...s.compare_fields],
|
||||||
row_filters: s.row_filters || [],
|
row_filters: s.row_filters || [],
|
||||||
field_rules: s.field_rules || [],
|
field_rules: s.field_rules || [],
|
||||||
|
ignore_port_changes: s.ignore_port_changes,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
if (tplEditId) await bizCompareUpdateTemplate(tplEditId, body);
|
if (tplEditId) await bizCompareUpdateTemplate(tplEditId, body);
|
||||||
|
|
@ -1758,6 +1626,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
invalidateCutoverCache("bizMigration:");
|
invalidateCutoverCache("bizMigration:");
|
||||||
await refresh({ force: true });
|
await refresh({ force: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
setTplError(formatErr(e));
|
||||||
showError(formatErr(e));
|
showError(formatErr(e));
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
|
|
@ -1817,6 +1686,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
display_fields: s.display_fields || [...s.key_fields, ...s.compare_fields],
|
display_fields: s.display_fields || [...s.key_fields, ...s.compare_fields],
|
||||||
row_filters: s.row_filters || [],
|
row_filters: s.row_filters || [],
|
||||||
field_rules: s.field_rules || [],
|
field_rules: s.field_rules || [],
|
||||||
|
ignore_port_changes: s.ignore_port_changes,
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
showOk(t("bizCompare.templateImported"));
|
showOk(t("bizCompare.templateImported"));
|
||||||
|
|
@ -1913,12 +1783,9 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
const tplId = preset?.template_id || templates[0]?.id || "";
|
const tplId = preset?.template_id || templates[0]?.id || "";
|
||||||
setName(preset?.name || t("bizCompare.defaultJobName"));
|
setName(preset?.name || t("bizCompare.defaultJobName"));
|
||||||
setTemplateId(tplId);
|
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);
|
const presetIds = (preset?.enabled_sheet_ids || []).map(String).filter(Boolean);
|
||||||
if (presetIds.length) {
|
if (presetIds.length) {
|
||||||
const kept = presetIds.filter((id) => all.includes(id));
|
setEnabledSheetIds(presetIds);
|
||||||
setEnabledSheetIds(kept.length === all.length ? [] : kept);
|
|
||||||
} else {
|
} else {
|
||||||
setEnabledSheetIds([]);
|
setEnabledSheetIds([]);
|
||||||
}
|
}
|
||||||
|
|
@ -2129,6 +1996,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
|
|
||||||
const saveJobConfig = async () => {
|
const saveJobConfig = async () => {
|
||||||
if (!jobId) return;
|
if (!jobId) return;
|
||||||
|
if (missingJobSheets.length) { showError(t("bizCompare.missingSheets")); return; }
|
||||||
if (!enabledJobSheetCount) {
|
if (!enabledJobSheetCount) {
|
||||||
showError(t("bizCompare.needSheets"));
|
showError(t("bizCompare.needSheets"));
|
||||||
return;
|
return;
|
||||||
|
|
@ -2160,6 +2028,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
|
|
||||||
const runNow = async () => {
|
const runNow = async () => {
|
||||||
if (!jobId) return;
|
if (!jobId) return;
|
||||||
|
if (missingJobSheets.length) { showError(t("bizCompare.missingSheets")); return; }
|
||||||
if (!enabledJobSheetCount) {
|
if (!enabledJobSheetCount) {
|
||||||
showError(t("bizCompare.needSheets"));
|
showError(t("bizCompare.needSheets"));
|
||||||
return;
|
return;
|
||||||
|
|
@ -2321,6 +2190,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="muted bm-hint">{t("bizCompare.enabledSheetsHint")}</p>
|
<p className="muted bm-hint">{t("bizCompare.enabledSheetsHint")}</p>
|
||||||
|
{missingJobSheets.length ? <div className="bs-cmp-error" role="alert"><span>{t("bizCompare.missingSheets")} · {missingJobSheets.join(", ")}</span><Button size="sm" variant="secondary" isDisabled={!jobSheetAllIds.length} onPress={() => setEnabledSheetIds([])}>{t("bizCompare.enableCurrentSheets")}</Button></div> : null}
|
||||||
<div className="bs-cmp-scope__items">
|
<div className="bs-cmp-scope__items">
|
||||||
{jobTemplateSheets.map((s) => {
|
{jobTemplateSheets.map((s) => {
|
||||||
const sid = sheetIdentity(s);
|
const sid = sheetIdentity(s);
|
||||||
|
|
@ -2441,7 +2311,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
>
|
>
|
||||||
<option value="">{t("bizCompare.pick")}</option>
|
<option value="">{t("bizCompare.pick")}</option>
|
||||||
{beforeBatches.map((b) => (
|
{beforeBatches.map((b) => (
|
||||||
<option key={b.id} value={b.id}>
|
<option key={b.id} value={b.id} disabled={b.status !== "success"}>
|
||||||
{batchOptLabel(b)}
|
{batchOptLabel(b)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
|
|
@ -2482,7 +2352,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
>
|
>
|
||||||
<option value="">{t("bizCompare.pick")}</option>
|
<option value="">{t("bizCompare.pick")}</option>
|
||||||
{afterBatches.map((b) => (
|
{afterBatches.map((b) => (
|
||||||
<option key={b.id} value={b.id}>
|
<option key={b.id} value={b.id} disabled={b.status !== "success"}>
|
||||||
{batchOptLabel(b)}
|
{batchOptLabel(b)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
|
|
@ -2555,7 +2425,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className={`panel nm-page-panel${activeTab === "jobs" ? " bs-cmp-workspace" : ""}`}>
|
<section className={`panel nm-page-panel bs-cmp-workspace${activeTab === "templates" ? " bs-template-workspace" : ""}`}>
|
||||||
<div className="panel__toolbar">
|
<div className="panel__toolbar">
|
||||||
<h2>
|
<h2>
|
||||||
{pageMode === "templates" ? t("bizCompare.templates") : t("bizCompare.title")}
|
{pageMode === "templates" ? t("bizCompare.templates") : t("bizCompare.title")}
|
||||||
|
|
@ -2581,7 +2451,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
>
|
>
|
||||||
{t("bizCompare.importTemplate")}
|
{t("bizCompare.importTemplate")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="primary" onPress={openNewTemplate}>
|
<Button size="sm" variant="primary" isDisabled={busy} onPress={openNewTemplate}>
|
||||||
{t("bizCompare.newTemplate")}
|
{t("bizCompare.newTemplate")}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
|
|
@ -2622,7 +2492,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
placeholder={
|
placeholder={
|
||||||
activeTab === "jobs" ? t("bizCompare.jobFilterPh") : t("bizCompare.templateFilterPh")
|
activeTab === "jobs" ? t("bizCompare.jobFilterPh") : t("bizCompare.templateFilterPh")
|
||||||
}
|
}
|
||||||
onChange={(e) => setListKw(e.target.value)}
|
onChange={(e) => { setListKw(e.target.value); setListPage(1); }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -2641,12 +2511,13 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{filteredTemplates.map((tpl) => {
|
{filteredTemplates.slice((visibleListPage - 1) * listPageSize, visibleListPage * listPageSize).map((tpl) => {
|
||||||
const sheets = templateSheets(tpl);
|
const sheets = templateSheets(tpl);
|
||||||
return (
|
return (
|
||||||
<tr key={tpl.id}>
|
<tr key={tpl.id}>
|
||||||
<td>
|
<td>
|
||||||
<div className="pt-list-task-name">{tpl.name}</div>
|
<div className="pt-list-task-name">{tpl.name}</div>
|
||||||
|
{tpl.job_count ? <div className="muted">{t("bizCompare.templateUsage", {n: tpl.job_count})}</div> : null}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div className="bs-cmp-metric-chips">
|
<div className="bs-cmp-metric-chips">
|
||||||
|
|
@ -2664,7 +2535,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
<td className="muted">{tpl.note || "—"}</td>
|
<td className="muted">{tpl.note || "—"}</td>
|
||||||
<td>
|
<td>
|
||||||
<div className="pt-list-actions">
|
<div className="pt-list-actions">
|
||||||
<Button size="sm" variant="secondary" onPress={() => openEditTemplate(tpl)}>
|
<Button size="sm" variant="secondary" isDisabled={busy} onPress={() => openEditTemplate(tpl)}>
|
||||||
{t("bizCompare.edit")}
|
{t("bizCompare.edit")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -2678,7 +2549,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="danger"
|
variant="danger"
|
||||||
isDisabled={busy}
|
isDisabled={busy || Boolean(tpl.job_count) || jobs.some((j) => j.template_id === tpl.id)}
|
||||||
onPress={() => void removeTemplate(tpl.id)}
|
onPress={() => void removeTemplate(tpl.id)}
|
||||||
>
|
>
|
||||||
{t("bizCompare.delete")}
|
{t("bizCompare.delete")}
|
||||||
|
|
@ -2711,7 +2582,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{filteredJobs.map((j) => {
|
{filteredJobs.slice((visibleListPage - 1) * listPageSize, visibleListPage * listPageSize).map((j) => {
|
||||||
const tpl = templatesById.get(j.template_id);
|
const tpl = templatesById.get(j.template_id);
|
||||||
const n = templateSheets(tpl).length;
|
const n = templateSheets(tpl).length;
|
||||||
return (
|
return (
|
||||||
|
|
@ -2760,17 +2631,19 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<ListPager page={visibleListPage} pages={listPages} total={listTotal} pageSize={listPageSize} pageSizeOptions={[20, 50, 100, 200]} onPageChange={setListPage} onPageSizeChange={(n) => { setListPageSize(n); setListPage(1); }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Template editor */}
|
{/* Template editor */}
|
||||||
<AppModalShell open={tplOpen} onClose={() => setTplOpen(false)} size="cover">
|
<AppModalShell open={tplOpen} onClose={() => { if (!busy) setTplOpen(false); }} dismissible={!busy} size="cover" className="app-heroui-modal--xl bs-template-editor">
|
||||||
<Modal.Header>
|
<Modal.Header>
|
||||||
<Modal.Heading>
|
<Modal.Heading>
|
||||||
{tplEditId ? t("bizCompare.editTemplate") : t("bizCompare.newTemplate")}
|
{tplEditId ? t("bizCompare.editTemplate") : t("bizCompare.newTemplate")}
|
||||||
</Modal.Heading>
|
</Modal.Heading>
|
||||||
<Modal.CloseTrigger />
|
<Modal.CloseTrigger isDisabled={busy} />
|
||||||
</Modal.Header>
|
</Modal.Header>
|
||||||
<Modal.Body className="flex flex-col gap-3 ct-editor">
|
<Modal.Body className="flex flex-col gap-3 ct-editor" inert={busy}>
|
||||||
|
{tplError ? <div className="bs-cmp-error" role="alert">{t("bizCompare.templateValidationFailed")} · {tplError}</div> : null}
|
||||||
<div className="ct-editor__basics">
|
<div className="ct-editor__basics">
|
||||||
<Input
|
<Input
|
||||||
value={tplName}
|
value={tplName}
|
||||||
|
|
@ -2786,18 +2659,18 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="ct-editor__norm">
|
<details className="ct-editor__norm">
|
||||||
<div className="ct-editor__pick-title">{t("bizCompare.ifaceNormalize")}</div>
|
<summary className="ct-editor__pick-title">{t("bizCompare.ifaceNormalize")}</summary>
|
||||||
<p className="muted ct-editor__pick-hint">{t("bizCompare.ifaceNormalizeHint")}</p>
|
<p className="muted ct-editor__pick-hint">{t("bizCompare.ifaceNormalizeHint")}</p>
|
||||||
<textarea
|
<textarea
|
||||||
className="ct-editor__norm-area"
|
className="ct-editor__norm-area"
|
||||||
rows={5}
|
rows={3}
|
||||||
value={tplNormText}
|
value={tplNormText}
|
||||||
onChange={(e) => setTplNormText(e.target.value)}
|
onChange={(e) => setTplNormText(e.target.value)}
|
||||||
placeholder={"GE,gei\nSG,smartgroup"}
|
placeholder={"GE,gei\nSG,smartgroup"}
|
||||||
aria-label={t("bizCompare.ifaceNormalize")}
|
aria-label={t("bizCompare.ifaceNormalize")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</details>
|
||||||
|
|
||||||
<div className="ct-editor__pick">
|
<div className="ct-editor__pick">
|
||||||
<div className="ct-editor__pick-head">
|
<div className="ct-editor__pick-head">
|
||||||
|
|
@ -2846,15 +2719,22 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
<div className="ct-editor__nav-head">
|
<div className="ct-editor__nav-head">
|
||||||
<span>{t("bizCompare.colSheets")}</span>
|
<span>{t("bizCompare.colSheets")}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="ct-editor__nav-list" role="tablist">
|
<div className="ct-editor__nav-list" role="tablist" aria-label={t("bizCompare.colSheets")} onKeyDown={(event) => {
|
||||||
|
if (!["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const next = event.key === "Home" ? 0 : event.key === "End" ? tplSheets.length - 1 : (tplSheetIdx + (["ArrowUp", "ArrowLeft"].includes(event.key) ? -1 : 1) + tplSheets.length) % tplSheets.length;
|
||||||
|
setTplSheetIdx(next);
|
||||||
|
event.currentTarget.querySelectorAll<HTMLButtonElement>("[role=tab]")[next]?.focus();
|
||||||
|
}}>
|
||||||
{tplSheets.map((s, i) => {
|
{tplSheets.map((s, i) => {
|
||||||
const filterN = toOrGroups(s.row_filters || []).length;
|
const filterN = toOrGroups(s.row_filters || [])?.length ?? (s.row_filters || []).length;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={`${sheetIdentity(s)}-${i}`}
|
key={`${sheetIdentity(s)}-${i}`}
|
||||||
type="button"
|
type="button"
|
||||||
role="tab"
|
role="tab"
|
||||||
aria-selected={tplSheetIdx === i}
|
aria-selected={tplSheetIdx === i}
|
||||||
|
tabIndex={tplSheetIdx === i ? 0 : -1}
|
||||||
className={`ct-editor__nav-item${tplSheetIdx === i ? " is-active" : ""}`}
|
className={`ct-editor__nav-item${tplSheetIdx === i ? " is-active" : ""}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setTplSheetIdx(i);
|
setTplSheetIdx(i);
|
||||||
|
|
@ -2914,11 +2794,18 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-rule-tabs" role="tablist">
|
<div className="mt-rule-tabs" role="tablist" aria-label={t("bizCompare.editTemplate")} onKeyDown={(event) => {
|
||||||
|
if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const next = event.key === "Home" ? "fields" : event.key === "End" ? "filters" : tplPaneTab === "fields" ? "filters" : "fields";
|
||||||
|
setTplPaneTab(next);
|
||||||
|
event.currentTarget.querySelectorAll<HTMLButtonElement>("[role=tab]")[next === "fields" ? 0 : 1]?.focus();
|
||||||
|
}}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="tab"
|
role="tab"
|
||||||
aria-selected={tplPaneTab === "fields"}
|
aria-selected={tplPaneTab === "fields"}
|
||||||
|
tabIndex={tplPaneTab === "fields" ? 0 : -1}
|
||||||
className={`mt-rule-tab${tplPaneTab === "fields" ? " is-active" : ""}`}
|
className={`mt-rule-tab${tplPaneTab === "fields" ? " is-active" : ""}`}
|
||||||
onClick={() => setTplPaneTab("fields")}
|
onClick={() => setTplPaneTab("fields")}
|
||||||
>
|
>
|
||||||
|
|
@ -2929,24 +2816,28 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
type="button"
|
type="button"
|
||||||
role="tab"
|
role="tab"
|
||||||
aria-selected={tplPaneTab === "filters"}
|
aria-selected={tplPaneTab === "filters"}
|
||||||
|
tabIndex={tplPaneTab === "filters" ? 0 : -1}
|
||||||
className={`mt-rule-tab${tplPaneTab === "filters" ? " is-active" : ""}`}
|
className={`mt-rule-tab${tplPaneTab === "filters" ? " is-active" : ""}`}
|
||||||
onClick={() => setTplPaneTab("filters")}
|
onClick={() => setTplPaneTab("filters")}
|
||||||
>
|
>
|
||||||
{t("bizCompare.tabFilters")}
|
{t("bizCompare.tabFilters")}
|
||||||
<span className="mt-rule-tab__n">
|
<span className="mt-rule-tab__n">
|
||||||
{toOrGroups(activeTplSheet.row_filters || []).length}
|
{activeTplFilterGroups?.length ?? (activeTplSheet.row_filters || []).length}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{tplPaneTab === "filters" ? (
|
<FieldSelect label={t("bizCompare.portPolicy")} value={activeTplSheet.ignore_port_changes == null ? "auto" : activeTplSheet.ignore_port_changes ? "ignore" : "strict"} onChange={(e) => updateActiveSheet({ignore_port_changes: e.target.value === "auto" ? null : e.target.value === "ignore"})} hint={t("bizCompare.portPolicyHint")}>
|
||||||
|
<option value="auto">{t("bizCompare.portPolicyAuto")}</option><option value="strict">{t("bizCompare.portPolicyStrict")}</option><option value="ignore">{t("bizCompare.portPolicyIgnore")}</option>
|
||||||
|
</FieldSelect>
|
||||||
|
{tplPaneTab === "filters" ? (activeTplFilterGroups === null ? <div className="ct-complex-filters"><p className="muted">{t("bizCompare.complexFiltersPreserved")}</p><pre aria-label={t("bizCompare.tabFilters")}>{JSON.stringify(activeTplSheet.row_filters, null, 2)}</pre></div> : (
|
||||||
<TplRowFiltersEditor
|
<TplRowFiltersEditor
|
||||||
groups={toOrGroups(activeTplSheet.row_filters || [])}
|
groups={activeTplFilterGroups}
|
||||||
fieldOpts={activeTplFields.map((f) => f.name)}
|
fieldOpts={activeTplFields.map((f) => f.name)}
|
||||||
t={t}
|
t={t}
|
||||||
onChange={(next) => updateActiveSheet({ row_filters: fromOrGroups(next) })}
|
onChange={(next) => updateActiveSheet({ row_filters: fromOrGroups(next) })}
|
||||||
/>
|
/>
|
||||||
) : (
|
)) : (
|
||||||
<>
|
<>
|
||||||
<p className="muted ct-field-hint">{t("bizCompare.fieldsHintShort")}</p>
|
<p className="muted ct-field-hint">{t("bizCompare.fieldsHintShort")}</p>
|
||||||
<div className="pt-list-table-wrap ct-field-table-wrap">
|
<div className="pt-list-table-wrap ct-field-table-wrap">
|
||||||
|
|
@ -2970,8 +2861,10 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
!isKey && activeTplSheet.compare_fields.includes(f.name);
|
!isKey && activeTplSheet.compare_fields.includes(f.name);
|
||||||
const isDisplay = (activeTplSheet.display_fields || []).includes(f.name);
|
const isDisplay = (activeTplSheet.display_fields || []).includes(f.name);
|
||||||
const rule = ruleForField(activeTplSheet, f.name);
|
const rule = ruleForField(activeTplSheet, f.name);
|
||||||
const cmpMode = (rule.compare || "eq").toLowerCase() || "eq";
|
const rawMode = (rule.compare || "eq").trim().toLowerCase();
|
||||||
const norm = rule.normalize || "none";
|
const cmpMode = ["number", "int", "float"].includes(rawMode) ? "numeric" : ["pct", "rel"].includes(rawMode) ? "percent" : ["ignore", "skip", "off"].includes(rawMode) ? "eq" : rawMode;
|
||||||
|
const rawNorm = (rule.normalize || "none").trim().toLowerCase();
|
||||||
|
const norm = rawNorm === "strip" ? "none" : rawNorm;
|
||||||
const needsTol = cmpMode === "numeric" || cmpMode === "percent";
|
const needsTol = cmpMode === "numeric" || cmpMode === "percent";
|
||||||
return (
|
return (
|
||||||
<tr key={f.name}>
|
<tr key={f.name}>
|
||||||
|
|
@ -3021,6 +2914,8 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
f.name,
|
f.name,
|
||||||
e.target.checked,
|
e.target.checked,
|
||||||
),
|
),
|
||||||
|
field_rules: e.target.checked ? upsertFieldRule(activeTplSheet, f.name, {ignore: false,
|
||||||
|
compare: ["ignore", "skip", "off"].includes(rawMode) ? "eq" : rule.compare}) : activeTplSheet.field_rules,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
@ -3029,6 +2924,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
<select
|
<select
|
||||||
className="mt-select"
|
className="mt-select"
|
||||||
value={cmpMode === "ignore" ? "eq" : cmpMode}
|
value={cmpMode === "ignore" ? "eq" : cmpMode}
|
||||||
|
aria-label={`${f.name} ${t("bizCompare.compareMode")}`}
|
||||||
disabled={!isCompare}
|
disabled={!isCompare}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateActiveSheet({
|
updateActiveSheet({
|
||||||
|
|
@ -3048,6 +2944,9 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
<td>
|
<td>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step="any"
|
||||||
|
aria-label={`${f.name} ${t("bizCompare.tolerance")}`}
|
||||||
value={
|
value={
|
||||||
needsTol &&
|
needsTol &&
|
||||||
rule.tolerance !== undefined &&
|
rule.tolerance !== undefined &&
|
||||||
|
|
@ -3092,6 +2991,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
<select
|
<select
|
||||||
className="mt-select"
|
className="mt-select"
|
||||||
value={norm}
|
value={norm}
|
||||||
|
aria-label={`${f.name} ${t("bizCompare.normalizeField")}`}
|
||||||
disabled={!isCompare}
|
disabled={!isCompare}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateActiveSheet({
|
updateActiveSheet({
|
||||||
|
|
@ -3131,7 +3031,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
)}
|
)}
|
||||||
</Modal.Body>
|
</Modal.Body>
|
||||||
<Modal.Footer>
|
<Modal.Footer>
|
||||||
<Button size="sm" variant="secondary" onPress={() => setTplOpen(false)}>
|
<Button size="sm" variant="secondary" isDisabled={busy} onPress={() => setTplOpen(false)}>
|
||||||
{t("bizState.cancel")}
|
{t("bizState.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="primary" isDisabled={busy} onPress={() => void saveTemplate()}>
|
<Button size="sm" variant="primary" isDisabled={busy} onPress={() => void saveTemplate()}>
|
||||||
|
|
@ -4346,19 +4246,19 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
<Modal.Footer className="bs-cmp-job-footer">
|
<Modal.Footer className="bs-cmp-job-footer">
|
||||||
{jobDetailTab === "config" ? (
|
{jobDetailTab === "config" ? (
|
||||||
<>
|
<>
|
||||||
<Button size="sm" variant="primary" isDisabled={busy} onPress={() => void saveJobConfig()}>
|
<Button size="sm" variant="primary" isDisabled={busy || Boolean(missingJobSheets.length)} onPress={() => void saveJobConfig()}>
|
||||||
{t("bizCompare.saveJob")}
|
{t("bizCompare.saveJob")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
isDisabled={busy || jobHasActiveRun}
|
isDisabled={busy || jobHasActiveRun || Boolean(missingJobSheets.length)}
|
||||||
onPress={() => void runNow()}
|
onPress={() => void runNow()}
|
||||||
>
|
>
|
||||||
{jobHasActiveRun ? t("bizCompare.runStatusRunning") : t("bizCompare.runNow")}
|
{jobHasActiveRun ? t("bizCompare.runStatusRunning") : t("bizCompare.runNow")}
|
||||||
</Button>
|
</Button>
|
||||||
{jobId ? (
|
{jobId ? (
|
||||||
<Button size="sm" variant="ghost" className="bs-cmp-runs__delete" isDisabled={busy} onPress={() => void removeJob(jobId)}>
|
<Button size="sm" variant="ghost" className="bs-cmp-runs__delete" isDisabled={busy || jobHasActiveRun} onPress={() => void removeJob(jobId)}>
|
||||||
{t("bizCompare.deleteJob")}
|
{t("bizCompare.deleteJob")}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
|
||||||
233
web/src/pages/network/compareTemplateModel.ts
Normal file
|
|
@ -0,0 +1,233 @@
|
||||||
|
// Pure template editing/import helpers; preserve all comparison settings.
|
||||||
|
export 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[];
|
||||||
|
ignore_port_changes?: boolean | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FieldRule = {
|
||||||
|
field: string;
|
||||||
|
compare?: string;
|
||||||
|
normalize?: string;
|
||||||
|
ignore?: boolean;
|
||||||
|
tolerance?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RowFilter = {
|
||||||
|
field?: string;
|
||||||
|
op?: string;
|
||||||
|
value?: string | number | boolean | (string | number | boolean)[];
|
||||||
|
any?: RowFilter[];
|
||||||
|
all?: RowFilter[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function parseTemplateImport(raw: unknown): {
|
||||||
|
name: string;
|
||||||
|
note: string;
|
||||||
|
metrics: MetricSheet[];
|
||||||
|
iface_normalize_rules?: { from: string; to: string }[];
|
||||||
|
} | null {
|
||||||
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
||||||
|
const o = raw as Record<string, unknown>;
|
||||||
|
if ((o.format !== undefined && o.format !== "netx.biz_compare_template") || (o.version !== undefined && o.version !== 1)) {
|
||||||
|
throw new Error("template: unsupported_format_version");
|
||||||
|
}
|
||||||
|
// 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 || [],
|
||||||
|
ignore_fields: o.ignore_fields || [],
|
||||||
|
ignore_port_changes: o.ignore_port_changes,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (!name || !Array.isArray(metricsRaw) || !metricsRaw.length) return null;
|
||||||
|
const metrics: MetricSheet[] = [];
|
||||||
|
for (const item of metricsRaw) {
|
||||||
|
if (!item || typeof item !== "object" || Array.isArray(item)) throw new Error(`metrics[${metrics.length}]: sheet_required`);
|
||||||
|
const m = item as Record<string, unknown>;
|
||||||
|
const mid = String(m.metric_id || "").trim();
|
||||||
|
const path = `metrics[${metrics.length}]`;
|
||||||
|
const keys = stringList(m.key_fields, `${path}.key_fields`);
|
||||||
|
if (!mid || !keys.length) throw new Error(`${path}: metric_id_and_keys_required`);
|
||||||
|
const rules = m.field_rules === undefined ? [] : m.field_rules;
|
||||||
|
if (!Array.isArray(rules)) throw new Error(`${path}.field_rules: list_required`);
|
||||||
|
const ignore = stringList(m.ignore_fields ?? [], `${path}.ignore_fields`);
|
||||||
|
const fieldRules = structuredClone(rules) as FieldRule[];
|
||||||
|
for (const field of ignore) {
|
||||||
|
const rule = fieldRules.find((r) => r?.field === field);
|
||||||
|
if (rule) Object.assign(rule, { compare: "ignore", ignore: true });
|
||||||
|
else fieldRules.push({ field, compare: "ignore", ignore: true });
|
||||||
|
}
|
||||||
|
if (m.row_filters !== undefined && !Array.isArray(m.row_filters)) throw new Error(`${path}.row_filters: list_required`);
|
||||||
|
if (m.ignore_port_changes != null && typeof m.ignore_port_changes !== "boolean") throw new Error(`${path}: boolean_port_policy_required`);
|
||||||
|
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: stringList(m.iface_fields ?? [], `${path}.iface_fields`),
|
||||||
|
compare_fields: stringList(m.compare_fields ?? [], `${path}.compare_fields`).filter((f) => !ignore.includes(f)),
|
||||||
|
display_fields: m.display_fields === undefined ? undefined : stringList(m.display_fields, `${path}.display_fields`),
|
||||||
|
row_filters: (m.row_filters ?? []) as RowFilter[],
|
||||||
|
field_rules: fieldRules,
|
||||||
|
ignore_port_changes: m.ignore_port_changes as boolean | null | undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
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") throw new Error("iface_normalize_rules: alias_pair_required");
|
||||||
|
const r = item as Record<string, unknown>;
|
||||||
|
const fr = String(r.from || "").trim();
|
||||||
|
const to = String(r.to || "").trim();
|
||||||
|
if (!fr || !to) throw new Error("iface_normalize_rules: alias_pair_required");
|
||||||
|
if (iface_normalize_rules.some((x) => x.from.toLowerCase() === fr.toLowerCase())) throw new Error("iface_normalize_rules: duplicate_alias");
|
||||||
|
iface_normalize_rules.push({ from: fr, to });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (normRaw != null && !Array.isArray(normRaw)) throw new Error("iface_normalize_rules: list_required");
|
||||||
|
validateTemplateDraft(metrics);
|
||||||
|
return { name, note, metrics, iface_normalize_rules };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseIfaceNormText(text: string): { from: string; to: string }[] {
|
||||||
|
const out: { from: string; to: string }[] = [];
|
||||||
|
for (const [index, line] of text.split(/\r?\n/).entries()) {
|
||||||
|
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) throw new Error(`iface_normalize_rules:${index + 1}: alias_pair_required`);
|
||||||
|
if (out.some((x) => x.from.toLowerCase() === fr.toLowerCase())) throw new Error(`iface_normalize_rules:${index + 1}: duplicate_alias`);
|
||||||
|
out.push({ from: fr, to });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLeafFilter(f: RowFilter): boolean {
|
||||||
|
return Boolean(f && !f.any && !f.all && f.field);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringList(raw: unknown, path: string): string[] {
|
||||||
|
if (!Array.isArray(raw) || raw.some((x) => typeof x !== "string" || !x.trim())) throw new Error(`${path}: nonempty_strings_required`);
|
||||||
|
const values = raw.map((x: string) => x.trim());
|
||||||
|
if (new Set(values).size !== values.length) throw new Error(`${path}: duplicate_field`);
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateTemplateDraft(metrics: MetricSheet[]): void {
|
||||||
|
if (!metrics.length) throw new Error("metrics: metrics_required");
|
||||||
|
const ids = new Set<string>();
|
||||||
|
const ops = new Set(["eq", "==", "ci_eq", "ne", "!=", "in", "not_in", "nin", "contains", "empty", "not_empty", "nonempty", "regex", "age_timer"]);
|
||||||
|
function filters(nodes: RowFilter[], path: string, depth = 0) {
|
||||||
|
if (!Array.isArray(nodes) || depth > 12) throw new Error(`${path}: invalid_filters`);
|
||||||
|
nodes.forEach((f, i) => {
|
||||||
|
const here = `${path}[${i}]`;
|
||||||
|
if (!f || typeof f !== "object") throw new Error(`${here}: filter_required`);
|
||||||
|
if (f.any !== undefined || f.all !== undefined) {
|
||||||
|
if ((f.any !== undefined && f.all !== undefined) || f.field || f.op || f.value != null) throw new Error(`${here}: group_or_leaf_required`);
|
||||||
|
const kids = f.any ?? f.all;
|
||||||
|
if (!Array.isArray(kids) || !kids.length) throw new Error(`${here}: nonempty_group_required`);
|
||||||
|
filters(kids, here, depth + 1);
|
||||||
|
} else {
|
||||||
|
const op = (f.op || "eq").trim().toLowerCase();
|
||||||
|
if (!f.field?.trim() || !ops.has(op)) throw new Error(`${here}: invalid_filter_field_or_operator`);
|
||||||
|
if (["in", "not_in", "nin"].includes(op) && (!Array.isArray(f.value) || !f.value.length)) throw new Error(`${here}: nonempty_value_list_required`);
|
||||||
|
if (!["in", "not_in", "nin", "empty", "not_empty", "nonempty", "age_timer"].includes(op) && !["string", "number", "boolean"].includes(typeof f.value)) throw new Error(`${here}: scalar_value_required`);
|
||||||
|
if (["contains", "regex"].includes(op) && !String(f.value ?? "").trim()) throw new Error(`${here}: value_required`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
metrics.forEach((s, i) => {
|
||||||
|
const path = `metrics[${i}]`;
|
||||||
|
const id = (s.sheet_id || s.metric_id).trim();
|
||||||
|
if (!s.metric_id?.trim() || !id || ids.has(id)) throw new Error(`${path}: invalid_or_duplicate_sheet_id`);
|
||||||
|
ids.add(id);
|
||||||
|
if (!stringList(s.key_fields, `${path}.key_fields`).length) throw new Error(`${path}: key_fields_required`);
|
||||||
|
for (const key of ["iface_fields", "compare_fields", "display_fields"] as const) if (s[key] !== undefined) stringList(s[key], `${path}.${key}`);
|
||||||
|
const fields = new Set<string>();
|
||||||
|
for (const r of s.field_rules || []) {
|
||||||
|
if (!r?.field?.trim() || fields.has(r.field.trim())) throw new Error(`${path}.field_rules: invalid_or_duplicate_field`);
|
||||||
|
fields.add(r.field.trim());
|
||||||
|
if (!["", "eq", "ignore", "skip", "off", "numeric", "number", "int", "float", "percent", "pct", "rel"].includes((r.compare || "").toLowerCase())) throw new Error(`${path}.field_rules: unknown_compare_mode`);
|
||||||
|
if (!["", "none", "strip", "lower", "upper", "mac", "empty_as_blank"].includes((r.normalize || "").toLowerCase())) throw new Error(`${path}.field_rules: unknown_normalize_mode`);
|
||||||
|
if (r.tolerance != null && (typeof r.tolerance !== "number" || !Number.isFinite(r.tolerance) || r.tolerance < 0)) throw new Error(`${path}.${r.field}: finite_nonnegative_tolerance_required`);
|
||||||
|
}
|
||||||
|
filters(s.row_filters || [], `${path}.row_filters`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Expand sheet row_filters into OR-groups of AND leaf conditions for editing. */
|
||||||
|
export function toOrGroups(filters: RowFilter[]): RowFilter[][] | null {
|
||||||
|
const list = filters || [];
|
||||||
|
if (!list.length) return [];
|
||||||
|
if (list.length === 1 && list[0]?.any && Array.isArray(list[0].any)) {
|
||||||
|
const groups: RowFilter[][] = [];
|
||||||
|
for (const g of list[0].any) {
|
||||||
|
if (g?.all?.length && !g.any && !g.field && g.all.every(isLeafFilter)) groups.push(g.all);
|
||||||
|
else if (isLeafFilter(g)) groups.push([g]);
|
||||||
|
else return null;
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
if (list.every(isLeafFilter)) return [list.map((f) => ({ ...f }))];
|
||||||
|
// Opaque structures stay intact. Never show an editable, incomplete subset.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serialize OR-groups back to engine row_filters (AND of leaves, or single any-of). */
|
||||||
|
export 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 })),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
155
web/tests/bizCompareTemplates.browser.mjs
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
// Local Vite + fixture APIs only. Never connects to devices or a production DB.
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { createRequire } from "node:module";
|
||||||
|
import { mkdir, readFile } 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 base = process.env.NETX_TEST_URL || "http://127.0.0.1:5179";
|
||||||
|
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 = [], passed = [], requests = [], writes = [];
|
||||||
|
let templateWriteDelay = 0;
|
||||||
|
page.on("pageerror", (e) => errors.push(e.message));
|
||||||
|
const complex = [{ any: [{ all: [{ any: [{ field: "id", op: "eq", value: 0 }, { field: "id", op: "eq", value: false }] }, { field: "state", op: "not_empty" }] }] }];
|
||||||
|
const sheet = { sheet_id: "s", title: "业务条目", metric_id: "custom", key_fields: ["id"], iface_fields: ["port"], compare_fields: ["count"], display_fields: ["id", "custom_context"], field_rules: [{ field: "count", compare: "numeric", tolerance: 2 }], row_filters: complex, ignore_port_changes: false };
|
||||||
|
const simple = { ...sheet, sheet_id: "simple", title: "简单过滤", row_filters: [{ field: "external_state", op: "eq", value: "up,down" }], field_rules: [{ field: "count", compare: "ignore", ignore: true }], compare_fields: [] };
|
||||||
|
let templates = [{ id: "tpl", name: "验收模板", job_count: 1, metrics: [sheet, simple], iface_normalize_rules: [{ from: "GE", to: "gei" }] }, ...Array.from({ length: 119 }, (_, i) => ({ id: `t-${i}`, name: `备用模板 ${i}`, job_count: 0, metrics: [sheet] }))];
|
||||||
|
const job = { id: "job", name: "检查范围失效任务", template_id: "tpl", enabled_sheet_ids: ["removed-sheet"], mode: "manual", status: "ready", before_task_id: "before", after_task_id: "after", before_batch_id: "b", after_batch_id: "a" };
|
||||||
|
await page.route("**/v1/**", async (route) => {
|
||||||
|
const req = route.request(), p = new URL(req.url()).pathname;
|
||||||
|
requests.push(p);
|
||||||
|
let data = { items: [] };
|
||||||
|
if (p === "/v1/auth/me") data = { user: { id: "fixture", username: "fixture", role: "admin", is_active: true } };
|
||||||
|
else if (req.method() === "POST" && p.endsWith("/compare/templates")) {
|
||||||
|
writes.push(req.postDataJSON());
|
||||||
|
const created = { ...req.postDataJSON(), id: `import-${writes.length}`, job_count: 0 };
|
||||||
|
templates = [created, ...templates]; data = created;
|
||||||
|
} else if (req.method() === "PATCH" && p.includes("/compare/templates/")) {
|
||||||
|
writes.push(req.postDataJSON()); data = { ...req.postDataJSON(), id: "tpl" };
|
||||||
|
if (templateWriteDelay) await new Promise((resolve) => setTimeout(resolve, templateWriteDelay));
|
||||||
|
templates = templates.map((t) => t.id === "tpl" ? { ...t, ...data } : t);
|
||||||
|
} else if (req.method() === "PATCH" && p.endsWith("/compare/jobs/job")) { writes.push(req.postDataJSON()); data = { ...job, ...req.postDataJSON() }; }
|
||||||
|
else if (p.endsWith("/compare/templates")) data = { items: templates };
|
||||||
|
else if (p.endsWith("/compare/metrics")) data = { items: [{ metric_id: "custom", fields: ["id", "port", "count", "state"].map((name) => ({ name, display_name: name, dtype: "string", is_key: name === "id" })) }] };
|
||||||
|
else if (p.endsWith("/compare/jobs")) data = { items: [job] };
|
||||||
|
else if (p.endsWith("/tasks")) data = { items: [{ id: "before", ne_name: "PE-OLD" }, { id: "after", ne_name: "PE-NEW" }] };
|
||||||
|
else if (p.endsWith("/batches")) data = { items: [{ id: p.includes("before") ? "b" : "a", status: "success", started_at: "2026-10-11T00:00:00Z" }, { id: "partial", status: "partial" }, { id: "running", status: "running" }] };
|
||||||
|
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(data) });
|
||||||
|
});
|
||||||
|
const templateRow = () => page.locator(".bs-template-workspace table tbody tr").filter({ has: page.getByText("验收模板", { exact: true }) });
|
||||||
|
const editor = () => page.getByRole("dialog", { name: "编辑模板", exact: true });
|
||||||
|
const openEditor = async () => { await templateRow().getByRole("button", { name: "编辑", exact: true }).click(); await editor().waitFor(); await page.waitForTimeout(350); };
|
||||||
|
try {
|
||||||
|
await page.goto(`${base}/network/cutover/compare-templates`);
|
||||||
|
await templateRow().waitFor();
|
||||||
|
assert.equal(await page.locator(".bs-template-workspace table tbody tr").count(), 50);
|
||||||
|
assert.ok(await templateRow().getByRole("button", { name: "删除", exact: true }).isDisabled());
|
||||||
|
await templateRow().getByText("已被 1 个任务引用", { exact: true }).waitFor();
|
||||||
|
assert.deepEqual(requests.filter((p) => p.includes("biz-state") && /\/(tasks|mappings|jobs)$/.test(p)), []);
|
||||||
|
passed.push("50 row render bound", "referenced template protected", "template page skips jobs/tasks/mappings");
|
||||||
|
await page.getByRole("button", { name: "下一页", exact: true }).click();
|
||||||
|
assert.equal(await page.locator(".bs-template-workspace table tbody tr").count(), 50);
|
||||||
|
await page.getByRole("button", { name: "下一页", exact: true }).click();
|
||||||
|
assert.equal(await page.locator(".bs-template-workspace table tbody tr").count(), 20);
|
||||||
|
await page.getByRole("textbox", { name: "筛选模板名 / metric", exact: true }).fill("验收模板");
|
||||||
|
await page.waitForFunction(() => document.querySelectorAll(".bs-template-workspace table tbody tr").length === 1);
|
||||||
|
passed.push("pagination", "search resets page");
|
||||||
|
const download = page.waitForEvent("download");
|
||||||
|
await templateRow().getByRole("button", { name: "导出", exact: true }).click();
|
||||||
|
const exported = JSON.parse(await readFile(await (await download).path(), "utf8"));
|
||||||
|
assert.equal(exported.metrics[0].ignore_port_changes, false);
|
||||||
|
assert.deepEqual(exported.metrics[0].row_filters, complex);
|
||||||
|
passed.push("export keeps false port policy", "export keeps complex filters");
|
||||||
|
await openEditor();
|
||||||
|
assert.equal(await editor().getByLabel("端口变化策略", { exact: true }).inputValue(), "strict");
|
||||||
|
await editor().getByText("custom_context", { exact: true }).waitFor();
|
||||||
|
assert.equal(await editor().locator(".ct-editor__norm").getAttribute("open"), null);
|
||||||
|
await page.screenshot({ path: path.join(output, "compare-template-editor.png"), fullPage: true });
|
||||||
|
await editor().getByRole("tab", { name: /^行过滤/ }).click();
|
||||||
|
assert.deepEqual(JSON.parse(await editor().locator(".ct-complex-filters pre").textContent()), complex);
|
||||||
|
assert.equal(await editor().getByRole("button", { name: "+ 或组", exact: true }).count(), 0);
|
||||||
|
templateWriteDelay = 1000;
|
||||||
|
const saving = page.waitForRequest((req) => req.method() === "PATCH" && req.url().endsWith("/compare/templates/tpl"));
|
||||||
|
await editor().getByRole("button", { name: "保存模板", exact: true }).click();
|
||||||
|
await saving;
|
||||||
|
assert.ok(await editor().locator("[inert]").count());
|
||||||
|
assert.ok(await editor().getByRole("button", { name: "Close", exact: true }).isDisabled());
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
assert.ok(await editor().isVisible());
|
||||||
|
await editor().waitFor({ state: "hidden" });
|
||||||
|
templateWriteDelay = 0;
|
||||||
|
assert.deepEqual(writes.at(-1).metrics[0].row_filters, complex);
|
||||||
|
assert.equal(writes.at(-1).metrics[0].ignore_port_changes, false);
|
||||||
|
passed.push("strict policy loaded", "custom fields editable", "aliases collapsed", "opaque filters cannot be partially edited", "saving locks editor and dismissal", "save preserves complex filters and port policy");
|
||||||
|
await openEditor();
|
||||||
|
await editor().getByLabel("count 容差", { exact: true }).fill("-1");
|
||||||
|
const writeCount = writes.length;
|
||||||
|
await editor().getByRole("button", { name: "保存模板", exact: true }).click();
|
||||||
|
await editor().getByRole("alert").waitFor();
|
||||||
|
assert.equal(writes.length, writeCount);
|
||||||
|
await editor().getByLabel("count 容差", { exact: true }).fill("3");
|
||||||
|
assert.equal(await editor().getByRole("alert").count(), 0);
|
||||||
|
await editor().getByRole("tab", { name: /^字段规则/ }).focus();
|
||||||
|
await page.keyboard.press("End");
|
||||||
|
assert.equal(await editor().getByRole("tab", { name: /^行过滤/ }).getAttribute("aria-selected"), "true");
|
||||||
|
// Use the sheet nav directly: locale wording is not part of this assertion.
|
||||||
|
await editor().locator(".ct-editor__nav [role=tab]").first().focus();
|
||||||
|
await page.keyboard.press("End");
|
||||||
|
assert.equal(await editor().locator(".ct-editor__nav [role=tab]").last().getAttribute("aria-selected"), "true");
|
||||||
|
await editor().getByRole("tab", { name: /^字段规则/ }).click();
|
||||||
|
const countRow = editor().locator(".ct-field-table tbody tr").filter({ has: page.getByText("count", { exact: true }) });
|
||||||
|
await countRow.locator('input[type="checkbox"]').nth(2).check();
|
||||||
|
assert.equal(await editor().getByLabel("count 方式", { exact: true }).inputValue(), "eq");
|
||||||
|
await editor().getByRole("tab", { name: /^行过滤/ }).click();
|
||||||
|
assert.equal(await editor().getByLabel("字段 1.1", { exact: true }).inputValue(), "external_state");
|
||||||
|
await editor().getByLabel("过滤运算符 1.1", { exact: true }).selectOption("in");
|
||||||
|
await page.setViewportSize({ width: 390, height: 1000 });
|
||||||
|
await page.getByText(/^metrics\[0\].count:/).waitFor({ state: "hidden" });
|
||||||
|
assert.ok(await editor().locator(".ct-editor__main").evaluate((el) => el.scrollWidth <= el.clientWidth + 1));
|
||||||
|
assert.ok(await editor().locator(".ct-filter-row").evaluate((el) => el.scrollWidth <= el.clientWidth + 1));
|
||||||
|
const titleBounds = await editor().getByLabel("表标题", { exact: true }).boundingBox();
|
||||||
|
const actionsBounds = await editor().locator(".ct-editor__pane-head > .btn-row").boundingBox();
|
||||||
|
assert.ok(titleBounds.y + titleBounds.height <= actionsBounds.y, "mobile actions must not overlap title input");
|
||||||
|
const saveBounds = await editor().getByRole("button", { name: "保存模板", exact: true }).boundingBox();
|
||||||
|
assert.ok(saveBounds.x >= 0 && saveBounds.x + saveBounds.width <= 391);
|
||||||
|
await page.screenshot({ path: path.join(output, "compare-template-editor-mobile.png"), fullPage: true });
|
||||||
|
await editor().getByRole("button", { name: "保存模板", exact: true }).click();
|
||||||
|
await editor().waitFor({ state: "hidden" });
|
||||||
|
const updated = writes.at(-1).metrics[1];
|
||||||
|
assert.deepEqual(updated.row_filters[0].value, ["up", "down"]);
|
||||||
|
assert.ok(updated.compare_fields.includes("count"));
|
||||||
|
assert.ok(!updated.field_rules.some((r) => r.field === "count" && (r.ignore || ["ignore", "skip", "off"].includes(r.compare))));
|
||||||
|
passed.push("negative tolerance blocks write", "keyboard rule tabs", "keyboard sheet tabs", "re-enable ignored field", "filter-only field visible", "membership converts to array", "390px layout and footer");
|
||||||
|
await page.setViewportSize({ width: 1440, height: 1000 });
|
||||||
|
const bad = { name: "无效导入", metrics: [sheet, { ...sheet, sheet_id: "bad", key_fields: [] }] };
|
||||||
|
const beforeImport = writes.length;
|
||||||
|
await page.locator('input[type="file"]').setInputFiles({ name: "invalid.json", mimeType: "application/json", buffer: Buffer.from(JSON.stringify(bad)) });
|
||||||
|
await page.getByText(/metrics\[1\]/).waitFor();
|
||||||
|
assert.equal(writes.length, beforeImport);
|
||||||
|
const imported = { ...exported, name: "导入验收" };
|
||||||
|
await page.locator('input[type="file"]').setInputFiles({ name: "valid.json", mimeType: "application/json", buffer: Buffer.from(JSON.stringify(imported)) });
|
||||||
|
await page.getByText("模板已导入", { exact: true }).waitFor();
|
||||||
|
assert.equal(writes.at(-1).metrics[0].ignore_port_changes, false);
|
||||||
|
assert.deepEqual(writes.at(-1).metrics[0].row_filters, complex);
|
||||||
|
passed.push("invalid import never partially writes", "valid import roundtrip");
|
||||||
|
await page.goto(`${base}/network/cutover/biz-compare`);
|
||||||
|
await page.getByRole("row").filter({ hasText: job.name }).getByRole("button", { name: "详情", exact: true }).click();
|
||||||
|
await page.getByRole("tab", { name: "任务配置", exact: true }).click();
|
||||||
|
await page.getByText(/配置中的检查项已被移除或改名,请重新选择检查范围/).waitFor();
|
||||||
|
assert.ok(await page.getByRole("button", { name: "保存配置", exact: true }).isDisabled());
|
||||||
|
await page.getByLabel("操作前批次", { exact: true }).locator('option[value="partial"]').waitFor({ state: "attached" });
|
||||||
|
assert.ok(await page.getByLabel("操作前批次", { exact: true }).locator('option[value="partial"]').isDisabled());
|
||||||
|
assert.ok(await page.getByLabel("操作后批次", { exact: true }).locator('option[value="running"]').isDisabled());
|
||||||
|
await page.getByRole("button", { name: "启用当前全部检查项", exact: true }).click();
|
||||||
|
const savedJob = page.waitForResponse((res) => res.request().method() === "PATCH" && res.url().endsWith("/compare/jobs/job"));
|
||||||
|
await page.getByRole("button", { name: "保存配置", exact: true }).click();
|
||||||
|
await savedJob;
|
||||||
|
assert.deepEqual(writes.at(-1).enabled_sheet_ids, []);
|
||||||
|
passed.push("missing sheets block save", "incomplete sources disabled", "explicit scope repair");
|
||||||
|
assert.deepEqual(errors, []);
|
||||||
|
passed.push("no runtime errors");
|
||||||
|
console.log(JSON.stringify({ passed, output }));
|
||||||
|
} finally { await browser.close(); }
|
||||||
59
web/tests/compareTemplateModel.test.mjs
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import { parseTemplateImport, parseIfaceNormText, toOrGroups, fromOrGroups, validateTemplateDraft } from "../src/pages/network/compareTemplateModel.ts";
|
||||||
|
|
||||||
|
const sheet = () => ({ metric_id: "custom", sheet_id: "s", key_fields: ["id"], iface_fields: ["port"], compare_fields: ["count"], field_rules: [], row_filters: [] });
|
||||||
|
const template = (metrics = [sheet()]) => ({ name: "验收", metrics });
|
||||||
|
|
||||||
|
test("invalid second sheet rejects the complete import", () => {
|
||||||
|
assert.throws(() => parseTemplateImport(template([sheet(), { ...sheet(), sheet_id: "s2", key_fields: [] }])), /metrics\[1\]/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("all three port policies and custom fields survive import", () => {
|
||||||
|
for (const policy of [true, false, null]) {
|
||||||
|
const parsed = parseTemplateImport(template([{ ...sheet(), ignore_port_changes: policy }]));
|
||||||
|
assert.equal(parsed.metrics[0].ignore_port_changes, policy);
|
||||||
|
assert.deepEqual(parsed.metrics[0].compare_fields, ["count"]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("legacy single metric import preserves explicit false and ignored fields without mutation", () => {
|
||||||
|
const raw = { name: "legacy", ...sheet(), ignore_port_changes: false, ignore_fields: ["count"], field_rules: [{ field: "count", compare: "numeric" }] };
|
||||||
|
const original = structuredClone(raw);
|
||||||
|
const parsed = parseTemplateImport(raw);
|
||||||
|
assert.equal(parsed.metrics[0].ignore_port_changes, false);
|
||||||
|
assert.deepEqual(parsed.metrics[0].compare_fields, []);
|
||||||
|
assert.equal(parsed.metrics[0].field_rules[0].ignore, true);
|
||||||
|
assert.deepEqual(raw, original);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("complex conditions remain intact and cannot become partial editable groups", () => {
|
||||||
|
const filters = [{ any: [{ all: [{ any: [{ field: "a", op: "eq", value: 0 }, { field: "b", op: "eq", value: false }] }, { field: "c", op: "empty" }] }] }];
|
||||||
|
assert.equal(toOrGroups(filters), null);
|
||||||
|
assert.deepEqual(parseTemplateImport(template([{ ...sheet(), row_filters: filters }])).metrics[0].row_filters, filters);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("supported OR/AND editor roundtrip preserves false, zero and list values", () => {
|
||||||
|
const groups = [[{ field: "a", op: "eq", value: 0 }, { field: "b", op: "eq", value: false }], [{ field: "c", op: "in", value: [0, false, "up"] }]];
|
||||||
|
assert.deepEqual(toOrGroups(fromOrGroups(groups)), groups);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("import rejects unsupported format/version and malformed aliases", () => {
|
||||||
|
assert.throws(() => parseTemplateImport({ ...template(), format: "other" }), /unsupported/);
|
||||||
|
assert.throws(() => parseTemplateImport({ ...template(), version: 2 }), /unsupported/);
|
||||||
|
assert.throws(() => parseTemplateImport({ ...template(), iface_normalize_rules: [{ from: "GE", to: "a" }, { from: "ge", to: "b" }] }), /duplicate_alias/);
|
||||||
|
assert.throws(() => parseTemplateImport({ ...template(), iface_normalize_rules: [{ from: "GE" }] }), /alias_pair/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("alias editor rejects bad lines instead of silently discarding them", () => {
|
||||||
|
assert.throws(() => parseIfaceNormText("GE, xe\nbroken"), /:2:/);
|
||||||
|
assert.throws(() => parseIfaceNormText("GE, xe\nge\tother"), /duplicate_alias/);
|
||||||
|
assert.deepEqual(parseIfaceNormText("# aliases\nGE, xe\nEth\tport"), [{ from: "GE", to: "xe" }, { from: "Eth", to: "port" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("invalid tolerances, modes, duplicate sheets and scalar set membership block saving", () => {
|
||||||
|
for (const tolerance of [-1, Infinity, NaN]) assert.throws(() => validateTemplateDraft([{ ...sheet(), field_rules: [{ field: "count", compare: "numeric", tolerance }] }]), /tolerance/);
|
||||||
|
assert.throws(() => validateTemplateDraft([{ ...sheet(), field_rules: [{ field: "count", compare: "typo" }] }]), /unknown_compare/);
|
||||||
|
assert.throws(() => validateTemplateDraft([sheet(), sheet()]), /duplicate_sheet/);
|
||||||
|
assert.throws(() => validateTemplateDraft([{ ...sheet(), row_filters: [{ field: "id", op: "in", value: "a,b" }] }]), /value_list/);
|
||||||
|
});
|
||||||