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:
oliver 2026-09-20 10:13:45 +08:00
parent a769e603b7
commit 18a0c41196
8 changed files with 835 additions and 178 deletions

View file

@ -9,6 +9,7 @@ from typing import Any
from uuid import uuid4 from uuid import uuid4
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy import String, cast, func, or_
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from ..lldp_shared import resolve_vendor_key from ..lldp_shared import resolve_vendor_key
@ -26,7 +27,13 @@ from ..models import (
) )
from ..timeutil import utcnow_naive from ..timeutil import utcnow_naive
from .command_match import preview_task_item from .command_match import preview_task_item
from .profiles import all_profiles, get_profile, profile_to_public_dict, profiles_for_vendor from .profiles import (
all_profiles,
get_profile,
metric_field_map,
profile_to_public_dict,
profiles_for_vendor,
)
from .retention import ( from .retention import (
batch_protect_info, batch_protect_info,
delete_batch_data, delete_batch_data,
@ -540,6 +547,7 @@ def run_purge_for_task(db: Session, task_id: str) -> dict[str, Any]:
def get_batch(db: Session, batch_id: str) -> dict[str, Any]: def get_batch(db: Session, batch_id: str) -> dict[str, Any]:
"""Batch workbook summary: meta + commands + sheet catalog (no metric row payload)."""
b = db.get(BizStateBatch, batch_id) b = db.get(BizStateBatch, batch_id)
if not b: if not b:
raise HTTPException(status_code=404, detail="batch_not_found") raise HTTPException(status_code=404, detail="batch_not_found")
@ -549,29 +557,87 @@ def get_batch(db: Session, batch_id: str) -> dict[str, Any]:
.order_by(BizStateBatchCommand.created_at.asc()) .order_by(BizStateBatchCommand.created_at.asc())
.all() .all()
) )
neighbors = (
db.query(BizStateLldpNeighbor)
.filter(BizStateLldpNeighbor.batch_id == batch_id)
.order_by(BizStateLldpNeighbor.local_if.asc())
.limit(5000)
.all()
)
metric_rows = (
db.query(BizStateMetricRow)
.filter(BizStateMetricRow.batch_id == batch_id)
.order_by(
BizStateMetricRow.metric_id.asc(),
BizStateMetricRow.seq.asc(),
BizStateMetricRow.id.asc(),
)
.limit(20000)
.all()
)
metrics_by_id: dict[str, list[dict[str, Any]]] = {}
for r in metric_rows:
mid = str(r.metric_id or "")
metrics_by_id.setdefault(mid, []).append(dict(r.data_json or {}))
protect = batch_protect_info(db, batch_id) protect = batch_protect_info(db, batch_id)
# Per-metric row counts (generic table)
metric_counts: dict[str, int] = {}
for mid, cnt in (
db.query(BizStateMetricRow.metric_id, func.count(BizStateMetricRow.id))
.filter(BizStateMetricRow.batch_id == batch_id)
.group_by(BizStateMetricRow.metric_id)
.all()
):
key = str(mid or "").strip()
if key:
metric_counts[key] = int(cnt or 0)
lldp_count = (
db.query(func.count(BizStateLldpNeighbor.id))
.filter(BizStateLldpNeighbor.batch_id == batch_id)
.scalar()
)
lldp_n = int(lldp_count or 0)
if lldp_n:
metric_counts["lldp_neighbor"] = lldp_n
cmd_payload: list[dict[str, Any]] = []
sheets_order: list[str] = []
sheet_cmds: dict[str, list[dict[str, Any]]] = {}
def _push_sheet(mid: str, cmd_info: dict[str, Any] | None = None) -> None:
id_ = str(mid or "").strip()
if not id_ or id_ in ("vrf_list", "commands"):
return
if id_ not in sheets_order:
sheets_order.append(id_)
sheet_cmds.setdefault(id_, [])
if cmd_info is not None:
sheet_cmds[id_].append(cmd_info)
for c in cmds:
info = {
"id": c.id,
"profile_id": c.profile_id,
"parser_id": c.parser_id,
"metric_id": c.metric_id,
"raw_command": c.raw_command,
"params": c.params_json or {},
"parse_status": c.parse_status,
"row_count": c.row_count,
"message": c.message,
"has_raw": bool(str(c.raw_text or "").strip()),
}
cmd_payload.append(info)
mid = str(c.metric_id or "").strip()
if mid and mid not in ("", "vrf_list"):
_push_sheet(
mid,
{
"id": c.id,
"raw_command": c.raw_command,
"parse_status": c.parse_status,
"row_count": c.row_count,
"message": c.message,
"has_raw": info["has_raw"],
"profile_id": c.profile_id,
},
)
# Aux command rows may have empty metric_id — still attach by profile if needed later
for mid in metric_counts:
if mid not in sheets_order:
sheets_order.append(mid)
sheet_cmds.setdefault(mid, [])
sheets = [
{
"metric_id": mid,
"row_count": int(metric_counts.get(mid) or 0),
"commands": list(sheet_cmds.get(mid) or []),
}
for mid in sheets_order
]
return { return {
"id": b.id, "id": b.id,
"task_id": b.task_id, "task_id": b.task_id,
@ -587,22 +653,77 @@ def get_batch(db: Session, batch_id: str) -> dict[str, Any]:
else None, else None,
"protected": bool(protect.get("protected")), "protected": bool(protect.get("protected")),
"protect_reasons": list(protect.get("reasons") or []), "protect_reasons": list(protect.get("reasons") or []),
"commands": [ "commands": cmd_payload,
{ "sheets": sheets,
"id": c.id, }
"profile_id": c.profile_id,
"parser_id": c.parser_id,
"metric_id": c.metric_id, def list_batch_metric_rows(
"raw_command": c.raw_command, db: Session,
"params": c.params_json or {}, batch_id: str,
"parse_status": c.parse_status, metric_id: str,
"row_count": c.row_count, *,
"message": c.message, page: int = 1,
"raw_text_preview": (c.raw_text or "")[:2000], page_size: int = 50,
} kw: str = "",
for c in cmds column: str = "",
], ) -> dict[str, Any]:
"lldp_neighbors": [ """Paginated rows for one batch metric sheet (server-side filter)."""
b = db.get(BizStateBatch, batch_id)
if not b:
raise HTTPException(status_code=404, detail="batch_not_found")
mid = str(metric_id or "").strip()
if not mid or mid in ("commands", "vrf_list"):
raise HTTPException(status_code=400, detail="invalid_metric_id")
page_n = max(1, int(page or 1))
size_n = max(1, min(200, int(page_size or 50)))
kw_n = str(kw or "").strip()
col_n = str(column or "").strip()
fields = metric_field_map().get(mid) or []
columns = [
{
"key": f.name,
"header": f.display_name or f.name,
"role": f.role,
"is_key": bool(f.is_key),
}
for f in fields
]
if mid == "lldp_neighbor":
q = db.query(BizStateLldpNeighbor).filter(BizStateLldpNeighbor.batch_id == batch_id)
if kw_n:
like = f"%{kw_n}%"
if col_n == "local_if":
q = q.filter(BizStateLldpNeighbor.local_if.ilike(like))
elif col_n == "remote_sys":
q = q.filter(BizStateLldpNeighbor.remote_sys.ilike(like))
elif col_n == "remote_if":
q = q.filter(BizStateLldpNeighbor.remote_if.ilike(like))
elif col_n == "remote_ip":
q = q.filter(BizStateLldpNeighbor.remote_ip.ilike(like))
elif col_n == "protocol":
q = q.filter(BizStateLldpNeighbor.protocol.ilike(like))
else:
q = q.filter(
or_(
BizStateLldpNeighbor.local_if.ilike(like),
BizStateLldpNeighbor.remote_sys.ilike(like),
BizStateLldpNeighbor.remote_if.ilike(like),
BizStateLldpNeighbor.remote_ip.ilike(like),
BizStateLldpNeighbor.protocol.ilike(like),
)
)
total = int(q.count() or 0)
rows_db = (
q.order_by(BizStateLldpNeighbor.local_if.asc())
.offset((page_n - 1) * size_n)
.limit(size_n)
.all()
)
items = [
{ {
"local_if": n.local_if, "local_if": n.local_if,
"remote_sys": n.remote_sys, "remote_sys": n.remote_sys,
@ -610,9 +731,56 @@ def get_batch(db: Session, batch_id: str) -> dict[str, Any]:
"remote_ip": n.remote_ip, "remote_ip": n.remote_ip,
"protocol": n.protocol, "protocol": n.protocol,
} }
for n in neighbors for n in rows_db
], ]
"metrics": metrics_by_id, if not columns:
columns = [
{"key": "local_if", "header": "local_if", "role": "identity", "is_key": True},
{"key": "remote_sys", "header": "remote_sys", "role": "identity", "is_key": True},
{"key": "remote_if", "header": "remote_if", "role": "identity", "is_key": True},
{"key": "remote_ip", "header": "remote_ip", "role": "meta", "is_key": False},
{"key": "protocol", "header": "protocol", "role": "meta", "is_key": False},
]
else:
q = db.query(BizStateMetricRow).filter(
BizStateMetricRow.batch_id == batch_id,
BizStateMetricRow.metric_id == mid,
)
if kw_n:
like = f"%{kw_n}%"
if col_n:
# JSON path as text — works on Postgres JSONB and SQLite JSON
q = q.filter(cast(BizStateMetricRow.data_json[col_n], String).ilike(like))
else:
q = q.filter(cast(BizStateMetricRow.data_json, String).ilike(like))
total = int(q.count() or 0)
rows_db = (
q.order_by(BizStateMetricRow.seq.asc(), BizStateMetricRow.id.asc())
.offset((page_n - 1) * size_n)
.limit(size_n)
.all()
)
items = [dict(r.data_json or {}) for r in rows_db]
if not columns and items:
keys: list[str] = []
for rec in items:
for k in rec.keys():
if k not in keys:
keys.append(str(k))
columns = [
{"key": k, "header": k, "role": "identity", "is_key": False} for k in keys
]
pages = max(1, (total + size_n - 1) // size_n) if total else 1
return {
"batch_id": batch_id,
"metric_id": mid,
"total": total,
"page": page_n,
"page_size": size_n,
"pages": pages,
"columns": columns,
"items": items,
} }
@ -655,7 +823,6 @@ def export_batch_zip(db: Session, batch_id: str) -> bytes:
detail = get_batch(db, batch_id) detail = get_batch(db, batch_id)
buf = io.BytesIO() buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED) as zf: with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED) as zf:
# manifest
lines = [ lines = [
f"batch_id={detail['id']}", f"batch_id={detail['id']}",
f"task_id={detail['task_id']}", f"task_id={detail['task_id']}",
@ -674,41 +841,61 @@ def export_batch_zip(db: Session, batch_id: str) -> bytes:
for c in detail["commands"]: for c in detail["commands"]:
safe = "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in c["raw_command"])[:80] safe = "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in c["raw_command"])[:80]
zf.writestr(f"raw/{c['id']}_{safe}.txt", c.get("raw_text_preview") or "")
# full raw from DB
row = db.get(BizStateBatchCommand, c["id"]) row = db.get(BizStateBatchCommand, c["id"])
if row and row.raw_text: raw = (row.raw_text if row else "") or ""
zf.writestr(f"raw/{c['id']}_{safe}.full.txt", row.raw_text) if raw:
zf.writestr(f"raw/{c['id']}_{safe}.full.txt", raw)
zf.writestr(f"raw/{c['id']}_{safe}.txt", raw[:2000])
# CSV # LLDP CSV
neighbors = (
db.query(BizStateLldpNeighbor)
.filter(BizStateLldpNeighbor.batch_id == batch_id)
.order_by(BizStateLldpNeighbor.local_if.asc())
.all()
)
csv_lines = ["local_if,remote_sys,remote_if,remote_ip,protocol"] csv_lines = ["local_if,remote_sys,remote_if,remote_ip,protocol"]
for n in detail["lldp_neighbors"]: for n in neighbors:
csv_lines.append( csv_lines.append(
",".join( ",".join(
[ [
_csv(n["local_if"]), _csv(n.local_if),
_csv(n["remote_sys"]), _csv(n.remote_sys),
_csv(n["remote_if"]), _csv(n.remote_if),
_csv(n["remote_ip"]), _csv(n.remote_ip),
_csv(n["protocol"]), _csv(n.protocol),
] ]
) )
) )
zf.writestr("tables/lldp_neighbor.csv", "\n".join(csv_lines) + "\n") zf.writestr("tables/lldp_neighbor.csv", "\n".join(csv_lines) + "\n")
for mid, rows in sorted((detail.get("metrics") or {}).items()): # Generic metrics CSV (stream by metric_id)
for sheet in detail.get("sheets") or []:
mid = str(sheet.get("metric_id") or "").strip()
if not mid or mid == "lldp_neighbor":
continue
rows = (
db.query(BizStateMetricRow)
.filter(
BizStateMetricRow.batch_id == batch_id,
BizStateMetricRow.metric_id == mid,
)
.order_by(BizStateMetricRow.seq.asc(), BizStateMetricRow.id.asc())
.all()
)
if not rows: if not rows:
continue continue
recs = [dict(r.data_json or {}) for r in rows]
cols: list[str] = [] cols: list[str] = []
for rec in rows: for rec in recs:
for k in rec.keys(): for k in rec.keys():
if k not in cols: if k not in cols:
cols.append(str(k)) cols.append(str(k))
lines = [",".join(_csv(c) for c in cols)] out_lines = [",".join(_csv(c) for c in cols)]
for rec in rows: for rec in recs:
lines.append(",".join(_csv(str(rec.get(c, "") or "")) for c in cols)) out_lines.append(",".join(_csv(str(rec.get(c, "") or "")) for c in cols))
safe = "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in mid)[:80] or "metric" safe = "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in mid)[:80] or "metric"
zf.writestr(f"tables/{safe}.csv", "\n".join(lines) + "\n") zf.writestr(f"tables/{safe}.csv", "\n".join(out_lines) + "\n")
return buf.getvalue() return buf.getvalue()

View file

@ -4,7 +4,7 @@ from __future__ import annotations
from typing import Any from typing import Any
from fastapi import APIRouter, BackgroundTasks, Depends from fastapi import APIRouter, BackgroundTasks, Depends, Query
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@ -237,6 +237,27 @@ def api_get_batch(batch_id: str, db: Session = Depends(get_db)) -> dict[str, Any
return svc.get_batch(db, batch_id) return svc.get_batch(db, batch_id)
@router.get("/batches/{batch_id}/metrics/{metric_id}")
def api_list_batch_metric_rows(
batch_id: str,
metric_id: str,
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
kw: str = Query(""),
column: str = Query(""),
db: Session = Depends(get_db),
) -> dict[str, Any]:
return svc.list_batch_metric_rows(
db,
batch_id,
metric_id,
page=page,
page_size=page_size,
kw=kw,
column=column,
)
@router.get("/batches/{batch_id}/commands/{command_id}") @router.get("/batches/{batch_id}/commands/{command_id}")
def api_get_batch_command( def api_get_batch_command(
batch_id: str, command_id: str, db: Session = Depends(get_db) batch_id: str, command_id: str, db: Session = Depends(get_db)

View file

@ -0,0 +1,81 @@
"""Batch workbook summary + paginated metric rows."""
from __future__ import annotations
import unittest
from unittest.mock import MagicMock, patch
from fastapi import HTTPException
from netx_api.biz_state.service import get_batch, list_batch_metric_rows
from netx_api.models import BizStateBatch, BizStateBatchCommand
class BatchWorkbookApiTests(unittest.TestCase):
def test_get_batch_summary_has_sheets_not_metrics(self) -> None:
batch = BizStateBatch(
id="b1",
task_id="t1",
status="ok",
command_count=1,
row_count=2,
)
cmd = BizStateBatchCommand(
id="c1",
batch_id="b1",
profile_id="zte.arp",
parser_id="arp",
metric_id="arp",
raw_command="show arp | one-line",
parse_status="ok",
row_count=2,
raw_text="RAW",
)
db = MagicMock()
db.get.side_effect = lambda model, pk: batch if pk == "b1" else None
cmd_q = MagicMock()
cmd_q.filter.return_value.order_by.return_value.all.return_value = [cmd]
metric_count_q = MagicMock()
metric_count_q.filter.return_value.group_by.return_value.all.return_value = [("arp", 2)]
lldp_count_q = MagicMock()
lldp_count_q.filter.return_value.scalar.return_value = 0
def query(*_args, **_kwargs):
# First call in get_batch after protect: BatchCommand; then metric count; then lldp
# Distinguish by call count
n = query.n
query.n += 1
if n == 0:
return cmd_q
if n == 1:
return metric_count_q
return lldp_count_q
query.n = 0
db.query.side_effect = query
with patch(
"netx_api.biz_state.service.batch_protect_info",
return_value={"protected": False, "reasons": []},
):
out = get_batch(db, "b1")
self.assertIn("sheets", out)
self.assertNotIn("metrics", out)
self.assertNotIn("lldp_neighbors", out)
self.assertEqual(out["commands"][0]["has_raw"], True)
self.assertEqual(out["sheets"][0]["metric_id"], "arp")
self.assertEqual(out["sheets"][0]["row_count"], 2)
self.assertEqual(out["sheets"][0]["commands"][0]["raw_command"], "show arp | one-line")
def test_list_metric_rows_rejects_commands_sheet(self) -> None:
db = MagicMock()
db.get.return_value = BizStateBatch(id="b1", task_id="t1")
with self.assertRaises(HTTPException) as ctx:
list_batch_metric_rows(db, "b1", "commands")
self.assertEqual(ctx.exception.status_code, 400)
if __name__ == "__main__":
unittest.main()

View file

@ -248,8 +248,16 @@ const en = {
sheetArp: "ARP", sheetArp: "ARP",
sheetNd6: "ND6 cache", sheetNd6: "ND6 cache",
sheetBgpPeer: "BGP peers", sheetBgpPeer: "BGP peers",
sheetConfigIface: "Interface intent",
sheetConfigVrf: "VRF intent",
sheetConfigBgp: "BGP intent",
sheetFilterPh: "Filter this sheet…", sheetFilterPh: "Filter this sheet…",
sheetEmpty: "No rows in 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", filterColumn: "Column",
filterAllCols: "All columns", filterAllCols: "All columns",
listFilterPh: "Filter NE / IP / vendor / status", listFilterPh: "Filter NE / IP / vendor / status",

View file

@ -248,8 +248,16 @@ const zh = {
sheetArp: "ARP", sheetArp: "ARP",
sheetNd6: "ND6 Cache", sheetNd6: "ND6 Cache",
sheetBgpPeer: "BGP 对等体", sheetBgpPeer: "BGP 对等体",
sheetConfigIface: "接口 Intent",
sheetConfigVrf: "VRF Intent",
sheetConfigBgp: "BGP Intent",
sheetFilterPh: "筛选当前表…", sheetFilterPh: "筛选当前表…",
sheetEmpty: "本表无数据", sheetEmpty: "本表无数据",
sheetLoading: "加载中…",
collectCommand: "采集命令",
viewRawLog: "原始日志",
rawLogTitle: "原始采集日志",
rawLogEmpty: "无原始输出",
filterColumn: "筛选列", filterColumn: "筛选列",
filterAllCols: "全部列", filterAllCols: "全部列",
listFilterPh: "筛选网元 / IP / 厂商 / 状态", listFilterPh: "筛选网元 / IP / 厂商 / 状态",

View file

@ -9946,6 +9946,98 @@ html.login-page--paused .login-page__flare {
min-height: min(62vh, 640px); 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 { .bs-sheet-filter {
align-items: center; align-items: center;
} }

View file

@ -15,8 +15,10 @@ import {
bizStateDiscover, bizStateDiscover,
bizStateDownloadExport, bizStateDownloadExport,
bizStateGetBatch, bizStateGetBatch,
bizStateGetBatchCommand,
bizStateGetTask, bizStateGetTask,
bizStateListBatches, bizStateListBatches,
bizStateListBatchMetricRows,
bizStateListProfiles, bizStateListProfiles,
bizStateListTasks, bizStateListTasks,
bizStatePatchTask, bizStatePatchTask,
@ -71,17 +73,30 @@ type BatchRow = {
type Candidate = { value: string; label: string; rd?: string }; type Candidate = { value: string; label: string; rd?: string };
type SheetCol = { key: string; header: 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; id: string;
title: string; title: string;
columns: SheetCol[]; rowCount: number;
rows: Record<string, unknown>[]; commands: SheetCmd[];
}; };
type TaskTab = "profiles" | "batches"; type TaskTab = "profiles" | "batches";
type NeSourceFilter = "all" | "managed" | "ume"; type NeSourceFilter = "all" | "managed" | "ume";
const NE_PAGE_SIZE = 10; const NE_PAGE_SIZE = 10;
const SHEET_PAGE_SIZE_OPTIONS = [20, 50, 100, 200];
const DEFAULT_SHEET_PAGE_SIZE = 50;
function fmtIntervalLabel( function fmtIntervalLabel(
sec: number, sec: number,
@ -156,93 +171,39 @@ function metricSheetTitle(metricId: string, t: (k: string) => string): string {
lldp_neighbor: t("bizState.sheetLldp"), lldp_neighbor: t("bizState.sheetLldp"),
isis_adjacency: t("bizState.sheetIsis"), isis_adjacency: t("bizState.sheetIsis"),
interface_brief: t("bizState.sheetIfaceBrief"), interface_brief: t("bizState.sheetIfaceBrief"),
interface_detail: t("bizState.sheetIfaceBrief"),
arp: t("bizState.sheetArp"), arp: t("bizState.sheetArp"),
nd6_cache: t("bizState.sheetNd6"), nd6_cache: t("bizState.sheetNd6"),
bgp_peer: t("bizState.sheetBgpPeer"), 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; return map[metricId] || metricId;
} }
function rowsForMetric(batch: any, metricId: string): Record<string, unknown>[] { function buildSheetTabs(batch: any, t: (k: string) => string): SheetTab[] {
const mid = String(metricId || "").trim(); const tabs: SheetTab[] = [];
if (!mid) return []; const cmds = (batch?.commands || []) as SheetCmd[];
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[];
if (cmds.length) { if (cmds.length) {
sheets.push({ tabs.push({
id: "commands", id: "commands",
title: t("bizState.sheetCommands"), title: t("bizState.sheetCommands"),
columns: [ rowCount: cmds.length,
{ key: "raw_command", header: t("bizState.colCommand") }, commands: cmds,
{ 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,
})),
}); });
} }
for (const s of (batch?.sheets || []) as any[]) {
// Collect metric ids: command order first, then legacy / metrics payload const mid = String(s?.metric_id || "").trim();
const metricOrder: string[] = []; if (!mid || mid === "vrf_list") continue;
const pushMid = (mid: string) => { tabs.push({
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({
id: mid, id: mid,
title: metricSheetTitle(mid, t), title: metricSheetTitle(mid, t),
columns, rowCount: Number(s?.row_count || 0),
rows, commands: Array.isArray(s?.commands) ? (s.commands as SheetCmd[]) : [],
}); });
} }
return sheets; return tabs;
}
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() { export function BizStatePage() {
@ -285,12 +246,23 @@ export function BizStatePage() {
const [selectedVrfs, setSelectedVrfs] = useState<string[]>([]); const [selectedVrfs, setSelectedVrfs] = useState<string[]>([]);
const [discoverCmd, setDiscoverCmd] = useState(""); const [discoverCmd, setDiscoverCmd] = useState("");
// batch workbook modal // batch workbook modal (summary + lazy-paged metric sheets)
const [batchDetail, setBatchDetail] = useState<any>(null); const [batchDetail, setBatchDetail] = useState<any>(null);
const [sheetId, setSheetId] = useState(""); const [sheetId, setSheetId] = useState("");
const [sheetKeyword, setSheetKeyword] = useState(""); const [sheetKeyword, setSheetKeyword] = useState("");
const [sheetColumn, setSheetColumn] = 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 refreshTasks = useCallback(async () => {
const purpose = const purpose =
@ -353,25 +325,127 @@ export function BizStatePage() {
[profiles], [profiles],
); );
const sheets = useMemo( const sheetTabs = useMemo(
() => (batchDetail ? buildBatchSheets(batchDetail, t) : []), () => (batchDetail ? buildSheetTabs(batchDetail, t) : []),
[batchDetail, t], [batchDetail, t],
); );
const activeSheet = useMemo(() => { const activeSheet = useMemo(() => {
if (!sheets.length) return null; if (!sheetTabs.length) return null;
return sheets.find((s) => s.id === sheetId) || sheets[0]; return sheetTabs.find((s) => s.id === sheetId) || sheetTabs[0];
}, [sheets, sheetId]); }, [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 []; if (!activeSheet) return [];
return filterSheetRows( if (activeSheet.id === "commands") return commandsSheetColumns;
activeSheet.rows, return sheetColumns.length ? sheetColumns : [{ key: "_empty", header: "—" }];
activeSheet.columns, }, [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, debouncedSheetKw,
sheetColumn, sheetColumn,
); );
}, [activeSheet, debouncedSheetKw, sheetColumn]); }, [
batchDetail?.id,
activeSheet,
sheetPage,
sheetPageSize,
debouncedSheetKw,
sheetColumn,
loadSheetPage,
]);
const openCreate = () => { const openCreate = () => {
setCreateOpen(true); setCreateOpen(true);
@ -689,10 +763,13 @@ export function BizStatePage() {
setBatchDetail(d); setBatchDetail(d);
setSheetKeyword(""); setSheetKeyword("");
setSheetColumn(""); setSheetColumn("");
const built = buildBatchSheets(d, t); setSheetPage(1);
// Prefer first metric with rows; fall back to commands / first tab setSheetRows([]);
setSheetColumns([]);
setSheetTotal(0);
const built = buildSheetTabs(d, t);
const prefer = 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.find((s) => s.id !== "commands") ||
built[0]; built[0];
setSheetId(prefer?.id || ""); setSheetId(prefer?.id || "");
@ -706,6 +783,48 @@ export function BizStatePage() {
setSheetId(""); setSheetId("");
setSheetKeyword(""); setSheetKeyword("");
setSheetColumn(""); 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; const runningCount = tasks.filter((x) => x.status === "running").length;
@ -1344,7 +1463,7 @@ export function BizStatePage() {
</Modal.Footer> </Modal.Footer>
</AppModalShell> </AppModalShell>
{/* Batch workbook: one sheet per monitor metric */} {/* Batch workbook: summary + lazy-paged metric sheets */}
<AppModalShell <AppModalShell
open={Boolean(batchDetail)} open={Boolean(batchDetail)}
onClose={closeBatch} onClose={closeBatch}
@ -1359,55 +1478,94 @@ export function BizStatePage() {
</Modal.Header> </Modal.Header>
<Modal.Body className="flex flex-col gap-2 bs-workbook-body"> <Modal.Body className="flex flex-col gap-2 bs-workbook-body">
{batchDetail ? ( {batchDetail ? (
<p className="muted"> <div className="bs-workbook-meta">
{batchDetail.status} · {batchDetail.command_count} cmd · {batchDetail.row_count} rows ·{" "} <NmStatusChip color={jobChipColor(String(batchDetail.status || ""))}>
{fmtTime(batchDetail.started_at)} {String(batchDetail.status || "—")}
</p> </NmStatusChip>
<span className="muted">
{batchDetail.command_count} cmd · {batchDetail.row_count} rows ·{" "}
{fmtTime(batchDetail.started_at)}
</span>
</div>
) : null} ) : null}
<div className="bs-sheet-tabs bs-sheet-tabs--top" role="tablist" aria-label={t("bizState.batchWorkbook")}> <div className="bs-sheet-tabs bs-sheet-tabs--top" role="tablist" aria-label={t("bizState.batchWorkbook")}>
{sheets.map((s) => ( {sheetTabs.map((s) => (
<button <button
key={s.id} key={s.id}
type="button" type="button"
role="tab" role="tab"
aria-selected={activeSheet?.id === s.id} aria-selected={activeSheet?.id === s.id}
className={`bs-sheet-tab${activeSheet?.id === s.id ? " is-active" : ""}`} className={`bs-sheet-tab${activeSheet?.id === s.id ? " is-active" : ""}`}
onClick={() => { onClick={() => selectSheet(s.id)}
setSheetId(s.id);
setSheetKeyword("");
setSheetColumn("");
}}
> >
{s.title} {s.title}
<span className="bs-sheet-tab__count">{s.rows.length}</span> <span className="bs-sheet-tab__count">{s.rowCount}</span>
</button> </button>
))} ))}
{!sheets.length ? <span className="muted">{t("bizState.sheetEmpty")}</span> : null} {!sheetTabs.length ? <span className="muted">{t("bizState.sheetEmpty")}</span> : null}
</div> </div>
{activeSheet ? ( {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"> <div className="filter-inline bs-sheet-filter">
<FieldSelect <FieldSelect
value={sheetColumn} value={sheetColumn}
onChange={(e) => setSheetColumn(e.target.value)} onChange={(e) => {
setSheetColumn(e.target.value);
setSheetPage(1);
}}
aria-label={t("bizState.filterColumn")} aria-label={t("bizState.filterColumn")}
> >
<option value="">{t("bizState.filterAllCols")}</option> <option value="">{t("bizState.filterAllCols")}</option>
{activeSheet.columns.map((c) => ( {displayColumns
<option key={c.key} value={c.key}> .filter((c) => c.key !== "_actions" && c.key !== "_empty")
{c.header} .map((c) => (
</option> <option key={c.key} value={c.key}>
))} {c.header}
</option>
))}
</FieldSelect> </FieldSelect>
<Input <Input
value={sheetKeyword} value={sheetKeyword}
placeholder={t("bizState.sheetFilterPh")} placeholder={t("bizState.sheetFilterPh")}
onChange={(e) => setSheetKeyword(e.target.value)} onChange={(e) => {
setSheetKeyword(e.target.value);
setSheetPage(1);
}}
/> />
<span className="muted bs-sheet-count"> <span className="muted bs-sheet-count">
{filteredSheetRows.length}/{activeSheet.rows.length} {sheetLoading ? t("bizState.sheetLoading") : `${displayTotal} ${t("bizState.colRows")}`}
</span> </span>
</div> </div>
@ -1415,20 +1573,31 @@ export function BizStatePage() {
<table className="data-table pt-list-table"> <table className="data-table pt-list-table">
<thead> <thead>
<tr> <tr>
{activeSheet.columns.map((c) => ( {displayColumns.map((c) => (
<th key={c.key}>{c.header}</th> <th key={c.key}>{c.header}</th>
))} ))}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{filteredSheetRows.slice(0, 2000).map((row, i) => ( {displayRows.map((row, i) => (
<tr key={i}> <tr key={String(row.id || i)}>
{activeSheet.columns.map((c) => ( {displayColumns.map((c) => (
<td key={c.key}> <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]))}> <NmStatusChip color={jobChipColor(cellText(row[c.key]))}>
{cellText(row[c.key]) || "—"} {cellText(row[c.key]) || "—"}
</NmStatusChip> </NmStatusChip>
) : c.key === "raw_command" ? (
<code className="bs-inline-cmd">{cellText(row[c.key]) || "—"}</code>
) : ( ) : (
cellText(row[c.key]) || "—" cellText(row[c.key]) || "—"
)} )}
@ -1436,16 +1605,37 @@ export function BizStatePage() {
))} ))}
</tr> </tr>
))} ))}
{!filteredSheetRows.length ? ( {!displayRows.length && !sheetLoading ? (
<tr> <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> <div className="pt-list-empty">{t("bizState.sheetEmpty")}</div>
</td> </td>
</tr> </tr>
) : null} ) : 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> </tbody>
</table> </table>
</div> </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> <div className="pt-list-empty">{t("bizState.sheetEmpty")}</div>
@ -1462,6 +1652,33 @@ export function BizStatePage() {
</Button> </Button>
</Modal.Footer> </Modal.Footer>
</AppModalShell> </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> </section>
); );
} }

View file

@ -1841,6 +1841,49 @@ export const bizStatePurgeTask = (taskId: string) =>
export const bizStateGetBatch = (batchId: string) => export const bizStateGetBatch = (batchId: string) =>
apiGet<Record<string, unknown>>(`/v1/biz-state/batches/${encodeURIComponent(batchId)}`); 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> => { export const bizStateDownloadExport = async (batchId: string): Promise<void> => {
const path = `/v1/biz-state/batches/${encodeURIComponent(batchId)}/export`; const path = `/v1/biz-state/batches/${encodeURIComponent(batchId)}/export`;
const res = await fetch(path, { method: "GET", credentials: fetchCreds, headers: authHeaders() }); const res = await fetch(path, { method: "GET", credentials: fetchCreds, headers: authHeaders() });