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 <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-18 09:50:44 +08:00
parent bf35c0594e
commit 194a4592c1
5 changed files with 329 additions and 35 deletions

View file

@ -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)

View file

@ -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",

View file

@ -338,6 +338,10 @@ const zh = {
beforeBatch: "操作前批次",
afterTask: "操作后任务",
afterBatch: "操作后批次",
sideBefore: "操作前",
sideAfter: "操作后",
sidesVs: "比对",
sidesTitle: "比对双方",
pick: "选择…",
sameAsBefore: "同操作前 / 选择…",
autoHint: "auto:操作后任务新批次到达会自动跑比对",

View file

@ -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;

View file

@ -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<Record<string, unknown> | null | undefined>
): Record<string, unknown> {
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 (
<td
className={`bs-cmp-val-cell bs-cmp-val-cell--pair${
mismatch ? " bs-cmp-val-cell--diff" : ""
}`}
mismatch || isAdded || isRemoved ? " bs-cmp-val-cell--diff" : ""
}${isAdded ? " is-added" : ""}${isRemoved ? " is-removed" : ""}`}
>
<div className="bs-cmp-pair">
<div className="bs-cmp-pair__row">
<span className="bs-cmp-pair__tag">{beforeLabel}</span>
<span
className={`bs-cmp-val${mismatch ? " bs-cmp-val--pre" : ""}${
!beforeText ? " is-empty" : ""
}`}
>
{pre}
</span>
<span className={preClass}>{isAdded ? "—" : pre}</span>
</div>
<div className="bs-cmp-pair__row">
<span className="bs-cmp-pair__tag">{afterLabel}</span>
<span
className={`bs-cmp-val${mismatch ? " bs-cmp-val--post" : ""}${
!afterText ? " is-empty" : ""
}`}
>
{post}
</span>
<span className={postClass}>{isRemoved ? "—" : post}</span>
</div>
</div>
{reason ? <div className="bs-cmp-val-reason muted">{reason}</div> : 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<string, unknown> | undefined,
sample?.before as Record<string, unknown> | undefined,
sample?.after as Record<string, unknown> | undefined,
);
extras = Object.keys(side).filter((f) => !keySet.has(f) && !f.startsWith("_"));
}
return {
keys,
compare,
@ -2037,14 +2067,21 @@ export function BizComparePage() {
}}
>
<option value="">{t("bizCompare.pickRun")}</option>
{runs.map((r) => (
<option key={r.id} value={r.id}>
{fmtTime(r.created_at)} · {t("bizCompare.failCount")}{" "}
{(r.summary?.added ?? 0) +
(r.summary?.removed ?? 0) +
(r.summary?.changed ?? 0)}
</option>
))}
{runs.map((r) => {
const bl = (r as any).before?.label || "";
const al = (r as any).after?.label || "";
const sides =
bl || al ? `${bl || "—"} → ${al || "—"} · ` : "";
return (
<option key={r.id} value={r.id}>
{sides}
{fmtTime(r.created_at)} · {t("bizCompare.failCount")}{" "}
{(r.summary?.added ?? 0) +
(r.summary?.removed ?? 0) +
(r.summary?.changed ?? 0)}
</option>
);
})}
</FieldSelect>
<div className="btn-row">
<Button
@ -2067,6 +2104,64 @@ export function BizComparePage() {
</div>
{runDetail ? (
<>
<div className="bs-cmp-sides" aria-label={t("bizCompare.sidesTitle")}>
<div className="bs-cmp-sides__side is-before">
<span className="bs-cmp-sides__tag">{t("bizCompare.sideBefore")}</span>
<strong className="bs-cmp-sides__name" title={runDetail.before?.label || ""}>
{runDetail.before?.label || runDetail.before_batch_id || "—"}
</strong>
<span className="bs-cmp-sides__meta muted">
{[
runDetail.before?.vendor,
fmtTime(runDetail.before?.started_at),
runDetail.before?.status
? `rows=${runDetail.before?.row_count ?? 0}`
: "",
]
.filter(Boolean)
.join(" · ") || "—"}
</span>
</div>
<div className="bs-cmp-sides__vs" aria-hidden>
<span>{t("bizCompare.sidesVs")}</span>
</div>
<div className="bs-cmp-sides__side is-after">
<span className="bs-cmp-sides__tag">{t("bizCompare.sideAfter")}</span>
<strong className="bs-cmp-sides__name" title={runDetail.after?.label || ""}>
{runDetail.after?.label || runDetail.after_batch_id || "—"}
</strong>
<span className="bs-cmp-sides__meta muted">
{[
runDetail.after?.vendor,
fmtTime(runDetail.after?.started_at),
runDetail.after?.status
? `rows=${runDetail.after?.row_count ?? 0}`
: "",
]
.filter(Boolean)
.join(" · ") || "—"}
</span>
</div>
{(runDetail.job_name || runDetail.template_name || runDetail.mapping_name) && (
<div className="bs-cmp-sides__context muted">
{[
runDetail.job_name
? `${t("bizCompare.jobName")}: ${runDetail.job_name}`
: "",
runDetail.template_name
? `${t("bizCompare.template")}: ${runDetail.template_name}`
: "",
runDetail.mapping_name
? `${t("bizCompare.mapName")}: ${runDetail.mapping_name}`
: "",
]
.filter(Boolean)
.join(" · ")}
</div>
)}
</div>
<div className="bs-cmp-board__body">
<aside className="bs-cmp-nav" aria-label={t("bizCompare.sheetNavTitle")}>
<div className="bs-cmp-nav__head">
@ -2293,11 +2388,13 @@ export function BizComparePage() {
</thead>
<tbody>
{pagedDiffs.map((d, i) => {
const pre = (d.mapped_before || d.before || {}) as Record<
string,
unknown
>;
const post = (d.after || {}) as Record<string, unknown>;
const pre = pickSideRow(
d.mapped_before as Record<string, unknown> | null | undefined,
d.before as Record<string, unknown> | null | undefined,
);
const post = pickSideRow(
d.after as Record<string, unknown> | null | undefined,
);
const isFail = d.kind !== "unchanged";
return (
<tr key={i} className={`bs-cmp-row bs-cmp-row--${d.kind}`}>
@ -2326,15 +2423,18 @@ export function BizComparePage() {
const av = cellText(post[f]);
const ch = d.changes?.[f];
const isCmp = resultColumns.compareSet.has(f);
// Compare fields: engine mismatch; display fields: value differ / side missing
const mismatch = isCmp
? Boolean(ch) || d.kind === "added" || d.kind === "removed"
: pv !== av;
const mismatch =
d.kind === "added" || d.kind === "removed"
? Boolean(pv || av)
: isCmp
? Boolean(ch)
: pv !== av;
return (
<PairCell
key={f}
beforeText={pv}
afterText={av}
kind={d.kind}
mismatch={mismatch}
reason={ch?.reason}
beforeLabel={t("bizCompare.pairBefore")}
@ -2377,6 +2477,7 @@ export function BizComparePage() {
/>
</div>
</div>
</>
) : (
<div className="pt-list-empty">{t("bizCompare.noRuns")}</div>
)}