netx/web/src/pages/network/BizMigrationPage.tsx
oliver a65c9fc420 Allow compare templates to split one collected metric into filtered sheets.
BGP/ISIS (and any other metric) can share a metric_id with distinct sheet_id + row_filters; drop conflicting bgp state_or_pfx and keep state/pfx_rcd.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-19 01:46:03 +08:00

1842 lines
69 KiB
TypeScript

import { Button, Input, Modal } from "@heroui/react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
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 {
bizCompareListMappings,
bizCompareCreateMapping,
bizCompareUpdateMapping,
bizMigrationCollectNow,
bizMigrationCreateBatch,
bizMigrationCreateProject,
bizMigrationEnsureHighfreq,
bizMigrationEvaluate,
bizMigrationFinishBatch,
bizMigrationGetBoard,
bizMigrationListBaselineExpect,
bizMigrationListBatches,
bizMigrationListDiffs,
bizMigrationListProjects,
bizMigrationListRedTickets,
bizMigrationListRuns,
bizMigrationPatchBatch,
bizMigrationPatchProject,
bizMigrationResolveRedTicket,
bizMonitorListTemplates,
bizStateListBatches,
bizStateListTasks,
ApiRequestError,
formatErr,
} from "../../services/api";
import { formatSystemTime } from "../../utils/time";
import { jobChipColor, NmStatusChip } from "./nmChips";
type TaskOpt = { id: string; ne_name: string; ne_ip: string; note?: string; interval_sec?: number };
type PortMapping = {
id: string;
name: string;
rows: Array<{ before_if: string; after_if: string }>;
};
type MonitorTplOpt = {
id: string;
name: string;
compare_template_id?: string;
compare_template_name?: string;
collect_metric_ids?: string[];
};
type Project = {
id: string;
name: string;
old_task_id: string;
new_task_id: string;
old_baseline_batch_id: string;
new_baseline_batch_id: string;
mapping_id: string;
monitor_template_id?: string;
monitor_template?: MonitorTplOpt;
status: string;
note?: string;
created_at?: string | null;
old_task?: TaskOpt;
new_task?: TaskOpt;
};
type MigBatch = {
id: string;
batch_label: string;
status: string;
expect_set?: {
ports?: string[];
items?: Array<{ metric_id: string; sheet_id?: string; key?: string; keys?: string[] }>;
};
accept_status?: string;
accept_run_id?: string;
accept_summary?: {
passed?: boolean;
progress_ok?: number;
progress_total?: number;
anomaly?: number;
verdict_counts?: Record<string, number>;
};
};
type RedTicket = {
id: string;
batch_id: string;
key_str: string;
new_key_str?: string;
verdict: string;
old_status?: string;
new_status?: string;
status: string;
};
type SheetCard = {
metric_id: string;
sheet_id?: string;
title?: string;
progress_ok: number;
progress_total: number;
anomaly: number;
new_baseline_missing?: boolean;
new_baseline_mode?: string;
};
type DiffRow = {
id: string;
metric_id: string;
sheet_id?: string;
verdict: string;
color: string;
old_kind: string;
new_kind: string;
in_expect: boolean;
key_str?: string;
new_key_str?: string;
old_status?: string;
new_status?: string;
rule_hit?: string;
};
type EvalRun = {
id: string;
created_at?: string | null;
purpose?: string;
old_batch_id?: string;
new_batch_id?: string;
summary?: { anomaly?: number; progress?: { ok?: number; total?: number } };
};
type ExpectSheetItem = {
key: string;
keys: string[];
mapped_to?: string;
label?: string;
row?: Record<string, unknown>;
};
type ExpectSheet = {
metric_id: string;
sheet_id?: string;
title?: string;
key_fields: string[];
iface_fields: string[];
items: ExpectSheetItem[];
};
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 DetailTab = "setup" | "batches" | "board";
const COLOR: Record<string, string> = {
green: "#16a34a",
yellow: "#ca8a04",
red: "#dc2626",
gray: "#6b7280",
};
const VERDICT_I18N: Record<string, string> = {
migrated: "bizMigration.verdictMigrated",
migrating: "bizMigration.verdictMigrating",
lost: "bizMigration.verdictLost",
anomaly: "bizMigration.verdictAnomaly",
not_involved: "bizMigration.verdictNotInvolved",
unexpected_new: "bizMigration.verdictUnexpected",
unfinished: "bizMigration.verdictUnfinished",
ok: "bizMigration.verdictOk",
};
function fmtTime(v?: string | null) {
if (!v) return "—";
return formatSystemTime(v) || v;
}
function taskLabel(x: TaskOpt) {
const base = `${x.ne_name || x.id} (${x.ne_ip || "-"})`;
const note = x.note ? ` · ${x.note}` : "";
const iv = x.interval_sec ? ` · ${x.interval_sec}s` : "";
return `${base}${note}${iv}`;
}
export function BizMigrationPage() {
const { t } = useI18n();
const { showOk, showError } = useToast();
const [projects, setProjects] = useState<Project[]>([]);
const [tasks, setTasks] = useState<TaskOpt[]>([]);
const [mappings, setMappings] = useState<PortMapping[]>([]);
const [busy, setBusy] = useState(false);
const [listKeyword, setListKeyword] = useState("");
const debouncedListKw = useDebouncedValue(listKeyword, 250);
const [createOpen, setCreateOpen] = useState(false);
const [createName, setCreateName] = useState("");
const [createOldTaskId, setCreateOldTaskId] = useState("");
const [createNewTaskId, setCreateNewTaskId] = useState("");
const [createMonitorTplId, setCreateMonitorTplId] = useState("");
const [createOldBaselineId, setCreateOldBaselineId] = useState("");
const [createNewBaselineId, setCreateNewBaselineId] = useState("");
const [createOldBatches, setCreateOldBatches] = useState<{ id: string; started_at?: string | null }[]>([]);
const [createNewBatches, setCreateNewBatches] = useState<{ id: string; started_at?: string | null }[]>([]);
const [createShowMore, setCreateShowMore] = useState(false);
const [monitorTpls, setMonitorTpls] = useState<MonitorTplOpt[]>([]);
const [boardSubTab, setBoardSubTab] = useState<"diffs" | "red">("diffs");
const [projectId, setProjectId] = useState("");
const [detailTab, setDetailTab] = useState<DetailTab>("setup");
const [mappingId, setMappingId] = useState("");
const [mapName, setMapName] = useState("");
const [mapText, setMapText] = useState("");
const [monitorTplId, setMonitorTplId] = useState("");
const [oldBaselineId, setOldBaselineId] = useState("");
const [newBaselineId, setNewBaselineId] = useState("");
const [oldBatches, setOldBatches] = useState<{ id: string; started_at?: string | null }[]>([]);
const [newBatches, setNewBatches] = useState<{ id: string; started_at?: string | null }[]>([]);
const [batches, setBatches] = useState<MigBatch[]>([]);
const [batchId, setBatchId] = useState("");
const [board, setBoard] = useState<{
run?: {
id: string;
summary?: {
sheet_cards?: SheetCard[];
progress?: { ok: number; total: number };
anomaly?: number;
expect_ports?: string[];
};
} | null;
} | null>(null);
const [diffs, setDiffs] = useState<DiffRow[]>([]);
const [onlyExpect, setOnlyExpect] = useState(false);
const [boardMetricId, setBoardMetricId] = useState("");
const [batchLabel, setBatchLabel] = useState("");
const [expectSheets, setExpectSheets] = useState<ExpectSheet[]>([]);
const [expectMetricId, setExpectMetricId] = useState("");
const [selectedExpectKeys, setSelectedExpectKeys] = useState<Set<string>>(new Set());
const [portFilter, setPortFilter] = useState("");
const [redTickets, setRedTickets] = useState<RedTicket[]>([]);
const [openRedCount, setOpenRedCount] = useState(0);
const [acceptInfo, setAcceptInfo] = useState<MigBatch["accept_summary"] | null>(null);
const [pinOldBatchId, setPinOldBatchId] = useState("");
const [pinNewBatchId, setPinNewBatchId] = useState("");
const [evalRuns, setEvalRuns] = useState<EvalRun[]>([]);
const [selectedRunId, setSelectedRunId] = useState("");
const project = useMemo(
() => projects.find((p) => p.id === projectId) || null,
[projects, projectId],
);
const batch = useMemo(() => batches.find((b) => b.id === batchId) || null, [batches, batchId]);
const sheetCards = board?.run?.summary?.sheet_cards || [];
const visibleDiffs = useMemo(() => {
let rows = diffs;
if (boardMetricId) {
rows = rows.filter(
(d) => sheetIdentity(d) === boardMetricId || d.metric_id === boardMetricId,
);
}
if (onlyExpect) rows = rows.filter((d) => d.in_expect);
return rows;
}, [diffs, onlyExpect, boardMetricId]);
const boardMetricOptions = useMemo(() => {
const ids = new Set<string>();
for (const c of sheetCards) {
const id = sheetIdentity(c);
if (id) ids.add(id);
}
for (const d of diffs) {
const id = sheetIdentity(d);
if (id) ids.add(id);
}
return [...ids];
}, [sheetCards, diffs]);
const filteredProjects = useMemo(() => {
const kw = debouncedListKw.trim().toLowerCase();
if (!kw) return projects;
return projects.filter((p) => {
const blob = [
p.name,
p.status,
p.note || "",
p.old_task?.ne_name || "",
p.old_task?.ne_ip || "",
p.new_task?.ne_name || "",
p.new_task?.ne_ip || "",
p.old_task_id,
p.new_task_id,
]
.join(" ")
.toLowerCase();
return blob.includes(kw);
});
}, [projects, debouncedListKw]);
const activeCount = useMemo(
() => projects.filter((p) => String(p.status || "").toLowerCase() === "active").length,
[projects],
);
useEffect(() => {
if (batch?.accept_summary && Object.keys(batch.accept_summary).length) {
setAcceptInfo(batch.accept_summary);
} else if (batch && batch.accept_status === "none") {
setAcceptInfo(null);
}
}, [batch]);
const filteredExpectItems = useMemo(() => {
const sheet =
expectSheets.find((s) => sheetIdentity(s) === expectMetricId) || expectSheets[0];
const items = sheet?.items || [];
const kw = portFilter.trim().toLowerCase();
if (!kw) return items;
return items.filter((it) => {
const blob = `${it.key} ${it.label || ""} ${JSON.stringify(it.row || {})}`.toLowerCase();
return blob.includes(kw);
});
}, [expectSheets, expectMetricId, portFilter]);
const reloadProjects = useCallback(async () => {
const res = await bizMigrationListProjects();
setProjects((res.items || []) as Project[]);
}, []);
const reloadMappings = useCallback(async () => {
const mp = await bizCompareListMappings();
setMappings(
((mp.items || []) as Record<string, unknown>[]).map((x) => ({
id: String(x.id || ""),
name: String(x.name || x.id || ""),
rows: Array.isArray(x.rows)
? (x.rows as Array<{ before_if?: string; after_if?: string }>).map((r) => ({
before_if: String(r.before_if || ""),
after_if: String(r.after_if || ""),
}))
: [],
})),
);
}, []);
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, list?: PortMapping[]) => {
const m = (list || mappings).find((x) => x.id === id);
if (!m) {
setMappingId(id);
return;
}
setMappingId(id);
setMapName(m.name);
setMapText(m.rows.map((r) => `${r.before_if},${r.after_if}`).join("\n"));
};
const resetMappingEditor = () => {
setMappingId("");
setMapName(t("bizCompare.mapping"));
setMapText("");
};
const saveMapping = async (): Promise<string> => {
const rows = parseMapRows();
if (mappingId) {
await bizCompareUpdateMapping(mappingId, { name: mapName.trim() || t("bizCompare.mapping"), rows });
await reloadMappings();
return mappingId;
}
const m = await bizCompareCreateMapping({
name: mapName.trim() || t("bizCompare.mapping"),
rows,
});
const id = String(m.id || "");
setMappingId(id);
await reloadMappings();
return id;
};
const loadBaselineExpect = useCallback(async (pid: string) => {
if (!pid) {
setExpectSheets([]);
return;
}
try {
const res = await bizMigrationListBaselineExpect(pid);
const sheets = (res.sheets || []) as ExpectSheet[];
setExpectSheets(sheets);
setExpectMetricId((prev) => {
if (prev && sheets.some((s) => sheetIdentity(s) === prev)) return prev;
return sheets[0] ? sheetIdentity(sheets[0]) : "";
});
} catch {
setExpectSheets([]);
}
}, []);
const loadRedTickets = useCallback(async (pid: string) => {
if (!pid) {
setRedTickets([]);
setOpenRedCount(0);
return;
}
try {
const res = await bizMigrationListRedTickets(pid);
setRedTickets((res.items || []) as RedTicket[]);
setOpenRedCount(Number(res.open_count || 0));
} catch {
setRedTickets([]);
setOpenRedCount(0);
}
}, []);
useEffect(() => {
void (async () => {
try {
const [pr, tk, mp, mt] = await Promise.all([
bizMigrationListProjects(),
bizStateListTasks(),
bizCompareListMappings(),
bizMonitorListTemplates(),
]);
setProjects((pr.items || []) as Project[]);
setTasks(
((tk.items || []) as Record<string, unknown>[]).map((x) => ({
id: String(x.id || ""),
ne_name: String(x.ne_name || ""),
ne_ip: String(x.ne_ip || ""),
note: String(x.note || ""),
interval_sec: Number(x.interval_sec || 0) || undefined,
})),
);
setMappings(
((mp.items || []) as Record<string, unknown>[]).map((x) => ({
id: String(x.id || ""),
name: String(x.name || x.id || ""),
rows: Array.isArray(x.rows)
? (x.rows as Array<{ before_if?: string; after_if?: string }>).map((r) => ({
before_if: String(r.before_if || ""),
after_if: String(r.after_if || ""),
}))
: [],
})),
);
const mts = ((mt.items || []) as Record<string, unknown>[]).map((x) => ({
id: String(x.id || ""),
name: String(x.name || x.id || ""),
compare_template_id: String(x.compare_template_id || ""),
compare_template_name: String(x.compare_template_name || ""),
collect_metric_ids: Array.isArray(x.collect_metric_ids)
? (x.collect_metric_ids as string[])
: [],
}));
setMonitorTpls(mts);
if (!createMonitorTplId && mts.length) {
const port = mts.find((x) => x.name.includes("端口") || x.name.toLowerCase().includes("port"));
setCreateMonitorTplId(port?.id || mts[0].id);
}
} catch (e) {
showError(formatErr(e));
}
})();
}, [showError]);
useEffect(() => {
if (!projectId) {
setBatches([]);
setBatchId("");
setExpectSheets([]);
setExpectMetricId("");
setSelectedExpectKeys(new Set());
setRedTickets([]);
setOpenRedCount(0);
setBoard(null);
setDiffs([]);
setAcceptInfo(null);
return;
}
void (async () => {
try {
const res = await bizMigrationListBatches(projectId);
const items = (res.items || []) as MigBatch[];
setBatches(items);
setBatchId((prev) => (items.find((b) => b.id === prev) ? prev : items[0]?.id || ""));
await loadBaselineExpect(projectId);
await loadRedTickets(projectId);
} catch (e) {
showError(formatErr(e));
}
})();
}, [projectId, showError, loadBaselineExpect, loadRedTickets]);
useEffect(() => {
if (!batchId) {
setBoard(null);
setDiffs([]);
setEvalRuns([]);
setSelectedRunId("");
return;
}
void loadRuns(batchId);
void loadBoard(batchId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [batchId]);
useEffect(() => {
if (!createOldTaskId) {
setCreateOldBatches([]);
return;
}
void bizStateListBatches(createOldTaskId, 30).then((r) => {
setCreateOldBatches(
((r.items || []) as Record<string, unknown>[]).map((x) => ({
id: String(x.id || ""),
started_at: (x.started_at as string) || null,
})),
);
});
}, [createOldTaskId]);
useEffect(() => {
if (!createNewTaskId) {
setCreateNewBatches([]);
return;
}
void bizStateListBatches(createNewTaskId, 30).then((r) => {
setCreateNewBatches(
((r.items || []) as Record<string, unknown>[]).map((x) => ({
id: String(x.id || ""),
started_at: (x.started_at as string) || null,
})),
);
});
}, [createNewTaskId]);
useEffect(() => {
if (!project?.old_task_id) {
setOldBatches([]);
return;
}
void bizStateListBatches(project.old_task_id, 30).then((r) => {
setOldBatches(
((r.items || []) as Record<string, unknown>[]).map((x) => ({
id: String(x.id || ""),
started_at: (x.started_at as string) || null,
})),
);
});
}, [project?.old_task_id]);
useEffect(() => {
if (!project?.new_task_id) {
setNewBatches([]);
return;
}
void bizStateListBatches(project.new_task_id, 30).then((r) => {
setNewBatches(
((r.items || []) as Record<string, unknown>[]).map((x) => ({
id: String(x.id || ""),
started_at: (x.started_at as string) || null,
})),
);
});
}, [project?.new_task_id]);
useEffect(() => {
if (!project) return;
setMonitorTplId(project.monitor_template_id || project.monitor_template?.id || "");
setOldBaselineId(project.old_baseline_batch_id || "");
setNewBaselineId(project.new_baseline_batch_id || "");
if (project.mapping_id) loadMappingText(project.mapping_id);
else resetMappingEditor();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [project?.id, project?.mapping_id, mappings.length]);
async function loadRuns(bid: string) {
try {
const res = await bizMigrationListRuns(bid, 20);
const items = (res.items || []) as EvalRun[];
setEvalRuns(items);
setSelectedRunId((prev) => {
if (prev && items.some((r) => r.id === prev)) return prev;
return items[0]?.id || "";
});
} catch {
setEvalRuns([]);
}
}
async function loadBoard(bid: string, runId = "") {
try {
const b = await bizMigrationGetBoard(bid, runId);
setBoard(b as typeof board);
const rid = String((b as { run?: { id?: string } })?.run?.id || runId || "");
if (rid) setSelectedRunId(rid);
if (rid) {
const d = await bizMigrationListDiffs({ runId: rid, limit: 500 });
setDiffs((d.items || []) as DiffRow[]);
} else {
setDiffs([]);
}
} catch (e) {
showError(formatErr(e));
}
}
function resetCreateForm() {
setCreateName("");
setCreateOldTaskId("");
setCreateNewTaskId("");
setCreateOldBaselineId("");
setCreateNewBaselineId("");
setCreateShowMore(false);
resetMappingEditor();
const port = monitorTpls.find((x) => x.name.includes("端口") || x.name.toLowerCase().includes("port"));
setCreateMonitorTplId(port?.id || monitorTpls[0]?.id || "");
}
const openCreate = () => {
closeProject();
resetCreateForm();
setCreateOpen(true);
};
const closeCreate = () => {
setCreateOpen(false);
resetCreateForm();
};
const openProject = (id: string, tab: DetailTab = "setup") => {
setProjectId(id);
setDetailTab(tab);
setSelectedExpectKeys(new Set());
setPortFilter("");
setBatchLabel("");
setOnlyExpect(true);
setBoardSubTab("diffs");
setBoardMetricId("");
};
const closeProject = () => {
setProjectId("");
setDetailTab("setup");
};
async function onCreateProject() {
if (!createName.trim() || !createOldTaskId || !createNewTaskId) {
showError(t("bizMigration.needProjectFields"));
return;
}
setBusy(true);
try {
let mid = mappingId;
if (parseMapRows().length) {
mid = await saveMapping();
}
const p = (await bizMigrationCreateProject({
name: createName.trim(),
old_task_id: createOldTaskId,
new_task_id: createNewTaskId,
mapping_id: mid || "",
monitor_template_id: createMonitorTplId || "",
old_baseline_batch_id: createOldBaselineId || "",
new_baseline_batch_id: createNewBaselineId || "",
status: "active",
})) as Project;
await reloadProjects();
closeCreate();
showOk(t("bizMigration.projectCreated"));
openProject(p.id, "setup");
} catch (e) {
showError(formatErr(e));
} finally {
setBusy(false);
}
}
async function onSaveMappingOnly() {
if (!projectId) return;
setBusy(true);
try {
const mid = await saveMapping();
if (mid) {
await bizMigrationPatchProject(projectId, { mapping_id: mid });
await reloadProjects();
}
showOk(t("bizMigration.mappingBound"));
} catch (e) {
showError(formatErr(e));
} finally {
setBusy(false);
}
}
async function onSaveBaseline() {
if (!projectId) return;
setBusy(true);
try {
let mid = mappingId;
if (mappingId || parseMapRows().length) {
mid = await saveMapping();
} else {
mid = "";
}
await bizMigrationPatchProject(projectId, {
old_baseline_batch_id: oldBaselineId || project?.old_baseline_batch_id || "",
new_baseline_batch_id: newBaselineId || project?.new_baseline_batch_id || "",
mapping_id: mid || "",
monitor_template_id: monitorTplId || project?.monitor_template_id || "",
});
await reloadProjects();
await loadBaselineExpect(projectId);
showOk(t("bizMigration.baselineSaved"));
} catch (e) {
showError(formatErr(e));
} finally {
setBusy(false);
}
}
async function onCreateBatch() {
if (!projectId) return;
// One item per selected key. A flat `keys: [a, b]` is parsed as one composite key.
const items: Array<{ metric_id: string; sheet_id?: string; key: string }> = [];
const ports: string[] = [];
for (const sheet of expectSheets) {
const keys = sheet.items.map((it) => it.key).filter((k) => selectedExpectKeys.has(k));
const sid = sheetIdentity(sheet);
for (const key of keys) {
items.push({ metric_id: sheet.metric_id, sheet_id: sid, key });
}
if (sheet.metric_id === "interface_brief") ports.push(...keys);
}
if (!items.length && !ports.length) {
showError(t("bizMigration.needExpectPorts"));
return;
}
setBusy(true);
try {
const b = (await bizMigrationCreateBatch(projectId, {
batch_label: batchLabel.trim() || t("bizMigration.defaultBatchLabel"),
expect_set: { ports, items },
status: "pending",
})) as MigBatch;
const res = await bizMigrationListBatches(projectId);
setBatches((res.items || []) as MigBatch[]);
setBatchId(b.id);
setBatchLabel("");
setSelectedExpectKeys(new Set());
if (Number((b as { open_red_count?: number }).open_red_count || 0) > 0) {
showOk(
t("bizMigration.batchCreatedWithRed", {
n: String((b as { open_red_count?: number }).open_red_count),
}),
);
} else {
showOk(t("bizMigration.batchCreated"));
}
await loadRedTickets(projectId);
setDetailTab("board");
} catch (e) {
showError(formatErr(e));
} finally {
setBusy(false);
}
}
async function setBatchStatus(status: string) {
if (!batchId) return;
setBusy(true);
try {
if (status === "review" || status === "done") {
const res = (await bizMigrationFinishBatch(batchId, status === "done")) as {
batch?: MigBatch;
accept_summary?: MigBatch["accept_summary"];
open_red_count?: number;
};
const list = await bizMigrationListBatches(projectId);
setBatches((list.items || []) as MigBatch[]);
setAcceptInfo(res.accept_summary || res.batch?.accept_summary || null);
await loadRedTickets(projectId);
if (res.accept_summary?.passed) {
showOk(t("bizMigration.acceptPassed"));
} else {
showOk(t("bizMigration.acceptFailed", { n: String(res.open_red_count ?? openRedCount) }));
}
if (res.batch?.accept_run_id) {
await loadBoard(batchId, res.batch.accept_run_id);
}
setDetailTab("board");
} else {
await bizMigrationPatchBatch(batchId, { status });
const res = await bizMigrationListBatches(projectId);
setBatches((res.items || []) as MigBatch[]);
showOk(t("bizMigration.statusUpdated"));
if (status === "active") await loadRedTickets(projectId);
}
} catch (e) {
showError(formatErr(e));
} finally {
setBusy(false);
}
}
async function onResolveTicket(id: string) {
try {
await bizMigrationResolveRedTicket(id);
await loadRedTickets(projectId);
showOk(t("bizMigration.redResolved"));
} catch (e) {
showError(formatErr(e));
}
}
async function onEvaluate() {
if (!batchId) return;
if (!project?.old_baseline_batch_id || !project?.new_baseline_batch_id) {
showError(t("bizMigration.needBothBaselines"));
return;
}
setBusy(true);
try {
const body: Record<string, unknown> = {};
if (pinOldBatchId) body.old_batch_id = pinOldBatchId;
if (pinNewBatchId) body.new_batch_id = pinNewBatchId;
const run = await bizMigrationEvaluate(batchId, body);
const rid = String((run as { id?: string }).id || "");
await loadRuns(batchId);
await loadBoard(batchId, rid);
showOk(t("bizMigration.evaluated"));
setDetailTab("board");
} catch (e) {
const detail = e instanceof ApiRequestError ? String(e.detail || "") : "";
if (detail === "old_baseline_required") showError(t("bizMigration.errOldBaselineRequired"));
else if (detail === "new_baseline_required") showError(t("bizMigration.errNewBaselineRequired"));
else showError(formatErr(e));
} finally {
setBusy(false);
}
}
async function onEnsureHighfreq() {
if (!projectId) return;
setBusy(true);
try {
await bizMigrationEnsureHighfreq(projectId, {
interval_sec: 60,
collect_now: true,
});
await reloadProjects();
showOk(t("bizMigration.highfreqReady"));
} catch (e) {
showError(formatErr(e));
} finally {
setBusy(false);
}
}
async function onCollectNow() {
if (!projectId) return;
setBusy(true);
try {
await bizMigrationCollectNow(projectId);
showOk(t("bizMigration.collectTriggered"));
} catch (e) {
showError(formatErr(e));
} finally {
setBusy(false);
}
}
function toggleExpectKey(key: string) {
setSelectedExpectKeys((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
}
const selectedMonitorTpl = useMemo(() => {
const id = monitorTplId || project?.monitor_template_id || "";
return monitorTpls.find((x) => x.id === id) || project?.monitor_template || null;
}, [monitorTplId, monitorTpls, project]);
function verdictLabel(v: string) {
const key = VERDICT_I18N[v];
return key ? t(key) : v;
}
function statusLabel(status: string) {
const s = String(status || "").toLowerCase();
if (s === "active") return t("bizMigration.statusActive");
if (s === "done" || s === "completed") return t("bizMigration.statusDone");
if (s === "paused") return t("bizMigration.statusPaused");
if (s === "pending") return t("bizMigration.statusPending");
if (s === "review") return t("bizMigration.statusReview");
return status || "—";
}
const taskOptions = (
<>
<option value="">—</option>
{tasks.map((x) => (
<option key={x.id} value={x.id}>
{taskLabel(x)}
</option>
))}
</>
);
const batchOptions = (items: { id: string; started_at?: string | null }[]) => (
<>
<option value="">—</option>
{items.map((x) => (
<option key={x.id} value={x.id}>
{fmtTime(x.started_at) !== "—" ? fmtTime(x.started_at) : x.id.slice(0, 12)}
</option>
))}
</>
);
return (
<section className="panel nm-page-panel">
<div className="panel__toolbar">
<h2>{t("bizMigration.title")}</h2>
<div className="btn-row">
<Button size="sm" variant="primary" onPress={openCreate}>
{t("bizMigration.create")}
</Button>
</div>
</div>
<p className="panel__hint muted">
{t("bizMigration.hintShort")}{" "}
<Link to="/network/cutover/monitor-templates">{t("network.nav.monitorTemplates")}</Link>
{" · "}
<Link to="/network/cutover/compare-templates">{t("network.nav.compareTemplates")}</Link>
</p>
<div className="pt-list">
<div className="pt-list-kpis">
<div className="pt-list-kpi">
<div className="pt-list-kpi__label">{t("bizMigration.kpiProjects")}</div>
<div className="pt-list-kpi__value">{projects.length}</div>
</div>
<div className="pt-list-kpi pt-list-kpi--live">
<div className="pt-list-kpi__label">{t("bizMigration.kpiActive")}</div>
<div className="pt-list-kpi__value">{activeCount}</div>
</div>
</div>
<div className="filter-inline">
<Input
value={listKeyword}
placeholder={t("bizMigration.listFilterPh")}
onChange={(e) => setListKeyword(e.target.value)}
/>
</div>
<div className="pt-list-table-wrap">
<table className="data-table pt-list-table">
<thead>
<tr>
<th>{t("bizMigration.colName")}</th>
<th>{t("bizMigration.colPair")}</th>
<th>{t("bizMigration.colStatus")}</th>
<th>{t("bizMigration.colBaseline")}</th>
<th>{t("bizMigration.colCreated")}</th>
<th>{t("bizMigration.colActions")}</th>
</tr>
</thead>
<tbody>
{filteredProjects.map((row) => (
<tr key={row.id}>
<td>
<div className="pt-list-task-name">{row.name}</div>
{row.note ? <div className="muted">{row.note}</div> : null}
</td>
<td>
<div className="pt-list-task-name">
{row.old_task?.ne_name || row.old_task_id.slice(0, 8)}
</div>
<div className="muted">
→ {row.new_task?.ne_name || row.new_task_id.slice(0, 8)}
{row.old_task?.ne_ip || row.new_task?.ne_ip
? ` · ${row.old_task?.ne_ip || "—"} / ${row.new_task?.ne_ip || "—"}`
: ""}
</div>
</td>
<td>
<NmStatusChip color={jobChipColor(row.status)}>{statusLabel(row.status)}</NmStatusChip>
</td>
<td>
{row.old_baseline_batch_id && row.new_baseline_batch_id ? (
<NmStatusChip color="success">{t("bizMigration.baselineSet")}</NmStatusChip>
) : (
<NmStatusChip color="warning">{t("bizMigration.baselineUnset")}</NmStatusChip>
)}
</td>
<td className="pt-list-time">{fmtTime(row.created_at)}</td>
<td>
<div className="pt-list-actions">
<Button size="sm" variant="primary" onPress={() => openProject(row.id, "setup")}>
{t("bizMigration.detail")}
</Button>
<Button size="sm" variant="secondary" onPress={() => openProject(row.id, "board")}>
{t("bizMigration.board")}
</Button>
</div>
</td>
</tr>
))}
{!filteredProjects.length ? (
<tr>
<td colSpan={6}>
<div className="pt-list-empty">{t("bizMigration.empty")}</div>
</td>
</tr>
) : null}
</tbody>
</table>
</div>
</div>
{/* Create task */}
<AppModalShell open={createOpen} onClose={closeCreate} size="lg">
<Modal.Header>
<Modal.Heading>{t("bizMigration.create")}</Modal.Heading>
<Modal.CloseTrigger />
</Modal.Header>
<Modal.Body className="flex flex-col gap-3 bm-create">
<p className="muted bm-hint">{t("bizMigration.createHintShort")}</p>
<Input
value={createName}
onChange={(e) => setCreateName(e.target.value)}
placeholder={t("bizMigration.projectNamePh")}
aria-label={t("bizMigration.projectName")}
/>
<div className="bm-pair-grid">
<FieldSelect
label={t("bizMigration.oldTask")}
value={createOldTaskId}
onChange={(e) => {
setCreateOldTaskId(e.target.value);
setCreateOldBaselineId("");
}}
fullWidth
>
{taskOptions}
</FieldSelect>
<FieldSelect
label={t("bizMigration.newTask")}
value={createNewTaskId}
onChange={(e) => {
setCreateNewTaskId(e.target.value);
setCreateNewBaselineId("");
}}
fullWidth
>
{taskOptions}
</FieldSelect>
</div>
{!tasks.length ? (
<p className="muted bm-hint">
{t("bizMigration.needBizStateTasks")}{" "}
<Link to="/network/cutover/biz-state" onClick={closeCreate}>
{t("bizMigration.openBizState")}
</Link>
</p>
) : null}
<Button size="sm" variant="ghost" onPress={() => setCreateShowMore((v) => !v)}>
{createShowMore ? t("bizMigration.hideCreateMore") : t("bizMigration.showCreateMore")}
</Button>
{createShowMore ? (
<div className="flex flex-col gap-3">
<FieldSelect
label={t("bizMigration.monitorTemplate")}
value={createMonitorTplId}
onChange={(e) => setCreateMonitorTplId(e.target.value)}
fullWidth
>
<option value="">{t("bizMigration.optionalNone")}</option>
{monitorTpls.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</FieldSelect>
<div className="bm-mapping">
<div className="bm-mapping__label">{t("bizMigration.portMapping")}</div>
<p className="muted bm-hint">{t("bizCompare.mappingOptionalHint")}</p>
<div className="bm-mapping__row">
<FieldSelect
value={mappingId}
onChange={(e) => {
const id = e.target.value;
if (id) loadMappingText(id);
else resetMappingEditor();
}}
fullWidth
>
<option value="">{t("bizCompare.newMapping")}</option>
{mappings.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</FieldSelect>
<Input
value={mapName}
placeholder={t("bizCompare.mapName")}
onChange={(e) => setMapName(e.target.value)}
aria-label={t("bizCompare.mapName")}
/>
<Button size="sm" variant="secondary" isDisabled={busy} onPress={() => void onSaveMappingOnly()}>
{t("bizCompare.saveMapping")}
</Button>
</div>
<textarea
className="bm-mapping__text"
value={mapText}
onChange={(e) => setMapText(e.target.value)}
placeholder={t("bizCompare.mapHint")}
rows={5}
/>
</div>
<div className="bm-pair-grid">
<FieldSelect
label={t("bizMigration.oldBaseline")}
value={createOldBaselineId}
onChange={(e) => setCreateOldBaselineId(e.target.value)}
fullWidth
disabled={!createOldTaskId}
>
{batchOptions(createOldBatches)}
</FieldSelect>
<FieldSelect
label={t("bizMigration.newBaseline")}
value={createNewBaselineId}
onChange={(e) => setCreateNewBaselineId(e.target.value)}
fullWidth
disabled={!createNewTaskId}
>
{batchOptions(createNewBatches)}
</FieldSelect>
</div>
</div>
) : null}
</Modal.Body>
<Modal.Footer>
<Button size="sm" variant="secondary" onPress={closeCreate}>
{t("bizMigration.cancel")}
</Button>
<Button size="sm" variant="primary" isDisabled={busy} onPress={() => void onCreateProject()}>
{t("bizMigration.create")}
</Button>
</Modal.Footer>
</AppModalShell>
{/* Project detail */}
<AppModalShell
open={Boolean(projectId && project)}
onClose={closeProject}
size="cover"
className={detailTab === "board" ? "bs-cmp-board-modal" : undefined}
>
<Modal.Header>
<Modal.Heading>
{project?.name || t("bizMigration.detail")}
{project ? (
<span style={{ marginLeft: 10 }}>
<NmStatusChip color={jobChipColor(project.status)}>{statusLabel(project.status)}</NmStatusChip>
</span>
) : null}
</Modal.Heading>
<Modal.CloseTrigger />
</Modal.Header>
<Modal.Body className="flex flex-col gap-3 bm-detail">
{project ? (
<>
<div className="bm-detail__chrome">
<div className="bm-detail__meta muted">
<span>
{project.old_task?.ne_name || project.old_task_id.slice(0, 8)}
{project.old_task?.ne_ip ? ` (${project.old_task.ne_ip})` : ""}
</span>
<span className="bm-detail__arrow">→</span>
<span>
{project.new_task?.ne_name || project.new_task_id.slice(0, 8)}
{project.new_task?.ne_ip ? ` (${project.new_task.ne_ip})` : ""}
</span>
{openRedCount > 0 ? (
<span className="form-error bm-detail__red">
{t("bizMigration.openRedHint", { n: String(openRedCount) })}
</span>
) : null}
</div>
<div className="btn-row bm-detail__actions">
<Button
size="sm"
variant="secondary"
isDisabled={busy}
onPress={() => void onEnsureHighfreq()}
>
{t("bizMigration.enableHighfreq")}
</Button>
<Button size="sm" variant="secondary" isDisabled={busy} onPress={() => void onCollectNow()}>
{t("bizMigration.collectNow")}
</Button>
<Link to="/network/cutover/biz-state" className="bm-detail__link">
{t("bizMigration.openBizState")}
</Link>
</div>
</div>
<div className="btn-row nm-config-modal__tabs" role="tablist">
<Button
size="sm"
variant={detailTab === "setup" ? "primary" : "secondary"}
className={detailTab === "setup" ? "is-active" : undefined}
onPress={() => setDetailTab("setup")}
>
{t("bizMigration.tabSetup")}
</Button>
<Button
size="sm"
variant={detailTab === "batches" ? "primary" : "secondary"}
className={detailTab === "batches" ? "is-active" : undefined}
onPress={() => setDetailTab("batches")}
>
{t("bizMigration.tabBatches")}
</Button>
<Button
size="sm"
variant={detailTab === "board" ? "primary" : "secondary"}
className={detailTab === "board" ? "is-active" : undefined}
onPress={() => setDetailTab("board")}
>
{t("bizMigration.tabBoard")}
</Button>
</div>
{detailTab === "setup" ? (
<div className="bm-setup flex flex-col gap-3">
<p className="muted bm-hint">{t("bizMigration.setupHintShort")}</p>
<div className="bm-pair-grid">
<div className="bm-pair-card">
<div className="bm-pair-card__label">{t("bizMigration.oldTask")}</div>
<div className="pt-list-task-name">
{project.old_task?.ne_name || project.old_task_id.slice(0, 8)}
</div>
<div className="muted bm-hint">{project.old_task?.ne_ip || "—"}</div>
</div>
<div className="bm-pair-card">
<div className="bm-pair-card__label">{t("bizMigration.newTask")}</div>
<div className="pt-list-task-name">
{project.new_task?.ne_name || project.new_task_id.slice(0, 8)}
</div>
<div className="muted bm-hint">{project.new_task?.ne_ip || "—"}</div>
</div>
</div>
<div className="bm-pair-grid">
<FieldSelect
label={t("bizMigration.monitorTemplate")}
value={monitorTplId}
onChange={(e) => setMonitorTplId(e.target.value)}
fullWidth
>
<option value="">{t("bizMigration.optionalNone")}</option>
{monitorTpls.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</FieldSelect>
<div />
</div>
{selectedMonitorTpl?.compare_template_name || selectedMonitorTpl?.compare_template_id ? (
<p className="muted bm-hint">
{t("bizMigration.compareTemplate")}:{" "}
<Link to="/network/cutover/compare-templates">
{selectedMonitorTpl.compare_template_name ||
selectedMonitorTpl.compare_template_id}
</Link>
</p>
) : null}
<div className="bm-mapping">
<div className="bm-mapping__label">{t("bizMigration.portMapping")}</div>
<p className="muted bm-hint">{t("bizCompare.mappingOptionalHint")}</p>
<div className="bm-mapping__row">
<FieldSelect
value={mappingId}
onChange={(e) => {
const id = e.target.value;
if (id) loadMappingText(id);
else resetMappingEditor();
}}
fullWidth
>
<option value="">{t("bizCompare.newMapping")}</option>
{mappings.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</FieldSelect>
<Input
value={mapName}
placeholder={t("bizCompare.mapName")}
onChange={(e) => setMapName(e.target.value)}
aria-label={t("bizCompare.mapName")}
/>
<Button
size="sm"
variant="secondary"
isDisabled={busy}
onPress={() => void onSaveMappingOnly()}
>
{t("bizCompare.saveMapping")}
</Button>
</div>
<textarea
className="bm-mapping__text"
value={mapText}
onChange={(e) => setMapText(e.target.value)}
placeholder={t("bizCompare.mapHint")}
rows={5}
/>
</div>
<div className="bm-pair-grid">
<FieldSelect
label={t("bizMigration.oldBaseline")}
value={oldBaselineId}
onChange={(e) => setOldBaselineId(e.target.value)}
fullWidth
>
{batchOptions(oldBatches)}
</FieldSelect>
<FieldSelect
label={t("bizMigration.newBaseline")}
value={newBaselineId}
onChange={(e) => setNewBaselineId(e.target.value)}
fullWidth
>
{batchOptions(newBatches)}
</FieldSelect>
</div>
<div className="btn-row">
<Button size="sm" variant="primary" isDisabled={busy} onPress={() => void onSaveBaseline()}>
{t("bizMigration.saveBaseline")}
</Button>
</div>
</div>
) : null}
{detailTab === "batches" ? (
<div className="bm-batches">
<div className="bm-batches__toolbar btn-row">
<Input
value={batchLabel}
onChange={(e) => setBatchLabel(e.target.value)}
size="sm"
placeholder={t("bizMigration.defaultBatchLabel")}
aria-label={t("bizMigration.batchLabel")}
style={{ minWidth: 140 }}
/>
<Button size="sm" variant="primary" isDisabled={busy} onPress={() => void onCreateBatch()}>
{t("bizMigration.createBatch")}
</Button>
<FieldSelect
label={t("bizMigration.batches")}
value={batchId}
onChange={(e) => setBatchId(e.target.value)}
aria-label={t("bizMigration.batches")}
>
{batches.map((b) => (
<option key={b.id} value={b.id}>
{b.batch_label} ({statusLabel(b.status)})
</option>
))}
</FieldSelect>
{batch ? (
<>
<Button
size="sm"
variant="secondary"
isDisabled={busy}
onPress={() => void setBatchStatus("active")}
>
{t("bizMigration.startBatch")}
</Button>
<Button
size="sm"
variant="secondary"
isDisabled={busy}
onPress={() => void setBatchStatus("review")}
>
{t("bizMigration.finishBatch")}
</Button>
<Button
size="sm"
variant="primary"
isDisabled={
busy ||
!project?.old_baseline_batch_id ||
!project?.new_baseline_batch_id
}
onPress={() => void onEvaluate()}
>
{t("bizMigration.evaluate")}
</Button>
</>
) : null}
</div>
{!project.old_baseline_batch_id ? (
<div className="form-error bm-hint">{t("bizMigration.needBaselineFirst")}</div>
) : null}
<div className="ct-editor__main bm-batches__main">
<aside className="ct-editor__nav" aria-label={t("bizMigration.pickExpectPorts")}>
<div className="ct-editor__nav-head">
<span>
{t("bizMigration.pickExpectPorts")} ({selectedExpectKeys.size})
</span>
</div>
<div className="ct-editor__nav-list" role="tablist">
{(expectSheets.length
? expectSheets
: [
{
metric_id: "—",
sheet_id: "—",
title: "—",
items: [] as ExpectSheetItem[],
key_fields: [] as string[],
iface_fields: [] as string[],
},
]
).map((s) => {
const sid = sheetIdentity(s);
const active =
(expectMetricId || sheetIdentity(expectSheets[0])) === sid;
return (
<button
key={sid}
type="button"
role="tab"
aria-selected={active}
className={`ct-editor__nav-item${active ? " is-active" : ""}`}
onClick={() => setExpectMetricId(sid)}
>
<span className="ct-editor__nav-name">{sheetLabel(s)}</span>
<span className="ct-editor__nav-tag">{s.items.length}</span>
</button>
);
})}
</div>
</aside>
<div className="ct-editor__pane">
<div className="filter-inline" style={{ marginBottom: 8 }}>
<Input
size="sm"
value={portFilter}
onChange={(e) => setPortFilter(e.target.value)}
placeholder={t("bizMigration.portFilterPh")}
/>
</div>
<div className="pt-list-table-wrap bm-expect-table">
<table className="data-table pt-list-table">
<thead>
<tr>
<th style={{ width: 36 }} />
<th>{t("bizMigration.colKey")}</th>
<th>{t("bizMigration.colMapped")}</th>
<th>{t("bizMigration.colDesc")}</th>
</tr>
</thead>
<tbody>
{filteredExpectItems.map((it) => {
const row = it.row || {};
const statusBits = ["admin", "phy", "prot"]
.map((k) => (row[k] != null ? String(row[k]) : ""))
.filter(Boolean);
const desc =
it.label ||
String(row.description || row.desc || row.peer || row.neighbor || "") ||
(statusBits.length ? statusBits.join("/") : "");
return (
<tr
key={it.key}
onClick={() => toggleExpectKey(it.key)}
style={{ cursor: "pointer" }}
>
<td>
<input
type="checkbox"
checked={selectedExpectKeys.has(it.key)}
onChange={() => toggleExpectKey(it.key)}
/>
</td>
<td>
<code>{it.key}</code>
</td>
<td>{it.mapped_to || "—"}</td>
<td className="bm-expect-desc">{desc || "—"}</td>
</tr>
);
})}
{!filteredExpectItems.length ? (
<tr>
<td colSpan={4}>
<div className="pt-list-empty">{t("bizMigration.emptyBaselinePorts")}</div>
</td>
</tr>
) : null}
</tbody>
</table>
</div>
</div>
</div>
</div>
) : null}
{detailTab === "board" ? (
<div className="bs-cmp-board bm-board">
<div className="bs-cmp-board__toolbar">
<FieldSelect
label={t("bizMigration.batches")}
value={batchId}
onChange={(e) => setBatchId(e.target.value)}
aria-label={t("bizMigration.batches")}
>
{batches.map((b) => (
<option key={b.id} value={b.id}>
{b.batch_label} ({statusLabel(b.status)})
</option>
))}
</FieldSelect>
<FieldSelect
label={t("bizMigration.pinOldBatch")}
value={pinOldBatchId}
onChange={(e) => setPinOldBatchId(e.target.value)}
aria-label={t("bizMigration.pinOldBatch")}
>
<option value="">{t("bizMigration.pinLatest")}</option>
{batchOptions(oldBatches)}
</FieldSelect>
<FieldSelect
label={t("bizMigration.pinNewBatch")}
value={pinNewBatchId}
onChange={(e) => setPinNewBatchId(e.target.value)}
aria-label={t("bizMigration.pinNewBatch")}
>
<option value="">{t("bizMigration.pinLatest")}</option>
{batchOptions(newBatches)}
</FieldSelect>
<FieldSelect
label={t("bizMigration.runHistory")}
value={selectedRunId}
onChange={(e) => {
const rid = e.target.value;
setSelectedRunId(rid);
if (batchId && rid) void loadBoard(batchId, rid);
}}
aria-label={t("bizMigration.runHistory")}
>
{!evalRuns.length ? (
<option value="">{t("bizMigration.runLatest")}</option>
) : null}
{evalRuns.map((r) => (
<option key={r.id} value={r.id}>
{fmtTime(r.created_at)} · {r.purpose || "manual"}
{r.summary?.anomaly != null ? ` · ⚠${r.summary.anomaly}` : ""}
</option>
))}
</FieldSelect>
<div className="bs-cmp-board__actions btn-row">
<Button
size="sm"
variant="primary"
isDisabled={
busy ||
!batchId ||
!project?.old_baseline_batch_id ||
!project?.new_baseline_batch_id
}
onPress={() => void onEvaluate()}
>
{t("bizMigration.evaluate")}
</Button>
<label className="config-sync-policy-check" title={t("bizMigration.onlyExpectHint")}>
<input
type="checkbox"
checked={onlyExpect}
onChange={(e) => setOnlyExpect(e.target.checked)}
/>
<span>{t("bizMigration.onlyExpect")}</span>
</label>
</div>
{acceptInfo ? (
<div className="bm-board__accept muted">
<span
style={{
color: acceptInfo.passed ? COLOR.green : COLOR.red,
fontWeight: 600,
}}
>
{acceptInfo.passed
? t("bizMigration.acceptPassedShort")
: t("bizMigration.acceptFailedShort")}
</span>
<span>
{t("bizMigration.progress")} {acceptInfo.progress_ok ?? 0}/
{acceptInfo.progress_total ?? 0}
</span>
<span style={{ color: COLOR.red }}>
{t("bizMigration.anomaly")} {acceptInfo.anomaly ?? 0}
</span>
</div>
) : null}
</div>
{sheetCards.some((c) => c.new_baseline_missing) ? (
<div className="form-error bm-hint">
{t("bizMigration.newBaselineMissingBanner", {
metrics: sheetCards
.filter((c) => c.new_baseline_missing)
.map((c) => sheetLabel(c))
.join(", "),
})}
</div>
) : null}
<div className="mt-rule-tabs" role="tablist">
<button
type="button"
role="tab"
aria-selected={boardSubTab === "diffs"}
className={`mt-rule-tab${boardSubTab === "diffs" ? " is-active" : ""}`}
onClick={() => setBoardSubTab("diffs")}
>
{t("bizMigration.board")}
<span className="mt-rule-tab__n">{visibleDiffs.length}</span>
</button>
<button
type="button"
role="tab"
aria-selected={boardSubTab === "red"}
className={`mt-rule-tab mt-rule-tab--anomaly${boardSubTab === "red" ? " is-active" : ""}`}
onClick={() => setBoardSubTab("red")}
>
{t("bizMigration.redTitle")}
<span className="mt-rule-tab__n">{openRedCount}</span>
</button>
</div>
{boardSubTab === "red" ? (
<div className="pt-list-table-wrap bm-board__table">
<table className="data-table pt-list-table">
<thead>
<tr>
<th>{t("bizMigration.colOldPort")}</th>
<th>{t("bizMigration.colNewPort")}</th>
<th>{t("bizMigration.colOldStatus")}</th>
<th>{t("bizMigration.colNewStatus")}</th>
<th>{t("bizMigration.colVerdict")}</th>
<th>{t("bizMigration.colStatus")}</th>
<th />
</tr>
</thead>
<tbody>
{redTickets.map((r) => (
<tr key={r.id}>
<td>
<code>{r.key_str}</code>
</td>
<td>{r.new_key_str || "—"}</td>
<td>{r.old_status || "—"}</td>
<td>{r.new_status || "—"}</td>
<td style={{ color: COLOR.red, fontWeight: 600 }}>{verdictLabel(r.verdict)}</td>
<td>{r.status}</td>
<td>
{r.status !== "resolved" ? (
<Button
size="sm"
variant="secondary"
onPress={() => void onResolveTicket(r.id)}
>
{t("bizMigration.resolveRed")}
</Button>
) : null}
</td>
</tr>
))}
{!redTickets.length ? (
<tr>
<td colSpan={7}>
<div className="pt-list-empty">{t("bizMigration.emptyRed")}</div>
</td>
</tr>
) : null}
</tbody>
</table>
</div>
) : (
<div className="bs-cmp-board__body">
<aside className="bs-cmp-nav" aria-label={t("bizMigration.colMetric")}>
<div className="bs-cmp-nav__head">
<div className="bs-cmp-nav__head-main">
<span className="bs-cmp-nav__title">{t("bizMigration.colMetric")}</span>
<span className="bs-cmp-nav__count">{sheetCards.length || 1}</span>
</div>
</div>
<div className="bs-cmp-nav__list" role="tablist">
<button
type="button"
role="tab"
aria-selected={!boardMetricId}
className={`bs-cmp-nav__item${!boardMetricId ? " is-active" : ""}`}
onClick={() => setBoardMetricId("")}
>
<span className="bs-cmp-nav__dot" />
<span className="bs-cmp-nav__name">{t("bizMigration.allMetrics")}</span>
</button>
{sheetCards.map((c) => {
const sid = sheetIdentity(c);
const active = boardMetricId === sid;
const hot = (c.anomaly || 0) > 0 || Boolean(c.new_baseline_missing);
return (
<button
key={sid}
type="button"
role="tab"
aria-selected={active}
className={`bs-cmp-nav__item${active ? " is-active" : ""}${
hot ? " has-diff" : " is-clean"
}`}
onClick={() => setBoardMetricId(sid)}
>
<span className="bs-cmp-nav__dot" />
<span className="bs-cmp-nav__name">
{sheetLabel(c)}
{c.new_baseline_missing ? " · baseline" : ""}
</span>
<span className="bs-cmp-nav__stats">
<span className="bs-cmp-nav__num bs-cmp-nav__num--ok">
{c.progress_ok}/{c.progress_total}
</span>
<span
className={`bs-cmp-nav__num bs-cmp-nav__num--fail${hot ? " is-hot" : ""}`}
>
{c.anomaly}
</span>
</span>
</button>
);
})}
</div>
</aside>
<div className="bs-cmp-main">
{board?.run ? (
<div className="pt-list-table-wrap bm-board__table">
<table className="data-table pt-list-table">
<thead>
<tr>
<th>{t("bizMigration.colMetric")}</th>
<th>{t("bizMigration.colKey")}</th>
<th>{t("bizMigration.colMapped")}</th>
<th>{t("bizMigration.colOld")}</th>
<th>{t("bizMigration.colNew")}</th>
<th>{t("bizMigration.colVerdict")}</th>
<th>{t("bizMigration.colRuleHit")}</th>
</tr>
</thead>
<tbody>
{visibleDiffs.map((d) => (
<tr key={d.id}>
<td>{sheetLabel(d)}</td>
<td>
<code>{d.key_str || "—"}</code>
</td>
<td>{d.new_key_str || "—"}</td>
<td>{d.old_status || d.old_kind || "—"}</td>
<td>{d.new_status || d.new_kind || "—"}</td>
<td
style={{
color: COLOR[d.color] || COLOR.gray,
fontWeight: 600,
}}
>
{verdictLabel(d.verdict)}
</td>
<td className="muted">
<code style={{ fontSize: 11 }}>{d.rule_hit || "—"}</code>
</td>
</tr>
))}
{!visibleDiffs.length ? (
<tr>
<td colSpan={7}>
<div className="pt-list-empty">{t("bizMigration.emptyDiffs")}</div>
</td>
</tr>
) : null}
</tbody>
</table>
</div>
) : (
<div className="pt-list-empty">{t("bizMigration.emptyDiffs")}</div>
)}
</div>
</div>
)}
</div>
) : null}
</>
) : null}
</Modal.Body>
<Modal.Footer>
<Button size="sm" variant="secondary" onPress={closeProject}>
{t("bizMigration.cancel")}
</Button>
</Modal.Footer>
</AppModalShell>
</section>
);
}