diff --git a/netx_api/biz_state/compare_service.py b/netx_api/biz_state/compare_service.py index 3bbf89e..d579850 100644 --- a/netx_api/biz_state/compare_service.py +++ b/netx_api/biz_state/compare_service.py @@ -3,6 +3,7 @@ from __future__ import annotations import io +import json import zipfile from datetime import datetime from typing import Any @@ -12,6 +13,7 @@ from fastapi import HTTPException from sqlalchemy.orm import Session from ..models import ( + BizCompareDiff, BizCompareJob, BizCompareRun, BizCompareTemplate, @@ -29,6 +31,129 @@ def _utcnow() -> datetime: return utcnow_naive() +_DIFF_CHUNK = 2000 +_SEARCH_TEXT_MAX = 4000 + + +def _diff_search_text(d: dict[str, Any]) -> str: + parts = [str(d.get("kind") or "")] + for key in ("key", "before", "after", "mapped_before", "changes"): + val = d.get(key) + if val: + try: + parts.append(json.dumps(val, ensure_ascii=False, default=str, separators=(",", ":"))) + except Exception: + parts.append(str(val)) + return " ".join(parts)[:_SEARCH_TEXT_MAX] + + +def _top_changed_fields(diffs: list[dict[str, Any]], *, limit: int = 8) -> list[dict[str, Any]]: + field_counts: dict[str, int] = {} + for d in diffs: + if str(d.get("kind") or "") != "changed": + continue + for fname in d.get("changes") or {}: + field_counts[str(fname)] = field_counts.get(str(fname), 0) + 1 + return sorted( + [{"field": k, "count": v} for k, v in field_counts.items()], + key=lambda x: (-int(x["count"]), str(x["field"])), + )[:limit] + + +def _persist_sheet_diffs( + db: Session, + *, + run_id: str, + metric_id: str, + diffs: list[dict[str, Any]], +) -> None: + """Bulk-insert diff rows; avoids embedding million-row arrays in summary_json.""" + buf: list[dict[str, Any]] = [] + for i, d in enumerate(diffs): + buf.append( + { + "id": uuid4().hex, + "run_id": run_id, + "metric_id": metric_id, + "seq": i, + "kind": str(d.get("kind") or ""), + "key_json": dict(d.get("key") or {}), + "before_json": dict(d.get("before") or {}), + "after_json": dict(d.get("after") or {}), + "mapped_before_json": dict(d.get("mapped_before") or {}), + "changes_json": dict(d.get("changes") or {}), + "search_text": _diff_search_text(d), + } + ) + if len(buf) >= _DIFF_CHUNK: + db.bulk_insert_mappings(BizCompareDiff, buf) + buf.clear() + if buf: + db.bulk_insert_mappings(BizCompareDiff, buf) + + +def _diff_row_out(r: BizCompareDiff) -> dict[str, Any]: + return { + "kind": r.kind, + "key": r.key_json or {}, + "before": r.before_json or {}, + "after": r.after_json or {}, + "mapped_before": r.mapped_before_json or {}, + "changes": r.changes_json or {}, + } + + +def _run_has_diff_rows(db: Session, run_id: str) -> bool: + return ( + db.query(BizCompareDiff.id).filter(BizCompareDiff.run_id == run_id).limit(1).first() + is not None + ) + + +def _filter_inline_diffs( + diffs: list[dict[str, Any]], + *, + kind: str, + kw: str, +) -> list[dict[str, Any]]: + kind_n = (kind or "diff").strip().lower() + kw_n = (kw or "").strip().lower() + out: list[dict[str, Any]] = [] + for d in diffs: + dk = str(d.get("kind") or "") + if kind_n == "diff": + if dk == "unchanged": + continue + elif kind_n != "all" and dk != kind_n: + continue + if kw_n: + blob = _diff_search_text(d).lower() + if kw_n not in blob: + continue + out.append(d) + return out + + +def _sheet_meta_from_summary(summary: dict[str, Any], run: BizCompareRun, tpl: Any) -> list[dict[str, Any]]: + sheets = list(summary.get("sheets") or []) + if sheets: + return sheets + return [ + { + "metric_id": run.metric_id, + "key_fields": list((tpl.key_fields if tpl else None) or []), + "iface_fields": list((tpl.iface_fields if tpl else None) or []), + "compare_fields": list((tpl.compare_fields if tpl else None) or []), + "mode": "fields", + "summary": { + k: summary.get(k, 0) + for k in ("added", "removed", "changed", "unchanged", "before_count", "after_count") + }, + "diffs": list(run.diffs_json or []), + } + ] + + def _str_list(raw: Any) -> list[str]: return [str(x).strip() for x in (raw or []) if str(x).strip()] @@ -624,6 +749,13 @@ def delete_job(db: Session, job_id: str) -> None: j = db.get(BizCompareJob, job_id) if not j: raise HTTPException(status_code=404, detail="job_not_found") + run_ids = [ + rid for (rid,) in db.query(BizCompareRun.id).filter(BizCompareRun.job_id == job_id).all() + ] + if run_ids: + db.query(BizCompareDiff).filter(BizCompareDiff.run_id.in_(run_ids)).delete( + synchronize_session=False + ) db.query(BizCompareRun).filter(BizCompareRun.job_id == job_id).delete() db.delete(j) db.commit() @@ -725,9 +857,24 @@ def run_compare(db: Session, job_id: str, *, force_after_batch_id: str = "") -> mapping_by_metric[one["metric_id"]] = one["mapping_stats"] first = sheet_results[0] + field_counts: dict[str, int] = {} + for s in sheet_results: + for d in list(s.get("diffs") or []): + if str(d.get("kind") or "") != "changed": + continue + for fname in d.get("changes") or {}: + field_counts[str(fname)] = field_counts.get(str(fname), 0) + 1 + top_fields = sorted( + [{"field": k, "count": v} for k, v in field_counts.items()], + key=lambda x: (-int(x["count"]), str(x["field"])), + )[:8] + + run_id = uuid4().hex + # Persist counts/meta only — diffs go to biz_compare_diff rows summary_payload = { **agg, "sheet_count": len(sheet_results), + "top_changed_fields": top_fields, "sheets": [ { "metric_id": s["metric_id"], @@ -736,14 +883,13 @@ def run_compare(db: Session, job_id: str, *, force_after_batch_id: str = "") -> "compare_fields": s["compare_fields"], "mode": s["mode"], "summary": s["summary"], - "diffs": s["diffs"], } for s in sheet_results ], } run = BizCompareRun( - id=uuid4().hex, + id=run_id, job_id=j.id, template_id=tpl.id, mapping_id=j.mapping_id, @@ -752,12 +898,20 @@ def run_compare(db: Session, job_id: str, *, force_after_batch_id: str = "") -> metric_id=first["metric_id"], status="success", summary_json=summary_payload, - diffs_json=first["diffs"], + diffs_json=[], mapping_stats_json=mapping_by_metric, message="", created_at=_utcnow(), ) db.add(run) + db.flush() + for s in sheet_results: + _persist_sheet_diffs( + db, + run_id=run_id, + metric_id=str(s["metric_id"]), + diffs=list(s.get("diffs") or []), + ) j.updated_at = _utcnow() if j.mode == "manual": j.after_batch_id = after_batch_id @@ -815,7 +969,6 @@ def _enrich_summary(summary: dict[str, Any], sheets: list[dict[str, Any]]) -> di pass_rate = round((unchanged / matched) * 100, 1) if matched else (100.0 if total == 0 else 0.0) diff_rate = round((diff_count / total) * 100, 1) if total else 0.0 - field_counts: dict[str, int] = {} sheet_cards: list[dict[str, Any]] = [] for sh in sheets: ss = dict(sh.get("summary") or {}) @@ -839,16 +992,21 @@ def _enrich_summary(summary: dict[str, Any], sheets: list[dict[str, Any]]) -> di "pass_rate": round((su / sm) * 100, 1) if sm else (100.0 if st == 0 else 0.0), } ) - for d in list(sh.get("diffs") or []): - if str(d.get("kind") or "") != "changed": - continue - for fname in (d.get("changes") or {}): - field_counts[str(fname)] = field_counts.get(str(fname), 0) + 1 - top_fields = sorted( - [{"field": k, "count": v} for k, v in field_counts.items()], - key=lambda x: (-int(x["count"]), str(x["field"])), - )[:8] + top_fields = list(summary.get("top_changed_fields") or []) + if not top_fields: + # Legacy runs that still embed diffs in summary_json + field_counts: dict[str, int] = {} + for sh in sheets: + for d in list(sh.get("diffs") or []): + if str(d.get("kind") or "") != "changed": + continue + for fname in d.get("changes") or {}: + field_counts[str(fname)] = field_counts.get(str(fname), 0) + 1 + top_fields = sorted( + [{"field": k, "count": v} for k, v in field_counts.items()], + key=lambda x: (-int(x["count"]), str(x["field"])), + )[:8] return { "added": added, @@ -875,24 +1033,21 @@ def get_run(db: Session, run_id: str) -> dict[str, Any]: raise HTTPException(status_code=404, detail="run_not_found") tpl = db.get(BizCompareTemplate, r.template_id) if r.template_id else None summary = dict(r.summary_json or {}) - sheets = list(summary.get("sheets") or []) - if not sheets: - # Legacy single-metric run - sheets = [ - { - "metric_id": r.metric_id, - "key_fields": list((tpl.key_fields if tpl else None) or []), - "iface_fields": list((tpl.iface_fields if tpl else None) or []), - "compare_fields": list((tpl.compare_fields if tpl else None) or []), - "mode": "fields", - "summary": { - k: summary.get(k, 0) - for k in ("added", "removed", "changed", "unchanged", "before_count", "after_count") - }, - "diffs": list(r.diffs_json or []), - } - ] - enriched = _enrich_summary(summary, sheets) + raw_sheets = _sheet_meta_from_summary(summary, r, tpl) + # Never return full diffs in run detail (million-row safe) + sheets = [ + { + "metric_id": sh.get("metric_id") or "", + "key_fields": list(sh.get("key_fields") or []), + "iface_fields": list(sh.get("iface_fields") or []), + "compare_fields": list(sh.get("compare_fields") or []), + "mode": sh.get("mode") or ("presence" if not sh.get("compare_fields") else "fields"), + "summary": dict(sh.get("summary") or {}), + } + for sh in raw_sheets + ] + enriched = _enrich_summary(summary, raw_sheets) + stored = "rows" if _run_has_diff_rows(db, run_id) else "inline" return { "id": r.id, "job_id": r.job_id, @@ -904,7 +1059,8 @@ def get_run(db: Session, run_id: str) -> dict[str, Any]: "status": r.status, "summary": enriched, "sheets": sheets, - "diffs": list(r.diffs_json or []), + "diffs": [], + "diffs_stored": stored, "mapping_stats": r.mapping_stats_json or {}, "message": r.message, "created_at": r.created_at.isoformat() + "Z" if r.created_at else None, @@ -912,6 +1068,110 @@ def get_run(db: Session, run_id: str) -> dict[str, Any]: } +def list_run_diffs( + db: Session, + run_id: str, + *, + metric_id: str = "", + kind: str = "diff", + kw: str = "", + page: int = 1, + page_size: int = 100, +) -> dict[str, Any]: + r = db.get(BizCompareRun, run_id) + if not r: + raise HTTPException(status_code=404, detail="run_not_found") + page_n = max(1, int(page or 1)) + size_n = max(1, min(500, int(page_size or 100))) + kind_n = (kind or "diff").strip().lower() + kw_n = (kw or "").strip() + + summary = dict(r.summary_json or {}) + tpl = db.get(BizCompareTemplate, r.template_id) if r.template_id else None + sheets = _sheet_meta_from_summary(summary, r, tpl) + mid = (metric_id or "").strip() or (sheets[0].get("metric_id") if sheets else r.metric_id) or "" + + if _run_has_diff_rows(db, run_id): + q = db.query(BizCompareDiff).filter( + BizCompareDiff.run_id == run_id, + BizCompareDiff.metric_id == mid, + ) + if kind_n == "diff": + q = q.filter(BizCompareDiff.kind.in_(("added", "removed", "changed"))) + elif kind_n != "all": + q = q.filter(BizCompareDiff.kind == kind_n) + if kw_n: + q = q.filter(BizCompareDiff.search_text.ilike(f"%{kw_n}%")) + total = q.count() + rows = ( + q.order_by(BizCompareDiff.seq.asc(), BizCompareDiff.id.asc()) + .offset((page_n - 1) * size_n) + .limit(size_n) + .all() + ) + return { + "total": total, + "page": page_n, + "page_size": size_n, + "metric_id": mid, + "items": [_diff_row_out(x) for x in rows], + } + + # Legacy: diffs embedded in summary_json / diffs_json + sheet = next((s for s in sheets if str(s.get("metric_id") or "") == mid), None) + if sheet is None and sheets: + sheet = sheets[0] + mid = str(sheet.get("metric_id") or mid) + inline = list((sheet or {}).get("diffs") or []) + if not inline and mid == r.metric_id: + inline = list(r.diffs_json or []) + filtered = _filter_inline_diffs(inline, kind=kind_n, kw=kw_n) + total = len(filtered) + start = (page_n - 1) * size_n + page_items = filtered[start : start + size_n] + return { + "total": total, + "page": page_n, + "page_size": size_n, + "metric_id": mid, + "items": page_items, + } + + +def _iter_sheet_diffs(db: Session, run_id: str, metric_id: str) -> list[dict[str, Any]]: + """Load all diffs for one sheet (export). Prefer row table; fall back to inline.""" + if _run_has_diff_rows(db, run_id): + out: list[dict[str, Any]] = [] + offset = 0 + while True: + rows = ( + db.query(BizCompareDiff) + .filter(BizCompareDiff.run_id == run_id, BizCompareDiff.metric_id == metric_id) + .order_by(BizCompareDiff.seq.asc(), BizCompareDiff.id.asc()) + .offset(offset) + .limit(_DIFF_CHUNK) + .all() + ) + if not rows: + break + out.extend(_diff_row_out(x) for x in rows) + offset += len(rows) + if len(rows) < _DIFF_CHUNK: + break + return out + r = db.get(BizCompareRun, run_id) + if not r: + return [] + summary = dict(r.summary_json or {}) + sheets = list(summary.get("sheets") or []) + for sh in sheets: + if str(sh.get("metric_id") or "") == metric_id: + return list(sh.get("diffs") or []) + if metric_id == r.metric_id: + return list(r.diffs_json or []) + return [] + + def export_run_zip(db: Session, run_id: str) -> bytes: detail = get_run(db, run_id) buf = io.BytesIO() @@ -941,8 +1201,11 @@ def export_run_zip(db: Session, run_id: str) -> bytes: for sheet in list(detail.get("sheets") or []): mid = str(sheet.get("metric_id") or "sheet") safe = "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in mid)[:80] or "sheet" - zf.writestr(f"tables/{safe}.csv", _sheet_csv(sheet)) - # summary table + sheet_full = { + **sheet, + "diffs": _iter_sheet_diffs(db, run_id, mid), + } + zf.writestr(f"tables/{safe}.csv", _sheet_csv(sheet_full)) sum_lines = ["metric_id,mode,before,after,added,removed,changed,unchanged,diff_count,pass_rate"] for card in list(s.get("sheet_cards") or []): sum_lines.append( diff --git a/netx_api/biz_state/schema_ensure.py b/netx_api/biz_state/schema_ensure.py index a47557d..3dd8bfd 100644 --- a/netx_api/biz_state/schema_ensure.py +++ b/netx_api/biz_state/schema_ensure.py @@ -24,6 +24,9 @@ def apply_biz_state_schema(conn: Connection) -> None: "CREATE INDEX IF NOT EXISTS ix_biz_compare_job_status ON biz_compare_job (status)", "CREATE INDEX IF NOT EXISTS ix_biz_compare_run_job_id ON biz_compare_run (job_id)", "CREATE INDEX IF NOT EXISTS ix_biz_state_vrf_route_batch_id ON biz_state_vrf_route_summary (batch_id)", + "CREATE INDEX IF NOT EXISTS ix_biz_compare_diff_run_id ON biz_compare_diff (run_id)", + "CREATE INDEX IF NOT EXISTS ix_biz_compare_diff_run_metric_kind ON biz_compare_diff (run_id, metric_id, kind)", + "CREATE INDEX IF NOT EXISTS ix_biz_compare_diff_run_metric_seq ON biz_compare_diff (run_id, metric_id, seq)", ): try: _run_sql(conn, sql) diff --git a/netx_api/biz_state_router.py b/netx_api/biz_state_router.py index 36886bd..dceab3c 100644 --- a/netx_api/biz_state_router.py +++ b/netx_api/biz_state_router.py @@ -371,6 +371,27 @@ def api_get_run(run_id: str, db: Session = Depends(get_db)) -> dict[str, Any]: return cmp_svc.get_run(db, run_id) +@router.get("/compare/runs/{run_id}/diffs") +def api_list_run_diffs( + run_id: str, + metric_id: str = "", + kind: str = "diff", + kw: str = "", + page: int = 1, + page_size: int = 100, + db: Session = Depends(get_db), +) -> dict[str, Any]: + return cmp_svc.list_run_diffs( + db, + run_id, + metric_id=metric_id, + kind=kind, + kw=kw, + page=page, + page_size=page_size, + ) + + @router.get("/compare/runs/{run_id}/export") def api_export_run(run_id: str, db: Session = Depends(get_db)) -> StreamingResponse: data = cmp_svc.export_run_zip(db, run_id) diff --git a/netx_api/models/__init__.py b/netx_api/models/__init__.py index 57806a1..5c14ee2 100644 --- a/netx_api/models/__init__.py +++ b/netx_api/models/__init__.py @@ -25,6 +25,7 @@ from .managed_ne import ( UmeCliOverride, ) from .biz_state import ( + BizCompareDiff, BizCompareJob, BizCompareRun, BizCompareTemplate, @@ -142,4 +143,5 @@ __all__ = [ "BizPortMappingRow", "BizCompareJob", "BizCompareRun", + "BizCompareDiff", ] diff --git a/netx_api/models/biz_state.py b/netx_api/models/biz_state.py index 783fecc..f2cad0f 100644 --- a/netx_api/models/biz_state.py +++ b/netx_api/models/biz_state.py @@ -5,7 +5,7 @@ from __future__ import annotations from datetime import datetime from uuid import uuid4 -from sqlalchemy import Boolean, DateTime, Integer, String, Text, UniqueConstraint +from sqlalchemy import Boolean, DateTime, Index, Integer, String, Text, UniqueConstraint from sqlalchemy.orm import Mapped, mapped_column from ..db import Base @@ -256,3 +256,25 @@ class BizCompareRun(Base): mapping_stats_json: Mapped[dict] = mapped_column(_JsonType, default=dict) message: Mapped[str] = mapped_column(String(1024), default="") created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive, index=True) + + +class BizCompareDiff(Base): + """Per-row compare result — supports million-scale sheets with paged queries.""" + + __tablename__ = "biz_compare_diff" + __table_args__ = ( + Index("ix_biz_compare_diff_run_metric_kind", "run_id", "metric_id", "kind"), + Index("ix_biz_compare_diff_run_metric_seq", "run_id", "metric_id", "seq"), + ) + + id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: uuid4().hex) + run_id: Mapped[str] = mapped_column(String(64), default="", index=True) + metric_id: Mapped[str] = mapped_column(String(64), default="", index=True) + seq: Mapped[int] = mapped_column(Integer, default=0) + kind: Mapped[str] = mapped_column(String(16), default="", index=True) + key_json: Mapped[dict] = mapped_column(_JsonType, default=dict) + before_json: Mapped[dict] = mapped_column(_JsonType, default=dict) + after_json: Mapped[dict] = mapped_column(_JsonType, default=dict) + mapped_before_json: Mapped[dict] = mapped_column(_JsonType, default=dict) + changes_json: Mapped[dict] = mapped_column(_JsonType, default=dict) + search_text: Mapped[str] = mapped_column(Text, default="") diff --git a/tests/test_biz_state_compare.py b/tests/test_biz_state_compare.py index 92e9750..366f342 100644 --- a/tests/test_biz_state_compare.py +++ b/tests/test_biz_state_compare.py @@ -125,5 +125,32 @@ class CompareEngineTests(unittest.TestCase): self.assertEqual(out["diffs"][0]["kind"], "unchanged") +class CompareDiffPagingTests(unittest.TestCase): + def test_filter_inline_diffs_kind_and_kw(self) -> None: + from netx_api.biz_state.compare_service import _filter_inline_diffs + + diffs = [ + {"kind": "added", "key": {"p": "1"}, "before": {}, "after": {"p": "1"}, "changes": {}}, + {"kind": "removed", "key": {"p": "2"}, "before": {"p": "2"}, "after": {}, "changes": {}}, + {"kind": "unchanged", "key": {"p": "3"}, "before": {"p": "3"}, "after": {"p": "3"}, "changes": {}}, + { + "kind": "changed", + "key": {"p": "4"}, + "before": {"x": "a"}, + "after": {"x": "b"}, + "changes": {"x": {"before": "a", "after": "b"}}, + }, + ] + only_diff = _filter_inline_diffs(diffs, kind="diff", kw="") + self.assertEqual({d["kind"] for d in only_diff}, {"added", "removed", "changed"}) + only_added = _filter_inline_diffs(diffs, kind="added", kw="") + self.assertEqual(len(only_added), 1) + hit = _filter_inline_diffs(diffs, kind="all", kw='"p":"4"') + self.assertEqual(len(hit), 1) + self.assertEqual(hit[0]["kind"], "changed") + hit2 = _filter_inline_diffs(diffs, kind="all", kw="changed") + self.assertTrue(any(d["kind"] == "changed" for d in hit2)) + + if __name__ == "__main__": unittest.main() diff --git a/web/src/pages/network/BizComparePage.tsx b/web/src/pages/network/BizComparePage.tsx index 0b0379f..75b3c8d 100644 --- a/web/src/pages/network/BizComparePage.tsx +++ b/web/src/pages/network/BizComparePage.tsx @@ -1,5 +1,6 @@ import { Button, Input, Modal } from "@heroui/react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { ListPager } from "../../components/ListPager"; import { AppModalShell } from "../../components/ui/AppModalShell"; import { FieldSelect } from "../../components/ui/FieldSelect"; import { useDebouncedValue } from "../../hooks/useDebouncedValue"; @@ -16,6 +17,7 @@ import { bizCompareListJobs, bizCompareListMappings, bizCompareListMetrics, + bizCompareListRunDiffs, bizCompareListRuns, bizCompareListTemplates, bizCompareRunJob, @@ -197,10 +199,15 @@ export function BizComparePage() { const [runDetail, setRunDetail] = useState(null); const [resultSheetId, setResultSheetId] = useState(""); - // result filters + // result filters (server-paged) const [kindFilter, setKindFilter] = useState("diff"); const [resultKw, setResultKw] = useState(""); - const debouncedResultKw = useDebouncedValue(resultKw, 200); + const debouncedResultKw = useDebouncedValue(resultKw, 300); + const [resultPage, setResultPage] = useState(1); + const [resultPageSize, setResultPageSize] = useState(100); + const [resultTotal, setResultTotal] = useState(0); + const [pagedDiffs, setPagedDiffs] = useState([]); + const [diffsLoading, setDiffsLoading] = useState(false); const boardRef = useRef(null); const [boardFs, setBoardFs] = useState(false); @@ -316,38 +323,71 @@ export function BizComparePage() { [runSheets, resultSheetId], ); + // Reset page when sheet / filter / page size changes + useEffect(() => { + setResultPage(1); + }, [resultSheetId, kindFilter, debouncedResultKw, resultPageSize, runDetail?.id]); + + useEffect(() => { + const runId = String(runDetail?.id || ""); + const mid = resultSheetId || activeRunSheet?.metric_id || ""; + if (!runId || !mid || jobDetailTab !== "result") { + setPagedDiffs([]); + setResultTotal(0); + return; + } + let cancelled = false; + void (async () => { + setDiffsLoading(true); + try { + const res = await bizCompareListRunDiffs({ + runId, + metricId: mid, + kind: kindFilter, + kw: debouncedResultKw.trim(), + page: resultPage, + pageSize: resultPageSize, + }); + if (cancelled) return; + setPagedDiffs((res.items || []) as DiffRow[]); + setResultTotal(Number(res.total || 0)); + const pages = Math.max( + 1, + Math.ceil(Number(res.total || 0) / Number(res.page_size || resultPageSize)), + ); + if (resultPage > pages) setResultPage(pages); + } catch (e) { + if (!cancelled) { + setPagedDiffs([]); + setResultTotal(0); + showError(formatErr(e)); + } + } finally { + if (!cancelled) setDiffsLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, [ + runDetail?.id, + resultSheetId, + activeRunSheet?.metric_id, + kindFilter, + debouncedResultKw, + resultPage, + resultPageSize, + jobDetailTab, + showError, + ]); + const resultColumns = useMemo(() => { - const keys = - activeRunSheet?.key_fields?.length - ? activeRunSheet.key_fields - : Object.keys((activeRunSheet?.diffs?.[0]?.key as any) || {}); + const keys = activeRunSheet?.key_fields?.length + ? activeRunSheet.key_fields + : Object.keys((pagedDiffs[0]?.key as Record) || {}); const compare = (activeRunSheet?.compare_fields || []).filter((f) => !keys.includes(f)); return { keys, compare, presence: !(activeRunSheet?.compare_fields || []).length }; - }, [activeRunSheet]); - - const filteredDiffs = useMemo(() => { - const diffs = (activeRunSheet?.diffs || []) as DiffRow[]; - const kw = debouncedResultKw.trim().toLowerCase(); - return diffs.filter((d) => { - if (kindFilter === "diff") { - if (d.kind === "unchanged") return false; - } else if (kindFilter !== "all" && d.kind !== kindFilter) { - return false; - } - if (!kw) return true; - const blob = [ - d.kind, - ...Object.values(d.key || {}), - ...Object.values(d.before || {}), - ...Object.values(d.after || {}), - JSON.stringify(d.changes || {}), - ] - .map(cellText) - .join(" ") - .toLowerCase(); - return blob.includes(kw); - }); - }, [activeRunSheet, kindFilter, debouncedResultKw]); + }, [activeRunSheet, pagedDiffs]); const kindLabel = (kind: string) => { if (kind === "added") return t("bizCompare.kindAddedShort"); @@ -358,6 +398,7 @@ export function BizComparePage() { }; const summary = runDetail?.summary || {}; + const resultPages = Math.max(1, Math.ceil(resultTotal / Math.max(1, resultPageSize))); const sheetCards = useMemo(() => { const raw = (summary.sheet_cards || []) as Array<{ metric_id: string; @@ -1484,7 +1525,9 @@ export function BizComparePage() { onChange={(e) => setResultKw(e.target.value)} /> - {filteredDiffs.length}/{(activeRunSheet?.diffs || []).length} + {diffsLoading + ? "…" + : `${pagedDiffs.length}/${resultTotal}`} {resultColumns.presence ? ` · ${t("bizCompare.presenceShort")}` : ""} @@ -1503,7 +1546,7 @@ export function BizComparePage() { - {filteredDiffs.slice(0, 2000).map((d, i) => { + {pagedDiffs.map((d, i) => { const pre = (d.mapped_before || d.before || {}) as Record< string, unknown @@ -1569,7 +1612,7 @@ export function BizComparePage() { ); })} - {runDetail && !filteredDiffs.length ? ( + {runDetail && !diffsLoading && !pagedDiffs.length ? ( + + { + setResultPageSize(n); + setResultPage(1); + }} + disabled={diffsLoading || !runDetail} + /> ) : ( diff --git a/web/src/services/api.ts b/web/src/services/api.ts index eaf5c0a..713a617 100644 --- a/web/src/services/api.ts +++ b/web/src/services/api.ts @@ -1912,6 +1912,29 @@ export const bizCompareListRuns = (jobId: string, limit = 20) => export const bizCompareGetRun = (runId: string) => apiGet>(`/v1/biz-state/compare/runs/${encodeURIComponent(runId)}`); +export const bizCompareListRunDiffs = (params: { + runId: string; + metricId?: string; + kind?: string; + kw?: string; + page?: number; + pageSize?: number; +}) => { + const p = new URLSearchParams(); + if (params.metricId) p.set("metric_id", params.metricId); + if (params.kind) p.set("kind", params.kind); + if (params.kw) p.set("kw", params.kw); + p.set("page", String(Math.max(1, Number(params.page || 1)))); + p.set("page_size", String(Math.max(1, Math.min(500, Number(params.pageSize || 100))))); + return apiGet<{ + total: number; + page: number; + page_size: number; + metric_id: string; + items: Record[]; + }>(`/v1/biz-state/compare/runs/${encodeURIComponent(params.runId)}/diffs?${p.toString()}`); +}; + export const bizCompareDownloadRun = async (runId: string): Promise => { const path = `/v1/biz-state/compare/runs/${encodeURIComponent(runId)}/export`; const res = await fetch(path, { method: "GET", credentials: fetchCreds, headers: authHeaders() });