From 194a4592c1ba347b6e1cc2091365f8964acf8aed Mon Sep 17 00:00:00 2001 From: oliver Date: Fri, 18 Sep 2026 09:50:44 +0800 Subject: [PATCH] Show compare sides on the board and fix missing/extra row cells. Enrich run detail with before/after NE labels, and keep present-side values readable for added/removed rows instead of striking them out. Co-authored-by: Cursor --- netx_api/biz_state/compare_service.py | 65 +++++++++ web/src/i18n/en.ts | 4 + web/src/i18n/zh.ts | 4 + web/src/index.css | 120 ++++++++++++++++ web/src/pages/network/BizComparePage.tsx | 171 ++++++++++++++++++----- 5 files changed, 329 insertions(+), 35 deletions(-) diff --git a/netx_api/biz_state/compare_service.py b/netx_api/biz_state/compare_service.py index ff6e2ed..e273c9f 100644 --- a/netx_api/biz_state/compare_service.py +++ b/netx_api/biz_state/compare_service.py @@ -21,6 +21,7 @@ from ..models import ( BizPortMappingRow, BizStateBatch, BizStateLldpNeighbor, + BizStateTask, ) from ..timeutil import utcnow_naive from .compare_engine import compare_rows, mapping_stats @@ -38,6 +39,59 @@ def _utcnow() -> datetime: return utcnow_naive() +def _compare_side(db: Session, batch_id: str) -> dict[str, Any]: + """Human-readable before/after side for board header (who vs who).""" + bid = str(batch_id or "").strip() + if not bid: + return { + "batch_id": "", + "task_id": "", + "ne_name": "", + "ne_id": "", + "ne_ip": "", + "vendor": "", + "status": "", + "row_count": 0, + "started_at": None, + "label": "", + } + b = db.get(BizStateBatch, bid) + if not b: + return { + "batch_id": bid, + "task_id": "", + "ne_name": "", + "ne_id": "", + "ne_ip": "", + "vendor": "", + "status": "missing", + "row_count": 0, + "started_at": None, + "label": bid[:12], + } + task = db.get(BizStateTask, b.task_id) if b.task_id else None + ne_name = str(b.ne_name or (task.ne_name if task else "") or "").strip() + ne_id = str(b.ne_id or (task.ne_id if task else "") or "").strip() + ne_ip = str((task.ne_ip if task else "") or "").strip() + vendor = str(b.vendor or (task.vendor if task else "") or "").strip() + title = ne_name or ne_id or (b.task_id or bid)[:12] + if ne_ip and ne_ip not in (ne_name, ne_id, title): + label = f"{title} ({ne_ip})" + else: + label = title + return { + "batch_id": bid, + "task_id": str(b.task_id or ""), + "ne_name": ne_name, + "ne_id": ne_id, + "ne_ip": ne_ip, + "vendor": vendor, + "status": str(b.status or ""), + "row_count": int(b.row_count or 0), + "started_at": b.started_at.isoformat() + "Z" if b.started_at else None, + "label": label, + } + _DIFF_CHUNK = 2000 _SEARCH_TEXT_MAX = 4000 @@ -1338,13 +1392,22 @@ def get_run(db: Session, run_id: str) -> dict[str, Any]: ] enriched = _enrich_summary(summary, raw_sheets) stored = "rows" if _run_has_diff_rows(db, run_id) else "inline" + job = db.get(BizCompareJob, r.job_id) if r.job_id else None + mapping = db.get(BizPortMapping, r.mapping_id) if r.mapping_id else None + before_side = _compare_side(db, r.before_batch_id) + after_side = _compare_side(db, r.after_batch_id) return { "id": r.id, "job_id": r.job_id, + "job_name": (job.name if job else "") or "", "template_id": r.template_id, + "template_name": (tpl.name if tpl else "") or "", "mapping_id": r.mapping_id, + "mapping_name": (mapping.name if mapping else "") or "", "before_batch_id": r.before_batch_id, "after_batch_id": r.after_batch_id, + "before": before_side, + "after": after_side, "metric_id": r.metric_id, "status": r.status, "summary": enriched, @@ -1532,6 +1595,8 @@ def list_runs(db: Session, job_id: str, *, limit: int = 20) -> list[dict[str, An "id": r.id, "before_batch_id": r.before_batch_id, "after_batch_id": r.after_batch_id, + "before": _compare_side(db, r.before_batch_id), + "after": _compare_side(db, r.after_batch_id), "status": r.status, "summary": { k: (r.summary_json or {}).get(k, 0) diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 2749f33..d3e03e3 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -338,6 +338,10 @@ const en = { beforeBatch: "Before batch", afterTask: "After task", afterBatch: "After batch", + sideBefore: "Before", + sideAfter: "After", + sidesVs: "vs", + sidesTitle: "Compare sides", pick: "Select…", sameAsBefore: "Same as before / select…", autoHint: "auto: new after-task batches trigger compare", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 6f535c8..f83f772 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -338,6 +338,10 @@ const zh = { beforeBatch: "操作前批次", afterTask: "操作后任务", afterBatch: "操作后批次", + sideBefore: "操作前", + sideAfter: "操作后", + sidesVs: "比对", + sidesTitle: "比对双方", pick: "选择…", sameAsBefore: "同操作前 / 选择…", autoHint: "auto:操作后任务新批次到达会自动跑比对", diff --git a/web/src/index.css b/web/src/index.css index 2390a2e..6e950fe 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -10103,6 +10103,107 @@ html.login-page--paused .login-page__flare { flex: 0 0 auto; } +.bs-cmp-sides { + display: grid; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); + gap: 8px 12px; + align-items: center; + padding: 10px 12px; + border-radius: 10px; + border: 1px solid var(--bs-cmp-border, rgba(148, 163, 184, 0.22)); + background: rgba(15, 23, 42, 0.55); + flex: 0 0 auto; +} + +.bs-cmp-sides__side { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + padding: 6px 10px; + border-radius: 8px; +} + +.bs-cmp-sides__side.is-before { + background: rgba(239, 68, 68, 0.08); + border: 1px solid rgba(248, 113, 113, 0.28); +} + +.bs-cmp-sides__side.is-after { + background: rgba(34, 197, 94, 0.08); + border: 1px solid rgba(74, 222, 128, 0.28); +} + +.bs-cmp-sides__tag { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + color: #94a3b8; +} + +.bs-cmp-sides__side.is-before .bs-cmp-sides__tag { + color: #fca5a5; +} + +.bs-cmp-sides__side.is-after .bs-cmp-sides__tag { + color: #86efac; +} + +.bs-cmp-sides__name { + font-size: 15px; + font-weight: 700; + color: #f1f5f9; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + line-height: 1.3; +} + +.bs-cmp-sides__meta { + font-size: 11px; + font-variant-numeric: tabular-nums; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.bs-cmp-sides__vs { + display: flex; + align-items: center; + justify-content: center; + width: 2.5rem; + height: 2.5rem; + border-radius: 999px; + background: rgba(59, 130, 246, 0.18); + border: 1px solid rgba(96, 165, 250, 0.4); + color: #93c5fd; + font-size: 12px; + font-weight: 800; + letter-spacing: 0.02em; +} + +.bs-cmp-sides__context { + grid-column: 1 / -1; + font-size: 11px; + padding-top: 2px; + border-top: 1px solid rgba(148, 163, 184, 0.15); +} + +@media (max-width: 720px) { + .bs-cmp-sides { + grid-template-columns: 1fr; + } + + .bs-cmp-sides__vs { + justify-self: center; + width: auto; + height: auto; + padding: 2px 10px; + border-radius: 6px; + } +} + .bs-cmp-board__body { display: grid; grid-template-columns: minmax(176px, 220px) minmax(0, 1fr); @@ -10841,6 +10942,25 @@ html.login-page--paused .login-page__flare { font-weight: 600; } +/* Whole-row 缺失/多余: keep the present side readable (no strikethrough) */ +.bs-cmp-val--present-pre { + color: #fecaca; + font-weight: 600; +} + +.bs-cmp-val--present-post { + color: #86efac; + font-weight: 600; +} + +.bs-cmp-val-cell--pair.is-removed { + background: rgba(239, 68, 68, 0.08); +} + +.bs-cmp-val-cell--pair.is-added { + background: rgba(34, 197, 94, 0.08); +} + .bs-cmp-val-arrow { margin: 0 6px; opacity: 0.45; diff --git a/web/src/pages/network/BizComparePage.tsx b/web/src/pages/network/BizComparePage.tsx index ed8b17c..8ee72b1 100644 --- a/web/src/pages/network/BizComparePage.tsx +++ b/web/src/pages/network/BizComparePage.tsx @@ -138,44 +138,62 @@ function failFieldNames(d: DiffRow): string[] { return Object.keys(d.changes || {}); } +/** Prefer first non-empty row dict (API may return {} for null sides). */ +function pickSideRow( + ...candidates: Array | null | undefined> +): Record { + for (const c of candidates) { + if (c && typeof c === "object" && Object.keys(c).length > 0) return c; + } + return {}; +} + /** Non-key fields: always show before/after pair for cutover review. */ function PairCell(props: { beforeText: string; afterText: string; + kind: string; mismatch: boolean; reason?: string; beforeLabel: string; afterLabel: string; }) { - const { beforeText, afterText, mismatch, reason, beforeLabel, afterLabel } = props; + const { beforeText, afterText, kind, mismatch, reason, beforeLabel, afterLabel } = props; const pre = beforeText || "—"; const post = afterText || "—"; + const isAdded = kind === "added"; + const isRemoved = kind === "removed"; + // Whole-row missing/extra: emphasize the present side; do not strike it out. + const preClass = [ + "bs-cmp-val", + !beforeText || isAdded ? "is-empty" : "", + isRemoved && beforeText ? "bs-cmp-val--present-pre" : "", + !isAdded && !isRemoved && mismatch && beforeText ? "bs-cmp-val--pre" : "", + ] + .filter(Boolean) + .join(" "); + const postClass = [ + "bs-cmp-val", + !afterText || isRemoved ? "is-empty" : "", + isAdded && afterText ? "bs-cmp-val--present-post" : "", + !isAdded && !isRemoved && mismatch && afterText ? "bs-cmp-val--post" : "", + ] + .filter(Boolean) + .join(" "); return (
{beforeLabel} - - {pre} - + {isAdded ? "—" : pre}
{afterLabel} - - {post} - + {isRemoved ? "—" : post}
{reason ?
{reason}
: null} @@ -672,7 +690,19 @@ export function BizComparePage() { const rest = display.filter((f) => !keySet.has(f)); display = [...keys, ...rest]; } - const extras = display.filter((f) => !keySet.has(f)); + let extras = display.filter((f) => !keySet.has(f)); + // Presence / sparse display: if no value columns, pull fields from sample + // added/removed rows so 缺失/多余 still show side data. + if (!extras.length && pagedDiffs.length) { + const sample = + pagedDiffs.find((d) => d.kind === "added" || d.kind === "removed") || pagedDiffs[0]; + const side = pickSideRow( + sample?.mapped_before as Record | undefined, + sample?.before as Record | undefined, + sample?.after as Record | undefined, + ); + extras = Object.keys(side).filter((f) => !keySet.has(f) && !f.startsWith("_")); + } return { keys, compare, @@ -2037,14 +2067,21 @@ export function BizComparePage() { }} > - {runs.map((r) => ( - - ))} + {runs.map((r) => { + const bl = (r as any).before?.label || ""; + const al = (r as any).after?.label || ""; + const sides = + bl || al ? `${bl || "—"} → ${al || "—"} · ` : ""; + return ( + + ); + })}