mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 11:50:44 +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
|
|
@ -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()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
81
tests/test_batch_workbook_api.py
Normal file
81
tests/test_batch_workbook_api.py
Normal 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()
|
||||
|
|
@ -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