mirror of
https://github.com/hansjone/netx.git
synced 2026-10-14 17:20:46 +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]);
|
||||
}
|
||||
|
|
@ -67,7 +67,7 @@ const bizCompare = {
|
|||
rowFilters: "Row filter rules",
|
||||
rowFiltersHint:
|
||||
"Filter rows before compare. Conditions in a group are AND; groups are OR. Selecting ARP seeds editable default rules you can change.",
|
||||
rowFiltersHintShort: "AND in group · OR across groups · empty = all rows",
|
||||
rowFiltersHintShort: "AND in group · OR across groups · empty = all rows · filters run after normalization: write canonical values",
|
||||
rowFiltersEmpty: "No filters → compare all rows.",
|
||||
filterClear: "Clear",
|
||||
filterAddOr: "Add group (OR)",
|
||||
|
|
@ -111,12 +111,82 @@ const bizCompare = {
|
|||
validateNeed: "Select mapping and before/after batches first",
|
||||
mapHint:
|
||||
"One pair per line: before_if,after_if (comma / tab / pipe). Example:\ngei-0/1/1,gei-0/1/2\nxgei-0/1/0,xgei-0/2/0\n# comments start with #. Not slash-grouped A,B/C,D.",
|
||||
templateMonitorUsage: "Used by {{n}} monitor templates",
|
||||
mapParsed: "{{n}} pairs parsed",
|
||||
mapInvalidLines: "{{m}} invalid (line {{lines}})",
|
||||
mapDuplicate: "duplicate before port: {{list}}",
|
||||
mapUnsaved: "unsaved",
|
||||
mapInvalid: "Mapping text has errors and was not saved. Invalid lines: {{lines}}; duplicate before ports: {{dup}}",
|
||||
mapEmpty: "Mapping text is empty. Pick \"New mapping…\" if you want no mapping, otherwise restore the pairs before saving.",
|
||||
mapSharedConfirm: "This mapping is also used by {{jobs}} other compare jobs and {{projects}} cutover projects; saving affects them too. Continue?",
|
||||
mappingAutoSaved: "Mapping saved automatically",
|
||||
dataState: {
|
||||
"ok": "OK",
|
||||
"no_data": "No data",
|
||||
"suspect_parse": "Suspect parse",
|
||||
"not_collected": "Not collected",
|
||||
"manual_pending": "Manual pending"
|
||||
},
|
||||
stateBanner: {
|
||||
"no_data": "Both sides are empty: nothing to compare, excluded from the pass rate.",
|
||||
"suspect_parse": "The device printed output but 0 rows were parsed. Failures may be a parsing problem rather than lost business; check the parser/template first.",
|
||||
"not_collected": "One side never collected this check (or the slice). Not judged, excluded from the pass rate."
|
||||
},
|
||||
rawKeyTitle: "Before-side name as printed by the device",
|
||||
scopeBoth: "Both sides",
|
||||
scopeBefore: "Before only",
|
||||
scopeAfter: "After only",
|
||||
valueMap: "Value map",
|
||||
valueMapScope: "value map scope",
|
||||
valueMapPlaceholder: "One per line: source,canonical\nestab,established",
|
||||
rankLabel: "best→worst",
|
||||
rankPlaceholder: "up,down (earlier is better)",
|
||||
modeNotWorse: "Not worse",
|
||||
modeNumericMin: "Not below (%)",
|
||||
modeNumericMax: "Not above (%)",
|
||||
filterSide: "filter side",
|
||||
unmappedPortPolicy: "Unmapped ports",
|
||||
unmappedByName: "Match by name (default)",
|
||||
unmappedIgnorePort: "Ignore port, match by content",
|
||||
unmappedPortPolicyHint: "With a partial port map, how ports that have no mapping are paired.",
|
||||
duplicatePairing: "Same-key pairing",
|
||||
pairSorted: "Pair by sorted value (recommended)",
|
||||
pairOrdered: "Pair by collection order (legacy)",
|
||||
duplicatePairingHint: "When one key has several rows (ECMP, multipath), CLI ordering changes must not create false differences.",
|
||||
builtinVendorCanonical: "Built-in per-vendor interface names (GigabitEthernet↔GE …)",
|
||||
mappingNeedBatches: "Pick the before and after batches first",
|
||||
preflight: {
|
||||
"run": "Validate mapping",
|
||||
"ready": "Mapping looks usable",
|
||||
"risk": "Risks found — fix before running",
|
||||
"coverage": "{{mapped}} mapped · {{suspect}} suspected unmapped · coverage {{rate}}",
|
||||
"pairs": "{{n}} map pairs",
|
||||
"suspect": "Suspected unmapped (on before, no mapping, absent on after)",
|
||||
"addSuspects": "Add to the mapping editor (targets to fill in)",
|
||||
"commandDiff": "Collected commands differ between sides",
|
||||
"rows": "Rows (before→after)",
|
||||
"state": "Data state",
|
||||
"mapping": "Mapping",
|
||||
"skippedHeavy": "Large table, interfaces not enumerated",
|
||||
"sheetCounts": "{{exact}} mapped · {{same}} same name · {{suspect}} suspected unmapped",
|
||||
"issue": {
|
||||
"duplicate": "Duplicate before port (first one wins)",
|
||||
"conflicts": "Before ports that collide after normalizing",
|
||||
"unused": "Map keys that match no interface",
|
||||
"missingTargets": "Map targets missing on the after device",
|
||||
"nTo1": "Several before ports map to one target (N:1)"
|
||||
}
|
||||
},
|
||||
coverageBanner: "Mapping coverage: {{n}} before-side ports have no mapping and do not exist on the after device; the gaps may be missing map rows: ",
|
||||
mapViewText: "Text",
|
||||
mapViewTable: "Table",
|
||||
mapTable: {"before": "Before port", "after": "After port", "status": "Status", "remove": "Remove row", "add": "Add row", "empty": "No mapping rows yet", "ok": "OK", "todo": "Target to fill in", "duplicate": "Duplicate before port", "invalid": "Before port missing"},
|
||||
mappingSaved: "Mapping saved",
|
||||
mappingOptionalHint:
|
||||
"Optional. Empty = ignore local port renames (match on non-interface keys). Fill to apply port map. Main-port pairs also cover .subinterfaces (suffix kept).",
|
||||
ifaceNormalize: "Interface normalize",
|
||||
ifaceNormalizeHint:
|
||||
"Template rules, one per line: alias,canonical. Applied before port mapping. Example:\nGE,gei\nSG,smartgroup\nXGE,xgei",
|
||||
"Template rules, one per line: alias,canonical. Applied before port mapping. Example:; optional 3rd/4th columns scope a rule: before/after and vendor, e.g. Eth-Trunk,ae,after,huawei\nGE,gei\nSG,smartgroup\nXGE,xgei",
|
||||
createJob: "Create compare job",
|
||||
defaultJobName: "Cutover compare",
|
||||
jobName: "Name",
|
||||
|
|
|
|||
|
|
@ -203,6 +203,18 @@ const bizMigration = {
|
|||
startBatch: "Start",
|
||||
finishBatch: "Finish",
|
||||
evaluate: "Evaluate",
|
||||
evalProgress: "{{label}} {{i}}/{{n}} {{title}}",
|
||||
evalQueued: "{{label}} queued…",
|
||||
preflight: "Mapping pre-flight",
|
||||
preflightHint: "Checks mapping coverage and collection differences on the two portrait baselines before the window.",
|
||||
manualPendingBanner: "Waiting for manual collection (not part of the acceptance gate): {{metrics}}",
|
||||
notCollectedBanner: "Collected differently on the two devices, not judged (not red): {{metrics}}",
|
||||
duplicateWarningBanner: "Duplicate keys outside this wave (do not block acceptance; refine key fields): {{metrics}}",
|
||||
redResolvableHint: "{{n}} red tickets belong to objects that turned green in the latest acceptance.",
|
||||
redResolveAllResolvable: "Close all that turned green",
|
||||
redNowGreen: "now green",
|
||||
redResolvableClosed: "{{n}} red tickets closed",
|
||||
portSuccessHint: "A port counts as migrated when the old port is gone, physically down or admin down (any one) and the new port is admin and physically up. Pulling the fibre without shutdown counts.",
|
||||
evaluated: "Done",
|
||||
statusUpdated: "Status updated",
|
||||
board: "Board",
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ const bizCompare = {
|
|||
rowFilters: "行过滤规则",
|
||||
rowFiltersHint:
|
||||
"比对前按规则筛选行。组内条件为「且」,组与组之间为「或」。勾选 ARP 等监控项时会带上默认可编辑规则,可按需改删。",
|
||||
rowFiltersHintShort: "组内且 · 组间或 · 无过滤则比对全部行",
|
||||
rowFiltersHintShort: "组内且 · 组间或 · 无过滤则比对全部行 · 过滤在归一化之后执行,请按规范值书写",
|
||||
rowFiltersEmpty: "无过滤则比对全部行。",
|
||||
filterClear: "清空",
|
||||
filterAddOr: "添加条件组(或)",
|
||||
|
|
@ -110,12 +110,82 @@ const bizCompare = {
|
|||
validateNeed: "请先选择映射与前后批次",
|
||||
mapHint:
|
||||
"每行一对:操作前接口,操作后接口(逗号/Tab/竖线分隔)。例:\ngei-0/1/1,gei-0/1/2\nxgei-0/1/0,xgei-0/2/0\n# 井号开头为注释。不是 A,B/C,D 这种斜杠分组。",
|
||||
templateMonitorUsage: "已被 {{n}} 个监控模板引用",
|
||||
mapParsed: "已解析 {{n}} 对",
|
||||
mapInvalidLines: "无效 {{m}} 行(第 {{lines}} 行)",
|
||||
mapDuplicate: "前接口重复:{{list}}",
|
||||
mapUnsaved: "未保存",
|
||||
mapInvalid: "映射文本有误,未保存。无效行:{{lines}};重复的前接口:{{dup}}",
|
||||
mapEmpty: "映射文本为空。若不需要映射,请选择“新建映射…”;否则请恢复内容后再保存。",
|
||||
mapSharedConfirm: "该映射还被 {{jobs}} 个其他比对任务、{{projects}} 个割接项目使用,保存会同时影响它们。继续?",
|
||||
mappingAutoSaved: "已自动保存映射",
|
||||
dataState: {
|
||||
"ok": "正常",
|
||||
"no_data": "无数据",
|
||||
"suspect_parse": "疑似解析失败",
|
||||
"not_collected": "未采集",
|
||||
"manual_pending": "待手动采集"
|
||||
},
|
||||
stateBanner: {
|
||||
"no_data": "两侧都没有数据,没有可比较的内容,不计入通过率。",
|
||||
"suspect_parse": "设备输出了内容但解析出 0 行,失败行可能是解析问题而非业务丢失,请先检查解析/模板。",
|
||||
"not_collected": "某一侧没有采集该检查项(或缺少对应切片),不做判定,不计入通过率。"
|
||||
},
|
||||
rawKeyTitle: "操作前设备原始名称",
|
||||
scopeBoth: "两侧",
|
||||
scopeBefore: "仅操作前",
|
||||
scopeAfter: "仅操作后",
|
||||
valueMap: "值映射",
|
||||
valueMapScope: "值映射范围",
|
||||
valueMapPlaceholder: "每行:原值,规范值\nestab,established",
|
||||
rankLabel: "好→坏顺序",
|
||||
rankPlaceholder: "up,down(靠前更好)",
|
||||
modeNotWorse: "不劣于",
|
||||
modeNumericMin: "不低于(%)",
|
||||
modeNumericMax: "不高于(%)",
|
||||
filterSide: "过滤作用侧",
|
||||
unmappedPortPolicy: "未映射端口",
|
||||
unmappedByName: "按名称匹配(默认)",
|
||||
unmappedIgnorePort: "忽略端口,按内容匹配",
|
||||
unmappedPortPolicyHint: "只配了部分端口映射时,没有映射的端口如何配对。",
|
||||
duplicatePairing: "同键多行配对",
|
||||
pairSorted: "按取值排序后配对(推荐)",
|
||||
pairOrdered: "按采集顺序配对(旧行为)",
|
||||
duplicatePairingHint: "ECMP、多路径等同一个键出现多行时,CLI 输出顺序变化不应产生假差异。",
|
||||
builtinVendorCanonical: "启用内置厂商接口名称统一(GigabitEthernet↔GE 等)",
|
||||
mappingNeedBatches: "请先选择前后批次",
|
||||
preflight: {
|
||||
"run": "校验映射",
|
||||
"ready": "映射可用",
|
||||
"risk": "存在风险,请先处理",
|
||||
"coverage": "已映射 {{mapped}} · 疑似漏配 {{suspect}} · 覆盖率 {{rate}}",
|
||||
"pairs": "{{n}} 条映射",
|
||||
"suspect": "疑似漏配(操作前有、未映射、操作后也不存在)",
|
||||
"addSuspects": "加入映射编辑区(目标待填)",
|
||||
"commandDiff": "两侧采集命令不一致",
|
||||
"rows": "行数(前→后)",
|
||||
"state": "数据状态",
|
||||
"mapping": "映射",
|
||||
"skippedHeavy": "大表,未逐个检查接口",
|
||||
"sheetCounts": "已映射 {{exact}} · 同名 {{same}} · 疑似漏配 {{suspect}}",
|
||||
"issue": {
|
||||
"duplicate": "前接口重复(只取第一条)",
|
||||
"conflicts": "归一化后前接口冲突",
|
||||
"unused": "未命中任何接口的映射键",
|
||||
"missingTargets": "映射目标在操作后设备上不存在",
|
||||
"nTo1": "多个前接口指向同一目标(N:1)"
|
||||
}
|
||||
},
|
||||
coverageBanner: "映射覆盖:{{n}} 个操作前端口没有映射且在操作后设备上不存在,缺失可能是漏配映射:",
|
||||
mapViewText: "文本",
|
||||
mapViewTable: "表格",
|
||||
mapTable: {"before": "操作前接口", "after": "操作后接口", "status": "状态", "remove": "删除行", "add": "添加一行", "empty": "还没有映射行", "ok": "正常", "todo": "待填目标", "duplicate": "前接口重复", "invalid": "缺少前接口"},
|
||||
mappingSaved: "映射已保存",
|
||||
mappingOptionalHint:
|
||||
"可选。留空=忽略本端端口改名,按非接口 Key 对齐。填写后才做端口映射校验。主接口映射会自动覆盖 .子接口(后缀不变)。",
|
||||
ifaceNormalize: "接口归一化",
|
||||
ifaceNormalizeHint:
|
||||
"模板级规则,每行:简写,规范名。先归一化再端口映射。例:\nGE,gei\nSG,smartgroup\nXGE,xgei",
|
||||
"模板级规则,每行:简写,规范名。先归一化再端口映射。例:;可选第 3/4 列限定:before/after 与厂商,如 Eth-Trunk,ae,after,huawei\nGE,gei\nSG,smartgroup\nXGE,xgei",
|
||||
createJob: "建立比对任务",
|
||||
defaultJobName: "割接比对",
|
||||
jobName: "名称",
|
||||
|
|
|
|||
|
|
@ -200,6 +200,18 @@ const bizMigration = {
|
|||
startBatch: "启动",
|
||||
finishBatch: "收工",
|
||||
evaluate: "立即判定",
|
||||
evalProgress: "{{label}}中 {{i}}/{{n}} {{title}}",
|
||||
evalQueued: "{{label}}排队中…",
|
||||
preflight: "映射预检",
|
||||
preflightHint: "按两台设备的画像基线检查映射覆盖率与采集差异,割接前先看一眼。",
|
||||
manualPendingBanner: "待手动采集(不计入验收门槛):{{metrics}}",
|
||||
notCollectedBanner: "两侧采集不一致、不做判定(不判红):{{metrics}}",
|
||||
duplicateWarningBanner: "本批范围外存在重复键(不影响验收,建议完善 key 字段):{{metrics}}",
|
||||
redResolvableHint: "{{n}} 张红单对应的对象在最近一次验收中已转绿。",
|
||||
redResolveAllResolvable: "一键关闭已转绿红单",
|
||||
redNowGreen: "已转绿",
|
||||
redResolvableClosed: "已关闭 {{n}} 张红单",
|
||||
portSuccessHint: "端口迁移成功 = 旧端口(已删除 / 物理 down / 管理 down 任一)且新端口管理、物理都 up。拔纤但未 shutdown 也算完成。",
|
||||
evaluated: "判定完成",
|
||||
statusUpdated: "状态已更新",
|
||||
board: "进度看板",
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@
|
|||
@import "./styles/pages/biz-compare-templates.css";
|
||||
@import "./styles/pages/biz-migration.css";
|
||||
@import "./styles/pages/biz-compare.css";
|
||||
@import "./styles/pages/cutover-shared.css";
|
||||
@import "./styles/pages/port-traffic.css";
|
||||
@import "./styles/legacy/overrides-dark.css";
|
||||
@import "./styles/legacy/overrides-late.css";
|
||||
|
|
|
|||
|
|
@ -52,6 +52,9 @@ export const bizCompareUpdateMapping = (mappingId: string, body: Record<string,
|
|||
export const bizCompareValidateMapping = (body: Record<string, unknown>) =>
|
||||
apiPost<Record<string, unknown>>("/v1/biz-state/compare/mappings/validate", body);
|
||||
|
||||
export const bizCompareMappingPreflight = (body: Record<string, unknown>) =>
|
||||
apiPost<Record<string, unknown>>("/v1/biz-state/compare/preflight", body);
|
||||
|
||||
export const bizCompareListJobs = () =>
|
||||
apiGet<{ items: Record<string, unknown>[] }>("/v1/biz-state/compare/jobs");
|
||||
|
||||
|
|
|
|||
|
|
@ -176,3 +176,17 @@ export const bizMigrationListDiffs = (params: {
|
|||
`/v1/biz-migration/runs/${encodeURIComponent(params.runId)}/diffs${qs ? `?${qs}` : ""}`,
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
/** Evaluate / finish run in the background: poll this until ``status`` leaves ``running``. */
|
||||
export const bizMigrationGetRun = (runId: string) =>
|
||||
apiGet<Record<string, unknown>>(`/v1/biz-migration/runs/${encodeURIComponent(runId)}`);
|
||||
|
||||
export const bizMigrationPreflight = (projectId: string) =>
|
||||
apiGet<Record<string, unknown>>(`/v1/biz-migration/projects/${encodeURIComponent(projectId)}/preflight`);
|
||||
|
||||
export const bizMigrationResolveResolvable = (projectId: string) =>
|
||||
apiPost<Record<string, unknown>>(
|
||||
`/v1/biz-migration/projects/${encodeURIComponent(projectId)}/red-tickets/resolve-resolvable`,
|
||||
{},
|
||||
);
|
||||
|
|
|
|||
167
web/src/styles/pages/cutover-shared.css
Normal file
167
web/src/styles/pages/cutover-shared.css
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
/* Shared by 业务对比 and 割接监控: pre-flight report, data-state badges, value-map editor. */
|
||||
|
||||
.cx-preflight {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid rgb(var(--nx-ok-rgb) / 0.4);
|
||||
border-radius: 6px;
|
||||
background: rgb(var(--nx-ok-rgb) / 0.06);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cx-preflight.is-risk {
|
||||
border-color: rgb(var(--nx-warn-rgb) / 0.5);
|
||||
background: rgb(var(--nx-warn-rgb) / 0.07);
|
||||
}
|
||||
|
||||
.cx-preflight__head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 4px 14px;
|
||||
}
|
||||
|
||||
.cx-preflight__block h5,
|
||||
.cx-preflight__block h6 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.cx-preflight__list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 10px;
|
||||
max-height: 140px;
|
||||
overflow: auto;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.cx-preflight__scroll {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.cx-preflight code {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.cx-preflight__table {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.cx-preflight__table details summary {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cx-state {
|
||||
display: inline-block;
|
||||
padding: 0 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
border: 1px solid rgb(var(--nx-ok-rgb) / 0.4);
|
||||
color: var(--nx-ok);
|
||||
}
|
||||
|
||||
.cx-state--no_data,
|
||||
.cx-state--not_collected,
|
||||
.cx-state--manual_pending {
|
||||
border-color: var(--nx-line);
|
||||
color: var(--nx-text-muted);
|
||||
}
|
||||
|
||||
.cx-state--suspect_parse {
|
||||
border-color: rgb(var(--nx-warn-rgb) / 0.55);
|
||||
color: var(--nx-warn);
|
||||
}
|
||||
|
||||
.cx-state__why {
|
||||
font-size: 11px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.cx-banner {
|
||||
margin: 0;
|
||||
padding: 6px 10px;
|
||||
border-left: 3px solid rgb(var(--nx-warn-rgb) / 0.8);
|
||||
background: rgb(var(--nx-warn-rgb) / 0.08);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.cx-rawkey {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: var(--nx-text-muted);
|
||||
}
|
||||
|
||||
.ct-valuemap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.ct-valuemap__text {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.ct-valuemap__text.is-invalid {
|
||||
border-color: var(--nx-bad);
|
||||
}
|
||||
|
||||
.ct-valuemap__err {
|
||||
color: var(--nx-bad-text);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.ct-sheet-options {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 8px 16px;
|
||||
}
|
||||
|
||||
.ct-sheet-options__check {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.bs-cmp-mapping__status.is-error {
|
||||
color: var(--nx-bad-text);
|
||||
}
|
||||
|
||||
.bs-cmp-mapping__views {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.bs-cmp-mapping__views .is-active {
|
||||
font-weight: 600;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.cx-maptable {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
max-height: 260px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.cx-maptable__input {
|
||||
width: 100%;
|
||||
min-width: 90px;
|
||||
}
|
||||
|
||||
.cx-maptable__row.is-duplicate,
|
||||
.cx-maptable__row.is-invalid {
|
||||
background: rgb(var(--nx-bad-rgb) / 0.08);
|
||||
}
|
||||
|
|
@ -40,6 +40,8 @@ const diffs = [
|
|||
{ kind: "added", key: { neighbor: "10.2.1.3", vrf: "core" }, before: null, after: row("10.2.1.3", "Established"), changes: {} },
|
||||
{ kind: "unchanged", key: { neighbor: "10.2.1.4", vrf: "core" }, before: row("10.2.1.4", "Established"), after: row("10.2.1.4", "Established"), changes: {} },
|
||||
];
|
||||
const mappingPosts = [];
|
||||
const preflightPosts = [];
|
||||
let failDiffs = false;
|
||||
let active = false;
|
||||
let legacy = false;
|
||||
|
|
@ -79,6 +81,18 @@ await page.route("**/v1/**", async (route) => {
|
|||
} else if (p.endsWith("/runs/run-1")) {
|
||||
pollCounts.detail++;
|
||||
data = { ...run, config_snapshot_version: legacy ? null : 1, status: active ? "running" : "success" };
|
||||
} else if (p.endsWith("/compare/preflight") && route.request().method() === "POST") {
|
||||
preflightPosts.push(route.request().postDataJSON());
|
||||
data = {
|
||||
verdict: "risk", risk_reasons: ["suspect_unmapped"], coverage: { mapped: 2, suspect_unmapped: 1, identity_same_name: 0, rate: 66.7 },
|
||||
mapping: { pairs: 2, unused_map_keys: [], map_targets_missing_on_after: [], n_to_1_targets: [] },
|
||||
sheets: [{ sheet_id: "peer", title: "BGP 邻居", metric_id: "bgp_peer", before_count: 4, after_count: 4, data_state: "ok",
|
||||
counts: { mapped_exact: 2, mapped_by_parent: 0, identity_same_name: 0, suspect_unmapped: 1 }, suspect_unmapped: ["gei-7"], suspect_unmapped_total: 1 }],
|
||||
command_coverage: { only_before: [], only_after: [] },
|
||||
};
|
||||
} else if (p.endsWith("/mappings") && route.request().method() === "POST") {
|
||||
mappingPosts.push(route.request().postDataJSON());
|
||||
data = { id: "map-new", name: "端口映射", rows: route.request().postDataJSON().rows };
|
||||
} else if (p.endsWith("/mappings")) data = { items: [] };
|
||||
else data = { items: [] };
|
||||
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(data) });
|
||||
|
|
@ -108,6 +122,49 @@ try {
|
|||
assert.deepEqual(body.enabled_sheet_ids, ["peer"]);
|
||||
assert.equal(body.before_batch_id, "b");
|
||||
assert.equal(body.after_batch_id, "a");
|
||||
// A3: mapping text typed but never "saved" must not be dropped silently.
|
||||
const mapEditor = page.locator(".bs-cmp-mapping__editor");
|
||||
const mapStatus = page.getByTestId("map-parse-status");
|
||||
await mapEditor.fill("GE0/1/0/1 gei0/2/0/1\nxge1\tgei2\nbroken line here");
|
||||
await mapStatus.getByText("已解析 2 对", { exact: false }).waitFor();
|
||||
assert.match(await mapStatus.innerText(), /无效 1 行(第 3 行)/);
|
||||
assert.match(await mapStatus.innerText(), /未保存/);
|
||||
let patched = false;
|
||||
const onPatch = (req) => { if (req.method() === "PATCH" && req.url().endsWith("/compare/jobs/job-1")) patched = true; };
|
||||
page.on("request", onPatch);
|
||||
await page.getByRole("button", { name: "保存配置", exact: true }).click();
|
||||
await page.waitForTimeout(400);
|
||||
assert.equal(patched, false, "invalid mapping text blocks saving the job");
|
||||
assert.equal(mappingPosts.length, 0);
|
||||
await mapEditor.fill("GE0/1/0/1 gei0/2/0/1\nxge1\tgei2");
|
||||
const mappedJob = page.waitForRequest((req) => req.method() === "PATCH" && req.url().endsWith("/compare/jobs/job-1"));
|
||||
await page.getByRole("button", { name: "保存配置", exact: true }).click();
|
||||
assert.equal((await mappedJob).postDataJSON().mapping_id, "map-new");
|
||||
page.off("request", onPatch);
|
||||
assert.deepEqual(mappingPosts[0].rows, [
|
||||
{ before_if: "GE0/1/0/1", after_if: "gei0/2/0/1" }, { before_if: "xge1", after_if: "gei2" }]);
|
||||
// B5: the pre-flight checks the editor text (saved or not) against the two batches.
|
||||
await page.getByRole("button", { name: "校验映射", exact: true }).click();
|
||||
const pf = page.getByTestId("preflight-report");
|
||||
await pf.waitFor();
|
||||
assert.deepEqual(preflightPosts.at(-1).mapping_rows, [
|
||||
{ before_if: "GE0/1/0/1", after_if: "gei0/2/0/1" }, { before_if: "xge1", after_if: "gei2" }]);
|
||||
assert.equal(preflightPosts.at(-1).before_batch_id, "b");
|
||||
assert.equal(preflightPosts.at(-1).after_batch_id, "a");
|
||||
assert.match(await pf.textContent(), /疑似漏配/);
|
||||
await pf.getByRole("button", { name: "加入映射编辑区(目标待填)", exact: true }).click();
|
||||
assert.match(await mapEditor.inputValue(), /gei-7,/);
|
||||
// F1: table view edits the same text.
|
||||
await mapEditor.fill("a1,b1\na1,b2\nc1,");
|
||||
await page.getByRole("button", { name: "表格", exact: true }).click();
|
||||
const tbl = page.getByTestId("mapping-table");
|
||||
await tbl.waitFor();
|
||||
assert.equal(await tbl.locator("tbody tr.is-duplicate").count(), 2);
|
||||
assert.equal(await tbl.locator("tbody tr.is-todo").count(), 1);
|
||||
await tbl.getByLabel("操作后接口 3", { exact: true }).fill("d1");
|
||||
await page.getByRole("button", { name: "文本", exact: true }).click();
|
||||
assert.equal(await mapEditor.inputValue(), "a1,b1\na1,b2\nc1,d1");
|
||||
await mapEditor.fill("");
|
||||
await page.getByLabel("模式", { exact: true }).selectOption("auto");
|
||||
assert.equal(await page.getByLabel("操作后批次", { exact: true }).count(), 0);
|
||||
await page.getByText("保持操作前批次固定;操作后任务采集完成时,自动取最新批次进行对比。", { exact: true }).waitFor();
|
||||
|
|
@ -209,6 +266,25 @@ try {
|
|||
await page.getByLabel("接口对应关系", { exact: true }).waitFor();
|
||||
await page.getByRole("button", { name: "关闭", exact: true }).click();
|
||||
|
||||
// V1/V5: a side that parsed nothing is flagged, and the before-side name is shown as printed.
|
||||
const card = run.summary.sheet_cards[0];
|
||||
Object.assign(card, { data_state: "suspect_parse", data_state_reasons: ["after:parsed_0_of_40_lines"] });
|
||||
diffs[0].raw_key_before = { neighbor: "GE0/1", vrf: "core" };
|
||||
diffs[0].key = { neighbor: "gei0/1", vrf: "core" };
|
||||
await page.reload();
|
||||
await page.getByRole("row").filter({ hasText: "割接前后 · 业务验收" }).getByRole("button", { name: "详情", exact: true }).click();
|
||||
await page.getByRole("tab", { name: "比对结果", exact: true }).click();
|
||||
await page.locator(".bs-cmp-row").first().waitFor();
|
||||
const banner = page.getByTestId("data-state-banner");
|
||||
await banner.waitFor();
|
||||
assert.match(await banner.textContent(), /疑似解析失败/);
|
||||
assert.match(await banner.textContent(), /parsed_0_of_40_lines/);
|
||||
assert.match(await page.locator(".bs-cmp-nav__item").first().textContent(), /疑似解析失败/);
|
||||
assert.match(await page.locator(".cx-rawkey").first().textContent(), /GE0\/1/);
|
||||
Object.assign(card, { data_state: "ok", data_state_reasons: [] });
|
||||
delete diffs[0].raw_key_before;
|
||||
await page.getByRole("button", { name: "关闭", exact: true }).click();
|
||||
|
||||
active = true;
|
||||
await page.reload();
|
||||
await page.getByRole("row").filter({ hasText: "割接前后 · 业务验收" }).getByRole("button", { name: "详情", exact: true }).click();
|
||||
|
|
@ -218,7 +294,7 @@ try {
|
|||
assert.ok(pollCounts.runs <= 3 && pollCounts.detail <= 3, JSON.stringify(pollCounts));
|
||||
assert.ok(pollCounts.runs >= 2 && pollCounts.detail >= 2, "active run should keep polling");
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(JSON.stringify({ passed: ["job list labels", "four config sections", "source pairing", "last sheet locked", "configuration save payload", "automatic mode", "390px configuration", "keyboard tabs", "wizard source selection", "same-task batch selection", "source change clears batch", "history counts", "execution and verdict separation", "queued progress semantics", "history loading", "history error retry", "390px history", "result layout", "saved execution settings", "legacy settings warning", "error clears stale rows", "retry", "clear filters", "768px toolbar", "old job response ignored", "single poller", "no runtime errors"], pollCounts, output }));
|
||||
console.log(JSON.stringify({ passed: ["job list labels", "four config sections", "source pairing", "last sheet locked", "configuration save payload", "automatic mode", "390px configuration", "keyboard tabs", "wizard source selection", "same-task batch selection", "source change clears batch", "history counts", "execution and verdict separation", "queued progress semantics", "history loading", "history error retry", "390px history", "result layout", "saved execution settings", "legacy settings warning", "error clears stale rows", "retry", "clear filters", "768px toolbar", "old job response ignored", "single poller", "mapping pre-flight", "mapping table view", "data state banner", "raw before key", "no runtime errors"], pollCounts, output }));
|
||||
} catch (error) {
|
||||
await page.screenshot({ path: path.join(output, "biz-compare-test-failure.png"), fullPage: true });
|
||||
console.error(JSON.stringify(await page.locator(".bs-cmp-source select").evaluateAll((els) => els.map((el) => ({ value: el.value, html: el.parentElement.outerHTML })))));
|
||||
|
|
|
|||
|
|
@ -149,6 +149,45 @@ try {
|
|||
await savedJob;
|
||||
assert.deepEqual(writes.at(-1).enabled_sheet_ids, []);
|
||||
passed.push("missing sheets block save", "incomplete sources disabled", "explicit scope repair");
|
||||
// Cross-vendor identity rules: value maps, directional compare, per-side aliases and sheet options
|
||||
// must reach the API exactly as edited.
|
||||
await page.goto(`${base}/network/cutover/compare-templates`);
|
||||
await templateRow().waitFor();
|
||||
await openEditor();
|
||||
await editor().locator("details.ct-editor__norm summary").click();
|
||||
await editor().getByLabel("接口归一化", { exact: true }).fill("GE,gei\nEth-Trunk,ae,after,huawei");
|
||||
await editor().getByRole("tab", { name: /^字段规则/ }).click();
|
||||
const stateRow = editor().locator(".ct-field-table tbody tr").filter({ has: page.getByText("state", { exact: true }) });
|
||||
await stateRow.locator('input[type="checkbox"]').nth(2).check();
|
||||
await editor().getByLabel("state 方式", { exact: true }).selectOption("not_worse");
|
||||
await editor().getByLabel("state 好→坏顺序", { exact: true }).fill("up,down");
|
||||
await editor().getByLabel("state 值映射", { exact: true }).fill("estab,established\n*down,down");
|
||||
await editor().getByLabel("state 值映射", { exact: true }).blur();
|
||||
await editor().getByLabel("state 值映射范围", { exact: true }).selectOption("before");
|
||||
await editor().getByLabel("state 值映射", { exact: true }).fill("up/up,up");
|
||||
await editor().getByLabel("state 值映射", { exact: true }).blur();
|
||||
await editor().getByLabel("未映射端口", { exact: true }).selectOption("ignore_port");
|
||||
await editor().getByLabel("同键多行配对", { exact: true }).selectOption("ordered");
|
||||
await editor().getByRole("checkbox", { name: /内置厂商接口名称统一/ }).uncheck();
|
||||
await editor().getByRole("button", { name: "保存模板", exact: true }).click();
|
||||
await page.waitForTimeout(500);
|
||||
const saved = writes.at(-1);
|
||||
assert.deepEqual(saved.iface_normalize_rules, [{ from: "GE", to: "gei" }, { from: "Eth-Trunk", to: "ae", side: "after", vendor: "huawei" }]);
|
||||
const sheetS = saved.metrics.find((m) => m.sheet_id === "s");
|
||||
const stateRules = sheetS.field_rules.filter((r) => r.field === "state");
|
||||
assert.deepEqual(stateRules.find((r) => !r.side), { field: "state", compare: "not_worse", rank: ["up", "down"], value_map: { estab: "established", "*down": "down" } });
|
||||
assert.deepEqual(stateRules.find((r) => r.side === "before"), { field: "state", side: "before", value_map: { "up/up": "up" } });
|
||||
assert.equal(sheetS.unmapped_port_policy, "ignore_port");
|
||||
assert.equal(sheetS.duplicate_pairing, "ordered");
|
||||
assert.equal(sheetS.builtin_vendor_canonical, false);
|
||||
assert.deepEqual(sheetS.row_filters, complex, "opaque filters survive the same save");
|
||||
passed.push("value map and rank saved", "per-side value map saved", "per-side alias saved", "sheet identity options saved");
|
||||
// ... and come back unchanged when the template is opened again.
|
||||
await openEditor();
|
||||
await editor().getByRole("tab", { name: /^字段规则/ }).click();
|
||||
assert.equal(await editor().getByLabel("state 值映射", { exact: true }).inputValue(), "estab,established\n*down,down");
|
||||
assert.equal(await editor().getByLabel("未映射端口", { exact: true }).inputValue(), "ignore_port");
|
||||
await page.keyboard.press("Escape");
|
||||
assert.deepEqual(errors, []);
|
||||
passed.push("no runtime errors");
|
||||
console.log(JSON.stringify({ passed, output }));
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ const browser = await chromium.launch({ headless: true, channel: process.env.NET
|
|||
const page = await browser.newPage({ viewport: { width: 1440, height: 1080 } });
|
||||
const errors = [], passed = [], requests = [], writes = [];
|
||||
page.on("pageerror", (e) => errors.push(e.message));
|
||||
let liveRunId = "live-1", uncertain = false, failBoard = false, delayedSheet = "", sampleAgeSec = 0;
|
||||
let liveRunId = "live-1", uncertain = false, failBoard = false, delayedSheet = "", sampleAgeSec = 0, richCards = false, evalPolls = 0;
|
||||
const now = new Date().toISOString();
|
||||
const project = { id: "p", name: "核心 PE 替换", old_task_id: "op", new_task_id: "np", old_hf_task_id: "oh", new_hf_task_id: "nh", old_baseline_batch_id: "ob", new_baseline_batch_id: "nb", monitor_template_id: "mt", hf_interval_sec: 60, status: "active", created_at: now,
|
||||
collect_metric_ids_effective: ["arp"], old_task: { id: "op", ne_name: "PE-OLD", ne_ip: "10.0.0.1" }, new_task: { id: "np", ne_name: "PE-NEW", ne_ip: "10.0.0.2" } };
|
||||
|
|
@ -25,7 +25,14 @@ function run(rid, pid = "p") {
|
|||
const sampleTime = sampleAgeSec ? new Date(Date.now() - sampleAgeSec * 1000).toISOString() : now;
|
||||
return { id: rid, purpose: rid === "hist" ? "manual" : "auto", created_at: now,
|
||||
summary: { progress: { ok: pid === "p2" ? 3 : 99, total: pid === "p2" ? 3 : 101 }, anomaly: 1, anomaly_outside_expect: 1, coverage_complete: !uncertain, duplicate_keys: uncertain ? 2 : 0,
|
||||
sheet_cards: sheets.map((s) => ({ ...s, progress_ok: 49, progress_total: 50, anomaly: 0, old_collected_at: sampleTime, new_collected_at: sampleTime, age_limit_sec: 180, sample_issues: uncertain ? ["sample_skew"] : [] })) } };
|
||||
duplicate_warnings: richCards ? [{ sheet_id: "isis", title: "ISIS 邻居", count: 2, examples: ["a|b"] }] : [],
|
||||
sheet_cards: [
|
||||
...sheets.map((s) => ({ ...s, progress_ok: 49, progress_total: 50, anomaly: 0, old_collected_at: sampleTime, new_collected_at: sampleTime, age_limit_sec: 180, sample_issues: uncertain ? ["sample_skew"] : [] })),
|
||||
...(richCards ? [
|
||||
{ sheet_id: "routes", metric_id: "bgp_route", title: "BGP 路由", manual_pending: true, progress_ok: 0, progress_total: 0, anomaly: 0 },
|
||||
{ sheet_id: "v6", metric_id: "bgp_peer", title: "BGP IPv6", not_collected: true, data_state: "not_collected", data_state_reasons: ["new:slice_not_collected"], progress_ok: 0, progress_total: 0, anomaly: 0 },
|
||||
] : []),
|
||||
] } };
|
||||
}
|
||||
await page.route("**/v1/**", async (route) => {
|
||||
const req = route.request(), url = new URL(req.url()), p = url.pathname;
|
||||
|
|
@ -38,6 +45,20 @@ await page.route("**/v1/**", async (route) => {
|
|||
project.collect_metric_ids_effective = project.collect_metric_ids;
|
||||
data = project;
|
||||
}
|
||||
else if (req.method() === "POST" && p.endsWith("/batches/wave/evaluate")) { evalPolls = 0; data = { id: "async-run", status: "running", purpose: "manual", summary: { progress: { phase: "queued" } } }; }
|
||||
else if (p.endsWith("/runs/async-run")) {
|
||||
evalPolls += 1;
|
||||
data = evalPolls < 3
|
||||
? { id: "async-run", status: "running", summary: { progress: { phase: "evaluating", sheet_index: 1, sheet_total: 2, sheet_title: "ARP · 公网" } } }
|
||||
: { ...run("async-run"), status: "success" };
|
||||
}
|
||||
else if (p.endsWith("/p/preflight")) data = {
|
||||
verdict: "risk", risk_reasons: ["suspect_unmapped"], coverage: { mapped: 2, suspect_unmapped: 1, identity_same_name: 0, rate: 66.7 },
|
||||
mapping: { pairs: 2, unused_map_keys: ["gei-9"], map_targets_missing_on_after: [], n_to_1_targets: [] },
|
||||
sheets: [{ sheet_id: "ifb", title: "端口状态", metric_id: "interface_brief", before_count: 3, after_count: 2, data_state: "ok",
|
||||
counts: { mapped_exact: 2, mapped_by_parent: 0, identity_same_name: 0, suspect_unmapped: 1 }, suspect_unmapped: ["gei-7"], suspect_unmapped_total: 1 }],
|
||||
command_coverage: { only_before: ["bgp_peer{afi=ipv4}"], only_after: [] },
|
||||
};
|
||||
else if (p.endsWith("/p/ensure-highfreq")) data = { project, hf_status: "running", collect: { old: [], new: [] } };
|
||||
else if (req.method() === "POST" && p.endsWith("/p/batches")) { writes.push(req.postDataJSON()); data = { ...wave, id: "created-wave", ...req.postDataJSON() }; }
|
||||
else if (p.endsWith("/biz-migration/projects")) data = { items: [project, second] };
|
||||
|
|
@ -197,6 +218,23 @@ try {
|
|||
assert.equal(await table().locator("tbody tr").count(), 100);
|
||||
passed.push("refresh failure keeps snapshot with explicit error");
|
||||
failBoard = false;
|
||||
// H2: evaluation is computed in the background; the page polls the run and shows progress.
|
||||
const evalPost = page.waitForRequest((r) => r.method() === "POST" && r.url().endsWith("/batches/wave/evaluate"));
|
||||
await dialog().getByRole("button", { name: "立即判定", exact: true }).first().click();
|
||||
await evalPost;
|
||||
await dialog().getByTestId("eval-progress").waitFor();
|
||||
assert.match(await dialog().getByTestId("eval-progress").textContent(), /1\/2|排队/);
|
||||
await dialog().getByTestId("eval-progress").waitFor({ state: "detached", timeout: 15000 });
|
||||
assert.ok(requests.filter((r) => r.p.endsWith("/runs/async-run")).length >= 3, "run polled until it settled");
|
||||
passed.push("evaluation polls a background run", "progress shown while the run computes");
|
||||
// H3/V2/H5 states on the board.
|
||||
richCards = true;
|
||||
await dialog().getByRole("button", { name: "刷新看板", exact: true }).click();
|
||||
await dialog().getByTestId("manual-pending-banner").waitFor();
|
||||
assert.match(await dialog().getByTestId("not-collected-banner").textContent(), /BGP IPv6/);
|
||||
assert.match(await dialog().getByTestId("duplicate-warning-banner").textContent(), /ISIS 邻居/);
|
||||
richCards = false;
|
||||
passed.push("manual route table is pending, not failed", "uncollected slice is not judged", "out-of-scope duplicate keys are warnings");
|
||||
await page.setViewportSize({ width: 390, height: 1000 });
|
||||
assert.ok(await dialog().locator(".bm-live-kpis").evaluate((el) => el.scrollWidth <= el.clientWidth + 1));
|
||||
assert.ok(await dialog().locator(".bm-board").evaluate((el) => el.scrollWidth <= el.clientWidth + 1));
|
||||
|
|
@ -215,6 +253,14 @@ try {
|
|||
await page.waitForTimeout(500);
|
||||
assert.deepEqual(writes.at(-1).collect_metric_ids, ['arp']);
|
||||
passed.push("route can be selected from comparison items", "route has manual mode instead of periodic interval", "dynamic metric additions saved explicitly", "removed metric no longer selected");
|
||||
// B5: mapping pre-flight on the portrait baselines.
|
||||
await dialog().getByRole("button", { name: "映射预检", exact: true }).click();
|
||||
const report = dialog().getByTestId("preflight-report");
|
||||
await report.waitFor();
|
||||
assert.match(await report.textContent(), /疑似漏配/);
|
||||
assert.match(await report.textContent(), /gei-7/);
|
||||
assert.match(await report.textContent(), /bgp_peer\{afi=ipv4\}/);
|
||||
passed.push("pre-flight lists suspected unmapped ports and collection differences");
|
||||
await dialog().getByRole("button", { name: "割接批次", exact: true }).click();
|
||||
await page.waitForFunction(() => document.querySelectorAll('.bm-expect-table tbody tr').length === 100);
|
||||
await dialog().getByRole("checkbox", { name: "ARP · 公网 业务 0", exact: true }).check();
|
||||
|
|
@ -243,6 +289,7 @@ try {
|
|||
assert.deepEqual(errors, []);
|
||||
console.log(JSON.stringify({ passed, errors }, null, 2));
|
||||
} catch (error) {
|
||||
console.log("FAILED:", String(error.message).split("\n").slice(0, 6).join(" | "));
|
||||
await page.screenshot({ path: path.join(output, "cutover-monitor-failure.png"), fullPage: true });
|
||||
console.log(JSON.stringify(await page.locator('.bm-board').evaluate((el) => {
|
||||
const layout = [];
|
||||
|
|
|
|||
|
|
@ -57,3 +57,53 @@ test("invalid tolerances, modes, duplicate sheets and scalar set membership bloc
|
|||
assert.throws(() => validateTemplateDraft([sheet(), sheet()]), /duplicate_sheet/);
|
||||
assert.throws(() => validateTemplateDraft([{ ...sheet(), row_filters: [{ field: "id", op: "in", value: "a,b" }] }]), /value_list/);
|
||||
});
|
||||
|
||||
import { sheetPayload, formatIfaceNormText, parseValueMapText, formatValueMapText } from "../src/features/cutover/lib/compareTemplateModel.ts";
|
||||
|
||||
test("sheet options survive import and the save payload", () => {
|
||||
const parsed = parseTemplateImport(template([{ ...sheet(), unmapped_port_policy: "ignore_port", duplicate_pairing: "ordered", builtin_vendor_canonical: false }]));
|
||||
const s = parsed.metrics[0];
|
||||
assert.equal(s.unmapped_port_policy, "ignore_port");
|
||||
assert.equal(s.duplicate_pairing, "ordered");
|
||||
assert.equal(s.builtin_vendor_canonical, false);
|
||||
const body = sheetPayload(s);
|
||||
assert.equal(body.unmapped_port_policy, "ignore_port");
|
||||
assert.equal(body.builtin_vendor_canonical, false);
|
||||
assert.equal("duplicate_pairing" in sheetPayload({ ...s, duplicate_pairing: "" }), false);
|
||||
});
|
||||
|
||||
test("alias rules keep side and vendor and may repeat per scope", () => {
|
||||
const rules = parseIfaceNormText("GE,gei\nGE,GigabitEthernet,before\nEth-Trunk,ae,after,huawei");
|
||||
assert.deepEqual(rules, [{ from: "GE", to: "gei" }, { from: "GE", to: "GigabitEthernet", side: "before" }, { from: "Eth-Trunk", to: "ae", side: "after", vendor: "huawei" }]);
|
||||
assert.deepEqual(parseIfaceNormText(formatIfaceNormText(rules)), rules);
|
||||
assert.throws(() => parseIfaceNormText("GE,gei\nge,x"), /duplicate_alias/);
|
||||
assert.throws(() => parseIfaceNormText("GE,gei,middle"), /unknown_alias_side/);
|
||||
});
|
||||
|
||||
test("a vendor-scoped alias with no side survives the text round trip", () => {
|
||||
const rules = parseIfaceNormText("Eth-Trunk,ae,both,huawei");
|
||||
assert.deepEqual(rules, [{ from: "Eth-Trunk", to: "ae", vendor: "huawei" }]);
|
||||
assert.equal(formatIfaceNormText(rules), "Eth-Trunk,ae,both,huawei");
|
||||
});
|
||||
|
||||
test("value maps parse, format and are bounded", () => {
|
||||
const map = parseValueMapText("estab,established\n# note\n*down,down");
|
||||
assert.deepEqual(map, { estab: "established", "*down": "down" });
|
||||
assert.deepEqual(parseValueMapText(formatValueMapText(map)), map);
|
||||
assert.throws(() => parseValueMapText("lonely"), /pair_required/);
|
||||
assert.throws(() => parseValueMapText(Array.from({ length: 501 }, (_, i) => `a${i},b`).join("\n")), /too_large/);
|
||||
});
|
||||
|
||||
test("directional compare modes need a rank and a field can carry one rule per side", () => {
|
||||
const base = sheet();
|
||||
assert.throws(() => validateTemplateDraft([{ ...base, field_rules: [{ field: "state", compare: "not_worse" }] }]), /rank_list_required/);
|
||||
validateTemplateDraft([{ ...base, field_rules: [{ field: "state", compare: "not_worse", rank: ["up", "down"] }, { field: "count", compare: "numeric_min", tolerance: 5 }] }]);
|
||||
validateTemplateDraft([{ ...base, field_rules: [{ field: "state", side: "before", value_map: { a: "b" } }, { field: "state", side: "after", value_map: { c: "d" } }] }]);
|
||||
assert.throws(() => validateTemplateDraft([{ ...base, field_rules: [{ field: "state", value_map: { a: "b" } }, { field: "state", value_map: { c: "d" } }] }]), /duplicate_field/);
|
||||
assert.throws(() => validateTemplateDraft([{ ...base, field_rules: [{ field: "state", value_map: { a: 1 } }] }]), /string_pairs/);
|
||||
});
|
||||
|
||||
test("filter side survives the editor round trip", () => {
|
||||
const groups = toOrGroups([{ field: "type", op: "eq", value: "dynamic", side: "before" }]);
|
||||
assert.deepEqual(fromOrGroups(groups), [{ field: "type", op: "eq", value: "dynamic", side: "before" }]);
|
||||
});
|
||||
|
|
|
|||
30
web/tests/mappingText.test.mjs
Normal file
30
web/tests/mappingText.test.mjs
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { parseMapText, mapTextDirty } from "../src/features/cutover/lib/mappingText.ts";
|
||||
|
||||
test("comma, pipe, tab and exactly-two-token space lines are all accepted", () => {
|
||||
const r = parseMapText("a1,b1\na2|b2\na3\tb3\na4 b4\n# note\n\nbefore_if,after_if");
|
||||
assert.deepEqual(r.rows.map((x) => [x.before_if, x.after_if]), [["a1", "b1"], ["a2", "b2"], ["a3", "b3"], ["a4", "b4"]]);
|
||||
assert.deepEqual(r.invalidLines, []);
|
||||
});
|
||||
|
||||
test("unreadable lines are reported with their line numbers instead of dropped", () => {
|
||||
const r = parseMapText("a1,b1\nonlyone\na,b,c\na5 b5 c5\nok1,ok2");
|
||||
assert.deepEqual(r.invalidLines, [2, 3, 4]);
|
||||
assert.equal(r.rows.length, 2);
|
||||
});
|
||||
|
||||
test("repeated before port is flagged and the first pair is kept", () => {
|
||||
const r = parseMapText("a,b\na,c");
|
||||
assert.deepEqual(r.duplicateBefore, ["a"]);
|
||||
assert.deepEqual(r.rows, [{ before_if: "a", after_if: "b" }]);
|
||||
});
|
||||
|
||||
test("dirty check ignores order and spacing but sees edits and invalid text", () => {
|
||||
const saved = [{ before_if: "a", after_if: "b" }, { before_if: "c", after_if: "d" }];
|
||||
assert.equal(mapTextDirty("c , d\na,b", saved), false);
|
||||
assert.equal(mapTextDirty("a,b", saved), true);
|
||||
assert.equal(mapTextDirty("a,b\nc,d\nbroken", saved), true);
|
||||
assert.equal(mapTextDirty("", undefined), false);
|
||||
assert.equal(mapTextDirty("x,y", undefined), true);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue