mirror of
https://github.com/hansjone/netx.git
synced 2026-10-15 18:23:18 +08:00
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:
parent
fff7d63b9b
commit
315539e0fd
59 changed files with 6483 additions and 487 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")}`
|
||||
|
|
|
|||
110
web/src/features/cutover/components/MappingTable.tsx
Normal file
110
web/src/features/cutover/components/MappingTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
205
web/src/features/cutover/components/PreflightReport.tsx
Normal file
205
web/src/features/cutover/components/PreflightReport.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
|
|
|
|||
52
web/src/features/cutover/lib/mappingText.ts
Normal file
52
web/src/features/cutover/lib/mappingText.ts
Normal 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]);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue