From 18a0c41196e1fe92f55eacd574d2c62f2a1bfa17 Mon Sep 17 00:00:00 2001 From: oliver Date: Sun, 20 Sep 2026 10:13:45 +0800 Subject: [PATCH] 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 --- netx_api/biz_state/service.py | 307 +++++++++++++---- netx_api/biz_state_router.py | 23 +- tests/test_batch_workbook_api.py | 81 +++++ web/src/i18n/en.ts | 8 + web/src/i18n/zh.ts | 8 + web/src/index.css | 92 +++++ web/src/pages/network/BizStatePage.tsx | 451 ++++++++++++++++++------- web/src/services/api.ts | 43 +++ 8 files changed, 835 insertions(+), 178 deletions(-) create mode 100644 tests/test_batch_workbook_api.py diff --git a/netx_api/biz_state/service.py b/netx_api/biz_state/service.py index b20600b..d6cdfc4 100644 --- a/netx_api/biz_state/service.py +++ b/netx_api/biz_state/service.py @@ -9,6 +9,7 @@ from typing import Any from uuid import uuid4 from fastapi import HTTPException +from sqlalchemy import String, cast, func, or_ from sqlalchemy.orm import Session from ..lldp_shared import resolve_vendor_key @@ -26,7 +27,13 @@ from ..models import ( ) from ..timeutil import utcnow_naive 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 ( batch_protect_info, 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]: + """Batch workbook summary: meta + commands + sheet catalog (no metric row payload).""" b = db.get(BizStateBatch, batch_id) if not b: 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()) .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) + + # 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 { "id": b.id, "task_id": b.task_id, @@ -587,22 +653,77 @@ def get_batch(db: Session, batch_id: str) -> dict[str, Any]: else None, "protected": bool(protect.get("protected")), "protect_reasons": list(protect.get("reasons") or []), - "commands": [ - { - "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, - "raw_text_preview": (c.raw_text or "")[:2000], - } - for c in cmds - ], - "lldp_neighbors": [ + "commands": cmd_payload, + "sheets": sheets, + } + + +def list_batch_metric_rows( + db: Session, + batch_id: str, + metric_id: str, + *, + page: int = 1, + page_size: int = 50, + kw: str = "", + column: str = "", +) -> dict[str, Any]: + """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, "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, "protocol": n.protocol, } - for n in neighbors - ], - "metrics": metrics_by_id, + for n in rows_db + ] + 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) buf = io.BytesIO() with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED) as zf: - # manifest lines = [ f"batch_id={detail['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"]: 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"]) - if row and row.raw_text: - zf.writestr(f"raw/{c['id']}_{safe}.full.txt", row.raw_text) + raw = (row.raw_text if row else "") or "" + 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"] - for n in detail["lldp_neighbors"]: + for n in neighbors: csv_lines.append( ",".join( [ - _csv(n["local_if"]), - _csv(n["remote_sys"]), - _csv(n["remote_if"]), - _csv(n["remote_ip"]), - _csv(n["protocol"]), + _csv(n.local_if), + _csv(n.remote_sys), + _csv(n.remote_if), + _csv(n.remote_ip), + _csv(n.protocol), ] ) ) 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: continue + recs = [dict(r.data_json or {}) for r in rows] cols: list[str] = [] - for rec in rows: + for rec in recs: for k in rec.keys(): if k not in cols: cols.append(str(k)) - lines = [",".join(_csv(c) for c in cols)] - for rec in rows: - lines.append(",".join(_csv(str(rec.get(c, "") or "")) for c in cols)) + out_lines = [",".join(_csv(c) for c in cols)] + for rec in recs: + 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" - zf.writestr(f"tables/{safe}.csv", "\n".join(lines) + "\n") + zf.writestr(f"tables/{safe}.csv", "\n".join(out_lines) + "\n") return buf.getvalue() diff --git a/netx_api/biz_state_router.py b/netx_api/biz_state_router.py index bedaa24..fc9c61c 100644 --- a/netx_api/biz_state_router.py +++ b/netx_api/biz_state_router.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import Any -from fastapi import APIRouter, BackgroundTasks, Depends +from fastapi import APIRouter, BackgroundTasks, Depends, Query from fastapi.responses import StreamingResponse from pydantic import BaseModel, Field 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) +@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}") def api_get_batch_command( batch_id: str, command_id: str, db: Session = Depends(get_db) diff --git a/tests/test_batch_workbook_api.py b/tests/test_batch_workbook_api.py new file mode 100644 index 0000000..cdb3727 --- /dev/null +++ b/tests/test_batch_workbook_api.py @@ -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() diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 616c83f..dc785ac 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -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", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index a6e3532..b339929 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -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 / 厂商 / 状态", diff --git a/web/src/index.css b/web/src/index.css index f0caeff..23eab86 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -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; } diff --git a/web/src/pages/network/BizStatePage.tsx b/web/src/pages/network/BizStatePage.tsx index d318d2e..53ec781 100644 --- a/web/src/pages/network/BizStatePage.tsx +++ b/web/src/pages/network/BizStatePage.tsx @@ -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[]; + 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[] { - const mid = String(metricId || "").trim(); - if (!mid) return []; - const fromGeneric = (batch?.metrics || {})[mid]; - if (Array.isArray(fromGeneric) && fromGeneric.length) { - return fromGeneric as Record[]; - } - if (mid === "lldp_neighbor") { - return ((batch?.lldp_neighbors || []) as Record[]) || []; - } - return Array.isArray(fromGeneric) ? (fromGeneric as Record[]) : []; -} - -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[], - columns: SheetCol[], - keyword: string, - columnKey: string, -): Record[] { - 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([]); const [discoverCmd, setDiscoverCmd] = useState(""); - // batch workbook modal + // batch workbook modal (summary + lazy-paged metric sheets) const [batchDetail, setBatchDetail] = useState(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[]>([]); + const [sheetColumns, setSheetColumns] = useState([]); + 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( + () => [ + { 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[]; + 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[]; + 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() { - {/* Batch workbook: one sheet per monitor metric */} + {/* Batch workbook: summary + lazy-paged metric sheets */} {batchDetail ? ( -

- {batchDetail.status} · {batchDetail.command_count} cmd · {batchDetail.row_count} rows ·{" "} - {fmtTime(batchDetail.started_at)} -

+
+ + {String(batchDetail.status || "—")} + + + {batchDetail.command_count} cmd · {batchDetail.row_count} rows ·{" "} + {fmtTime(batchDetail.started_at)} + +
) : null}
- {sheets.map((s) => ( + {sheetTabs.map((s) => ( ))} - {!sheets.length ? {t("bizState.sheetEmpty")} : null} + {!sheetTabs.length ? {t("bizState.sheetEmpty")} : null}
{activeSheet ? ( <> + {activeSheet.id !== "commands" && activeSheet.commands.length ? ( +
+
{t("bizState.collectCommand")}
+
+ {activeSheet.commands.map((c) => ( +
+ + {c.raw_command || "—"} + +
+ {c.parse_status ? ( + + {c.parse_status} + + ) : null} + +
+
+ ))} +
+
+ ) : null} +
setSheetColumn(e.target.value)} + onChange={(e) => { + setSheetColumn(e.target.value); + setSheetPage(1); + }} aria-label={t("bizState.filterColumn")} > - {activeSheet.columns.map((c) => ( - - ))} + {displayColumns + .filter((c) => c.key !== "_actions" && c.key !== "_empty") + .map((c) => ( + + ))} setSheetKeyword(e.target.value)} + onChange={(e) => { + setSheetKeyword(e.target.value); + setSheetPage(1); + }} /> - {filteredSheetRows.length}/{activeSheet.rows.length} + {sheetLoading ? t("bizState.sheetLoading") : `${displayTotal} ${t("bizState.colRows")}`}
@@ -1415,20 +1573,31 @@ export function BizStatePage() { - {activeSheet.columns.map((c) => ( + {displayColumns.map((c) => ( ))} - {filteredSheetRows.slice(0, 2000).map((row, i) => ( - - {activeSheet.columns.map((c) => ( + {displayRows.map((row, i) => ( + + {displayColumns.map((c) => ( ))} - {!filteredSheetRows.length ? ( + {!displayRows.length && !sheetLoading ? ( - ) : null} + {sheetLoading && activeSheet.id !== "commands" && !displayRows.length ? ( + + + + ) : null}
{c.header}
- {c.key === "parse_status" ? ( + {c.key === "_actions" ? ( + + ) : c.key === "parse_status" ? ( {cellText(row[c.key]) || "—"} + ) : c.key === "raw_command" ? ( + {cellText(row[c.key]) || "—"} ) : ( cellText(row[c.key]) || "—" )} @@ -1436,16 +1605,37 @@ export function BizStatePage() { ))}
+
{t("bizState.sheetEmpty")}
+
{t("bizState.sheetLoading")}
+
+ + { + setSheetPageSize(n); + setSheetPage(1); + }} + /> ) : (
{t("bizState.sheetEmpty")}
@@ -1462,6 +1652,33 @@ export function BizStatePage() {
+ + {/* Raw CLI log viewer */} + setRawLogOpen(false)} + size="lg" + className="app-heroui-modal--lg bs-rawlog-modal" + > + + {t("bizState.rawLogTitle")} + + + + {rawLogCmd ? {rawLogCmd} : null} + {rawLogMeta ?

{rawLogMeta}

: null} + {rawLogLoading ? ( +
{t("bizState.sheetLoading")}
+ ) : ( +
{rawLogText || t("bizState.rawLogEmpty")}
+ )} +
+ + + +
); } diff --git a/web/src/services/api.ts b/web/src/services/api.ts index e0220b9..001fd78 100644 --- a/web/src/services/api.ts +++ b/web/src/services/api.ts @@ -1841,6 +1841,49 @@ export const bizStatePurgeTask = (taskId: string) => export const bizStateGetBatch = (batchId: string) => apiGet>(`/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[]; + }>( + `/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 => { const path = `/v1/biz-state/batches/${encodeURIComponent(batchId)}/export`; const res = await fetch(path, { method: "GET", credentials: fetchCreds, headers: authHeaders() });