mirror of
https://github.com/hansjone/netx.git
synced 2026-10-12 01:50:46 +08:00
feat(biz-compare): preserve templates and refine industrial editor
This commit is contained in:
parent
90cf1907b4
commit
355bcfc4c0
19 changed files with 719 additions and 219 deletions
|
|
@ -386,6 +386,17 @@ const en = {
|
|||
templateExported: "Template exported",
|
||||
templateImported: "Template imported",
|
||||
templateImportInvalid: "Invalid file: need name and metrics",
|
||||
templateValidationFailed: "Template validation failed; correct the indicated settings",
|
||||
filterOperator: "Filter operator",
|
||||
complexFiltersPreserved: "These nested filters are preserved in full. Export, edit the JSON and import it to update them.",
|
||||
portPolicy: "Port change policy",
|
||||
portPolicyHint: "Port mappings take priority. This policy controls whether interface identity fields participate in matching without a mapping.",
|
||||
portPolicyAuto: "Automatic",
|
||||
portPolicyStrict: "Interfaces must match",
|
||||
portPolicyIgnore: "Allow port changes",
|
||||
missingSheets: "Configured checks were removed or renamed; select the scope again",
|
||||
enableCurrentSheets: "Enable all current checks",
|
||||
templateUsage: "Used by {{n}} jobs",
|
||||
templateImportHint: "Import/export JSON to share templates privately (no job/batch data)",
|
||||
templateHint:
|
||||
"Pick metrics, then set Key / interface / compare / display. Compare modes: exact, abs delta, or relative %. Display can include context-only columns. Empty compare = presence-only.",
|
||||
|
|
|
|||
|
|
@ -386,6 +386,17 @@ const zh = {
|
|||
templateExported: "模板已导出",
|
||||
templateImported: "模板已导入",
|
||||
templateImportInvalid: "导入文件无效:需要 name 与 metrics",
|
||||
templateValidationFailed: "模板校验失败,请修正标明的配置项",
|
||||
filterOperator: "过滤运算符",
|
||||
complexFiltersPreserved: "该过滤规则包含复杂嵌套,已完整保留。可通过导出、修改 JSON 后重新导入来更新。",
|
||||
portPolicy: "端口变化策略",
|
||||
portPolicyHint: "存在端口映射时按映射匹配;此策略控制无映射时接口身份字段是否参与配对。",
|
||||
portPolicyAuto: "自动判断",
|
||||
portPolicyStrict: "接口必须匹配",
|
||||
portPolicyIgnore: "允许端口变化",
|
||||
missingSheets: "配置中的检查项已被移除或改名,请重新选择检查范围",
|
||||
enableCurrentSheets: "启用当前全部检查项",
|
||||
templateUsage: "已被 {{n}} 个任务引用",
|
||||
templateImportHint: "导入/导出 JSON,便于私下传递模板(不含任务/批次数据)",
|
||||
templateHint:
|
||||
"先勾选监控项,再配置 Key / 接口 / 比对 / 展示。比对可设完全相等、绝对容差或相对偏差%;展示列可含不参与比对的上下文。不勾比对字段则只校验条目存在性。",
|
||||
|
|
|
|||
|
|
@ -12583,6 +12583,54 @@ html.login-page--paused .login-page__flare {
|
|||
.bs-monitor-modal .bs-sheet-filter > .input { flex-basis: 100%; }
|
||||
}
|
||||
|
||||
/* Compare templates: compact operations editor and safe nested-rule readout. */
|
||||
.bs-template-editor .modal__dialog { border-radius: 6px; border-color: #2b3748; background: #0c1521; }
|
||||
.bs-template-editor .button,
|
||||
.bs-template-editor .input,
|
||||
.bs-template-editor textarea,
|
||||
.bs-template-editor .mt-select,
|
||||
.bs-template-editor .ui-field__select { border-radius: 4px; box-shadow: none; }
|
||||
.bs-template-editor .modal__footer { flex-wrap: wrap; }
|
||||
.bs-template-editor .ct-editor__norm,
|
||||
.bs-template-editor .ct-editor__pick,
|
||||
.bs-template-editor .ct-editor__nav,
|
||||
.bs-template-editor .ct-editor__pane { border: 1px solid #2b3748; border-radius: 4px; background: #111c2a; min-width: 0; }
|
||||
.bs-template-editor .ct-editor__norm { padding: 10px; }
|
||||
.bs-template-editor .ct-editor__norm summary { cursor: pointer; color: #c4ddfa; }
|
||||
.bs-template-editor .ct-editor__norm-area { width: 100%; max-width: 100%; resize: vertical; font: 12px/1.6 ui-monospace, Consolas, monospace; }
|
||||
.bs-template-editor .ct-editor__main { grid-template-columns: 190px minmax(0, 1fr); min-width: 0; }
|
||||
.bs-template-editor .ct-editor__nav { max-height: 560px; }
|
||||
.bs-template-editor .ct-editor__nav-item { border-radius: 3px; font-size: 12px; }
|
||||
.bs-template-editor .ct-editor__nav-item.is-active { background: #1b2c43; border-color: #526f94; }
|
||||
.bs-template-editor .ct-editor__meta { font: 11px/1.6 ui-monospace, Consolas, monospace; overflow-wrap: anywhere; }
|
||||
.bs-template-editor .mt-rule-tab.is-active { color: #9cc7f7; border-color: #526f94; background: #1b2c43; }
|
||||
.bs-template-editor .ct-field-table-wrap { min-width: 0; overflow: auto; border: 1px solid #2b3748; border-radius: 4px; }
|
||||
.bs-template-editor .ct-field-table { min-width: 800px; }
|
||||
.bs-template-editor .ct-field-table td { font-size: 12px; }
|
||||
.bs-template-editor .ct-field-table th { font-size: 11px; }
|
||||
.bs-template-editor .ct-field-table .input { max-width: 100px; }
|
||||
.bs-template-editor .ct-filter-row { min-width: 0; }
|
||||
.bs-template-editor .ct-filter-row .mt-select { min-width: 0; max-width: 100%; }
|
||||
.bs-template-editor .ct-complex-filters pre { max-height: 360px; overflow: auto; background: #0c1521; border: 1px solid #2b3748; border-radius: 4px; padding: 10px; font: 11px/1.6 ui-monospace, Consolas, monospace; }
|
||||
.bs-template-editor button:focus-visible { outline: 2px solid #6ba9ec; outline-offset: 2px; }
|
||||
.bs-template-workspace .pt-list-table { min-width: 720px; }
|
||||
.bs-template-workspace .bs-cmp-metric-chips { max-height: 84px; overflow: auto; }
|
||||
@media (max-width: 900px) {
|
||||
.bs-template-editor .ct-editor__main { grid-template-columns: minmax(0, 1fr); }
|
||||
.bs-template-editor .ct-editor__nav-list { flex-direction: row; overflow-x: auto; }
|
||||
.bs-template-editor .ct-editor__nav-item { flex: 0 0 160px; }
|
||||
.bs-template-editor .ct-editor__nav { max-height: none; }
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.bs-template-editor .ct-editor__pane { padding: 10px; }
|
||||
.bs-template-editor .ct-editor__pane-head { flex-direction: column; }
|
||||
.bs-template-editor .ct-editor__pane-head > div:first-child { width: 100%; }
|
||||
.bs-template-editor .ct-editor__pane-head > .btn-row { align-self: flex-end; }
|
||||
.bs-template-editor .ct-filter-row { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto; }
|
||||
.bs-template-editor .ct-filter-row > .input { grid-column: 1 / 3; grid-row: 2; }
|
||||
.bs-template-editor .ct-filter-row > button { grid-column: 3; grid-row: 1 / 3; }
|
||||
}
|
||||
|
||||
/* Native :fullscreen + CSS immersive fallback (class only when FS API blocked) */
|
||||
.bs-cmp-board.is-fullscreen,
|
||||
.bs-cmp-board:fullscreen {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { Button, Input, Modal } from "@heroui/react";
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import type { KeyboardEvent as ReactKeyboardEvent } from "react";
|
||||
import { parseTemplateImport, parseIfaceNormText, toOrGroups, fromOrGroups, validateTemplateDraft } from "./compareTemplateModel";
|
||||
import type { MetricSheet, FieldRule, RowFilter } from "./compareTemplateModel";
|
||||
import { ListPager } from "../../components/ListPager";
|
||||
import { AppModalShell } from "../../components/ui/AppModalShell";
|
||||
import { FieldSelect } from "../../components/ui/FieldSelect";
|
||||
|
|
@ -61,34 +63,6 @@ type MetricField = {
|
|||
};
|
||||
type MetricSchema = { metric_id: string; fields: MetricField[] };
|
||||
|
||||
type MetricSheet = {
|
||||
sheet_id?: string;
|
||||
title?: string;
|
||||
metric_id: string;
|
||||
key_fields: string[];
|
||||
iface_fields: string[];
|
||||
compare_fields: string[];
|
||||
display_fields?: string[];
|
||||
row_filters?: RowFilter[];
|
||||
field_rules?: FieldRule[];
|
||||
};
|
||||
|
||||
type FieldRule = {
|
||||
field: string;
|
||||
compare?: string;
|
||||
normalize?: string;
|
||||
ignore?: boolean;
|
||||
tolerance?: number;
|
||||
};
|
||||
|
||||
type RowFilter = {
|
||||
field?: string;
|
||||
op?: string;
|
||||
value?: string | string[];
|
||||
any?: RowFilter[];
|
||||
all?: RowFilter[];
|
||||
};
|
||||
|
||||
type Template = {
|
||||
id: string;
|
||||
name: string;
|
||||
|
|
@ -100,6 +74,7 @@ type Template = {
|
|||
compare_fields: string[];
|
||||
iface_normalize_rules?: { from: string; to: string }[];
|
||||
note: string;
|
||||
job_count?: number;
|
||||
};
|
||||
|
||||
type Mapping = { id: string; name: string; rows: { before_if: string; after_if: string }[] };
|
||||
|
|
@ -549,6 +524,7 @@ function cloneSheet(s: MetricSheet): MetricSheet {
|
|||
display_fields: display,
|
||||
row_filters: JSON.parse(JSON.stringify(s.row_filters || [])),
|
||||
field_rules: JSON.parse(JSON.stringify(s.field_rules || [])),
|
||||
ignore_port_changes: s.ignore_port_changes,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -569,6 +545,7 @@ function templateExportPayload(tpl: Template) {
|
|||
: [...(s.key_fields || []), ...(s.compare_fields || [])],
|
||||
row_filters: JSON.parse(JSON.stringify(s.row_filters || [])),
|
||||
field_rules: JSON.parse(JSON.stringify(s.field_rules || [])),
|
||||
ignore_port_changes: s.ignore_port_changes,
|
||||
}));
|
||||
return {
|
||||
format: TEMPLATE_EXPORT_FORMAT,
|
||||
|
|
@ -581,105 +558,10 @@ function templateExportPayload(tpl: Template) {
|
|||
};
|
||||
}
|
||||
|
||||
function parseTemplateImport(raw: unknown): {
|
||||
name: string;
|
||||
note: string;
|
||||
metrics: MetricSheet[];
|
||||
iface_normalize_rules?: { from: string; to: string }[];
|
||||
} | null {
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
const o = raw as Record<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 {
|
||||
return (rules || []).map((r) => `${r.from},${r.to}`).join("\n");
|
||||
}
|
||||
|
||||
function parseIfaceNormText(text: string): { from: string; to: string }[] {
|
||||
const out: { from: string; to: string }[] = [];
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const s = line.trim();
|
||||
if (!s || s.startsWith("#")) continue;
|
||||
let fr = "";
|
||||
let to = "";
|
||||
if (s.includes(",")) {
|
||||
const i = s.indexOf(",");
|
||||
fr = s.slice(0, i).trim();
|
||||
to = s.slice(i + 1).trim();
|
||||
} else if (s.includes("\t")) {
|
||||
const i = s.indexOf("\t");
|
||||
fr = s.slice(0, i).trim();
|
||||
to = s.slice(i + 1).trim();
|
||||
} else {
|
||||
const parts = s.split(/\s+/);
|
||||
if (parts.length >= 2) {
|
||||
fr = parts[0];
|
||||
to = parts.slice(1).join(" ");
|
||||
}
|
||||
}
|
||||
if (fr && to) out.push({ from: fr, to });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function downloadJsonFile(filename: string, data: unknown) {
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
|
@ -713,51 +595,6 @@ function upsertFieldRule(sheet: MetricSheet, field: string, patch: Partial<Field
|
|||
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 {
|
||||
return { field, op: "eq", value: "" };
|
||||
}
|
||||
|
|
@ -827,6 +664,7 @@ function TplRowFiltersEditor({
|
|||
<div className="ct-filter-row">
|
||||
<select
|
||||
className="mt-select"
|
||||
aria-label={`${t("bizCompare.filterField")} ${gi + 1}.${fi + 1}`}
|
||||
value={filt.field || ""}
|
||||
onChange={(e) => {
|
||||
const next = groups.map((g) => g.map((x) => ({ ...x })));
|
||||
|
|
@ -840,13 +678,19 @@ function TplRowFiltersEditor({
|
|||
{name}
|
||||
</option>
|
||||
))}
|
||||
{filt.field && !fieldOpts.includes(filt.field) ? <option value={filt.field}>{filt.field}</option> : null}
|
||||
</select>
|
||||
<select
|
||||
className="mt-select"
|
||||
aria-label={`${t("bizCompare.filterOperator")} ${gi + 1}.${fi + 1}`}
|
||||
value={filt.op || "eq"}
|
||||
onChange={(e) => {
|
||||
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);
|
||||
}}
|
||||
>
|
||||
|
|
@ -855,18 +699,20 @@ function TplRowFiltersEditor({
|
|||
{op}
|
||||
</option>
|
||||
))}
|
||||
{filt.op && !(FILTER_OPS as readonly string[]).includes(filt.op) ? <option value={filt.op}>{filt.op}</option> : null}
|
||||
</select>
|
||||
<Input
|
||||
value={
|
||||
Array.isArray(filt.value) ? filt.value.join(",") : String(filt.value ?? "")
|
||||
}
|
||||
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) => {
|
||||
const next = groups.map((g) => g.map((x) => ({ ...x })));
|
||||
const op = next[gi][fi]?.op || "eq";
|
||||
let value: string | string[] = e.target.value;
|
||||
if (op === "in" || op === "not_in") {
|
||||
if (["in", "not_in", "nin"].includes(op)) {
|
||||
value = e.target.value
|
||||
.split(",")
|
||||
.map((x) => x.trim())
|
||||
|
|
@ -930,10 +776,13 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
const [mappings, setMappings] = useState<Mapping[]>([]);
|
||||
const [jobs, setJobs] = useState<Job[]>([]);
|
||||
const [listKw, setListKw] = useState("");
|
||||
const [listPage, setListPage] = useState(1);
|
||||
const [listPageSize, setListPageSize] = useState(50);
|
||||
const debouncedListKw = useDebouncedValue(listKw, 250);
|
||||
|
||||
// template editor
|
||||
const [tplOpen, setTplOpen] = useState(false);
|
||||
const [tplError, setTplError] = useState("");
|
||||
const [tplEditId, setTplEditId] = useState("");
|
||||
const [tplName, setTplName] = useState("");
|
||||
const [tplNote, setTplNote] = useState("");
|
||||
|
|
@ -1026,11 +875,11 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
};
|
||||
const fetchBundle = async (): Promise<Bundle> => {
|
||||
const [taskRes, tpl, maps, j, met] = await Promise.all([
|
||||
bizStateListTasks(),
|
||||
pageMode === "templates" ? Promise.resolve({items: []}) : bizStateListTasks(),
|
||||
bizCompareListTemplates(),
|
||||
bizCompareListMappings(),
|
||||
bizCompareListJobs(),
|
||||
bizCompareListMetrics(),
|
||||
pageMode === "templates" ? Promise.resolve({items: []}) : bizCompareListMappings(),
|
||||
pageMode === "templates" ? Promise.resolve({items: []}) : bizCompareListJobs(),
|
||||
cutoverCachedGet("bizCompare:metrics", () => bizCompareListMetrics(), {ttlMs: 60000}),
|
||||
]);
|
||||
return { taskRes, tpl, maps, j, met };
|
||||
};
|
||||
|
|
@ -1043,11 +892,11 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
setMetrics((b.met.items || []) as MetricSchema[]);
|
||||
};
|
||||
if (opts?.force) {
|
||||
apply(await cutoverCachedGet("bizCompare:lists", fetchBundle, { force: true }));
|
||||
apply(await cutoverCachedGet(`bizCompare:lists:${pageMode}`, fetchBundle, { force: true }));
|
||||
return;
|
||||
}
|
||||
apply(await cutoverCachedGetSWR("bizCompare:lists", fetchBundle, apply));
|
||||
}, []);
|
||||
apply(await cutoverCachedGetSWR(`bizCompare:lists:${pageMode}`, fetchBundle, apply));
|
||||
}, [pageMode]);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
|
|
@ -1140,10 +989,13 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
return `${x.name} ${mids} ${x.note}`.toLowerCase().includes(kw);
|
||||
});
|
||||
}, [templates, debouncedListKw]);
|
||||
const listTotal = activeTab === "templates" ? filteredTemplates.length : filteredJobs.length;
|
||||
const listPages = Math.max(1, Math.ceil(listTotal / listPageSize));
|
||||
const visibleListPage = Math.min(listPage, listPages);
|
||||
|
||||
const selectedJobTemplate = useMemo(
|
||||
() => templates.find((x) => x.id === templateId) || null,
|
||||
[templates, templateId],
|
||||
() => templatesById.get(templateId) || null,
|
||||
[templatesById, templateId],
|
||||
);
|
||||
|
||||
const jobTemplateSheets = useMemo(
|
||||
|
|
@ -1193,23 +1045,33 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
const setJobTemplateAndSheets = useCallback(
|
||||
(nextTplId: string, presetIds?: string[] | null) => {
|
||||
setTemplateId(nextTplId);
|
||||
const tpl = templates.find((x) => x.id === nextTplId);
|
||||
const all = tpl ? templateSheets(tpl).map((s) => sheetIdentity(s)).filter(Boolean) : [];
|
||||
if (presetIds && presetIds.length) {
|
||||
const kept = presetIds.filter((id) => all.includes(id));
|
||||
setEnabledSheetIds(kept.length === all.length ? [] : kept);
|
||||
// Keep stale IDs visible as a configuration error; [] would enable every sheet.
|
||||
setEnabledSheetIds([...presetIds]);
|
||||
} else {
|
||||
setEnabledSheetIds([]);
|
||||
}
|
||||
},
|
||||
[templates],
|
||||
[],
|
||||
);
|
||||
|
||||
const activeTplSheet = tplSheets[tplSheetIdx] || null;
|
||||
const activeTplFields = useMemo(() => {
|
||||
if (!activeTplSheet) return [];
|
||||
return metrics.find((m) => m.metric_id === activeTplSheet.metric_id)?.fields || [];
|
||||
const fields = [...(metrics.find((m) => m.metric_id === activeTplSheet.metric_id)?.fields || [])];
|
||||
const seen = new Set(fields.map((f) => f.name));
|
||||
for (const name of [...activeTplSheet.key_fields, ...activeTplSheet.iface_fields,
|
||||
...activeTplSheet.compare_fields, ...(activeTplSheet.display_fields || []),
|
||||
...(activeTplSheet.field_rules || []).map((r) => r.field)]) {
|
||||
if (!seen.has(name)) {
|
||||
fields.push({name, display_name: name, dtype: "string", is_key: false, is_interface: false, role: "meta"});
|
||||
seen.add(name);
|
||||
}
|
||||
}
|
||||
return fields;
|
||||
}, [metrics, activeTplSheet]);
|
||||
const activeTplFilterGroups = activeTplSheet ? toOrGroups(activeTplSheet.row_filters || []) : [];
|
||||
const missingJobSheets = enabledSheetIds.filter((id) => !jobSheetAllIds.includes(id));
|
||||
|
||||
const runSheets: RunSheet[] = useMemo(() => {
|
||||
const sheets = (runDetail?.sheets || []) as RunSheet[];
|
||||
|
|
@ -1593,6 +1455,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
};
|
||||
|
||||
const updateActiveSheet = (patch: Partial<MetricSheet>) => {
|
||||
setTplError("");
|
||||
setTplSheets((prev) =>
|
||||
prev.map((s, i) => {
|
||||
if (i !== tplSheetIdx) return s;
|
||||
|
|
@ -1630,6 +1493,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
};
|
||||
|
||||
const openNewTemplate = () => {
|
||||
setTplError("");
|
||||
setTplEditId("");
|
||||
setTplName("");
|
||||
setTplNote("");
|
||||
|
|
@ -1642,6 +1506,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
};
|
||||
|
||||
const openEditTemplate = (tpl: Template) => {
|
||||
setTplError("");
|
||||
const sheets = templateSheets(tpl);
|
||||
setTplEditId(tpl.id);
|
||||
setTplName(tpl.name);
|
||||
|
|
@ -1713,6 +1578,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
};
|
||||
|
||||
const saveTemplate = async () => {
|
||||
setTplError("");
|
||||
if (!tplSheets.length) {
|
||||
showError(t("bizCompare.metricsRequired"));
|
||||
return;
|
||||
|
|
@ -1734,6 +1600,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
validateTemplateDraft(tplSheets);
|
||||
const body = {
|
||||
name: tplName || tplSheets.map((s) => sheetLabel(s)).join("+"),
|
||||
note: tplNote,
|
||||
|
|
@ -1748,6 +1615,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
display_fields: s.display_fields || [...s.key_fields, ...s.compare_fields],
|
||||
row_filters: s.row_filters || [],
|
||||
field_rules: s.field_rules || [],
|
||||
ignore_port_changes: s.ignore_port_changes,
|
||||
})),
|
||||
};
|
||||
if (tplEditId) await bizCompareUpdateTemplate(tplEditId, body);
|
||||
|
|
@ -1758,6 +1626,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
invalidateCutoverCache("bizMigration:");
|
||||
await refresh({ force: true });
|
||||
} catch (e) {
|
||||
setTplError(formatErr(e));
|
||||
showError(formatErr(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
|
|
@ -1817,6 +1686,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
display_fields: s.display_fields || [...s.key_fields, ...s.compare_fields],
|
||||
row_filters: s.row_filters || [],
|
||||
field_rules: s.field_rules || [],
|
||||
ignore_port_changes: s.ignore_port_changes,
|
||||
})),
|
||||
});
|
||||
showOk(t("bizCompare.templateImported"));
|
||||
|
|
@ -1913,12 +1783,9 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
const tplId = preset?.template_id || templates[0]?.id || "";
|
||||
setName(preset?.name || t("bizCompare.defaultJobName"));
|
||||
setTemplateId(tplId);
|
||||
const tpl = templates.find((x) => x.id === tplId);
|
||||
const all = tpl ? templateSheets(tpl).map((s) => sheetIdentity(s)).filter(Boolean) : [];
|
||||
const presetIds = (preset?.enabled_sheet_ids || []).map(String).filter(Boolean);
|
||||
if (presetIds.length) {
|
||||
const kept = presetIds.filter((id) => all.includes(id));
|
||||
setEnabledSheetIds(kept.length === all.length ? [] : kept);
|
||||
setEnabledSheetIds(presetIds);
|
||||
} else {
|
||||
setEnabledSheetIds([]);
|
||||
}
|
||||
|
|
@ -2129,6 +1996,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
|
||||
const saveJobConfig = async () => {
|
||||
if (!jobId) return;
|
||||
if (missingJobSheets.length) { showError(t("bizCompare.missingSheets")); return; }
|
||||
if (!enabledJobSheetCount) {
|
||||
showError(t("bizCompare.needSheets"));
|
||||
return;
|
||||
|
|
@ -2160,6 +2028,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
|
||||
const runNow = async () => {
|
||||
if (!jobId) return;
|
||||
if (missingJobSheets.length) { showError(t("bizCompare.missingSheets")); return; }
|
||||
if (!enabledJobSheetCount) {
|
||||
showError(t("bizCompare.needSheets"));
|
||||
return;
|
||||
|
|
@ -2321,6 +2190,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
</span>
|
||||
</div>
|
||||
<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">
|
||||
{jobTemplateSheets.map((s) => {
|
||||
const sid = sheetIdentity(s);
|
||||
|
|
@ -2441,7 +2311,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
>
|
||||
<option value="">{t("bizCompare.pick")}</option>
|
||||
{beforeBatches.map((b) => (
|
||||
<option key={b.id} value={b.id}>
|
||||
<option key={b.id} value={b.id} disabled={b.status !== "success"}>
|
||||
{batchOptLabel(b)}
|
||||
</option>
|
||||
))}
|
||||
|
|
@ -2482,7 +2352,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
>
|
||||
<option value="">{t("bizCompare.pick")}</option>
|
||||
{afterBatches.map((b) => (
|
||||
<option key={b.id} value={b.id}>
|
||||
<option key={b.id} value={b.id} disabled={b.status !== "success"}>
|
||||
{batchOptLabel(b)}
|
||||
</option>
|
||||
))}
|
||||
|
|
@ -2555,7 +2425,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
);
|
||||
|
||||
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">
|
||||
<h2>
|
||||
{pageMode === "templates" ? t("bizCompare.templates") : t("bizCompare.title")}
|
||||
|
|
@ -2581,7 +2451,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
>
|
||||
{t("bizCompare.importTemplate")}
|
||||
</Button>
|
||||
<Button size="sm" variant="primary" onPress={openNewTemplate}>
|
||||
<Button size="sm" variant="primary" isDisabled={busy} onPress={openNewTemplate}>
|
||||
{t("bizCompare.newTemplate")}
|
||||
</Button>
|
||||
</>
|
||||
|
|
@ -2622,7 +2492,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
placeholder={
|
||||
activeTab === "jobs" ? t("bizCompare.jobFilterPh") : t("bizCompare.templateFilterPh")
|
||||
}
|
||||
onChange={(e) => setListKw(e.target.value)}
|
||||
onChange={(e) => { setListKw(e.target.value); setListPage(1); }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -2641,12 +2511,13 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredTemplates.map((tpl) => {
|
||||
{filteredTemplates.slice((visibleListPage - 1) * listPageSize, visibleListPage * listPageSize).map((tpl) => {
|
||||
const sheets = templateSheets(tpl);
|
||||
return (
|
||||
<tr key={tpl.id}>
|
||||
<td>
|
||||
<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>
|
||||
<div className="bs-cmp-metric-chips">
|
||||
|
|
@ -2664,7 +2535,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
<td className="muted">{tpl.note || "—"}</td>
|
||||
<td>
|
||||
<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")}
|
||||
</Button>
|
||||
<Button
|
||||
|
|
@ -2678,7 +2549,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
isDisabled={busy}
|
||||
isDisabled={busy || Boolean(tpl.job_count) || jobs.some((j) => j.template_id === tpl.id)}
|
||||
onPress={() => void removeTemplate(tpl.id)}
|
||||
>
|
||||
{t("bizCompare.delete")}
|
||||
|
|
@ -2711,7 +2582,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredJobs.map((j) => {
|
||||
{filteredJobs.slice((visibleListPage - 1) * listPageSize, visibleListPage * listPageSize).map((j) => {
|
||||
const tpl = templatesById.get(j.template_id);
|
||||
const n = templateSheets(tpl).length;
|
||||
return (
|
||||
|
|
@ -2760,17 +2631,19 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
</table>
|
||||
</div>
|
||||
)}
|
||||
<ListPager page={visibleListPage} pages={listPages} total={listTotal} pageSize={listPageSize} pageSizeOptions={[20, 50, 100, 200]} onPageChange={setListPage} onPageSizeChange={(n) => { setListPageSize(n); setListPage(1); }} />
|
||||
</div>
|
||||
|
||||
{/* 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.Heading>
|
||||
{tplEditId ? t("bizCompare.editTemplate") : t("bizCompare.newTemplate")}
|
||||
</Modal.Heading>
|
||||
<Modal.CloseTrigger />
|
||||
<Modal.CloseTrigger isDisabled={busy} />
|
||||
</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">
|
||||
<Input
|
||||
value={tplName}
|
||||
|
|
@ -2786,18 +2659,18 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
/>
|
||||
</div>
|
||||
|
||||
<div className="ct-editor__norm">
|
||||
<div className="ct-editor__pick-title">{t("bizCompare.ifaceNormalize")}</div>
|
||||
<details className="ct-editor__norm">
|
||||
<summary className="ct-editor__pick-title">{t("bizCompare.ifaceNormalize")}</summary>
|
||||
<p className="muted ct-editor__pick-hint">{t("bizCompare.ifaceNormalizeHint")}</p>
|
||||
<textarea
|
||||
className="ct-editor__norm-area"
|
||||
rows={5}
|
||||
rows={3}
|
||||
value={tplNormText}
|
||||
onChange={(e) => setTplNormText(e.target.value)}
|
||||
placeholder={"GE,gei\nSG,smartgroup"}
|
||||
aria-label={t("bizCompare.ifaceNormalize")}
|
||||
/>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div className="ct-editor__pick">
|
||||
<div className="ct-editor__pick-head">
|
||||
|
|
@ -2846,15 +2719,22 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
<div className="ct-editor__nav-head">
|
||||
<span>{t("bizCompare.colSheets")}</span>
|
||||
</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) => {
|
||||
const filterN = toOrGroups(s.row_filters || []).length;
|
||||
const filterN = toOrGroups(s.row_filters || [])?.length ?? (s.row_filters || []).length;
|
||||
return (
|
||||
<button
|
||||
key={`${sheetIdentity(s)}-${i}`}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tplSheetIdx === i}
|
||||
tabIndex={tplSheetIdx === i ? 0 : -1}
|
||||
className={`ct-editor__nav-item${tplSheetIdx === i ? " is-active" : ""}`}
|
||||
onClick={() => {
|
||||
setTplSheetIdx(i);
|
||||
|
|
@ -2914,11 +2794,18 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
</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
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tplPaneTab === "fields"}
|
||||
tabIndex={tplPaneTab === "fields" ? 0 : -1}
|
||||
className={`mt-rule-tab${tplPaneTab === "fields" ? " is-active" : ""}`}
|
||||
onClick={() => setTplPaneTab("fields")}
|
||||
>
|
||||
|
|
@ -2929,24 +2816,28 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tplPaneTab === "filters"}
|
||||
tabIndex={tplPaneTab === "filters" ? 0 : -1}
|
||||
className={`mt-rule-tab${tplPaneTab === "filters" ? " is-active" : ""}`}
|
||||
onClick={() => setTplPaneTab("filters")}
|
||||
>
|
||||
{t("bizCompare.tabFilters")}
|
||||
<span className="mt-rule-tab__n">
|
||||
{toOrGroups(activeTplSheet.row_filters || []).length}
|
||||
{activeTplFilterGroups?.length ?? (activeTplSheet.row_filters || []).length}
|
||||
</span>
|
||||
</button>
|
||||
</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
|
||||
groups={toOrGroups(activeTplSheet.row_filters || [])}
|
||||
groups={activeTplFilterGroups}
|
||||
fieldOpts={activeTplFields.map((f) => f.name)}
|
||||
t={t}
|
||||
onChange={(next) => updateActiveSheet({ row_filters: fromOrGroups(next) })}
|
||||
/>
|
||||
) : (
|
||||
)) : (
|
||||
<>
|
||||
<p className="muted ct-field-hint">{t("bizCompare.fieldsHintShort")}</p>
|
||||
<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);
|
||||
const isDisplay = (activeTplSheet.display_fields || []).includes(f.name);
|
||||
const rule = ruleForField(activeTplSheet, f.name);
|
||||
const cmpMode = (rule.compare || "eq").toLowerCase() || "eq";
|
||||
const norm = rule.normalize || "none";
|
||||
const rawMode = (rule.compare || "eq").trim().toLowerCase();
|
||||
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";
|
||||
return (
|
||||
<tr key={f.name}>
|
||||
|
|
@ -3021,6 +2914,8 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
f.name,
|
||||
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
|
||||
className="mt-select"
|
||||
value={cmpMode === "ignore" ? "eq" : cmpMode}
|
||||
aria-label={`${f.name} ${t("bizCompare.compareMode")}`}
|
||||
disabled={!isCompare}
|
||||
onChange={(e) =>
|
||||
updateActiveSheet({
|
||||
|
|
@ -3048,6 +2944,9 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
<td>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
step="any"
|
||||
aria-label={`${f.name} ${t("bizCompare.tolerance")}`}
|
||||
value={
|
||||
needsTol &&
|
||||
rule.tolerance !== undefined &&
|
||||
|
|
@ -3092,6 +2991,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
<select
|
||||
className="mt-select"
|
||||
value={norm}
|
||||
aria-label={`${f.name} ${t("bizCompare.normalizeField")}`}
|
||||
disabled={!isCompare}
|
||||
onChange={(e) =>
|
||||
updateActiveSheet({
|
||||
|
|
@ -3131,7 +3031,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
)}
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button size="sm" variant="secondary" onPress={() => setTplOpen(false)}>
|
||||
<Button size="sm" variant="secondary" isDisabled={busy} onPress={() => setTplOpen(false)}>
|
||||
{t("bizState.cancel")}
|
||||
</Button>
|
||||
<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">
|
||||
{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")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
isDisabled={busy || jobHasActiveRun}
|
||||
isDisabled={busy || jobHasActiveRun || Boolean(missingJobSheets.length)}
|
||||
onPress={() => void runNow()}
|
||||
>
|
||||
{jobHasActiveRun ? t("bizCompare.runStatusRunning") : t("bizCompare.runNow")}
|
||||
</Button>
|
||||
{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")}
|
||||
</Button>
|
||||
) : null}
|
||||
|
|
|
|||
233
web/src/pages/network/compareTemplateModel.ts
Normal file
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
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
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/);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue