mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 03:10:46 +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 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()
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
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",
|
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",
|
||||||
|
|
|
||||||
|
|
@ -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 / 厂商 / 状态",
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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() });
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue