mirror of
https://github.com/hansjone/netx.git
synced 2026-10-12 00:40:44 +08:00
4301 lines
177 KiB
TypeScript
4301 lines
177 KiB
TypeScript
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<string, unknown>;
|
||
before: Record<string, unknown> | null;
|
||
after: Record<string, unknown> | null;
|
||
mapped_before?: Record<string, unknown> | null;
|
||
changes: Record<string, { before?: unknown; after?: unknown; reason?: string }>;
|
||
};
|
||
|
||
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<string, number>;
|
||
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<Record<string, unknown> | null | undefined>
|
||
): Record<string, unknown> {
|
||
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 (
|
||
<td
|
||
className={`bs-cmp-val-cell bs-cmp-val-cell--pair${
|
||
mismatch || isAdded || isRemoved ? " bs-cmp-val-cell--diff" : ""
|
||
}${same ? " is-same" : ""}${isAdded ? " is-added" : ""}${isRemoved ? " is-removed" : ""}${
|
||
zoneStart ? " bs-cmp-zone-start" : ""
|
||
}`}
|
||
title={
|
||
same
|
||
? pre
|
||
: `${beforeLabel} ${isAdded ? "—" : pre} → ${afterLabel} ${isRemoved ? "—" : post}`
|
||
}
|
||
>
|
||
{same ? (
|
||
<div className="bs-cmp-pair bs-cmp-pair--same">
|
||
<span className="bs-cmp-val bs-cmp-val--same">{pre || "—"}</span>
|
||
</div>
|
||
) : (
|
||
<div className="bs-cmp-pair">
|
||
<div className="bs-cmp-pair__row">
|
||
<span className="bs-cmp-pair__tag">{beforeLabel}</span>
|
||
<span className={preClass}>{isAdded ? "—" : pre}</span>
|
||
</div>
|
||
<div className="bs-cmp-pair__row">
|
||
<span className="bs-cmp-pair__tag">{afterLabel}</span>
|
||
<span className={postClass}>{isRemoved ? "—" : post}</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{reason ? <div className="bs-cmp-val-reason muted">{reason}</div> : null}
|
||
</td>
|
||
);
|
||
}
|
||
|
||
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<string, unknown> | 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<string, string> = {
|
||
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>) => 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>): 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, string | number>) => string;
|
||
onChange: (next: RowFilter[][]) => void;
|
||
}) {
|
||
const setGroups = onChange;
|
||
return (
|
||
<div className="ct-filters">
|
||
<p className="muted ct-filters__hint">{t("bizCompare.rowFiltersHintShort")}</p>
|
||
<div className="ct-filters__actions">
|
||
<button
|
||
type="button"
|
||
className="mt-link-btn"
|
||
onClick={() => setGroups([...(groups.length ? groups : []), [emptyLeaf(fieldOpts[0] || "")]])}
|
||
>
|
||
{t("bizCompare.filterAddOrShort")}
|
||
</button>
|
||
{groups.length ? (
|
||
<button type="button" className="mt-link-btn" onClick={() => setGroups([])}>
|
||
{t("bizCompare.filterClear")}
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
{!groups.length ? <p className="muted ct-filters__empty">{t("bizCompare.rowFiltersEmpty")}</p> : null}
|
||
{groups.map((group, gi) => (
|
||
<div key={gi} className="mt-or-wrap">
|
||
{gi > 0 ? <div className="mt-logic-badge mt-logic-badge--or">{t("bizCompare.filterOr")}</div> : null}
|
||
<div className="mt-and-box">
|
||
<div className="mt-and-box__head">
|
||
<span className="muted">{t("bizCompare.filterGroupN", { n: String(gi + 1) })}</span>
|
||
<button
|
||
type="button"
|
||
className="mt-icon-btn"
|
||
aria-label={t("bizCompare.filterRemoveGroup")}
|
||
onClick={() => setGroups(groups.filter((_, i) => i !== gi))}
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
{group.map((filt, fi) => (
|
||
<div key={fi}>
|
||
{fi > 0 ? (
|
||
<div className="mt-logic-badge mt-logic-badge--and">{t("bizCompare.filterAnd")}</div>
|
||
) : null}
|
||
<div className="ct-filter-row">
|
||
<select
|
||
className="mt-select"
|
||
aria-label={`${t("bizCompare.filterField")} ${gi + 1}.${fi + 1}`}
|
||
value={filt.field || ""}
|
||
onChange={(e) => {
|
||
const next = groups.map((g) => g.map((x) => ({ ...x })));
|
||
next[gi][fi] = { ...next[gi][fi], field: e.target.value };
|
||
setGroups(next);
|
||
}}
|
||
>
|
||
<option value="">{t("bizCompare.filterField")}</option>
|
||
{fieldOpts.map((name) => (
|
||
<option key={name} value={name}>
|
||
{name}
|
||
</option>
|
||
))}
|
||
{filt.field && !fieldOpts.includes(filt.field) ? <option value={filt.field}>{filt.field}</option> : null}
|
||
</select>
|
||
<select
|
||
className="mt-select"
|
||
aria-label={`${t("bizCompare.filterOperator")} ${gi + 1}.${fi + 1}`}
|
||
value={filt.op || "eq"}
|
||
onChange={(e) => {
|
||
const next = groups.map((g) => g.map((x) => ({ ...x })));
|
||
const op = e.target.value;
|
||
const value = next[gi][fi].value;
|
||
next[gi][fi] = { ...next[gi][fi], op, value: ["in", "not_in", "nin"].includes(op)
|
||
? (Array.isArray(value) ? value : String(value ?? "").split(",").map((x) => x.trim()).filter(Boolean))
|
||
: (Array.isArray(value) ? value.join(",") : value) };
|
||
setGroups(next);
|
||
}}
|
||
>
|
||
{FILTER_OPS.map((op) => (
|
||
<option key={op} value={op}>
|
||
{op}
|
||
</option>
|
||
))}
|
||
{filt.op && !(FILTER_OPS as readonly string[]).includes(filt.op) ? <option value={filt.op}>{filt.op}</option> : null}
|
||
</select>
|
||
<Input
|
||
value={
|
||
Array.isArray(filt.value) ? filt.value.join(",") : String(filt.value ?? "")
|
||
}
|
||
placeholder={t("bizCompare.filterValue")}
|
||
aria-label={`${t("bizCompare.filterValue")} ${gi + 1}.${fi + 1}`}
|
||
disabled={["empty", "not_empty", "nonempty", "age_timer"].includes(filt.op || "")}
|
||
onChange={(e) => {
|
||
const next = groups.map((g) => g.map((x) => ({ ...x })));
|
||
const op = next[gi][fi]?.op || "eq";
|
||
let value: string | string[] = e.target.value;
|
||
if (["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);
|
||
}}
|
||
/>
|
||
<button
|
||
type="button"
|
||
className="mt-icon-btn"
|
||
aria-label={t("bizCompare.filterRemoveGroup")}
|
||
onClick={() => {
|
||
const next = groups.map((g) => g.map((x) => ({ ...x })));
|
||
next[gi].splice(fi, 1);
|
||
setGroups(next.filter((g) => g.length));
|
||
}}
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
<button
|
||
type="button"
|
||
className="mt-link-btn"
|
||
onClick={() => {
|
||
const next = groups.map((g) => g.map((x) => ({ ...x })));
|
||
next[gi].push(emptyLeaf(fieldOpts[0] || ""));
|
||
setGroups(next);
|
||
}}
|
||
>
|
||
{t("bizCompare.filterAddAndShort")}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export type BizComparePageMode = "jobs" | "templates" | "all";
|
||
|
||
export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePageMode }) {
|
||
const { t } = useI18n();
|
||
const { showOk, showError } = useToast();
|
||
|
||
const [pageTab, setPageTab] = useState<PageTab>(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<TaskOpt[]>([]);
|
||
const [metrics, setMetrics] = useState<MetricSchema[]>([]);
|
||
const [templates, setTemplates] = useState<Template[]>([]);
|
||
const [mappings, setMappings] = useState<Mapping[]>([]);
|
||
const [jobs, setJobs] = useState<Job[]>([]);
|
||
const [listKw, setListKw] = useState("");
|
||
const [listPage, setListPage] = useState(1);
|
||
const [listPageSize, setListPageSize] = useState(50);
|
||
const debouncedListKw = useDebouncedValue(listKw, 250);
|
||
|
||
// template editor
|
||
const [tplOpen, setTplOpen] = useState(false);
|
||
const [tplError, setTplError] = useState("");
|
||
const [tplEditId, setTplEditId] = useState("");
|
||
const [tplName, setTplName] = useState("");
|
||
const [tplNote, setTplNote] = useState("");
|
||
const [tplNormText, setTplNormText] = useState("");
|
||
const [tplSheets, setTplSheets] = useState<MetricSheet[]>([]);
|
||
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<CreateJobStep>(0);
|
||
const [jobId, setJobId] = useState("");
|
||
const [jobDetailTab, setJobDetailTab] = useState<JobDetailTab>("config");
|
||
const [name, setName] = useState("");
|
||
const [templateId, setTemplateId] = useState("");
|
||
const [enabledSheetIds, setEnabledSheetIds] = useState<string[]>([]);
|
||
const [mappingId, setMappingId] = useState("");
|
||
const [beforeTaskId, setBeforeTaskId] = useState("");
|
||
const [afterTaskId, setAfterTaskId] = useState("");
|
||
const [beforeBatches, setBeforeBatches] = useState<BatchOpt[]>([]);
|
||
const [afterBatches, setAfterBatches] = useState<BatchOpt[]>([]);
|
||
const [beforeBatchId, setBeforeBatchId] = useState("");
|
||
const [afterBatchId, setAfterBatchId] = useState("");
|
||
const [mode, setMode] = useState<"manual" | "auto">("manual");
|
||
const [storeUnchanged, setStoreUnchanged] = useState<StoreUnchanged>("auto");
|
||
const [mapName, setMapName] = useState("端口映射");
|
||
const [mapText, setMapText] = useState("");
|
||
const [validateOut, setValidateOut] = useState<any>(null);
|
||
const [runs, setRuns] = useState<RunRecord[]>([]);
|
||
const [runsLoading, setRunsLoading] = useState(false);
|
||
const [runsError, setRunsError] = useState("");
|
||
const [runDetail, setRunDetail] = useState<any>(null);
|
||
const [resultSheetId, setResultSheetId] = useState("");
|
||
|
||
// result filters (server-paged)
|
||
const [kindFilter, setKindFilter] = useState<KindFilter>("diff");
|
||
const [resultKw, setResultKw] = useState("");
|
||
const debouncedResultKw = useDebouncedValue(resultKw, 300);
|
||
const [resultKeyFilters, setResultKeyFilters] = useState<Record<string, string>>({});
|
||
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<string, string>;
|
||
const out: Record<string, string> = {};
|
||
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<string, string>;
|
||
}
|
||
}, [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<DiffRow[]>([]);
|
||
const [diffsLoading, setDiffsLoading] = useState(false);
|
||
const [diffsSource, setDiffsSource] = useState<DiffListSource>("");
|
||
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<HTMLDivElement | null>(null);
|
||
const tableScrollRef = useRef<HTMLDivElement | null>(null);
|
||
const tableScrollPosRef = useRef({ top: 0, left: 0 });
|
||
const [boardFs, setBoardFs] = useState(false);
|
||
const [navCollapsed, setNavCollapsed] = useState(false);
|
||
const tplImportRef = useRef<HTMLInputElement | null>(null);
|
||
|
||
const refresh = useCallback(async (opts?: { force?: boolean }) => {
|
||
const request = ++listsRequestRef.current;
|
||
type Bundle = {
|
||
taskRes: Awaited<ReturnType<typeof bizStateListTasks>>;
|
||
tpl: Awaited<ReturnType<typeof bizCompareListTemplates>>;
|
||
maps: Awaited<ReturnType<typeof bizCompareListMappings>>;
|
||
j: Awaited<ReturnType<typeof bizCompareListJobs>>;
|
||
met: Awaited<ReturnType<typeof bizCompareListMetrics>>;
|
||
};
|
||
const fetchBundle = async (): Promise<Bundle> => {
|
||
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<string, unknown>) || {});
|
||
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<string, unknown> | undefined,
|
||
sample?.before as Record<string, unknown> | undefined,
|
||
sample?.after as Record<string, unknown> | 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<MetricSheet>) => {
|
||
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<Job>) => {
|
||
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 = () => (
|
||
<FieldSelect
|
||
label={t("bizCompare.storeUnchanged")}
|
||
value={storeUnchanged}
|
||
onChange={(e) => setStoreUnchanged(e.target.value as StoreUnchanged)}
|
||
fullWidth
|
||
>
|
||
<option value="auto">{t("bizCompare.storeUnchangedAuto")}</option>
|
||
<option value="sample">{t("bizCompare.storeUnchangedSample")}</option>
|
||
<option value="keys">{t("bizCompare.storeUnchangedKeys")}</option>
|
||
<option value="always">{t("bizCompare.storeUnchangedAlways")}</option>
|
||
<option value="never">{t("bizCompare.storeUnchangedNever")}</option>
|
||
</FieldSelect>
|
||
);
|
||
|
||
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<HTMLDivElement>) => {
|
||
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<HTMLButtonElement>(`#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 = () => (
|
||
<div className="bs-cmp-scope">
|
||
<div className="bs-cmp-scope__heading">
|
||
{t("bizCompare.enabledSheets")}
|
||
<span className="bs-cmp-scope__count">
|
||
{enabledJobSheetCount}/{jobSheetAllIds.length || 0}
|
||
</span>
|
||
</div>
|
||
<p className="muted bm-hint">{t("bizCompare.enabledSheetsHint")}</p>
|
||
{missingJobSheets.length ? <div className="bs-cmp-error" role="alert"><span>{t("bizCompare.missingSheets")} · {missingJobSheets.join(", ")}</span><Button size="sm" variant="secondary" isDisabled={!jobSheetAllIds.length} onPress={() => setEnabledSheetIds([])}>{t("bizCompare.enableCurrentSheets")}</Button></div> : null}
|
||
<div className="bs-cmp-scope__items">
|
||
{jobTemplateSheets.map((s) => {
|
||
const sid = sheetIdentity(s);
|
||
const on = isJobSheetOn(sid);
|
||
return (
|
||
<button
|
||
key={sid}
|
||
type="button"
|
||
className={`bs-cmp-scope__item${on ? " is-on" : ""}`}
|
||
aria-pressed={on}
|
||
disabled={on && enabledJobSheetCount === 1}
|
||
onClick={() => toggleJobSheet(sid)}
|
||
title={s.metric_id !== sid ? s.metric_id : undefined}
|
||
>
|
||
<span className="bs-cmp-scope__check" aria-hidden="true">{on ? "✓" : ""}</span>
|
||
<span className="bs-cmp-scope__content">
|
||
<strong>{sheetLabel(s)}</strong>
|
||
<span>{s.compare_fields.length ? t("bizCompare.modeFieldsShort") : t("bizCompare.presenceShort")}</span>
|
||
</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
{!jobTemplateSheets.length ? (
|
||
<p className="muted bm-hint">{t("bizCompare.noTemplateSheets")}</p>
|
||
) : null}
|
||
{jobTemplateSheets.length && !enabledJobSheetCount ? (
|
||
<p className="form-error bm-hint">{t("bizCompare.needSheets")}</p>
|
||
) : null}
|
||
</div>
|
||
);
|
||
|
||
const renderMappingBlock = () => (
|
||
<div className="bs-cmp-mapping">
|
||
<p className="bs-cmp-config__hint">{t("bizCompare.mappingOptionalHint")}</p>
|
||
<div className="bs-cmp-mapping__fields">
|
||
<FieldSelect
|
||
label={t("bizCompare.selectMapping")}
|
||
value={mappingId}
|
||
onChange={(e) => {
|
||
const id = e.target.value;
|
||
if (id) loadMappingText(id);
|
||
else {
|
||
setMappingId("");
|
||
setMapText("");
|
||
}
|
||
}}
|
||
>
|
||
<option value="">{t("bizCompare.newMapping")}</option>
|
||
{mappings.map((m) => (
|
||
<option key={m.id} value={m.id}>
|
||
{m.name}
|
||
</option>
|
||
))}
|
||
</FieldSelect>
|
||
<label className="ui-field ui-field--full">
|
||
<span className="ui-field__label">{t("bizCompare.mapName")}</span>
|
||
<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__actions">
|
||
<Button size="sm" variant="secondary" isDisabled={busy} onPress={() => void saveMapping()}>
|
||
{t("bizCompare.saveMapping")}
|
||
</Button>
|
||
<Button size="sm" variant="secondary" isDisabled={busy} onPress={() => void doValidate()}>
|
||
{t("bizCompare.validateMapping")}
|
||
</Button>
|
||
</div>
|
||
{validateOut ? (
|
||
<pre className="muted" style={{ fontSize: 12, maxHeight: 120, overflow: "auto" }}>
|
||
{JSON.stringify(validateOut, null, 2)}
|
||
</pre>
|
||
) : null}
|
||
</div>
|
||
);
|
||
|
||
const renderBatchFields = () => (
|
||
<div className="bs-cmp-sources">
|
||
<section className="bs-cmp-source" aria-label={t("bizCompare.sideBefore")}>
|
||
<header className="bs-cmp-source__head">
|
||
<span className="bs-cmp-source__tag" aria-hidden="true">PRE</span>
|
||
<strong>{t("bizCompare.sideBefore")}</strong>
|
||
<span>{t("bizCompare.sourceBaseline")}</span>
|
||
</header>
|
||
<FieldSelect
|
||
label={t("bizCompare.beforeTask")}
|
||
value={beforeTaskId}
|
||
onChange={(e) => {
|
||
setBeforeTaskId(e.target.value);
|
||
setBeforeBatchId("");
|
||
if (!afterTaskId) setAfterBatchId("");
|
||
}}
|
||
fullWidth
|
||
>
|
||
<option value="">{t("bizCompare.pick")}</option>
|
||
{tasks.map((row) => (
|
||
<option key={row.id} value={row.id}>
|
||
{taskLabel(row)}
|
||
</option>
|
||
))}
|
||
</FieldSelect>
|
||
<FieldSelect
|
||
label={t("bizCompare.beforeBatch")}
|
||
value={beforeBatchId}
|
||
onChange={(e) => setBeforeBatchId(e.target.value)}
|
||
fullWidth
|
||
>
|
||
<option value="">{t("bizCompare.pick")}</option>
|
||
{beforeBatches.map((b) => (
|
||
<option key={b.id} value={b.id} disabled={b.status !== "success"}>
|
||
{batchOptLabel(b)}
|
||
</option>
|
||
))}
|
||
</FieldSelect>
|
||
<div className="bs-cmp-source__meta">
|
||
<span>{tasks.find((row) => row.id === beforeTaskId)?.ne_ip || "—"}</span>
|
||
<span>{t("bizCompare.sourceRows", { n: beforeBatches.find((b) => b.id === beforeBatchId)?.row_count?.toLocaleString() ?? "—" })}</span>
|
||
</div>
|
||
</section>
|
||
<section className="bs-cmp-source is-after" aria-label={t("bizCompare.sideAfter")}>
|
||
<header className="bs-cmp-source__head">
|
||
<span className="bs-cmp-source__tag" aria-hidden="true">POST</span>
|
||
<strong>{t("bizCompare.sideAfter")}</strong>
|
||
<span>{mode === "auto" ? t("bizCompare.sourceLatest") : t("bizCompare.sourceTarget")}</span>
|
||
</header>
|
||
<FieldSelect
|
||
label={t("bizCompare.afterTask")}
|
||
value={afterTaskId}
|
||
onChange={(e) => {
|
||
setAfterTaskId(e.target.value);
|
||
setAfterBatchId("");
|
||
}}
|
||
fullWidth
|
||
>
|
||
<option value="">{t("bizCompare.sameAsBefore")}</option>
|
||
{tasks.map((row) => (
|
||
<option key={row.id} value={row.id}>
|
||
{taskLabel(row)}
|
||
</option>
|
||
))}
|
||
</FieldSelect>
|
||
{mode === "manual" ? (
|
||
<FieldSelect
|
||
label={t("bizCompare.afterBatch")}
|
||
value={afterBatchId}
|
||
onChange={(e) => setAfterBatchId(e.target.value)}
|
||
fullWidth
|
||
>
|
||
<option value="">{t("bizCompare.pick")}</option>
|
||
{afterBatches.map((b) => (
|
||
<option key={b.id} value={b.id} disabled={b.status !== "success"}>
|
||
{batchOptLabel(b)}
|
||
</option>
|
||
))}
|
||
</FieldSelect>
|
||
) : (
|
||
<p className="bs-cmp-config__hint bs-cmp-source__auto">{t("bizCompare.autoSourceHint")}</p>
|
||
)}
|
||
<div className="bs-cmp-source__meta">
|
||
<span>{tasks.find((row) => row.id === (afterTaskId || beforeTaskId))?.ne_ip || "—"}</span>
|
||
<span>{mode === "auto" ? t("bizCompare.sourceLatest") : t("bizCompare.sourceRows", { n: afterBatches.find((b) => b.id === afterBatchId)?.row_count?.toLocaleString() ?? "—" })}</span>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
);
|
||
|
||
const renderBasicFields = () => (
|
||
<div className="bs-cmp-config__fields">
|
||
<label className="ui-field ui-field--full">
|
||
<span className="ui-field__label">{t("bizCompare.jobName")}</span>
|
||
<Input value={name} onChange={(e) => setName(e.target.value)} />
|
||
</label>
|
||
<FieldSelect
|
||
label={t("bizCompare.template")}
|
||
value={templateId}
|
||
onChange={(e) => setJobTemplateAndSheets(e.target.value)}
|
||
fullWidth
|
||
>
|
||
{templates.map((tpl) => {
|
||
const n = templateSheets(tpl).length;
|
||
return (
|
||
<option key={tpl.id} value={tpl.id}>
|
||
{tpl.name} ({n} {t("bizCompare.sheetsUnit")})
|
||
</option>
|
||
);
|
||
})}
|
||
</FieldSelect>
|
||
<FieldSelect
|
||
label={t("bizCompare.mode")}
|
||
value={mode}
|
||
onChange={(e) => setMode(e.target.value as "manual" | "auto")}
|
||
fullWidth
|
||
>
|
||
<option value="manual">{t("bizCompare.modeManual")}</option>
|
||
<option value="auto">{t("bizCompare.modeAuto")}</option>
|
||
</FieldSelect>
|
||
{renderStoreUnchangedField()}
|
||
<p className="bs-cmp-config__hint is-wide">{t("bizCompare.storeUnchangedHint")}</p>
|
||
</div>
|
||
);
|
||
|
||
const renderJobForm = () => (
|
||
<div className="bs-cmp-form bs-cmp-config" role="tabpanel" id="compare-config-panel" aria-labelledby="compare-config-tab">
|
||
<section className="bs-cmp-config__section is-wide" aria-labelledby="compare-basic-title">
|
||
<h3 className="bs-cmp-config__title" id="compare-basic-title"><span>01</span>{t("bizCompare.configBasics")}</h3>
|
||
{renderBasicFields()}
|
||
</section>
|
||
<section className="bs-cmp-config__section is-wide" aria-labelledby="compare-source-title">
|
||
<h3 className="bs-cmp-config__title" id="compare-source-title"><span>02</span>{t("bizCompare.configSources")}</h3>
|
||
{renderBatchFields()}
|
||
</section>
|
||
<section className="bs-cmp-config__section" aria-labelledby="compare-scope-title">
|
||
<h3 className="bs-cmp-config__title" id="compare-scope-title"><span>03</span>{t("bizCompare.configScope")}</h3>
|
||
{renderSheetChips()}
|
||
</section>
|
||
<section className="bs-cmp-config__section" aria-labelledby="compare-mapping-title">
|
||
<h3 className="bs-cmp-config__title" id="compare-mapping-title"><span>04</span>{t("bizCompare.mapping")}</h3>
|
||
{renderMappingBlock()}
|
||
</section>
|
||
</div>
|
||
);
|
||
|
||
return (
|
||
<section className={`panel nm-page-panel bs-cmp-workspace${activeTab === "templates" ? " bs-template-workspace" : ""}`}>
|
||
<div className="panel__toolbar">
|
||
<h2>
|
||
{pageMode === "templates" ? t("bizCompare.templates") : t("bizCompare.title")}
|
||
</h2>
|
||
<div className="btn-row">
|
||
{activeTab === "templates" ? (
|
||
<>
|
||
<input
|
||
ref={tplImportRef}
|
||
type="file"
|
||
accept="application/json,.json"
|
||
style={{ display: "none" }}
|
||
onChange={(e) => {
|
||
const f = e.target.files?.[0];
|
||
if (f) void importTemplateFile(f);
|
||
}}
|
||
/>
|
||
<Button
|
||
size="sm"
|
||
variant="secondary"
|
||
isDisabled={busy}
|
||
onPress={() => tplImportRef.current?.click()}
|
||
>
|
||
{t("bizCompare.importTemplate")}
|
||
</Button>
|
||
<Button size="sm" variant="primary" isDisabled={busy} onPress={openNewTemplate}>
|
||
{t("bizCompare.newTemplate")}
|
||
</Button>
|
||
</>
|
||
) : (
|
||
<Button size="sm" variant="primary" onPress={openCreateJob}>
|
||
{t("bizCompare.createCompare")}
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="pt-list">
|
||
{showTabSwitch ? (
|
||
<div className="btn-row nm-config-modal__tabs" role="tablist">
|
||
<Button
|
||
size="sm"
|
||
variant={activeTab === "jobs" ? "primary" : "secondary"}
|
||
className={activeTab === "jobs" ? "is-active" : undefined}
|
||
onPress={() => setPageTab("jobs")}
|
||
>
|
||
{t("bizCompare.jobList")}
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant={activeTab === "templates" ? "primary" : "secondary"}
|
||
className={activeTab === "templates" ? "is-active" : undefined}
|
||
onPress={() => setPageTab("templates")}
|
||
>
|
||
{t("bizCompare.templates")}
|
||
</Button>
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="filter-inline">
|
||
<Input
|
||
value={listKw}
|
||
aria-label={activeTab === "jobs" ? t("bizCompare.jobFilterPh") : t("bizCompare.templateFilterPh")}
|
||
placeholder={
|
||
activeTab === "jobs" ? t("bizCompare.jobFilterPh") : t("bizCompare.templateFilterPh")
|
||
}
|
||
onChange={(e) => { setListKw(e.target.value); setListPage(1); }}
|
||
/>
|
||
</div>
|
||
|
||
{activeTab === "templates" ? (
|
||
<div className="pt-list-table-wrap">
|
||
<p className="muted" style={{ margin: "0 0 8px" }}>
|
||
{t("bizCompare.templateImportHint")}
|
||
</p>
|
||
<table className="data-table pt-list-table">
|
||
<thead>
|
||
<tr>
|
||
<th>{t("bizCompare.colName")}</th>
|
||
<th>{t("bizCompare.colSheets")}</th>
|
||
<th>{t("bizCompare.note")}</th>
|
||
<th>{t("bizCompare.colActions")}</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{filteredTemplates.slice((visibleListPage - 1) * listPageSize, visibleListPage * listPageSize).map((tpl) => {
|
||
const sheets = templateSheets(tpl);
|
||
return (
|
||
<tr key={tpl.id}>
|
||
<td>
|
||
<div className="pt-list-task-name">{tpl.name}</div>
|
||
{tpl.job_count ? <div className="muted">{t("bizCompare.templateUsage", {n: tpl.job_count})}</div> : null}
|
||
</td>
|
||
<td>
|
||
<div className="bs-cmp-metric-chips">
|
||
{sheets.map((s, i) => (
|
||
<code key={`${sheetIdentity(s)}-${i}`} className="bs-cmp-metric-chip">
|
||
{sheetLabel(s)}
|
||
{!s.compare_fields?.length ? (
|
||
<span className="muted"> · {t("bizCompare.presenceShort")}</span>
|
||
) : null}
|
||
</code>
|
||
))}
|
||
{!sheets.length ? "—" : null}
|
||
</div>
|
||
</td>
|
||
<td className="muted">{tpl.note || "—"}</td>
|
||
<td>
|
||
<div className="pt-list-actions">
|
||
<Button size="sm" variant="secondary" isDisabled={busy} onPress={() => openEditTemplate(tpl)}>
|
||
{t("bizCompare.edit")}
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant="secondary"
|
||
isDisabled={busy}
|
||
onPress={() => exportTemplate(tpl)}
|
||
>
|
||
{t("bizCompare.exportTemplate")}
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant="danger"
|
||
isDisabled={busy || Boolean(tpl.job_count) || jobs.some((j) => j.template_id === tpl.id)}
|
||
onPress={() => void removeTemplate(tpl.id)}
|
||
>
|
||
{t("bizCompare.delete")}
|
||
</Button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
{!filteredTemplates.length ? (
|
||
<tr>
|
||
<td colSpan={4}>
|
||
<div className="pt-list-empty">{t("bizCompare.emptyTemplates")}</div>
|
||
</td>
|
||
</tr>
|
||
) : null}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
) : (
|
||
<div className="pt-list-table-wrap">
|
||
<table className="data-table pt-list-table">
|
||
<thead>
|
||
<tr>
|
||
<th>{t("bizCompare.colName")}</th>
|
||
<th>{t("bizCompare.template")}</th>
|
||
<th>{t("bizCompare.colMode")}</th>
|
||
<th>{t("bizCompare.colStatus")}</th>
|
||
<th>{t("bizCompare.colActions")}</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{filteredJobs.slice((visibleListPage - 1) * listPageSize, visibleListPage * listPageSize).map((j) => {
|
||
const tpl = templatesById.get(j.template_id);
|
||
const n = templateSheets(tpl).length;
|
||
return (
|
||
<tr key={j.id}>
|
||
<td>
|
||
<div className="pt-list-task-name">{j.name}</div>
|
||
<code className="bs-cmp-runs__id" title={j.id}>{j.id.slice(0, 12)}</code>
|
||
</td>
|
||
<td className="muted">
|
||
{tpl?.name || j.template_id.slice(0, 8)}
|
||
{n ? ` · ${n} ${t("bizCompare.sheetsUnit")}` : ""}
|
||
</td>
|
||
<td>{j.mode === "auto" ? t("bizCompare.modeAuto") : t("bizCompare.modeManual")}</td>
|
||
<td>
|
||
<NmStatusChip color={jobChipColor(j.status)}>
|
||
{j.status === "ready" ? t("bizCompare.jobReady") : j.status === "auto" ? t("bizCompare.modeAuto") : j.status}
|
||
</NmStatusChip>
|
||
</td>
|
||
<td>
|
||
<div className="pt-list-actions">
|
||
<Button size="sm" variant="primary" onPress={() => void openJob(j.id)}>
|
||
{t("bizCompare.detail")}
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
className="bs-cmp-runs__delete"
|
||
isDisabled={busy}
|
||
onPress={() => void removeJob(j.id)}
|
||
>
|
||
{t("bizCompare.deleteJob")}
|
||
</Button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
{!filteredJobs.length ? (
|
||
<tr>
|
||
<td colSpan={5}>
|
||
<div className="pt-list-empty">{t("bizCompare.emptyJobs")}</div>
|
||
</td>
|
||
</tr>
|
||
) : null}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
<ListPager page={visibleListPage} pages={listPages} total={listTotal} pageSize={listPageSize} pageSizeOptions={[20, 50, 100, 200]} onPageChange={setListPage} onPageSizeChange={(n) => { setListPageSize(n); setListPage(1); }} />
|
||
</div>
|
||
|
||
{/* Template editor */}
|
||
<AppModalShell open={tplOpen} onClose={() => { if (!busy) setTplOpen(false); }} dismissible={!busy} size="cover" className="app-heroui-modal--xl bs-template-editor">
|
||
<Modal.Header>
|
||
<Modal.Heading>
|
||
{tplEditId ? t("bizCompare.editTemplate") : t("bizCompare.newTemplate")}
|
||
</Modal.Heading>
|
||
<Modal.CloseTrigger isDisabled={busy} />
|
||
</Modal.Header>
|
||
<Modal.Body className="flex flex-col gap-3 ct-editor" inert={busy}>
|
||
{tplError ? <div className="bs-cmp-error" role="alert">{t("bizCompare.templateValidationFailed")} · {tplError}</div> : null}
|
||
<div className="ct-editor__basics">
|
||
<Input
|
||
value={tplName}
|
||
onChange={(e) => setTplName(e.target.value)}
|
||
placeholder={t("bizCompare.colName")}
|
||
aria-label={t("bizCompare.colName")}
|
||
/>
|
||
<Input
|
||
value={tplNote}
|
||
onChange={(e) => setTplNote(e.target.value)}
|
||
placeholder={t("bizCompare.note")}
|
||
aria-label={t("bizCompare.note")}
|
||
/>
|
||
</div>
|
||
|
||
<details className="ct-editor__norm">
|
||
<summary className="ct-editor__pick-title">{t("bizCompare.ifaceNormalize")}</summary>
|
||
<p className="muted ct-editor__pick-hint">{t("bizCompare.ifaceNormalizeHint")}</p>
|
||
<textarea
|
||
className="ct-editor__norm-area"
|
||
rows={3}
|
||
value={tplNormText}
|
||
onChange={(e) => setTplNormText(e.target.value)}
|
||
placeholder={"GE,gei\nSG,smartgroup"}
|
||
aria-label={t("bizCompare.ifaceNormalize")}
|
||
/>
|
||
</details>
|
||
|
||
<div className="ct-editor__pick">
|
||
<div className="ct-editor__pick-head">
|
||
<span className="ct-editor__pick-title">
|
||
{t("bizCompare.stepPickMetrics")}
|
||
<span className="muted" style={{ fontWeight: 400, marginLeft: 8 }}>
|
||
{t("bizCompare.selectedCount", { n: String(tplSheets.length) })}
|
||
</span>
|
||
</span>
|
||
<Button size="sm" variant="ghost" onPress={() => setShowMetricPicker((v) => !v)}>
|
||
{showMetricPicker ? t("bizCompare.hideMetrics") : t("bizCompare.showMetrics")}
|
||
</Button>
|
||
</div>
|
||
{showMetricPicker ? (
|
||
<>
|
||
<p className="muted ct-editor__pick-hint">{t("bizCompare.pickMetricsHintShort")}</p>
|
||
<div className="ct-metric-chips">
|
||
{metrics.map((m) => {
|
||
const checked = tplSheets.some((s) => s.metric_id === m.metric_id);
|
||
return (
|
||
<button
|
||
key={m.metric_id}
|
||
type="button"
|
||
className={`mt-chip${checked ? " is-on" : ""}`}
|
||
aria-pressed={checked}
|
||
onClick={() => toggleTplMetric(m.metric_id, !checked)}
|
||
>
|
||
{m.metric_id}
|
||
<span className="ct-metric-chip__n">{m.fields?.length || 0}</span>
|
||
</button>
|
||
);
|
||
})}
|
||
{!metrics.length ? (
|
||
<span className="muted">{t("bizCompare.noMetricFields")}</span>
|
||
) : null}
|
||
</div>
|
||
</>
|
||
) : null}
|
||
</div>
|
||
|
||
{!tplSheets.length ? (
|
||
<div className="pt-list-empty">{t("bizCompare.noMetricsYet")}</div>
|
||
) : (
|
||
<div className="ct-editor__main">
|
||
<aside className="ct-editor__nav" aria-label={t("bizCompare.colSheets")}>
|
||
<div className="ct-editor__nav-head">
|
||
<span>{t("bizCompare.colSheets")}</span>
|
||
</div>
|
||
<div className="ct-editor__nav-list" role="tablist" aria-label={t("bizCompare.colSheets")} onKeyDown={(event) => {
|
||
if (!["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return;
|
||
event.preventDefault();
|
||
const next = event.key === "Home" ? 0 : event.key === "End" ? tplSheets.length - 1 : (tplSheetIdx + (["ArrowUp", "ArrowLeft"].includes(event.key) ? -1 : 1) + tplSheets.length) % tplSheets.length;
|
||
setTplSheetIdx(next);
|
||
event.currentTarget.querySelectorAll<HTMLButtonElement>("[role=tab]")[next]?.focus();
|
||
}}>
|
||
{tplSheets.map((s, i) => {
|
||
const filterN = toOrGroups(s.row_filters || [])?.length ?? (s.row_filters || []).length;
|
||
return (
|
||
<button
|
||
key={`${sheetIdentity(s)}-${i}`}
|
||
type="button"
|
||
role="tab"
|
||
aria-selected={tplSheetIdx === i}
|
||
tabIndex={tplSheetIdx === i ? 0 : -1}
|
||
className={`ct-editor__nav-item${tplSheetIdx === i ? " is-active" : ""}`}
|
||
onClick={() => {
|
||
setTplSheetIdx(i);
|
||
setTplPaneTab("fields");
|
||
}}
|
||
>
|
||
<span className="ct-editor__nav-name">{sheetLabel(s)}</span>
|
||
<span className="ct-editor__nav-tag">
|
||
{!s.compare_fields.length
|
||
? t("bizCompare.presenceShort")
|
||
: t("bizCompare.tagCompareN", { n: String(s.compare_fields.length) })}
|
||
{filterN ? ` · F${filterN}` : ""}
|
||
</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</aside>
|
||
|
||
{activeTplSheet ? (
|
||
<div className="ct-editor__pane">
|
||
<div className="ct-editor__pane-head">
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<div className="ct-editor__metric-edit" style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||
<Input
|
||
value={activeTplSheet.title || ""}
|
||
placeholder={t("bizCompare.sheetTitle")}
|
||
aria-label={t("bizCompare.sheetTitle")}
|
||
onChange={(e) => updateActiveSheet({ title: e.target.value })}
|
||
style={{ maxWidth: 220 }}
|
||
/>
|
||
<Input
|
||
value={activeTplSheet.sheet_id || activeTplSheet.metric_id}
|
||
placeholder={t("bizCompare.sheetId")}
|
||
aria-label={t("bizCompare.sheetId")}
|
||
onChange={(e) => updateActiveSheet({ sheet_id: e.target.value.trim() })}
|
||
style={{ maxWidth: 220 }}
|
||
/>
|
||
</div>
|
||
<div className="ct-editor__meta muted">
|
||
{activeTplSheet.metric_id}
|
||
{" · "}
|
||
{activeTplSheet.compare_fields.length
|
||
? t("bizCompare.modeFieldsShort")
|
||
: t("bizCompare.presenceShort")}
|
||
{" · "}
|
||
Key {(activeTplSheet.key_fields || []).join(" · ") || "—"}
|
||
</div>
|
||
</div>
|
||
<div className="btn-row" style={{ gap: 6 }}>
|
||
<Button size="sm" variant="secondary" onPress={() => duplicateTplSheet(tplSheetIdx)}>
|
||
{t("bizCompare.splitSheet")}
|
||
</Button>
|
||
<Button size="sm" variant="ghost" onPress={() => removeTplMetric(tplSheetIdx)}>
|
||
{t("bizCompare.removeSheet")}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-rule-tabs" role="tablist" aria-label={t("bizCompare.editTemplate")} onKeyDown={(event) => {
|
||
if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return;
|
||
event.preventDefault();
|
||
const next = event.key === "Home" ? "fields" : event.key === "End" ? "filters" : tplPaneTab === "fields" ? "filters" : "fields";
|
||
setTplPaneTab(next);
|
||
event.currentTarget.querySelectorAll<HTMLButtonElement>("[role=tab]")[next === "fields" ? 0 : 1]?.focus();
|
||
}}>
|
||
<button
|
||
type="button"
|
||
role="tab"
|
||
aria-selected={tplPaneTab === "fields"}
|
||
tabIndex={tplPaneTab === "fields" ? 0 : -1}
|
||
className={`mt-rule-tab${tplPaneTab === "fields" ? " is-active" : ""}`}
|
||
onClick={() => setTplPaneTab("fields")}
|
||
>
|
||
{t("bizCompare.tabFields")}
|
||
<span className="mt-rule-tab__n">{activeTplFields.length}</span>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
role="tab"
|
||
aria-selected={tplPaneTab === "filters"}
|
||
tabIndex={tplPaneTab === "filters" ? 0 : -1}
|
||
className={`mt-rule-tab${tplPaneTab === "filters" ? " is-active" : ""}`}
|
||
onClick={() => setTplPaneTab("filters")}
|
||
>
|
||
{t("bizCompare.tabFilters")}
|
||
<span className="mt-rule-tab__n">
|
||
{activeTplFilterGroups?.length ?? (activeTplSheet.row_filters || []).length}
|
||
</span>
|
||
</button>
|
||
</div>
|
||
|
||
<FieldSelect label={t("bizCompare.portPolicy")} value={activeTplSheet.ignore_port_changes == null ? "auto" : activeTplSheet.ignore_port_changes ? "ignore" : "strict"} onChange={(e) => updateActiveSheet({ignore_port_changes: e.target.value === "auto" ? null : e.target.value === "ignore"})} hint={t("bizCompare.portPolicyHint")}>
|
||
<option value="auto">{t("bizCompare.portPolicyAuto")}</option><option value="strict">{t("bizCompare.portPolicyStrict")}</option><option value="ignore">{t("bizCompare.portPolicyIgnore")}</option>
|
||
</FieldSelect>
|
||
{tplPaneTab === "filters" ? (activeTplFilterGroups === null ? <div className="ct-complex-filters"><p className="muted">{t("bizCompare.complexFiltersPreserved")}</p><pre aria-label={t("bizCompare.tabFilters")}>{JSON.stringify(activeTplSheet.row_filters, null, 2)}</pre></div> : (
|
||
<TplRowFiltersEditor
|
||
groups={activeTplFilterGroups}
|
||
fieldOpts={activeTplFields.map((f) => f.name)}
|
||
t={t}
|
||
onChange={(next) => updateActiveSheet({ row_filters: fromOrGroups(next) })}
|
||
/>
|
||
)) : (
|
||
<>
|
||
<p className="muted ct-field-hint">{t("bizCompare.fieldsHintShort")}</p>
|
||
<div className="pt-list-table-wrap ct-field-table-wrap">
|
||
<table className="data-table pt-list-table ct-field-table">
|
||
<thead>
|
||
<tr>
|
||
<th>{t("bizCompare.field")}</th>
|
||
<th title={t("bizCompare.keyFields")}>{t("bizCompare.keyFields")}</th>
|
||
<th title={t("bizCompare.ifaceFields")}>{t("bizCompare.ifaceFields")}</th>
|
||
<th title={t("bizCompare.compareFields")}>{t("bizCompare.compareFields")}</th>
|
||
<th>{t("bizCompare.compareMode")}</th>
|
||
<th>{t("bizCompare.tolerance")}</th>
|
||
<th>{t("bizCompare.displayField")}</th>
|
||
<th>{t("bizCompare.normalizeField")}</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{activeTplFields.map((f) => {
|
||
const isKey = activeTplSheet.key_fields.includes(f.name);
|
||
const isCompare =
|
||
!isKey && activeTplSheet.compare_fields.includes(f.name);
|
||
const isDisplay = (activeTplSheet.display_fields || []).includes(f.name);
|
||
const rule = ruleForField(activeTplSheet, f.name);
|
||
const rawMode = (rule.compare || "eq").trim().toLowerCase();
|
||
const cmpMode = ["number", "int", "float"].includes(rawMode) ? "numeric" : ["pct", "rel"].includes(rawMode) ? "percent" : ["ignore", "skip", "off"].includes(rawMode) ? "eq" : rawMode;
|
||
const rawNorm = (rule.normalize || "none").trim().toLowerCase();
|
||
const norm = rawNorm === "strip" ? "none" : rawNorm;
|
||
const needsTol = cmpMode === "numeric" || cmpMode === "percent";
|
||
return (
|
||
<tr key={f.name}>
|
||
<td>
|
||
<code className="ct-field-name">{f.name}</code>
|
||
</td>
|
||
<td>
|
||
<input
|
||
type="checkbox"
|
||
checked={isKey}
|
||
onChange={(e) =>
|
||
updateActiveSheet({
|
||
key_fields: toggleInList(
|
||
activeTplSheet.key_fields,
|
||
f.name,
|
||
e.target.checked,
|
||
),
|
||
})
|
||
}
|
||
/>
|
||
</td>
|
||
<td>
|
||
<input
|
||
type="checkbox"
|
||
checked={activeTplSheet.iface_fields.includes(f.name)}
|
||
onChange={(e) =>
|
||
updateActiveSheet({
|
||
iface_fields: toggleInList(
|
||
activeTplSheet.iface_fields,
|
||
f.name,
|
||
e.target.checked,
|
||
),
|
||
})
|
||
}
|
||
/>
|
||
</td>
|
||
<td>
|
||
<input
|
||
type="checkbox"
|
||
disabled={isKey}
|
||
title={isKey ? t("bizCompare.keyIsIdentity") : undefined}
|
||
checked={isCompare}
|
||
onChange={(e) =>
|
||
updateActiveSheet({
|
||
compare_fields: toggleInList(
|
||
activeTplSheet.compare_fields,
|
||
f.name,
|
||
e.target.checked,
|
||
),
|
||
field_rules: e.target.checked ? upsertFieldRule(activeTplSheet, f.name, {ignore: false,
|
||
compare: ["ignore", "skip", "off"].includes(rawMode) ? "eq" : rule.compare}) : activeTplSheet.field_rules,
|
||
})
|
||
}
|
||
/>
|
||
</td>
|
||
<td>
|
||
<select
|
||
className="mt-select"
|
||
value={cmpMode === "ignore" ? "eq" : cmpMode}
|
||
aria-label={`${f.name} ${t("bizCompare.compareMode")}`}
|
||
disabled={!isCompare}
|
||
onChange={(e) =>
|
||
updateActiveSheet({
|
||
field_rules: upsertFieldRule(activeTplSheet, f.name, {
|
||
compare: e.target.value,
|
||
tolerance:
|
||
e.target.value === "eq" ? undefined : rule.tolerance ?? 0,
|
||
}),
|
||
})
|
||
}
|
||
>
|
||
<option value="eq">{t("bizCompare.modeEq")}</option>
|
||
<option value="numeric">{t("bizCompare.modeNumeric")}</option>
|
||
<option value="percent">{t("bizCompare.modePercent")}</option>
|
||
</select>
|
||
</td>
|
||
<td>
|
||
<Input
|
||
type="number"
|
||
min={0}
|
||
step="any"
|
||
aria-label={`${f.name} ${t("bizCompare.tolerance")}`}
|
||
value={
|
||
needsTol &&
|
||
rule.tolerance !== undefined &&
|
||
rule.tolerance !== null
|
||
? String(rule.tolerance)
|
||
: ""
|
||
}
|
||
placeholder={cmpMode === "percent" ? "%" : ""}
|
||
disabled={!isCompare || !needsTol}
|
||
onChange={(e) => {
|
||
const raw = e.target.value.trim();
|
||
const tol = raw === "" ? undefined : Number(raw);
|
||
updateActiveSheet({
|
||
field_rules: upsertFieldRule(activeTplSheet, f.name, {
|
||
compare: cmpMode,
|
||
tolerance: Number.isFinite(tol as number)
|
||
? (tol as number)
|
||
: undefined,
|
||
}),
|
||
});
|
||
}}
|
||
/>
|
||
</td>
|
||
<td>
|
||
<input
|
||
type="checkbox"
|
||
disabled={isKey}
|
||
title={isKey ? t("bizCompare.keyAlwaysDisplay") : undefined}
|
||
checked={isKey || isDisplay}
|
||
onChange={(e) =>
|
||
updateActiveSheet({
|
||
display_fields: toggleInList(
|
||
activeTplSheet.display_fields || [],
|
||
f.name,
|
||
e.target.checked,
|
||
),
|
||
})
|
||
}
|
||
/>
|
||
</td>
|
||
<td>
|
||
<select
|
||
className="mt-select"
|
||
value={norm}
|
||
aria-label={`${f.name} ${t("bizCompare.normalizeField")}`}
|
||
disabled={!isCompare}
|
||
onChange={(e) =>
|
||
updateActiveSheet({
|
||
field_rules: upsertFieldRule(activeTplSheet, f.name, {
|
||
normalize: e.target.value,
|
||
}),
|
||
})
|
||
}
|
||
>
|
||
<option value="none">{t("bizCompare.normalizeNone")}</option>
|
||
<option value="lower">{t("bizCompare.normalizeLower")}</option>
|
||
<option value="upper">{t("bizCompare.normalizeUpper")}</option>
|
||
<option value="mac">{t("bizCompare.normalizeMac")}</option>
|
||
<option value="empty_as_blank">
|
||
{t("bizCompare.normalizeEmpty")}
|
||
</option>
|
||
</select>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
{!activeTplFields.length ? (
|
||
<tr>
|
||
<td colSpan={8}>
|
||
<div className="pt-list-empty">{t("bizCompare.noMetricFields")}</div>
|
||
</td>
|
||
</tr>
|
||
) : null}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
)}
|
||
</Modal.Body>
|
||
<Modal.Footer>
|
||
<Button size="sm" variant="secondary" isDisabled={busy} onPress={() => setTplOpen(false)}>
|
||
{t("bizState.cancel")}
|
||
</Button>
|
||
<Button size="sm" variant="primary" isDisabled={busy} onPress={() => void saveTemplate()}>
|
||
{t("bizCompare.saveTemplate")}
|
||
</Button>
|
||
</Modal.Footer>
|
||
</AppModalShell>
|
||
|
||
{/* Create job — guided steps */}
|
||
<AppModalShell
|
||
open={jobCreateOpen}
|
||
onClose={closeCreateJob}
|
||
size="lg"
|
||
className="bm-create-modal bs-cmp-create"
|
||
>
|
||
<Modal.Header>
|
||
<Modal.Heading>{t("bizCompare.createCompare")}</Modal.Heading>
|
||
<Modal.CloseTrigger />
|
||
</Modal.Header>
|
||
<Modal.Body className="flex flex-col gap-3 bm-create">
|
||
<nav className="bm-steps" aria-label={t("bizCompare.createSteps")}>
|
||
{(
|
||
[
|
||
t("bizCompare.stepBasics"),
|
||
t("bizCompare.stepSheets"),
|
||
t("bizCompare.stepBatches"),
|
||
t("bizCompare.stepMapping"),
|
||
] as const
|
||
).map((label, i) => {
|
||
const done = i < createStep;
|
||
const active = i === createStep;
|
||
return (
|
||
<button
|
||
key={label}
|
||
type="button"
|
||
className={`bm-steps__item${active ? " is-active" : ""}${done ? " is-done" : ""}`}
|
||
onClick={() => {
|
||
if (i <= createStep) {
|
||
setCreateStep(i as CreateJobStep);
|
||
return;
|
||
}
|
||
let ok = true;
|
||
for (let s = 0; s < i; s++) {
|
||
if (!canAdvanceCreateStep(s as CreateJobStep)) {
|
||
ok = false;
|
||
break;
|
||
}
|
||
}
|
||
if (ok) setCreateStep(i as CreateJobStep);
|
||
}}
|
||
>
|
||
<span className="bm-steps__num">{done ? "✓" : i + 1}</span>
|
||
<span className="bm-steps__label">{label}</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</nav>
|
||
|
||
{createStep === 0 ? <div className="bm-create__pane">{renderBasicFields()}</div> : null}
|
||
|
||
{createStep === 1 ? <div className="bm-create__pane">{renderSheetChips()}</div> : null}
|
||
|
||
{createStep === 2 ? (
|
||
<div className="bm-create__pane bs-cmp-form">
|
||
{renderBatchFields()}
|
||
</div>
|
||
) : null}
|
||
|
||
{createStep === 3 ? <div className="bm-create__pane">{renderMappingBlock()}</div> : null}
|
||
</Modal.Body>
|
||
<Modal.Footer>
|
||
<Button size="sm" variant="secondary" onPress={closeCreateJob}>
|
||
{t("bizState.cancel")}
|
||
</Button>
|
||
<div className="bm-create__footer-spacer" />
|
||
{createStep > 0 ? (
|
||
<Button size="sm" variant="secondary" onPress={onCreateBack}>
|
||
{t("bizCompare.back")}
|
||
</Button>
|
||
) : null}
|
||
{createStep < CREATE_JOB_STEPS - 1 ? (
|
||
<Button size="sm" variant="primary" onPress={onCreateNext}>
|
||
{t("bizCompare.next")}
|
||
</Button>
|
||
) : (
|
||
<Button size="sm" variant="primary" isDisabled={busy} onPress={() => void createJob()}>
|
||
{t("bizCompare.createCompare")}
|
||
</Button>
|
||
)}
|
||
</Modal.Footer>
|
||
</AppModalShell>
|
||
|
||
{/* Job detail */}
|
||
<AppModalShell
|
||
open={Boolean(jobId)}
|
||
onClose={closeJob}
|
||
dismissible={!boardFs}
|
||
size={jobDetailTab === "result" ? "cover" : "lg"}
|
||
className={`app-heroui-modal--xl bs-cmp-job-modal${jobDetailTab === "result" ? " bs-cmp-board-modal" : ""}`}
|
||
>
|
||
<Modal.Header className="bs-cmp-job-header">
|
||
<Modal.Heading className="bs-cmp-job-heading">
|
||
<span className="bs-cmp-job-heading__id" title={jobId}>JOB / {jobId.slice(0, 12)}</span>
|
||
<span>{name || t("bizCompare.detail")}</span>
|
||
</Modal.Heading>
|
||
<Modal.CloseTrigger />
|
||
</Modal.Header>
|
||
<Modal.Body className="flex flex-col gap-3 bs-cmp-job-body">
|
||
<div className="btn-row nm-config-modal__tabs bs-cmp-job-tabs" role="tablist" aria-label={t("bizCompare.detailTabs")} onKeyDown={handleJobTabKeyDown}>
|
||
<button
|
||
type="button"
|
||
role="tab"
|
||
id="compare-config-tab"
|
||
aria-selected={jobDetailTab === "config"}
|
||
aria-controls="compare-config-panel"
|
||
tabIndex={jobDetailTab === "config" ? 0 : -1}
|
||
className={`button${jobDetailTab === "config" ? " is-active" : ""}`}
|
||
onClick={() => setJobDetailTab("config")}
|
||
>
|
||
<span className="bs-cmp-job-tabs__index" aria-hidden="true">01</span>{t("bizCompare.tabConfig")}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
role="tab"
|
||
id="compare-runs-tab"
|
||
aria-label={t("bizCompare.tabRuns")}
|
||
aria-selected={jobDetailTab === "runs"}
|
||
aria-controls="compare-runs-panel"
|
||
tabIndex={jobDetailTab === "runs" ? 0 : -1}
|
||
className={`button${jobDetailTab === "runs" ? " is-active" : ""}`}
|
||
onClick={() => {
|
||
setJobDetailTab("runs");
|
||
void refreshRuns();
|
||
}}
|
||
>
|
||
<span className="bs-cmp-job-tabs__index" aria-hidden="true">02</span>{t("bizCompare.tabRuns")}
|
||
{runs.length ? <span className="bs-cmp-job-tabs__count">{runs.length}</span> : null}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
role="tab"
|
||
id="compare-result-tab"
|
||
aria-selected={jobDetailTab === "result"}
|
||
aria-controls="compare-result-panel"
|
||
tabIndex={jobDetailTab === "result" ? 0 : -1}
|
||
className={`button${jobDetailTab === "result" ? " is-active" : ""}`}
|
||
onClick={() => setJobDetailTab("result")}
|
||
>
|
||
<span className="bs-cmp-job-tabs__index" aria-hidden="true">03</span>{t("bizCompare.tabResult")}
|
||
</button>
|
||
</div>
|
||
|
||
{jobDetailTab === "config" ? (
|
||
renderJobForm()
|
||
) : jobDetailTab === "runs" ? (
|
||
<div className="bs-cmp-runs" role="tabpanel" id="compare-runs-panel" aria-labelledby="compare-runs-tab" aria-busy={runsLoading}>
|
||
<div className="bs-cmp-runs__toolbar">
|
||
<div className="bs-cmp-runs__intro">
|
||
<h3>{t("bizCompare.runHistoryTitle")}</h3>
|
||
<p className="bs-cmp-runs__hint">{t("bizCompare.runHistoryHint")}</p>
|
||
</div>
|
||
<div className="btn-row">
|
||
<Button size="sm" variant="secondary" isDisabled={busy || runsLoading} isPending={runsLoading} onPress={() => void refreshRuns()}>
|
||
{t("bizCompare.refreshRuns")}
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant="primary"
|
||
isDisabled={busy || jobHasActiveRun}
|
||
onPress={() => void runNow()}
|
||
>
|
||
{jobHasActiveRun ? t("bizCompare.runStatusRunning") : t("bizCompare.runNow")}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
<dl className="bs-cmp-run-stats">
|
||
<div><dt>{t("bizCompare.loadedRuns")}</dt><dd>{runCounts.total}</dd></div>
|
||
<div className={runCounts.active ? "is-active" : ""}><dt>{t("bizCompare.activeRuns")}</dt><dd>{runCounts.active}</dd></div>
|
||
<div><dt>{t("bizCompare.completedRuns")}</dt><dd>{runCounts.completed}</dd></div>
|
||
<div className={runCounts.stopped ? "is-stopped" : ""}><dt>{t("bizCompare.stoppedRuns")}</dt><dd>{runCounts.stopped}</dd></div>
|
||
</dl>
|
||
{runsError ? (
|
||
<div className="bs-cmp-runs__error" role="alert">
|
||
<span>{t("bizCompare.runsLoadError")} · {runsError}</span>
|
||
<Button size="sm" variant="secondary" isDisabled={runsLoading} onPress={() => void refreshRuns()}>{t("bizCompare.retry")}</Button>
|
||
</div>
|
||
) : null}
|
||
{!runs.length ? (
|
||
runsLoading ? <div className="pt-list-empty" role="status">{t("bizCompare.runsLoading")}</div>
|
||
: !runsError ? <div className="pt-list-empty">{t("bizCompare.noRuns")}</div> : null
|
||
) : (
|
||
<div className="pt-list-table-wrap">
|
||
<table className="data-table pt-list-table bs-cmp-runs__table">
|
||
<thead>
|
||
<tr>
|
||
<th>{t("bizCompare.colStatus")}</th>
|
||
<th>{t("bizCompare.colTime")}</th>
|
||
<th>{t("bizCompare.sidesTitle")}</th>
|
||
<th>{t("bizCompare.colProgress")}</th>
|
||
<th>{t("bizCompare.colActions")}</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{runs.map((r) => {
|
||
const st = String(r.status || "");
|
||
const active = st === "running" || st === "queued";
|
||
const selected = String(runDetail?.id || "") === String(r.id);
|
||
const before = enrichSide(
|
||
r.before,
|
||
beforeTaskId,
|
||
tasks,
|
||
beforeBatches,
|
||
);
|
||
const after = enrichSide(
|
||
r.after,
|
||
afterTaskId || beforeTaskId,
|
||
tasks,
|
||
afterBatches.length ? afterBatches : beforeBatches,
|
||
);
|
||
const when = formatSystemTime(r.created_at) || "—";
|
||
const sum = r.summary || {};
|
||
const fail =
|
||
Number(sum.removed || 0) + Number(sum.changed || 0);
|
||
const ok = Number(sum.unchanged || 0);
|
||
const durMs = Number(sum.duration_ms || 0);
|
||
const prog = (r.progress ||
|
||
r.summary?.progress ||
|
||
{}) as RunProgressInfo;
|
||
const pct = active ? runProgressPercent(prog) : 100;
|
||
const elapsedSec = Math.round(Number(prog.elapsed_ms || 0) / 1000);
|
||
const stLabel =
|
||
st === "queued" ? t("bizCompare.runStatusQueued")
|
||
: st === "running" ? t("bizCompare.runStatusRunning")
|
||
: st === "failed"
|
||
? t("bizCompare.runStatusFailed")
|
||
: st === "cancelled"
|
||
? t("bizCompare.runStatusCancelled")
|
||
: st === "success" ? t("bizCompare.runFinished") : st || "—";
|
||
const chipColor = active
|
||
? "accent"
|
||
: st === "cancelled"
|
||
? "warning"
|
||
: st === "failed" ? "danger" : "default";
|
||
const meta = active ? runProgressMetaLine(prog, t) : "";
|
||
return (
|
||
<tr key={String(r.id)} className={selected ? "is-selected" : undefined} data-status={st} aria-selected={selected}>
|
||
<td>
|
||
<NmStatusChip color={chipColor}>{stLabel}</NmStatusChip>
|
||
{(active && elapsedSec > 0) || (!active && durMs > 0) ? (
|
||
<div className="bs-cmp-runs__elapsed">
|
||
{t("bizCompare.runElapsed", { s: String(active ? elapsedSec : Math.round(durMs / 1000)) })}
|
||
</div>
|
||
) : null}
|
||
</td>
|
||
<td className="pt-list-time">
|
||
<span>{when}</span>
|
||
<code className="bs-cmp-runs__id" title={r.id}>{r.id.slice(0, 12)}</code>
|
||
</td>
|
||
<td>
|
||
<div className="bs-cmp-runs__sides" title={`${sideDeviceName(before)} → ${sideDeviceName(after)}`}>
|
||
<div><span className="bs-cmp-runs__side-tag">{t("bizCompare.sideBefore")}</span><strong>{sideDeviceName(before)}</strong><time>{sideCollectTime(before)}</time></div>
|
||
<div><span className="bs-cmp-runs__side-tag">{t("bizCompare.sideAfter")}</span><strong>{sideDeviceName(after)}</strong><time>{sideCollectTime(after)}</time></div>
|
||
</div>
|
||
</td>
|
||
<td>
|
||
{active ? (
|
||
<div className="bs-cmp-run-prog">
|
||
<div
|
||
className="bs-cmp-run-prog__track"
|
||
role="progressbar"
|
||
aria-label={t("bizCompare.colProgress")}
|
||
aria-valuenow={Number(prog.sheet_total || 0) > 0 ? pct : undefined}
|
||
aria-valuemin={0}
|
||
aria-valuemax={100}
|
||
>
|
||
<div
|
||
className={`bs-cmp-run-prog__fill${
|
||
Number(prog.sheet_total || 0) <= 0 ? " is-indeterminate" : ""
|
||
}`}
|
||
style={
|
||
Number(prog.sheet_total || 0) > 0
|
||
? { width: `${pct}%` }
|
||
: undefined
|
||
}
|
||
/>
|
||
</div>
|
||
<div className="bs-cmp-run-prog__meta" title={meta}>
|
||
{meta || "…"}
|
||
</div>
|
||
</div>
|
||
) : (
|
||
st === "failed" || st === "cancelled" ? (
|
||
<div className="bs-cmp-runs__summary is-stopped" title={r.message}>{String(r.message || stLabel).slice(0, 160)}</div>
|
||
) : (
|
||
<div className="bs-cmp-run-verdict">
|
||
<span className={fail ? "is-fail" : ""}>{t("bizCompare.kindDiff")}<strong>{fail.toLocaleString()}</strong></span>
|
||
<span className="is-pass">{t("bizCompare.kindUnchanged")}<strong>{ok.toLocaleString()}</strong></span>
|
||
<span>{t("bizCompare.kindAdded")}<strong>{Number(sum.added || 0).toLocaleString()}</strong></span>
|
||
</div>
|
||
)
|
||
)}
|
||
</td>
|
||
<td>
|
||
<div className="pt-list-actions">
|
||
<Button
|
||
size="sm"
|
||
variant={selected ? "primary" : "secondary"}
|
||
isDisabled={busy || active}
|
||
onPress={() => void loadRun(String(r.id))}
|
||
>
|
||
{t("bizCompare.viewResult")}
|
||
</Button>
|
||
{active ? (
|
||
<Button
|
||
size="sm"
|
||
variant="danger"
|
||
isDisabled={busy}
|
||
onPress={() => void cancelRun(String(r.id))}
|
||
>
|
||
{t("bizCompare.cancelRun")}
|
||
</Button>
|
||
) : (
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
className="bs-cmp-runs__delete"
|
||
isDisabled={busy}
|
||
onPress={() => void removeRun(String(r.id))}
|
||
>
|
||
{t("bizCompare.deleteRun")}
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<div
|
||
ref={boardRef}
|
||
role="tabpanel"
|
||
id="compare-result-panel"
|
||
aria-labelledby="compare-result-tab"
|
||
className={`bs-cmp-board${boardFs ? " is-fullscreen" : ""}`}
|
||
>
|
||
<div className="bs-cmp-board__toolbar">
|
||
<div className="bs-cmp-board__meta">
|
||
{runDetail ? (
|
||
<div className="bs-cmp-sides" aria-label={t("bizCompare.sidesTitle")}>
|
||
{(() => {
|
||
const before = enrichSide(
|
||
runDetail.before,
|
||
beforeTaskId,
|
||
tasks,
|
||
beforeBatches,
|
||
);
|
||
const after = enrichSide(
|
||
runDetail.after,
|
||
afterTaskId || beforeTaskId,
|
||
tasks,
|
||
afterBatches.length ? afterBatches : beforeBatches,
|
||
);
|
||
const beforeName = sideDeviceName(before);
|
||
const afterName = sideDeviceName(after);
|
||
const beforeTime = sideCollectTime(before);
|
||
const afterTime = sideCollectTime(after);
|
||
return (
|
||
<>
|
||
<div className="bs-cmp-sides__side is-before">
|
||
<span className="bs-cmp-sides__tag">{t("bizCompare.sideBefore")}</span>
|
||
<strong className="bs-cmp-sides__name" title={`${beforeName} ${beforeTime}`}>
|
||
{beforeName}
|
||
</strong>
|
||
<span className="bs-cmp-sides__meta" title={beforeTime}>
|
||
{beforeTime}
|
||
</span>
|
||
</div>
|
||
<span className="bs-cmp-sides__arrow" aria-hidden>
|
||
→
|
||
</span>
|
||
<div className="bs-cmp-sides__side is-after">
|
||
<span className="bs-cmp-sides__tag">{t("bizCompare.sideAfter")}</span>
|
||
<strong className="bs-cmp-sides__name" title={`${afterName} ${afterTime}`}>
|
||
{afterName}
|
||
</strong>
|
||
<span className="bs-cmp-sides__meta" title={afterTime}>
|
||
{afterTime}
|
||
</span>
|
||
</div>
|
||
</>
|
||
);
|
||
})()}
|
||
</div>
|
||
) : null}
|
||
{runDetail ? (
|
||
<span
|
||
className={`bs-cmp-board__config${runDetail.config_snapshot_version ? "" : " is-legacy"}`}
|
||
title={t(runDetail.config_snapshot_version
|
||
? "bizCompare.configSnapshotHint"
|
||
: "bizCompare.configLegacyHint")}
|
||
>
|
||
{t(runDetail.config_snapshot_version
|
||
? "bizCompare.configSnapshot"
|
||
: "bizCompare.configLegacy")}
|
||
</span>
|
||
) : null}
|
||
{!boardFs ? (
|
||
<>
|
||
<select
|
||
className="ui-field__select bs-cmp-board__run-select"
|
||
aria-label={t("bizCompare.pickBatchRun")}
|
||
value={runDetail?.id || ""}
|
||
onChange={(e) => {
|
||
const id = e.target.value;
|
||
if (id) void loadRun(id);
|
||
}}
|
||
>
|
||
<option value="">{t("bizCompare.pickRun")}</option>
|
||
{runs.map((r) => {
|
||
const before = enrichSide(
|
||
(r as any).before,
|
||
beforeTaskId,
|
||
tasks,
|
||
beforeBatches,
|
||
);
|
||
const after = enrichSide(
|
||
(r as any).after,
|
||
afterTaskId || beforeTaskId,
|
||
tasks,
|
||
afterBatches.length ? afterBatches : beforeBatches,
|
||
);
|
||
const bl = sideDeviceName(before);
|
||
const al = sideDeviceName(after);
|
||
const when = formatSystemTime((r as any).created_at) || "";
|
||
const st = String((r as any).status || "");
|
||
const durMs = Number((r as any).summary?.duration_ms || 0);
|
||
const stLabel =
|
||
st === "running" || st === "queued"
|
||
? t("bizCompare.runStatusRunning")
|
||
: st === "failed"
|
||
? t("bizCompare.runStatusFailed")
|
||
: st === "cancelled"
|
||
? t("bizCompare.runStatusCancelled")
|
||
: durMs > 0
|
||
? t("bizCompare.runStatusDoneSec", {
|
||
s: String(Math.round(durMs / 1000)),
|
||
})
|
||
: "";
|
||
return (
|
||
<option key={r.id} value={r.id}>
|
||
{stLabel ? `[${stLabel}] ` : ""}
|
||
{when ? `${when} · ` : ""}
|
||
{bl} {sideCollectTime(before)} → {al} {sideCollectTime(after)}
|
||
</option>
|
||
);
|
||
})}
|
||
</select>
|
||
{runs.length ? (
|
||
<span className="muted bs-cmp-board__run-count">
|
||
{t("bizCompare.runCount", { n: String(runs.length) })}
|
||
</span>
|
||
) : null}
|
||
</>
|
||
) : null}
|
||
</div>
|
||
<div className="btn-row bs-cmp-board__actions">
|
||
{!boardFs ? (
|
||
<>
|
||
<Button
|
||
size="sm"
|
||
variant="secondary"
|
||
isDisabled={busy || runIsActive || !runDetail?.id}
|
||
onPress={() => void downloadRunTables()}
|
||
>
|
||
{t("bizCompare.exportTables")}
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant="danger"
|
||
isDisabled={busy || runIsActive || !runDetail?.id}
|
||
onPress={() => void removeRun(String(runDetail?.id || ""))}
|
||
>
|
||
{t("bizCompare.deleteRun")}
|
||
</Button>
|
||
</>
|
||
) : null}
|
||
<Button
|
||
size="sm"
|
||
variant="secondary"
|
||
isDisabled={!runDetail}
|
||
onPress={() => void toggleBoardFullscreen()}
|
||
>
|
||
{boardFs ? t("bizCompare.exitFullscreen") : t("bizCompare.fullscreen")}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{runDetail && runIsActive ? (
|
||
<div className="bs-cmp-progress is-compact" role="status" aria-live="polite">
|
||
<div className="bs-cmp-progress__head">
|
||
<NmStatusChip color="accent">{t("bizCompare.runStatusRunning")}</NmStatusChip>
|
||
{runEngine === "sql" || runEngine === "python" ? (
|
||
<span className="muted bs-cmp-progress__engine">
|
||
{runEngine === "sql"
|
||
? t("bizCompare.runEngineSql")
|
||
: t("bizCompare.runEnginePython")}
|
||
{runProgress.engine_note
|
||
? ` · ${t("bizCompare.runEngineNote", {
|
||
note: String(runProgress.engine_note),
|
||
})}`
|
||
: ""}
|
||
</span>
|
||
) : null}
|
||
<span className="muted bs-cmp-progress__elapsed">
|
||
{t("bizCompare.runElapsed", {
|
||
s: String(Math.round(Number(runProgress.elapsed_ms || 0) / 1000)),
|
||
})}
|
||
</span>
|
||
<div className="bs-cmp-progress__actions">
|
||
<Button
|
||
size="sm"
|
||
variant="danger"
|
||
isDisabled={busy}
|
||
onPress={() => void cancelRun(String(runDetail.id))}
|
||
>
|
||
{t("bizCompare.cancelRun")}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
<div
|
||
className="bs-cmp-progress__track"
|
||
role="progressbar"
|
||
aria-valuenow={runProgressPercent(runProgress)}
|
||
aria-valuemin={0}
|
||
aria-valuemax={100}
|
||
>
|
||
<div
|
||
className={`bs-cmp-progress__fill${
|
||
Number(runProgress.sheet_total || 0) <= 0 ? " is-indeterminate" : ""
|
||
}`}
|
||
style={
|
||
Number(runProgress.sheet_total || 0) > 0
|
||
? { width: `${runProgressPercent(runProgress)}%` }
|
||
: undefined
|
||
}
|
||
/>
|
||
</div>
|
||
<div className="bs-cmp-progress__meta">
|
||
{runProgressMetaLine(runProgress, t)}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
{runDetail && runStatus === "failed" ? (
|
||
<div className="bs-cmp-progress is-failed" role="alert">
|
||
<div className="bs-cmp-progress__head">
|
||
<NmStatusChip color="danger">{t("bizCompare.runStatusFailed")}</NmStatusChip>
|
||
</div>
|
||
<div className="bs-cmp-progress__meta">{String(runDetail.message || "")}</div>
|
||
</div>
|
||
) : null}
|
||
{runDetail && runStatus === "cancelled" ? (
|
||
<div className="bs-cmp-progress is-cancelled" role="status">
|
||
<div className="bs-cmp-progress__head">
|
||
<NmStatusChip color="warning">{t("bizCompare.runStatusCancelled")}</NmStatusChip>
|
||
</div>
|
||
<div className="bs-cmp-progress__meta">
|
||
{String(runDetail.message || t("bizCompare.runCancelled"))}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
{runDetail ? (
|
||
<div
|
||
className={`bs-cmp-board__body${navCollapsed ? " is-nav-collapsed" : ""}`}
|
||
>
|
||
<aside
|
||
className={`bs-cmp-nav${navCollapsed ? " is-collapsed" : ""}`}
|
||
aria-label={t("bizCompare.sheetNavTitle")}
|
||
>
|
||
<div className="bs-cmp-nav__head">
|
||
<button
|
||
type="button"
|
||
className="bs-cmp-nav__toggle"
|
||
aria-expanded={!navCollapsed}
|
||
title={
|
||
navCollapsed
|
||
? t("bizCompare.expandSheetNav")
|
||
: t("bizCompare.collapseSheetNav")
|
||
}
|
||
onClick={() => setNavCollapsed((v) => !v)}
|
||
>
|
||
{navCollapsed ? "»" : "«"}
|
||
</button>
|
||
{!navCollapsed ? (
|
||
<>
|
||
<span className="bs-cmp-nav__head-main">
|
||
<span className="bs-cmp-nav__title">{t("bizCompare.sheetNavTitle")}</span>
|
||
<span
|
||
className="bs-cmp-nav__count"
|
||
title={t("bizCompare.sheetNavHint", {
|
||
n: String(sheetCards.length || runSheets.length),
|
||
})}
|
||
>
|
||
{sheetCards.length || runSheets.length}
|
||
</span>
|
||
</span>
|
||
<span className="bs-cmp-nav__legend" aria-hidden>
|
||
<span
|
||
className="bs-cmp-nav__num bs-cmp-nav__num--fail is-hot"
|
||
title={t("bizCompare.kindFail")}
|
||
>
|
||
{t("bizCompare.kindFail")}
|
||
</span>
|
||
<span
|
||
className="bs-cmp-nav__num bs-cmp-nav__num--ok is-hot"
|
||
title={t("bizCompare.kindSuccess")}
|
||
>
|
||
{t("bizCompare.kindSuccess")}
|
||
</span>
|
||
<span
|
||
className="bs-cmp-nav__num bs-cmp-nav__num--rate"
|
||
title={t("bizCompare.passRateShort")}
|
||
>
|
||
%
|
||
</span>
|
||
</span>
|
||
</>
|
||
) : null}
|
||
</div>
|
||
<div className="bs-cmp-nav__list" role="tablist">
|
||
{(sheetCards.length
|
||
? sheetCards
|
||
: [...runSheets]
|
||
.map((s) => {
|
||
const removed = Number(s.summary?.removed || 0);
|
||
const changed = Number(s.summary?.changed || 0);
|
||
const unchanged = Number(s.summary?.unchanged || 0);
|
||
const fail = removed + changed;
|
||
const judged = fail + unchanged;
|
||
const st = String((s as any).status || "done");
|
||
const pending = ["pending", "running", "queued"].includes(st);
|
||
return {
|
||
sheet_id: sheetIdentity(s),
|
||
title: sheetLabel(s),
|
||
metric_id: s.metric_id,
|
||
mode: s.mode,
|
||
status: st,
|
||
added: s.summary?.added,
|
||
removed,
|
||
changed,
|
||
unchanged,
|
||
before_count: s.summary?.before_count,
|
||
after_count: s.summary?.after_count,
|
||
fail_count: fail,
|
||
success_count: unchanged,
|
||
diff_count: fail,
|
||
pass_rate: pending
|
||
? null
|
||
: judged
|
||
? (() => {
|
||
const rate = (unchanged / judged) * 100;
|
||
if (fail <= 0) return Math.round(rate * 10) / 10;
|
||
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;
|
||
})()
|
||
: 100,
|
||
};
|
||
})
|
||
.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 = Number(a.fail_count || 0);
|
||
const db = Number(b.fail_count || 0);
|
||
if (da !== db) return db - da;
|
||
return String(a.metric_id).localeCompare(String(b.metric_id));
|
||
})
|
||
).map((c) => {
|
||
const fail = sheetFailOf(c);
|
||
const ok = sheetSuccessOf(c);
|
||
const rate = sheetPassRateOf(c);
|
||
const id = sheetIdentity(c);
|
||
const label = sheetLabel(c);
|
||
const active = resultSheetId === id;
|
||
const pending = ["pending", "running", "queued"].includes(
|
||
String((c as any).status || ""),
|
||
);
|
||
return (
|
||
<button
|
||
key={id}
|
||
type="button"
|
||
role="tab"
|
||
aria-selected={active}
|
||
className={`bs-cmp-nav__item${active ? " is-active" : ""}${
|
||
pending ? " is-pending" : fail > 0 ? " has-diff" : " is-clean"
|
||
}`}
|
||
onClick={() => {
|
||
setResultSheetId(id);
|
||
// Cutover default: land on fails when switching sheets
|
||
if (fail > 0 && kindFilter === "unchanged") {
|
||
setKindFilter("diff");
|
||
}
|
||
}}
|
||
title={
|
||
pending
|
||
? `${label} · ${t("bizCompare.sheetPending")}`
|
||
: `${label} · ${t("bizCompare.kindFail")} ${fail} · ${t("bizCompare.kindSuccess")} ${ok} · ${t("bizCompare.passRateShort")} ${formatPassRate(rate)}`
|
||
}
|
||
>
|
||
<span className="bs-cmp-nav__dot" aria-hidden />
|
||
<span className="bs-cmp-nav__name">{label}</span>
|
||
<span className="bs-cmp-nav__stats">
|
||
<span
|
||
className={`bs-cmp-nav__num bs-cmp-nav__num--fail${
|
||
fail > 0 ? " is-hot" : ""
|
||
}`}
|
||
>
|
||
{pending ? "—" : fail}
|
||
</span>
|
||
<span className="bs-cmp-nav__num bs-cmp-nav__num--ok is-hot">
|
||
{pending ? "—" : ok}
|
||
</span>
|
||
<span className="bs-cmp-nav__num bs-cmp-nav__num--rate">
|
||
{pending || rate === null ? "…" : formatPassRate(rate)}
|
||
</span>
|
||
</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</aside>
|
||
|
||
<div className="bs-cmp-main">
|
||
<div
|
||
className={`bs-cmp-strip${
|
||
activeSheetPending
|
||
? " is-pending"
|
||
: activeFail > 0
|
||
? " is-warn"
|
||
: " is-ok"
|
||
}`}
|
||
>
|
||
<div className="bs-cmp-strip__sheet">
|
||
<span className="bs-cmp-strip__sheet-tag">
|
||
{t("bizCompare.sheetCurrent")}
|
||
</span>
|
||
<strong
|
||
className="bs-cmp-strip__sheet-name"
|
||
title={sheetLabel(activeSheetCard)}
|
||
>
|
||
{sheetLabel(activeSheetCard)}
|
||
</strong>
|
||
<span className="muted bs-cmp-strip__sheet-mode">
|
||
{activeSheetPending
|
||
? t("bizCompare.sheetPending")
|
||
: activeFail > 0
|
||
? t("bizCompare.kindFail")
|
||
: t("bizCompare.kindPass")}
|
||
{activeSheetCard?.mode === "presence"
|
||
? ` · ${t("bizCompare.presenceShort")}`
|
||
: ""}
|
||
{activeSheetPending
|
||
? ""
|
||
: ` · ${activeSheetCard?.before_count ?? 0}→${activeSheetCard?.after_count ?? 0}`}
|
||
</span>
|
||
</div>
|
||
<div
|
||
className="bs-cmp-strip__pass"
|
||
title={`${t("bizCompare.passRate")} · ${t("bizCompare.passRateScope")}`}
|
||
>
|
||
<span className="bs-cmp-strip__pass-label">
|
||
{t("bizCompare.passRate")}
|
||
<span className="bs-cmp-strip__pass-scope">
|
||
{t("bizCompare.passRateScope")}
|
||
</span>
|
||
</span>
|
||
<span className="bs-cmp-strip__pass-value">
|
||
{activeSheetPending || activePassRate === null
|
||
? "…"
|
||
: formatPassRate(activePassRate)}
|
||
</span>
|
||
</div>
|
||
<div className="bs-cmp-strip__kinds" role="group">
|
||
{(
|
||
[
|
||
["diff", activeFail, "diff"],
|
||
["removed", activeRemoved, "removed"],
|
||
["changed", activeChanged, "changed"],
|
||
["added", activeAdded, "added"],
|
||
["unchanged", activeSuccess, "unchanged"],
|
||
["all", null, "all"],
|
||
] as const
|
||
).map(([id, n, cls]) => (
|
||
<button
|
||
key={id}
|
||
type="button"
|
||
className={`bs-cmp-strip__kind bs-cmp-strip__kind--${cls}${
|
||
kindFilter === id ? " is-active" : ""
|
||
}`}
|
||
aria-pressed={kindFilter === id}
|
||
onClick={() => setKindFilter(id as KindFilter)}
|
||
>
|
||
{id === "diff"
|
||
? t("bizCompare.kindDiff")
|
||
: id === "all"
|
||
? t("bizCompare.kindAll")
|
||
: id === "added"
|
||
? t("bizCompare.kindAddedShort")
|
||
: id === "removed"
|
||
? t("bizCompare.kindRemovedShort")
|
||
: id === "changed"
|
||
? t("bizCompare.kindChangedShort")
|
||
: t("bizCompare.kindSuccess")}
|
||
{n !== null ? (
|
||
<>
|
||
{" "}
|
||
<b>{n}</b>
|
||
</>
|
||
) : null}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div className="bs-cmp-filter-bar">
|
||
<Input
|
||
value={resultKw}
|
||
aria-label={t("bizCompare.resultFilterPh")}
|
||
placeholder={t("bizCompare.resultFilterPh")}
|
||
onChange={(e) => setResultKw(e.target.value)}
|
||
/>
|
||
<span className="muted bs-sheet-count" role="status" aria-live="polite">
|
||
{diffsLoading ? "…" : t("bizCompare.resultRange", {
|
||
from: resultTotal ? (resultPage - 1) * resultPageSize + 1 : 0,
|
||
to: Math.min(resultPage * resultPageSize, resultTotal),
|
||
total: resultTotal,
|
||
})}
|
||
</span>
|
||
{resultSearchKeyFields.length ? (
|
||
<button
|
||
type="button"
|
||
className={`bs-cmp-strip__toggle${keyFiltersVisible ? " is-active" : ""}`}
|
||
aria-expanded={keyFiltersVisible}
|
||
onClick={() => setKeyFiltersOpen((v) => !v)}
|
||
>
|
||
{t("bizCompare.keyFiltersToggle")}
|
||
{activeKeyFilterCount
|
||
? ` (${activeKeyFilterCount})`
|
||
: ""}
|
||
</button>
|
||
) : null}
|
||
<button
|
||
type="button"
|
||
className={`bs-cmp-strip__toggle${!hideDisplayCols ? " is-active" : ""}`}
|
||
title={t("bizCompare.hideDisplayColsHint")}
|
||
aria-pressed={!hideDisplayCols}
|
||
onClick={() => setHideDisplayCols((v) => !v)}
|
||
>
|
||
{t("bizCompare.displayColsToggle")}
|
||
</button>
|
||
{(resultKw.trim() || activeKeyFilterCount > 0) ? (
|
||
<Button size="sm" variant="ghost" onPress={() => {
|
||
setResultKw("");
|
||
setResultKeyFilters({});
|
||
}}>
|
||
{t("bizCompare.clearSearch")}
|
||
</Button>
|
||
) : null}
|
||
</div>
|
||
{resultSearchKeyFields.length && keyFiltersVisible ? (
|
||
<div className="bs-cmp-key-filters">
|
||
{resultSearchKeyFields.map((f) => (
|
||
<label key={f} className="bs-cmp-key-filter">
|
||
<span className="muted">{f}</span>
|
||
<Input
|
||
value={resultKeyFilters[f] || ""}
|
||
placeholder={
|
||
f === "direction"
|
||
? "in / out"
|
||
: f === "network"
|
||
? "1.1.1.1"
|
||
: ""
|
||
}
|
||
onChange={(e) =>
|
||
setResultKeyFilters((prev) => {
|
||
const next = { ...prev };
|
||
const v = e.target.value;
|
||
if (!v.trim()) delete next[f];
|
||
else next[f] = v;
|
||
return next;
|
||
})
|
||
}
|
||
/>
|
||
</label>
|
||
))}
|
||
{activeKeyFilterCount ? (
|
||
<button
|
||
type="button"
|
||
className="linkish"
|
||
onClick={() => setResultKeyFilters({})}
|
||
>
|
||
{t("bizCompare.clearKeyFilters")}
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
{isLiveSearch ? (
|
||
<p className="muted bm-hint bs-cmp-strip__hint">
|
||
{diffsTruncated
|
||
? t("bizCompare.liveSearchTruncatedHint")
|
||
: t("bizCompare.liveSearchHint")}
|
||
</p>
|
||
) : null}
|
||
{!isLiveSearch &&
|
||
kindFilter === "unchanged" &&
|
||
(activeRunSheet?.summary?.unchanged_truncated ||
|
||
(Number(
|
||
(activeRunSheet?.summary as any)?.unchanged ??
|
||
runDetail?.summary?.unchanged ??
|
||
0,
|
||
) >
|
||
Number(
|
||
(activeRunSheet?.summary as any)?.unchanged_listed ??
|
||
runDetail?.summary?.unchanged_listed ??
|
||
0,
|
||
) &&
|
||
Number(
|
||
(activeRunSheet?.summary as any)?.unchanged_listed ??
|
||
runDetail?.summary?.unchanged_listed ??
|
||
0,
|
||
) > 0)) ? (
|
||
<p className="muted bm-hint bs-cmp-strip__hint">
|
||
{t("bizCompare.unchangedSampleHint", {
|
||
listed: String(
|
||
Number(
|
||
(activeRunSheet?.summary as any)?.unchanged_listed ??
|
||
runDetail?.summary?.unchanged_listed ??
|
||
resultTotal,
|
||
),
|
||
),
|
||
total: String(
|
||
Number(
|
||
(activeRunSheet?.summary as any)?.unchanged ??
|
||
runDetail?.summary?.unchanged ??
|
||
0,
|
||
),
|
||
),
|
||
})}
|
||
</p>
|
||
) : null}
|
||
</div>
|
||
|
||
<div
|
||
ref={tableScrollRef}
|
||
onScroll={rememberTableScroll}
|
||
className={`pt-list-table-wrap bs-sheet-table bs-cmp-result-table${
|
||
diffsLoading ? " is-loading" : ""
|
||
}`}
|
||
aria-busy={diffsLoading}
|
||
>
|
||
<table className="data-table pt-list-table bs-cmp-diff-table">
|
||
<thead>
|
||
{(() => {
|
||
const keyCols = resultColumns.keys;
|
||
const compareCols = resultColumns.extras.filter((f) =>
|
||
resultColumns.compareSet.has(f),
|
||
);
|
||
const displayCols = hideDisplayCols
|
||
? []
|
||
: resultColumns.extras.filter(
|
||
(f) => !resultColumns.compareSet.has(f),
|
||
);
|
||
const verdictColSpan = showFailCol ? 2 : 1;
|
||
const hasGroups =
|
||
keyCols.length + compareCols.length + displayCols.length > 0;
|
||
return (
|
||
<>
|
||
<tr className="bs-cmp-group-row">
|
||
<th
|
||
colSpan={verdictColSpan}
|
||
className="bs-cmp-group bs-cmp-group--verdict"
|
||
>
|
||
{t("bizCompare.colKind")}
|
||
</th>
|
||
{keyCols.length ? (
|
||
<th
|
||
colSpan={keyCols.length}
|
||
className="bs-cmp-group bs-cmp-group--key"
|
||
>
|
||
{t("bizCompare.keyFields")}
|
||
</th>
|
||
) : null}
|
||
{compareCols.length ? (
|
||
<th
|
||
colSpan={compareCols.length}
|
||
className="bs-cmp-group bs-cmp-group--compare"
|
||
>
|
||
{t("bizCompare.compareFields")}
|
||
</th>
|
||
) : null}
|
||
{displayCols.length ? (
|
||
<th
|
||
colSpan={displayCols.length}
|
||
className="bs-cmp-group bs-cmp-group--display"
|
||
>
|
||
{t("bizCompare.displayField")}
|
||
</th>
|
||
) : null}
|
||
</tr>
|
||
<tr className="bs-cmp-field-row">
|
||
<th className="bs-cmp-col-kind bs-cmp-sticky-kind bs-cmp-zone-start">
|
||
<span className="bs-cmp-th__name">
|
||
{t("bizCompare.colResult")}
|
||
</span>
|
||
</th>
|
||
{showFailCol ? (
|
||
<th className="bs-cmp-col-fail">
|
||
<span className="bs-cmp-th__name">
|
||
{t("bizCompare.colDetail")}
|
||
</span>
|
||
</th>
|
||
) : null}
|
||
{hasGroups
|
||
? keyCols.map((k, ki) => (
|
||
<th
|
||
key={k}
|
||
className={`bs-cmp-col-key${
|
||
ki === 0 ? " bs-cmp-zone-start" : ""
|
||
}`}
|
||
>
|
||
<span className="bs-cmp-th__name">{k}</span>
|
||
</th>
|
||
))
|
||
: null}
|
||
{hasGroups
|
||
? compareCols.map((f, fi) => (
|
||
<th
|
||
key={f}
|
||
className={`bs-cmp-col-compare${
|
||
fi === 0 ? " bs-cmp-zone-start" : ""
|
||
}`}
|
||
>
|
||
<span className="bs-cmp-th__name">{f}</span>
|
||
</th>
|
||
))
|
||
: null}
|
||
{hasGroups
|
||
? displayCols.map((f, fi) => (
|
||
<th
|
||
key={f}
|
||
className={`bs-cmp-col-display${
|
||
fi === 0 ? " bs-cmp-zone-start" : ""
|
||
}`}
|
||
>
|
||
<span className="bs-cmp-th__name">{f}</span>
|
||
</th>
|
||
))
|
||
: null}
|
||
</tr>
|
||
</>
|
||
);
|
||
})()}
|
||
</thead>
|
||
<tbody>
|
||
{diffsLoading || diffsError ? (
|
||
<tr>
|
||
<td colSpan={resultEmptyColSpan}>
|
||
<div className={`bs-cmp-result-state${diffsError ? " is-error" : ""}`}
|
||
role={diffsError ? "alert" : "status"}>
|
||
<strong>{t(diffsError ? "bizCompare.resultLoadFailed" : "bizCompare.resultLoading")}</strong>
|
||
{diffsError ? <>
|
||
<span>{diffsError}</span>
|
||
<Button size="sm" variant="secondary" onPress={() => setDiffsRetry((n) => n + 1)}>
|
||
{t("bizCompare.retry")}
|
||
</Button>
|
||
</> : null}
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
) : null}
|
||
{pagedDiffs.map((d, i) => {
|
||
const pre = pickSideRow(
|
||
d.mapped_before as Record<string, unknown> | null | undefined,
|
||
d.before as Record<string, unknown> | null | undefined,
|
||
);
|
||
const post = pickSideRow(
|
||
d.after as Record<string, unknown> | null | undefined,
|
||
);
|
||
const verdict = rowVerdict(d.kind);
|
||
const isFail = verdict.tone === "fail";
|
||
return (
|
||
<tr key={i} className={`bs-cmp-row bs-cmp-row--${d.kind}`}>
|
||
<td className="bs-cmp-col-kind bs-cmp-sticky-kind">
|
||
<span className={`bs-cmp-verdict bs-cmp-verdict--${verdict.tone}`}>
|
||
{verdict.label}
|
||
</span>
|
||
{isFail ? (
|
||
<span className="bs-cmp-verdict__sub">
|
||
{kindLabel(d.kind)}
|
||
</span>
|
||
) : null}
|
||
</td>
|
||
{showFailCol ? (
|
||
<td className="bs-cmp-fail-cell">
|
||
{verdict.tone === "pass"
|
||
? t("bizCompare.failFieldsEmpty")
|
||
: failFieldsLabel(d)}
|
||
</td>
|
||
) : null}
|
||
{resultColumns.keys.map((k, ki) => (
|
||
<td
|
||
key={k}
|
||
className={`bs-cmp-key-cell${
|
||
ki === 0 ? " bs-cmp-zone-start" : ""
|
||
}`}
|
||
>
|
||
{cellText(d.key?.[k] ?? pre[k] ?? post[k]) || "—"}
|
||
</td>
|
||
))}
|
||
{resultColumns.extras
|
||
.filter(
|
||
(f) =>
|
||
resultColumns.compareSet.has(f) || !hideDisplayCols,
|
||
)
|
||
.map((f, fi, visibleExtras) => {
|
||
const pv = cellText(pre[f]);
|
||
const av = cellText(post[f]);
|
||
const ch = d.changes?.[f];
|
||
const isCmp = resultColumns.compareSet.has(f);
|
||
const prev = visibleExtras[fi - 1];
|
||
const prevCmp = prev
|
||
? resultColumns.compareSet.has(prev)
|
||
: null;
|
||
const zoneStart = fi === 0 || prevCmp !== isCmp;
|
||
const mismatch =
|
||
d.kind === "added" || d.kind === "removed"
|
||
? Boolean(pv || av)
|
||
: isCmp
|
||
? Boolean(ch)
|
||
: pv !== av;
|
||
return (
|
||
<PairCell
|
||
key={f}
|
||
beforeText={pv}
|
||
afterText={av}
|
||
kind={d.kind}
|
||
mismatch={mismatch}
|
||
reason={ch?.reason}
|
||
beforeLabel={t("bizCompare.pairBefore")}
|
||
afterLabel={t("bizCompare.pairAfter")}
|
||
zoneStart={zoneStart}
|
||
/>
|
||
);
|
||
})}
|
||
</tr>
|
||
);
|
||
})}
|
||
{runDetail && !diffsLoading && !diffsError && !pagedDiffs.length ? (
|
||
<tr>
|
||
<td colSpan={resultEmptyColSpan}>
|
||
<div className="pt-list-empty">
|
||
{hasResultSearch ? t("bizCompare.searchEmpty") : kindFilter === "unchanged"
|
||
? Number(activeRunSheet?.summary?.unchanged || 0) > 0 &&
|
||
!Number(activeRunSheet?.summary?.unchanged_listed || 0)
|
||
? t("bizCompare.unchangedNotStored")
|
||
: t("bizCompare.resultEmpty")
|
||
: activeSheetPending
|
||
? t("bizCompare.runStatusRunning")
|
||
: t("bizCompare.resultEmpty")}
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
) : null}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<ListPager
|
||
page={resultPage}
|
||
pages={resultPages}
|
||
total={resultTotal}
|
||
pageSize={resultPageSize}
|
||
pageSizeOptions={[50, 100, 200, 500]}
|
||
onPageChange={setResultPage}
|
||
onPageSizeChange={(n) => {
|
||
setResultPageSize(n);
|
||
setResultPage(1);
|
||
}}
|
||
disabled={diffsLoading || !runDetail}
|
||
/>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="pt-list-empty">{t("bizCompare.noRuns")}</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</Modal.Body>
|
||
<Modal.Footer className="bs-cmp-job-footer">
|
||
{jobDetailTab === "config" ? (
|
||
<>
|
||
<Button size="sm" variant="primary" isDisabled={busy || Boolean(missingJobSheets.length)} onPress={() => void saveJobConfig()}>
|
||
{t("bizCompare.saveJob")}
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant="secondary"
|
||
isDisabled={busy || jobHasActiveRun || Boolean(missingJobSheets.length)}
|
||
onPress={() => void runNow()}
|
||
>
|
||
{jobHasActiveRun ? t("bizCompare.runStatusRunning") : t("bizCompare.runNow")}
|
||
</Button>
|
||
{jobId ? (
|
||
<Button size="sm" variant="ghost" className="bs-cmp-runs__delete" isDisabled={busy || jobHasActiveRun} onPress={() => void removeJob(jobId)}>
|
||
{t("bizCompare.deleteJob")}
|
||
</Button>
|
||
) : null}
|
||
</>
|
||
) : jobDetailTab === "runs" ? null : (
|
||
<>
|
||
<Button
|
||
size="sm"
|
||
variant="secondary"
|
||
isDisabled={busy || runIsActive || !runDetail?.id}
|
||
onPress={() => void downloadRunTables()}
|
||
>
|
||
{t("bizCompare.exportTables")}
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant="danger"
|
||
isDisabled={busy || !runDetail?.id}
|
||
onPress={() => void removeRun(String(runDetail?.id || ""))}
|
||
>
|
||
{t("bizCompare.deleteRun")}
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant="primary"
|
||
isDisabled={busy || jobHasActiveRun}
|
||
onPress={() => void runNow()}
|
||
>
|
||
{jobHasActiveRun ? t("bizCompare.runStatusRunning") : t("bizCompare.runNow")}
|
||
</Button>
|
||
</>
|
||
)}
|
||
<Button size="sm" variant="ghost" onPress={closeJob}>
|
||
{t("bizState.cancel")}
|
||
</Button>
|
||
</Modal.Footer>
|
||
</AppModalShell>
|
||
</section>
|
||
);
|
||
}
|