import { Button, Input, Modal } from "@heroui/react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Link } from "react-router-dom"; 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 { bizCompareListMappings, bizCompareCreateMapping, bizCompareUpdateMapping, bizMigrationCollectNow, bizMigrationCreateBatch, bizMigrationCreateProject, bizMigrationDeleteProject, bizMigrationEnsureHighfreq, bizMigrationEvaluate, bizMigrationFinishBatch, bizMigrationGetBoard, bizMigrationListBaselineExpect, bizMigrationListBatches, bizMigrationListDiffs, bizMigrationListProjects, bizMigrationListRedTickets, bizMigrationListRuns, bizMigrationNePortrait, bizMigrationPatchBatch, bizMigrationPatchProject, bizMigrationResolveRedTicket, bizMonitorListTemplates, bizStateListBatches, fetchCliTargets, ApiRequestError, formatErr, } from "../../services/api"; // bizStateListTasks not needed for create — NEs + auto HF import type { CliTargetItem } from "../../types"; import { writeClipboardText } from "../../utils/clipboard"; import { pageCount } from "../../utils/display"; import { formatSystemTime, localDatetimeInputToUtcIso, utcIsoToLocalDatetimeInput } from "../../utils/time"; import { cutoverCachedGet, cutoverCachedGetSWR, invalidateCutoverCache } from "./cutoverDataCache"; import { jobChipColor, NmStatusChip, sourceChipColor } from "./nmChips"; type NeSourceFilter = "all" | "managed" | "ume"; type CreatePickSide = "old" | "new"; type CreateStep = 0 | 1 | 2 | 3; const CREATE_NE_PAGE_SIZE_DEFAULT = 20; const CREATE_NE_PAGE_SIZE_OPTIONS = [10, 20, 50, 100]; const CREATE_STEPS = 4; const MANUAL_ROUTE_METRICS = new Set(["ip_route", "ipv6_route", "bgp_route"]); const isManualMetric = (mid: string) => MANUAL_ROUTE_METRICS.has(mid); function neSourceOf(row: CliTargetItem): "managed" | "ume" { return row.source === "ume" ? "ume" : "managed"; } function nePayload(ne: CliTargetItem) { return { source: neSourceOf(ne), ne_id: ne.id, ne_name: ne.name, ne_ip: ne.ip_address, vendor: ne.vendor || "", device_type: ne.device_type || "", }; } function monitorTplMetrics(tpl: MonitorTplOpt | null | undefined): string[] { if (!tpl) return ["interface_brief"]; const raw = tpl.collect_metric_ids?.length ? tpl.collect_metric_ids : tpl.collect_metric_ids_effective; const mids = (raw || []).map((x) => String(x || "").trim()).filter(Boolean); return mids.length ? mids : ["interface_brief"]; } // Freeze the operator selection; later template additions stay opt-in. function collectOverridePayload(selected: string[], _available: string[]): string[] { return [...new Set(selected.filter(Boolean))]; } function availableTplMetrics(tpl: MonitorTplOpt | null | undefined): string[] { return tpl?.available_metric_ids?.length ? tpl.available_metric_ids : monitorTplMetrics(tpl); } function metricIntervalPayload( selected: string[], intervals: Record, ): Record { const out: Record = {}; for (const mid of selected) { const raw = intervals[mid]; if (!isManualMetric(mid) && raw && Number(raw) >= 60) out[mid] = Math.max(60, Number(raw)); } return out; } type HfCollectSide = Array<{ ok?: boolean; error?: string; task_id?: string }>; function summarizeHfCollect(out: { hf_status?: string; old?: HfCollectSide; new?: HfCollectSide; collect?: { old?: HfCollectSide; new?: HfCollectSide }; }): { kind: "ok" | "inactive" | "missing" | "partial" | "fail"; failN: number; okN: number; err: string } { if (out.hf_status && out.hf_status !== "running") { return { kind: "inactive", failN: 0, okN: 0, err: "hf_window_inactive" }; } const sides = [ ...(out.old || out.collect?.old || []), ...(out.new || out.collect?.new || []), ]; if (!sides.length) return { kind: "fail", failN: 0, okN: 0, err: "collect_failed" }; const okN = sides.filter((x) => x.ok).length; const fails = sides.filter((x) => !x.ok); const err = String(fails[0]?.error || ""); if (!fails.length) return { kind: "ok", failN: 0, okN, err: "" }; if (err === "hf_window_inactive") return { kind: "inactive", failN: fails.length, okN, err }; if (err === "hf_task_missing" && !okN) return { kind: "missing", failN: fails.length, okN, err }; if (okN > 0) return { kind: "partial", failN: fails.length, okN, err }; return { kind: "fail", failN: fails.length, okN, err: err || "collect_failed" }; } function mapMigrationApiErr( e: unknown, t: (key: string, params?: Record) => string, ): string { const detail = e instanceof ApiRequestError ? String(e.detail || "") : ""; if (detail.startsWith("metric_needs_bindings:")) { const [, metric = "", ...rest] = detail.split(":"); return t("bizMigration.errMetricNeedsBindings", { metric, params: rest.join(":") || "required", }); } if (detail.startsWith("no_profile_for_metric:")) { const [, metric = ""] = detail.split(":"); return t("bizMigration.errNoProfileForMetric", { metric }); } if (detail === "old_baseline_required") return t("bizMigration.errOldBaselineRequired"); if (detail === "new_baseline_required") return t("bizMigration.errNewBaselineRequired"); if (detail === "collect_pair_same_device") return t("bizMigration.errPairSameDevice"); return formatErr(e); } function collectErrorMessage(code: string, t: (key: string) => string): string { const keys: Record = { collect_pair_capacity: "errPairCapacity", collect_pair_busy: "errPairBusy", collect_pair_invalid: "errPairInvalid", hf_task_missing: "hfNotBound", }; return keys[code] ? t(`bizMigration.${keys[code]}`) : code || "collect_failed"; } function formatHfBindLine( binds?: Array<{ task_id: string; metric_ids?: string[]; interval_sec?: number; manual_only?: boolean; collect_group_id?: string }>, ): string { if (!binds?.length) return ""; return binds .map((b) => `${b.manual_only ? "manual" : `${b.interval_sec || 60}s`}·${(b.metric_ids || []).join("+") || "—"}`) .join(" · "); } type TaskOpt = { id: string; ne_name: string; ne_ip: string; note?: string; interval_sec?: number; purpose?: string; status?: string; }; 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[]; collect_metric_ids_effective?: string[]; available_metric_ids?: string[]; }; type Project = { id: string; name: string; old_task_id: string; new_task_id: string; old_hf_task_id?: string; new_hf_task_id?: string; old_hf_bindings?: Array<{ task_id: string; metric_ids?: string[]; interval_sec?: number; manual_only?: boolean; collect_group_id?: string }>; new_hf_bindings?: Array<{ task_id: string; metric_ids?: string[]; interval_sec?: number; manual_only?: boolean; collect_group_id?: string }>; old_baseline_batch_id: string; new_baseline_batch_id: string; mapping_id: string; monitor_template_id?: string; monitor_template?: MonitorTplOpt; collect_metric_ids?: string[]; collect_metric_ids_effective?: string[]; metric_interval_sec?: Record; hf_interval_sec?: number; hf_start_at?: string | null; hf_end_at?: string | null; status: string; note?: string; created_at?: string | null; old_task?: TaskOpt; new_task?: TaskOpt; old_hf_task?: TaskOpt; new_hf_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; }; }; type DiffRow = { id: string; metric_id: string; sheet_id?: string; verdict: string; color: string; old_kind: string; new_kind: string; in_expect: boolean; old_key?: string; new_key?: string; key_str?: string; new_key_str?: string; match_old_key?: string; match_new_key?: string; old_status?: string; new_status?: string; rule_hit?: string; integrity_changes?: Array<{ field: string; before: unknown; after: unknown }>; previous_batch?: boolean; evidence?: { old?: { device?: { ne_name?: string; ne_ip?: string }; command?: { raw_command?: string; parse_status?: string }; collect?: { collected_at?: string; batch_id?: string }; iface?: Array<{ raw?: string; normalized?: string; mapped?: boolean; map_to?: string }>; }; new?: { device?: { ne_name?: string; ne_ip?: string }; command?: { raw_command?: string; parse_status?: string }; collect?: { collected_at?: string; batch_id?: string }; iface?: Array<{ raw?: string; normalized?: string }>; }; port_map?: { applied?: boolean; match_before?: string; match_after?: string; display_before?: string; display_after?: string; }; }; old?: Record; new?: Record; }; type RedTicket = { id: string; batch_id: string; old_key?: string; new_key?: string; key_str: string; new_key_str?: string; verdict: string; old_status?: string; new_status?: string; status: string; evidence?: DiffRow["evidence"]; }; type SamplingPair = { metric_id: string; manual_only?: boolean; collect_round_id?: string; old_collect_status?: string; new_collect_status?: string; old_duration_sec?: number; new_duration_sec?: number; old_queue_sec?: number; new_queue_sec?: number; start_skew_sec?: number; }; 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; collect_skipped?: boolean; current_missing?: boolean; awaiting_peer?: number; sample_issues?: string[]; old_collected_at?: string; new_collected_at?: string; duplicate_keys_before?: number; duplicate_keys_after?: number; age_limit_sec?: number; baseline_missing_sides?: 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; }; 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 = { green: "#16a34a", yellow: "#ca8a04", red: "#dc2626", gray: "#6b7280", }; const VERDICT_I18N: Record = { migrated: "bizMigration.verdictMigrated", migrating: "bizMigration.verdictMigrating", lost: "bizMigration.verdictLost", anomaly: "bizMigration.verdictAnomaly", not_involved: "bizMigration.verdictNotInvolved", unexpected_new: "bizMigration.verdictUnexpected", unfinished: "bizMigration.verdictUnfinished", awaiting_peer: "bizMigration.awaitingPeer", migrated_previous: "bizMigration.migratedPrevious", unfinished_previous: "bizMigration.unfinishedPrevious", ok: "bizMigration.verdictOk", }; function fmtTime(v?: string | null) { if (!v) return "—"; return formatSystemTime(v) || v; } export function BizMigrationPage() { const { t } = useI18n(); const { showOk, showError } = useToast(); const [projects, setProjects] = useState([]); const [mappings, setMappings] = useState([]); const [busy, setBusy] = useState(false); const [listKeyword, setListKeyword] = useState(""); const debouncedListKw = useDebouncedValue(listKeyword, 250); const [createOpen, setCreateOpen] = useState(false); const [createStep, setCreateStep] = useState(0); const [createName, setCreateName] = useState(""); const [createOldNe, setCreateOldNe] = useState(null); const [createNewNe, setCreateNewNe] = useState(null); const [createPickSide, setCreatePickSide] = useState("old"); const [createNePickerOpen, setCreateNePickerOpen] = useState(true); const [createNeKeyword, setCreateNeKeyword] = useState(""); const debouncedCreateNeKw = useDebouncedValue(createNeKeyword, 300); const [createNeSource, setCreateNeSource] = useState("all"); const [createNePage, setCreateNePage] = useState(1); const [createNePageSize, setCreateNePageSize] = useState(CREATE_NE_PAGE_SIZE_DEFAULT); const [createNeTotal, setCreateNeTotal] = useState(0); const [createNeItems, setCreateNeItems] = useState([]); const [createNeLoading, setCreateNeLoading] = useState(false); const [createMonitorTplId, setCreateMonitorTplId] = useState(""); const [createCollectMetricIds, setCreateCollectMetricIds] = useState([]); const [createMetricIntervalSec, setCreateMetricIntervalSec] = useState>({}); const [createHfIntervalSec, setCreateHfIntervalSec] = useState(60); const [createHfStartAt, setCreateHfStartAt] = useState(""); const [createHfEndAt, setCreateHfEndAt] = useState(""); const [createOldBaselineId, setCreateOldBaselineId] = useState(""); const [createNewBaselineId, setCreateNewBaselineId] = useState(""); const [createOldBatches, setCreateOldBatches] = useState< { id: string; started_at?: string | null; alias?: string }[] >([]); const [createNewBatches, setCreateNewBatches] = useState< { id: string; started_at?: string | null; alias?: string }[] >([]); const [createOldHasPortrait, setCreateOldHasPortrait] = useState(false); const [createNewHasPortrait, setCreateNewHasPortrait] = useState(false); const [monitorTpls, setMonitorTpls] = useState([]); const [boardSubTab, setBoardSubTab] = useState<"diffs" | "red">("diffs"); const [projectId, setProjectId] = useState(""); const [detailTab, setDetailTab] = useState("setup"); const [mappingId, setMappingId] = useState(""); const [mapName, setMapName] = useState(""); const [mapText, setMapText] = useState(""); const [monitorTplId, setMonitorTplId] = useState(""); const [collectMetricIds, setCollectMetricIds] = useState([]); const [metricIntervalSec, setMetricIntervalSec] = useState>({}); const [hfIntervalSec, setHfIntervalSec] = useState(60); const [hfStartAt, setHfStartAt] = useState(""); const [hfEndAt, setHfEndAt] = useState(""); const [oldBaselineId, setOldBaselineId] = useState(""); const [newBaselineId, setNewBaselineId] = useState(""); const [oldBatches, setOldBatches] = useState<{ id: string; started_at?: string | null; alias?: string }[]>([]); const [newBatches, setNewBatches] = useState<{ id: string; started_at?: string | null; alias?: string }[]>([]); const [hfOldBatches, setHfOldBatches] = useState<{ id: string; started_at?: string | null; alias?: string }[]>([]); const [hfNewBatches, setHfNewBatches] = useState<{ id: string; started_at?: string | null; alias?: string }[]>([]); const [batches, setBatches] = useState([]); const [batchId, setBatchId] = useState(""); const [board, setBoard] = useState<{ sampling?: SamplingPair[]; run?: { id: string; created_at?: string; purpose?: string; summary?: { sheet_cards?: SheetCard[]; progress?: { ok: number; total: number }; anomaly?: number; expect_ports?: string[]; coverage_complete?: boolean; anomaly_outside_expect?: number; steady_outside?: number; verdict_counts?: Record; duplicate_keys?: number; uncovered_scope?: string[]; }; } | null; } | null>(null); const [diffs, setDiffs] = useState([]); const [onlyExpect, setOnlyExpect] = useState(false); const [boardMetricId, setBoardMetricId] = useState(""); const [batchLabel, setBatchLabel] = useState(""); const [expectSheets, setExpectSheets] = useState([]); const [expectMetricId, setExpectMetricId] = useState(""); const [selectedExpectKeys, setSelectedExpectKeys] = useState>(new Set()); const [portFilter, setPortFilter] = useState(""); const [expectPage, setExpectPage] = useState(1); const [redTickets, setRedTickets] = useState([]); const [openRedCount, setOpenRedCount] = useState(0); const [acceptInfo, setAcceptInfo] = useState(null); const [pinOldBatchId, setPinOldBatchId] = useState(""); const [pinNewBatchId, setPinNewBatchId] = useState(""); const [evalRuns, setEvalRuns] = useState([]); const [selectedRunId, setSelectedRunId] = useState(""); const [followLatest, setFollowLatest] = useState(true); const [diffPage, setDiffPage] = useState(1); const [diffTotal, setDiffTotal] = useState(0); const [boardColor, setBoardColor] = useState(""); const [boardKeyword, setBoardKeyword] = useState(""); const boardSearch = useDebouncedValue(boardKeyword, 300); const [boardLoading, setBoardLoading] = useState(false); const [boardError, setBoardError] = useState(""); const [boardClock, setBoardClock] = useState(Date.now()); const [boardFullscreen, setBoardFullscreen] = useState(false); const boardRef = useRef(null); const fullscreenPending = useRef(false); const boardRequest = useRef(0); const runsRequest = useRef(0); const activeBatchRef = useRef(batchId); activeBatchRef.current = batchId; const activeProjectRef = useRef(projectId); activeProjectRef.current = projectId; const historyRunId = followLatest ? "" : selectedRunId; 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 = diffs; const liveSummary = board?.run?.summary; const liveProgress = liveSummary?.progress; const monitorIsLive = followLatest && batch?.status === "active" && project?.status !== "done"; const samplesStale = monitorIsLive && sheetCards.some((c) => [c.old_collected_at, c.new_collected_at].some((at) => at && boardClock - Date.parse(at) > (c.age_limit_sec || 180) * 1000)); const pairedPending = followLatest && board?.sampling?.some((pair) => [pair.old_collect_status, pair.new_collect_status].some((s) => !["success", "partial"].includes(s || "idle"))); const coverageUnknown = Boolean(board?.run && (liveSummary?.coverage_complete !== true || samplesStale || pairedPending)); const progressPercent = liveProgress?.total ? Math.max(0, Math.min(100, Math.round(liveProgress.ok / liveProgress.total * 100))) : 0; const wallState = boardError ? "unknown" : !board?.run ? "waiting" : coverageUnknown ? "unknown" : (liveSummary?.anomaly || 0) > 0 ? "anomaly" : "normal"; const exitBoardFullscreen = useCallback(async () => { if (document.fullscreenElement === boardRef.current) { try { await document.exitFullscreen(); } catch { return; } } setBoardFullscreen(false); }, []); async function toggleBoardFullscreen() { if (fullscreenPending.current) return; const element = boardRef.current; if (!element) return; fullscreenPending.current = true; try { if (boardFullscreen) { await exitBoardFullscreen(); return; } try { await element.requestFullscreen(); } catch { /* Use the full-window layout below. */ } if (boardRef.current === element) setBoardFullscreen(true); } finally { fullscreenPending.current = false; } } useEffect(() => { const sync = () => setBoardFullscreen(Boolean(boardRef.current && document.fullscreenElement === boardRef.current)); document.addEventListener("fullscreenchange", sync); return () => document.removeEventListener("fullscreenchange", sync); }, []); useEffect(() => { const element = boardRef.current; return () => { if (element && document.fullscreenElement === element) void document.exitFullscreen().catch(() => {}); }; }, [projectId, detailTab]); useEffect(() => { if (!boardFullscreen) return; const onEscape = (event: KeyboardEvent) => { if (event.key !== "Escape") return; event.preventDefault(); event.stopImmediatePropagation(); void exitBoardFullscreen(); }; window.addEventListener("keydown", onEscape, true); return () => window.removeEventListener("keydown", onEscape, true); }, [boardFullscreen, exitBoardFullscreen]); useEffect(() => { if (!projectId || detailTab !== "board") return; // Freshness must keep advancing even when API refreshes fail. const timer = setInterval(() => setBoardClock(Date.now()), 1000); return () => clearInterval(timer); }, [projectId, detailTab]); 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_hf_task?.ne_name || "", p.old_hf_task?.ne_ip || "", p.new_hf_task?.ne_name || "", p.new_hf_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 expectPages = Math.max(1, Math.ceil(filteredExpectItems.length / 100)); const safeExpectPage = Math.min(expectPage, expectPages); const visibleExpectItems = filteredExpectItems.slice((safeExpectPage - 1) * 100, safeExpectPage * 100); const reloadProjects = useCallback(async () => { const res = await cutoverCachedGet("bizMigration:projects", () => bizMigrationListProjects(), { force: true, }); setProjects((res.items || []) as Project[]); invalidateCutoverCache("bizMigration:bootstrap"); }, []); const reloadMappings = useCallback(async () => { const mp = await cutoverCachedGet("bizMigration:mappings", () => bizCompareListMappings(), { force: true, }); setMappings( ((mp.items || []) as Record[]).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 || ""), })) : [], })), ); invalidateCutoverCache("bizMigration:bootstrap"); }, []); 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 => { 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); if (pid !== activeProjectRef.current) return; 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 (e) { if (pid === activeProjectRef.current) { setExpectSheets([]); showError(formatErr(e)); } } }, [showError]); const loadRedTickets = useCallback(async (pid: string) => { if (!pid) { setRedTickets([]); setOpenRedCount(0); return; } try { const res = await bizMigrationListRedTickets(pid); if (pid !== activeProjectRef.current) return; setRedTickets((res.items || []) as RedTicket[]); setOpenRedCount(Number(res.open_count || 0)); } catch (e) { if (pid === activeProjectRef.current) showError(formatErr(e)); } }, [showError]); useEffect(() => { void (async () => { try { type Bundle = { pr: Awaited>; mp: Awaited>; mt: Awaited>; }; const fetchBundle = async (): Promise => { const [pr, mp, mt] = await Promise.all([ bizMigrationListProjects(), bizCompareListMappings(), bizMonitorListTemplates(), ]); return { pr, mp, mt }; }; const apply = (b: Bundle) => { setProjects((b.pr.items || []) as Project[]); setMappings( ((b.mp.items || []) as Record[]).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 = ((b.mt.items || []) as Record[]).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[]) : [], collect_metric_ids_effective: Array.isArray(x.collect_metric_ids_effective) ? (x.collect_metric_ids_effective as string[]) : [], available_metric_ids: Array.isArray(x.available_metric_ids) ? (x.available_metric_ids as string[]) : [], })); setMonitorTpls(mts); if (!createMonitorTplId && mts.length) { const preferred = mts.find((x) => x.name === "默认割接监控") || mts.find((x) => /默认|default|状态|status/i.test(x.name)) || mts[0]; setCreateMonitorTplId(preferred.id); setCreateCollectMetricIds(monitorTplMetrics(preferred).filter((mid) => !isManualMetric(mid))); } }; apply(await cutoverCachedGetSWR("bizMigration:bootstrap", fetchBundle, apply)); } catch (e) { showError(formatErr(e)); } })(); }, [showError]); useEffect(() => { let cancelled = false; setBatchId(""); setBatches([]); setAcceptInfo(null); setRedTickets([]); setOpenRedCount(0); 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); if (cancelled || projectId !== activeProjectRef.current) return; const items = (res.items || []) as MigBatch[]; setBatches(items); setBatchId(items.find((b) => b.status === "active")?.id || items[0]?.id || ""); await loadBaselineExpect(projectId); await loadRedTickets(projectId); } catch (e) { if (!cancelled) showError(formatErr(e)); } })(); return () => { cancelled = true; }; }, [projectId, showError, loadBaselineExpect, loadRedTickets]); useEffect(() => { setBoard(null); setDiffs([]); setDiffTotal(0); setDiffPage(1); setBoardMetricId(""); setFollowLatest(true); setBoardError(""); boardRequest.current += 1; runsRequest.current += 1; if (!batchId) { setBoard(null); setDiffs([]); setEvalRuns([]); setSelectedRunId(""); return; } void loadRuns(batchId); // eslint-disable-next-line react-hooks/exhaustive-deps }, [batchId]); useEffect(() => { if (!batchId || detailTab !== "board") return; let cancelled = false; let timer: ReturnType | undefined; const refresh = async () => { if (document.visibilityState === "visible") { await loadBoard(batchId, historyRunId); if (!cancelled && followLatest) await loadRuns(batchId); } if (!cancelled && monitorIsLive) { timer = setTimeout(() => void refresh(), 10000); } }; void refresh(); return () => { cancelled = true; if (timer) clearTimeout(timer); boardRequest.current += 1; runsRequest.current += 1; }; // Requests are serial within each refresh cycle; filters start a new cycle. // eslint-disable-next-line react-hooks/exhaustive-deps }, [batchId, detailTab, monitorIsLive, historyRunId, followLatest, boardMetricId, onlyExpect, boardColor, boardSearch, diffPage]); useEffect(() => { if (!createOpen) return; let cancelled = false; setCreateNeLoading(true); void (async () => { try { const res = await fetchCliTargets({ source: createNeSource, keyword: debouncedCreateNeKw, page: createNePage, pageSize: createNePageSize, }); if (cancelled) return; setCreateNeItems(res.items || []); setCreateNeTotal(Number(res.total || 0)); } catch (e) { if (!cancelled) showError(formatErr(e)); } finally { if (!cancelled) setCreateNeLoading(false); } })(); return () => { cancelled = true; }; }, [createOpen, createNeSource, debouncedCreateNeKw, createNePage, createNePageSize, showError]); useEffect(() => { let cancelled = false; if (!createOldNe) { setCreateOldBatches([]); setCreateOldHasPortrait(false); setCreateOldBaselineId(""); return; } setCreateOldBaselineId(""); setCreateOldBatches([]); void bizMigrationNePortrait({ source: neSourceOf(createOldNe), ne_id: createOldNe.id, limit: 30, }).then((r) => { if (cancelled) return; setCreateOldHasPortrait(Boolean(r.task?.id)); setCreateOldBatches( (r.batches || []).filter((x) => ["success", "partial"].includes(String(x.status || ""))).map((x) => ({ id: String(x.id || ""), started_at: x.started_at || null, alias: String(x.alias || ""), })), ); }).catch((e) => { if (!cancelled) showError(formatErr(e)); }); return () => { cancelled = true; }; }, [createOldNe]); useEffect(() => { let cancelled = false; if (!createNewNe) { setCreateNewBatches([]); setCreateNewHasPortrait(false); setCreateNewBaselineId(""); return; } setCreateNewBaselineId(""); setCreateNewBatches([]); void bizMigrationNePortrait({ source: neSourceOf(createNewNe), ne_id: createNewNe.id, limit: 30, }).then((r) => { if (cancelled) return; setCreateNewHasPortrait(Boolean(r.task?.id)); setCreateNewBatches( (r.batches || []).filter((x) => ["success", "partial"].includes(String(x.status || ""))).map((x) => ({ id: String(x.id || ""), started_at: x.started_at || null, alias: String(x.alias || ""), })), ); }).catch((e) => { if (!cancelled) showError(formatErr(e)); }); return () => { cancelled = true; }; }, [createNewNe]); useEffect(() => { let cancelled = false; if (!project?.old_task_id) { setOldBatches([]); return; } void bizStateListBatches(project.old_task_id, 30).then((r) => { if (cancelled) return; setOldBatches( ((r.items || []) as Record[]).filter((x) => ["success", "partial"].includes(String(x.status || ""))).map((x) => ({ id: String(x.id || ""), started_at: (x.started_at as string) || null, alias: String(x.alias || ""), })), ); }).catch((e) => { if (!cancelled) showError(formatErr(e)); }); return () => { cancelled = true; }; }, [project?.old_task_id]); useEffect(() => { let cancelled = false; if (!project?.new_task_id) { setNewBatches([]); return; } void bizStateListBatches(project.new_task_id, 30).then((r) => { if (cancelled) return; setNewBatches( ((r.items || []) as Record[]).filter((x) => ["success", "partial"].includes(String(x.status || ""))).map((x) => ({ id: String(x.id || ""), started_at: (x.started_at as string) || null, alias: String(x.alias || ""), })), ); }).catch((e) => { if (!cancelled) showError(formatErr(e)); }); return () => { cancelled = true; }; }, [project?.new_task_id]); useEffect(() => { let cancelled = false; const ids = [ ...(project?.old_hf_bindings || []).map((b) => b.task_id), project?.old_hf_task_id || "", ].filter(Boolean); const uniq = [...new Set(ids)]; if (!uniq.length) { setHfOldBatches([]); return; } void Promise.all(uniq.map((tid) => bizStateListBatches(tid, 30))).then((results) => { if (cancelled) return; const seen = new Set(); const items: { id: string; started_at?: string | null; alias?: string }[] = []; for (const r of results) { for (const x of (r.items || []) as Record[]) { const id = String(x.id || ""); if (!id || seen.has(id)) continue; seen.add(id); items.push({ id, started_at: (x.started_at as string) || null, alias: String(x.alias || ""), }); } } setHfOldBatches(items); }).catch((e) => { if (!cancelled) showError(formatErr(e)); }); return () => { cancelled = true; }; }, [project?.old_hf_task_id, project?.old_hf_bindings]); useEffect(() => { let cancelled = false; const ids = [ ...(project?.new_hf_bindings || []).map((b) => b.task_id), project?.new_hf_task_id || "", ].filter(Boolean); const uniq = [...new Set(ids)]; if (!uniq.length) { setHfNewBatches([]); return; } void Promise.all(uniq.map((tid) => bizStateListBatches(tid, 30))).then((results) => { if (cancelled) return; const seen = new Set(); const items: { id: string; started_at?: string | null; alias?: string }[] = []; for (const r of results) { for (const x of (r.items || []) as Record[]) { const id = String(x.id || ""); if (!id || seen.has(id)) continue; seen.add(id); items.push({ id, started_at: (x.started_at as string) || null, alias: String(x.alias || ""), }); } } setHfNewBatches(items); }).catch((e) => { if (!cancelled) showError(formatErr(e)); }); return () => { cancelled = true; }; }, [project?.new_hf_task_id, project?.new_hf_bindings]); 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 || ""); const eff = project.collect_metric_ids?.length ? project.collect_metric_ids : project.collect_metric_ids_effective || project.monitor_template?.collect_metric_ids || []; setCollectMetricIds([...eff]); const ivMap = project.metric_interval_sec || {}; const nextIv: Record = {}; for (const mid of eff) { const n = Number(ivMap[mid] || 0); if (n >= 60) nextIv[mid] = String(n); } setMetricIntervalSec(nextIv); setHfIntervalSec(Math.max(60, Number(project.hf_interval_sec || 60))); setHfStartAt(utcIsoToLocalDatetimeInput(project.hf_start_at)); setHfEndAt(utcIsoToLocalDatetimeInput(project.hf_end_at)); if (project.mapping_id) loadMappingText(project.mapping_id); else resetMappingEditor(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [ project?.id, project?.mapping_id, project?.collect_metric_ids, project?.collect_metric_ids_effective, project?.metric_interval_sec, project?.hf_interval_sec, mappings.length, ]); async function loadRuns(bid: string) { const request = ++runsRequest.current; try { const res = await bizMigrationListRuns(bid, 20); if (request !== runsRequest.current || bid !== activeBatchRef.current) return; 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 { if (request === runsRequest.current && bid === activeBatchRef.current) setEvalRuns([]); } } async function loadBoard(bid: string, runId = "") { const request = ++boardRequest.current; setBoardLoading(true); try { const b = await bizMigrationGetBoard(bid, runId); if (request !== boardRequest.current || bid !== activeBatchRef.current) return; const rid = String((b as { run?: { id?: string } })?.run?.id || runId || ""); let rows: DiffRow[] = []; let total = 0; if (rid) { const d = await bizMigrationListDiffs({ runId: rid, sheetId: boardMetricId, onlyExpect, color: boardColor, kw: boardSearch, offset: (diffPage - 1) * 100, limit: 100 }); rows = (d.items || []) as DiffRow[]; total = Number(d.total || 0); } if (request !== boardRequest.current || bid !== activeBatchRef.current) return; setBoard(b as typeof board); setBoardClock(Date.now()); if (rid) setSelectedRunId(rid); setDiffs(rows); setDiffTotal(total); setBoardError(""); if (diffPage > Math.max(1, Math.ceil(total / 100))) setDiffPage(Math.max(1, Math.ceil(total / 100))); } catch (e) { if (request === boardRequest.current && bid === activeBatchRef.current) { setBoardError(formatErr(e)); } } finally { if (request === boardRequest.current) setBoardLoading(false); } } function resetCreateForm() { setCreateStep(0); setCreateName(""); setCreateOldNe(null); setCreateNewNe(null); setCreatePickSide("old"); setCreateNePickerOpen(true); setCreateNeKeyword(""); setCreateNeSource("all"); setCreateNePage(1); setCreateNePageSize(CREATE_NE_PAGE_SIZE_DEFAULT); setCreateCollectMetricIds([]); setCreateMetricIntervalSec({}); setCreateHfIntervalSec(60); setCreateHfStartAt(""); setCreateHfEndAt(""); setCreateOldBaselineId(""); setCreateNewBaselineId(""); setCreateOldBatches([]); setCreateNewBatches([]); setCreateOldHasPortrait(false); setCreateNewHasPortrait(false); resetMappingEditor(); const preferred = monitorTpls.find((x) => x.name === "默认割接监控") || monitorTpls.find((x) => /默认|default|状态|status/i.test(x.name)) || monitorTpls[0]; const tplId = preferred?.id || ""; setCreateMonitorTplId(tplId); setCreateCollectMetricIds(monitorTplMetrics(preferred).filter((mid) => !isManualMetric(mid))); } const openCreate = () => { closeProject(); resetCreateForm(); setCreateOpen(true); }; const closeCreate = () => { setCreateOpen(false); resetCreateForm(); }; function canAdvanceCreateStep(step: CreateStep): boolean { if (step === 0) return Boolean(createName.trim() && createOldNe && createNewNe); if (step === 1) return Boolean(createMonitorTplId && createCollectMetricIds.length); if (step === 2) return createHfIntervalSec >= 60; return true; } function createStepBlockReason(step: CreateStep): string | null { if (step === 0) { if (!createName.trim()) return t("bizMigration.needProjectName"); if (!createOldNe || !createNewNe) return t("bizMigration.needNePair"); return null; } if (step === 1) { if (!createMonitorTplId) return t("bizMigration.needMonitorTemplate"); if (!createCollectMetricIds.length) return t("bizMigration.needMetrics"); return null; } return null; } function onCreateNext() { const reason = createStepBlockReason(createStep); if (reason) { showError(reason); return; } setCreateStep((s) => Math.min(CREATE_STEPS - 1, (s + 1) as CreateStep) as CreateStep); } function onCreateBack() { setCreateStep((s) => Math.max(0, s - 1) as CreateStep); } function pickCreateNeSide(side: CreatePickSide) { setCreatePickSide(side); setCreateNePickerOpen(true); } function selectCreateNe(row: CliTargetItem) { if (createPickSide === "old") { setCreateOldNe(row); setCreateOldBaselineId(""); // After picking old, auto-focus new side but keep picker open for the other side if (!createNewNe) setCreatePickSide("new"); } else { setCreateNewNe(row); setCreateNewBaselineId(""); if (!createOldNe) setCreatePickSide("old"); } } const openProject = (id: string, tab: DetailTab = "setup") => { setBoardFullscreen(false); setProjectId(id); setDetailTab(tab); setSelectedExpectKeys(new Set()); setPortFilter(""); setBatchLabel(""); setOnlyExpect(true); setBoardSubTab("diffs"); setBoardMetricId(""); }; const closeProject = () => { setBoardFullscreen(false); setProjectId(""); setDetailTab("setup"); }; async function onCreateProject() { if (!createName.trim() || !createOldNe || !createNewNe) { showError(t("bizMigration.needNePair")); return; } if (!createMonitorTplId) { showError(t("bizMigration.needMonitorTemplate")); return; } if (!createCollectMetricIds.length) { showError(t("bizMigration.needMetrics")); return; } setBusy(true); try { const metricIntervals: Record = {}; for (const mid of createCollectMetricIds) { const raw = createMetricIntervalSec[mid]; if (raw && Number(raw) >= 60) metricIntervals[mid] = Math.max(60, Number(raw)); } const mapRows = parseMapRows(); const available = monitorTplMetrics(monitorTpls.find((m) => m.id === createMonitorTplId)); const body: Record = { name: createName.trim(), old_ne: nePayload(createOldNe), new_ne: nePayload(createNewNe), monitor_template_id: createMonitorTplId, collect_metric_ids: collectOverridePayload(createCollectMetricIds, available), metric_interval_sec: metricIntervals, hf_interval_sec: Math.max(60, createHfIntervalSec || 60), hf_start_at: localDatetimeInputToUtcIso(createHfStartAt), hf_end_at: localDatetimeInputToUtcIso(createHfEndAt), old_baseline_batch_id: createOldBaselineId || "", new_baseline_batch_id: createNewBaselineId || "", status: "active", collect_now: false, }; if (mappingId) { body.mapping_id = mappingId; if (mapRows.length) await saveMapping(); } else if (mapRows.length) { body.mapping_name = mapName.trim() || `${createName.trim()}-ports`; body.mapping_rows = mapRows; } const p = (await bizMigrationCreateProject(body)) as Project; await reloadProjects(); closeCreate(); showOk(t("bizMigration.projectCreated")); openProject(p.id, p.old_baseline_batch_id && p.new_baseline_batch_id ? "batches" : "setup"); } catch (e) { showError(mapMigrationApiErr(e, t)); } 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; if (!collectMetricIds.length) { showError(t("bizMigration.needMetrics")); return; } setBusy(true); try { let mid = mappingId; if (mappingId || parseMapRows().length) { mid = await saveMapping(); } else { mid = ""; } const available = collectMetricOptions.length ? collectMetricOptions : monitorTplMetrics(selectedMonitorTpl as MonitorTplOpt | null); const intervals = metricIntervalPayload(collectMetricIds, metricIntervalSec); 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 || "", collect_metric_ids: collectOverridePayload(collectMetricIds, available), metric_interval_sec: intervals, hf_interval_sec: Math.max(60, hfIntervalSec || 60), hf_start_at: localDatetimeInputToUtcIso(hfStartAt), hf_end_at: localDatetimeInputToUtcIso(hfEndAt), }); await bizMigrationEnsureHighfreq(projectId, { interval_sec: Math.max(60, hfIntervalSec || 60), collect_now: false, collect_metric_ids: collectOverridePayload(collectMetricIds, available), hf_start_at: localDatetimeInputToUtcIso(hfStartAt), hf_end_at: localDatetimeInputToUtcIso(hfEndAt), }); await reloadProjects(); await loadBaselineExpect(projectId); showOk(t("bizMigration.baselineSaved")); } catch (e) { showError(mapMigrationApiErr(e, t)); } 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 sid = sheetIdentity(sheet); for (const item of sheet.items) { if (selectedExpectKeys.has(JSON.stringify([sid, item.key]))) { items.push({ metric_id: sheet.metric_id, sheet_id: sid, key: item.keys }); if (sheet.metric_id === "interface_brief" && sheet.key_fields.length === 1 && sheet.iface_fields.includes(sheet.key_fields[0])) ports.push(item.keys[0]); } } } if (!items.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 = {}; 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) { showError(mapMigrationApiErr(e, t)); } finally { setBusy(false); } } async function onEnsureHighfreq() { if (!projectId) return; if (!collectMetricIds.length) { showError(t("bizMigration.needMetrics")); return; } setBusy(true); try { const available = collectMetricOptions.length ? collectMetricOptions : monitorTplMetrics(selectedMonitorTpl as MonitorTplOpt | null); const intervals = metricIntervalPayload(collectMetricIds, metricIntervalSec); await bizMigrationPatchProject(projectId, { collect_metric_ids: collectOverridePayload(collectMetricIds, available), metric_interval_sec: intervals, hf_interval_sec: Math.max(60, hfIntervalSec || 60), hf_start_at: localDatetimeInputToUtcIso(hfStartAt), hf_end_at: localDatetimeInputToUtcIso(hfEndAt), }); const res = (await bizMigrationEnsureHighfreq(projectId, { interval_sec: Math.max(60, hfIntervalSec || 60), collect_now: true, collect_metric_ids: collectOverridePayload(collectMetricIds, available), hf_start_at: localDatetimeInputToUtcIso(hfStartAt), hf_end_at: localDatetimeInputToUtcIso(hfEndAt), })) as { hf_status?: string; collect?: { old?: HfCollectSide; new?: HfCollectSide }; }; await reloadProjects(); const sum = summarizeHfCollect(res); if (sum.kind === "inactive") { showOk(t("bizMigration.highfreqReadyInactive")); } else if (sum.kind === "partial") { showError(t("bizMigration.collectPartial", { ok: String(sum.okN), fail: String(sum.failN) })); } else if (sum.kind === "fail" || sum.kind === "missing") { showError(collectErrorMessage(sum.err, t)); } else { showOk(t("bizMigration.highfreqReady")); } } catch (e) { showError(mapMigrationApiErr(e, t)); } finally { setBusy(false); } } async function onCollectNow() { if (!projectId) return; setBusy(true); try { const out = (await bizMigrationCollectNow(projectId)) as { hf_status?: string; old?: HfCollectSide; new?: HfCollectSide; }; const sum = summarizeHfCollect(out); if (sum.kind === "inactive") showError(t("bizMigration.hfWindowInactive")); else if (sum.kind === "missing") showError(t("bizMigration.hfNotBound")); else if (sum.kind === "partial") { showError(t("bizMigration.collectPartial", { ok: String(sum.okN), fail: String(sum.failN) })); } else if (sum.kind === "fail") showError(collectErrorMessage(sum.err, t)); else showOk(t("bizMigration.collectTriggered")); } catch (e) { showError(formatErr(e)); } finally { setBusy(false); } } async function onMarkProjectDone() { if (!projectId) return; if (!window.confirm(t("bizMigration.confirmProjectDone"))) return; setBusy(true); try { await bizMigrationPatchProject(projectId, { status: "done" }); await reloadProjects(); showOk(t("bizMigration.projectMarkedDone")); } catch (e) { showError(formatErr(e)); } finally { setBusy(false); } } async function onDeleteProject() { if (!projectId) return; if (!window.confirm(t("bizMigration.confirmDeleteProject"))) return; setBusy(true); try { await bizMigrationDeleteProject(projectId); closeProject(); await reloadProjects(); showOk(t("bizMigration.projectDeleted")); } catch (e) { showError(formatErr(e)); } finally { setBusy(false); } } function toggleExpectKey(rawKey: string) { const key = JSON.stringify([expectMetricId || sheetIdentity(expectSheets[0]), rawKey]); 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]); const collectMetricOptions = useMemo(() => { const set = new Set(); for (const m of availableTplMetrics(selectedMonitorTpl as MonitorTplOpt | null)) { if (m) set.add(m); } for (const m of project?.collect_metric_ids_effective || []) { if (m) set.add(m); } for (const m of collectMetricIds) { if (m) set.add(m); } if (!set.size) set.add("interface_brief"); return [...set]; }, [selectedMonitorTpl, project?.collect_metric_ids_effective, collectMetricIds]); 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 createNePages = pageCount(createNeTotal, createNePageSize); const createTplMetrics = useMemo(() => { const tpl = monitorTpls.find((m) => m.id === createMonitorTplId); const base = availableTplMetrics(tpl); const set = new Set([...base, ...createCollectMetricIds]); return [...set]; }, [monitorTpls, createMonitorTplId, createCollectMetricIds]); const batchOptions = (items: { id: string; started_at?: string | null; alias?: string }[]) => ( <> {items.map((x) => { const alias = String(x.alias || "").trim(); const when = fmtTime(x.started_at); const label = alias ? when !== "—" ? `${alias} · ${when}` : alias : when !== "—" ? `${when} · ${x.id}` : x.id; return ( ); })} ); const copyBatchId = (id: string) => { void (async () => { const ok = await writeClipboardText(id); if (ok) showOk(t("common.copied")); else showError(t("common.opFailed")); })(); }; return (

{t("bizMigration.title")}

{t("bizMigration.hintShort")}{" "} {t("network.nav.monitorTemplates")} {" · "} {t("network.nav.compareTemplates")}

{t("bizMigration.kpiProjects")}
{projects.length}
{t("bizMigration.kpiActive")}
{activeCount}
setListKeyword(e.target.value)} />
{filteredProjects.map((row) => ( ))} {!filteredProjects.length ? ( ) : null}
{t("bizMigration.colName")} {t("bizMigration.colPair")} {t("bizMigration.colStatus")} {t("bizMigration.colHf")} {t("bizMigration.colBaseline")} {t("bizMigration.colCreated")} {t("bizMigration.colActions")}
{row.name}
{row.note ?
{row.note}
: null}
{row.old_task?.ne_name || row.old_hf_task?.ne_name || row.old_task_id?.slice(0, 8) || row.old_hf_task_id?.slice(0, 8) || "—"}
→{" "} {row.new_task?.ne_name || row.new_hf_task?.ne_name || row.new_task_id?.slice(0, 8) || row.new_hf_task_id?.slice(0, 8) || "—"} {row.old_task?.ne_ip || row.new_task?.ne_ip || row.old_hf_task?.ne_ip || row.new_hf_task?.ne_ip ? ` · ${row.old_task?.ne_ip || row.old_hf_task?.ne_ip || "—"} / ${ row.new_task?.ne_ip || row.new_hf_task?.ne_ip || "—" }` : ""}
{statusLabel(row.status)} {row.old_hf_task_id && row.new_hf_task_id ? (
{t("bizMigration.hfReady")} {Math.max(60, Number(row.hf_interval_sec || 60))}s
) : ( {t("bizMigration.hfMissing")} )}
{row.old_baseline_batch_id && row.new_baseline_batch_id ? ( {t("bizMigration.baselineSet")} ) : ( {t("bizMigration.baselineUnset")} )} {fmtTime(row.created_at)}
{t("bizMigration.empty")}
{/* Create cutover project — stepped: NE → template → schedule → baseline/mapping */} {t("bizMigration.create")} {createStep === 0 ? (
{t("bizMigration.projectName")}
setCreateName(e.target.value)} placeholder={t("bizMigration.projectNamePh")} aria-label={t("bizMigration.projectName")} />
{createNePickerOpen ? (
{createPickSide === "old" ? t("bizMigration.pickOldNe") : t("bizMigration.pickNewNe")} {createOldNe && createNewNe ? ( ) : null}
{ setCreateNeKeyword(e.target.value); setCreateNePage(1); }} /> { setCreateNeSource(e.target.value as NeSourceFilter); setCreateNePage(1); }} aria-label={t("bizState.colSource")} > {createNeLoading ? … : null}
{createNeItems.map((row) => { const active = createPickSide === "old" ? createOldNe?.id === row.id && createOldNe && neSourceOf(createOldNe) === neSourceOf(row) : createNewNe?.id === row.id && createNewNe && neSourceOf(createNewNe) === neSourceOf(row); return ( selectCreateNe(row)} style={{ cursor: "pointer" }} > ); })} {!createNeItems.length ? ( ) : null}
{t("bizState.colSource")} {t("bizState.colNe")} IP {t("bizState.colVendor")}
selectCreateNe(row)} onClick={(e) => e.stopPropagation()} /> {row.source} {row.name || "—"} {row.ip_address || "—"} {row.vendor || "—"}
{t("bizState.neEmpty")}
{ setCreateNePageSize(size); setCreateNePage(1); }} disabled={createNeLoading} />
) : (

{t("bizMigration.pickNeHint")}

)}
) : null} {createStep === 1 ? (
{ const id = e.target.value; setCreateMonitorTplId(id); const tpl = monitorTpls.find((m) => m.id === id); setCreateCollectMetricIds(monitorTplMetrics(tpl).filter((mid) => !isManualMetric(mid))); setCreateMetricIntervalSec({}); }} fullWidth > {monitorTpls.map((m) => ( ))}
{t("bizMigration.sectionMetrics")} {createCollectMetricIds.length}/{createTplMetrics.length}

{t("bizMigration.pairCollectionHint")}

{createTplMetrics.map((mid) => { const on = createCollectMetricIds.includes(mid); return ( ); })}
{!createCollectMetricIds.length ? (

{t("bizMigration.needMetrics")}

) : null}
) : null} {createStep === 2 ? (
{t("bizMigration.hfInterval")}
setCreateHfIntervalSec(Math.max(60, Number(e.target.value) || 60)) } aria-label={t("bizMigration.hfInterval")} />
{t("bizMigration.hfStart")}
setCreateHfStartAt(e.target.value)} aria-label={t("bizMigration.hfStart")} />
{t("bizMigration.hfEnd")}
setCreateHfEndAt(e.target.value)} aria-label={t("bizMigration.hfEnd")} />
{createCollectMetricIds.length ? ( <>
{t("bizMigration.metricIntervalOptional")}
{createCollectMetricIds.map((mid) => ( ))}
{t("bizMigration.sectionMetrics")} {t("bizMigration.metricInterval")}
{mid} {isManualMetric(mid) ? {t("bizMigration.manualRoute")} : setCreateMetricIntervalSec((prev) => ({ ...prev, [mid]: e.target.value, })) } aria-label={`${mid} interval`} />}
) : null}
) : null} {createStep === 3 ? (
{t("bizMigration.sectionBaseline")}
{!createOldHasPortrait || !createNewHasPortrait ? (

{t("bizMigration.openBizState")}

) : null}
setCreateOldBaselineId(e.target.value)} fullWidth disabled={!createOldHasPortrait} > {batchOptions(createOldBatches)} setCreateNewBaselineId(e.target.value)} fullWidth disabled={!createNewHasPortrait} > {batchOptions(createNewBatches)}
{t("bizMigration.portMapping")}
{ const id = e.target.value; if (id) loadMappingText(id); else resetMappingEditor(); }} fullWidth > {mappings.map((m) => ( ))} setMapName(e.target.value)} aria-label={t("bizCompare.mapName")} />