import { Button, Input, Modal } from "@heroui/react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { KeyboardEvent as ReactKeyboardEvent } from "react"; 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 { bizStateBulkDeleteBatches, bizStateCollectNow, bizStateCollectStop, bizStateCreateTask, bizStateDeleteBatch, bizStateDeleteTask, bizStateDiscover, bizStateDownloadCommandRaw, bizStateDownloadExport, bizStateDownloadTaskCommands, bizStateGetBatch, bizStateGetBatchCommand, bizStateGetTask, bizStateGetTaskProgress, bizStateImportLog, bizStateImportLogStandalone, bizStateListBatches, bizStateListBatchMetricRows, bizStateListProfiles, bizStateListTasks, bizStatePatchTask, bizStatePauseTask, bizStatePurgeTask, bizStateSetBatchBaseline, bizStateSetBatchAlias, bizStateSetBindings, bizStateStartTask, fetchCliTargets, formatErr, } from "../../services/api"; import type { CliTargetItem } from "../../types"; import { pageCount } from "../../utils/display"; import { writeClipboardText } from "../../utils/clipboard"; import { formatSystemTime } from "../../utils/time"; import { cutoverCachedGet, cutoverCachedGetSWR, invalidateCutoverCache } from "./cutoverDataCache"; import { jobChipColor, NmStatusChip, sourceChipColor } from "./nmChips"; type TaskRow = { id: string; source?: string; ne_name: string; ne_ip: string; vendor: string; note?: string; purpose?: string; status: string; collect_running: boolean; last_error: string; last_collect_started_at?: string | null; last_collect_ended_at?: string | null; interval_sec?: number; }; type Placeholder = { name: string; required?: boolean; bind_mode?: string; discover_profile_id?: string; }; type Profile = { profile_id: string; title: string; command_template: string; description: string; metric_id: string; kind?: string; /** light | heavy — collect dual-lane */ collect_lane?: string; placeholders?: Placeholder[]; aux_commands?: Array<{ key: string; profile_id: string; title?: string; command_template?: string; }>; }; type BatchRow = { id: string; status: string; row_count: number; command_count: number; message?: string; started_at?: string | null; ended_at?: string | null; is_baseline?: boolean; protected?: boolean; protect_reasons?: string[]; alias?: string; }; type Candidate = { value: string; label: string; rd?: string; bindings?: Record; }; type SheetCol = { key: string; header: string }; type SheetCmd = { id: string; raw_command: string; parse_status?: string; row_count?: number; raw_line_count?: number; declared_total?: number; message?: string; has_raw?: boolean; profile_id?: string; }; type SheetTab = { id: string; title: string; rowCount: number; commands: SheetCmd[]; }; type TaskTab = "profiles" | "batches"; type NeSourceFilter = "all" | "managed" | "ume"; const NE_PAGE_SIZE = 10; const SHEET_PAGE_SIZE_OPTIONS = [20, 50, 100, 200]; const DEFAULT_SHEET_PAGE_SIZE = 50; function fmtIntervalLabel( sec: number, daysLabel: string, hoursLabel: string, secondsLabel: string, ) { const s = Math.max(1, Math.round(Number(sec || 0)) || 1); if (s % 86400 === 0) return `${s / 86400} ${daysLabel}`; if (s % 3600 === 0) return `${s / 3600} ${hoursLabel}`; return `${s} ${secondsLabel}`; } function secToIntervalUi(sec: number): { value: number; unit: "days" | "hours" | "seconds" } { const s = Math.max(1, Math.round(Number(sec || 3600)) || 3600); if (s % 86400 === 0) return { value: s / 86400, unit: "days" }; if (s % 3600 === 0) return { value: s / 3600, unit: "hours" }; return { value: s, unit: "seconds" }; } function intervalUiToSec(value: number, unit: "days" | "hours" | "seconds") { const n = Math.max(1, Number(value) || 1); if (unit === "days") return n * 86400; if (unit === "hours") return n * 3600; return Math.max(60, n); } function intervalUnitMax(unit: "days" | "hours" | "seconds") { if (unit === "days") return 365; if (unit === "hours") return 8760; return 604800; // up to 7 days in seconds } function formatCmdCollectStats( t: (key: string, vars?: Record) => string, lines: number, rows: number, declared?: number, ) { const base = t("bizState.rawLogStats", { lines, rows }); const d = Number(declared || 0); if (d > 0) { return `${base} · ${t("bizState.rawLogDeclared", { declared: d })}`; } return base; } function convertIntervalValue( value: number, from: "days" | "hours" | "seconds", to: "days" | "hours" | "seconds", ) { const sec = intervalUiToSec(value, from); if (to === "days") return Math.max(1, Math.min(365, Math.round(sec / 86400) || 1)); if (to === "hours") return Math.max(1, Math.min(8760, Math.round(sec / 3600) || 1)); return Math.max(60, Math.min(604800, sec)); } function fmtTime(v?: string | null) { if (!v) return "—"; return formatSystemTime(v) || v; } function fmtDuration(started?: string | null, ended?: string | null) { if (!started || !ended) return ""; const a = Date.parse(started); const b = Date.parse(ended); if (!Number.isFinite(a) || !Number.isFinite(b) || b < a) return ""; const sec = Math.round((b - a) / 1000); if (sec < 60) return `${sec}s`; const m = Math.floor(sec / 60); const s = sec % 60; if (m < 60) return s ? `${m}m${s}s` : `${m}m`; const h = Math.floor(m / 60); const rm = m % 60; return rm ? `${h}h${rm}m` : `${h}h`; } function cellText(v: unknown): string { if (v === null || v === undefined) return ""; if (typeof v === "object") return JSON.stringify(v); return String(v); } function neSourceOf(row: CliTargetItem): "managed" | "ume" { return row.source === "ume" ? "ume" : "managed"; } function columnsFromRows(rows: Record[]): SheetCol[] { const keys = new Set(); for (const row of rows) { for (const k of Object.keys(row || {})) { keys.add(k); } } return [...keys].map((k) => ({ key: k, header: k })); } function buildSheetTabs(batch: any, t: (k: string) => string): SheetTab[] { const tabs: SheetTab[] = []; const cmds = (batch?.commands || []) as SheetCmd[]; if (cmds.length) { tabs.push({ id: "commands", title: t("bizState.sheetCommands"), rowCount: cmds.length, commands: cmds, }); } for (const s of (batch?.sheets || []) as any[]) { const mid = String(s?.metric_id || "").trim(); if (!mid || mid === "vrf_list") continue; const title = String(s?.title || "").trim() || mid; tabs.push({ id: mid, title, rowCount: Number(s?.row_count || 0), commands: Array.isArray(s?.commands) ? (s.commands as SheetCmd[]) : [], }); } return tabs; } export function BizStatePage() { const { t } = useI18n(); const { showOk, showError } = useToast(); const [tasks, setTasks] = useState([]); const [tasksLoading, setTasksLoading] = useState(true); const [tasksError, setTasksError] = useState(""); const tasksRequestRef = useRef(0); const taskRequestRef = useRef(0); const taskIdRef = useRef(""); const batchRequestRef = useRef(0); const sheetRequestRef = useRef(0); const collectRequestRef = useRef(0); const rawRequestRef = useRef(0); const discoverRequestRef = useRef(0); const pollInFlightRef = useRef(false); const [busy, setBusy] = useState(false); const [collectingIds, setCollectingIds] = useState>({}); /** Track that we observed collect_running=true so we don't clear the chip before enqueue lands. */ const seenCollectRunningRef = useRef>({}); const collectStartedAtRef = useRef>({}); const [listKeyword, setListKeyword] = useState(""); const debouncedListKw = useDebouncedValue(listKeyword, 250); const [taskPage, setTaskPage] = useState(1); const [taskPageSize, setTaskPageSize] = useState(50); const [purposeFilter, setPurposeFilter] = useState<"all" | "portrait" | "cutover_hf">("all"); const purposeRef = useRef(purposeFilter); // create-task modal (all valid CLI targets) const [createOpen, setCreateOpen] = useState(false); const [createMode, setCreateMode] = useState<"device" | "import">("device"); const [neKeyword, setNeKeyword] = useState(""); const debouncedNeKw = useDebouncedValue(neKeyword, 300); const [neSource, setNeSource] = useState("all"); const [nePage, setNePage] = useState(1); const [neTotal, setNeTotal] = useState(0); const [neItems, setNeItems] = useState([]); const [neLoading, setNeLoading] = useState(false); const [selectedNe, setSelectedNe] = useState(null); // task modal const [taskId, setTaskId] = useState(""); const [detail, setDetail] = useState(null); const [taskLoading, setTaskLoading] = useState(false); const [taskError, setTaskError] = useState(""); const [profiles, setProfiles] = useState([]); const [batches, setBatches] = useState([]); const [taskTab, setTaskTab] = useState("profiles"); const [intervalValue, setIntervalValue] = useState(1); const [intervalUnit, setIntervalUnit] = useState<"days" | "hours" | "seconds">("hours"); const [retentionDays, setRetentionDays] = useState(30); const [dailyKeepEnabled, setDailyKeepEnabled] = useState(false); const [dailyKeepCount, setDailyKeepCount] = useState(10); const [selectedBatchIds, setSelectedBatchIds] = useState([]); // VRF bind modal (blocking) const [bindItemId, setBindItemId] = useState(""); const [candidates, setCandidates] = useState([]); const [selectedVrfs, setSelectedVrfs] = useState([]); const [discoverCmd, setDiscoverCmd] = useState(""); const [discoverLoading, setDiscoverLoading] = useState(false); const [discoverError, setDiscoverError] = useState(""); const [discoverCacheHit, setDiscoverCacheHit] = useState(false); // Bound-params detail modal (double-click params cell) const [paramsDetail, setParamsDetail] = useState<{ title: string; lines: string[]; } | null>(null); // batch workbook modal (summary + lazy-paged metric sheets) const [batchDetail, setBatchDetail] = useState(null); const [batchTargetId, setBatchTargetId] = useState(""); const [batchLoading, setBatchLoading] = useState(false); const [batchError, setBatchError] = useState(""); const [sheetId, setSheetId] = useState(""); const [sheetKeyword, setSheetKeyword] = useState(""); const [sheetColumn, setSheetColumn] = useState(""); const debouncedSheetKw = useDebouncedValue(sheetKeyword, 250); const [sheetPage, setSheetPage] = useState(1); const [sheetPageSize, setSheetPageSize] = useState(DEFAULT_SHEET_PAGE_SIZE); const [sheetRows, setSheetRows] = useState[]>([]); const [sheetColumns, setSheetColumns] = useState([]); const [sheetTotal, setSheetTotal] = useState(0); const [sheetLoading, setSheetLoading] = useState(false); const [sheetError, setSheetError] = useState(""); const [sheetRetry, setSheetRetry] = useState(0); const [rawLogOpen, setRawLogOpen] = useState(false); const [rawLogLoading, setRawLogLoading] = useState(false); const [rawLogCmd, setRawLogCmd] = useState(""); const [rawLogText, setRawLogText] = useState(""); const [rawLogMeta, setRawLogMeta] = useState(""); const [rawLogCommandId, setRawLogCommandId] = useState(""); const [rawLogBatchId, setRawLogBatchId] = useState(""); // Manual log import into an existing task (append batch) const [importOpen, setImportOpen] = useState(false); const [importTaskId, setImportTaskId] = useState(""); const [importVendorKey, setImportVendorKey] = useState("zte"); const [importNeName, setImportNeName] = useState(""); const [importFile, setImportFile] = useState(null); const [importBusy, setImportBusy] = useState(false); const importFileRef = useRef(null); const createImportFileRef = useRef(null); const [rawLogLines, setRawLogLines] = useState(0); const [rawLogRows, setRawLogRows] = useState(0); const [rawLogDeclared, setRawLogDeclared] = useState(0); const [rawLogMessage, setRawLogMessage] = useState(""); /** Collect status / errors detail (lighter than workbook). */ const [collectDetail, setCollectDetail] = useState(null); const [collectDetailLoading, setCollectDetailLoading] = useState(false); const refreshTasks = useCallback(async () => { if (purposeFilter !== purposeRef.current) return []; const request = ++tasksRequestRef.current; const purpose = purposeFilter === "all" ? "" : purposeFilter === "portrait" ? "portrait" : "cutover_hf"; const key = `bizState:tasks:${purpose || "all"}`; const res = await cutoverCachedGet(key, () => bizStateListTasks(purpose), { force: true }); const items = (res.items || []) as TaskRow[]; if (request === tasksRequestRef.current && purposeFilter === purposeRef.current) setTasks(items); return items; }, [purposeFilter]); /** Lightweight poll: task flags + batch counters only (no profile reload). */ const refreshTaskProgress = useCallback(async (id: string, isCurrent: () => boolean = () => true) => { const request = taskRequestRef.current; const [task, b] = await Promise.all([bizStateGetTaskProgress(id), bizStateListBatches(id, 200)]); if (request !== taskRequestRef.current || taskIdRef.current !== id || !isCurrent()) return task as TaskRow; setDetail((prev: any) => { if (!prev || prev.id !== id) return prev; return { ...prev, collect_running: Boolean(task.collect_running), last_error: task.last_error, last_collect_started_at: task.last_collect_started_at, last_collect_ended_at: task.last_collect_ended_at, status: task.status, }; }); setBatches((b.items || []) as BatchRow[]); const deletable = new Set((b.items || []).filter((row) => !row.protected && !["queued", "running"].includes(String(row.status))).map((row) => row.id)); setSelectedBatchIds((prev) => prev.filter((bid) => deletable.has(bid))); return task as TaskRow; }, []); const reloadTasks = async () => { setTasksLoading(true); setTasksError(""); try { await refreshTasks(); } catch (e) { if (purposeFilter === purposeRef.current) setTasksError(formatErr(e)); } finally { if (purposeFilter === purposeRef.current) setTasksLoading(false); } }; useEffect(() => { purposeRef.current = purposeFilter; const request = ++tasksRequestRef.current; let cancelled = false; void (async () => { try { const purpose = purposeFilter === "all" ? "" : purposeFilter === "portrait" ? "portrait" : "cutover_hf"; const key = `bizState:tasks:${purpose || "all"}`; const apply = (res: Awaited>) => { if (cancelled || request !== tasksRequestRef.current) return; setTasks((res.items || []) as TaskRow[]); }; apply(await cutoverCachedGetSWR(key, () => bizStateListTasks(purpose), apply)); } catch (e) { if (!cancelled && request === tasksRequestRef.current) setTasksError(formatErr(e)); } finally { if (!cancelled && request === tasksRequestRef.current) setTasksLoading(false); } })(); return () => { cancelled = true; }; }, [purposeFilter]); useEffect(() => () => { tasksRequestRef.current += 1; taskRequestRef.current += 1; taskIdRef.current = ""; batchRequestRef.current += 1; sheetRequestRef.current += 1; collectRequestRef.current += 1; rawRequestRef.current += 1; discoverRequestRef.current += 1; }, []); const activeTaskIds = tasks.filter((row) => row.collect_running).map((row) => row.id).sort().join(","); const hasScheduledTask = tasks.some((row) => row.status === "running"); // Progress poll while any collect is running (list chips and/or open task). // Does NOT block navigation; cleans up on unmount / when nothing is collecting. useEffect(() => { const watching = new Set(Object.keys(collectingIds)); for (const id of activeTaskIds.split(",").filter(Boolean)) watching.add(id); if (taskId && detail?.collect_running) watching.add(taskId); if (!watching.size && !hasScheduledTask && !taskId) return; let cancelled = false; let timer: number | undefined; const delay = watching.size ? 4000 : 15000; const tick = async () => { if (cancelled) return; if (pollInFlightRef.current) { timer = window.setTimeout(() => void tick(), delay); return; } pollInFlightRef.current = true; try { const items = await refreshTasks(); if (cancelled) return; // A purpose-filtered list may omit a manually started task. Read its flags directly. const known = new Map(items.map((row) => [row.id, row])); await Promise.all(Object.keys(collectingIds).filter((id) => !known.has(id)).map(async (id) => { const row = await bizStateGetTaskProgress(id); known.set(id, row as TaskRow); })); if (cancelled) return; setCollectingIds((prev) => { let changed = false; const next = { ...prev }; const now = Date.now(); for (const id of Object.keys(next)) { const row = known.get(id); if (row?.collect_running) { seenCollectRunningRef.current[id] = true; continue; } const seen = Boolean(seenCollectRunningRef.current[id]); const started = collectStartedAtRef.current[id] || 0; // Clear after we saw running→idle, or enqueue never landed (~20s). if (seen || (started && now - started > 20_000)) { delete next[id]; delete seenCollectRunningRef.current[id]; delete collectStartedAtRef.current[id]; changed = true; } } return changed ? next : prev; }); if (taskId) { await refreshTaskProgress(taskId, () => !cancelled); } } catch { /* ignore transient poll errors */ } finally { pollInFlightRef.current = false; if (!cancelled) timer = window.setTimeout(() => void tick(), delay); } }; if (watching.size) void tick(); else timer = window.setTimeout(() => void tick(), delay); return () => { cancelled = true; window.clearTimeout(timer); }; }, [collectingIds, activeTaskIds, hasScheduledTask, taskId, detail?.collect_running, refreshTasks, refreshTaskProgress]); useEffect(() => { if (!createOpen) return; let cancelled = false; setNeLoading(true); void (async () => { try { const res = await fetchCliTargets({ source: neSource, keyword: debouncedNeKw, page: nePage, pageSize: NE_PAGE_SIZE, }); if (cancelled) return; setNeItems(res.items || []); setNeTotal(Number(res.total || 0)); } catch (e) { if (!cancelled) showError(formatErr(e)); } finally { if (!cancelled) setNeLoading(false); } })(); return () => { cancelled = true; }; }, [createOpen, neSource, debouncedNeKw, nePage]); const nePages = pageCount(neTotal, NE_PAGE_SIZE); const filteredTasks = useMemo(() => { const kw = debouncedListKw.trim().toLowerCase(); if (!kw) return tasks; return tasks.filter((row) => { const blob = `${row.ne_name} ${row.ne_ip} ${row.vendor} ${row.note || ""} ${row.purpose || ""} ${row.status} ${row.source || ""} ${row.last_error}`.toLowerCase(); return blob.includes(kw); }); }, [tasks, debouncedListKw]); const collectProfiles = useMemo( () => profiles.filter((p) => (p.kind || "collect") === "collect"), [profiles], ); const profileSelectionStats = useMemo(() => { const items = (detail?.items || []) as Array<{ source_profile_id?: string; enabled?: boolean; bindings?: unknown[]; }>; const byId = new Map(items.map((it) => [String(it.source_profile_id || ""), it])); let enabled = 0; let unbound = 0; for (const prof of collectProfiles) { const it = byId.get(prof.profile_id); if (!it?.enabled) continue; enabled += 1; if ((prof.placeholders || []).length > 0) { const binds = (it.bindings || []) as Array<{ value?: string }>; const hasVal = binds.some((b) => String(b.value || "").trim()); if (!hasVal) unbound += 1; } } return { total: collectProfiles.length, enabled, unbound }; }, [collectProfiles, detail?.items]); const sheetTabs = useMemo( () => (batchDetail ? buildSheetTabs(batchDetail, t) : []), [batchDetail, t], ); const activeSheet = useMemo(() => { if (!sheetTabs.length) return null; return sheetTabs.find((s) => s.id === sheetId) || sheetTabs[0]; }, [sheetTabs, sheetId]); const commandsSheetColumns = useMemo( () => [ { key: "_actions", header: t("bizState.colActions") }, { key: "raw_command", header: t("bizState.colCommand") }, { key: "metric_id", header: "metric" }, { key: "parse_status", header: t("bizState.colStatus") }, { key: "raw_line_count", header: t("bizState.colRawLines") }, { key: "row_count", header: t("bizState.colRows") }, { key: "message", header: t("bizState.colMessage") }, ], [t], ); const filteredCommands = useMemo(() => { const commands = (batchDetail?.commands || []) as Record[]; const kw = debouncedSheetKw.trim().toLowerCase(); if (!kw) return commands; const keys = sheetColumn ? [sheetColumn] : ["raw_command", "metric_id", "parse_status", "raw_line_count", "row_count", "message"]; return commands.filter((row) => keys.some((key) => cellText(row[key]).toLowerCase().includes(kw))); }, [batchDetail?.commands, debouncedSheetKw, sheetColumn]); const displayColumns = useMemo(() => { if (!activeSheet) return []; if (activeSheet.id === "commands") return commandsSheetColumns; return sheetColumns.length ? sheetColumns : [{ key: "_empty", header: "—" }]; }, [activeSheet, commandsSheetColumns, sheetColumns]); const displayRows = useMemo(() => { if (!activeSheet) return []; if (activeSheet.id === "commands") { const start = (sheetPage - 1) * sheetPageSize; return filteredCommands.slice(start, start + sheetPageSize); } return sheetRows; }, [ activeSheet, filteredCommands, sheetPage, sheetPageSize, sheetRows, ]); const displayTotal = useMemo(() => { if (!activeSheet) return 0; if (activeSheet.id === "commands") { return filteredCommands.length; } return sheetTotal; }, [activeSheet, filteredCommands, sheetTotal]); const collectDetailCmdSummary = useMemo(() => { const stats = collectDetail?.command_stats as | { ok?: number; failed?: number; skipped?: number; other?: number; total?: number; aux_failed?: number } | undefined; if (stats && typeof stats.total === "number") { return { ok: Number(stats.ok || 0), fail: Number(stats.failed || 0), skipped: Number(stats.skipped || 0), other: Number(stats.other || 0), total: Number(stats.total || 0), auxFailed: Number(stats.aux_failed || 0), }; } const cmds = (collectDetail?.commands || []) as SheetCmd[]; let ok = 0; let fail = 0; let skipped = 0; let other = 0; for (const c of cmds) { const st = String(c.parse_status || "").toLowerCase(); if (st === "ok" || st === "success" || st === "aux" || st === "aux_ok" || st === "aux_cached") ok += 1; else if (st === "failed" || st === "error" || st === "fail" || st === "aux_failed" || st.endsWith("_failed")) fail += 1; else if (st.startsWith("skipped")) skipped += 1; else other += 1; } return { ok, fail, skipped, other, total: cmds.length, auxFailed: 0 }; }, [collectDetail]); const loadSheetPage = useCallback( async (batchId: string, metricId: string, page: number, pageSize: number, kw: string, column: string) => { if (!batchId || !metricId || metricId === "commands") return; const request = ++sheetRequestRef.current; setSheetLoading(true); setSheetError(""); setSheetRows([]); try { const res = await bizStateListBatchMetricRows({ batchId, metricId, page, pageSize, kw, column, }); if (request !== sheetRequestRef.current) return; setSheetRows(res.items || []); setSheetTotal(Number(res.total || 0)); const cols = (res.columns || []).map((c) => ({ key: c.key, // Always show original field key; never localized display_name. header: c.key || c.header || "", })); setSheetColumns(cols.length ? cols : columnsFromRows(res.items || [])); } catch (e) { if (request !== sheetRequestRef.current) return; setSheetError(formatErr(e)); setSheetRows([]); setSheetTotal(0); } finally { if (request === sheetRequestRef.current) setSheetLoading(false); } }, [], ); const activeMetricId = activeSheet?.id || ""; useEffect(() => { if (!batchDetail?.id || !activeMetricId || activeMetricId === "commands") { sheetRequestRef.current += 1; setSheetLoading(false); setSheetError(""); return; } if (sheetKeyword.trim() !== debouncedSheetKw.trim()) { sheetRequestRef.current += 1; setSheetLoading(true); setSheetError(""); setSheetRows([]); return; } void loadSheetPage( String(batchDetail.id), activeMetricId, sheetPage, sheetPageSize, debouncedSheetKw, sheetColumn, ); return () => { sheetRequestRef.current += 1; }; }, [ batchDetail?.id, activeMetricId, sheetPage, sheetPageSize, debouncedSheetKw, sheetKeyword, sheetColumn, sheetRetry, loadSheetPage, ]); const openCreate = () => { setCreateOpen(true); setCreateMode("device"); setSelectedNe(null); setNeKeyword(""); setNeSource("all"); setNePage(1); setImportVendorKey("zte"); setImportNeName(""); setImportFile(null); if (createImportFileRef.current) createImportFileRef.current.value = ""; }; const closeCreate = () => { setCreateOpen(false); setSelectedNe(null); setImportFile(null); setImportNeName(""); if (createImportFileRef.current) createImportFileRef.current.value = ""; }; const loadTask = async (id: string) => { if (taskIdRef.current !== id) return; const request = ++taskRequestRef.current; setTaskLoading(true); setTaskError(""); try { const task = await bizStateGetTask(id); if (request !== taskRequestRef.current) return; const [b, p] = await Promise.all([ bizStateListBatches(id, 200), cutoverCachedGet(`bizState:profiles:${String(task.vendor || "")}:${String(task.device_type || "")}`, () => bizStateListProfiles({vendor: String(task.vendor || ""), device_type: String(task.device_type || "")}), {ttlMs: 30000}), ]); if (request !== taskRequestRef.current) return; setDetail(task); const ui = secToIntervalUi(Number(task.interval_sec || 3600)); setIntervalValue(ui.value); setIntervalUnit(ui.unit); setRetentionDays(Math.max(1, Number(task.retention_days || 30))); setDailyKeepEnabled(Boolean(task.daily_keep_enabled)); setDailyKeepCount(Math.max(1, Number(task.daily_keep_count || 10))); setBatches((b.items || []) as BatchRow[]); setSelectedBatchIds([]); setProfiles((p.items || []) as Profile[]); } catch (e) { if (request === taskRequestRef.current) setTaskError(formatErr(e)); } finally { if (request === taskRequestRef.current) setTaskLoading(false); } }; const openTask = async (id: string, tab: TaskTab = "profiles") => { taskRequestRef.current += 1; taskIdRef.current = id; setTaskId(id); setDetail(null); setProfiles([]); setBatches([]); setSelectedBatchIds([]); setTaskTab(tab); setBindItemId(""); setCandidates([]); closeBatch(); closeCollectDetail(); try { await loadTask(id); } catch (e) { showError(formatErr(e)); setTaskId(""); } }; const closeTask = () => { taskRequestRef.current += 1; taskIdRef.current = ""; setTaskLoading(false); setTaskError(""); closeBatch(); closeCollectDetail(); closeBindModal(); setTaskId(""); setDetail(null); setBindItemId(""); setCandidates([]); }; const createTask = async () => { if (!selectedNe) return; const source = neSourceOf(selectedNe); setBusy(true); try { const task = await bizStateCreateTask({ source, ne_id: selectedNe.id, ne_name: selectedNe.name, ne_ip: selectedNe.ip_address, vendor: selectedNe.vendor || "", device_type: selectedNe.device_type || "", }); showOk(t("bizState.created")); closeCreate(); invalidateCutoverCache("bizCompare:"); await refreshTasks(); await openTask(String(task.id), "profiles"); } catch (e) { showError(t("bizState.createFailed") + ": " + formatErr(e)); } finally { setBusy(false); } }; /** Create offline import task + first batch (same entry as pick-NE create). */ const createImportTask = async () => { if (!importFile) return; setImportBusy(true); setBusy(true); try { const out = await bizStateImportLogStandalone(importFile, { vendor_key: importVendorKey, ne_name: importNeName.trim(), }); if (out && out.started === false) { showError(String(out.reason || "") || t("common.opFailed")); return; } const newTaskId = String(out.task_id || ""); const n = Number(out.segments_preview?.segments || 0); showOk( n > 0 ? t("bizState.importStarted", { count: String(n) }) : t("bizState.created"), ); closeCreate(); invalidateCutoverCache("bizCompare:"); await refreshTasks(); if (newTaskId) { setTaskCollecting(newTaskId, true); await openTask(newTaskId, "batches"); } } catch (e) { showError(t("bizState.createFailed") + ": " + formatErr(e)); } finally { setImportBusy(false); setBusy(false); } }; const setScheduleEnabled = async (id: string, enabled: boolean) => { setBusy(true); try { if (enabled) { await bizStateStartTask(id); } else { await bizStatePauseTask(id); } showOk(enabled ? t("bizState.started") : t("bizState.paused")); if (taskId === id) await loadTask(id); await refreshTasks(); } catch (e) { showError(formatErr(e)); } finally { setBusy(false); } }; const saveSchedule = async () => { if (!taskId) return; setBusy(true); try { await bizStatePatchTask(taskId, { interval_sec: intervalUiToSec(intervalValue, intervalUnit), retention_days: Math.max(1, Number(retentionDays) || 30), daily_keep_enabled: dailyKeepEnabled, daily_keep_count: Math.max(1, Number(dailyKeepCount) || 10), }); showOk(t("bizState.scheduleSaved")); await loadTask(taskId); await refreshTasks(); } catch (e) { showError(formatErr(e)); } finally { setBusy(false); } }; const toggleBatchSelect = (id: string, protectedBatch: boolean) => { if (protectedBatch) return; setSelectedBatchIds((prev) => prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id], ); }; const selectDeletableBatches = () => { setSelectedBatchIds(batches.filter((b) => !b.protected && !["running", "queued"].includes(b.status)).map((b) => b.id)); }; const markBaseline = async (batchId: string, marked: boolean) => { setBusy(true); try { await bizStateSetBatchBaseline(batchId, marked); if (taskId) await loadTask(taskId); } catch (e) { showError(formatErr(e)); } finally { setBusy(false); } }; const editBatchAlias = async (batchId: string, current?: string) => { const next = window.prompt(t("bizState.aliasPrompt"), String(current || "")); if (next === null) return; setBusy(true); try { await bizStateSetBatchAlias(batchId, next.trim()); showOk(t("bizState.aliasSaved")); if (taskId) await loadTask(taskId); if (batchDetail?.id === batchId) { setBatchDetail((prev: any) => (prev?.id === batchId ? { ...prev, alias: next.trim() } : prev)); } } catch (e) { showError(formatErr(e)); } finally { setBusy(false); } }; const removeBatch = async (batchId: string) => { if (!window.confirm(t("bizState.confirmDeleteBatch"))) return; setBusy(true); try { await bizStateDeleteBatch(batchId); showOk(t("bizState.batchDeleted")); if (taskId) await loadTask(taskId); } catch (e) { showError(formatErr(e)); } finally { setBusy(false); } }; const bulkRemoveBatches = async () => { if (!selectedBatchIds.length) return; if ( !window.confirm( t("bizState.confirmBulkDelete").replace("{{count}}", String(selectedBatchIds.length)), ) ) { return; } setBusy(true); try { const res = await bizStateBulkDeleteBatches(selectedBatchIds); showOk( t("bizState.bulkDeleted") .replace("{{deleted}}", String(res.deleted_count || 0)) .replace("{{skipped}}", String((res.skipped || []).length)), ); if (taskId) await loadTask(taskId); } catch (e) { showError(formatErr(e)); } finally { setBusy(false); } }; const purgeNow = async () => { if (!taskId) return; setBusy(true); try { const res = await bizStatePurgeTask(taskId); showOk(t("bizState.purgeOk").replace("{{dropped}}", String(res.dropped || 0))); await loadTask(taskId); } catch (e) { showError(formatErr(e)); } finally { setBusy(false); } }; const setTaskCollecting = (id: string, on: boolean) => { setCollectingIds((prev) => { if (on) { collectStartedAtRef.current[id] = Date.now(); seenCollectRunningRef.current[id] = false; if (prev[id]) return prev; return { ...prev, [id]: true }; } delete seenCollectRunningRef.current[id]; delete collectStartedAtRef.current[id]; if (!prev[id]) return prev; const next = { ...prev }; delete next[id]; return next; }); }; const collectNowForTask = async (id: string, fromModal = false) => { setTaskCollecting(id, true); try { const out = await bizStateCollectNow(id); if (out && out.started === false) { setTaskCollecting(id, false); const reason = String(out.reason || ""); if (reason === "already_collecting") { setTaskCollecting(id, true); showOk(t("bizState.collecting")); } else { showError(reason || t("common.opFailed")); return; } } else { showOk(t("bizState.collecting")); } if (fromModal && taskId === id) { setTaskTab("batches"); try { await refreshTaskProgress(id); } catch { /* progress poll will retry */ } } else { try { await refreshTasks(); } catch { /* list poll will retry */ } } // Do NOT block UI for the full collect duration. Progress is driven by // the collectingIds / collect_running effect (cleans up on unmount). } catch (e) { setTaskCollecting(id, false); showError(formatErr(e)); } }; const collectNow = async () => { if (!taskId) return; await collectNowForTask(taskId, true); }; const guessVendorKey = (vendor: string, deviceType = "") => { const v = `${vendor || ""} ${deviceType || ""}`.toLowerCase(); if (v.includes("huawei") || v.includes("vrp")) return "huawei"; if (v.includes("cisco") || v.includes("ios")) return "cisco"; if (v.includes("zte") || v.includes("zxros") || v.includes("zxr")) return "zte"; return "zte"; }; const openImportLog = (id: string, vendor = "", deviceType = "") => { setImportTaskId(id); setImportVendorKey(guessVendorKey(vendor, deviceType)); setImportNeName(""); setImportFile(null); if (importFileRef.current) importFileRef.current.value = ""; setImportOpen(true); }; const submitImportLog = async () => { if (!importTaskId || !importFile) return; setImportBusy(true); setTaskCollecting(importTaskId, true); try { const out = await bizStateImportLog(importTaskId, importFile, { vendor_key: importVendorKey, }); if (out && out.started === false) { setTaskCollecting(importTaskId, false); const reason = String(out.reason || ""); if (reason === "already_collecting") { setTaskCollecting(importTaskId, true); showOk(t("bizState.collecting")); } else if (reason === "import_offline_only") { showError(t("bizState.importOfflineOnly")); return; } else { showError(reason || t("common.opFailed")); return; } } else { const n = Number(out.segments_preview?.segments || 0); showOk( n > 0 ? t("bizState.importStarted", { count: String(n) }) : t("bizState.importing"), ); } setImportOpen(false); if (taskId === importTaskId) { setTaskTab("batches"); try { await refreshTaskProgress(importTaskId); } catch { /* poll will retry */ } } else { try { await refreshTasks(); } catch { /* ignore */ } } } catch (e) { setTaskCollecting(importTaskId, false); showError(formatErr(e)); } finally { setImportBusy(false); } }; const stopCollectForTask = async (id: string) => { setBusy(true); try { const out = await bizStateCollectStop(id); if (out.stopped) { showOk(t("bizState.stopCollectOk")); } else { showOk(t("bizState.stopCollectIdle")); } setTaskCollecting(id, false); try { await refreshTasks(); } catch { /* ignore */ } if (taskId === id) { try { await refreshTaskProgress(id); } catch { /* ignore */ } } } catch (e) { showError(formatErr(e)); } finally { setBusy(false); } }; const exportTaskCommands = async () => { if (!taskId) return; setBusy(true); try { await bizStateDownloadTaskCommands(taskId); showOk(t("bizState.exportCommandsOk")); } catch (e) { showError(formatErr(e)); } finally { setBusy(false); } }; const removeTask = async (id: string) => { if (!window.confirm(t("bizState.confirmDelete"))) return; setBusy(true); try { await bizStateDeleteTask(id); showOk(t("bizState.deleted")); if (taskId === id) closeTask(); invalidateCutoverCache("bizCompare:"); await refreshTasks(); } catch (e) { showError(formatErr(e)); } finally { setBusy(false); } }; const toggleProfileItem = async (profile: Profile, enable: boolean) => { if (!taskId || !detail) return; const items = [...(detail.items || [])]; const idx = items.findIndex((it: any) => it.source_profile_id === profile.profile_id); if (enable) { if (idx >= 0) items[idx] = { ...items[idx], enabled: true }; else { items.push({ source_profile_id: profile.profile_id, kind: "catalog", enabled: true, title: profile.title, bindings: [], }); } } else if (idx >= 0) { items[idx] = { ...items[idx], enabled: false }; } setBusy(true); try { await bizStatePatchTask(taskId, { items }); await loadTask(taskId); } catch (e) { showError(formatErr(e)); } finally { setBusy(false); } }; const setAllProfilesEnabled = async (enable: boolean) => { if (!taskId || !detail || !collectProfiles.length) return; const items = [...(detail.items || [])] as any[]; const byId = new Map(items.map((it, i) => [String(it.source_profile_id || ""), i])); for (const prof of collectProfiles) { const idx = byId.get(prof.profile_id); if (idx != null) { items[idx] = { ...items[idx], enabled: enable }; } else if (enable) { items.push({ source_profile_id: prof.profile_id, kind: "catalog", enabled: true, title: prof.title, bindings: [], }); } } setBusy(true); try { await bizStatePatchTask(taskId, { items }); await loadTask(taskId); showOk( enable ? t("bizState.profilesSelectAllOk", { n: collectProfiles.length }) : t("bizState.profilesDeselectAllOk"), ); } catch (e) { showError(formatErr(e)); } finally { setBusy(false); } }; const closeBindModal = () => { discoverRequestRef.current += 1; setBindItemId(""); setCandidates([]); setSelectedVrfs([]); setDiscoverCmd(""); setDiscoverError(""); setDiscoverCacheHit(false); setDiscoverLoading(false); }; const startDiscover = async (item: any, forceRefresh = false) => { if (!taskId) return; const request = ++discoverRequestRef.current; const prof = profiles.find((p) => p.profile_id === item.source_profile_id); const phs = prof?.placeholders || []; const ph = phs[0]; if (!ph) { showError(t("bizState.noNeedBind")); return; } setBindItemId(item.id); setCandidates([]); setSelectedVrfs([]); setDiscoverCmd(""); setDiscoverError(""); setDiscoverCacheHit(false); setDiscoverLoading(true); setBusy(true); try { // Shared discover_profile_id → omit placeholder so API returns pair candidates. const sharedDisc = phs.length > 1 && phs.every((p) => p.discover_profile_id === ph.discover_profile_id); const res = await bizStateDiscover({ task_id: taskId, collect_profile_id: item.source_profile_id, placeholder: sharedDisc ? "" : ph.name, force_refresh: forceRefresh, }); if (request !== discoverRequestRef.current) return; if (!res.ok) { const err = res.error || t("bizState.discoverFailed"); setDiscoverError(err); showError(err); return; } setDiscoverCmd(res.command || ""); setDiscoverCacheHit(Boolean(res.cache_hit)); const cand = (res.candidates || []) as Candidate[]; setCandidates(cand); const existing = (item.bindings || []) as { placeholder?: string; value?: string }[]; let keep: string[] = []; if (res.pair_mode || sharedDisc) { const phNames = phs.map((p) => p.name); const byPh: Record = {}; for (const b of existing) { const name = String(b.placeholder || ""); const val = String(b.value || ""); if (!name || !val) continue; (byPh[name] ||= []).push(val); } const counts = phNames.map((n) => (byPh[n] || []).length); const n = counts.length ? Math.min(...counts) : 0; const existingKeys = new Set(); for (let i = 0; i < n; i++) { const parts = phNames .slice() .sort() .map((name) => `${name}=${(byPh[name] || [])[i] || ""}`); existingKeys.add(parts.join("|")); } keep = cand.filter((c) => existingKeys.has(c.value)).map((c) => c.value); } else { const vals = existing .filter((b) => b.placeholder === ph.name) .map((b) => String(b.value)); keep = vals.filter((v) => cand.some((c) => c.value === v)); } setSelectedVrfs(keep); if (!cand.length) { setDiscoverError(t("bizState.discoverEmpty")); } } catch (e) { if (request !== discoverRequestRef.current) return; const err = formatErr(e); setDiscoverError(err); showError(err); } finally { if (request === discoverRequestRef.current) setDiscoverLoading(false); setBusy(false); } }; const saveBindings = async (values?: string[]) => { if (!taskId || !bindItemId) return; const item = (detail?.items || []).find((it: any) => it.id === bindItemId); const prof = profiles.find((p) => p.profile_id === item?.source_profile_id); const phName = (prof?.placeholders || [])[0]?.name || "vrf"; const picked = values !== undefined ? values : selectedVrfs; if (!picked.length) return; const candByVal = new Map(candidates.map((c) => [c.value, c])); const rows: { placeholder: string; value: string }[] = []; for (const v of picked) { const c = candByVal.get(v); const binds = c?.bindings; if (binds && Object.keys(binds).length) { for (const [k, val] of Object.entries(binds)) { if (k && val) rows.push({ placeholder: k, value: String(val) }); } } else { rows.push({ placeholder: phName, value: v }); } } setBusy(true); try { await bizStateSetBindings(taskId, bindItemId, rows); showOk(t("bizState.bindingsSaved")); await loadTask(taskId); closeBindModal(); } catch (e) { showError(formatErr(e)); } finally { setBusy(false); } }; const openBatch = async (batchId: string) => { const request = ++batchRequestRef.current; sheetRequestRef.current += 1; setBatchTargetId(batchId); setBatchDetail(null); setBatchLoading(true); setBatchError(""); try { const d = await bizStateGetBatch(batchId); if (request !== batchRequestRef.current) return; setBatchDetail(d); setSheetKeyword(""); setSheetColumn(""); setSheetPage(1); setSheetRows([]); setSheetColumns([]); setSheetTotal(0); const built = buildSheetTabs(d, t); const prefer = built.find((s) => s.id !== "commands" && s.rowCount > 0) || built.find((s) => s.id !== "commands") || built[0]; setSheetId(prefer?.id || ""); } catch (e) { if (request === batchRequestRef.current) setBatchError(formatErr(e)); } finally { if (request === batchRequestRef.current) setBatchLoading(false); } }; const closeBatch = () => { batchRequestRef.current += 1; sheetRequestRef.current += 1; setBatchTargetId(""); setBatchLoading(false); setBatchError(""); setSheetLoading(false); setSheetError(""); setBatchDetail(null); setSheetId(""); setSheetKeyword(""); setSheetColumn(""); setSheetPage(1); setSheetRows([]); setSheetColumns([]); setSheetTotal(0); closeRawLog(); setRawLogText(""); setRawLogCommandId(""); }; const openCollectDetail = async (batchId: string) => { const request = ++collectRequestRef.current; setCollectDetailLoading(true); setCollectDetail(null); try { const d = await bizStateGetBatch(batchId); if (request !== collectRequestRef.current) return; setCollectDetail(d); } catch (e) { if (request === collectRequestRef.current) showError(formatErr(e)); } finally { if (request === collectRequestRef.current) setCollectDetailLoading(false); } }; const closeCollectDetail = () => { collectRequestRef.current += 1; setCollectDetail(null); setCollectDetailLoading(false); }; const openRawLog = async (commandId: string, fromBatchId?: string) => { const bid = fromBatchId || (batchDetail?.id ? String(batchDetail.id) : "") || (collectDetail?.id ? String(collectDetail.id) : ""); if (!bid || !commandId) return; const request = ++rawRequestRef.current; setRawLogBatchId(bid); setRawLogOpen(true); setRawLogLoading(true); setRawLogText(""); setRawLogCmd(""); setRawLogMeta(""); setRawLogCommandId(commandId); setRawLogLines(0); setRawLogRows(0); setRawLogDeclared(0); setRawLogMessage(""); try { const d = await bizStateGetBatchCommand(bid, commandId); if (request !== rawRequestRef.current) return; setRawLogCmd(String(d.raw_command || "")); setRawLogText(String(d.raw_text || "")); const lines = Number(d.raw_line_count ?? 0); const rows = Number(d.row_count ?? 0); const declared = Number(d.declared_total ?? 0); setRawLogLines(lines); setRawLogRows(rows); setRawLogDeclared(declared); setRawLogMessage(String(d.message || "")); const bits = [ d.parse_status, d.metric_id, formatCmdCollectStats(t, lines, rows, declared), d.collected_at ? fmtTime(d.collected_at) : "", ].filter(Boolean); setRawLogMeta(bits.join(" · ")); } catch (e) { if (request !== rawRequestRef.current) return; showError(formatErr(e)); closeRawLog(); } finally { if (request === rawRequestRef.current) setRawLogLoading(false); } }; const closeRawLog = () => { rawRequestRef.current += 1; setRawLogOpen(false); setRawLogLoading(false); setRawLogBatchId(""); setRawLogCommandId(""); }; const exportRawLog = async () => { const bid = rawLogBatchId; if (!bid || !rawLogCommandId) { // Fallback: download already-loaded text if (!rawLogText) return; const safe = (rawLogCmd || "command").replace(/[^\w.-]+/g, "_").slice(0, 80); const blob = new Blob([rawLogText], { type: "text/plain;charset=utf-8" }); const url = URL.createObjectURL(blob); try { const a = document.createElement("a"); a.href = url; a.download = `${safe || "command"}.txt`; a.click(); } finally { URL.revokeObjectURL(url); } return; } try { await bizStateDownloadCommandRaw(bid, rawLogCommandId); showOk(t("bizState.exportRawLogOk")); } catch (e) { showError(formatErr(e)); } }; const selectSheet = (id: string) => { sheetRequestRef.current += 1; setSheetLoading(false); setSheetError(""); setSheetId(id); setSheetKeyword(""); setSheetColumn(""); setSheetPage(1); setSheetRows([]); setSheetColumns([]); setSheetTotal(0); }; const runningCount = tasks.filter((x) => x.status === "running").length; const taskPages = pageCount(filteredTasks.length, taskPageSize); const visibleTaskPage = Math.min(taskPage, taskPages); const visibleTasks = filteredTasks.slice((visibleTaskPage - 1) * taskPageSize, visibleTaskPage * taskPageSize); const collectingCount = tasks.filter((x) => x.collect_running || collectingIds[x.id]).length; const taskNotReady = busy || taskLoading || !detail || Boolean(taskError); const batchStatusLabel = (status: string) => { const labels: Record = { success: "bizState.batchSuccess", running: "bizState.statusCollecting", queued: "bizState.statusQueued", partial: "bizState.batchPartial", failed: "bizState.batchFailed", cancelled: "bizState.batchCancelled", }; return labels[status] ? t(labels[status]) : status || "—"; }; const handleSheetTabKey = (event: ReactKeyboardEvent) => { const current = sheetTabs.findIndex((row) => row.id === activeMetricId); const next = event.key === "ArrowRight" ? (current + 1) % sheetTabs.length : event.key === "ArrowLeft" ? (current + sheetTabs.length - 1) % sheetTabs.length : event.key === "Home" ? 0 : event.key === "End" ? sheetTabs.length - 1 : -1; if (next < 0 || !sheetTabs.length) return; event.preventDefault(); selectSheet(sheetTabs[next].id); event.currentTarget.querySelectorAll('[role="tab"]')[next]?.focus(); }; return (

{t("bizState.title")}

{t("bizState.loadedTasks")}
{tasks.length}
{t("bizState.scheduleOn")}
{runningCount}
{t("bizState.statusCollecting")}
{collectingCount}
{t("bizState.collectErrors")}
{tasks.filter((row) => row.last_error).length}
{ setListKeyword(e.target.value); setTaskPage(1); }} /> { const value = e.target.value as "all" | "portrait" | "cutover_hf"; if (value === purposeFilter) return; purposeRef.current = value; setTasksLoading(true); setTasksError(""); setTasks([]); setTaskPage(1); setPurposeFilter(value); }} aria-label={t("bizState.purposeFilter")} >
{tasksError ?
{t("bizState.tasksLoadError")} · {tasksError}
: null}
{visibleTasks.map((row) => ( ))} {!filteredTasks.length && !tasksError ? ( ) : null}
{t("bizState.colNe")} {t("bizState.colSource")} {t("bizState.colPurpose")} {t("bizState.colInterval")} {t("bizState.scheduleEnabled")} {t("bizState.colStatus")} {t("bizState.colStarted")} {t("bizState.colEnded")} {t("bizState.colActions")}
{row.ne_name || row.ne_ip || "—"}
{row.vendor || "—"} · {row.ne_ip || "—"} {row.note ? ` · ${row.note}` : ""}
{row.last_error ?
{row.last_error}
: null}
{row.source === "import" ? t("bizState.sourceImport") : row.source || "managed"} {row.purpose === "cutover_hf" ? t("bizState.purposeCutoverHf") : t("bizState.purposePortrait")} {fmtIntervalLabel( Number(row.interval_sec || 3600), t("bizState.intervalUnitDays"), t("bizState.intervalUnitHours"), t("bizState.intervalUnitSeconds"), )}
{row.collect_running || collectingIds[row.id] ? ( {t("bizState.statusCollecting")} ) : row.status === "running" ? ( {t("bizState.scheduleOn")} ) : row.status === "paused" ? ( {t("bizState.statusPaused")} ) : ( {t("bizState.scheduleOff")} )}
{fmtTime(row.last_collect_started_at)} {fmtTime(row.last_collect_ended_at)} {fmtDuration(row.last_collect_started_at, row.last_collect_ended_at) ? (
{fmtDuration(row.last_collect_started_at, row.last_collect_ended_at)}
) : null}
{row.source !== "import" ? ( row.status === "running" ? ( ) : ( ) ) : null} {row.source !== "import" ? ( ) : null} {row.collect_running || collectingIds[row.id] ? ( ) : null}
{tasksLoading ? t("bizState.sheetLoading") : t("bizState.empty")}
{ setTaskPageSize(size); setTaskPage(1); }} />
{/* Create task: pick NE or offline import (unified entry) */} {t("bizState.create")}
{createMode === "device" ? ( <>

{t("bizState.createHint")}

{ setNeKeyword(e.target.value); setNePage(1); }} /> { setNeSource(e.target.value as NeSourceFilter); setNePage(1); }} aria-label={t("bizState.colSource")} > {selectedNe ? ( {t("bizState.selectedNe")}: {selectedNe.name} ({selectedNe.ip_address}) ·{" "} {neSourceOf(selectedNe)} ) : null}
{neItems.map((row) => { const checked = selectedNe?.id === row.id && neSourceOf(selectedNe) === neSourceOf(row); return ( ); })} {!neItems.length ? ( ) : null}
{t("bizState.colSource")} {t("bizState.colNe")} IP {t("bizState.colVendor")} {t("bizState.colConnect")}
setSelectedNe(row)} /> {row.source} {row.name || "—"} {row.ip_address || "—"} {row.vendor || "—"} {row.connect_status || "—"}
{neLoading ? t("common.refreshing") : t("bizState.neEmpty")}
) : ( <>

{t("bizState.createImportHint")}

setImportVendorKey(e.target.value)} fullWidth > )}
{createMode === "device" ? ( ) : ( )}
{/* Task detail modal */} TASK / {taskId.slice(0, 12)} {detail?.ne_name || detail?.ne_ip || t("bizState.detail")} {taskLoading ?
{t("bizState.taskLoading")}
: null} {taskError ?
{t("bizState.taskLoadError")} · {taskError}
: null}
{detail?.source !== "import" ? ( ) : null} {detail?.collect_running || (taskId && collectingIds[taskId]) ? ( ) : null}
{/* Append batch via log import on an existing task */} { if (!importBusy) setImportOpen(false); }} dismissible={!importBusy} size="md" > {t("bizState.importLog")}

{t("bizState.importLogHint")}

setImportVendorKey(e.target.value)} fullWidth >
{/* Bound params detail — double-click params cell */} setParamsDetail(null)} dismissible size="md" className="bs-bind-modal" > {t("bizState.paramsDetailTitle")} {paramsDetail?.title ? ` · ${paramsDetail.title}` : ""} {paramsDetail?.lines?.length ? ( <>

{t("bizState.paramsDetailCount", { n: paramsDetail.lines.length })}

{paramsDetail.lines.map((line, idx) => (
{line}
))}
) : (

{t("bizState.unbound")}

)}
{/* VRF discover / bind — blocking modal above task dialog */} {t("bizState.bindTitle")} {discoverLoading ? (

{t("bizState.discoverLoading")}

) : null} {discoverCmd ? (

{discoverCmd} {candidates.length ? ` · ${selectedVrfs.length}/${candidates.length}` : null} {discoverCacheHit ? ` · ${t("bizState.discoverCacheHit")}` : null}

) : null} {discoverError ?

{discoverError}

: null} {!discoverLoading && candidates.length ? ( <>
{bindItemId ? ( ) : null}
{candidates.map((c) => ( ))}
) : null}
{/* Batch workbook: summary + lazy-paged metric sheets */} {t("bizState.batchWorkbook")} {batchLoading ?
{t("bizState.sheetLoading")}
: null} {batchError ?
{t("bizState.batchLoadError")} · {batchError}
: null} {batchDetail ? (
{batchStatusLabel(String(batchDetail.status || ""))} {batchDetail.alias ? ( {String(batchDetail.alias)} ) : null} {t("bizState.workbookStats", { compare: Number(batchDetail.compare_item_count ?? batchDetail.sheet_count ?? 0), withData: Number(batchDetail.sheets_with_data ?? 0), sheets: Number(batchDetail.sheet_count ?? 0), })} {" · "} {batchDetail.command_count} cmd · {batchDetail.row_count} rows ·{" "} {t("bizState.colStarted")} {fmtTime(batchDetail.started_at)} ·{" "} {t("bizState.colEnded")} {fmtTime(batchDetail.ended_at)} {fmtDuration(batchDetail.started_at, batchDetail.ended_at) ? ` · ${fmtDuration(batchDetail.started_at, batchDetail.ended_at)}` : ""}
{t("bizState.batchId")} {String(batchDetail.id || "")}
{batchDetail.message ? (
{t("bizState.colMessage")}
{String(batchDetail.message)}
) : null}
) : null}
{sheetTabs.map((s) => ( ))} {!sheetTabs.length ? {t("bizState.sheetEmpty")} : null}
{activeSheet ? ( <> {activeSheet.id !== "commands" && activeSheet.commands.length ? (
{t("bizState.collectCommand")}
{activeSheet.commands.map((c) => (
{c.raw_command || "—"}
{formatCmdCollectStats( t, Number(c.raw_line_count ?? 0), Number(c.row_count ?? 0), Number(c.declared_total ?? 0), )} {c.parse_status ? ( {c.parse_status} ) : null}
))}
) : null} {activeSheet.id === "commands" ? (
{displayRows.map((row, i) => (
{cellText(row.raw_command) || "—"}
{formatCmdCollectStats( t, Number(row.raw_line_count ?? 0), Number(row.row_count ?? 0), Number(row.declared_total ?? 0), )} {cellText(row.parse_status) ? ( {cellText(row.parse_status)} ) : null} {cellText(row.metric_id) ? ( {cellText(row.metric_id)} ) : null}
{cellText(row.message) ? (
{cellText(row.message)}
) : null}
))} {!displayRows.length ? (
{t("bizState.sheetEmpty")}
) : null}
) : null} {activeSheet.id !== "commands" ? (
{ setSheetColumn(e.target.value); setSheetPage(1); }} aria-label={t("bizState.filterColumn")} > {displayColumns .filter((c) => c.key !== "_actions" && c.key !== "_empty") .map((c) => ( ))} { setSheetKeyword(e.target.value); setSheetPage(1); }} /> {sheetLoading ? t("bizState.sheetLoading") : `${displayTotal} ${t("bizState.colRows")}`}
) : (
{ setSheetKeyword(e.target.value); setSheetPage(1); }} /> {`${displayTotal} ${t("bizState.colRows")}`}
)} {activeSheet.id !== "commands" ? (
{displayColumns.map((c) => ( ))} {displayRows.map((row, i) => ( {displayColumns.map((c) => ( ))} ))} {!displayRows.length && !sheetLoading && !sheetError ? ( ) : null} {sheetLoading && !displayRows.length ? ( ) : null}
{c.header}
{c.key === "_actions" ? (
) : c.key === "parse_status" ? ( {cellText(row[c.key]) || "—"} ) : c.key === "raw_command" ? ( {cellText(row[c.key]) || "—"} ) : ( cellText(row[c.key]) || "—" )}
{t("bizState.sheetEmpty")}
{t("bizState.sheetLoading")}
) : null} {sheetError ?
{t("bizState.sheetLoadError")} · {sheetError}
: null} { setSheetPageSize(n); setSheetPage(1); }} /> ) : ( !batchLoading && !batchError ?
{t("bizState.sheetEmpty")}
: null )}
{batchDetail ? ( ) : null}
{/* Raw CLI log viewer */} {t("bizState.rawLogTitle")} {rawLogCmd ? {rawLogCmd} : null}
{formatCmdCollectStats(t, rawLogLines, rawLogRows, rawLogDeclared)} {rawLogMeta ? {rawLogMeta} : null}
{rawLogMessage ? (
{t("bizState.colMessage")}
{rawLogMessage}
) : null} {rawLogLoading ? (
{t("bizState.sheetLoading")}
) : (
{rawLogText || t("bizState.rawLogEmpty")}
)}
{/* Batch collect status / errors detail */} {t("bizState.batchCollectDetail")} {collectDetailLoading ? (
{t("bizState.sheetLoading")}
) : collectDetail ? ( <>
{String(collectDetail.status || "—")} {collectDetail.alias ? ( {String(collectDetail.alias)} ) : null} {collectDetail.command_count} cmd · {collectDetail.row_count} rows ·{" "} {t("bizState.colStarted")} {fmtTime(collectDetail.started_at)} ·{" "} {t("bizState.colEnded")} {fmtTime(collectDetail.ended_at)} {fmtDuration(collectDetail.started_at, collectDetail.ended_at) ? ` · ${fmtDuration(collectDetail.started_at, collectDetail.ended_at)}` : ""}
{t("bizState.colMessage")}
                  {String(collectDetail.message || "").trim() || t("bizState.batchMessageEmpty")}
                

{t("bizState.batchCmdSummary", { ok: collectDetailCmdSummary.ok, fail: collectDetailCmdSummary.fail, skipped: collectDetailCmdSummary.skipped, other: collectDetailCmdSummary.other, total: collectDetailCmdSummary.total, batchCmds: Number(collectDetail.command_count ?? collectDetailCmdSummary.total), })}

{((collectDetail.commands || []) as SheetCmd[]).map((c) => ( ))} {!((collectDetail.commands || []) as SheetCmd[]).length ? ( ) : null}
{t("bizState.colCommand")} {t("bizState.colStatus")} {t("bizState.colRawLines")} {t("bizState.colRows")} {t("bizState.colDeclared")} {t("bizState.colMessage")} {t("bizState.colActions")}
{c.raw_command || "—"} {c.parse_status || "—"} {Number(c.raw_line_count ?? 0)} {Number(c.row_count ?? 0)} {Number(c.declared_total ?? 0) > 0 ? Number(c.declared_total) : "—"} {c.message?.trim() ? c.message : "—"}
{t("bizState.sheetEmpty")}
) : null}
{collectDetail?.id ? ( <> ) : null}
); }