netx/web/src/pages/network/BizStatePage.tsx
2026-09-17 17:21:16 +08:00

1091 lines
37 KiB
TypeScript

import { Button, Input, Modal } from "@heroui/react";
import { useCallback, useEffect, useMemo, useState } 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 {
bizStateCollectNow,
bizStateCreateTask,
bizStateDeleteTask,
bizStateDiscover,
bizStateDownloadExport,
bizStateGetBatch,
bizStateGetTask,
bizStateListBatches,
bizStateListProfiles,
bizStateListTasks,
bizStatePatchTask,
bizStateSetBindings,
fetchCliTargets,
formatErr,
} from "../../services/api";
import type { CliTargetItem } from "../../types";
import { pageCount } from "../../utils/display";
import { formatSystemTime } from "../../utils/time";
import { jobChipColor, NmStatusChip, sourceChipColor } from "./nmChips";
type TaskRow = {
id: string;
source?: string;
ne_name: string;
ne_ip: string;
vendor: string;
status: string;
collect_running: boolean;
last_error: string;
last_collect_ended_at?: string | null;
};
type Placeholder = { name: string };
type Profile = {
profile_id: string;
title: string;
command_template: string;
description: string;
metric_id: string;
kind?: string;
placeholders?: Placeholder[];
};
type BatchRow = {
id: string;
status: string;
row_count: number;
command_count: number;
started_at?: string | null;
};
type Candidate = { value: string; label: string; rd?: string };
type SheetCol = { key: string; header: string };
type SheetDef = {
id: string;
title: string;
columns: SheetCol[];
rows: Record<string, unknown>[];
};
type TaskTab = "profiles" | "batches";
type NeSourceFilter = "all" | "managed" | "ume";
const NE_PAGE_SIZE = 10;
function fmtTime(v?: string | null) {
if (!v) return "—";
return formatSystemTime(v) || v;
}
function cellText(v: unknown): string {
if (v === null || v === undefined) return "";
if (typeof v === "object") return JSON.stringify(v);
return String(v);
}
function neSourceOf(row: CliTargetItem): "managed" | "ume" {
return row.source === "ume" ? "ume" : "managed";
}
function buildBatchSheets(batch: any, t: (k: string) => string): SheetDef[] {
const sheets: SheetDef[] = [];
const cmds = (batch?.commands || []) as any[];
if (cmds.length) {
sheets.push({
id: "commands",
title: t("bizState.sheetCommands"),
columns: [
{ key: "raw_command", header: t("bizState.colCommand") },
{ key: "metric_id", header: "metric" },
{ key: "parse_status", header: t("bizState.colStatus") },
{ key: "row_count", header: t("bizState.colRows") },
{ key: "message", header: t("bizState.colMessage") },
],
rows: cmds.map((c) => ({
raw_command: c.raw_command,
metric_id: c.metric_id,
parse_status: c.parse_status,
row_count: c.row_count,
message: c.message,
profile_id: c.profile_id,
})),
});
}
const lldp = (batch?.lldp_neighbors || []) as any[];
if (lldp.length) {
sheets.push({
id: "lldp_neighbor",
title: t("bizState.sheetLldp"),
columns: [
{ key: "local_if", header: "local_if" },
{ key: "remote_sys", header: "remote_sys" },
{ key: "remote_if", header: "remote_if" },
{ key: "remote_ip", header: "remote_ip" },
{ key: "protocol", header: "protocol" },
],
rows: lldp,
});
}
const vrf = (batch?.vrf_route_summary || []) as any[];
if (vrf.length) {
sheets.push({
id: "vrf_route_summary",
title: t("bizState.sheetVrfRoute"),
columns: [
{ key: "vrf", header: "vrf" },
{ key: "source", header: "source" },
{ key: "networks", header: "networks" },
],
rows: vrf,
});
}
return sheets;
}
function filterSheetRows(
rows: Record<string, unknown>[],
columns: SheetCol[],
keyword: string,
columnKey: string,
): Record<string, unknown>[] {
const kw = keyword.trim().toLowerCase();
if (!kw) return rows;
return rows.filter((row) => {
const keys = columnKey ? [columnKey] : columns.map((c) => c.key);
return keys.some((k) => cellText(row[k]).toLowerCase().includes(kw));
});
}
export function BizStatePage() {
const { t } = useI18n();
const { showOk, showError } = useToast();
const [tasks, setTasks] = useState<TaskRow[]>([]);
const [busy, setBusy] = useState(false);
const [listKeyword, setListKeyword] = useState("");
const debouncedListKw = useDebouncedValue(listKeyword, 250);
// create-task modal (all valid CLI targets)
const [createOpen, setCreateOpen] = useState(false);
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 [profiles, setProfiles] = useState<Profile[]>([]);
const [batches, setBatches] = useState<BatchRow[]>([]);
const [taskTab, setTaskTab] = useState<TaskTab>("profiles");
// VRF bind (inside task modal)
const [bindItemId, setBindItemId] = useState("");
const [candidates, setCandidates] = useState<Candidate[]>([]);
const [selectedVrfs, setSelectedVrfs] = useState<string[]>([]);
const [discoverCmd, setDiscoverCmd] = useState("");
// batch workbook modal
const [batchDetail, setBatchDetail] = useState<any>(null);
const [sheetId, setSheetId] = useState("");
const [sheetKeyword, setSheetKeyword] = useState("");
const [sheetColumn, setSheetColumn] = useState("");
const debouncedSheetKw = useDebouncedValue(sheetKeyword, 200);
const refreshTasks = useCallback(async () => {
const res = await bizStateListTasks();
setTasks((res.items || []) as TaskRow[]);
}, []);
useEffect(() => {
void (async () => {
try {
await refreshTasks();
} catch (e) {
showError(formatErr(e));
}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount once
}, [refreshTasks]);
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.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 sheets = useMemo(
() => (batchDetail ? buildBatchSheets(batchDetail, t) : []),
[batchDetail, t],
);
const activeSheet = useMemo(() => {
if (!sheets.length) return null;
return sheets.find((s) => s.id === sheetId) || sheets[0];
}, [sheets, sheetId]);
const filteredSheetRows = useMemo(() => {
if (!activeSheet) return [];
return filterSheetRows(
activeSheet.rows,
activeSheet.columns,
debouncedSheetKw,
sheetColumn,
);
}, [activeSheet, debouncedSheetKw, sheetColumn]);
const openCreate = () => {
setCreateOpen(true);
setSelectedNe(null);
setNeKeyword("");
setNeSource("all");
setNePage(1);
};
const closeCreate = () => {
setCreateOpen(false);
setSelectedNe(null);
};
const loadTask = async (id: string) => {
const task = await bizStateGetTask(id);
setDetail(task);
const b = await bizStateListBatches(id);
setBatches((b.items || []) as BatchRow[]);
const p = await bizStateListProfiles({
vendor: task.vendor || "",
device_type: task.device_type || "",
});
setProfiles((p.items || []) as Profile[]);
};
const openTask = async (id: string, tab: TaskTab = "profiles") => {
setTaskId(id);
setTaskTab(tab);
setBindItemId("");
setCandidates([]);
setBatchDetail(null);
try {
await loadTask(id);
} catch (e) {
showError(formatErr(e));
setTaskId("");
}
};
const closeTask = () => {
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();
await refreshTasks();
await openTask(String(task.id), "profiles");
} catch (e) {
showError(t("bizState.createFailed") + ": " + formatErr(e));
} finally {
setBusy(false);
}
};
const setStatus = async (status: string) => {
if (!taskId) return;
setBusy(true);
try {
await bizStatePatchTask(taskId, { status });
showOk(status === "running" ? t("bizState.started") : t("bizState.paused"));
await loadTask(taskId);
await refreshTasks();
} catch (e) {
showError(formatErr(e));
} finally {
setBusy(false);
}
};
const collectNow = async () => {
if (!taskId) return;
setBusy(true);
try {
await bizStateCollectNow(taskId);
for (let i = 0; i < 20; i++) {
await new Promise((r) => setTimeout(r, 1500));
const task = await bizStateGetTask(taskId);
setDetail(task);
if (!task.collect_running) break;
}
await loadTask(taskId);
await refreshTasks();
setTaskTab("batches");
} 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();
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 startDiscover = async (item: any) => {
if (!taskId) return;
const prof = profiles.find((p) => p.profile_id === item.source_profile_id);
const ph = (prof?.placeholders || [])[0];
if (!ph) {
showError(t("bizState.noNeedBind"));
return;
}
setBusy(true);
setBindItemId(item.id);
try {
const res = await bizStateDiscover({
task_id: taskId,
collect_profile_id: item.source_profile_id,
placeholder: ph.name,
});
if (!res.ok) {
showError(res.error || t("bizState.discoverFailed"));
setCandidates([]);
return;
}
setDiscoverCmd(res.command || "");
const cand = (res.candidates || []) as Candidate[];
setCandidates(cand);
const existing = (item.bindings || [])
.filter((b: any) => b.placeholder === ph.name)
.map((b: any) => String(b.value));
setSelectedVrfs(existing.length ? existing : cand.map((c) => c.value));
} catch (e) {
showError(formatErr(e));
} finally {
setBusy(false);
}
};
const saveBindings = async () => {
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";
setBusy(true);
try {
await bizStateSetBindings(
taskId,
bindItemId,
selectedVrfs.map((v) => ({ placeholder: phName, value: v })),
);
showOk(t("bizState.bindingsSaved"));
await loadTask(taskId);
setBindItemId("");
setCandidates([]);
} catch (e) {
showError(formatErr(e));
} finally {
setBusy(false);
}
};
const openBatch = async (batchId: string) => {
try {
const d = await bizStateGetBatch(batchId);
setBatchDetail(d);
setSheetKeyword("");
setSheetColumn("");
const built = buildBatchSheets(d, t);
setSheetId(built[0]?.id || "");
} catch (e) {
showError(formatErr(e));
}
};
const closeBatch = () => {
setBatchDetail(null);
setSheetId("");
setSheetKeyword("");
setSheetColumn("");
};
const runningCount = tasks.filter((x) => x.status === "running").length;
return (
<section className="panel nm-page-panel">
<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.colNe")}</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.start")}</div>
<div className="pt-list-kpi__value">{runningCount}</div>
</div>
</div>
<div className="filter-inline">
<Input
value={listKeyword}
placeholder={t("bizState.listFilterPh")}
onChange={(e) => setListKeyword(e.target.value)}
/>
</div>
<div className="pt-list-table-wrap">
<table className="data-table pt-list-table">
<thead>
<tr>
<th>{t("bizState.colNe")}</th>
<th>{t("bizState.colSource")}</th>
<th>{t("bizState.colStatus")}</th>
<th>{t("bizState.colLast")}</th>
<th>{t("bizState.colActions")}</th>
</tr>
</thead>
<tbody>
{filteredTasks.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 || "—"}
</div>
{row.last_error ? <div className="form-error">{row.last_error}</div> : null}
</td>
<td>
<NmStatusChip color={sourceChipColor(row.source)}>
{row.source || "managed"}
</NmStatusChip>
</td>
<td>
<div className="pt-list-actions" style={{ flexWrap: "wrap", gap: 4 }}>
<NmStatusChip color={jobChipColor(row.status)}>{row.status}</NmStatusChip>
{row.collect_running ? (
<NmStatusChip color="accent">{t("bizState.collecting")}</NmStatusChip>
) : null}
</div>
</td>
<td className="pt-list-time">{fmtTime(row.last_collect_ended_at)}</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.status !== "running" ? (
<Button
size="sm"
variant="ghost"
isDisabled={busy}
onPress={async () => {
setBusy(true);
try {
await bizStatePatchTask(row.id, { status: "running" });
showOk(t("bizState.started"));
await refreshTasks();
} catch (e) {
showError(formatErr(e));
} finally {
setBusy(false);
}
}}
>
{t("bizState.start")}
</Button>
) : (
<Button
size="sm"
variant="ghost"
isDisabled={busy}
onPress={async () => {
setBusy(true);
try {
await bizStatePatchTask(row.id, { status: "paused" });
showOk(t("bizState.paused"));
await refreshTasks();
} catch (e) {
showError(formatErr(e));
} finally {
setBusy(false);
}
}}
>
{t("bizState.pause")}
</Button>
)}
<Button
size="sm"
variant="danger"
isDisabled={busy}
onPress={() => void removeTask(row.id)}
>
{t("bizState.delete")}
</Button>
</div>
</td>
</tr>
))}
{!filteredTasks.length ? (
<tr>
<td colSpan={5}>
<div className="pt-list-empty">{t("bizState.empty")}</div>
</td>
</tr>
) : null}
</tbody>
</table>
</div>
</div>
{/* Create task: pick any CLI target with search */}
<AppModalShell open={createOpen} onClose={closeCreate} size="lg">
<Modal.Header>
<Modal.Heading>{t("bizState.create")}</Modal.Heading>
<Modal.CloseTrigger />
</Modal.Header>
<Modal.Body className="flex flex-col gap-3">
<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}
/>
</Modal.Body>
<Modal.Footer>
<Button
size="sm"
variant="primary"
isDisabled={busy || !selectedNe}
onPress={() => void createTask()}
>
{t("bizState.create")}
</Button>
<Button size="sm" variant="ghost" onPress={closeCreate}>
{t("bizState.cancel")}
</Button>
</Modal.Footer>
</AppModalShell>
{/* Task detail modal */}
<AppModalShell open={Boolean(taskId)} onClose={closeTask} size="lg" className="app-heroui-modal--xl">
<Modal.Header>
<Modal.Heading>
{detail?.ne_name || detail?.ne_ip || t("bizState.detail")}
{detail ? ` · ${detail.status}` : ""}
</Modal.Heading>
<Modal.CloseTrigger />
</Modal.Header>
<Modal.Body className="flex flex-col gap-3">
{detail ? (
<p className="muted">
{detail.vendor || "—"} · {detail.ne_ip || "—"} · interval {detail.interval_sec}s
</p>
) : null}
{detail?.last_error ? <p className="form-error">{detail.last_error}</p> : null}
<div className="btn-row nm-config-modal__tabs">
<Button
size="sm"
variant={taskTab === "profiles" ? "primary" : "secondary"}
onPress={() => setTaskTab("profiles")}
>
{t("bizState.profiles")}
</Button>
<Button
size="sm"
variant={taskTab === "batches" ? "primary" : "secondary"}
onPress={() => setTaskTab("batches")}
>
{t("bizState.batches")}
</Button>
</div>
{taskTab === "profiles" ? (
<>
<div className="pt-list-table-wrap">
<table className="data-table pt-list-table">
<thead>
<tr>
<th>{t("bizState.enable")}</th>
<th>{t("bizState.profiles")}</th>
<th>{t("bizState.command")}</th>
<th>{t("bizState.params")}</th>
<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 || [];
const needsBind = (prof.placeholders || []).length > 0;
return (
<tr key={prof.profile_id}>
<td>
<input
type="checkbox"
checked={enabled}
disabled={busy}
onChange={(e) => void toggleProfileItem(prof, e.target.checked)}
/>
</td>
<td>
<div className="pt-list-task-name">{prof.title}</div>
{prof.description ? <div className="muted">{prof.description}</div> : null}
</td>
<td>
<code>{prof.command_template}</code>
</td>
<td>
{needsBind
? binds.length
? binds.map((b: any) => b.value).join(", ")
: t("bizState.unbound")
: "—"}
</td>
<td>
{needsBind && enabled && it ? (
<Button
size="sm"
variant="secondary"
isDisabled={busy}
onPress={() => void startDiscover(it)}
>
{t("bizState.discoverVrf")}
</Button>
) : null}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{bindItemId && candidates.length ? (
<div className="bs-bind-panel">
<h4 style={{ margin: "0 0 8px" }}>{t("bizState.bindTitle")}</h4>
<p className="muted">
<code>{discoverCmd}</code> · {selectedVrfs.length}
</p>
<div className="bs-bind-list">
{candidates.map((c) => (
<label key={c.value} className="bs-bind-item">
<input
type="checkbox"
checked={selectedVrfs.includes(c.value)}
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>
<div className="btn-row" style={{ marginTop: 8 }}>
<Button
size="sm"
variant="primary"
isDisabled={busy || !selectedVrfs.length}
onPress={() => void saveBindings()}
>
{t("bizState.saveBindings")}
</Button>
<Button
size="sm"
variant="secondary"
isDisabled={busy}
onPress={() => {
setBindItemId("");
setCandidates([]);
}}
>
{t("bizState.cancel")}
</Button>
</div>
</div>
) : null}
</>
) : (
<div className="pt-list-table-wrap">
<table className="data-table pt-list-table">
<thead>
<tr>
<th>{t("bizState.colTime")}</th>
<th>{t("bizState.colStatus")}</th>
<th>{t("bizState.colRows")}</th>
<th>{t("bizState.colActions")}</th>
</tr>
</thead>
<tbody>
{batches.map((b) => (
<tr key={b.id}>
<td className="pt-list-time">{fmtTime(b.started_at)}</td>
<td>
<NmStatusChip color={jobChipColor(b.status)}>{b.status}</NmStatusChip>
</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="primary" onPress={() => void openBatch(b.id)}>
{t("bizState.viewBatch")}
</Button>
<Button size="sm" variant="ghost" onPress={() => void bizStateDownloadExport(b.id)}>
{t("bizState.export")}
</Button>
</div>
</td>
</tr>
))}
{!batches.length ? (
<tr>
<td colSpan={4}>
<div className="pt-list-empty">{t("bizState.noBatches")}</div>
</td>
</tr>
) : null}
</tbody>
</table>
</div>
)}
</Modal.Body>
<Modal.Footer>
<Button size="sm" variant="primary" isDisabled={busy} onPress={() => void setStatus("running")}>
{t("bizState.start")}
</Button>
<Button size="sm" variant="secondary" isDisabled={busy} onPress={() => void setStatus("paused")}>
{t("bizState.pause")}
</Button>
<Button size="sm" variant="secondary" isDisabled={busy} onPress={() => void collectNow()}>
{t("bizState.collectNow")}
</Button>
<Button size="sm" variant="danger" isDisabled={busy} onPress={() => void removeTask(taskId)}>
{t("bizState.delete")}
</Button>
<Button size="sm" variant="ghost" onPress={closeTask}>
{t("bizState.cancel")}
</Button>
</Modal.Footer>
</AppModalShell>
{/* Batch workbook: one sheet per monitor metric */}
<AppModalShell
open={Boolean(batchDetail)}
onClose={closeBatch}
size="lg"
className="app-heroui-modal--xl bs-workbook-modal"
>
<Modal.Header>
<Modal.Heading>
{t("bizState.batchWorkbook")} · {batchDetail?.id?.slice(0, 8)}…
</Modal.Heading>
<Modal.CloseTrigger />
</Modal.Header>
<Modal.Body className="flex flex-col gap-2 bs-workbook-body">
{batchDetail ? (
<p className="muted">
{batchDetail.status} · {batchDetail.command_count} cmd · {batchDetail.row_count} rows ·{" "}
{fmtTime(batchDetail.started_at)}
</p>
) : null}
{activeSheet ? (
<>
<div className="filter-inline bs-sheet-filter">
<FieldSelect
value={sheetColumn}
onChange={(e) => setSheetColumn(e.target.value)}
aria-label={t("bizState.filterColumn")}
>
<option value="">{t("bizState.filterAllCols")}</option>
{activeSheet.columns.map((c) => (
<option key={c.key} value={c.key}>
{c.header}
</option>
))}
</FieldSelect>
<Input
value={sheetKeyword}
placeholder={t("bizState.sheetFilterPh")}
onChange={(e) => setSheetKeyword(e.target.value)}
/>
<span className="muted bs-sheet-count">
{filteredSheetRows.length}/{activeSheet.rows.length}
</span>
</div>
<div className="pt-list-table-wrap bs-sheet-table">
<table className="data-table pt-list-table">
<thead>
<tr>
{activeSheet.columns.map((c) => (
<th key={c.key}>{c.header}</th>
))}
</tr>
</thead>
<tbody>
{filteredSheetRows.slice(0, 2000).map((row, i) => (
<tr key={i}>
{activeSheet.columns.map((c) => (
<td key={c.key}>
{c.key === "parse_status" ? (
<NmStatusChip color={jobChipColor(cellText(row[c.key]))}>
{cellText(row[c.key]) || "—"}
</NmStatusChip>
) : (
cellText(row[c.key]) || "—"
)}
</td>
))}
</tr>
))}
{!filteredSheetRows.length ? (
<tr>
<td colSpan={activeSheet.columns.length}>
<div className="pt-list-empty">{t("bizState.sheetEmpty")}</div>
</td>
</tr>
) : null}
</tbody>
</table>
</div>
</>
) : (
<div className="pt-list-empty">{t("bizState.sheetEmpty")}</div>
)}
<div className="bs-sheet-tabs" role="tablist" aria-label={t("bizState.batchWorkbook")}>
{sheets.map((s) => (
<button
key={s.id}
type="button"
role="tab"
aria-selected={activeSheet?.id === s.id}
className={`bs-sheet-tab${activeSheet?.id === s.id ? " is-active" : ""}`}
onClick={() => {
setSheetId(s.id);
setSheetKeyword("");
setSheetColumn("");
}}
>
{s.title}
<span className="bs-sheet-tab__count">{s.rows.length}</span>
</button>
))}
{!sheets.length ? <span className="muted">{t("bizState.sheetEmpty")}</span> : null}
</div>
</Modal.Body>
<Modal.Footer>
{batchDetail ? (
<Button size="sm" variant="secondary" onPress={() => void bizStateDownloadExport(batchDetail.id)}>
{t("bizState.export")}
</Button>
) : null}
<Button size="sm" variant="ghost" onPress={closeBatch}>
{t("bizState.cancel")}
</Button>
</Modal.Footer>
</AppModalShell>
</section>
);
}