mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 04:20:45 +08:00
Paginate biz-state batch workbook and expose raw CLI logs.
Load batch summaries without metric payloads, fetch sheet rows on demand, and show collect commands with a raw-log viewer. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
a769e603b7
commit
18a0c41196
8 changed files with 835 additions and 178 deletions
|
|
@ -248,8 +248,16 @@ const en = {
|
|||
sheetArp: "ARP",
|
||||
sheetNd6: "ND6 cache",
|
||||
sheetBgpPeer: "BGP peers",
|
||||
sheetConfigIface: "Interface intent",
|
||||
sheetConfigVrf: "VRF intent",
|
||||
sheetConfigBgp: "BGP intent",
|
||||
sheetFilterPh: "Filter this sheet…",
|
||||
sheetEmpty: "No rows in this sheet",
|
||||
sheetLoading: "Loading…",
|
||||
collectCommand: "Collect command",
|
||||
viewRawLog: "Raw log",
|
||||
rawLogTitle: "Raw collect log",
|
||||
rawLogEmpty: "No raw output",
|
||||
filterColumn: "Column",
|
||||
filterAllCols: "All columns",
|
||||
listFilterPh: "Filter NE / IP / vendor / status",
|
||||
|
|
|
|||
|
|
@ -248,8 +248,16 @@ const zh = {
|
|||
sheetArp: "ARP",
|
||||
sheetNd6: "ND6 Cache",
|
||||
sheetBgpPeer: "BGP 对等体",
|
||||
sheetConfigIface: "接口 Intent",
|
||||
sheetConfigVrf: "VRF Intent",
|
||||
sheetConfigBgp: "BGP Intent",
|
||||
sheetFilterPh: "筛选当前表…",
|
||||
sheetEmpty: "本表无数据",
|
||||
sheetLoading: "加载中…",
|
||||
collectCommand: "采集命令",
|
||||
viewRawLog: "原始日志",
|
||||
rawLogTitle: "原始采集日志",
|
||||
rawLogEmpty: "无原始输出",
|
||||
filterColumn: "筛选列",
|
||||
filterAllCols: "全部列",
|
||||
listFilterPh: "筛选网元 / IP / 厂商 / 状态",
|
||||
|
|
|
|||
|
|
@ -9946,6 +9946,98 @@ html.login-page--paused .login-page__flare {
|
|||
min-height: min(62vh, 640px);
|
||||
}
|
||||
|
||||
.bs-workbook-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px 12px;
|
||||
}
|
||||
|
||||
.bs-sheet-cmd-bar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.22);
|
||||
background: rgba(15, 23, 42, 0.45);
|
||||
}
|
||||
|
||||
.bs-sheet-cmd-bar__label {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.bs-sheet-cmd-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.bs-sheet-cmd-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.bs-sheet-cmd-code {
|
||||
flex: 1 1 240px;
|
||||
min-width: 0;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
color: #e2e8f0;
|
||||
background: rgba(2, 6, 23, 0.55);
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
border-radius: 6px;
|
||||
padding: 6px 8px;
|
||||
overflow: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bs-sheet-cmd-code--block {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.bs-sheet-cmd-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.bs-inline-cmd {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.bs-rawlog-body {
|
||||
min-height: min(50vh, 480px);
|
||||
}
|
||||
|
||||
.bs-rawlog-pre {
|
||||
flex: 1 1 auto;
|
||||
margin: 0;
|
||||
padding: 12px 14px;
|
||||
max-height: min(56vh, 560px);
|
||||
overflow: auto;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.22);
|
||||
background: rgba(2, 6, 23, 0.72);
|
||||
color: #e2e8f0;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.bs-sheet-filter {
|
||||
align-items: center;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,8 +15,10 @@ import {
|
|||
bizStateDiscover,
|
||||
bizStateDownloadExport,
|
||||
bizStateGetBatch,
|
||||
bizStateGetBatchCommand,
|
||||
bizStateGetTask,
|
||||
bizStateListBatches,
|
||||
bizStateListBatchMetricRows,
|
||||
bizStateListProfiles,
|
||||
bizStateListTasks,
|
||||
bizStatePatchTask,
|
||||
|
|
@ -71,17 +73,30 @@ type BatchRow = {
|
|||
type Candidate = { value: string; label: string; rd?: string };
|
||||
|
||||
type SheetCol = { key: string; header: string };
|
||||
type SheetDef = {
|
||||
|
||||
type SheetCmd = {
|
||||
id: string;
|
||||
raw_command: string;
|
||||
parse_status?: string;
|
||||
row_count?: number;
|
||||
message?: string;
|
||||
has_raw?: boolean;
|
||||
profile_id?: string;
|
||||
};
|
||||
|
||||
type SheetTab = {
|
||||
id: string;
|
||||
title: string;
|
||||
columns: SheetCol[];
|
||||
rows: Record<string, unknown>[];
|
||||
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,
|
||||
|
|
@ -156,93 +171,39 @@ function metricSheetTitle(metricId: string, t: (k: string) => string): string {
|
|||
lldp_neighbor: t("bizState.sheetLldp"),
|
||||
isis_adjacency: t("bizState.sheetIsis"),
|
||||
interface_brief: t("bizState.sheetIfaceBrief"),
|
||||
interface_detail: t("bizState.sheetIfaceBrief"),
|
||||
arp: t("bizState.sheetArp"),
|
||||
nd6_cache: t("bizState.sheetNd6"),
|
||||
bgp_peer: t("bizState.sheetBgpPeer"),
|
||||
config_interface: t("bizState.sheetConfigIface"),
|
||||
config_vrf: t("bizState.sheetConfigVrf"),
|
||||
config_bgp_peer: t("bizState.sheetConfigBgp"),
|
||||
};
|
||||
return map[metricId] || metricId;
|
||||
}
|
||||
|
||||
function rowsForMetric(batch: any, metricId: string): Record<string, unknown>[] {
|
||||
const mid = String(metricId || "").trim();
|
||||
if (!mid) return [];
|
||||
const fromGeneric = (batch?.metrics || {})[mid];
|
||||
if (Array.isArray(fromGeneric) && fromGeneric.length) {
|
||||
return fromGeneric as Record<string, unknown>[];
|
||||
}
|
||||
if (mid === "lldp_neighbor") {
|
||||
return ((batch?.lldp_neighbors || []) as Record<string, unknown>[]) || [];
|
||||
}
|
||||
return Array.isArray(fromGeneric) ? (fromGeneric as Record<string, unknown>[]) : [];
|
||||
}
|
||||
|
||||
function buildBatchSheets(batch: any, t: (k: string) => string): SheetDef[] {
|
||||
const sheets: SheetDef[] = [];
|
||||
const cmds = (batch?.commands || []) as any[];
|
||||
function buildSheetTabs(batch: any, t: (k: string) => string): SheetTab[] {
|
||||
const tabs: SheetTab[] = [];
|
||||
const cmds = (batch?.commands || []) as SheetCmd[];
|
||||
if (cmds.length) {
|
||||
sheets.push({
|
||||
tabs.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,
|
||||
})),
|
||||
rowCount: cmds.length,
|
||||
commands: cmds,
|
||||
});
|
||||
}
|
||||
|
||||
// Collect metric ids: command order first, then legacy / metrics payload
|
||||
const metricOrder: string[] = [];
|
||||
const pushMid = (mid: string) => {
|
||||
const id = String(mid || "").trim();
|
||||
if (!id || id === "vrf_list" || id === "commands") return;
|
||||
if (!metricOrder.includes(id)) metricOrder.push(id);
|
||||
};
|
||||
for (const c of cmds) pushMid(String(c.metric_id || ""));
|
||||
for (const mid of Object.keys(batch?.metrics || {})) pushMid(mid);
|
||||
if ((batch?.lldp_neighbors || []).length) pushMid("lldp_neighbor");
|
||||
|
||||
for (const mid of metricOrder) {
|
||||
const rows = rowsForMetric(batch, mid);
|
||||
// Still show a sheet when the command ran (even 0 rows) so empty/fail is visible
|
||||
const ran = cmds.some((c) => String(c.metric_id || "") === mid);
|
||||
if (!rows.length && !ran) continue;
|
||||
const columns =
|
||||
rows.length > 0
|
||||
? columnsFromRows(rows)
|
||||
: [{ key: "_empty", header: "—" }];
|
||||
sheets.push({
|
||||
for (const s of (batch?.sheets || []) as any[]) {
|
||||
const mid = String(s?.metric_id || "").trim();
|
||||
if (!mid || mid === "vrf_list") continue;
|
||||
tabs.push({
|
||||
id: mid,
|
||||
title: metricSheetTitle(mid, t),
|
||||
columns,
|
||||
rows,
|
||||
rowCount: Number(s?.row_count || 0),
|
||||
commands: Array.isArray(s?.commands) ? (s.commands as SheetCmd[]) : [],
|
||||
});
|
||||
}
|
||||
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));
|
||||
});
|
||||
return tabs;
|
||||
}
|
||||
|
||||
export function BizStatePage() {
|
||||
|
|
@ -285,12 +246,23 @@ export function BizStatePage() {
|
|||
const [selectedVrfs, setSelectedVrfs] = useState<string[]>([]);
|
||||
const [discoverCmd, setDiscoverCmd] = useState("");
|
||||
|
||||
// batch workbook modal
|
||||
// batch workbook modal (summary + lazy-paged metric sheets)
|
||||
const [batchDetail, setBatchDetail] = useState<any>(null);
|
||||
const [sheetId, setSheetId] = useState("");
|
||||
const [sheetKeyword, setSheetKeyword] = useState("");
|
||||
const [sheetColumn, setSheetColumn] = useState("");
|
||||
const debouncedSheetKw = useDebouncedValue(sheetKeyword, 200);
|
||||
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 [rawLogOpen, setRawLogOpen] = useState(false);
|
||||
const [rawLogLoading, setRawLogLoading] = useState(false);
|
||||
const [rawLogCmd, setRawLogCmd] = useState("");
|
||||
const [rawLogText, setRawLogText] = useState("");
|
||||
const [rawLogMeta, setRawLogMeta] = useState("");
|
||||
|
||||
const refreshTasks = useCallback(async () => {
|
||||
const purpose =
|
||||
|
|
@ -353,25 +325,127 @@ export function BizStatePage() {
|
|||
[profiles],
|
||||
);
|
||||
|
||||
const sheets = useMemo(
|
||||
() => (batchDetail ? buildBatchSheets(batchDetail, t) : []),
|
||||
const sheetTabs = useMemo(
|
||||
() => (batchDetail ? buildSheetTabs(batchDetail, t) : []),
|
||||
[batchDetail, t],
|
||||
);
|
||||
|
||||
const activeSheet = useMemo(() => {
|
||||
if (!sheets.length) return null;
|
||||
return sheets.find((s) => s.id === sheetId) || sheets[0];
|
||||
}, [sheets, sheetId]);
|
||||
if (!sheetTabs.length) return null;
|
||||
return sheetTabs.find((s) => s.id === sheetId) || sheetTabs[0];
|
||||
}, [sheetTabs, sheetId]);
|
||||
|
||||
const filteredSheetRows = useMemo(() => {
|
||||
const commandsSheetColumns = useMemo<SheetCol[]>(
|
||||
() => [
|
||||
{ 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") },
|
||||
{ key: "_actions", header: t("bizState.colActions") },
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
const displayColumns = useMemo(() => {
|
||||
if (!activeSheet) return [];
|
||||
return filterSheetRows(
|
||||
activeSheet.rows,
|
||||
activeSheet.columns,
|
||||
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 cmds = (batchDetail?.commands || []) as Record<string, unknown>[];
|
||||
const kw = debouncedSheetKw.trim().toLowerCase();
|
||||
const filtered = !kw
|
||||
? cmds
|
||||
: cmds.filter((row) => {
|
||||
const keys = sheetColumn
|
||||
? [sheetColumn]
|
||||
: ["raw_command", "metric_id", "parse_status", "row_count", "message"];
|
||||
return keys.some((k) => cellText(row[k]).toLowerCase().includes(kw));
|
||||
});
|
||||
const start = (sheetPage - 1) * sheetPageSize;
|
||||
return filtered.slice(start, start + sheetPageSize).map((c) => ({ ...c }));
|
||||
}
|
||||
return sheetRows;
|
||||
}, [
|
||||
activeSheet,
|
||||
batchDetail,
|
||||
debouncedSheetKw,
|
||||
sheetColumn,
|
||||
sheetPage,
|
||||
sheetPageSize,
|
||||
sheetRows,
|
||||
]);
|
||||
|
||||
const displayTotal = useMemo(() => {
|
||||
if (!activeSheet) return 0;
|
||||
if (activeSheet.id === "commands") {
|
||||
const cmds = (batchDetail?.commands || []) as Record<string, unknown>[];
|
||||
const kw = debouncedSheetKw.trim().toLowerCase();
|
||||
if (!kw) return cmds.length;
|
||||
return cmds.filter((row) => {
|
||||
const keys = sheetColumn
|
||||
? [sheetColumn]
|
||||
: ["raw_command", "metric_id", "parse_status", "row_count", "message"];
|
||||
return keys.some((k) => cellText(row[k]).toLowerCase().includes(kw));
|
||||
}).length;
|
||||
}
|
||||
return sheetTotal;
|
||||
}, [activeSheet, batchDetail, debouncedSheetKw, sheetColumn, sheetTotal]);
|
||||
|
||||
const loadSheetPage = useCallback(
|
||||
async (batchId: string, metricId: string, page: number, pageSize: number, kw: string, column: string) => {
|
||||
if (!batchId || !metricId || metricId === "commands") return;
|
||||
setSheetLoading(true);
|
||||
try {
|
||||
const res = await bizStateListBatchMetricRows({
|
||||
batchId,
|
||||
metricId,
|
||||
page,
|
||||
pageSize,
|
||||
kw,
|
||||
column,
|
||||
});
|
||||
setSheetRows(res.items || []);
|
||||
setSheetTotal(Number(res.total || 0));
|
||||
const cols = (res.columns || []).map((c) => ({
|
||||
key: c.key,
|
||||
header: c.header || c.key,
|
||||
}));
|
||||
setSheetColumns(cols.length ? cols : columnsFromRows(res.items || []));
|
||||
} catch (e) {
|
||||
showError(formatErr(e));
|
||||
setSheetRows([]);
|
||||
setSheetTotal(0);
|
||||
} finally {
|
||||
setSheetLoading(false);
|
||||
}
|
||||
},
|
||||
[showError],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!batchDetail?.id || !activeSheet || activeSheet.id === "commands") return;
|
||||
void loadSheetPage(
|
||||
String(batchDetail.id),
|
||||
activeSheet.id,
|
||||
sheetPage,
|
||||
sheetPageSize,
|
||||
debouncedSheetKw,
|
||||
sheetColumn,
|
||||
);
|
||||
}, [activeSheet, debouncedSheetKw, sheetColumn]);
|
||||
}, [
|
||||
batchDetail?.id,
|
||||
activeSheet,
|
||||
sheetPage,
|
||||
sheetPageSize,
|
||||
debouncedSheetKw,
|
||||
sheetColumn,
|
||||
loadSheetPage,
|
||||
]);
|
||||
|
||||
const openCreate = () => {
|
||||
setCreateOpen(true);
|
||||
|
|
@ -689,10 +763,13 @@ export function BizStatePage() {
|
|||
setBatchDetail(d);
|
||||
setSheetKeyword("");
|
||||
setSheetColumn("");
|
||||
const built = buildBatchSheets(d, t);
|
||||
// Prefer first metric with rows; fall back to commands / first tab
|
||||
setSheetPage(1);
|
||||
setSheetRows([]);
|
||||
setSheetColumns([]);
|
||||
setSheetTotal(0);
|
||||
const built = buildSheetTabs(d, t);
|
||||
const prefer =
|
||||
built.find((s) => s.id !== "commands" && s.rows.length > 0) ||
|
||||
built.find((s) => s.id !== "commands" && s.rowCount > 0) ||
|
||||
built.find((s) => s.id !== "commands") ||
|
||||
built[0];
|
||||
setSheetId(prefer?.id || "");
|
||||
|
|
@ -706,6 +783,48 @@ export function BizStatePage() {
|
|||
setSheetId("");
|
||||
setSheetKeyword("");
|
||||
setSheetColumn("");
|
||||
setSheetPage(1);
|
||||
setSheetRows([]);
|
||||
setSheetColumns([]);
|
||||
setSheetTotal(0);
|
||||
setRawLogOpen(false);
|
||||
setRawLogText("");
|
||||
};
|
||||
|
||||
const openRawLog = async (commandId: string) => {
|
||||
if (!batchDetail?.id || !commandId) return;
|
||||
setRawLogOpen(true);
|
||||
setRawLogLoading(true);
|
||||
setRawLogText("");
|
||||
setRawLogCmd("");
|
||||
setRawLogMeta("");
|
||||
try {
|
||||
const d = await bizStateGetBatchCommand(String(batchDetail.id), commandId);
|
||||
setRawLogCmd(String(d.raw_command || ""));
|
||||
setRawLogText(String(d.raw_text || ""));
|
||||
const bits = [
|
||||
d.parse_status,
|
||||
d.metric_id,
|
||||
d.row_count != null ? `${d.row_count} rows` : "",
|
||||
d.collected_at ? fmtTime(d.collected_at) : "",
|
||||
].filter(Boolean);
|
||||
setRawLogMeta(bits.join(" · "));
|
||||
} catch (e) {
|
||||
showError(formatErr(e));
|
||||
setRawLogOpen(false);
|
||||
} finally {
|
||||
setRawLogLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const selectSheet = (id: string) => {
|
||||
setSheetId(id);
|
||||
setSheetKeyword("");
|
||||
setSheetColumn("");
|
||||
setSheetPage(1);
|
||||
setSheetRows([]);
|
||||
setSheetColumns([]);
|
||||
setSheetTotal(0);
|
||||
};
|
||||
|
||||
const runningCount = tasks.filter((x) => x.status === "running").length;
|
||||
|
|
@ -1344,7 +1463,7 @@ export function BizStatePage() {
|
|||
</Modal.Footer>
|
||||
</AppModalShell>
|
||||
|
||||
{/* Batch workbook: one sheet per monitor metric */}
|
||||
{/* Batch workbook: summary + lazy-paged metric sheets */}
|
||||
<AppModalShell
|
||||
open={Boolean(batchDetail)}
|
||||
onClose={closeBatch}
|
||||
|
|
@ -1359,55 +1478,94 @@ export function BizStatePage() {
|
|||
</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>
|
||||
<div className="bs-workbook-meta">
|
||||
<NmStatusChip color={jobChipColor(String(batchDetail.status || ""))}>
|
||||
{String(batchDetail.status || "—")}
|
||||
</NmStatusChip>
|
||||
<span className="muted">
|
||||
{batchDetail.command_count} cmd · {batchDetail.row_count} rows ·{" "}
|
||||
{fmtTime(batchDetail.started_at)}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="bs-sheet-tabs bs-sheet-tabs--top" role="tablist" aria-label={t("bizState.batchWorkbook")}>
|
||||
{sheets.map((s) => (
|
||||
{sheetTabs.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("");
|
||||
}}
|
||||
onClick={() => selectSheet(s.id)}
|
||||
>
|
||||
{s.title}
|
||||
<span className="bs-sheet-tab__count">{s.rows.length}</span>
|
||||
<span className="bs-sheet-tab__count">{s.rowCount}</span>
|
||||
</button>
|
||||
))}
|
||||
{!sheets.length ? <span className="muted">{t("bizState.sheetEmpty")}</span> : null}
|
||||
{!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">
|
||||
{c.parse_status ? (
|
||||
<NmStatusChip color={jobChipColor(String(c.parse_status))}>
|
||||
{c.parse_status}
|
||||
</NmStatusChip>
|
||||
) : null}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
isDisabled={!c.has_raw}
|
||||
onPress={() => void openRawLog(c.id)}
|
||||
>
|
||||
{t("bizState.viewRawLog")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="filter-inline bs-sheet-filter">
|
||||
<FieldSelect
|
||||
value={sheetColumn}
|
||||
onChange={(e) => setSheetColumn(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setSheetColumn(e.target.value);
|
||||
setSheetPage(1);
|
||||
}}
|
||||
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>
|
||||
))}
|
||||
{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}
|
||||
placeholder={t("bizState.sheetFilterPh")}
|
||||
onChange={(e) => setSheetKeyword(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setSheetKeyword(e.target.value);
|
||||
setSheetPage(1);
|
||||
}}
|
||||
/>
|
||||
<span className="muted bs-sheet-count">
|
||||
{filteredSheetRows.length}/{activeSheet.rows.length}
|
||||
{sheetLoading ? t("bizState.sheetLoading") : `${displayTotal} ${t("bizState.colRows")}`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
|
@ -1415,20 +1573,31 @@ export function BizStatePage() {
|
|||
<table className="data-table pt-list-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{activeSheet.columns.map((c) => (
|
||||
{displayColumns.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) => (
|
||||
{displayRows.map((row, i) => (
|
||||
<tr key={String(row.id || i)}>
|
||||
{displayColumns.map((c) => (
|
||||
<td key={c.key}>
|
||||
{c.key === "parse_status" ? (
|
||||
{c.key === "_actions" ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
isDisabled={!row.has_raw}
|
||||
onPress={() => void openRawLog(String(row.id || ""))}
|
||||
>
|
||||
{t("bizState.viewRawLog")}
|
||||
</Button>
|
||||
) : 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]) || "—"
|
||||
)}
|
||||
|
|
@ -1436,16 +1605,37 @@ export function BizStatePage() {
|
|||
))}
|
||||
</tr>
|
||||
))}
|
||||
{!filteredSheetRows.length ? (
|
||||
{!displayRows.length && !sheetLoading ? (
|
||||
<tr>
|
||||
<td colSpan={Math.max(1, activeSheet.columns.length)}>
|
||||
<td colSpan={Math.max(1, displayColumns.length)}>
|
||||
<div className="pt-list-empty">{t("bizState.sheetEmpty")}</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{sheetLoading && activeSheet.id !== "commands" && !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>
|
||||
|
||||
<ListPager
|
||||
page={sheetPage}
|
||||
pages={pageCount(displayTotal, sheetPageSize)}
|
||||
total={displayTotal}
|
||||
pageSize={sheetPageSize}
|
||||
pageSizeOptions={SHEET_PAGE_SIZE_OPTIONS}
|
||||
disabled={sheetLoading}
|
||||
onPageChange={setSheetPage}
|
||||
onPageSizeChange={(n) => {
|
||||
setSheetPageSize(n);
|
||||
setSheetPage(1);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="pt-list-empty">{t("bizState.sheetEmpty")}</div>
|
||||
|
|
@ -1462,6 +1652,33 @@ export function BizStatePage() {
|
|||
</Button>
|
||||
</Modal.Footer>
|
||||
</AppModalShell>
|
||||
|
||||
{/* Raw CLI log viewer */}
|
||||
<AppModalShell
|
||||
open={rawLogOpen}
|
||||
onClose={() => setRawLogOpen(false)}
|
||||
size="lg"
|
||||
className="app-heroui-modal--lg bs-rawlog-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}
|
||||
{rawLogMeta ? <p className="muted">{rawLogMeta}</p> : 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="ghost" onPress={() => setRawLogOpen(false)}>
|
||||
{t("bizState.cancel")}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</AppModalShell>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1841,6 +1841,49 @@ export const bizStatePurgeTask = (taskId: string) =>
|
|||
export const bizStateGetBatch = (batchId: string) =>
|
||||
apiGet<Record<string, unknown>>(`/v1/biz-state/batches/${encodeURIComponent(batchId)}`);
|
||||
|
||||
export const bizStateListBatchMetricRows = (params: {
|
||||
batchId: string;
|
||||
metricId: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
kw?: string;
|
||||
column?: string;
|
||||
}) => {
|
||||
const p = new URLSearchParams();
|
||||
p.set("page", String(Math.max(1, Number(params.page || 1))));
|
||||
p.set("page_size", String(Math.max(1, Math.min(200, Number(params.pageSize || 50)))));
|
||||
if (params.kw) p.set("kw", params.kw);
|
||||
if (params.column) p.set("column", params.column);
|
||||
return apiGet<{
|
||||
batch_id: string;
|
||||
metric_id: string;
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
pages: number;
|
||||
columns: Array<{ key: string; header: string; role?: string; is_key?: boolean }>;
|
||||
items: Record<string, unknown>[];
|
||||
}>(
|
||||
`/v1/biz-state/batches/${encodeURIComponent(params.batchId)}/metrics/${encodeURIComponent(params.metricId)}?${p.toString()}`,
|
||||
);
|
||||
};
|
||||
|
||||
export const bizStateGetBatchCommand = (batchId: string, commandId: string) =>
|
||||
apiGet<{
|
||||
id: string;
|
||||
batch_id: string;
|
||||
raw_command: string;
|
||||
metric_id: string;
|
||||
parse_status: string;
|
||||
row_count: number;
|
||||
message: string;
|
||||
raw_text: string;
|
||||
collected_at?: string | null;
|
||||
device?: { ne_id?: string; ne_name?: string; ne_ip?: string };
|
||||
}>(
|
||||
`/v1/biz-state/batches/${encodeURIComponent(batchId)}/commands/${encodeURIComponent(commandId)}`,
|
||||
);
|
||||
|
||||
export const bizStateDownloadExport = async (batchId: string): Promise<void> => {
|
||||
const path = `/v1/biz-state/batches/${encodeURIComponent(batchId)}/export`;
|
||||
const res = await fetch(path, { method: "GET", credentials: fetchCreds, headers: authHeaders() });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue