From 854b0950f5324b18d6b096546e3f74efd4ae14ed Mon Sep 17 00:00:00 2001 From: oliver Date: Thu, 17 Sep 2026 19:23:26 +0800 Subject: [PATCH] Add compare result board, richer KPIs, and zip table export. Co-authored-by: Cursor --- netx_api/biz_state/compare_service.py | 171 +++++++++++++- netx_api/biz_state_router.py | 10 + web/src/i18n/en.ts | 16 ++ web/src/i18n/zh.ts | 16 ++ web/src/index.css | 278 ++++++++++++++++++++++- web/src/pages/network/BizComparePage.tsx | 204 ++++++++++++++++- web/src/services/api.ts | 20 ++ 7 files changed, 695 insertions(+), 20 deletions(-) diff --git a/netx_api/biz_state/compare_service.py b/netx_api/biz_state/compare_service.py index a3a7efe..3bbf89e 100644 --- a/netx_api/biz_state/compare_service.py +++ b/netx_api/biz_state/compare_service.py @@ -2,6 +2,8 @@ from __future__ import annotations +import io +import zipfile from datetime import datetime from typing import Any from uuid import uuid4 @@ -763,6 +765,110 @@ def run_compare(db: Session, job_id: str, *, force_after_batch_id: str = "") -> return get_run(db, run.id) +def _csv_cell(v: Any) -> str: + s = "" if v is None else str(v) + if any(ch in s for ch in ",\"\n\r"): + return '"' + s.replace('"', '""') + '"' + return s + + +def _sheet_csv(sheet: dict[str, Any]) -> str: + keys = list(sheet.get("key_fields") or []) + compare = list(sheet.get("compare_fields") or []) + headers = ["kind", *keys] + for f in compare: + headers.append(f"{f}__pre") + headers.append(f"{f}__post") + lines = [",".join(_csv_cell(h) for h in headers)] + for d in list(sheet.get("diffs") or []): + kind = str(d.get("kind") or "") + pre = dict(d.get("mapped_before") or d.get("before") or {}) + post = dict(d.get("after") or {}) + key = dict(d.get("key") or {}) + row = [kind] + for k in keys: + row.append(key.get(k, pre.get(k, post.get(k, "")))) + for f in compare: + if kind == "added": + row.append("") + row.append(post.get(f, "")) + elif kind == "removed": + row.append(pre.get(f, "")) + row.append("") + else: + row.append(pre.get(f, "")) + row.append(post.get(f, "")) + lines.append(",".join(_csv_cell(x) for x in row)) + return "\ufeff" + "\n".join(lines) + "\n" + + +def _enrich_summary(summary: dict[str, Any], sheets: list[dict[str, Any]]) -> dict[str, Any]: + added = int(summary.get("added") or 0) + removed = int(summary.get("removed") or 0) + changed = int(summary.get("changed") or 0) + unchanged = int(summary.get("unchanged") or 0) + before_count = int(summary.get("before_count") or 0) + after_count = int(summary.get("after_count") or 0) + total = added + removed + changed + unchanged + matched = changed + unchanged + diff_count = added + removed + changed + 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 {}) + sa = int(ss.get("added") or 0) + sr = int(ss.get("removed") or 0) + sc = int(ss.get("changed") or 0) + su = int(ss.get("unchanged") or 0) + st = sa + sr + sc + su + sm = sc + su + sheet_cards.append( + { + "metric_id": sh.get("metric_id") or "", + "mode": sh.get("mode") or ("presence" if not sh.get("compare_fields") else "fields"), + "added": sa, + "removed": sr, + "changed": sc, + "unchanged": su, + "before_count": int(ss.get("before_count") or 0), + "after_count": int(ss.get("after_count") or 0), + "diff_count": sa + sr + sc, + "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] + + return { + "added": added, + "removed": removed, + "changed": changed, + "unchanged": unchanged, + "before_count": before_count, + "after_count": after_count, + "sheet_count": int(summary.get("sheet_count") or len(sheets) or 0), + "total_rows": total, + "matched_rows": matched, + "diff_count": diff_count, + "pass_rate": pass_rate, + "diff_rate": diff_rate, + "ok": diff_count == 0, + "sheet_cards": sheet_cards, + "top_changed_fields": top_fields, + } + + def get_run(db: Session, run_id: str) -> dict[str, Any]: r = db.get(BizCompareRun, run_id) if not r: @@ -779,10 +885,14 @@ def get_run(db: Session, run_id: str) -> dict[str, Any]: "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")}, + "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) return { "id": r.id, "job_id": r.job_id, @@ -792,10 +902,7 @@ def get_run(db: Session, run_id: str) -> dict[str, Any]: "after_batch_id": r.after_batch_id, "metric_id": r.metric_id, "status": r.status, - "summary": { - k: summary.get(k, 0) - for k in ("added", "removed", "changed", "unchanged", "before_count", "after_count", "sheet_count") - }, + "summary": enriched, "sheets": sheets, "diffs": list(r.diffs_json or []), "mapping_stats": r.mapping_stats_json or {}, @@ -805,6 +912,60 @@ def get_run(db: Session, run_id: str) -> dict[str, Any]: } +def export_run_zip(db: Session, run_id: str) -> bytes: + detail = get_run(db, run_id) + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED) as zf: + s = detail.get("summary") or {} + manifest = [ + f"run_id={detail.get('id')}", + f"job_id={detail.get('job_id')}", + f"before_batch_id={detail.get('before_batch_id')}", + f"after_batch_id={detail.get('after_batch_id')}", + f"created_at={detail.get('created_at')}", + f"pass_rate={s.get('pass_rate')}%", + f"diff_count={s.get('diff_count')}", + f"added={s.get('added')} removed={s.get('removed')} " + f"changed={s.get('changed')} unchanged={s.get('unchanged')}", + f"before_count={s.get('before_count')} after_count={s.get('after_count')}", + "", + "sheets:", + ] + for card in list(s.get("sheet_cards") or []): + manifest.append( + f"- {card.get('metric_id')}: diff={card.get('diff_count')} " + f"pass={card.get('pass_rate')}% " + f"+{card.get('added')}/-{card.get('removed')}/~{card.get('changed')}/= {card.get('unchanged')}" + ) + zf.writestr("manifest.txt", "\n".join(manifest) + "\n") + 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 + 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( + ",".join( + _csv_cell(x) + for x in ( + card.get("metric_id"), + card.get("mode"), + card.get("before_count"), + card.get("after_count"), + card.get("added"), + card.get("removed"), + card.get("changed"), + card.get("unchanged"), + card.get("diff_count"), + card.get("pass_rate"), + ) + ) + ) + zf.writestr("tables/_summary.csv", "\ufeff" + "\n".join(sum_lines) + "\n") + return buf.getvalue() + + def list_runs(db: Session, job_id: str, *, limit: int = 20) -> list[dict[str, Any]]: rows = ( db.query(BizCompareRun) diff --git a/netx_api/biz_state_router.py b/netx_api/biz_state_router.py index 0e41145..36886bd 100644 --- a/netx_api/biz_state_router.py +++ b/netx_api/biz_state_router.py @@ -369,3 +369,13 @@ def api_list_runs(job_id: str, limit: int = 20, db: Session = Depends(get_db)) - @router.get("/compare/runs/{run_id}") 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}/export") +def api_export_run(run_id: str, db: Session = Depends(get_db)) -> StreamingResponse: + data = cmp_svc.export_run_zip(db, run_id) + return StreamingResponse( + iter([data]), + media_type="application/zip", + headers={"Content-Disposition": f'attachment; filename="biz_compare_{run_id}.zip"'}, + ) diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 060ccb0..25299bc 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -272,6 +272,7 @@ const en = { sheetsUnit: "sheets", presenceShort: "presence", modeFields: "Mode: compare selected field values", + modeFieldsShort: "fields", modePresence: "Mode: presence-only (entry set match)", mapping: "Port mapping (optional)", newMapping: "New mapping…", @@ -326,6 +327,21 @@ const en = { kindChangedShort: "Changed", kindUnchangedShort: "Same", diffCount: "{{n}} diffs", + passRate: "Pass rate", + passOk: "In sync", + passWarn: "Has diffs", + diffCountLabel: "Diff rows", + diffRate: "Diff rate", + beforeCount: "Before rows", + afterCount: "After rows", + matchedRows: "Matched", + totalRows: "Total rows", + sheetCount: "Sheets", + topChangedFields: "Top changed fields", + exportTables: "Download tables", + exportOk: "Compare tables exported", + fullscreen: "Fullscreen board", + exitFullscreen: "Exit fullscreen", emptyJobs: "No compare jobs yet", created: "Compare job created", ran: "Compare finished", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 508bb67..0fb179a 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -272,6 +272,7 @@ const zh = { sheetsUnit: "张表", presenceShort: "仅条目", modeFields: "模式:按比对字段检查值", + modeFieldsShort: "字段比对", modePresence: "模式:仅校验前后条目一致(不比具体值)", mapping: "端口映射(可选)", newMapping: "新建映射…", @@ -325,6 +326,21 @@ const zh = { kindChangedShort: "变更", kindUnchangedShort: "未变", diffCount: "差异 {{n}}", + passRate: "通过率", + passOk: "前后一致", + passWarn: "存在差异", + diffCountLabel: "差异条目", + diffRate: "差异率", + beforeCount: "操作前行数", + afterCount: "操作后行数", + matchedRows: "对齐条目", + totalRows: "合计条目", + sheetCount: "监控表", + topChangedFields: "变更字段 Top", + exportTables: "下载表格", + exportOk: "已导出比对表格", + fullscreen: "大屏全屏", + exitFullscreen: "退出全屏", emptyJobs: "暂无比对任务", created: "比对任务已创建", ran: "比对完成", diff --git a/web/src/index.css b/web/src/index.css index 07aa533..8d54af6 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -10015,11 +10015,261 @@ html.login-page--paused .login-page__flare { margin-bottom: 4px; } -/* biz_state compare result workbook */ +/* biz_state compare result workbook / board */ .bs-cmp-job-body { min-height: min(64vh, 680px); } +.bs-cmp-board-modal .modal__dialog, +.bs-cmp-board-modal .modal__dialog--lg, +.bs-cmp-board-modal .modal__dialog--md, +.bs-cmp-board-modal .modal__dialog--cover { + width: min(96vw, 1600px) !important; + max-width: min(96vw, 1600px) !important; +} + +.bs-cmp-board-modal .app-heroui-modal__dialog [data-slot="modal-body"], +.bs-cmp-board-modal .app-heroui-modal__dialog .modal__body { + max-height: min(90vh, 980px); +} + +.bs-cmp-board { + display: flex; + flex-direction: column; + gap: 10px; + flex: 1 1 auto; + min-height: 0; + padding: 4px; + border-radius: 12px; + background: + radial-gradient(1200px 420px at 10% -10%, rgba(37, 99, 235, 0.08), transparent 55%), + radial-gradient(900px 360px at 90% 0%, rgba(16, 185, 129, 0.06), transparent 50%), + rgba(148, 163, 184, 0.04); +} + +.bs-cmp-board.is-fullscreen, +.bs-cmp-board:fullscreen { + background: #0b1220; + color: #e2e8f0; + padding: 16px 18px; + gap: 12px; + overflow: auto; +} + +.bs-cmp-board__toolbar { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: flex-end; + justify-content: space-between; +} + +.bs-cmp-board__hero { + display: grid; + grid-template-columns: minmax(160px, 220px) 1fr; + gap: 10px; + align-items: stretch; +} + +.bs-cmp-hero-pass { + border-radius: 14px; + border: 1px solid rgba(148, 163, 184, 0.28); + padding: 14px 16px; + background: rgba(255, 255, 255, 0.55); + display: flex; + flex-direction: column; + justify-content: center; +} + +.bs-cmp-board.is-fullscreen .bs-cmp-hero-pass, +.bs-cmp-board:fullscreen .bs-cmp-hero-pass { + background: rgba(15, 23, 42, 0.85); + border-color: rgba(148, 163, 184, 0.25); +} + +.bs-cmp-hero-pass.is-ok { + border-color: rgba(34, 197, 94, 0.45); + box-shadow: inset 0 0 0 1px rgba(34, 197, 94, 0.12); +} + +.bs-cmp-hero-pass.is-warn { + border-color: rgba(245, 158, 11, 0.5); + box-shadow: inset 0 0 0 1px rgba(245, 158, 11, 0.12); +} + +.bs-cmp-hero-pass__label { + font-size: 12px; + opacity: 0.7; +} + +.bs-cmp-hero-pass__value { + font-size: 42px; + font-weight: 800; + line-height: 1.05; + font-variant-numeric: tabular-nums; + letter-spacing: -0.03em; +} + +.bs-cmp-hero-pass.is-ok .bs-cmp-hero-pass__value { + color: #16a34a; +} + +.bs-cmp-hero-pass.is-warn .bs-cmp-hero-pass__value { + color: #d97706; +} + +.bs-cmp-hero-pass__unit { + font-size: 18px; + margin-left: 2px; + font-weight: 700; +} + +.bs-cmp-hero-pass__sub { + margin-top: 4px; + font-size: 12px; + opacity: 0.75; +} + +.bs-cmp-hero-metrics { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; +} + +.bs-cmp-hero-metric { + border-radius: 12px; + border: 1px solid rgba(148, 163, 184, 0.25); + background: rgba(255, 255, 255, 0.5); + padding: 10px 12px; + display: flex; + flex-direction: column; + gap: 2px; +} + +.bs-cmp-board.is-fullscreen .bs-cmp-hero-metric, +.bs-cmp-board:fullscreen .bs-cmp-hero-metric { + background: rgba(15, 23, 42, 0.75); +} + +.bs-cmp-hero-metric__label { + font-size: 11px; + opacity: 0.68; +} + +.bs-cmp-hero-metric__value { + font-size: 24px; + font-weight: 700; + font-variant-numeric: tabular-nums; + line-height: 1.15; +} + +.bs-cmp-hero-metric__sub { + font-size: 11px; + opacity: 0.65; +} + +.bs-cmp-sheet-cards { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 8px; +} + +.bs-cmp-sheet-card { + appearance: none; + text-align: left; + border: 1px solid rgba(148, 163, 184, 0.3); + border-radius: 12px; + background: rgba(255, 255, 255, 0.55); + color: inherit; + padding: 10px 12px; + cursor: pointer; +} + +.bs-cmp-board.is-fullscreen .bs-cmp-sheet-card, +.bs-cmp-board:fullscreen .bs-cmp-sheet-card { + background: rgba(15, 23, 42, 0.8); +} + +.bs-cmp-sheet-card.is-active { + border-color: rgba(37, 99, 235, 0.65); + box-shadow: 0 0 0 1px rgba(37, 99, 235, 0.25); +} + +.bs-cmp-sheet-card.has-diff { + border-color: rgba(245, 158, 11, 0.45); +} + +.bs-cmp-sheet-card.is-clean { + border-color: rgba(34, 197, 94, 0.35); +} + +.bs-cmp-sheet-card__title { + font-size: 12px; + font-weight: 600; + margin-bottom: 4px; + word-break: break-all; +} + +.bs-cmp-sheet-card__pass { + font-size: 22px; + font-weight: 800; + font-variant-numeric: tabular-nums; + line-height: 1.1; +} + +.bs-cmp-sheet-card.is-clean .bs-cmp-sheet-card__pass { + color: #16a34a; +} + +.bs-cmp-sheet-card.has-diff .bs-cmp-sheet-card__pass { + color: #d97706; +} + +.bs-cmp-sheet-card__stats { + display: flex; + gap: 8px; + margin-top: 6px; + font-size: 11px; + font-variant-numeric: tabular-nums; + opacity: 0.8; +} + +.bs-cmp-sheet-card__meta { + margin-top: 4px; + font-size: 11px; +} + +.bs-cmp-top-fields { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; +} + +.bs-cmp-top-fields__label { + font-size: 12px; + opacity: 0.7; + margin-right: 4px; +} + +.bs-cmp-top-fields__chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 3px 8px; + border-radius: 999px; + border: 1px solid rgba(148, 163, 184, 0.3); + font-size: 12px; + background: rgba(245, 158, 11, 0.08); +} + +.bs-cmp-top-fields__chip em { + font-style: normal; + font-weight: 700; + font-variant-numeric: tabular-nums; + color: #b45309; +} + .bs-cmp-result { min-height: 0; } @@ -10118,10 +10368,22 @@ html.login-page--paused .login-page__flare { color: #1d4ed8; } +.bs-cmp-board.is-fullscreen .bs-cmp-kind-pill.is-active, +.bs-cmp-board:fullscreen .bs-cmp-kind-pill.is-active { + color: #93c5fd; +} + .bs-cmp-result-table { flex: 1 1 auto; min-height: 280px; - max-height: min(48vh, 560px); + max-height: min(42vh, 480px); +} + +.bs-cmp-board.is-fullscreen .bs-cmp-result-table, +.bs-cmp-board:fullscreen .bs-cmp-result-table { + max-height: none; + min-height: 40vh; + flex: 1 1 auto; } .bs-cmp-diff-table { @@ -10139,6 +10401,11 @@ html.login-page--paused .login-page__flare { white-space: nowrap; } +.bs-cmp-board.is-fullscreen .bs-cmp-diff-table thead th, +.bs-cmp-board:fullscreen .bs-cmp-diff-table thead th { + background: #111827; +} + .bs-cmp-col-kind { width: 72px; white-space: nowrap; @@ -10236,7 +10503,12 @@ html.login-page--paused .login-page__flare { font-weight: 600; } -@media (max-width: 720px) { +@media (max-width: 900px) { + .bs-cmp-board__hero { + grid-template-columns: 1fr; + } + + .bs-cmp-hero-metrics, .bs-cmp-kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); } diff --git a/web/src/pages/network/BizComparePage.tsx b/web/src/pages/network/BizComparePage.tsx index d334222..cd558e7 100644 --- a/web/src/pages/network/BizComparePage.tsx +++ b/web/src/pages/network/BizComparePage.tsx @@ -1,5 +1,5 @@ import { Button, Input, Modal } from "@heroui/react"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { AppModalShell } from "../../components/ui/AppModalShell"; import { FieldSelect } from "../../components/ui/FieldSelect"; import { useDebouncedValue } from "../../hooks/useDebouncedValue"; @@ -11,6 +11,7 @@ import { bizCompareCreateTemplate, bizCompareDeleteJob, bizCompareDeleteTemplate, + bizCompareDownloadRun, bizCompareGetRun, bizCompareListJobs, bizCompareListMappings, @@ -200,6 +201,8 @@ export function BizComparePage() { const [kindFilter, setKindFilter] = useState("diff"); const [resultKw, setResultKw] = useState(""); const debouncedResultKw = useDebouncedValue(resultKw, 200); + const boardRef = useRef(null); + const [boardFs, setBoardFs] = useState(false); const refresh = useCallback(async () => { const [taskRes, tpl, maps, j, met] = await Promise.all([ @@ -355,6 +358,55 @@ export function BizComparePage() { }; const summary = runDetail?.summary || {}; + const sheetCards = (summary.sheet_cards || []) as Array<{ + metric_id: string; + mode?: string; + added?: number; + removed?: number; + changed?: number; + unchanged?: number; + before_count?: number; + after_count?: number; + diff_count?: number; + pass_rate?: number; + }>; + const topChangedFields = (summary.top_changed_fields || []) as Array<{ + field: string; + count: number; + }>; + + useEffect(() => { + const syncFs = () => { + const el = boardRef.current; + setBoardFs(Boolean(el && document.fullscreenElement === el)); + }; + document.addEventListener("fullscreenchange", syncFs); + return () => document.removeEventListener("fullscreenchange", syncFs); + }, []); + + const toggleBoardFullscreen = async () => { + const el = boardRef.current; + if (!el) return; + try { + if (document.fullscreenElement === el) await document.exitFullscreen(); + else await el.requestFullscreen(); + } catch (e) { + showError(formatErr(e)); + } + }; + + const downloadRunTables = async () => { + if (!runDetail?.id) return; + setBusy(true); + try { + await bizCompareDownloadRun(String(runDetail.id)); + showOk(t("bizCompare.exportOk")); + } catch (e) { + showError(formatErr(e)); + } finally { + setBusy(false); + } + }; const updateActiveSheet = (patch: Partial) => { setTplSheets((prev) => @@ -1180,7 +1232,12 @@ export function BizComparePage() { {/* Job detail */} - + {name || t("bizCompare.detail")} · {jobId.slice(0, 8)}… @@ -1210,8 +1267,11 @@ export function BizComparePage() { {jobDetailTab === "config" ? ( renderJobForm(false) ) : ( -
-
+
+
{runs.map((r) => ( ))} +
+ + +
{runDetail ? ( <> +
+
+
{t("bizCompare.passRate")}
+
+ {Number(summary.pass_rate ?? 0).toFixed(1)} + % +
+
+ {summary.ok ? t("bizCompare.passOk") : t("bizCompare.passWarn")} +
+
+
+
+ {t("bizCompare.diffCountLabel")} + {summary.diff_count ?? 0} + + {t("bizCompare.diffRate")}: {Number(summary.diff_rate ?? 0).toFixed(1)}% + +
+
+ {t("bizCompare.beforeCount")} + {summary.before_count ?? 0} + + {t("bizCompare.afterCount")}: {summary.after_count ?? 0} + +
+
+ {t("bizCompare.matchedRows")} + {summary.matched_rows ?? 0} + + {t("bizCompare.totalRows")}: {summary.total_rows ?? 0} + +
+
+ {t("bizCompare.sheetCount")} + + {summary.sheet_count ?? runSheets.length} + + + {fmtTime(runDetail.created_at)} + +
+
+
+ + {sheetCards.length ? ( +
+ {sheetCards.map((c) => ( + + ))} +
+ ) : null} + + {topChangedFields.length ? ( +
+ {t("bizCompare.topChangedFields")} + {topChangedFields.map((f) => ( + + {f.field} + {f.count} + + ))} +
+ ) : null} +
) : ( - + <> + + + )}