mirror of
https://github.com/hansjone/netx.git
synced 2026-10-12 19:03:17 +08:00
3335 lines
129 KiB
TypeScript
3335 lines
129 KiB
TypeScript
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<string, string>;
|
|
};
|
|
|
|
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, string | number>) => 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<string, unknown>[]): SheetCol[] {
|
|
const keys = new Set<string>();
|
|
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<TaskRow[]>([]);
|
|
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<Record<string, true>>({});
|
|
/** Track that we observed collect_running=true so we don't clear the chip before enqueue lands. */
|
|
const seenCollectRunningRef = useRef<Record<string, boolean>>({});
|
|
const collectStartedAtRef = useRef<Record<string, number>>({});
|
|
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<NeSourceFilter>("all");
|
|
const [nePage, setNePage] = useState(1);
|
|
const [neTotal, setNeTotal] = useState(0);
|
|
const [neItems, setNeItems] = useState<CliTargetItem[]>([]);
|
|
const [neLoading, setNeLoading] = useState(false);
|
|
const [selectedNe, setSelectedNe] = useState<CliTargetItem | null>(null);
|
|
|
|
// task modal
|
|
const [taskId, setTaskId] = useState("");
|
|
const [detail, setDetail] = useState<any>(null);
|
|
const [taskLoading, setTaskLoading] = useState(false);
|
|
const [taskError, setTaskError] = useState("");
|
|
const [profiles, setProfiles] = useState<Profile[]>([]);
|
|
const [batches, setBatches] = useState<BatchRow[]>([]);
|
|
const [taskTab, setTaskTab] = useState<TaskTab>("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<string[]>([]);
|
|
|
|
// VRF bind modal (blocking)
|
|
const [bindItemId, setBindItemId] = useState("");
|
|
const [candidates, setCandidates] = useState<Candidate[]>([]);
|
|
const [selectedVrfs, setSelectedVrfs] = useState<string[]>([]);
|
|
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<any>(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<Record<string, unknown>[]>([]);
|
|
const [sheetColumns, setSheetColumns] = useState<SheetCol[]>([]);
|
|
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<File | null>(null);
|
|
const [importBusy, setImportBusy] = useState(false);
|
|
const importFileRef = useRef<HTMLInputElement | null>(null);
|
|
const createImportFileRef = useRef<HTMLInputElement | null>(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<any>(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<ReturnType<typeof bizStateListTasks>>) => {
|
|
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<SheetCol[]>(
|
|
() => [
|
|
{ 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<string, unknown>[];
|
|
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<string, string[]> = {};
|
|
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<string>();
|
|
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<string, string> = {
|
|
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<HTMLDivElement>) => {
|
|
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<HTMLButtonElement>('[role="tab"]')[next]?.focus();
|
|
};
|
|
|
|
return (
|
|
<section className="panel nm-page-panel bs-monitor-workspace">
|
|
<div className="panel__toolbar">
|
|
<h2>{t("bizState.title")}</h2>
|
|
<div className="btn-row">
|
|
<Button size="sm" variant="primary" onPress={openCreate}>
|
|
{t("bizState.create")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="pt-list">
|
|
<div className="pt-list-kpis">
|
|
<div className="pt-list-kpi">
|
|
<div className="pt-list-kpi__label">{t("bizState.loadedTasks")}</div>
|
|
<div className="pt-list-kpi__value">{tasks.length}</div>
|
|
</div>
|
|
<div className="pt-list-kpi pt-list-kpi--live">
|
|
<div className="pt-list-kpi__label">{t("bizState.scheduleOn")}</div>
|
|
<div className="pt-list-kpi__value">{runningCount}</div>
|
|
</div>
|
|
<div className="pt-list-kpi">
|
|
<div className="pt-list-kpi__label">{t("bizState.statusCollecting")}</div>
|
|
<div className="pt-list-kpi__value">{collectingCount}</div>
|
|
</div>
|
|
<div className="pt-list-kpi">
|
|
<div className="pt-list-kpi__label">{t("bizState.collectErrors")}</div>
|
|
<div className="pt-list-kpi__value">{tasks.filter((row) => row.last_error).length}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="filter-inline">
|
|
<Input
|
|
value={listKeyword}
|
|
aria-label={t("bizState.listFilterPh")}
|
|
placeholder={t("bizState.listFilterPh")}
|
|
onChange={(e) => { setListKeyword(e.target.value); setTaskPage(1); }}
|
|
/>
|
|
<FieldSelect
|
|
value={purposeFilter}
|
|
onChange={(e) => {
|
|
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")}
|
|
>
|
|
<option value="all">{t("bizState.purposeAll")}</option>
|
|
<option value="portrait">{t("bizState.purposePortrait")}</option>
|
|
<option value="cutover_hf">{t("bizState.purposeCutoverHf")}</option>
|
|
</FieldSelect>
|
|
<Button size="sm" variant="secondary" isDisabled={tasksLoading} isPending={tasksLoading} onPress={() => void reloadTasks()}>{t("common.refresh")}</Button>
|
|
</div>
|
|
|
|
{tasksError ? <div className="bs-monitor-error" role="alert"><span>{t("bizState.tasksLoadError")} · {tasksError}</span><Button size="sm" variant="secondary" onPress={() => void reloadTasks()}>{t("bizState.retry")}</Button></div> : null}
|
|
|
|
<div className="pt-list-table-wrap">
|
|
<table className="data-table pt-list-table">
|
|
<thead>
|
|
<tr>
|
|
<th>{t("bizState.colNe")}</th>
|
|
<th>{t("bizState.colSource")}</th>
|
|
<th>{t("bizState.colPurpose")}</th>
|
|
<th>{t("bizState.colInterval")}</th>
|
|
<th>{t("bizState.scheduleEnabled")}</th>
|
|
<th>{t("bizState.colStatus")}</th>
|
|
<th>{t("bizState.colStarted")}</th>
|
|
<th>{t("bizState.colEnded")}</th>
|
|
<th>{t("bizState.colActions")}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{visibleTasks.map((row) => (
|
|
<tr key={row.id}>
|
|
<td>
|
|
<div className="pt-list-task-name">{row.ne_name || row.ne_ip || "—"}</div>
|
|
<div className="muted">
|
|
{row.vendor || "—"} · {row.ne_ip || "—"}
|
|
{row.note ? ` · ${row.note}` : ""}
|
|
</div>
|
|
{row.last_error ? <div className="form-error">{row.last_error}</div> : null}
|
|
</td>
|
|
<td>
|
|
<NmStatusChip color={sourceChipColor(row.source)}>
|
|
{row.source === "import"
|
|
? t("bizState.sourceImport")
|
|
: row.source || "managed"}
|
|
</NmStatusChip>
|
|
</td>
|
|
<td>
|
|
<NmStatusChip
|
|
color={row.purpose === "cutover_hf" ? "accent" : "default"}
|
|
>
|
|
{row.purpose === "cutover_hf"
|
|
? t("bizState.purposeCutoverHf")
|
|
: t("bizState.purposePortrait")}
|
|
</NmStatusChip>
|
|
</td>
|
|
<td className="pt-list-num">
|
|
{fmtIntervalLabel(
|
|
Number(row.interval_sec || 3600),
|
|
t("bizState.intervalUnitDays"),
|
|
t("bizState.intervalUnitHours"),
|
|
t("bizState.intervalUnitSeconds"),
|
|
)}
|
|
</td>
|
|
<td>
|
|
<label className="config-sync-policy-check">
|
|
<input
|
|
type="checkbox"
|
|
checked={row.status === "running"}
|
|
disabled={busy || row.source === "import"}
|
|
onChange={(e) => void setScheduleEnabled(row.id, e.target.checked)}
|
|
/>
|
|
<span>
|
|
{row.source === "import"
|
|
? t("bizState.scheduleOff")
|
|
: row.status === "running"
|
|
? t("bizState.scheduleOn")
|
|
: t("bizState.scheduleOff")}
|
|
</span>
|
|
</label>
|
|
</td>
|
|
<td>
|
|
<div className="pt-list-actions" style={{ flexWrap: "wrap", gap: 4 }}>
|
|
{row.collect_running || collectingIds[row.id] ? (
|
|
<NmStatusChip color="accent">{t("bizState.statusCollecting")}</NmStatusChip>
|
|
) : row.status === "running" ? (
|
|
<NmStatusChip color="success">{t("bizState.scheduleOn")}</NmStatusChip>
|
|
) : row.status === "paused" ? (
|
|
<NmStatusChip color="warning">{t("bizState.statusPaused")}</NmStatusChip>
|
|
) : (
|
|
<NmStatusChip color="default">{t("bizState.scheduleOff")}</NmStatusChip>
|
|
)}
|
|
</div>
|
|
</td>
|
|
<td className="pt-list-time">{fmtTime(row.last_collect_started_at)}</td>
|
|
<td className="pt-list-time">
|
|
{fmtTime(row.last_collect_ended_at)}
|
|
{fmtDuration(row.last_collect_started_at, row.last_collect_ended_at) ? (
|
|
<div className="muted">
|
|
{fmtDuration(row.last_collect_started_at, row.last_collect_ended_at)}
|
|
</div>
|
|
) : null}
|
|
</td>
|
|
<td>
|
|
<div className="pt-list-actions">
|
|
<Button size="sm" variant="primary" onPress={() => void openTask(row.id, "profiles")}>
|
|
{t("bizState.detail")}
|
|
</Button>
|
|
<Button size="sm" variant="ghost" onPress={() => void openTask(row.id, "batches")}>
|
|
{t("bizState.batches")}
|
|
</Button>
|
|
{row.source !== "import" ? (
|
|
row.status === "running" ? (
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
isDisabled={busy}
|
|
onPress={() => void setScheduleEnabled(row.id, false)}
|
|
>
|
|
{t("bizState.pause")}
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
isDisabled={busy}
|
|
onPress={() => void setScheduleEnabled(row.id, true)}
|
|
>
|
|
{t("bizState.start")}
|
|
</Button>
|
|
)
|
|
) : null}
|
|
{row.source !== "import" ? (
|
|
<Button
|
|
size="sm"
|
|
variant="secondary"
|
|
isDisabled={Boolean(row.collect_running || collectingIds[row.id])}
|
|
onPress={() => void collectNowForTask(row.id, false)}
|
|
>
|
|
{t("bizState.collectNow")}
|
|
</Button>
|
|
) : null}
|
|
<Button
|
|
size="sm"
|
|
variant="secondary"
|
|
isDisabled={Boolean(row.collect_running || collectingIds[row.id])}
|
|
onPress={() =>
|
|
openImportLog(row.id, String(row.vendor || ""), "")
|
|
}
|
|
>
|
|
{t("bizState.importLog")}
|
|
</Button>
|
|
{row.collect_running || collectingIds[row.id] ? (
|
|
<Button
|
|
size="sm"
|
|
variant="danger"
|
|
isDisabled={busy}
|
|
onPress={() => void stopCollectForTask(row.id)}
|
|
>
|
|
{t("bizState.stopCollect")}
|
|
</Button>
|
|
) : null}
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
className="bs-monitor-delete"
|
|
isDisabled={busy || Boolean(row.collect_running || collectingIds[row.id])}
|
|
onPress={() => void removeTask(row.id)}
|
|
>
|
|
{t("bizState.delete")}
|
|
</Button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
{!filteredTasks.length && !tasksError ? (
|
|
<tr>
|
|
<td colSpan={9}>
|
|
<div className="pt-list-empty" role="status">{tasksLoading ? t("bizState.sheetLoading") : t("bizState.empty")}</div>
|
|
</td>
|
|
</tr>
|
|
) : null}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<ListPager page={visibleTaskPage} pages={taskPages} total={filteredTasks.length} pageSize={taskPageSize}
|
|
onPageChange={setTaskPage} pageSizeOptions={SHEET_PAGE_SIZE_OPTIONS} disabled={tasksLoading}
|
|
onPageSizeChange={(size) => { setTaskPageSize(size); setTaskPage(1); }} />
|
|
</div>
|
|
|
|
{/* Create task: pick NE or offline import (unified entry) */}
|
|
<AppModalShell open={createOpen} onClose={closeCreate} size="lg" className="bs-monitor-modal">
|
|
<Modal.Header>
|
|
<Modal.Heading>{t("bizState.create")}</Modal.Heading>
|
|
<Modal.CloseTrigger />
|
|
</Modal.Header>
|
|
<Modal.Body className="flex flex-col gap-3">
|
|
<div className="btn-row nm-config-modal__tabs" role="tablist">
|
|
<Button
|
|
size="sm"
|
|
variant={createMode === "device" ? "primary" : "secondary"}
|
|
className={createMode === "device" ? "is-active" : undefined}
|
|
aria-selected={createMode === "device"}
|
|
onPress={() => setCreateMode("device")}
|
|
>
|
|
{t("bizState.createModeDevice")}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant={createMode === "import" ? "primary" : "secondary"}
|
|
className={createMode === "import" ? "is-active" : undefined}
|
|
aria-selected={createMode === "import"}
|
|
onPress={() => setCreateMode("import")}
|
|
>
|
|
{t("bizState.createModeImport")}
|
|
</Button>
|
|
</div>
|
|
{createMode === "device" ? (
|
|
<>
|
|
<p className="muted">{t("bizState.createHint")}</p>
|
|
<div className="filter-inline">
|
|
<Input
|
|
value={neKeyword}
|
|
placeholder={t("bizState.neKeywordPh")}
|
|
onChange={(e) => {
|
|
setNeKeyword(e.target.value);
|
|
setNePage(1);
|
|
}}
|
|
/>
|
|
<FieldSelect
|
|
value={neSource}
|
|
onChange={(e) => {
|
|
setNeSource(e.target.value as NeSourceFilter);
|
|
setNePage(1);
|
|
}}
|
|
aria-label={t("bizState.colSource")}
|
|
>
|
|
<option value="all">{t("bizState.allSource")}</option>
|
|
<option value="managed">managed</option>
|
|
<option value="ume">ume</option>
|
|
</FieldSelect>
|
|
{selectedNe ? (
|
|
<span className="muted">
|
|
{t("bizState.selectedNe")}: {selectedNe.name} ({selectedNe.ip_address}) ·{" "}
|
|
{neSourceOf(selectedNe)}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
<div className="pt-list-table-wrap">
|
|
<table className="data-table pt-list-table">
|
|
<thead>
|
|
<tr>
|
|
<th />
|
|
<th>{t("bizState.colSource")}</th>
|
|
<th>{t("bizState.colNe")}</th>
|
|
<th>IP</th>
|
|
<th>{t("bizState.colVendor")}</th>
|
|
<th>{t("bizState.colConnect")}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{neItems.map((row) => {
|
|
const checked =
|
|
selectedNe?.id === row.id &&
|
|
neSourceOf(selectedNe) === neSourceOf(row);
|
|
return (
|
|
<tr key={`${row.source}:${row.id}`}>
|
|
<td>
|
|
<input
|
|
type="radio"
|
|
name="bs-ne"
|
|
checked={checked}
|
|
onChange={() => setSelectedNe(row)}
|
|
/>
|
|
</td>
|
|
<td>
|
|
<NmStatusChip color={sourceChipColor(row.source)}>
|
|
{row.source}
|
|
</NmStatusChip>
|
|
</td>
|
|
<td>{row.name || "—"}</td>
|
|
<td>{row.ip_address || "—"}</td>
|
|
<td>{row.vendor || "—"}</td>
|
|
<td>
|
|
<NmStatusChip color={jobChipColor(row.connect_status)}>
|
|
{row.connect_status || "—"}
|
|
</NmStatusChip>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
{!neItems.length ? (
|
|
<tr>
|
|
<td colSpan={6}>
|
|
<div className="pt-list-empty">
|
|
{neLoading ? t("common.refreshing") : t("bizState.neEmpty")}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
) : null}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<ListPager
|
|
page={nePage}
|
|
pages={nePages}
|
|
total={neTotal}
|
|
pageSize={NE_PAGE_SIZE}
|
|
onPageChange={setNePage}
|
|
disabled={neLoading}
|
|
/>
|
|
</>
|
|
) : (
|
|
<>
|
|
<p className="muted text-sm">{t("bizState.createImportHint")}</p>
|
|
<FieldSelect
|
|
label={t("bizState.importVendor")}
|
|
value={importVendorKey}
|
|
onChange={(e) => setImportVendorKey(e.target.value)}
|
|
fullWidth
|
|
>
|
|
<option value="zte">ZTE</option>
|
|
<option value="huawei">Huawei</option>
|
|
<option value="cisco">Cisco</option>
|
|
<option value="generic">{t("bizState.importVendorGeneric")}</option>
|
|
</FieldSelect>
|
|
<label className="ui-field ui-field--full">
|
|
<span className="ui-field__label">{t("bizState.importNeName")}</span>
|
|
<Input
|
|
placeholder={t("bizState.importNeNamePh")}
|
|
value={importNeName}
|
|
onChange={(e) => setImportNeName(e.target.value)}
|
|
/>
|
|
</label>
|
|
<label className="ui-field ui-field--full">
|
|
<span className="ui-field__label">{t("bizState.importFile")}</span>
|
|
<input
|
|
ref={createImportFileRef}
|
|
type="file"
|
|
accept=".txt,.log,.ini,.cfg,.zip,.cli,.out"
|
|
disabled={importBusy || busy}
|
|
onChange={(e) => {
|
|
const f = e.target.files?.[0] || null;
|
|
setImportFile(f);
|
|
}}
|
|
/>
|
|
{importFile ? (
|
|
<span className="ui-field__hint">
|
|
{importFile.name} ({Math.round(importFile.size / 1024)} KB)
|
|
</span>
|
|
) : null}
|
|
</label>
|
|
</>
|
|
)}
|
|
</Modal.Body>
|
|
<Modal.Footer>
|
|
{createMode === "device" ? (
|
|
<Button
|
|
size="sm"
|
|
variant="primary"
|
|
isDisabled={busy || !selectedNe}
|
|
onPress={() => void createTask()}
|
|
>
|
|
{t("bizState.create")}
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
size="sm"
|
|
variant="primary"
|
|
isDisabled={busy || importBusy || !importFile}
|
|
onPress={() => void createImportTask()}
|
|
>
|
|
{importBusy ? t("bizState.importing") : t("bizState.createImportSubmit")}
|
|
</Button>
|
|
)}
|
|
<Button size="sm" variant="ghost" onPress={closeCreate} isDisabled={busy || importBusy}>
|
|
{t("bizState.cancel")}
|
|
</Button>
|
|
</Modal.Footer>
|
|
</AppModalShell>
|
|
|
|
{/* Task detail modal */}
|
|
<AppModalShell open={Boolean(taskId)} onClose={closeTask} size="lg" className="app-heroui-modal--xl bs-monitor-modal bs-monitor-task">
|
|
<Modal.Header>
|
|
<Modal.Heading className="bs-monitor-heading">
|
|
<span className="bs-monitor-id">TASK / {taskId.slice(0, 12)}</span>
|
|
<span>{detail?.ne_name || detail?.ne_ip || t("bizState.detail")}</span>
|
|
</Modal.Heading>
|
|
<Modal.CloseTrigger />
|
|
</Modal.Header>
|
|
<Modal.Body className="flex flex-col gap-3" aria-busy={taskLoading}>
|
|
{taskLoading ? <div className="bs-monitor-loading" role="status">{t("bizState.taskLoading")}</div> : null}
|
|
{taskError ? <div className="bs-monitor-error" role="alert"><span>{t("bizState.taskLoadError")} · {taskError}</span><Button size="sm" variant="secondary" onPress={() => void loadTask(taskId)}>{t("bizState.retry")}</Button></div> : null}
|
|
<div className="bs-monitor-task-content" inert={taskLoading || !detail || Boolean(taskError)} hidden={!detail}>
|
|
{detail ? (
|
|
<div className="config-sync-policy-row bs-schedule-row">
|
|
{detail.collect_running || (taskId && collectingIds[taskId]) ? (
|
|
<NmStatusChip color="accent">{t("bizState.statusCollecting")}</NmStatusChip>
|
|
) : detail.status === "running" ? (
|
|
<NmStatusChip color="success">{t("bizState.scheduleOn")}</NmStatusChip>
|
|
) : detail.status === "paused" ? (
|
|
<NmStatusChip color="warning">{t("bizState.statusPaused")}</NmStatusChip>
|
|
) : (
|
|
<NmStatusChip color="default">{t("bizState.scheduleOff")}</NmStatusChip>
|
|
)}
|
|
<label className="config-sync-policy-check">
|
|
<input
|
|
type="checkbox"
|
|
checked={detail.status === "running"}
|
|
disabled={busy || detail.source === "import"}
|
|
onChange={(e) => void setScheduleEnabled(taskId, e.target.checked)}
|
|
/>
|
|
<span>{t("bizState.scheduleEnabled")}</span>
|
|
</label>
|
|
{detail.source !== "import" ? (
|
|
detail.status === "running" ? (
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
isDisabled={taskNotReady}
|
|
onPress={() => void setScheduleEnabled(taskId, false)}
|
|
>
|
|
{t("bizState.pause")}
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
isDisabled={taskNotReady}
|
|
onPress={() => void setScheduleEnabled(taskId, true)}
|
|
>
|
|
{t("bizState.start")}
|
|
</Button>
|
|
)
|
|
) : (
|
|
<NmStatusChip color="warning">{t("bizState.sourceImport")}</NmStatusChip>
|
|
)}
|
|
<label className="config-sync-policy-field bs-monitor-interval-field">
|
|
<span>{t("bizState.interval")}</span>
|
|
<Input
|
|
type="number"
|
|
min={intervalUnit === "seconds" ? 60 : 1}
|
|
max={intervalUnitMax(intervalUnit)}
|
|
value={String(intervalValue)}
|
|
aria-label={t("bizState.interval")}
|
|
onChange={(e) => {
|
|
const min = intervalUnit === "seconds" ? 60 : 1;
|
|
const max = intervalUnitMax(intervalUnit);
|
|
setIntervalValue(Math.max(min, Math.min(max, Number(e.target.value) || min)));
|
|
}}
|
|
/>
|
|
<select
|
|
value={intervalUnit}
|
|
aria-label={t("bizState.intervalUnit")}
|
|
onChange={(e) => {
|
|
const next =
|
|
e.target.value === "seconds"
|
|
? "seconds"
|
|
: e.target.value === "hours"
|
|
? "hours"
|
|
: "days";
|
|
if (next === intervalUnit) return;
|
|
setIntervalValue(convertIntervalValue(intervalValue, intervalUnit, next));
|
|
setIntervalUnit(next);
|
|
}}
|
|
>
|
|
<option value="seconds">{t("bizState.intervalUnitSeconds")}</option>
|
|
<option value="hours">{t("bizState.intervalUnitHours")}</option>
|
|
<option value="days">{t("bizState.intervalUnitDays")}</option>
|
|
</select>
|
|
</label>
|
|
<label className="config-sync-policy-field">
|
|
<span>{t("bizState.retention")}</span>
|
|
<Input
|
|
type="number"
|
|
min={1}
|
|
max={3650}
|
|
value={String(retentionDays)}
|
|
aria-label={t("bizState.retention")}
|
|
onChange={(e) => setRetentionDays(Math.max(1, Number(e.target.value) || 1))}
|
|
/>
|
|
</label>
|
|
<label className="config-sync-policy-field" style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
|
<input
|
|
type="checkbox"
|
|
checked={dailyKeepEnabled}
|
|
disabled={busy}
|
|
onChange={(e) => setDailyKeepEnabled(e.target.checked)}
|
|
/>
|
|
<span>{t("bizState.dailyKeepEnabled")}</span>
|
|
</label>
|
|
{dailyKeepEnabled ? (
|
|
<label className="config-sync-policy-field">
|
|
<span>{t("bizState.dailyKeepCount")}</span>
|
|
<Input
|
|
type="number"
|
|
min={1}
|
|
max={1000}
|
|
value={String(dailyKeepCount)}
|
|
aria-label={t("bizState.dailyKeepCount")}
|
|
onChange={(e) => setDailyKeepCount(Math.max(1, Number(e.target.value) || 1))}
|
|
/>
|
|
</label>
|
|
) : null}
|
|
<Button size="sm" variant="secondary" isDisabled={taskNotReady} onPress={() => void saveSchedule()}>
|
|
{t("bizState.saveSchedule")}
|
|
</Button>
|
|
<Button size="sm" variant="ghost" isDisabled={taskNotReady} onPress={() => void purgeNow()}>
|
|
{t("bizState.purgeNow")}
|
|
</Button>
|
|
<span className="muted">
|
|
{detail.vendor || "—"} · {detail.ne_ip || "—"} · {t("bizState.retentionHint")}
|
|
{dailyKeepEnabled ? ` · ${t("bizState.dailyKeepHint")}` : ""}
|
|
</span>
|
|
</div>
|
|
) : null}
|
|
{detail?.last_error ? <p className="form-error">{detail.last_error}</p> : null}
|
|
|
|
<div className="btn-row nm-config-modal__tabs bs-monitor-task-tabs" role="tablist" aria-label={t("bizState.detail")} onKeyDown={(event) => {
|
|
if (!["ArrowRight", "ArrowLeft", "Home", "End"].includes(event.key)) return;
|
|
event.preventDefault();
|
|
const next = event.key === "Home" ? "profiles" : event.key === "End" ? "batches" : taskTab === "profiles" ? "batches" : "profiles";
|
|
setTaskTab(next);
|
|
event.currentTarget.querySelector<HTMLButtonElement>(`#monitor-${next}-tab`)?.focus();
|
|
}}>
|
|
<button
|
|
type="button" role="tab" id="monitor-profiles-tab" aria-controls="monitor-profiles-panel"
|
|
aria-selected={taskTab === "profiles"} tabIndex={taskTab === "profiles" ? 0 : -1}
|
|
className={`button${taskTab === "profiles" ? " is-active" : ""}`}
|
|
onClick={() => setTaskTab("profiles")}
|
|
>
|
|
{t("bizState.profiles")}
|
|
</button>
|
|
<button
|
|
type="button" role="tab" id="monitor-batches-tab" aria-controls="monitor-batches-panel"
|
|
aria-selected={taskTab === "batches"} tabIndex={taskTab === "batches" ? 0 : -1}
|
|
className={`button${taskTab === "batches" ? " is-active" : ""}`}
|
|
onClick={() => setTaskTab("batches")}
|
|
>
|
|
{t("bizState.batches")}
|
|
</button>
|
|
</div>
|
|
|
|
{taskTab === "profiles" ? (
|
|
<div className="bs-profiles-panel" role="tabpanel" id="monitor-profiles-panel" aria-labelledby="monitor-profiles-tab">
|
|
<div className="btn-row bs-profiles-toolbar">
|
|
<Button
|
|
size="sm"
|
|
variant="secondary"
|
|
isDisabled={taskNotReady || !collectProfiles.length}
|
|
onPress={() => void setAllProfilesEnabled(true)}
|
|
>
|
|
{t("bizState.profilesSelectAll")}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
isDisabled={taskNotReady || !profileSelectionStats.enabled}
|
|
onPress={() => void setAllProfilesEnabled(false)}
|
|
>
|
|
{t("bizState.profilesDeselectAll")}
|
|
</Button>
|
|
<span className="muted bs-profiles-stats">
|
|
{t("bizState.profilesSelectionStats", {
|
|
enabled: profileSelectionStats.enabled,
|
|
total: profileSelectionStats.total,
|
|
})}
|
|
{profileSelectionStats.unbound
|
|
? ` · ${t("bizState.profilesUnboundStats", { n: profileSelectionStats.unbound })}`
|
|
: ""}
|
|
</span>
|
|
</div>
|
|
<div className="pt-list-table-wrap bs-profiles-table-wrap">
|
|
<table className="data-table pt-list-table bs-profiles-table">
|
|
<thead>
|
|
<tr>
|
|
<th>{t("bizState.enable")}</th>
|
|
<th>{t("bizState.profiles")}</th>
|
|
<th>{t("bizState.colLane")}</th>
|
|
<th>{t("bizState.colActions")}</th>
|
|
<th>{t("bizState.params")}</th>
|
|
<th>{t("bizState.command")}</th>
|
|
<th>{t("bizState.colAux")}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{collectProfiles.map((prof) => {
|
|
const it = (detail?.items || []).find(
|
|
(x: any) => x.source_profile_id === prof.profile_id,
|
|
);
|
|
const enabled = Boolean(it?.enabled);
|
|
const binds = (it?.bindings || []) as {
|
|
placeholder?: string;
|
|
value?: string;
|
|
}[];
|
|
const needsBind = (prof.placeholders || []).length > 0;
|
|
const phNames = (prof.placeholders || []).map((p) => p.name);
|
|
const auxList = prof.aux_commands || [];
|
|
let bindLines: string[] = [];
|
|
if (needsBind && binds.length) {
|
|
if (phNames.length >= 2) {
|
|
const byPh: Record<string, string[]> = {};
|
|
for (const b of binds) {
|
|
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;
|
|
for (let i = 0; i < n; i++) {
|
|
bindLines.push(
|
|
phNames.map((name) => `${name}=${(byPh[name] || [])[i] || ""}`).join(" · "),
|
|
);
|
|
}
|
|
} else {
|
|
const ph = phNames[0] || "value";
|
|
bindLines = binds
|
|
.filter((b) => String(b.value || "").trim())
|
|
.map((b) => `${ph}=${String(b.value)}`);
|
|
}
|
|
}
|
|
const bindHint = needsBind
|
|
? bindLines.length
|
|
? bindLines.length <= 2
|
|
? bindLines.join("; ")
|
|
: `${bindLines.slice(0, 2).join("; ")} … (+${bindLines.length - 2})`
|
|
: t("bizState.unbound")
|
|
: "—";
|
|
return (
|
|
<tr key={prof.profile_id}>
|
|
<td className="bs-profiles-sticky-col bs-profiles-sticky-col--1">
|
|
<input
|
|
type="checkbox"
|
|
checked={enabled}
|
|
disabled={busy}
|
|
onChange={(e) => void toggleProfileItem(prof, e.target.checked)}
|
|
/>
|
|
</td>
|
|
<td className="bs-profiles-sticky-col bs-profiles-sticky-col--2">
|
|
<div className="pt-list-task-name">{prof.title}</div>
|
|
{prof.description ? <div className="muted">{prof.description}</div> : null}
|
|
{needsBind ? (
|
|
<div className="bs-bind-hint muted">{t("bizState.bindHintRequired")}</div>
|
|
) : null}
|
|
</td>
|
|
<td>
|
|
{String(prof.collect_lane || "light").toLowerCase() === "heavy" ? (
|
|
<span title={t("bizState.laneHeavyHint")}>
|
|
<NmStatusChip color="warning">{t("bizState.laneHeavy")}</NmStatusChip>
|
|
</span>
|
|
) : (
|
|
<span title={t("bizState.laneLightHint")}>
|
|
<NmStatusChip color="default">{t("bizState.laneLight")}</NmStatusChip>
|
|
</span>
|
|
)}
|
|
</td>
|
|
<td className="bs-actions-cell">
|
|
{needsBind && enabled && it ? (
|
|
<Button
|
|
size="sm"
|
|
variant="secondary"
|
|
isDisabled={taskNotReady || discoverLoading}
|
|
onPress={() => void startDiscover(it)}
|
|
>
|
|
{t("bizState.discoverBind")}
|
|
</Button>
|
|
) : needsBind ? (
|
|
<span className="muted">—</span>
|
|
) : (
|
|
"—"
|
|
)}
|
|
</td>
|
|
<td className="bs-params-cell">
|
|
{needsBind ? (
|
|
<div
|
|
className={`bs-params-scroll${
|
|
!bindLines.length ? " bs-params-warn" : ""
|
|
}${bindLines.length ? " bs-params-scroll--clickable" : ""}`}
|
|
title={
|
|
bindLines.length
|
|
? `${bindHint}\n${t("bizState.paramsClickHint")}`
|
|
: bindHint
|
|
}
|
|
onClick={() => {
|
|
if (!bindLines.length) return;
|
|
setParamsDetail({
|
|
title: prof.title,
|
|
lines: bindLines,
|
|
});
|
|
}}
|
|
>
|
|
{bindHint}
|
|
</div>
|
|
) : (
|
|
"—"
|
|
)}
|
|
</td>
|
|
<td>
|
|
<code className="bs-cmd-cell" title={prof.command_template}>
|
|
{prof.command_template}
|
|
</code>
|
|
</td>
|
|
<td className="bs-aux-cell">
|
|
{auxList.length ? (
|
|
<ul className="bs-aux-list">
|
|
{auxList.map((a) => {
|
|
const label = a.title || a.key || a.profile_id;
|
|
const tmpl = String(a.command_template || "").trim();
|
|
return (
|
|
<li key={`${a.key}:${a.profile_id}`} title={tmpl || a.profile_id}>
|
|
<span className="bs-aux-key">{a.key}</span>
|
|
<span className="muted"> · {label}</span>
|
|
{tmpl ? (
|
|
<code className="bs-aux-cmd">{tmpl}</code>
|
|
) : null}
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
) : (
|
|
<span className="muted">—</span>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="pt-list-table-wrap bs-monitor-batches" role="tabpanel" id="monitor-batches-panel" aria-labelledby="monitor-batches-tab">
|
|
<div className="btn-row" style={{ marginBottom: 8, gap: 8, flexWrap: "wrap" }}>
|
|
<span className="muted bs-monitor-history-count">{t("bizState.loadedBatches", {n: batches.length})}</span>
|
|
<Button size="sm" variant="secondary" isDisabled={taskNotReady} onPress={() => void refreshTaskProgress(taskId).catch((e) => showError(formatErr(e)))}>{t("common.refresh")}</Button>
|
|
<Button size="sm" variant="ghost" isDisabled={taskNotReady || !batches.length} onPress={selectDeletableBatches}>
|
|
{t("bizState.selectAll")}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="danger"
|
|
isDisabled={taskNotReady || !selectedBatchIds.length}
|
|
onPress={() => void bulkRemoveBatches()}
|
|
>
|
|
{t("bizState.bulkDelete")}
|
|
{selectedBatchIds.length ? ` (${selectedBatchIds.length})` : ""}
|
|
</Button>
|
|
</div>
|
|
<table className="data-table pt-list-table">
|
|
<thead>
|
|
<tr>
|
|
<th style={{ width: 36 }} />
|
|
<th>{t("bizState.colStarted")}</th>
|
|
<th>{t("bizState.colEnded")}</th>
|
|
<th>{t("bizState.colAlias")}</th>
|
|
<th>{t("bizState.colStatus")}</th>
|
|
<th>{t("bizState.colProtect")}</th>
|
|
<th>{t("bizState.colRows")}</th>
|
|
<th>{t("bizState.colActions")}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{batches.map((b) => {
|
|
const locked = Boolean(b.protected) || ["queued", "running"].includes(b.status);
|
|
return (
|
|
<tr key={b.id}>
|
|
<td>
|
|
<input
|
|
type="checkbox"
|
|
aria-label={b.alias?.trim() || b.id}
|
|
checked={selectedBatchIds.includes(b.id)}
|
|
disabled={locked || busy}
|
|
onChange={() => toggleBatchSelect(b.id, locked)}
|
|
/>
|
|
</td>
|
|
<td className="pt-list-time">{fmtTime(b.started_at)}</td>
|
|
<td className="pt-list-time">
|
|
{fmtTime(b.ended_at)}
|
|
{fmtDuration(b.started_at, b.ended_at) ? (
|
|
<div className="muted">{fmtDuration(b.started_at, b.ended_at)}</div>
|
|
) : null}
|
|
</td>
|
|
<td>
|
|
<button
|
|
type="button"
|
|
className="bs-alias-link"
|
|
disabled={busy}
|
|
title={t("bizState.setAlias")}
|
|
onClick={() => void editBatchAlias(b.id, b.alias)}
|
|
>
|
|
{b.alias?.trim() ? b.alias : t("bizState.aliasEmpty")}
|
|
</button>
|
|
</td>
|
|
<td>
|
|
<NmStatusChip color={jobChipColor(b.status)}>{batchStatusLabel(b.status)}</NmStatusChip>
|
|
</td>
|
|
<td>
|
|
{b.is_baseline ? (
|
|
<NmStatusChip color="accent">{t("bizState.baseline")}</NmStatusChip>
|
|
) : locked ? (
|
|
<span className="muted">{t("bizState.protected")}</span>
|
|
) : (
|
|
"—"
|
|
)}
|
|
</td>
|
|
<td className="pt-list-num">
|
|
{b.row_count}
|
|
<span className="muted"> / {b.command_count} cmd</span>
|
|
</td>
|
|
<td>
|
|
<div className="pt-list-actions">
|
|
<Button size="sm" variant="secondary" onPress={() => void openCollectDetail(b.id)}>
|
|
{t("bizState.batchDetail")}
|
|
</Button>
|
|
<Button size="sm" variant="primary" onPress={() => void openBatch(b.id)}>
|
|
{t("bizState.viewBatch")}
|
|
</Button>
|
|
<Button size="sm" variant="ghost" onPress={() => void bizStateDownloadExport(b.id).catch((e) => showError(formatErr(e)))}>
|
|
{t("bizState.export")}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
isDisabled={taskNotReady}
|
|
onPress={() => void editBatchAlias(b.id, b.alias)}
|
|
>
|
|
{t("bizState.setAlias")}
|
|
</Button>
|
|
{b.is_baseline ? (
|
|
<Button
|
|
size="sm"
|
|
variant="secondary"
|
|
isDisabled={taskNotReady}
|
|
onPress={() => void markBaseline(b.id, false)}
|
|
>
|
|
{t("bizState.unmarkBaseline")}
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
size="sm"
|
|
variant="secondary"
|
|
isDisabled={taskNotReady}
|
|
onPress={() => void markBaseline(b.id, true)}
|
|
>
|
|
{t("bizState.markBaseline")}
|
|
</Button>
|
|
)}
|
|
<Button
|
|
size="sm"
|
|
variant="danger"
|
|
isDisabled={taskNotReady || locked}
|
|
onPress={() => void removeBatch(b.id)}
|
|
>
|
|
{t("bizState.deleteBatch")}
|
|
</Button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
{!batches.length ? (
|
|
<tr>
|
|
<td colSpan={8}>
|
|
<div className="pt-list-empty">{t("bizState.noBatches")}</div>
|
|
</td>
|
|
</tr>
|
|
) : null}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Modal.Body>
|
|
<Modal.Footer>
|
|
{detail?.source !== "import" ? (
|
|
<Button
|
|
size="sm"
|
|
variant="primary"
|
|
isDisabled={Boolean(taskNotReady || detail?.collect_running || (taskId && collectingIds[taskId]))}
|
|
onPress={() => void collectNow()}
|
|
>
|
|
{t("bizState.collectNow")}
|
|
</Button>
|
|
) : null}
|
|
<Button
|
|
size="sm"
|
|
variant="secondary"
|
|
isDisabled={Boolean(
|
|
taskNotReady ||
|
|
!taskId ||
|
|
detail?.collect_running ||
|
|
(taskId && collectingIds[taskId]),
|
|
)}
|
|
onPress={() =>
|
|
openImportLog(
|
|
taskId,
|
|
String(detail?.vendor || ""),
|
|
String(detail?.device_type || ""),
|
|
)
|
|
}
|
|
>
|
|
{t("bizState.importLog")}
|
|
</Button>
|
|
{detail?.collect_running || (taskId && collectingIds[taskId]) ? (
|
|
<Button
|
|
size="sm"
|
|
variant="danger"
|
|
isDisabled={taskNotReady || !taskId}
|
|
onPress={() => void stopCollectForTask(taskId)}
|
|
>
|
|
{t("bizState.stopCollect")}
|
|
</Button>
|
|
) : null}
|
|
<Button
|
|
size="sm"
|
|
variant="secondary"
|
|
isDisabled={taskNotReady || !taskId}
|
|
onPress={() => void exportTaskCommands()}
|
|
>
|
|
{t("bizState.exportCommands")}
|
|
</Button>
|
|
<Button size="sm" variant="ghost" className="bs-monitor-delete" isDisabled={taskNotReady || Boolean(detail?.collect_running || collectingIds[taskId])} onPress={() => void removeTask(taskId)}>
|
|
{t("bizState.delete")}
|
|
</Button>
|
|
<Button size="sm" variant="ghost" onPress={closeTask}>
|
|
{t("bizState.cancel")}
|
|
</Button>
|
|
</Modal.Footer>
|
|
</AppModalShell>
|
|
|
|
{/* Append batch via log import on an existing task */}
|
|
<AppModalShell
|
|
open={importOpen}
|
|
onClose={() => {
|
|
if (!importBusy) setImportOpen(false);
|
|
}}
|
|
dismissible={!importBusy}
|
|
size="md"
|
|
>
|
|
<Modal.Header>
|
|
<Modal.Heading>{t("bizState.importLog")}</Modal.Heading>
|
|
</Modal.Header>
|
|
<Modal.Body className="flex flex-col gap-3">
|
|
<p className="muted text-sm">{t("bizState.importLogHint")}</p>
|
|
<FieldSelect
|
|
label={t("bizState.importVendor")}
|
|
value={importVendorKey}
|
|
onChange={(e) => setImportVendorKey(e.target.value)}
|
|
fullWidth
|
|
>
|
|
<option value="zte">ZTE</option>
|
|
<option value="huawei">Huawei</option>
|
|
<option value="cisco">Cisco</option>
|
|
<option value="generic">{t("bizState.importVendorGeneric")}</option>
|
|
</FieldSelect>
|
|
<label className="ui-field ui-field--full">
|
|
<span className="ui-field__label">{t("bizState.importFile")}</span>
|
|
<input
|
|
ref={importFileRef}
|
|
type="file"
|
|
accept=".txt,.log,.ini,.cfg,.zip,.cli,.out"
|
|
disabled={importBusy}
|
|
onChange={(e) => {
|
|
const f = e.target.files?.[0] || null;
|
|
setImportFile(f);
|
|
}}
|
|
/>
|
|
{importFile ? (
|
|
<span className="ui-field__hint">
|
|
{importFile.name} ({Math.round(importFile.size / 1024)} KB)
|
|
</span>
|
|
) : null}
|
|
</label>
|
|
</Modal.Body>
|
|
<Modal.Footer>
|
|
<Button
|
|
size="sm"
|
|
variant="primary"
|
|
isDisabled={importBusy || !importFile || !importTaskId}
|
|
onPress={() => void submitImportLog()}
|
|
>
|
|
{importBusy ? t("bizState.importing") : t("bizState.importSubmit")}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
isDisabled={importBusy}
|
|
onPress={() => setImportOpen(false)}
|
|
>
|
|
{t("bizState.cancel")}
|
|
</Button>
|
|
</Modal.Footer>
|
|
</AppModalShell>
|
|
|
|
{/* Bound params detail — double-click params cell */}
|
|
<AppModalShell
|
|
open={Boolean(paramsDetail)}
|
|
onClose={() => setParamsDetail(null)}
|
|
dismissible
|
|
size="md"
|
|
className="bs-bind-modal"
|
|
>
|
|
<Modal.Header>
|
|
<Modal.Heading>
|
|
{t("bizState.paramsDetailTitle")}
|
|
{paramsDetail?.title ? ` · ${paramsDetail.title}` : ""}
|
|
</Modal.Heading>
|
|
<Modal.CloseTrigger />
|
|
</Modal.Header>
|
|
<Modal.Body className="flex flex-col gap-2">
|
|
{paramsDetail?.lines?.length ? (
|
|
<>
|
|
<p className="muted">
|
|
{t("bizState.paramsDetailCount", { n: paramsDetail.lines.length })}
|
|
</p>
|
|
<div className="bs-bind-list bs-bind-list--modal bs-params-detail-list">
|
|
{paramsDetail.lines.map((line, idx) => (
|
|
<div key={`${idx}-${line}`} className="bs-params-detail-item">
|
|
<code>{line}</code>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</>
|
|
) : (
|
|
<p className="muted">{t("bizState.unbound")}</p>
|
|
)}
|
|
</Modal.Body>
|
|
<Modal.Footer>
|
|
<Button size="sm" variant="ghost" onPress={() => setParamsDetail(null)}>
|
|
{t("bizState.cancel")}
|
|
</Button>
|
|
</Modal.Footer>
|
|
</AppModalShell>
|
|
|
|
{/* VRF discover / bind — blocking modal above task dialog */}
|
|
<AppModalShell
|
|
open={Boolean(bindItemId)}
|
|
onClose={closeBindModal}
|
|
dismissible={!discoverLoading && !busy}
|
|
size="md"
|
|
className="bs-bind-modal"
|
|
>
|
|
<Modal.Header>
|
|
<Modal.Heading>{t("bizState.bindTitle")}</Modal.Heading>
|
|
<Modal.CloseTrigger />
|
|
</Modal.Header>
|
|
<Modal.Body className="flex flex-col gap-3">
|
|
{discoverLoading ? (
|
|
<p className="muted">{t("bizState.discoverLoading")}</p>
|
|
) : null}
|
|
{discoverCmd ? (
|
|
<p className="muted">
|
|
<code>{discoverCmd}</code>
|
|
{candidates.length ? ` · ${selectedVrfs.length}/${candidates.length}` : null}
|
|
{discoverCacheHit ? ` · ${t("bizState.discoverCacheHit")}` : null}
|
|
</p>
|
|
) : null}
|
|
{discoverError ? <p className="bs-params-warn">{discoverError}</p> : null}
|
|
{!discoverLoading && candidates.length ? (
|
|
<>
|
|
<div className="btn-row" style={{ gap: 8 }}>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
isDisabled={busy}
|
|
onPress={() => {
|
|
const allSelected =
|
|
candidates.length > 0 &&
|
|
candidates.every((c) => selectedVrfs.includes(c.value));
|
|
setSelectedVrfs(allSelected ? [] : candidates.map((c) => c.value));
|
|
}}
|
|
>
|
|
{candidates.length > 0 &&
|
|
candidates.every((c) => selectedVrfs.includes(c.value))
|
|
? t("bizState.deselectAllVrfs")
|
|
: t("bizState.selectAllVrfs")}
|
|
</Button>
|
|
{bindItemId ? (
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
isDisabled={busy || discoverLoading}
|
|
onPress={() => {
|
|
const item = (detail?.items || []).find((it: any) => it.id === bindItemId);
|
|
if (item) void startDiscover(item, true);
|
|
}}
|
|
>
|
|
{t("bizState.discoverRefresh")}
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
<div className="bs-bind-list bs-bind-list--modal">
|
|
{candidates.map((c) => (
|
|
<label key={c.value} className="bs-bind-item">
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedVrfs.includes(c.value)}
|
|
disabled={busy}
|
|
onChange={(e) => {
|
|
setSelectedVrfs((prev) =>
|
|
e.target.checked
|
|
? [...prev, c.value]
|
|
: prev.filter((x) => x !== c.value),
|
|
);
|
|
}}
|
|
/>{" "}
|
|
{c.label}
|
|
{c.rd ? <span className="muted"> · RD {c.rd}</span> : null}
|
|
</label>
|
|
))}
|
|
</div>
|
|
</>
|
|
) : null}
|
|
</Modal.Body>
|
|
<Modal.Footer>
|
|
<Button
|
|
size="sm"
|
|
variant="primary"
|
|
isDisabled={busy || discoverLoading || !selectedVrfs.length}
|
|
onPress={() => void saveBindings()}
|
|
>
|
|
{t("bizState.saveBindings")}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
isDisabled={discoverLoading}
|
|
onPress={closeBindModal}
|
|
>
|
|
{t("bizState.cancel")}
|
|
</Button>
|
|
</Modal.Footer>
|
|
</AppModalShell>
|
|
|
|
{/* Batch workbook: summary + lazy-paged metric sheets */}
|
|
<AppModalShell
|
|
open={Boolean(batchTargetId)}
|
|
onClose={closeBatch}
|
|
size="lg"
|
|
className="app-heroui-modal--xl bs-workbook-modal bs-monitor-modal"
|
|
>
|
|
<Modal.Header>
|
|
<Modal.Heading>{t("bizState.batchWorkbook")}</Modal.Heading>
|
|
<Modal.CloseTrigger />
|
|
</Modal.Header>
|
|
<Modal.Body className="flex flex-col gap-2 bs-workbook-body">
|
|
{batchLoading ? <div className="bs-monitor-loading" role="status">{t("bizState.sheetLoading")}</div> : null}
|
|
{batchError ? <div className="bs-monitor-error" role="alert"><span>{t("bizState.batchLoadError")} · {batchError}</span><Button size="sm" variant="secondary" onPress={() => void openBatch(batchTargetId)}>{t("bizState.retry")}</Button></div> : null}
|
|
{batchDetail ? (
|
|
<div className="bs-workbook-meta">
|
|
<NmStatusChip color={jobChipColor(String(batchDetail.status || ""))}>
|
|
{batchStatusLabel(String(batchDetail.status || ""))}
|
|
</NmStatusChip>
|
|
{batchDetail.alias ? (
|
|
<NmStatusChip color="accent">{String(batchDetail.alias)}</NmStatusChip>
|
|
) : null}
|
|
<span className="muted">
|
|
{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)}`
|
|
: ""}
|
|
</span>
|
|
<div className="bs-id-row" title={String(batchDetail.id || "")}>
|
|
<span className="bs-id-row__label">{t("bizState.batchId")}</span>
|
|
<code className="bs-id-row__value">{String(batchDetail.id || "")}</code>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
isDisabled={!batchDetail.id}
|
|
onPress={() => {
|
|
void (async () => {
|
|
const ok = await writeClipboardText(String(batchDetail.id || ""));
|
|
if (ok) showOk(t("common.copied"));
|
|
else showError(t("common.opFailed"));
|
|
})();
|
|
}}
|
|
>
|
|
{t("bizState.copyBatchId")}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
isDisabled={busy}
|
|
onPress={() => void editBatchAlias(String(batchDetail.id || ""), String(batchDetail.alias || ""))}
|
|
>
|
|
{t("bizState.setAlias")}
|
|
</Button>
|
|
</div>
|
|
{batchDetail.message ? (
|
|
<div
|
|
className={`bs-batch-message${
|
|
["failed", "partial", "error", "cancelled"].includes(
|
|
String(batchDetail.status || "").toLowerCase(),
|
|
)
|
|
? " bs-batch-message--alert"
|
|
: ""
|
|
}`}
|
|
role="status"
|
|
>
|
|
<span className="bs-batch-message__label">{t("bizState.colMessage")}</span>
|
|
<pre className="bs-batch-message__body">{String(batchDetail.message)}</pre>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="bs-sheet-tabs bs-sheet-tabs--top" role="tablist" aria-label={t("bizState.batchWorkbook")} onKeyDown={handleSheetTabKey}>
|
|
{sheetTabs.map((s) => (
|
|
<button
|
|
key={s.id}
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={activeSheet?.id === s.id}
|
|
tabIndex={activeSheet?.id === s.id ? 0 : -1}
|
|
aria-controls="monitor-sheet-panel"
|
|
className={`bs-sheet-tab${activeSheet?.id === s.id ? " is-active" : ""}`}
|
|
onClick={() => selectSheet(s.id)}
|
|
>
|
|
{s.title}
|
|
<span className="bs-sheet-tab__count">{s.rowCount}</span>
|
|
</button>
|
|
))}
|
|
{!sheetTabs.length ? <span className="muted">{t("bizState.sheetEmpty")}</span> : null}
|
|
</div>
|
|
|
|
{activeSheet ? (
|
|
<>
|
|
{activeSheet.id !== "commands" && activeSheet.commands.length ? (
|
|
<div className="bs-sheet-cmd-bar">
|
|
<div className="bs-sheet-cmd-bar__label">{t("bizState.collectCommand")}</div>
|
|
<div className="bs-sheet-cmd-list">
|
|
{activeSheet.commands.map((c) => (
|
|
<div key={c.id} className="bs-sheet-cmd-row">
|
|
<code className="bs-sheet-cmd-code" title={c.raw_command}>
|
|
{c.raw_command || "—"}
|
|
</code>
|
|
<div className="bs-sheet-cmd-actions">
|
|
<Button
|
|
size="sm"
|
|
variant="secondary"
|
|
isDisabled={!c.has_raw}
|
|
onPress={() => void openRawLog(c.id)}
|
|
>
|
|
{t("bizState.viewRawLog")}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
isDisabled={!c.has_raw || !batchDetail?.id}
|
|
onPress={() =>
|
|
void bizStateDownloadCommandRaw(String(batchDetail.id), c.id).catch((e) =>
|
|
showError(formatErr(e)),
|
|
)
|
|
}
|
|
>
|
|
{t("bizState.exportRawLog")}
|
|
</Button>
|
|
<span className="muted bs-cmd-stat">
|
|
{formatCmdCollectStats(
|
|
t,
|
|
Number(c.raw_line_count ?? 0),
|
|
Number(c.row_count ?? 0),
|
|
Number(c.declared_total ?? 0),
|
|
)}
|
|
</span>
|
|
{c.parse_status ? (
|
|
<NmStatusChip color={jobChipColor(String(c.parse_status))}>
|
|
{c.parse_status}
|
|
</NmStatusChip>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
{activeSheet.id === "commands" ? (
|
|
<div className="bs-sheet-cmd-list bs-commands-card-list" id="monitor-sheet-panel" role="tabpanel" aria-label={activeSheet.title}>
|
|
{displayRows.map((row, i) => (
|
|
<div key={String(row.id || i)} className="bs-sheet-cmd-row bs-commands-card">
|
|
<code className="bs-sheet-cmd-code" title={cellText(row.raw_command)}>
|
|
{cellText(row.raw_command) || "—"}
|
|
</code>
|
|
<div className="bs-sheet-cmd-actions">
|
|
<Button
|
|
size="sm"
|
|
variant="secondary"
|
|
isDisabled={!row.has_raw}
|
|
onPress={() => void openRawLog(String(row.id || ""))}
|
|
>
|
|
{t("bizState.viewRawLog")}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
isDisabled={!row.has_raw || !batchDetail?.id}
|
|
onPress={() =>
|
|
void bizStateDownloadCommandRaw(
|
|
String(batchDetail.id),
|
|
String(row.id || ""),
|
|
).catch((e) => showError(formatErr(e)))
|
|
}
|
|
>
|
|
{t("bizState.exportRawLog")}
|
|
</Button>
|
|
<span className="muted bs-cmd-stat">
|
|
{formatCmdCollectStats(
|
|
t,
|
|
Number(row.raw_line_count ?? 0),
|
|
Number(row.row_count ?? 0),
|
|
Number(row.declared_total ?? 0),
|
|
)}
|
|
</span>
|
|
{cellText(row.parse_status) ? (
|
|
<NmStatusChip color={jobChipColor(cellText(row.parse_status))}>
|
|
{cellText(row.parse_status)}
|
|
</NmStatusChip>
|
|
) : null}
|
|
{cellText(row.metric_id) ? (
|
|
<span className="muted bs-cmd-metric">{cellText(row.metric_id)}</span>
|
|
) : null}
|
|
</div>
|
|
{cellText(row.message) ? (
|
|
<div className="bs-commands-card__msg" title={cellText(row.message)}>
|
|
{cellText(row.message)}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
))}
|
|
{!displayRows.length ? (
|
|
<div className="pt-list-empty">{t("bizState.sheetEmpty")}</div>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
|
|
{activeSheet.id !== "commands" ? (
|
|
<div className="filter-inline bs-sheet-filter">
|
|
<FieldSelect
|
|
value={sheetColumn}
|
|
onChange={(e) => {
|
|
setSheetColumn(e.target.value);
|
|
setSheetPage(1);
|
|
}}
|
|
aria-label={t("bizState.filterColumn")}
|
|
>
|
|
<option value="">{t("bizState.filterAllCols")}</option>
|
|
{displayColumns
|
|
.filter((c) => c.key !== "_actions" && c.key !== "_empty")
|
|
.map((c) => (
|
|
<option key={c.key} value={c.key}>
|
|
{c.header}
|
|
</option>
|
|
))}
|
|
</FieldSelect>
|
|
<Input
|
|
value={sheetKeyword}
|
|
aria-label={t("bizState.sheetFilterPh")}
|
|
placeholder={t("bizState.sheetFilterPh")}
|
|
onChange={(e) => {
|
|
setSheetKeyword(e.target.value);
|
|
setSheetPage(1);
|
|
}}
|
|
/>
|
|
<span className="muted bs-sheet-count">
|
|
{sheetLoading ? t("bizState.sheetLoading") : `${displayTotal} ${t("bizState.colRows")}`}
|
|
</span>
|
|
<Button size="sm" variant="ghost" isDisabled={!sheetKeyword && !sheetColumn} onPress={() => { setSheetKeyword(""); setSheetColumn(""); setSheetPage(1); }}>{t("common.clearFilters")}</Button>
|
|
</div>
|
|
) : (
|
|
<div className="filter-inline bs-sheet-filter">
|
|
<Input
|
|
value={sheetKeyword}
|
|
aria-label={t("bizState.sheetFilterPh")}
|
|
placeholder={t("bizState.sheetFilterPh")}
|
|
onChange={(e) => {
|
|
setSheetKeyword(e.target.value);
|
|
setSheetPage(1);
|
|
}}
|
|
/>
|
|
<span className="muted bs-sheet-count">
|
|
{`${displayTotal} ${t("bizState.colRows")}`}
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{activeSheet.id !== "commands" ? (
|
|
<div className="pt-list-table-wrap bs-sheet-table" id="monitor-sheet-panel" role="tabpanel" aria-label={activeSheet.title} aria-busy={sheetLoading}>
|
|
<table className="data-table pt-list-table">
|
|
<thead>
|
|
<tr>
|
|
{displayColumns.map((c) => (
|
|
<th key={c.key}>{c.header}</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{displayRows.map((row, i) => (
|
|
<tr key={String(row.id || i)}>
|
|
{displayColumns.map((c) => (
|
|
<td key={c.key}>
|
|
{c.key === "_actions" ? (
|
|
<div className="pt-list-actions">
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
isDisabled={!row.has_raw}
|
|
onPress={() => void openRawLog(String(row.id || ""))}
|
|
>
|
|
{t("bizState.viewRawLog")}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
isDisabled={!row.has_raw || !batchDetail?.id}
|
|
onPress={() =>
|
|
void bizStateDownloadCommandRaw(
|
|
String(batchDetail.id),
|
|
String(row.id || ""),
|
|
).catch((e) => showError(formatErr(e)))
|
|
}
|
|
>
|
|
{t("bizState.exportRawLog")}
|
|
</Button>
|
|
</div>
|
|
) : c.key === "parse_status" ? (
|
|
<NmStatusChip color={jobChipColor(cellText(row[c.key]))}>
|
|
{cellText(row[c.key]) || "—"}
|
|
</NmStatusChip>
|
|
) : c.key === "raw_command" ? (
|
|
<code className="bs-inline-cmd">{cellText(row[c.key]) || "—"}</code>
|
|
) : (
|
|
cellText(row[c.key]) || "—"
|
|
)}
|
|
</td>
|
|
))}
|
|
</tr>
|
|
))}
|
|
{!displayRows.length && !sheetLoading && !sheetError ? (
|
|
<tr>
|
|
<td colSpan={Math.max(1, displayColumns.length)}>
|
|
<div className="pt-list-empty">{t("bizState.sheetEmpty")}</div>
|
|
</td>
|
|
</tr>
|
|
) : null}
|
|
{sheetLoading && !displayRows.length ? (
|
|
<tr>
|
|
<td colSpan={Math.max(1, displayColumns.length)}>
|
|
<div className="pt-list-empty muted">{t("bizState.sheetLoading")}</div>
|
|
</td>
|
|
</tr>
|
|
) : null}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
) : null}
|
|
|
|
{sheetError ? <div className="bs-monitor-error" role="alert"><span>{t("bizState.sheetLoadError")} · {sheetError}</span><Button size="sm" variant="secondary" onPress={() => setSheetRetry((n) => n + 1)}>{t("bizState.retry")}</Button></div> : null}
|
|
|
|
<ListPager
|
|
page={sheetPage}
|
|
pages={pageCount(displayTotal, sheetPageSize)}
|
|
total={displayTotal}
|
|
pageSize={sheetPageSize}
|
|
pageSizeOptions={SHEET_PAGE_SIZE_OPTIONS}
|
|
disabled={sheetLoading || Boolean(sheetError)}
|
|
onPageChange={setSheetPage}
|
|
onPageSizeChange={(n) => {
|
|
setSheetPageSize(n);
|
|
setSheetPage(1);
|
|
}}
|
|
/>
|
|
</>
|
|
) : (
|
|
!batchLoading && !batchError ? <div className="pt-list-empty">{t("bizState.sheetEmpty")}</div> : null
|
|
)}
|
|
</Modal.Body>
|
|
<Modal.Footer>
|
|
{batchDetail ? (
|
|
<Button size="sm" variant="secondary" onPress={() => void bizStateDownloadExport(batchDetail.id).catch((e) => showError(formatErr(e)))}>
|
|
{t("bizState.export")}
|
|
</Button>
|
|
) : null}
|
|
<Button size="sm" variant="ghost" onPress={closeBatch}>
|
|
{t("bizState.cancel")}
|
|
</Button>
|
|
</Modal.Footer>
|
|
</AppModalShell>
|
|
|
|
{/* Raw CLI log viewer */}
|
|
<AppModalShell
|
|
open={rawLogOpen}
|
|
onClose={closeRawLog}
|
|
size="lg"
|
|
className="app-heroui-modal--lg bs-rawlog-modal bs-monitor-modal"
|
|
>
|
|
<Modal.Header>
|
|
<Modal.Heading>{t("bizState.rawLogTitle")}</Modal.Heading>
|
|
<Modal.CloseTrigger />
|
|
</Modal.Header>
|
|
<Modal.Body className="flex flex-col gap-2 bs-rawlog-body">
|
|
{rawLogCmd ? <code className="bs-sheet-cmd-code bs-sheet-cmd-code--block">{rawLogCmd}</code> : null}
|
|
<div className="bs-rawlog-stats">
|
|
<NmStatusChip color="accent">
|
|
{formatCmdCollectStats(t, rawLogLines, rawLogRows, rawLogDeclared)}
|
|
</NmStatusChip>
|
|
{rawLogMeta ? <span className="muted">{rawLogMeta}</span> : null}
|
|
</div>
|
|
{rawLogMessage ? (
|
|
<div className="bs-batch-message bs-batch-message--alert" role="status">
|
|
<span className="bs-batch-message__label">{t("bizState.colMessage")}</span>
|
|
<pre className="bs-batch-message__body">{rawLogMessage}</pre>
|
|
</div>
|
|
) : null}
|
|
{rawLogLoading ? (
|
|
<div className="pt-list-empty muted">{t("bizState.sheetLoading")}</div>
|
|
) : (
|
|
<pre className="bs-rawlog-pre">{rawLogText || t("bizState.rawLogEmpty")}</pre>
|
|
)}
|
|
</Modal.Body>
|
|
<Modal.Footer>
|
|
<Button
|
|
size="sm"
|
|
variant="secondary"
|
|
isDisabled={rawLogLoading || (!rawLogText && !rawLogCommandId)}
|
|
onPress={() => void exportRawLog()}
|
|
>
|
|
{t("bizState.exportRawLog")}
|
|
</Button>
|
|
<Button size="sm" variant="ghost" onPress={closeRawLog}>
|
|
{t("bizState.cancel")}
|
|
</Button>
|
|
</Modal.Footer>
|
|
</AppModalShell>
|
|
|
|
{/* Batch collect status / errors detail */}
|
|
<AppModalShell
|
|
open={Boolean(collectDetail) || collectDetailLoading}
|
|
onClose={closeCollectDetail}
|
|
size="lg"
|
|
className="app-heroui-modal--lg bs-monitor-modal bs-collect-detail-modal"
|
|
>
|
|
<Modal.Header>
|
|
<Modal.Heading>{t("bizState.batchCollectDetail")}</Modal.Heading>
|
|
<Modal.CloseTrigger />
|
|
</Modal.Header>
|
|
<Modal.Body className="flex flex-col gap-2">
|
|
{collectDetailLoading ? (
|
|
<div className="pt-list-empty muted">{t("bizState.sheetLoading")}</div>
|
|
) : collectDetail ? (
|
|
<>
|
|
<div className="bs-workbook-meta">
|
|
<NmStatusChip color={jobChipColor(String(collectDetail.status || ""))}>
|
|
{String(collectDetail.status || "—")}
|
|
</NmStatusChip>
|
|
{collectDetail.alias ? (
|
|
<NmStatusChip color="accent">{String(collectDetail.alias)}</NmStatusChip>
|
|
) : null}
|
|
<span className="muted">
|
|
{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)}`
|
|
: ""}
|
|
</span>
|
|
</div>
|
|
<div
|
|
className={`bs-batch-message${
|
|
["failed", "partial", "error", "cancelled"].includes(
|
|
String(collectDetail.status || "").toLowerCase(),
|
|
)
|
|
? " bs-batch-message--alert"
|
|
: ""
|
|
}`}
|
|
role="status"
|
|
>
|
|
<span className="bs-batch-message__label">{t("bizState.colMessage")}</span>
|
|
<pre className="bs-batch-message__body">
|
|
{String(collectDetail.message || "").trim() || t("bizState.batchMessageEmpty")}
|
|
</pre>
|
|
</div>
|
|
<p className="muted">
|
|
{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),
|
|
})}
|
|
</p>
|
|
<div className="pt-list-table-wrap bs-sheet-table bs-collect-detail-table">
|
|
<table className="data-table pt-list-table">
|
|
<thead>
|
|
<tr>
|
|
<th>{t("bizState.colCommand")}</th>
|
|
<th>{t("bizState.colStatus")}</th>
|
|
<th>{t("bizState.colRawLines")}</th>
|
|
<th>{t("bizState.colRows")}</th>
|
|
<th>{t("bizState.colDeclared")}</th>
|
|
<th>{t("bizState.colMessage")}</th>
|
|
<th>{t("bizState.colActions")}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{((collectDetail.commands || []) as SheetCmd[]).map((c) => (
|
|
<tr key={c.id}>
|
|
<td>
|
|
<code className="bs-inline-cmd">{c.raw_command || "—"}</code>
|
|
</td>
|
|
<td>
|
|
<NmStatusChip color={jobChipColor(String(c.parse_status || ""))}>
|
|
{c.parse_status || "—"}
|
|
</NmStatusChip>
|
|
</td>
|
|
<td className="pt-list-num">{Number(c.raw_line_count ?? 0)}</td>
|
|
<td className="pt-list-num">{Number(c.row_count ?? 0)}</td>
|
|
<td className="pt-list-num">
|
|
{Number(c.declared_total ?? 0) > 0 ? Number(c.declared_total) : "—"}
|
|
</td>
|
|
<td className="bs-msg-cell" title={c.message || ""}>
|
|
{c.message?.trim() ? c.message : "—"}
|
|
</td>
|
|
<td>
|
|
<div className="pt-list-actions">
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
isDisabled={!c.has_raw}
|
|
onPress={() => void openRawLog(c.id, String(collectDetail.id))}
|
|
>
|
|
{t("bizState.viewRawLog")}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
isDisabled={!c.has_raw}
|
|
onPress={() =>
|
|
void bizStateDownloadCommandRaw(String(collectDetail.id), c.id).catch((e) =>
|
|
showError(formatErr(e)),
|
|
)
|
|
}
|
|
>
|
|
{t("bizState.exportRawLog")}
|
|
</Button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
{!((collectDetail.commands || []) as SheetCmd[]).length ? (
|
|
<tr>
|
|
<td colSpan={6}>
|
|
<div className="pt-list-empty">{t("bizState.sheetEmpty")}</div>
|
|
</td>
|
|
</tr>
|
|
) : null}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</>
|
|
) : null}
|
|
</Modal.Body>
|
|
<Modal.Footer>
|
|
{collectDetail?.id ? (
|
|
<>
|
|
<Button
|
|
size="sm"
|
|
variant="primary"
|
|
onPress={() => {
|
|
const id = String(collectDetail.id);
|
|
closeCollectDetail();
|
|
void openBatch(id);
|
|
}}
|
|
>
|
|
{t("bizState.openWorkbook")}
|
|
</Button>
|
|
<Button size="sm" variant="secondary" onPress={() => void bizStateDownloadExport(String(collectDetail.id))}>
|
|
{t("bizState.export")}
|
|
</Button>
|
|
</>
|
|
) : null}
|
|
<Button size="sm" variant="ghost" onPress={closeCollectDetail}>
|
|
{t("bizState.cancel")}
|
|
</Button>
|
|
</Modal.Footer>
|
|
</AppModalShell>
|
|
</section>
|
|
);
|
|
}
|