import { Button, Input, Modal } from "@heroui/react"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import type { KeyboardEvent as ReactKeyboardEvent } from "react"; import { parseTemplateImport, parseIfaceNormText, toOrGroups, fromOrGroups, validateTemplateDraft } from "./compareTemplateModel"; import type { MetricSheet, FieldRule, RowFilter } from "./compareTemplateModel"; import { ListPager } from "../../components/ListPager"; import { AppModalShell } from "../../components/ui/AppModalShell"; import { FieldSelect } from "../../components/ui/FieldSelect"; import { useDebouncedValue } from "../../hooks/useDebouncedValue"; import { useToast } from "../../hooks/useToast"; import { useI18n } from "../../i18n"; import { bizCompareCancelRun, bizCompareCreateJob, bizCompareCreateMapping, bizCompareCreateTemplate, bizCompareDeleteJob, bizCompareDeleteRun, bizCompareDeleteTemplate, bizCompareDownloadRun, bizCompareGetRun, bizCompareListJobs, bizCompareListMappings, bizCompareListMetrics, bizCompareListRunDiffs, bizCompareListRuns, bizCompareListTemplates, bizCompareRunJob, bizCompareUpdateJob, bizCompareUpdateMapping, bizCompareUpdateTemplate, bizCompareValidateMapping, bizStateListBatches, bizStateListTasks, formatErr, } from "../../services/api"; import { formatSystemTime } from "../../utils/time"; import { cutoverCachedGet, cutoverCachedGetSWR, invalidateCutoverCache } from "./cutoverDataCache"; import { jobChipColor, NmStatusChip } from "./nmChips"; type PageTab = "templates" | "jobs"; type JobDetailTab = "config" | "runs" | "result"; type KindFilter = "diff" | "all" | "added" | "removed" | "changed" | "unchanged"; type DiffListSource = "stored" | "live" | ""; type CreateJobStep = 0 | 1 | 2 | 3; const CREATE_JOB_STEPS = 4; type TaskOpt = { id: string; ne_name: string; ne_ip: string; vendor: string }; type BatchOpt = { id: string; status: string; row_count: number; started_at?: string | null; alias?: string; }; type MetricField = { name: string; display_name: string; dtype: string; is_key: boolean; is_interface: boolean; role: string; }; type MetricSchema = { metric_id: string; fields: MetricField[] }; type Template = { id: string; name: string; metrics?: MetricSheet[]; metric_ids?: string[]; metric_id: string; key_fields: string[]; iface_fields: string[]; compare_fields: string[]; iface_normalize_rules?: { from: string; to: string }[]; note: string; job_count?: number; }; type Mapping = { id: string; name: string; rows: { before_if: string; after_if: string }[] }; type StoreUnchanged = "auto" | "always" | "never" | "sample" | "keys"; type Job = { id: string; name: string; template_id: string; mapping_id: string; before_task_id: string; after_task_id: string; before_batch_id: string; after_batch_id: string; mode: string; status: string; enabled_sheet_ids?: string[]; store_unchanged?: StoreUnchanged | string; note?: string; }; type DiffRow = { kind: string; key: Record; before: Record | null; after: Record | null; mapped_before?: Record | null; changes: Record; }; type RunSheet = { sheet_id?: string; title?: string; metric_id: string; key_fields: string[]; iface_fields: string[]; compare_fields: string[]; display_fields?: string[]; field_rules?: FieldRule[]; mode?: string; /** pending|running|queued|done|cancelled — set while overall run is active */ status?: string; summary?: Record; diffs?: DiffRow[]; }; type RunRecord = { id: string; status?: string; created_at?: string; before?: SideInfo; after?: SideInfo; summary?: { added?: number; removed?: number; changed?: number; unchanged?: number; before_count?: number; after_count?: number; duration_ms?: number; progress?: RunProgressInfo; }; progress?: RunProgressInfo; message?: string; }; function fmtTime(v?: string | null) { if (!v) return "—"; return formatSystemTime(v) || v; } function cellText(v: unknown): string { if (v === null || v === undefined) return ""; if (typeof v === "object") return JSON.stringify(v); return String(v); } function failFieldNames(d: DiffRow): string[] { if (d.kind === "added" || d.kind === "removed") return []; return Object.keys(d.changes || {}); } /** Prefer first non-empty row dict (API may return {} for null sides). */ function pickSideRow( ...candidates: Array | null | undefined> ): Record { for (const c of candidates) { if (c && typeof c === "object" && Object.keys(c).length > 0) return c; } return {}; } /** Non-key fields: always show before/after pair for cutover review. */ function PairCell(props: { beforeText: string; afterText: string; kind: string; mismatch: boolean; reason?: string; beforeLabel: string; afterLabel: string; zoneStart?: boolean; }) { const { beforeText, afterText, kind, mismatch, reason, beforeLabel, afterLabel, zoneStart = false, } = props; const pre = beforeText || "—"; const post = afterText || "—"; const isAdded = kind === "added"; const isRemoved = kind === "removed"; const same = !mismatch && !isAdded && !isRemoved && String(beforeText || "") === String(afterText || ""); // Whole-row missing/extra: emphasize the present side; do not strike it out. const preClass = [ "bs-cmp-val", !beforeText || isAdded ? "is-empty" : "", isRemoved && beforeText ? "bs-cmp-val--present-pre" : "", !isAdded && !isRemoved && mismatch && beforeText ? "bs-cmp-val--pre" : "", ] .filter(Boolean) .join(" "); const postClass = [ "bs-cmp-val", !afterText || isRemoved ? "is-empty" : "", isAdded && afterText ? "bs-cmp-val--present-post" : "", !isAdded && !isRemoved && mismatch && afterText ? "bs-cmp-val--post" : "", ] .filter(Boolean) .join(" "); return ( {same ? (
{pre || "—"}
) : (
{beforeLabel} {isAdded ? "—" : pre}
{afterLabel} {isRemoved ? "—" : post}
)} {reason ?
{reason}
: null} ); } function toggleInList(list: string[], name: string, on: boolean): string[] { if (on) return list.includes(name) ? list : [...list, name]; return list.filter((x) => x !== name); } function sideDeviceName(side?: { ne_name?: string; ne_ip?: string; label?: string; batch_id?: string; } | null): string { if (!side) return "—"; const name = String(side.ne_name || "").trim(); const ip = String(side.ne_ip || "").trim(); const label = String(side.label || "").trim(); // Prefer real device identity; ignore hex-ish fallbacks that look like batch ids if (name) return name; if (ip) return ip; if (label && !/^[a-f0-9]{8,32}$/i.test(label)) return label; return "—"; } function sideCollectTime(side?: { started_at?: string | null } | null): string { const raw = side?.started_at; if (!raw) return "—"; const full = fmtTime(raw); if (!full || full === raw) { // Prefer compact local time when possible try { const d = new Date(/[zZ]$|[+-]\d{2}:\d{2}$/.test(String(raw)) ? raw : `${String(raw).replace(" ", "T")}Z`); if (!Number.isNaN(d.getTime())) { const m = d.getMonth() + 1; const day = d.getDate(); const hh = String(d.getHours()).padStart(2, "0"); const mm = String(d.getMinutes()).padStart(2, "0"); return `${m}/${day} ${hh}:${mm}`; } } catch { /* fall through */ } return full || "—"; } // Compact: drop seconds / year noise from locale string when long // e.g. "2026/9/18 09:35:53" -> "9/18 09:35" const m = String(full).match(/(?:\d{4}[/-])?(\d{1,2})[/-](\d{1,2})\s+(\d{2}):(\d{2})/); if (m) return `${m[1]}/${m[2]} ${m[3]}:${m[4]}`; return full; } type SideInfo = { ne_name?: string; ne_ip?: string; label?: string; batch_id?: string; started_at?: string | null; }; function enrichSide( side: Record | null | undefined, taskId: string, tasks: TaskOpt[], batches: BatchOpt[], ): SideInfo { const base: SideInfo = { ...(side || {}) } as SideInfo; const bid = String(base.batch_id || "").trim(); if (!base.started_at && bid) { const batch = batches.find((b) => b.id === bid); if (batch?.started_at) base.started_at = batch.started_at; } if (sideDeviceName(base) === "—") { const task = tasks.find((t) => t.id === taskId); if (task) { base.ne_name = task.ne_name || base.ne_name; base.ne_ip = task.ne_ip || base.ne_ip; base.label = task.ne_name || task.ne_ip || base.label; } } return base; } function taskLabel(row: TaskOpt) { return `${row.ne_name || row.ne_ip || row.id} (${row.vendor || "-"})`; } function batchOptLabel(b: BatchOpt) { const alias = String(b.alias || "").trim(); const when = fmtTime(b.started_at); const tail = `${b.status} · rows=${b.row_count}`; if (alias) return `${alias} · ${when} · ${tail}`; return `${when} · ${tail}`; } function sheetIdentity(s: { sheet_id?: string; metric_id?: string } | null | undefined): string { return String(s?.sheet_id || s?.metric_id || "").trim(); } function sheetLabel(s: { title?: string; sheet_id?: string; metric_id?: string } | null | undefined): string { return String(s?.title || s?.sheet_id || s?.metric_id || "").trim() || "—"; } type RunProgressInfo = { phase?: string; sheet_index?: number; sheet_total?: number; sheet_title?: string; sheet_id?: string; elapsed_ms?: number; load_side?: string; rows_loaded?: number; engine?: string; engine_note?: string; persisted?: number; persist_total?: number; }; function localizeRunPhase(phase: string | undefined, t: (key: string) => string): string { const p = String(phase || "").trim().toLowerCase(); if (!p || p === "…") return "…"; if (p.startsWith("persisting")) { if (p.includes("fail")) return t("bizCompare.phasePersistingFail"); if (p.includes("ok")) return t("bizCompare.phasePersistingOk"); return t("bizCompare.phasePersisting"); } const map: Record = { queued: "bizCompare.phaseQueued", loading: "bizCompare.phaseLoading", sql_count: "bizCompare.phaseSqlCount", sql_project: "bizCompare.phaseSqlProject", sql_join: "bizCompare.phaseSqlJoin", sql_fail_fetch: "bizCompare.phaseSqlFailFetch", comparing: "bizCompare.phaseComparing", done: "bizCompare.phaseDone", failed: "bizCompare.phaseFailed", cancelled: "bizCompare.phaseCancelled", }; const key = map[p]; return key ? t(key) : String(phase); } function runProgressPercent(prog: RunProgressInfo): number { const total = Number(prog.sheet_total || 0); const idx = Number(prog.sheet_index || 0); if (total <= 0) return 8; const phase = String(prog.phase || "").toLowerCase(); if (phase === "queued") return 4; if (phase === "done") return 100; let base = Math.max(0, Math.min(idx, total)) / total; const persistTotal = Number(prog.persist_total || 0); const persisted = Number(prog.persisted || 0); if (persistTotal > 0 && phase.startsWith("persisting")) { const within = Math.min(1, persisted / persistTotal) * (1 / total); base = Math.max(0, (idx - 1) / total) + within; } else if (phase === "loading" || phase === "sql_count" || phase === "comparing") { base = Math.max(0, (idx - 1) / total) + 0.35 / total; } return Math.max(4, Math.min(99, Math.round(base * 100))); } function runProgressMetaLine( prog: RunProgressInfo, t: (key: string, vars?: Record) => string, ): string { const parts: string[] = []; const phase = localizeRunPhase(prog.phase, t); if (phase && phase !== "…") parts.push(phase); const total = Number(prog.sheet_total || 0); if (total > 0) { parts.push( t("bizCompare.runSheetProgress", { i: String(prog.sheet_index || 0), n: String(total), }), ); } const title = String(prog.sheet_title || prog.sheet_id || "").trim(); if (title) parts.push(title); const rows = Number(prog.rows_loaded || 0); if (rows > 0) { const eng = String(prog.engine || "").toLowerCase(); parts.push( eng === "sql" ? t("bizCompare.runRowsSqlCount", { side: String(prog.load_side || "—"), n: String(rows), }) : t("bizCompare.runRowsLoaded", { side: String(prog.load_side || "—"), n: String(rows), }), ); } const persistTotal = Number(prog.persist_total || 0); if (String(prog.phase || "").startsWith("persisting") && persistTotal > 0) { parts.push( t("bizCompare.runPersisting", { done: String(prog.persisted || 0), total: String(persistTotal), }), ); } return parts.join(" · "); } function templateSheets(tpl?: Template | null): MetricSheet[] { if (!tpl) return []; if (tpl.metrics?.length) return tpl.metrics; if (tpl.metric_id) { return [ { sheet_id: tpl.metric_id, title: tpl.metric_id, metric_id: tpl.metric_id, key_fields: [...(tpl.key_fields || [])], iface_fields: [...(tpl.iface_fields || [])], compare_fields: [...(tpl.compare_fields || [])], display_fields: [...(tpl.key_fields || []), ...(tpl.compare_fields || [])], row_filters: [], field_rules: [], }, ]; } return []; } function defaultSheetForMetric(schema: MetricSchema | undefined, metricId: string): MetricSheet { const fields = schema?.fields || []; const key_fields = fields.filter((f) => f.is_key).map((f) => f.name); const compare_fields = fields .filter((f) => !f.is_key && (f.role === "state" || f.role === "meta")) .map((f) => f.name); const sheet: MetricSheet = { sheet_id: metricId, title: metricId, metric_id: metricId, key_fields, iface_fields: fields.filter((f) => f.is_interface).map((f) => f.name), compare_fields, display_fields: [...key_fields, ...compare_fields], row_filters: [], field_rules: [], }; // Match backend ARP defaults when adding from UI if (metricId === "arp") { sheet.row_filters = [ { any: [ { field: "entry_type", op: "eq", value: "dynamic" }, { all: [ { field: "entry_type", op: "empty" }, { field: "age", op: "age_timer" }, ], }, ], }, ]; if (fields.some((f) => f.name === "mac")) { sheet.field_rules = [{ field: "mac", normalize: "mac" }]; } const ctx = ["vrf", "entry_type", "age"].filter( (n) => !key_fields.includes(n) && !compare_fields.includes(n) && fields.some((f) => f.name === n), ); sheet.display_fields = [...key_fields, ...compare_fields, ...ctx]; } return sheet; } function cloneSheet(s: MetricSheet): MetricSheet { const keys = [...(s.key_fields || [])]; const compare = [...(s.compare_fields || [])]; const display = s.display_fields && s.display_fields.length ? [...s.display_fields] : [...keys, ...compare]; return { sheet_id: s.sheet_id || s.metric_id, title: s.title || s.sheet_id || s.metric_id, metric_id: s.metric_id, key_fields: keys, iface_fields: [...(s.iface_fields || [])], compare_fields: compare, display_fields: display, row_filters: JSON.parse(JSON.stringify(s.row_filters || [])), field_rules: JSON.parse(JSON.stringify(s.field_rules || [])), ignore_port_changes: s.ignore_port_changes, }; } 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, })); return { format: TEMPLATE_EXPORT_FORMAT, version: TEMPLATE_EXPORT_VERSION, exported_at: new Date().toISOString(), name: tpl.name, note: tpl.note || "", iface_normalize_rules: [...(tpl.iface_normalize_rules || [])], metrics, }; } function ifaceNormRulesToText(rules?: { from: string; to: string }[]): string { return (rules || []).map((r) => `${r.from},${r.to}`).join("\n"); } function downloadJsonFile(filename: string, data: unknown) { const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = filename; a.click(); URL.revokeObjectURL(url); } function ruleForField(sheet: MetricSheet, field: string): FieldRule { return (sheet.field_rules || []).find((r) => r.field === field) || { field }; } function upsertFieldRule(sheet: MetricSheet, field: string, patch: Partial): FieldRule[] { const rules = [...(sheet.field_rules || [])]; const idx = rules.findIndex((r) => r.field === field); const next: FieldRule = { ...(idx >= 0 ? rules[idx] : { field }), ...patch, field }; const norm = (next.normalize || "").toLowerCase(); const cmp = (next.compare || "").toLowerCase(); const empty = !next.ignore && (!cmp || cmp === "eq") && (!norm || norm === "none" || norm === "strip") && (next.tolerance === undefined || next.tolerance === null); if (empty) { return rules.filter((r) => r.field !== field); } if (idx >= 0) rules[idx] = next; else rules.push(next); return rules; } function emptyLeaf(field = ""): RowFilter { return { field, op: "eq", value: "" }; } const FILTER_OPS = [ "eq", "ne", "in", "not_in", "contains", "empty", "not_empty", "regex", "age_timer", ] as const; function TplRowFiltersEditor({ groups, fieldOpts, t, onChange, }: { groups: RowFilter[][]; fieldOpts: string[]; t: (k: string, vars?: Record) => string; onChange: (next: RowFilter[][]) => void; }) { const setGroups = onChange; return (

{t("bizCompare.rowFiltersHintShort")}

{groups.length ? ( ) : null}
{!groups.length ?

{t("bizCompare.rowFiltersEmpty")}

: null} {groups.map((group, gi) => (
{gi > 0 ?
{t("bizCompare.filterOr")}
: null}
{t("bizCompare.filterGroupN", { n: String(gi + 1) })}
{group.map((filt, fi) => (
{fi > 0 ? (
{t("bizCompare.filterAnd")}
) : null}
{ const next = groups.map((g) => g.map((x) => ({ ...x }))); const op = next[gi][fi]?.op || "eq"; let value: string | string[] = e.target.value; if (["in", "not_in", "nin"].includes(op)) { value = e.target.value .split(",") .map((x) => x.trim()) .filter(Boolean); } next[gi][fi] = { ...next[gi][fi], value }; setGroups(next); }} />
))}
))}
); } export type BizComparePageMode = "jobs" | "templates" | "all"; export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePageMode }) { const { t } = useI18n(); const { showOk, showError } = useToast(); const [pageTab, setPageTab] = useState(pageMode === "templates" ? "templates" : "jobs"); // Route-bound modes must follow pageMode; internal tabs only when pageMode === "all". // Without this, React reuses the same component instance across // /biz-compare ↔ /compare-templates and pageTab stays stuck. const activeTab: PageTab = pageMode === "templates" ? "templates" : pageMode === "jobs" ? "jobs" : pageTab; const showTabSwitch = pageMode === "all"; const [busy, setBusy] = useState(false); const [tasks, setTasks] = useState([]); const [metrics, setMetrics] = useState([]); const [templates, setTemplates] = useState([]); const [mappings, setMappings] = useState([]); const [jobs, setJobs] = useState([]); const [listKw, setListKw] = useState(""); const [listPage, setListPage] = useState(1); const [listPageSize, setListPageSize] = useState(50); const debouncedListKw = useDebouncedValue(listKw, 250); // template editor const [tplOpen, setTplOpen] = useState(false); const [tplError, setTplError] = useState(""); const [tplEditId, setTplEditId] = useState(""); const [tplName, setTplName] = useState(""); const [tplNote, setTplNote] = useState(""); const [tplNormText, setTplNormText] = useState(""); const [tplSheets, setTplSheets] = useState([]); const [tplSheetIdx, setTplSheetIdx] = useState(0); const [tplPaneTab, setTplPaneTab] = useState<"fields" | "filters">("fields"); const [showMetricPicker, setShowMetricPicker] = useState(true); // job create / detail const [jobCreateOpen, setJobCreateOpen] = useState(false); const [createStep, setCreateStep] = useState(0); const [jobId, setJobId] = useState(""); const [jobDetailTab, setJobDetailTab] = useState("config"); const [name, setName] = useState(""); const [templateId, setTemplateId] = useState(""); const [enabledSheetIds, setEnabledSheetIds] = useState([]); const [mappingId, setMappingId] = useState(""); const [beforeTaskId, setBeforeTaskId] = useState(""); const [afterTaskId, setAfterTaskId] = useState(""); const [beforeBatches, setBeforeBatches] = useState([]); const [afterBatches, setAfterBatches] = useState([]); const [beforeBatchId, setBeforeBatchId] = useState(""); const [afterBatchId, setAfterBatchId] = useState(""); const [mode, setMode] = useState<"manual" | "auto">("manual"); const [storeUnchanged, setStoreUnchanged] = useState("auto"); const [mapName, setMapName] = useState("端口映射"); const [mapText, setMapText] = useState(""); const [validateOut, setValidateOut] = useState(null); const [runs, setRuns] = useState([]); const [runsLoading, setRunsLoading] = useState(false); const [runsError, setRunsError] = useState(""); const [runDetail, setRunDetail] = useState(null); const [resultSheetId, setResultSheetId] = useState(""); // result filters (server-paged) const [kindFilter, setKindFilter] = useState("diff"); const [resultKw, setResultKw] = useState(""); const debouncedResultKw = useDebouncedValue(resultKw, 300); const [resultKeyFilters, setResultKeyFilters] = useState>({}); const [keyFiltersOpen, setKeyFiltersOpen] = useState(false); const [hideDisplayCols, setHideDisplayCols] = useState(true); const debouncedKeyFiltersJson = useDebouncedValue(JSON.stringify(resultKeyFilters), 300); const debouncedKeyFilters = useMemo(() => { try { const o = JSON.parse(debouncedKeyFiltersJson || "{}") as Record; const out: Record = {}; for (const [k, v] of Object.entries(o || {})) { if (String(k || "").trim() && String(v || "").trim()) out[String(k)] = String(v).trim(); } return out; } catch { return {} as Record; } }, [debouncedKeyFiltersJson]); const hasResultSearch = Boolean(debouncedResultKw.trim()) || Object.keys(debouncedKeyFilters).length > 0; const activeKeyFilterCount = Object.keys(resultKeyFilters).filter((k) => String(resultKeyFilters[k] || "").trim(), ).length; const keyFiltersVisible = keyFiltersOpen || activeKeyFilterCount > 0; const [resultPage, setResultPage] = useState(1); const [resultPageSize, setResultPageSize] = useState(100); const [resultTotal, setResultTotal] = useState(0); const [pagedDiffs, setPagedDiffs] = useState([]); const [diffsLoading, setDiffsLoading] = useState(false); const [diffsSource, setDiffsSource] = useState(""); const [diffsTruncated, setDiffsTruncated] = useState(false); const [diffsError, setDiffsError] = useState(""); const [diffsRetry, setDiffsRetry] = useState(0); const jobRequestRef = useRef(0); const runRequestRef = useRef(0); const listsRequestRef = useRef(0); const diffFiltersRef = useRef(""); const boardRef = useRef(null); const tableScrollRef = useRef(null); const tableScrollPosRef = useRef({ top: 0, left: 0 }); const [boardFs, setBoardFs] = useState(false); const [navCollapsed, setNavCollapsed] = useState(false); const tplImportRef = useRef(null); const refresh = useCallback(async (opts?: { force?: boolean }) => { const request = ++listsRequestRef.current; type Bundle = { taskRes: Awaited>; tpl: Awaited>; maps: Awaited>; j: Awaited>; met: Awaited>; }; const fetchBundle = async (): Promise => { const [taskRes, tpl, maps, j, met] = await Promise.all([ pageMode === "templates" ? Promise.resolve({items: []}) : bizStateListTasks(), bizCompareListTemplates(), pageMode === "templates" ? Promise.resolve({items: []}) : bizCompareListMappings(), pageMode === "templates" ? Promise.resolve({items: []}) : bizCompareListJobs(), cutoverCachedGet("bizCompare:metrics", () => bizCompareListMetrics(), {ttlMs: 60000}), ]); return { taskRes, tpl, maps, j, met }; }; const apply = (b: Bundle) => { if (request !== listsRequestRef.current) return; setTasks((b.taskRes.items || []) as TaskOpt[]); setTemplates((b.tpl.items || []) as Template[]); setMappings((b.maps.items || []) as Mapping[]); setJobs((b.j.items || []) as Job[]); setMetrics((b.met.items || []) as MetricSchema[]); }; if (opts?.force) { apply(await cutoverCachedGet(`bizCompare:lists:${pageMode}`, fetchBundle, { force: true })); return; } apply(await cutoverCachedGetSWR(`bizCompare:lists:${pageMode}`, fetchBundle, apply)); }, [pageMode]); useEffect(() => { void (async () => { try { await refresh(); } catch (e) { showError(formatErr(e)); } })(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [refresh]); useEffect(() => () => { listsRequestRef.current += 1; }, []); // Keep local tab + dismiss overlays when route mode flips (component may be reused). useEffect(() => { jobRequestRef.current += 1; runRequestRef.current += 1; if (pageMode === "templates") setPageTab("templates"); else if (pageMode === "jobs") setPageTab("jobs"); setTplOpen(false); setJobCreateOpen(false); setCreateStep(0); setJobId(""); setRuns([]); setRunsLoading(false); setRunsError(""); setRunDetail(null); setResultSheetId(""); if (document.fullscreenElement === boardRef.current) { void document.exitFullscreen().catch(() => undefined); } setBoardFs(false); return () => { jobRequestRef.current += 1; runRequestRef.current += 1; }; }, [pageMode]); useEffect(() => { let cancelled = false; setBeforeBatches([]); void (async () => { if (!beforeTaskId) return; try { const b = await cutoverCachedGet(`bizCompare:batches:${beforeTaskId}`, () => bizStateListBatches(beforeTaskId), { ttlMs: 5000 }); if (!cancelled) setBeforeBatches((b.items || []) as BatchOpt[]); } catch (e) { if (!cancelled) showError(formatErr(e)); } })(); return () => { cancelled = true; }; }, [beforeTaskId, showError]); const effectiveAfterTaskId = afterTaskId || beforeTaskId; useEffect(() => { let cancelled = false; setAfterBatches([]); void (async () => { if (!effectiveAfterTaskId) return; try { const b = await cutoverCachedGet(`bizCompare:batches:${effectiveAfterTaskId}`, () => bizStateListBatches(effectiveAfterTaskId), { ttlMs: 5000 }); if (!cancelled) setAfterBatches((b.items || []) as BatchOpt[]); } catch (e) { if (!cancelled) showError(formatErr(e)); } })(); return () => { cancelled = true; }; }, [effectiveAfterTaskId, showError]); const templatesById = useMemo(() => new Map(templates.map((x) => [x.id, x])), [templates]); const filteredJobs = useMemo(() => { const kw = debouncedListKw.trim().toLowerCase(); if (!kw) return jobs; return jobs.filter((j) => { const tpl = templatesById.get(j.template_id); const mids = (tpl?.metric_ids || templateSheets(tpl).map((s) => s.metric_id)).join(" "); return `${j.name} ${j.mode} ${j.status} ${tpl?.name || ""} ${mids}`.toLowerCase().includes(kw); }); }, [jobs, templatesById, debouncedListKw]); const filteredTemplates = useMemo(() => { const kw = debouncedListKw.trim().toLowerCase(); if (!kw) return templates; return templates.filter((x) => { const mids = (x.metric_ids || templateSheets(x).map((s) => s.metric_id)).join(" "); return `${x.name} ${mids} ${x.note}`.toLowerCase().includes(kw); }); }, [templates, debouncedListKw]); const listTotal = activeTab === "templates" ? filteredTemplates.length : filteredJobs.length; const listPages = Math.max(1, Math.ceil(listTotal / listPageSize)); const visibleListPage = Math.min(listPage, listPages); const selectedJobTemplate = useMemo( () => templatesById.get(templateId) || null, [templatesById, templateId], ); const jobTemplateSheets = useMemo( () => (selectedJobTemplate ? templateSheets(selectedJobTemplate) : []), [selectedJobTemplate], ); const jobSheetAllIds = useMemo( () => jobTemplateSheets.map((s) => sheetIdentity(s)).filter(Boolean), [jobTemplateSheets], ); const isJobSheetOn = useCallback( (sid: string) => { if (!enabledSheetIds.length) return true; return enabledSheetIds.includes(sid); }, [enabledSheetIds], ); const enabledJobSheetCount = useMemo(() => { if (!jobSheetAllIds.length) return 0; if (!enabledSheetIds.length) return jobSheetAllIds.length; return jobSheetAllIds.filter((id) => enabledSheetIds.includes(id)).length; }, [jobSheetAllIds, enabledSheetIds]); const toggleJobSheet = useCallback( (sid: string) => { const all = jobSheetAllIds; if (!all.length) return; const currentlyOn = !enabledSheetIds.length ? [...all] : enabledSheetIds.filter((id) => all.includes(id)); const next = currentlyOn.includes(sid) ? currentlyOn.filter((id) => id !== sid) : [...currentlyOn, sid]; if (!next.length) { showError(t("bizCompare.needSheets")); return; } // Empty list means "all on" (new template sheets auto-included) setEnabledSheetIds(next.length === all.length ? [] : next); }, [jobSheetAllIds, enabledSheetIds, showError, t], ); const setJobTemplateAndSheets = useCallback( (nextTplId: string, presetIds?: string[] | null) => { setTemplateId(nextTplId); if (presetIds && presetIds.length) { // Keep stale IDs visible as a configuration error; [] would enable every sheet. setEnabledSheetIds([...presetIds]); } else { setEnabledSheetIds([]); } }, [], ); const activeTplSheet = tplSheets[tplSheetIdx] || null; const activeTplFields = useMemo(() => { if (!activeTplSheet) return []; const fields = [...(metrics.find((m) => m.metric_id === activeTplSheet.metric_id)?.fields || [])]; const seen = new Set(fields.map((f) => f.name)); for (const name of [...activeTplSheet.key_fields, ...activeTplSheet.iface_fields, ...activeTplSheet.compare_fields, ...(activeTplSheet.display_fields || []), ...(activeTplSheet.field_rules || []).map((r) => r.field)]) { if (!seen.has(name)) { fields.push({name, display_name: name, dtype: "string", is_key: false, is_interface: false, role: "meta"}); seen.add(name); } } return fields; }, [metrics, activeTplSheet]); const activeTplFilterGroups = activeTplSheet ? toOrGroups(activeTplSheet.row_filters || []) : []; const missingJobSheets = enabledSheetIds.filter((id) => !jobSheetAllIds.includes(id)); const runSheets: RunSheet[] = useMemo(() => { const sheets = (runDetail?.sheets || []) as RunSheet[]; if (sheets.length) return sheets; if (runDetail?.diffs) { return [ { metric_id: String(runDetail.metric_id || "result"), key_fields: [], iface_fields: [], compare_fields: [], mode: "fields", summary: runDetail.summary, diffs: runDetail.diffs, }, ]; } return []; }, [runDetail]); useEffect(() => { if (!runSheets.length) { setResultSheetId(""); return; } if (!resultSheetId || !runSheets.some((s) => sheetIdentity(s) === resultSheetId)) { setResultSheetId(sheetIdentity(runSheets[0])); } }, [runSheets, resultSheetId]); const activeRunSheet = useMemo( () => runSheets.find((s) => sheetIdentity(s) === resultSheetId) || runSheets[0] || null, [runSheets, resultSheetId], ); const activeRunSheetId = sheetIdentity(activeRunSheet); const activeRunSheetStatus = String(activeRunSheet?.status || ""); // Clear field filters when switching sheet useEffect(() => { setResultKeyFilters({}); }, [resultSheetId]); useEffect(() => { const runId = String(runDetail?.id || ""); const mid = resultSheetId || activeRunSheetId || ""; const sheetSt = activeRunSheetStatus; // Block only while *this* sheet is still in flight — done sheets are readable mid-run const sheetStillRunning = ["pending", "running", "queued"].includes(sheetSt); if (!runId || !mid || jobDetailTab !== "result" || sheetStillRunning) { setPagedDiffs([]); setResultTotal(0); setDiffsSource(""); setDiffsTruncated(false); setDiffsLoading(false); setDiffsError(""); return; } const filterKey = JSON.stringify([runId, mid, kindFilter, debouncedResultKw, debouncedKeyFiltersJson, resultPageSize]); if (diffFiltersRef.current !== filterKey) { diffFiltersRef.current = filterKey; if (resultPage !== 1) { setPagedDiffs([]); setResultTotal(0); setDiffsLoading(true); setResultPage(1); return; } } let cancelled = false; void (async () => { setDiffsLoading(true); setDiffsError(""); setPagedDiffs([]); setResultTotal(0); setDiffsSource(""); setDiffsTruncated(false); try { const res = await bizCompareListRunDiffs({ runId, metricId: mid, kind: kindFilter, kw: debouncedResultKw.trim(), qf: debouncedKeyFilters, page: resultPage, pageSize: resultPageSize, }); if (cancelled) return; setPagedDiffs((res.items || []) as DiffRow[]); setResultTotal(Number(res.total || 0)); setDiffsSource( String((res as any).source || "").toLowerCase() === "live" ? "live" : "stored", ); setDiffsTruncated(Boolean((res as any).truncated)); const pages = Math.max( 1, Math.ceil(Number(res.total || 0) / Number(res.page_size || resultPageSize)), ); if (resultPage > pages) setResultPage(pages); } catch (e) { if (!cancelled) { setDiffsError(formatErr(e)); } } finally { if (!cancelled) setDiffsLoading(false); } })(); return () => { cancelled = true; }; }, [ runDetail?.id, runDetail?.status, resultSheetId, activeRunSheetId, activeRunSheetStatus, kindFilter, debouncedResultKw, debouncedKeyFiltersJson, debouncedKeyFilters, resultPage, resultPageSize, jobDetailTab, diffsRetry, ]); const resultColumns = useMemo(() => { const keys = activeRunSheet?.key_fields?.length ? activeRunSheet.key_fields : Object.keys((pagedDiffs[0]?.key as Record) || {}); const compare = (activeRunSheet?.compare_fields || []).filter((f) => !keys.includes(f)); const compareSet = new Set(compare); const keySet = new Set(keys); let display = (activeRunSheet?.display_fields || []).filter(Boolean); if (!display.length) { display = [...keys, ...compare]; } // Column order: Key → Compare → Display-only (context) // Compare fields always appear (even if display unticked), matching backend. const compareCols = [...compare]; const displayOnly = display.filter((f) => !keySet.has(f) && !compareSet.has(f)); let extras = [...compareCols, ...displayOnly]; // Presence / sparse display: if no value columns, pull fields from sample // added/removed rows so 缺失/多余 still show side data. if (!extras.length && pagedDiffs.length) { const sample = pagedDiffs.find((d) => d.kind === "added" || d.kind === "removed") || pagedDiffs[0]; const side = pickSideRow( sample?.mapped_before as Record | undefined, sample?.before as Record | undefined, sample?.after as Record | undefined, ); extras = Object.keys(side).filter((f) => !keySet.has(f) && !f.startsWith("_")); } return { keys, compare: compareCols, extras, compareSet, presence: !(activeRunSheet?.compare_fields || []).length, }; }, [activeRunSheet, pagedDiffs]); const kindLabel = (kind: string) => { if (kind === "added") return t("bizCompare.kindAddedShort"); if (kind === "removed") return t("bizCompare.kindRemovedShort"); if (kind === "changed") return t("bizCompare.kindChangedShort"); if (kind === "unchanged") return t("bizCompare.kindUnchangedShort"); return kind; }; /** Table verdict: fail = missing+mismatch; success = match; added is special. */ const rowVerdict = (kind: string): { label: string; tone: "fail" | "pass" | "added" } => { if (kind === "added") return { label: t("bizCompare.kindAddedShort"), tone: "added" }; if (kind === "unchanged") return { label: t("bizCompare.kindSuccess"), tone: "pass" }; return { label: t("bizCompare.kindFail"), tone: "fail" }; }; const failFieldsLabel = (d: DiffRow) => { if (d.kind === "added") return t("bizCompare.kindAddedShort"); if (d.kind === "removed") return t("bizCompare.failWholeRow"); const names = failFieldNames(d); return names.length ? names.join(" · ") : t("bizCompare.failFieldsEmpty"); }; const sheetFailOf = (c: { fail_count?: number; diff_count?: number; removed?: number; changed?: number; added?: number; }) => { // Always derive from removed+changed so "新增" never counts as fail // (legacy diff_count used to include added). if (c.removed !== undefined || c.changed !== undefined) { return Number(c.removed || 0) + Number(c.changed || 0); } if (c.fail_count !== undefined) return Number(c.fail_count || 0); const legacy = Number(c.diff_count || 0); const added = Number(c.added || 0); return Math.max(0, legacy - added); }; const sheetSuccessOf = (c: { success_count?: number; unchanged?: number }) => Number(c.success_count ?? c.unchanged ?? 0); const sheetPassRateOf = (c: { pass_rate?: number | null; status?: string; fail_count?: number; diff_count?: number; removed?: number; changed?: number; success_count?: number; unchanged?: number; added?: number; }): number | null => { const st = String(c.status || ""); if (st === "pending" || st === "running" || st === "queued") return null; if (c.pass_rate === null) return null; // Always recompute from fail/success so added never skews pass rate const fail = sheetFailOf(c); const ok = sheetSuccessOf(c); const judged = fail + ok; if (!judged) return 100; const rate = (ok / judged) * 100; if (fail <= 0) return Math.round(rate * 10) / 10; // 3 fails / 50k ≈ 99.994% — 1-decimal round becomes 100%; keep digits until < 100 for (const nd of [1, 2, 3, 4]) { const r = Math.round(rate * 10 ** nd) / 10 ** nd; if (r < 100) return r; } return 99.9999; }; const formatPassRate = (rate: number | null | undefined) => { if (rate === null || rate === undefined || Number.isNaN(rate)) return "…"; // Trim trailing zeros but keep enough precision for near-100 rates const s = Number(rate).toFixed(4).replace(/\.?0+$/, ""); return `${s}%`; }; const summary = runDetail?.summary || {}; const resultPages = Math.max(1, Math.ceil(resultTotal / Math.max(1, resultPageSize))); const sheetCards = useMemo(() => { const raw = (summary.sheet_cards || []) as Array<{ sheet_id?: string; title?: string; metric_id: string; mode?: string; status?: string; added?: number; removed?: number; changed?: number; unchanged?: number; before_count?: number; after_count?: number; fail_count?: number; success_count?: number; diff_count?: number; pass_rate?: number | null; }>; // Failures first; keep pending/running at the end so completed fails stay visible return [...raw].sort((a, b) => { const pa = ["pending", "running", "queued"].includes(String(a.status || "")) ? 1 : 0; const pb = ["pending", "running", "queued"].includes(String(b.status || "")) ? 1 : 0; if (pa !== pb) return pa - pb; const da = sheetFailOf(a); const db = sheetFailOf(b); if (da !== db) return db - da; return String(a.metric_id).localeCompare(String(b.metric_id)); }); }, [summary.sheet_cards]); const activeSheetCard = sheetCards.find((c) => sheetIdentity(c) === resultSheetId) || sheetCards[0]; const activeFail = sheetFailOf(activeSheetCard || {}); const activeSuccess = sheetSuccessOf(activeSheetCard || {}); const activePassRate = sheetPassRateOf(activeSheetCard || {}); const activeSheetPending = ["pending", "running", "queued"].includes( String(activeSheetCard?.status || ""), ); const activeAdded = Number(activeSheetCard?.added || 0); const activeRemoved = Number(activeSheetCard?.removed || 0); const activeChanged = Number(activeSheetCard?.changed || 0); const showFailCol = kindFilter === "diff" || kindFilter === "all" || kindFilter === "added" || kindFilter === "removed" || kindFilter === "changed"; const isLiveSearch = diffsSource === "live" && hasResultSearch; const resultSearchKeyFields = useMemo(() => { const keys = (activeRunSheet?.key_fields || []).map((f) => String(f || "").trim()).filter(Boolean); // Prefer BGP-ish fields first for compact UI const prefer = ["direction", "neighbor", "network", "rd", "afi", "vrf", "local_as"]; const ranked = [ ...prefer.filter((p) => keys.includes(p)), ...keys.filter((k) => !prefer.includes(k)), ]; return ranked.slice(0, 8); }, [activeRunSheet?.key_fields]); const resultEmptyColSpan = 1 + (showFailCol ? 1 : 0) + resultColumns.keys.length + Math.max( resultColumns.extras.filter( (f) => resultColumns.compareSet.has(f) || !hideDisplayCols, ).length, 0, ); const rememberTableScroll = useCallback(() => { const wrap = tableScrollRef.current; if (!wrap) return; tableScrollPosRef.current = { top: wrap.scrollTop, left: wrap.scrollLeft }; }, []); // Native Fullscreen escapes modal transform containing-block (CSS fixed fails inside dialog). useEffect(() => { const syncFs = () => { const el = boardRef.current; setBoardFs(Boolean(el && document.fullscreenElement === el)); }; document.addEventListener("fullscreenchange", syncFs); return () => document.removeEventListener("fullscreenchange", syncFs); }, []); // CSS-fallback immersive: Esc exits without closing the job modal. useEffect(() => { if (!boardFs || document.fullscreenElement) return; const onKey = (e: KeyboardEvent) => { if (e.key !== "Escape") return; e.preventDefault(); e.stopPropagation(); rememberTableScroll(); setBoardFs(false); }; document.addEventListener("keydown", onKey, true); return () => document.removeEventListener("keydown", onKey, true); }, [boardFs, rememberTableScroll]); // Keep table scroll across fullscreen enter/exit (layout swap otherwise jumps to top). useLayoutEffect(() => { const wrap = tableScrollRef.current; if (!wrap) return; const { top, left } = tableScrollPosRef.current; wrap.scrollTop = top; wrap.scrollLeft = left; }, [boardFs]); const toggleBoardFullscreen = async () => { const el = boardRef.current; if (!el) return; rememberTableScroll(); try { if (document.fullscreenElement === el) { await document.exitFullscreen(); return; } if (!document.fullscreenElement) { await el.requestFullscreen(); return; } } catch { // Native FS blocked — CSS immersive fallback } setBoardFs((v) => !v); }; const downloadRunTables = async () => { if (!runDetail?.id) return; setBusy(true); try { await bizCompareDownloadRun(String(runDetail.id)); showOk(t("bizCompare.exportOk")); } catch (e) { showError(formatErr(e)); } finally { setBusy(false); } }; const updateActiveSheet = (patch: Partial) => { setTplError(""); setTplSheets((prev) => prev.map((s, i) => { if (i !== tplSheetIdx) return s; const next = { ...s, ...patch }; // Keys are identity — drop from compare if (patch.key_fields || patch.compare_fields) { const keySet = new Set(next.key_fields); next.compare_fields = next.compare_fields.filter((f) => !keySet.has(f)); } if (patch.display_fields && !patch.key_fields && !patch.compare_fields) { // Explicit display toggle: keys always forced on const disp = new Set(patch.display_fields); for (const k of next.key_fields) disp.add(k); next.display_fields = [ ...next.key_fields, ...[...disp].filter((f) => !next.key_fields.includes(f)), ]; } else if (patch.key_fields || patch.compare_fields) { const disp = new Set(next.display_fields || []); for (const k of next.key_fields) disp.add(k); if (patch.compare_fields) { const prevCompare = new Set(s.compare_fields || []); for (const f of next.compare_fields) { if (!prevCompare.has(f)) disp.add(f); } } next.display_fields = [ ...next.key_fields, ...[...disp].filter((f) => !next.key_fields.includes(f)), ]; } return next; }), ); }; const openNewTemplate = () => { setTplError(""); setTplEditId(""); setTplName(""); setTplNote(""); setTplNormText("XXVGE,xxvgei\nXGE,xgei\nCGE,cgei\nGE,gei\nSG,smartgroup"); setTplSheets([]); setTplSheetIdx(0); setTplPaneTab("fields"); setShowMetricPicker(true); setTplOpen(true); }; const openEditTemplate = (tpl: Template) => { setTplError(""); const sheets = templateSheets(tpl); setTplEditId(tpl.id); setTplName(tpl.name); setTplNote(tpl.note || ""); setTplNormText(ifaceNormRulesToText(tpl.iface_normalize_rules)); setTplSheets(sheets.length ? sheets.map(cloneSheet) : []); setTplSheetIdx(0); setTplPaneTab("fields"); setShowMetricPicker(!sheets.length); setTplOpen(true); }; const toggleTplMetric = (metricId: string, on: boolean) => { if (on) { const schema = metrics.find((m) => m.metric_id === metricId); setTplSheets((prev) => { const next = [...prev, defaultSheetForMetric(schema, metricId)]; setTplSheetIdx(next.length - 1); if (!prev.length) setShowMetricPicker(false); return next; }); return; } // Uncheck removes ALL sheets of this source metric setTplSheets((prev) => { const next = prev.filter((s) => s.metric_id !== metricId); setTplSheetIdx((cur) => { if (!next.length) return 0; if (cur >= next.length) return next.length - 1; return cur; }); return next; }); }; const duplicateTplSheet = (idx: number) => { setTplSheets((prev) => { const src = prev[idx]; if (!src) return prev; const baseId = sheetIdentity(src); let n = 2; let candidate = `${baseId}.${n}`; const used = new Set(prev.map((s) => sheetIdentity(s))); while (used.has(candidate)) { n += 1; candidate = `${baseId}.${n}`; } const copy = cloneSheet(src); copy.sheet_id = candidate; copy.title = `${sheetLabel(src)} (${n})`; const next = [...prev]; next.splice(idx + 1, 0, copy); setTplSheetIdx(idx + 1); return next; }); }; const removeTplMetric = (idx: number) => { setTplSheets((prev) => { const next = prev.filter((_, i) => i !== idx); setTplSheetIdx((cur) => { if (!next.length) return 0; if (cur > idx) return cur - 1; if (cur >= next.length) return next.length - 1; return cur; }); return next; }); }; const saveTemplate = async () => { setTplError(""); if (!tplSheets.length) { showError(t("bizCompare.metricsRequired")); return; } for (const s of tplSheets) { if (!s.key_fields.length) { showError(`${sheetLabel(s)}: ${t("bizCompare.keyRequired")}`); return; } if (!sheetIdentity(s)) { showError(`${s.metric_id}: ${t("bizCompare.sheetIdRequired")}`); return; } } const ids = tplSheets.map((s) => sheetIdentity(s)); if (new Set(ids).size !== ids.length) { showError(t("bizCompare.duplicateSheetId")); return; } setBusy(true); try { validateTemplateDraft(tplSheets); const body = { 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, })), }; if (tplEditId) await bizCompareUpdateTemplate(tplEditId, body); else await bizCompareCreateTemplate(body); showOk(t("bizCompare.templateSaved")); setTplOpen(false); invalidateCutoverCache("bizMonitor:"); invalidateCutoverCache("bizMigration:"); await refresh({ force: true }); } catch (e) { setTplError(formatErr(e)); showError(formatErr(e)); } finally { setBusy(false); } }; const removeTemplate = async (id: string) => { if (!window.confirm(t("bizCompare.confirmDeleteTemplate"))) return; setBusy(true); try { await bizCompareDeleteTemplate(id); showOk(t("bizCompare.templateDeleted")); invalidateCutoverCache("bizMonitor:"); invalidateCutoverCache("bizMigration:"); await refresh({ force: true }); } catch (e) { showError(formatErr(e)); } finally { setBusy(false); } }; const exportTemplate = (tpl: Template) => { const payload = templateExportPayload(tpl); const safe = (tpl.name || "template").replace(/[^\w\u4e00-\u9fff.-]+/g, "_").slice(0, 64); downloadJsonFile(`netx-compare-template-${safe}.json`, payload); showOk(t("bizCompare.templateExported")); }; const importTemplateFile = async (file: File) => { setBusy(true); try { const text = await file.text(); let parsed: unknown; try { parsed = JSON.parse(text); } catch { showError(t("bizCompare.templateImportInvalid")); return; } const body = parseTemplateImport(parsed); if (!body) { showError(t("bizCompare.templateImportInvalid")); return; } await bizCompareCreateTemplate({ 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, })), }); showOk(t("bizCompare.templateImported")); invalidateCutoverCache("bizMonitor:"); invalidateCutoverCache("bizMigration:"); await refresh({ force: true }); if (pageMode !== "jobs") setPageTab("templates"); } catch (e) { showError(formatErr(e)); } finally { setBusy(false); if (tplImportRef.current) tplImportRef.current.value = ""; } }; const removeJob = async (id: string) => { if (!window.confirm(t("bizCompare.confirmDeleteJob"))) return; setBusy(true); try { await bizCompareDeleteJob(id); if (jobId === id) closeJob(); showOk(t("bizCompare.jobDeleted")); await refresh({ force: true }); } catch (e) { showError(formatErr(e)); } finally { setBusy(false); } }; 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; setMappingId(id); setMapName(m.name); setMapText(m.rows.map((r) => `${r.before_if},${r.after_if}`).join("\n")); }; 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 }); } catch (e) { showError(formatErr(e)); } finally { setBusy(false); } }; const doValidate = async () => { if (!mappingId || !beforeBatchId || !afterBatchId) { showError(t("bizCompare.validateNeed")); return; } setBusy(true); try { const v = await bizCompareValidateMapping({ mapping_id: mappingId, before_batch_id: beforeBatchId, after_batch_id: afterBatchId, template_id: templateId, }); setValidateOut(v); } catch (e) { showError(formatErr(e)); } finally { setBusy(false); } }; const resetJobForm = (preset?: Partial) => { const tplId = preset?.template_id || templates[0]?.id || ""; setName(preset?.name || t("bizCompare.defaultJobName")); setTemplateId(tplId); const presetIds = (preset?.enabled_sheet_ids || []).map(String).filter(Boolean); if (presetIds.length) { setEnabledSheetIds(presetIds); } else { setEnabledSheetIds([]); } setMappingId(preset?.mapping_id || ""); setBeforeTaskId(preset?.before_task_id || ""); setAfterTaskId(preset?.after_task_id || ""); setBeforeBatchId(preset?.before_batch_id || ""); setAfterBatchId(preset?.after_batch_id || ""); setMode(preset?.mode === "auto" ? "auto" : "manual"); { const su = String(preset?.store_unchanged || "auto"); setStoreUnchanged( su === "always" || su === "never" || su === "sample" || su === "keys" ? su : "auto", ); } setValidateOut(null); if (preset?.mapping_id) loadMappingText(preset.mapping_id); else { setMapName(t("bizCompare.mapping")); setMapText(""); } }; const jobConfigBody = () => ({ name, template_id: templateId, mapping_id: mappingId, before_task_id: beforeTaskId, after_task_id: afterTaskId || beforeTaskId, before_batch_id: beforeBatchId, after_batch_id: mode === "manual" ? afterBatchId : "", mode, enabled_sheet_ids: enabledSheetIds, store_unchanged: storeUnchanged, }); const renderStoreUnchangedField = () => ( setStoreUnchanged(e.target.value as StoreUnchanged)} fullWidth > ); const closeCreateJob = () => { setJobCreateOpen(false); setCreateStep(0); }; const openCreateJob = () => { resetJobForm(); setCreateStep(0); setJobCreateOpen(true); }; const canAdvanceCreateStep = (step: CreateJobStep): boolean => { if (step === 0) return Boolean(name.trim() && templateId); if (step === 1) return enabledJobSheetCount > 0; if (step === 2) { if (!beforeTaskId || !beforeBatchId) return false; if (mode === "manual" && !afterBatchId) return false; return true; } return true; }; const createStepBlockReason = (step: CreateJobStep): string | null => { if (step === 0) { if (!name.trim()) return t("bizCompare.needJobName"); if (!templateId) return t("bizCompare.needTemplate"); return null; } if (step === 1) { if (!enabledJobSheetCount) return t("bizCompare.needSheets"); return null; } if (step === 2) { if (!beforeTaskId || !beforeBatchId) return t("bizCompare.needBeforeBatch"); if (mode === "manual" && !afterBatchId) return t("bizCompare.needAfterBatch"); return null; } return null; }; const onCreateNext = () => { const reason = createStepBlockReason(createStep); if (reason) { showError(reason); return; } setCreateStep((s) => Math.min(CREATE_JOB_STEPS - 1, (s + 1) as CreateJobStep) as CreateJobStep); }; const onCreateBack = () => { setCreateStep((s) => Math.max(0, s - 1) as CreateJobStep); }; const createJob = async () => { const reason = createStepBlockReason(0) || createStepBlockReason(1) || createStepBlockReason(2); if (reason) { showError(reason); return; } setBusy(true); try { const j = await bizCompareCreateJob(jobConfigBody()); showOk(t("bizCompare.created")); closeCreateJob(); await refresh({ force: true }); await openJob(String(j.id)); } catch (e) { showError(formatErr(e)); } finally { setBusy(false); } }; const openJob = async (id: string) => { const request = ++jobRequestRef.current; runRequestRef.current += 1; setJobId(id); setJobDetailTab("config"); setRunDetail(null); setRuns([]); setRunsLoading(true); setRunsError(""); setResultSheetId(""); setKindFilter("diff"); setResultKw(""); const job = jobs.find((x) => x.id === id); if (job) resetJobForm(job); try { const r = await bizCompareListRuns(id, 50); if (request !== jobRequestRef.current) return; const items = (r.items || []) as RunRecord[]; setRuns(items); if (items.length) { const latest = await bizCompareGetRun(items[0].id); if (request !== jobRequestRef.current) return; setRunDetail(latest); // Land on batch list so stuck/cancelled runs are visible and actionable setJobDetailTab("runs"); } } catch (e) { if (request === jobRequestRef.current) { setRunsError(formatErr(e)); showError(formatErr(e)); } } finally { if (request === jobRequestRef.current) setRunsLoading(false); } }; const refreshRuns = async () => { if (!jobId || runsLoading) return; const request = jobRequestRef.current; setRunsLoading(true); setRunsError(""); try { const response = await bizCompareListRuns(jobId, 50); if (request === jobRequestRef.current) setRuns((response.items || []) as RunRecord[]); } catch (e) { if (request === jobRequestRef.current) setRunsError(formatErr(e)); } finally { if (request === jobRequestRef.current) setRunsLoading(false); } }; const handleJobTabKeyDown = (event: ReactKeyboardEvent) => { const tabs: JobDetailTab[] = ["config", "runs", "result"]; const current = tabs.indexOf(jobDetailTab); const next = event.key === "ArrowRight" ? (current + 1) % tabs.length : event.key === "ArrowLeft" ? (current + tabs.length - 1) % tabs.length : event.key === "Home" ? 0 : event.key === "End" ? tabs.length - 1 : -1; if (next < 0) return; event.preventDefault(); setJobDetailTab(tabs[next]); if (tabs[next] === "runs") void refreshRuns(); event.currentTarget.querySelector(`#compare-${tabs[next]}-tab`)?.focus(); }; const closeJob = () => { jobRequestRef.current += 1; runRequestRef.current += 1; if (document.fullscreenElement === boardRef.current) { void document.exitFullscreen().catch(() => undefined); } setBoardFs(false); setJobId(""); setRuns([]); setRunsLoading(false); setRunsError(""); setRunDetail(null); setResultSheetId(""); }; const saveJobConfig = async () => { if (!jobId) return; if (missingJobSheets.length) { showError(t("bizCompare.missingSheets")); return; } if (!enabledJobSheetCount) { showError(t("bizCompare.needSheets")); return; } setBusy(true); try { await bizCompareUpdateJob(jobId, jobConfigBody()); showOk(t("bizCompare.jobSaved")); await refresh({ force: true }); } catch (e) { showError(formatErr(e)); } finally { setBusy(false); } }; const runStatus = String(runDetail?.status || ""); const runIsActive = runStatus === "running" || runStatus === "queued"; const jobHasActiveRun = runs.some((r) => { const st = String(r.status || ""); return st === "running" || st === "queued"; }); const runCounts = useMemo(() => ({ total: runs.length, active: runs.filter((r) => r.status === "running" || r.status === "queued").length, completed: runs.filter((r) => r.status === "success").length, stopped: runs.filter((r) => r.status === "failed" || r.status === "cancelled").length, }), [runs]); const runNow = async () => { if (!jobId) return; if (missingJobSheets.length) { showError(t("bizCompare.missingSheets")); return; } if (!enabledJobSheetCount) { showError(t("bizCompare.needSheets")); return; } if (jobHasActiveRun) { showError(t("bizCompare.compareAlreadyRunning")); setJobDetailTab("runs"); return; } const request = jobRequestRef.current; setBusy(true); try { await bizCompareUpdateJob(jobId, jobConfigBody()); // Async enqueue — returns immediately with status=running; poll below. const run = await bizCompareRunJob(jobId); if (request !== jobRequestRef.current) return; runRequestRef.current += 1; setRunDetail(run); setJobDetailTab("runs"); showOk(t("bizCompare.runStarted")); const r = await bizCompareListRuns(jobId, 50); if (request !== jobRequestRef.current) return; setRuns((r.items || []) as RunRecord[]); await refresh({ force: true }); } catch (e) { if (request === jobRequestRef.current) { showError(formatErr(e)); setJobDetailTab("runs"); } } finally { setBusy(false); } }; const runProgress = (runDetail?.summary?.progress || {}) as RunProgressInfo; const runEngine = String(runProgress.engine || "").toLowerCase(); // One poller; schedule the next tick after requests settle to prevent overlap. useEffect(() => { if (!jobId || (!runIsActive && !(jobHasActiveRun && jobDetailTab === "runs"))) return; let cancelled = false; let notified = false; let timer: number | undefined; const jobRequest = jobRequestRef.current; const runId = String(runDetail?.id || ""); const tick = async () => { const runRequest = runRequestRef.current; try { const [r, d] = await Promise.all([ bizCompareListRuns(jobId, 50), runIsActive && runId ? bizCompareGetRun(runId) : Promise.resolve(null), ]); if (cancelled || jobRequest !== jobRequestRef.current) return; setRuns((r.items || []) as RunRecord[]); if (!d || runRequest !== runRequestRef.current) return; setRunDetail((prev: typeof runDetail) => String(prev?.id || "") === runId ? d : prev); const st = String(d.status || ""); if (!notified && st === "success") { notified = true; const ms = Number((d.summary as any)?.duration_ms || 0); const sec = ms > 0 ? Math.round(ms / 1000) : 0; showOk(sec > 0 ? t("bizCompare.ranWithDuration", { s: String(sec) }) : t("bizCompare.ran")); } else if (!notified && st === "failed") { notified = true; showError(String(d.message || t("bizCompare.runFailed"))); } else if (!notified && st === "cancelled") { notified = true; showError(String(d.message || t("bizCompare.runCancelled"))); } } catch (e) { if (!cancelled) showError(formatErr(e)); } finally { if (!cancelled) timer = window.setTimeout(() => void tick(), 2000); } }; void tick(); return () => { cancelled = true; window.clearTimeout(timer); }; }, [runDetail?.id, runIsActive, jobId, jobHasActiveRun, jobDetailTab, showOk, showError, t]); const loadRun = async (runId: string) => { const request = ++runRequestRef.current; const jobRequest = jobRequestRef.current; try { const d = await bizCompareGetRun(runId); if (request !== runRequestRef.current || jobRequest !== jobRequestRef.current) return; setRunDetail(d); setKindFilter("diff"); setResultKw(""); setResultKeyFilters({}); setJobDetailTab("result"); } catch (e) { if (request === runRequestRef.current && jobRequest === jobRequestRef.current) showError(formatErr(e)); } }; const cancelRun = async (runId: string) => { if (!runId) return; if (!window.confirm(t("bizCompare.confirmCancelRun"))) return; const request = jobRequestRef.current; setBusy(true); try { const d = await bizCompareCancelRun(runId); if (request !== jobRequestRef.current) return; setRunDetail((prev: typeof runDetail) => String(prev?.id || "") === runId ? d : prev); if (jobId) { const r = await bizCompareListRuns(jobId, 50); if (request !== jobRequestRef.current) return; setRuns((r.items || []) as RunRecord[]); } showOk(t("bizCompare.runCancelled")); } catch (e) { showError(formatErr(e)); } finally { setBusy(false); } }; const removeRun = async (runId: string) => { if (!runId) return; if (!window.confirm(t("bizCompare.confirmDeleteRun"))) return; const request = jobRequestRef.current; const runRequest = runRequestRef.current; setBusy(true); try { const wasCurrent = String(runDetail?.id || "") === runId; await bizCompareDeleteRun(runId); if (request !== jobRequestRef.current) return; let nextRuns: typeof runs = []; if (jobId) { const r = await bizCompareListRuns(jobId, 50); if (request !== jobRequestRef.current) return; nextRuns = (r.items || []) as RunRecord[]; } else { nextRuns = (runs || []).filter((r) => String(r.id) !== runId); } setRuns(nextRuns); if (wasCurrent && runRequest === runRequestRef.current) { runRequestRef.current += 1; setRunDetail(null); setResultSheetId(""); setJobDetailTab("runs"); } showOk(t("bizCompare.runDeleted")); } catch (e) { showError(formatErr(e)); } finally { setBusy(false); } }; const renderSheetChips = () => (
{t("bizCompare.enabledSheets")} {enabledJobSheetCount}/{jobSheetAllIds.length || 0}

{t("bizCompare.enabledSheetsHint")}

{missingJobSheets.length ?
{t("bizCompare.missingSheets")} · {missingJobSheets.join(", ")}
: null}
{jobTemplateSheets.map((s) => { const sid = sheetIdentity(s); const on = isJobSheetOn(sid); return ( ); })}
{!jobTemplateSheets.length ? (

{t("bizCompare.noTemplateSheets")}

) : null} {jobTemplateSheets.length && !enabledJobSheetCount ? (

{t("bizCompare.needSheets")}

) : null}
); const renderMappingBlock = () => (

{t("bizCompare.mappingOptionalHint")}

{ const id = e.target.value; if (id) loadMappingText(id); else { setMappingId(""); setMapText(""); } }} > {mappings.map((m) => ( ))}