Optimize cutover compare/monitor per the 2026-10-12 plan.

Correctness (A): normalize port-map names with the template rules (snapshot v2,
v1 replays unchanged); protect mappings/templates that are referenced; stop
dropping edited mapping text; accept pulled-but-not-shutdown ports as migrated;
validate baseline ownership on project create; one canonical key form; fix
built-in template tolerances and upgrade old packaged values in place.

Cross-vendor identity (B): single identity pipeline for every path, per-side and
per-vendor interface rules, value maps, filters after normalization, mapping
pre-flight for compare and cutover projects, SQL eligibility fixes.

Data quality (V): no_data / suspect_parse / not_collected sheet states, command
slice coverage, directional compare modes, value-sorted duplicate pairing,
before-side raw names in results and CSV.

Robustness (H): bounded auto-run history, background evaluate/finish with
polling, manual route tables out of the acceptance gate, baseline row cache,
scoped duplicate-key failures, merged HF tasks with a per-NE session cap,
same-device cutover, explicit scope_by_port_map, read-only GETs, resolvable red
tickets.

Frontend: pre-flight report, mapping table view, template editor support for the
new rules, data-state banners. Docs: canonical field contract.

Not included: Huawei status parsers (no real samples), SQL-path route compare.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
oliver 2026-10-12 11:51:55 +08:00
parent fff7d63b9b
commit 315539e0fd
59 changed files with 6483 additions and 487 deletions

View file

@ -1,8 +1,15 @@
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 "./lib/compareTemplateModel";
import type { MetricSheet, FieldRule, RowFilter } from "./lib/compareTemplateModel";
import {
parseTemplateImport, parseIfaceNormText, formatIfaceNormText, parseValueMapText, formatValueMapText,
sheetPayload, sheetOptions, toOrGroups, fromOrGroups, validateTemplateDraft,
} from "./lib/compareTemplateModel";
import type { MetricSheet, FieldRule, RowFilter, IfaceRule, Side } from "./lib/compareTemplateModel";
import { MappingTable } from "./components/MappingTable";
import { PreflightReport, dataStateLabel } from "./components/PreflightReport";
import type { PreflightReportData } from "./components/PreflightReport";
import { parseMapText, mapTextDirty } from "./lib/mappingText";
import { ListPager } from "../../components/ListPager";
import { AppModalShell } from "../../components/ui/AppModalShell";
import { FieldSelect } from "../../components/ui/FieldSelect";
@ -29,7 +36,7 @@ import {
bizCompareUpdateJob,
bizCompareUpdateMapping,
bizCompareUpdateTemplate,
bizCompareValidateMapping,
bizCompareMappingPreflight,
bizStateListBatches,
bizStateListTasks,
formatErr,
@ -72,12 +79,19 @@ type Template = {
key_fields: string[];
iface_fields: string[];
compare_fields: string[];
iface_normalize_rules?: { from: string; to: string }[];
iface_normalize_rules?: IfaceRule[];
note: string;
job_count?: number;
monitor_template_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 }[];
job_count?: number;
project_count?: number;
};
type StoreUnchanged = "auto" | "always" | "never" | "sample" | "keys";
type Job = {
@ -102,6 +116,8 @@ type DiffRow = {
before: Record<string, unknown> | null;
after: Record<string, unknown> | null;
mapped_before?: Record<string, unknown> | null;
/** Before-side key exactly as the device printed it; only present when it differs from the match key. */
raw_key_before?: Record<string, unknown> | null;
changes: Record<string, { before?: unknown; after?: unknown; reason?: string }>;
};
@ -525,6 +541,7 @@ function cloneSheet(s: MetricSheet): MetricSheet {
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,
...sheetOptions(s as unknown as Record<string, unknown>),
};
}
@ -532,21 +549,9 @@ const TEMPLATE_EXPORT_FORMAT = "netx.biz_compare_template";
const TEMPLATE_EXPORT_VERSION = 1;
function templateExportPayload(tpl: Template) {
const metrics = templateSheets(tpl).map((s) => ({
sheet_id: s.sheet_id || s.metric_id,
title: s.title || s.sheet_id || s.metric_id,
metric_id: s.metric_id,
key_fields: [...(s.key_fields || [])],
iface_fields: [...(s.iface_fields || [])],
compare_fields: [...(s.compare_fields || [])],
display_fields:
s.display_fields && s.display_fields.length
? [...s.display_fields]
: [...(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,
}));
const metrics = templateSheets(tpl).map((s) =>
JSON.parse(JSON.stringify(sheetPayload({ ...s, display_fields: s.display_fields?.length ? s.display_fields : undefined }))),
);
return {
format: TEMPLATE_EXPORT_FORMAT,
version: TEMPLATE_EXPORT_VERSION,
@ -558,8 +563,8 @@ function templateExportPayload(tpl: Template) {
};
}
function ifaceNormRulesToText(rules?: { from: string; to: string }[]): string {
return (rules || []).map((r) => `${r.from},${r.to}`).join("\n");
function ifaceNormRulesToText(rules?: IfaceRule[]): string {
return formatIfaceNormText(rules);
}
function downloadJsonFile(filename: string, data: unknown) {
@ -572,13 +577,16 @@ function downloadJsonFile(filename: string, data: unknown) {
URL.revokeObjectURL(url);
}
const isSided = (r: FieldRule) => r.side === "before" || r.side === "after";
/** The rule that drives the comparison of a field (side-scoped value maps are separate rules). */
function ruleForField(sheet: MetricSheet, field: string): FieldRule {
return (sheet.field_rules || []).find((r) => r.field === field) || { field };
return (sheet.field_rules || []).find((r) => r.field === field && !isSided(r)) || { field };
}
function upsertFieldRule(sheet: MetricSheet, field: string, patch: Partial<FieldRule>): FieldRule[] {
const rules = [...(sheet.field_rules || [])];
const idx = rules.findIndex((r) => r.field === field);
const idx = rules.findIndex((r) => r.field === field && !isSided(r));
const next: FieldRule = { ...(idx >= 0 ? rules[idx] : { field }), ...patch, field };
const norm = (next.normalize || "").toLowerCase();
const cmp = (next.compare || "").toLowerCase();
@ -586,15 +594,33 @@ function upsertFieldRule(sheet: MetricSheet, field: string, patch: Partial<Field
!next.ignore &&
(!cmp || cmp === "eq") &&
(!norm || norm === "none" || norm === "strip") &&
(next.tolerance === undefined || next.tolerance === null);
(next.tolerance === undefined || next.tolerance === null) &&
!Object.keys(next.value_map || {}).length &&
!(next.rank || []).length &&
next.apply_to_key === undefined;
if (empty) {
return rules.filter((r) => r.field !== field);
return rules.filter((_r, i) => i !== idx);
}
if (idx >= 0) rules[idx] = next;
else rules.push(next);
return rules;
}
/** Value map of a field for one scope: "both" lives on the main rule, before/after on their own rules. */
function valueMapFor(sheet: MetricSheet, field: string, scope: Side): Record<string, string> {
const rule = (sheet.field_rules || []).find((r) => r.field === field && (scope === "before" || scope === "after" ? r.side === scope : !isSided(r)));
return rule?.value_map || {};
}
function setValueMapFor(sheet: MetricSheet, field: string, scope: Side, map: Record<string, string>): FieldRule[] {
if (scope !== "before" && scope !== "after") {
return upsertFieldRule(sheet, field, { value_map: Object.keys(map).length ? map : undefined });
}
const rules = (sheet.field_rules || []).filter((r) => !(r.field === field && r.side === scope));
if (Object.keys(map).length) rules.push({ field, side: scope, value_map: map });
return rules;
}
function emptyLeaf(field = ""): RowFilter {
return { field, op: "eq", value: "" };
}
@ -611,6 +637,69 @@ const FILTER_OPS = [
"age_timer",
] as const;
/** Value-map editor for one field: text is parsed on blur so half-typed lines never clobber the rule. */
function ValueMapCell({
field, value, scope, onScope, onCommit, rank, onRank, showRank, t,
}: {
field: string;
value: Record<string, string>;
scope: Side;
onScope: (s: Side) => void;
onCommit: (map: Record<string, string>) => void;
rank: string[];
onRank: (rank: string[]) => void;
showRank: boolean;
t: (k: string, vars?: Record<string, string | number>) => string;
}) {
const external = formatValueMapText(value);
const [text, setText] = useState(external);
const [error, setError] = useState("");
useEffect(() => {
setText(external);
setError("");
}, [external, scope]);
return (
<div className="ct-valuemap">
{showRank ? (
<Input
aria-label={`${field} ${t("bizCompare.rankLabel")}`}
placeholder={t("bizCompare.rankPlaceholder")}
value={rank.join(",")}
onChange={(e) => onRank(e.target.value.split(",").map((x) => x.trim()).filter(Boolean))}
/>
) : null}
<select
className="mt-select"
aria-label={`${field} ${t("bizCompare.valueMapScope")}`}
value={scope === "before" || scope === "after" ? scope : "both"}
onChange={(e) => onScope(e.target.value as Side)}
>
<option value="both">{t("bizCompare.scopeBoth")}</option>
<option value="before">{t("bizCompare.scopeBefore")}</option>
<option value="after">{t("bizCompare.scopeAfter")}</option>
</select>
<textarea
className={`bs-cmp-mapping__editor ct-valuemap__text${error ? " is-invalid" : ""}`}
aria-label={`${field} ${t("bizCompare.valueMap")}`}
placeholder={t("bizCompare.valueMapPlaceholder")}
rows={2}
spellCheck={false}
value={text}
onChange={(e) => setText(e.target.value)}
onBlur={() => {
try {
onCommit(parseValueMapText(text));
setError("");
} catch (e) {
setError(String((e as Error).message));
}
}}
/>
{error ? <span className="ct-valuemap__err" role="alert">{error}</span> : null}
</div>
);
}
function TplRowFiltersEditor({
groups,
fieldOpts,
@ -622,6 +711,8 @@ function TplRowFiltersEditor({
t: (k: string, vars?: Record<string, string | number>) => string;
onChange: (next: RowFilter[][]) => void;
}) {
// A side only has meaning on a top-level filter, i.e. when there is a single AND group.
const sideAllowed = groups.length === 1;
const setGroups = onChange;
return (
<div className="ct-filters">
@ -701,6 +792,24 @@ function TplRowFiltersEditor({
))}
{filt.op && !(FILTER_OPS as readonly string[]).includes(filt.op) ? <option value={filt.op}>{filt.op}</option> : null}
</select>
{sideAllowed ? (
<select
className="mt-select"
aria-label={`${t("bizCompare.filterSide")} ${gi + 1}.${fi + 1}`}
value={filt.side === "before" || filt.side === "after" ? filt.side : "both"}
onChange={(e) => {
const next = groups.map((g) => g.map((x) => ({ ...x })));
const side = e.target.value as Side;
if (side === "before" || side === "after") next[gi][fi] = { ...next[gi][fi], side };
else delete next[gi][fi].side;
setGroups(next);
}}
>
<option value="both">{t("bizCompare.scopeBoth")}</option>
<option value="before">{t("bizCompare.scopeBefore")}</option>
<option value="after">{t("bizCompare.scopeAfter")}</option>
</select>
) : null}
<Input
value={
Array.isArray(filt.value) ? filt.value.join(",") : String(filt.value ?? "")
@ -789,6 +898,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
const [tplNormText, setTplNormText] = useState("");
const [tplSheets, setTplSheets] = useState<MetricSheet[]>([]);
const [tplSheetIdx, setTplSheetIdx] = useState(0);
const [valueMapScope, setValueMapScope] = useState<Record<string, Side>>({});
const [tplPaneTab, setTplPaneTab] = useState<"fields" | "filters">("fields");
const [showMetricPicker, setShowMetricPicker] = useState(true);
@ -811,7 +921,8 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
const [storeUnchanged, setStoreUnchanged] = useState<StoreUnchanged>("auto");
const [mapName, setMapName] = useState("端口映射");
const [mapText, setMapText] = useState("");
const [validateOut, setValidateOut] = useState<any>(null);
const [validateOut, setValidateOut] = useState<PreflightReportData | null>(null);
const [mapView, setMapView] = useState<"text" | "table">("text");
const [runs, setRuns] = useState<RunRecord[]>([]);
const [runsLoading, setRunsLoading] = useState(false);
const [runsError, setRunsError] = useState("");
@ -1333,6 +1444,8 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
success_count?: number;
diff_count?: number;
pass_rate?: number | null;
data_state?: string;
data_state_reasons?: string[];
}>;
// Failures first; keep pending/running at the end so completed fails stay visible
return [...raw].sort((a, b) => {
@ -1349,6 +1462,9 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
const activeFail = sheetFailOf(activeSheetCard || {});
const activeSuccess = sheetSuccessOf(activeSheetCard || {});
const activePassRate = sheetPassRateOf(activeSheetCard || {});
const activeDataState = String(activeSheetCard?.data_state || "ok");
const activeCoverage = (runSheets.find((sh) => sheetIdentity(sh) === sheetIdentity(activeSheetCard || {}))?.summary as
{ mapping_coverage?: { suspect_unmapped_total?: number; suspect_unmapped?: string[] } } | undefined)?.mapping_coverage;
const activeSheetPending = ["pending", "running", "queued"].includes(
String(activeSheetCard?.status || ""),
);
@ -1501,6 +1617,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
setTplSheets([]);
setTplSheetIdx(0);
setTplPaneTab("fields");
setValueMapScope({});
setShowMetricPicker(true);
setTplOpen(true);
};
@ -1515,6 +1632,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
setTplSheets(sheets.length ? sheets.map(cloneSheet) : []);
setTplSheetIdx(0);
setTplPaneTab("fields");
setValueMapScope({});
setShowMetricPicker(!sheets.length);
setTplOpen(true);
};
@ -1605,18 +1723,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
name: tplName || tplSheets.map((s) => sheetLabel(s)).join("+"),
note: tplNote,
iface_normalize_rules: parseIfaceNormText(tplNormText),
metrics: tplSheets.map((s) => ({
sheet_id: sheetIdentity(s),
title: s.title || sheetIdentity(s),
metric_id: s.metric_id,
key_fields: s.key_fields,
iface_fields: s.iface_fields,
compare_fields: s.compare_fields,
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,
})),
metrics: tplSheets.map((s) => sheetPayload(s, sheetIdentity(s))),
};
if (tplEditId) await bizCompareUpdateTemplate(tplEditId, body);
else await bizCompareCreateTemplate(body);
@ -1676,18 +1783,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
name: body.name,
note: body.note,
iface_normalize_rules: body.iface_normalize_rules || [],
metrics: body.metrics.map((s) => ({
sheet_id: sheetIdentity(s),
title: s.title || sheetIdentity(s),
metric_id: s.metric_id,
key_fields: s.key_fields,
iface_fields: s.iface_fields,
compare_fields: s.compare_fields,
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,
})),
metrics: body.metrics.map((s) => sheetPayload(s, sheetIdentity(s))),
});
showOk(t("bizCompare.templateImported"));
invalidateCutoverCache("bizMonitor:");
@ -1717,21 +1813,6 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
}
};
const parseMapRows = () => {
const rows: { before_if: string; after_if: string }[] = [];
for (const line of mapText.split(/\r?\n/)) {
const s = line.trim();
if (!s || s.startsWith("#") || s.toLowerCase().startsWith("old") || s.toLowerCase().startsWith("before")) {
continue;
}
const parts = s.split(/[,|\t]+/).map((x) => x.trim());
if (parts.length >= 2 && parts[0] && parts[1]) {
rows.push({ before_if: parts[0], after_if: parts[1] });
}
}
return rows;
};
const loadMappingText = (id: string) => {
const m = mappings.find((x) => x.id === id);
if (!m) return;
@ -1740,17 +1821,52 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
setMapText(m.rows.map((r) => `${r.before_if},${r.after_if}`).join("\n"));
};
const mapParsed = useMemo(() => parseMapText(mapText), [mapText]);
const savedMapping = mappings.find((x) => x.id === mappingId);
const mapDirty = useMemo(() => mapTextDirty(mapText, savedMapping?.rows), [mapText, savedMapping]);
/** Persist the editor text. Returns the mapping id, "" for "no mapping", or null when blocked. */
const persistMapping = async (): Promise<string | null> => {
const parsed = mapParsed;
if (parsed.invalidLines.length || parsed.duplicateBefore.length) {
showError(t("bizCompare.mapInvalid", {
lines: parsed.invalidLines.join(", ") || "-",
dup: parsed.duplicateBefore.join(", ") || "-",
}));
return null;
}
if (!parsed.rows.length) {
if (mappingId && mapDirty) {
showError(t("bizCompare.mapEmpty"));
return null;
}
return mappingId;
}
if (savedMapping) {
const own = jobId && jobs.find((j) => j.id === jobId)?.mapping_id === savedMapping.id ? 1 : 0;
const jobsUsing = Math.max(0, Number(savedMapping.job_count || 0) - own);
const projectsUsing = Number(savedMapping.project_count || 0);
if ((jobsUsing > 0 || projectsUsing > 0)
&& !window.confirm(t("bizCompare.mapSharedConfirm", { jobs: jobsUsing, projects: projectsUsing }))) {
return null;
}
}
const rows = parsed.rows;
let id = mappingId;
if (mappingId) await bizCompareUpdateMapping(mappingId, { name: mapName, rows });
else {
const m = await bizCompareCreateMapping({ name: mapName, rows });
id = String(m.id);
}
setMappingId(id);
await refresh({ force: true });
return id;
};
const saveMapping = async () => {
setBusy(true);
try {
const rows = parseMapRows();
if (mappingId) await bizCompareUpdateMapping(mappingId, { name: mapName, rows });
else {
const m = await bizCompareCreateMapping({ name: mapName, rows });
setMappingId(String(m.id));
}
showOk(t("bizCompare.mappingSaved"));
await refresh({ force: true });
if ((await persistMapping()) !== null) showOk(t("bizCompare.mappingSaved"));
} catch (e) {
showError(formatErr(e));
} finally {
@ -1758,19 +1874,36 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
}
};
/** Job create/save/run must not silently drop edited mapping text: save it first. */
const ensureMappingSaved = async (): Promise<string | null> => {
if (!mapDirty) return mappingId;
const id = await persistMapping();
if (id !== null && id !== mappingId) showOk(t("bizCompare.mappingAutoSaved"));
return id;
};
const doValidate = async () => {
if (!mappingId || !beforeBatchId || !afterBatchId) {
showError(t("bizCompare.validateNeed"));
if (!beforeBatchId || !afterBatchId) {
showError(t("bizCompare.mappingNeedBatches"));
return;
}
if (mapParsed.invalidLines.length || mapParsed.duplicateBefore.length) {
showError(t("bizCompare.mapInvalid", {
lines: mapParsed.invalidLines.join(", ") || "-",
dup: mapParsed.duplicateBefore.join(", ") || "-",
}));
return;
}
setBusy(true);
try {
const v = await bizCompareValidateMapping({
mapping_id: mappingId,
// The editor text is what gets checked, saved or not.
const v = (await bizCompareMappingPreflight({
template_id: templateId,
enabled_sheet_ids: enabledSheetIds,
mapping_rows: mapParsed.rows,
before_batch_id: beforeBatchId,
after_batch_id: afterBatchId,
template_id: templateId,
});
})) as unknown as PreflightReportData;
setValidateOut(v);
} catch (e) {
showError(formatErr(e));
@ -1779,6 +1912,13 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
}
};
/** Suspected-unmapped ports go into the editor as `port,` lines; the target is left to fill in. */
const addSuspectedPorts = (ports: string[]) => {
const have = new Set(mapParsed.rows.map((r) => r.before_if));
const lines = ports.filter((p) => !have.has(p)).map((p) => `${p},`);
if (lines.length) setMapText((prev) => `${prev.replace(/\s+$/, "")}${prev.trim() ? "\n" : ""}${lines.join("\n")}`);
};
const resetJobForm = (preset?: Partial<Job>) => {
const tplId = preset?.template_id || templates[0]?.id || "";
setName(preset?.name || t("bizCompare.defaultJobName"));
@ -1811,10 +1951,10 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
}
};
const jobConfigBody = () => ({
const jobConfigBody = (mid: string = mappingId) => ({
name,
template_id: templateId,
mapping_id: mappingId,
mapping_id: mid,
before_task_id: beforeTaskId,
after_task_id: afterTaskId || beforeTaskId,
before_batch_id: beforeBatchId,
@ -1903,7 +2043,9 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
}
setBusy(true);
try {
const j = await bizCompareCreateJob(jobConfigBody());
const mid = await ensureMappingSaved();
if (mid === null) return;
const j = await bizCompareCreateJob(jobConfigBody(mid));
showOk(t("bizCompare.created"));
closeCreateJob();
await refresh({ force: true });
@ -2003,7 +2145,9 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
}
setBusy(true);
try {
await bizCompareUpdateJob(jobId, jobConfigBody());
const mid = await ensureMappingSaved();
if (mid === null) return;
await bizCompareUpdateJob(jobId, jobConfigBody(mid));
showOk(t("bizCompare.jobSaved"));
await refresh({ force: true });
} catch (e) {
@ -2041,7 +2185,9 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
const request = jobRequestRef.current;
setBusy(true);
try {
await bizCompareUpdateJob(jobId, jobConfigBody());
const mid = await ensureMappingSaved();
if (mid === null) return;
await bizCompareUpdateJob(jobId, jobConfigBody(mid));
// Async enqueue — returns immediately with status=running; poll below.
const run = await bizCompareRunJob(jobId);
if (request !== jobRequestRef.current) return;
@ -2251,17 +2397,48 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
<Input value={mapName} onChange={(e) => setMapName(e.target.value)} />
</label>
</div>
<label className="ui-field ui-field--full">
<span className="ui-field__label">{t("bizCompare.mappingPairs")}</span>
<textarea
className="bs-cmp-mapping__editor"
value={mapText}
onChange={(e) => setMapText(e.target.value)}
placeholder={t("bizCompare.mapHint")}
rows={4}
spellCheck={false}
/>
</label>
<div className="bs-cmp-mapping__views">
{(["text", "table"] as const).map((v) => (
<button
key={v}
type="button"
className={`mt-link-btn${mapView === v ? " is-active" : ""}`}
aria-pressed={mapView === v}
onClick={() => setMapView(v)}
>
{t(v === "text" ? "bizCompare.mapViewText" : "bizCompare.mapViewTable")}
</button>
))}
</div>
{mapView === "table" ? (
<MappingTable text={mapText} onChange={setMapText} t={t} />
) : (
<label className="ui-field ui-field--full">
<span className="ui-field__label">{t("bizCompare.mappingPairs")}</span>
<textarea
className="bs-cmp-mapping__editor"
value={mapText}
onChange={(e) => setMapText(e.target.value)}
placeholder={t("bizCompare.mapHint")}
rows={4}
spellCheck={false}
/>
</label>
)}
<div
className={`muted bs-cmp-mapping__status${mapParsed.invalidLines.length || mapParsed.duplicateBefore.length ? " is-error" : ""}`}
role="status"
data-testid="map-parse-status"
>
{t("bizCompare.mapParsed", { n: mapParsed.rows.length })}
{mapParsed.invalidLines.length
? ` · ${t("bizCompare.mapInvalidLines", { m: mapParsed.invalidLines.length, lines: mapParsed.invalidLines.join(", ") })}`
: ""}
{mapParsed.duplicateBefore.length
? ` · ${t("bizCompare.mapDuplicate", { list: mapParsed.duplicateBefore.join(", ") })}`
: ""}
{mapDirty ? ` · ${t("bizCompare.mapUnsaved")}` : ""}
</div>
<div className="bs-cmp-mapping__actions">
<Button size="sm" variant="secondary" isDisabled={busy} onPress={() => void saveMapping()}>
{t("bizCompare.saveMapping")}
@ -2270,11 +2447,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
{t("bizCompare.validateMapping")}
</Button>
</div>
{validateOut ? (
<pre className="muted" style={{ fontSize: 12, maxHeight: 120, overflow: "auto" }}>
{JSON.stringify(validateOut, null, 2)}
</pre>
) : null}
{validateOut ? <PreflightReport report={validateOut} t={t} onAddSuspects={addSuspectedPorts} /> : null}
</div>
);
@ -2515,6 +2688,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
<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}
{tpl.monitor_template_count ? <div className="muted">{t("bizCompare.templateMonitorUsage", {n: tpl.monitor_template_count})}</div> : null}
</td>
<td>
<div className="bs-cmp-metric-chips">
@ -2546,7 +2720,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
<Button
size="sm"
variant="danger"
isDisabled={busy || Boolean(tpl.job_count) || jobs.some((j) => j.template_id === tpl.id)}
isDisabled={busy || Boolean(tpl.job_count) || Boolean(tpl.monitor_template_count) || jobs.some((j) => j.template_id === tpl.id)}
onPress={() => void removeTemplate(tpl.id)}
>
{t("bizCompare.delete")}
@ -2827,6 +3001,23 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
<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>
<div className="ct-sheet-options">
<FieldSelect label={t("bizCompare.unmappedPortPolicy")} value={activeTplSheet.unmapped_port_policy || "by_name"} hint={t("bizCompare.unmappedPortPolicyHint")}
onChange={(e) => updateActiveSheet({ unmapped_port_policy: e.target.value as "by_name" | "ignore_port" })}>
<option value="by_name">{t("bizCompare.unmappedByName")}</option>
<option value="ignore_port">{t("bizCompare.unmappedIgnorePort")}</option>
</FieldSelect>
<FieldSelect label={t("bizCompare.duplicatePairing")} value={activeTplSheet.duplicate_pairing || "sorted"} hint={t("bizCompare.duplicatePairingHint")}
onChange={(e) => updateActiveSheet({ duplicate_pairing: e.target.value as "sorted" | "ordered" })}>
<option value="sorted">{t("bizCompare.pairSorted")}</option>
<option value="ordered">{t("bizCompare.pairOrdered")}</option>
</FieldSelect>
<label className="ct-sheet-options__check">
<input type="checkbox" checked={activeTplSheet.builtin_vendor_canonical !== false}
onChange={(e) => updateActiveSheet({ builtin_vendor_canonical: e.target.checked ? undefined : false })} />
<span>{t("bizCompare.builtinVendorCanonical")}</span>
</label>
</div>
{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={activeTplFilterGroups}
@ -2849,6 +3040,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
<th>{t("bizCompare.tolerance")}</th>
<th>{t("bizCompare.displayField")}</th>
<th>{t("bizCompare.normalizeField")}</th>
<th>{t("bizCompare.valueMap")}</th>
</tr>
</thead>
<tbody>
@ -2862,7 +3054,8 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
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 = ["numeric", "percent", "numeric_min", "numeric_max"].includes(cmpMode);
const vmScope = valueMapScope[f.name] || "both";
return (
<tr key={f.name}>
<td>
@ -2923,19 +3116,23 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
value={cmpMode === "ignore" ? "eq" : cmpMode}
aria-label={`${f.name} ${t("bizCompare.compareMode")}`}
disabled={!isCompare}
onChange={(e) =>
onChange={(e) => {
const mode = e.target.value;
updateActiveSheet({
field_rules: upsertFieldRule(activeTplSheet, f.name, {
compare: e.target.value,
tolerance:
e.target.value === "eq" ? undefined : rule.tolerance ?? 0,
compare: mode,
tolerance: ["eq", "not_worse"].includes(mode) ? undefined : rule.tolerance ?? 0,
rank: mode === "not_worse" ? (rule.rank?.length ? rule.rank : ["up", "down"]) : undefined,
}),
})
}
});
}}
>
<option value="eq">{t("bizCompare.modeEq")}</option>
<option value="numeric">{t("bizCompare.modeNumeric")}</option>
<option value="percent">{t("bizCompare.modePercent")}</option>
<option value="not_worse">{t("bizCompare.modeNotWorse")}</option>
<option value="numeric_min">{t("bizCompare.modeNumericMin")}</option>
<option value="numeric_max">{t("bizCompare.modeNumericMax")}</option>
</select>
</td>
<td>
@ -2951,7 +3148,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
? String(rule.tolerance)
: ""
}
placeholder={cmpMode === "percent" ? "%" : ""}
placeholder={["percent", "numeric_min", "numeric_max"].includes(cmpMode) ? "%" : ""}
disabled={!isCompare || !needsTol}
onChange={(e) => {
const raw = e.target.value.trim();
@ -2989,7 +3186,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
className="mt-select"
value={norm}
aria-label={`${f.name} ${t("bizCompare.normalizeField")}`}
disabled={!isCompare}
disabled={!isCompare && !isKey}
onChange={(e) =>
updateActiveSheet({
field_rules: upsertFieldRule(activeTplSheet, f.name, {
@ -3007,12 +3204,25 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
</option>
</select>
</td>
<td>
<ValueMapCell
field={f.name}
t={t}
scope={vmScope}
onScope={(sc) => setValueMapScope((prev) => ({ ...prev, [f.name]: sc }))}
value={valueMapFor(activeTplSheet, f.name, vmScope)}
onCommit={(map) => updateActiveSheet({ field_rules: setValueMapFor(activeTplSheet, f.name, vmScope, map) })}
showRank={cmpMode === "not_worse"}
rank={rule.rank || []}
onRank={(rank) => updateActiveSheet({ field_rules: upsertFieldRule(activeTplSheet, f.name, { compare: "not_worse", rank }) })}
/>
</td>
</tr>
);
})}
{!activeTplFields.length ? (
<tr>
<td colSpan={8}>
<td colSpan={9}>
<div className="pt-list-empty">{t("bizCompare.noMetricFields")}</div>
</td>
</tr>
@ -3767,7 +3977,11 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
{pending ? "—" : ok}
</span>
<span className="bs-cmp-nav__num bs-cmp-nav__num--rate">
{pending || rate === null ? "…" : formatPassRate(rate)}
{pending
? "…"
: (c as { data_state?: string }).data_state && (c as { data_state?: string }).data_state !== "ok"
? dataStateLabel((c as { data_state?: string }).data_state, t)
: rate === null ? "…" : formatPassRate(rate)}
</span>
</span>
</button>
@ -3777,6 +3991,19 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
</aside>
<div className="bs-cmp-main">
{activeDataState !== "ok" ? (
<p className="cx-banner" role="status" data-testid="data-state-banner">
<strong>{dataStateLabel(activeDataState, t)}</strong>{" · "}
{t(`bizCompare.stateBanner.${activeDataState}`)}
{activeSheetCard?.data_state_reasons?.length ? ` (${activeSheetCard.data_state_reasons.join(", ")})` : ""}
</p>
) : null}
{activeCoverage?.suspect_unmapped_total ? (
<p className="cx-banner" role="status" data-testid="coverage-banner">
{t("bizCompare.coverageBanner", { n: activeCoverage.suspect_unmapped_total })}
{` ${(activeCoverage.suspect_unmapped || []).slice(0, 5).join(", ")}`}
</p>
) : null}
<div
className={`bs-cmp-strip${
activeSheetPending
@ -3821,9 +4048,13 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
</span>
</span>
<span className="bs-cmp-strip__pass-value">
{activeSheetPending || activePassRate === null
{activeSheetPending
? "…"
: formatPassRate(activePassRate)}
: activeDataState !== "ok" && activePassRate === null
? dataStateLabel(activeDataState, t)
: activePassRate === null
? "…"
: formatPassRate(activePassRate)}
</span>
</div>
<div className="bs-cmp-strip__kinds" role="group">
@ -4159,6 +4390,11 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
}`}
>
{cellText(d.key?.[k] ?? pre[k] ?? post[k]) || "—"}
{d.raw_key_before?.[k] !== undefined && cellText(d.raw_key_before[k]) !== cellText(d.key?.[k]) ? (
<span className="cx-rawkey" title={t("bizCompare.rawKeyTitle")}>
← {cellText(d.raw_key_before[k])}
</span>
) : null}
</td>
))}
{resultColumns.extras

View file

@ -18,6 +18,9 @@ import {
bizMigrationEnsureHighfreq,
bizMigrationEvaluate,
bizMigrationFinishBatch,
bizMigrationGetRun,
bizMigrationPreflight,
bizMigrationResolveResolvable,
bizMigrationGetBoard,
bizMigrationListBaselineExpect,
bizMigrationListBatches,
@ -40,6 +43,8 @@ import type { CliTargetItem } from "../../types";
import { writeClipboardText } from "../../utils/clipboard";
import { pageCount } from "../../utils/display";
import { formatSystemTime, localDatetimeInputToUtcIso, utcIsoToLocalDatetimeInput } from "../../utils/time";
import { PreflightReport } from "./components/PreflightReport";
import type { PreflightReportData } from "./components/PreflightReport";
import { cutoverCachedGet, cutoverCachedGetSWR, invalidateCutoverCache } from "./lib/cutoverDataCache";
import { jobChipColor, NmStatusChip, sourceChipColor } from "../../components/NmChips";
@ -287,6 +292,8 @@ type RedTicket = {
old_status?: string;
new_status?: string;
status: string;
/** Latest acceptance saw this object turn green; the operator may close it. */
resolvable?: boolean;
evidence?: DiffRow["evidence"];
};
type SamplingPair = {
@ -318,6 +325,10 @@ type SheetCard = {
new_collected_at?: string;
duplicate_keys_before?: number;
duplicate_keys_after?: number;
duplicate_in_expect?: string[];
manual_pending?: boolean;
not_collected?: boolean;
data_state_reasons?: string[];
age_limit_sec?: number;
baseline_missing_sides?: string[];
};
@ -389,6 +400,9 @@ export function BizMigrationPage() {
const [projects, setProjects] = useState<Project[]>([]);
const [mappings, setMappings] = useState<PortMapping[]>([]);
const [busy, setBusy] = useState(false);
// Background evaluate / acceptance: text shown while the run is being computed.
const [evalProgress, setEvalProgress] = useState("");
const [preflight, setPreflight] = useState<PreflightReportData | null>(null);
const [listKeyword, setListKeyword] = useState("");
const debouncedListKw = useDebouncedValue(listKeyword, 250);
@ -462,6 +476,9 @@ export function BizMigrationPage() {
verdict_counts?: Record<string, number>;
duplicate_keys?: number;
uncovered_scope?: string[];
duplicate_warnings?: Array<{ sheet_id?: string; title?: string; count: number; examples?: string[] }>;
manual_pending_metrics?: string[];
not_collected_metrics?: string[];
};
} | null;
} | null>(null);
@ -1434,18 +1451,53 @@ export function BizMigrationPage() {
}
}
/**
* Evaluate / acceptance run in the background on the server. Poll the run until it settles;
* a failed or interrupted run is reported with the server's reason.
*/
async function waitForRun(runId: string, label: string): Promise<Record<string, unknown>> {
const started = Date.now();
for (;;) {
const run = (await bizMigrationGetRun(runId)) as {
status?: string;
message?: string;
summary?: { progress?: { sheet_index?: number; sheet_total?: number; sheet_title?: string } };
};
if (run.status && run.status !== "running") {
setEvalProgress("");
if (run.status !== "success") throw new Error(run.message || run.status);
return run as Record<string, unknown>;
}
const p = run.summary?.progress;
setEvalProgress(
p?.sheet_total
? t("bizMigration.evalProgress", { label, i: String(p.sheet_index ?? 0), n: String(p.sheet_total), title: p.sheet_title || "" })
: t("bizMigration.evalQueued", { label }),
);
if (Date.now() - started > 30 * 60 * 1000) throw new Error("evaluation_timeout");
await new Promise((resolve) => setTimeout(resolve, 1500));
}
}
async function setBatchStatus(status: string) {
if (!batchId) return;
setBusy(true);
try {
if (status === "review" || status === "done") {
const res = (await bizMigrationFinishBatch(batchId, status === "done")) as {
let res = (await bizMigrationFinishBatch(batchId, status === "done")) as {
run_id?: string;
batch?: MigBatch;
accept_summary?: MigBatch["accept_summary"];
open_red_count?: number;
};
if (res.run_id && !res.accept_summary) {
await waitForRun(res.run_id, t("bizMigration.finishBatch"));
}
const list = await bizMigrationListBatches(projectId);
setBatches((list.items || []) as MigBatch[]);
const items = (list.items || []) as MigBatch[];
setBatches(items);
const closed = items.find((b) => b.id === batchId) || res.batch;
res = { ...res, batch: closed, accept_summary: res.accept_summary || closed?.accept_summary };
setAcceptInfo(res.accept_summary || res.batch?.accept_summary || null);
await loadRedTickets(projectId);
if (res.accept_summary?.passed) {
@ -1494,17 +1546,44 @@ export function BizMigrationPage() {
if (pinNewBatchId) body.new_batch_id = pinNewBatchId;
const run = await bizMigrationEvaluate(batchId, body);
const rid = String((run as { id?: string }).id || "");
if ((run as { status?: string }).status === "running" && rid) {
await waitForRun(rid, t("bizMigration.evaluate"));
}
await loadRuns(batchId);
await loadBoard(batchId, rid);
showOk(t("bizMigration.evaluated"));
setDetailTab("board");
} catch (e) {
setEvalProgress("");
showError(mapMigrationApiErr(e, t));
} finally {
setBusy(false);
}
}
async function onPreflight() {
if (!projectId) return;
setBusy(true);
try {
setPreflight((await bizMigrationPreflight(projectId)) as unknown as PreflightReportData);
} catch (e) {
showError(mapMigrationApiErr(e, t));
} finally {
setBusy(false);
}
}
async function onResolveResolvable() {
if (!projectId) return;
try {
const out = (await bizMigrationResolveResolvable(projectId)) as { closed?: number };
await loadRedTickets(projectId);
showOk(t("bizMigration.redResolvableClosed", { n: String(out.closed ?? 0) }));
} catch (e) {
showError(formatErr(e));
}
}
async function onEnsureHighfreq() {
if (!projectId) return;
if (!collectMetricIds.length) {
@ -2323,6 +2402,7 @@ export function BizMigrationPage() {
{t("bizMigration.evaluate")}
</Button>
) : null}
{evalProgress ? <span className="muted" role="status" data-testid="eval-progress">{evalProgress}</span> : null}
<Button
size="sm"
variant="secondary"
@ -2436,6 +2516,14 @@ export function BizMigrationPage() {
) : null}
</div>
</div>
<p className="muted bm-hint" data-testid="port-success-hint">{t("bizMigration.portSuccessHint")}</p>
<div className="bm-preflight">
<Button size="sm" variant="secondary" isDisabled={busy || !project.old_baseline_batch_id || !project.new_baseline_batch_id} onPress={() => void onPreflight()}>
{t("bizMigration.preflight")}
</Button>
<span className="muted bm-hint">{t("bizMigration.preflightHint")}</span>
{preflight ? <PreflightReport report={preflight} t={t} /> : null}
</div>
<div className="bm-hf-collect">
<div className="bm-mapping__label">{t("bizMigration.projectCollectMetrics")}</div>
<p className="muted bm-hint">{t("bizMigration.pairCollectionHint")}</p>
@ -3000,6 +3088,27 @@ export function BizMigrationPage() {
})}
</div>
) : null}
{sheetCards.some((c) => c.manual_pending) ? (
<div className="muted bm-hint" data-testid="manual-pending-banner">
{t("bizMigration.manualPendingBanner", {
metrics: sheetCards.filter((c) => c.manual_pending).map((c) => sheetLabel(c)).join(", "),
})}
</div>
) : null}
{sheetCards.some((c) => c.not_collected) ? (
<div className="muted bm-hint" data-testid="not-collected-banner">
{t("bizMigration.notCollectedBanner", {
metrics: sheetCards.filter((c) => c.not_collected).map((c) => `${sheetLabel(c)} (${(c.data_state_reasons || []).join(", ")})`).join("; "),
})}
</div>
) : null}
{liveSummary?.duplicate_warnings?.length ? (
<div className="muted bm-hint" data-testid="duplicate-warning-banner">
{t("bizMigration.duplicateWarningBanner", {
metrics: liveSummary.duplicate_warnings.map((w) => `${w.title || w.sheet_id} (${w.count})`).join(", "),
})}
</div>
) : null}
{sheetCards.some((c) => c.current_missing) ? (
<div className="form-error bm-hint">
{t("bizMigration.currentMissingBanner", {
@ -3036,6 +3145,14 @@ export function BizMigrationPage() {
{boardSubTab === "red" ? (
<div className="pt-list-table-wrap bm-board__table">
{redTickets.some((r) => r.resolvable && r.status !== "resolved") ? (
<div className="bm-hint" role="status">
<span>{t("bizMigration.redResolvableHint", { n: String(redTickets.filter((r) => r.resolvable && r.status !== "resolved").length) })}</span>{" "}
<Button size="sm" variant="secondary" onPress={() => void onResolveResolvable()}>
{t("bizMigration.redResolveAllResolvable")}
</Button>
</div>
) : null}
<table className="data-table pt-list-table">
<thead>
<tr>
@ -3060,7 +3177,10 @@ export function BizMigrationPage() {
<td>{r.old_status || "—"}</td>
<td>{r.new_status || "—"}</td>
<td style={{ color: COLOR.red, fontWeight: 600 }}>{verdictLabel(r.verdict)}</td>
<td>{r.status}</td>
<td>
{r.status}
{r.resolvable && r.status !== "resolved" ? <span className="muted"> · {t("bizMigration.redNowGreen")}</span> : null}
</td>
<td>
{r.status !== "resolved" ? (
<Button
@ -3109,7 +3229,8 @@ export function BizMigrationPage() {
const active = boardMetricId === sid;
const skipped = Boolean(c.collect_skipped);
const noCurrent = Boolean(c.current_missing);
const mutedCard = skipped || noCurrent;
const pending = Boolean(c.manual_pending || c.not_collected);
const mutedCard = skipped || noCurrent || pending;
const hot =
!mutedCard && ((c.anomaly || 0) > 0 || Boolean(c.new_baseline_missing));
return (
@ -3127,7 +3248,9 @@ export function BizMigrationPage() {
<span className="bs-cmp-nav__dot" />
<span className="bs-cmp-nav__name">
{sheetLabel(c)}
{skipped
{pending
? ` · ${t(c.manual_pending ? "bizCompare.dataState.manual_pending" : "bizCompare.dataState.not_collected")}`
: skipped
? ` · ${t("bizMigration.collectSkipped")}`
: noCurrent
? ` · ${t("bizMigration.currentMissing")}`

View file

@ -0,0 +1,110 @@
import { useMemo } from "react";
type T = (key: string, vars?: Record<string, string | number>) => string;
type Line = { index: number; before: string; after: string };
/** Lenient parse: a half-filled `port,` line is a row waiting for its target. */
function readLines(text: string): Line[] {
const out: Line[] = [];
text.split(/\r?\n/).forEach((raw, index) => {
const s = raw.trim();
if (!s || s.startsWith("#")) return;
const delimited = /[,|\t]/.test(s);
const parts = (delimited ? s.split(/[,|\t]+/) : s.split(/\s+/)).map((x) => x.trim());
out.push({ index, before: parts[0] || "", after: parts.slice(1).join(" ").trim() });
});
return out;
}
/**
* Table view over the mapping text. The text stays the single source of truth, so
* comments, order and the "save" flow are shared with the text editor.
*/
export function MappingTable({ text, onChange, t }: { text: string; onChange: (next: string) => void; t: T }) {
const rows = useMemo(() => readLines(text), [text]);
const seen = new Map<string, number>();
rows.forEach((r) => seen.set(r.before, (seen.get(r.before) || 0) + 1));
const replaceLine = (index: number, line: string | null) => {
const lines = text.split(/\r?\n/);
if (line === null) lines.splice(index, 1);
else lines[index] = line;
onChange(lines.join("\n"));
};
return (
<div className="cx-maptable" data-testid="mapping-table">
<table className="data-table pt-list-table">
<thead>
<tr>
<th>{t("bizCompare.mapTable.before")}</th>
<th>{t("bizCompare.mapTable.after")}</th>
<th>{t("bizCompare.mapTable.status")}</th>
<th />
</tr>
</thead>
<tbody>
{rows.map((r) => {
const status = !r.before
? "invalid"
: (seen.get(r.before) || 0) > 1
? "duplicate"
: !r.after
? "todo"
: "ok";
return (
<tr key={r.index} className={`cx-maptable__row is-${status}`}>
<td>
<input
className="ui-field__input cx-maptable__input"
aria-label={`${t("bizCompare.mapTable.before")} ${r.index + 1}`}
value={r.before}
onChange={(e) => replaceLine(r.index, `${e.target.value},${r.after}`)}
/>
</td>
<td>
<input
className="ui-field__input cx-maptable__input"
aria-label={`${t("bizCompare.mapTable.after")} ${r.index + 1}`}
value={r.after}
onChange={(e) => replaceLine(r.index, `${r.before},${e.target.value}`)}
/>
</td>
<td>
<span className={`cx-state cx-state--${status === "ok" ? "ok" : status === "todo" ? "no_data" : "suspect_parse"}`}>
{t(`bizCompare.mapTable.${status}`)}
</span>
</td>
<td>
<button
type="button"
className="mt-icon-btn"
aria-label={`${t("bizCompare.mapTable.remove")} ${r.index + 1}`}
onClick={() => replaceLine(r.index, null)}
>
×
</button>
</td>
</tr>
);
})}
{!rows.length ? (
<tr>
<td colSpan={4} className="muted">
{t("bizCompare.mapTable.empty")}
</td>
</tr>
) : null}
</tbody>
</table>
<button
type="button"
className="mt-link-btn"
onClick={() => onChange(`${text.replace(/\s+$/, "")}${text.trim() ? "\n" : ""},`)}
>
{t("bizCompare.mapTable.add")}
</button>
</div>
);
}

View file

@ -0,0 +1,205 @@
import type { ReactNode } from "react";
type T = (key: string, vars?: Record<string, string | number>) => string;
export type PreflightSheet = {
sheet_id: string;
title?: string;
metric_id: string;
before_count?: number;
after_count?: number;
skipped?: string;
data_state?: string;
data_state_reasons?: string[];
counts?: Record<string, number>;
mapped_exact?: string[];
mapped_by_parent?: string[];
identity_same_name?: string[];
suspect_unmapped?: string[];
suspect_unmapped_total?: number;
mapped_target_missing_on_after?: string[];
};
export type PreflightReportData = {
verdict: "ready" | "risk";
risk_reasons: string[];
coverage: { mapped: number; suspect_unmapped: number; identity_same_name: number; rate: number | null };
mapping: {
pairs: number;
conflicts?: Array<Record<string, string>>;
duplicate_before?: Array<Record<string, string>>;
unused_map_keys?: string[];
unused_map_keys_total?: number;
map_targets_missing_on_after?: string[];
map_targets_missing_on_after_total?: number;
n_to_1_targets?: Array<{ target: string; sources: string[] }>;
};
sheets: PreflightSheet[];
command_coverage?: { only_before?: string[]; only_after?: string[] };
};
export function dataStateLabel(state: string | undefined, t: T): string {
return t(`bizCompare.dataState.${state || "ok"}`);
}
function List({ items, total, children }: { items?: string[]; total?: number; children?: ReactNode }) {
if (!items?.length) return null;
const more = (total ?? items.length) - items.length;
return (
<ul className="cx-preflight__list">
{items.map((x) => (
<li key={x}>
<code>{x}</code>
</li>
))}
{more > 0 ? <li className="muted">+{more}</li> : null}
{children}
</ul>
);
}
/**
* Pre-flight result shared by the compare page ("validate mapping") and the cutover
* project: mapping coverage first, then per-sheet data state and details.
*/
export function PreflightReport({
report,
t,
onAddSuspects,
}: {
report: PreflightReportData;
t: T;
/** Compare editor only: append unmapped before-side ports as empty rows to the mapping text. */
onAddSuspects?: (ports: string[]) => void;
}) {
const m = report.mapping;
const suspects = report.sheets.flatMap((s) => s.suspect_unmapped || []);
const issues: Array<[string, ReactNode]> = [];
if (m.duplicate_before?.length) {
issues.push([
"duplicate",
<List key="d" items={m.duplicate_before.map((d) => `${d.before_if} → ${d.kept_to} / ${d.dropped_to}`)} />,
]);
}
if (m.conflicts?.length) {
issues.push([
"conflicts",
<List key="c" items={m.conflicts.map((c) => `${c.dropped_from} ↔ ${c.kept_from}`)} />,
]);
}
if (m.unused_map_keys?.length) {
issues.push(["unused", <List key="u" items={m.unused_map_keys} total={m.unused_map_keys_total} />]);
}
if (m.map_targets_missing_on_after?.length) {
issues.push([
"missingTargets",
<List key="t" items={m.map_targets_missing_on_after} total={m.map_targets_missing_on_after_total} />,
]);
}
if (m.n_to_1_targets?.length) {
issues.push([
"nTo1",
<List key="n" items={m.n_to_1_targets.map((x) => `${x.sources.join(", ")} → ${x.target}`)} />,
]);
}
const rate = report.coverage.rate;
return (
<div className={`cx-preflight is-${report.verdict}`} data-testid="preflight-report">
<div className="cx-preflight__head" role="status">
<strong>{t(`bizCompare.preflight.${report.verdict}`)}</strong>
<span>
{t("bizCompare.preflight.coverage", {
mapped: report.coverage.mapped,
suspect: report.coverage.suspect_unmapped,
rate: rate === null ? "—" : `${rate}%`,
})}
</span>
<span className="muted">{t("bizCompare.preflight.pairs", { n: m.pairs })}</span>
</div>
{suspects.length ? (
<div className="cx-preflight__block">
<h5>{t("bizCompare.preflight.suspect")}</h5>
<List items={suspects.slice(0, 200)} total={report.coverage.suspect_unmapped} />
{onAddSuspects ? (
<button type="button" className="mt-link-btn" onClick={() => onAddSuspects(suspects)}>
{t("bizCompare.preflight.addSuspects")}
</button>
) : null}
</div>
) : null}
{issues.map(([key, body]) => (
<div className="cx-preflight__block" key={key}>
<h5>{t(`bizCompare.preflight.issue.${key}`)}</h5>
{body}
</div>
))}
{report.command_coverage?.only_before?.length || report.command_coverage?.only_after?.length ? (
<div className="cx-preflight__block">
<h5>{t("bizCompare.preflight.commandDiff")}</h5>
<List items={(report.command_coverage.only_before || []).map((x) => `${t("bizCompare.scopeBefore")}: ${x}`)} />
<List items={(report.command_coverage.only_after || []).map((x) => `${t("bizCompare.scopeAfter")}: ${x}`)} />
</div>
) : null}
<div className="cx-preflight__scroll">
<table className="data-table pt-list-table cx-preflight__table">
<thead>
<tr>
<th>{t("bizCompare.field")}</th>
<th>{t("bizCompare.preflight.rows")}</th>
<th>{t("bizCompare.preflight.state")}</th>
<th>{t("bizCompare.preflight.mapping")}</th>
</tr>
</thead>
<tbody>
{report.sheets.map((s) => {
const state = s.data_state || "ok";
const c = s.counts || {};
return (
<tr key={s.sheet_id}>
<td>
<code>{s.title || s.sheet_id}</code>
</td>
<td>{`${s.before_count ?? 0} → ${s.after_count ?? 0}`}</td>
<td>
<span className={`cx-state cx-state--${state}`}>{dataStateLabel(state, t)}</span>
{s.data_state_reasons?.length ? (
<div className="muted cx-state__why">{s.data_state_reasons.join(" · ")}</div>
) : null}
</td>
<td>
{s.skipped ? (
<span className="muted">{t("bizCompare.preflight.skippedHeavy")}</span>
) : s.counts ? (
<details>
<summary>
{t("bizCompare.preflight.sheetCounts", {
exact: (c.mapped_exact || 0) + (c.mapped_by_parent || 0),
same: c.identity_same_name || 0,
suspect: s.suspect_unmapped_total ?? c.suspect_unmapped ?? 0,
})}
</summary>
<List items={s.suspect_unmapped} total={s.suspect_unmapped_total} />
{s.mapped_target_missing_on_after?.length ? (
<>
<h6>{t("bizCompare.preflight.issue.missingTargets")}</h6>
<List items={s.mapped_target_missing_on_after} />
</>
) : null}
</details>
) : (
<span className="muted">—</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
}

View file

@ -10,14 +10,28 @@ export type MetricSheet = {
row_filters?: RowFilter[];
field_rules?: FieldRule[];
ignore_port_changes?: boolean | null;
/** Partial port map: unmapped ports match by name (default) or by content only. */
unmapped_port_policy?: "" | "by_name" | "ignore_port";
/** Same-key rows pair by value (default for new runs) or by collection order. */
duplicate_pairing?: "" | "sorted" | "ordered";
/** false = skip the built-in per-vendor interface names (GigabitEthernet → GE …). */
builtin_vendor_canonical?: boolean;
};
export type Side = "" | "both" | "before" | "after";
export type FieldRule = {
field: string;
compare?: string;
normalize?: string;
ignore?: boolean;
tolerance?: number;
/** Cross-vendor value aliases, e.g. {"estab": "established"}; matched case-insensitively. */
value_map?: Record<string, string>;
side?: Side;
apply_to_key?: boolean;
/** Order for compare=not_worse: earlier entries are better. */
rank?: string[];
};
export type RowFilter = {
@ -26,13 +40,50 @@ export type RowFilter = {
value?: string | number | boolean | (string | number | boolean)[];
any?: RowFilter[];
all?: RowFilter[];
/** Only evaluate this filter on one side of the comparison. */
side?: Side;
};
export type IfaceRule = { from: string; to: string; side?: Side; vendor?: string };
export const DIRECTIONAL_COMPARE = ["not_worse", "numeric_min", "numeric_max"] as const;
export const MAX_VALUE_MAP = 500;
const SHEET_OPTION_KEYS = ["unmapped_port_policy", "duplicate_pairing", "builtin_vendor_canonical"] as const;
/** The sheet exactly as the API stores it: every identity/compare setting survives a round trip. */
export function sheetPayload(s: MetricSheet, identity?: string): Record<string, unknown> {
const id = identity ?? (s.sheet_id || s.metric_id);
const out: Record<string, unknown> = {
sheet_id: id,
title: s.title || id,
metric_id: s.metric_id,
key_fields: s.key_fields,
iface_fields: s.iface_fields,
compare_fields: s.compare_fields,
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,
};
for (const key of SHEET_OPTION_KEYS) {
const value = s[key];
if (value !== undefined && value !== "") out[key] = value;
}
return out;
}
export function sheetOptions(raw: Record<string, unknown>): Partial<MetricSheet> {
const out: Record<string, unknown> = {};
for (const key of SHEET_OPTION_KEYS) if (raw[key] !== undefined && raw[key] !== null) out[key] = raw[key];
return out as Partial<MetricSheet>;
}
export function parseTemplateImport(raw: unknown): {
name: string;
note: string;
metrics: MetricSheet[];
iface_normalize_rules?: { from: string; to: string }[];
iface_normalize_rules?: IfaceRule[];
} | null {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
const o = raw as Record<string, unknown>;
@ -89,11 +140,12 @@ export function parseTemplateImport(raw: unknown): {
row_filters: (m.row_filters ?? []) as RowFilter[],
field_rules: fieldRules,
ignore_port_changes: m.ignore_port_changes as boolean | null | undefined,
...sheetOptions(m),
});
}
if (!metrics.length) return null;
const normRaw = o.iface_normalize_rules;
const iface_normalize_rules: { from: string; to: string }[] = [];
const iface_normalize_rules: IfaceRule[] = [];
if (Array.isArray(normRaw)) {
for (const item of normRaw) {
if (!item || typeof item !== "object") throw new Error("iface_normalize_rules: alias_pair_required");
@ -101,8 +153,13 @@ export function parseTemplateImport(raw: 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 });
const rule: IfaceRule = { from: fr, to };
const side = String(r.side || "").trim().toLowerCase();
if (side === "before" || side === "after") rule.side = side;
const vendor = String(r.vendor || "").trim().toLowerCase();
if (vendor) rule.vendor = vendor;
if (iface_normalize_rules.some((x) => sameAlias(x, rule))) throw new Error("iface_normalize_rules: duplicate_alias");
iface_normalize_rules.push(rule);
}
}
if (normRaw != null && !Array.isArray(normRaw)) throw new Error("iface_normalize_rules: list_required");
@ -110,35 +167,54 @@ export function parseTemplateImport(raw: unknown): {
return { name, note, metrics, iface_normalize_rules };
}
export function parseIfaceNormText(text: string): { from: string; to: string }[] {
const out: { from: string; to: string }[] = [];
function sameAlias(a: IfaceRule, b: IfaceRule): boolean {
return a.from.toLowerCase() === b.from.toLowerCase() && (a.side || "") === (b.side || "") && (a.vendor || "") === (b.vendor || "");
}
/** `from,to[,side[,vendor]]` per line; side is before/after, vendor a vendor key such as huawei. */
export function parseIfaceNormText(text: string): IfaceRule[] {
const out: IfaceRule[] = [];
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(" ");
}
}
const parts = (s.includes(",") ? s.split(",") : s.includes("\t") ? s.split("\t") : s.split(/\s+/)).map((x) => x.trim());
const [fr = "", to = "", side = "", vendor = ""] = parts;
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 });
const sideN = side.toLowerCase();
if (sideN && !["before", "after", "both"].includes(sideN)) throw new Error(`iface_normalize_rules:${index + 1}: unknown_alias_side`);
const rule: IfaceRule = { from: fr, to };
if (sideN === "before" || sideN === "after") rule.side = sideN;
if (vendor) rule.vendor = vendor.toLowerCase();
if (out.some((x) => sameAlias(x, rule))) throw new Error(`iface_normalize_rules:${index + 1}: duplicate_alias`);
out.push(rule);
}
return out;
}
export function formatIfaceNormText(rules?: IfaceRule[]): string {
return (rules || [])
.map((r) => [r.from, r.to, r.side || (r.vendor ? "both" : ""), r.vendor || ""].filter((x, i, arr) => x || i < 2 || arr.slice(i).some(Boolean)).join(","))
.join("\n");
}
/** `source,canonical` per line (blank lines and # comments ignored) → value_map. */
export function parseValueMapText(text: string): Record<string, string> {
const out: Record<string, string> = {};
for (const [index, line] of text.split(/\r?\n/).entries()) {
const s = line.trim();
if (!s || s.startsWith("#")) continue;
const parts = (s.includes(",") ? s.split(",") : s.includes("\t") ? s.split("\t") : s.split(/\s+/)).map((x) => x.trim());
if (parts.length < 2 || !parts[0] || !parts[1]) throw new Error(`value_map:${index + 1}: pair_required`);
out[parts[0]] = parts.slice(1).join(" ");
}
if (Object.keys(out).length > MAX_VALUE_MAP) throw new Error("value_map: too_large");
return out;
}
export function formatValueMapText(map?: Record<string, string>): string {
return Object.entries(map || {}).map(([k, v]) => `${k},${v}`).join("\n");
}
function isLeafFilter(f: RowFilter): boolean {
return Boolean(f && !f.any && !f.all && f.field);
}
@ -182,9 +258,17 @@ export function validateTemplateDraft(metrics: MetricSheet[]): void {
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`);
// one rule per (field, side): a field may carry a different value_map on each side
const marker = `${r?.field?.trim()}\u0000${r?.side === "before" || r?.side === "after" ? r.side : ""}`;
if (!r?.field?.trim() || fields.has(marker)) throw new Error(`${path}.field_rules: invalid_or_duplicate_field`);
fields.add(marker);
if (!["", "eq", "ignore", "skip", "off", "numeric", "number", "int", "float", "percent", "pct", "rel", ...DIRECTIONAL_COMPARE].includes((r.compare || "").toLowerCase())) throw new Error(`${path}.field_rules: unknown_compare_mode`);
if ((r.compare || "").toLowerCase() === "not_worse" && (!Array.isArray(r.rank) || r.rank.length < 2)) throw new Error(`${path}.${r.field}: rank_list_required`);
if (r.value_map != null) {
const entries = Object.entries(r.value_map);
if (typeof r.value_map !== "object" || entries.some(([k, v]) => typeof k !== "string" || typeof v !== "string")) throw new Error(`${path}.${r.field}: value_map_string_pairs_required`);
if (entries.length > MAX_VALUE_MAP) throw new Error(`${path}.${r.field}: value_map_too_large`);
}
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`);
}
@ -219,6 +303,7 @@ export function fromOrGroups(groups: RowFilter[][]): RowFilter[] {
field: f.field || "",
op: f.op || "eq",
value: f.value ?? "",
...(f.side === "before" || f.side === "after" ? { side: f.side } : {}),
}))
.filter((f) => f.field),
)

View file

@ -0,0 +1,52 @@
export type MapRow = { before_if: string; after_if: string };
export type MapParse = {
rows: MapRow[];
/** 1-based line numbers that could not be read as a "before, after" pair. */
invalidLines: number[];
/** before_if values that appear on more than one line (first one is kept). */
duplicateBefore: string[];
};
const HEADER = /^(old|before)\w*[\s,|]+(new|after)/i;
/**
* Parse the port-mapping editor text. Pairs are split on `,` `|` or Tab; a line
* with exactly two whitespace-separated tokens is accepted too. Anything else is
* reported in `invalidLines` instead of being dropped silently.
*/
export function parseMapText(text: string): MapParse {
const rows: MapRow[] = [];
const invalidLines: number[] = [];
const duplicateBefore: string[] = [];
const seen = new Set<string>();
String(text ?? "").split(/\r?\n/).forEach((line, index) => {
const s = line.trim();
if (!s || s.startsWith("#") || HEADER.test(s)) return;
const delimited = /[,|\t]/.test(s);
const parts = (delimited ? s.split(/[,|\t]+/) : s.split(/\s+/)).map((x) => x.trim()).filter(Boolean);
if (parts.length !== 2) {
invalidLines.push(index + 1);
return;
}
const [before_if, after_if] = parts;
if (seen.has(before_if)) {
if (!duplicateBefore.includes(before_if)) duplicateBefore.push(before_if);
return;
}
seen.add(before_if);
rows.push({ before_if, after_if });
});
return { rows, invalidLines, duplicateBefore };
}
const pairKey = (r: MapRow) => `${r.before_if}\u0000${r.after_if}`;
/** True when the editor text differs from the saved rows (order-insensitive). */
export function mapTextDirty(text: string, saved: MapRow[] | undefined): boolean {
const parsed = parseMapText(text);
if (parsed.invalidLines.length || parsed.duplicateBefore.length) return true;
const a = parsed.rows.map(pairKey).sort();
const b = (saved ?? []).map(pairKey).sort();
return a.length !== b.length || a.some((v, i) => v !== b[i]);
}