Fix collect time on compare sides and clean left-nav stat digits.

Fill started_at from batch lists, show compact times, and right-align color-only -/+~/ = counts per sheet.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-18 10:26:24 +08:00
parent d96cbf330a
commit 357b831f2d
3 changed files with 169 additions and 134 deletions

View file

@ -406,6 +406,8 @@ def list_batches(db: Session, task_id: str, *, limit: int = 50) -> list[dict[str
"command_count": b.command_count, "command_count": b.command_count,
"row_count": b.row_count, "row_count": b.row_count,
"message": b.message, "message": b.message,
"ne_name": b.ne_name or "",
"ne_id": b.ne_id or "",
"started_at": b.started_at.isoformat() + "Z" if b.started_at else None, "started_at": b.started_at.isoformat() + "Z" if b.started_at else None,
"ended_at": b.ended_at.isoformat() + "Z" if b.ended_at else None, "ended_at": b.ended_at.isoformat() + "Z" if b.ended_at else None,
} }

View file

@ -10130,7 +10130,7 @@ html.login-page--paused .login-page__flare {
border-radius: 8px; border-radius: 8px;
border: 1px solid var(--bs-cmp-border, rgba(148, 163, 184, 0.22)); border: 1px solid var(--bs-cmp-border, rgba(148, 163, 184, 0.22));
background: rgba(15, 23, 42, 0.45); background: rgba(15, 23, 42, 0.45);
overflow: hidden; overflow: visible;
} }
.bs-cmp-sides__side { .bs-cmp-sides__side {
@ -10139,9 +10139,11 @@ html.login-page--paused .login-page__flare {
align-items: center; align-items: center;
gap: 6px; gap: 6px;
min-width: 0; min-width: 0;
max-width: 100%;
padding: 0 6px; padding: 0 6px;
border-radius: 6px; border-radius: 6px;
height: 24px; height: 24px;
flex: 0 1 auto;
} }
.bs-cmp-sides__side.is-before { .bs-cmp-sides__side.is-before {
@ -10177,7 +10179,9 @@ html.login-page--paused .login-page__flare {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
max-width: 10rem; max-width: 8rem;
min-width: 0;
flex: 0 1 auto;
line-height: 1.2; line-height: 1.2;
} }
@ -10186,6 +10190,7 @@ html.login-page--paused .login-page__flare {
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
white-space: nowrap; white-space: nowrap;
flex: 0 0 auto; flex: 0 0 auto;
color: #94a3b8;
} }
.bs-cmp-sides__arrow { .bs-cmp-sides__arrow {
@ -10256,6 +10261,18 @@ html.login-page--paused .login-page__flare {
line-height: 1.35; line-height: 1.35;
} }
.bs-cmp-nav__legend {
display: grid;
grid-template-columns: repeat(4, 1.6em);
gap: 2px;
justify-items: end;
margin-top: 6px;
margin-left: auto;
width: fit-content;
font-size: 10px;
font-weight: 700;
}
.bs-cmp-nav__list { .bs-cmp-nav__list {
flex: 1 1 auto; flex: 1 1 auto;
min-height: 0; min-height: 0;
@ -10269,10 +10286,9 @@ html.login-page--paused .login-page__flare {
.bs-cmp-nav__item { .bs-cmp-nav__item {
appearance: none; appearance: none;
display: grid; display: grid;
grid-template-columns: 8px minmax(0, 1fr); grid-template-columns: 8px minmax(0, 1fr) auto;
grid-template-rows: auto auto; gap: 0 8px;
gap: 4px 8px; align-items: center;
align-items: start;
width: 100%; width: 100%;
text-align: left; text-align: left;
border: 1px solid transparent; border: 1px solid transparent;
@ -10300,7 +10316,6 @@ html.login-page--paused .login-page__flare {
background: #64748b; background: #64748b;
justify-self: center; justify-self: center;
align-self: center; align-self: center;
grid-row: 1;
} }
.bs-cmp-nav__item.is-clean .bs-cmp-nav__dot { .bs-cmp-nav__item.is-clean .bs-cmp-nav__dot {
@ -10318,81 +10333,45 @@ html.login-page--paused .login-page__flare {
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
color: #e2e8f0; color: #e2e8f0;
grid-column: 2;
grid-row: 1;
align-self: center;
}
.bs-cmp-nav__badges {
display: flex;
flex-wrap: wrap;
gap: 4px;
grid-column: 2;
grid-row: 2;
min-width: 0; min-width: 0;
} }
.bs-cmp-nav__badge { .bs-cmp-nav__stats {
display: inline-grid;
grid-template-columns: repeat(4, 1.6em);
gap: 2px;
justify-items: end;
align-items: center;
flex: 0 0 auto; flex: 0 0 auto;
white-space: nowrap;
padding: 1px 6px;
border-radius: 4px;
font-size: 10px;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
line-height: 1.45;
color: #94a3b8;
background: rgba(148, 163, 184, 0.1);
border: 1px solid rgba(148, 163, 184, 0.16);
} }
.bs-cmp-nav__badge b { .bs-cmp-nav__num {
font-size: 11px;
font-weight: 700; font-weight: 700;
margin-left: 2px; line-height: 1;
color: #cbd5e1; color: #475569;
opacity: 0.55;
} }
.bs-cmp-nav__badge--removed.is-hot { .bs-cmp-nav__num--removed.is-hot {
color: #fecaca; color: #f87171;
background: rgba(239, 68, 68, 0.14); opacity: 1;
border-color: rgba(248, 113, 113, 0.35);
} }
.bs-cmp-nav__badge--removed.is-hot b { .bs-cmp-nav__num--added.is-hot {
color: #fca5a5; color: #4ade80;
opacity: 1;
} }
.bs-cmp-nav__badge--added.is-hot { .bs-cmp-nav__num--changed.is-hot {
color: #bbf7d0;
background: rgba(34, 197, 94, 0.14);
border-color: rgba(74, 222, 128, 0.35);
}
.bs-cmp-nav__badge--added.is-hot b {
color: #86efac;
}
.bs-cmp-nav__badge--changed.is-hot {
color: #fde68a;
background: rgba(245, 158, 11, 0.14);
border-color: rgba(251, 191, 36, 0.35);
}
.bs-cmp-nav__badge--changed.is-hot b {
color: #fbbf24; color: #fbbf24;
opacity: 1;
} }
.bs-cmp-nav__badge--unchanged b { .bs-cmp-nav__num--unchanged {
color: #94a3b8; color: #94a3b8;
} opacity: 0.85;
.bs-cmp-nav__badge--pass.is-hot {
color: #bbf7d0;
background: rgba(34, 197, 94, 0.14);
border-color: rgba(74, 222, 128, 0.35);
}
.bs-cmp-nav__badge--pass.is-hot b {
color: #86efac;
} }
.bs-cmp-main { .bs-cmp-main {

View file

@ -224,30 +224,61 @@ function sideDeviceName(side?: {
} }
function sideCollectTime(side?: { started_at?: string | null } | null): string { function sideCollectTime(side?: { started_at?: string | null } | null): string {
return fmtTime(side?.started_at); const raw = side?.started_at;
if (!raw) return "—";
const full = fmtTime(raw);
if (!full || full === raw) {
// Prefer compact local time when possible
try {
const d = new Date(/[zZ]$|[+-]\d{2}:\d{2}$/.test(String(raw)) ? raw : `${String(raw).replace(" ", "T")}Z`);
if (!Number.isNaN(d.getTime())) {
const m = d.getMonth() + 1;
const day = d.getDate();
const hh = String(d.getHours()).padStart(2, "0");
const mm = String(d.getMinutes()).padStart(2, "0");
return `${m}/${day} ${hh}:${mm}`;
}
} catch {
/* fall through */
}
return full || "—";
}
// Compact: drop seconds / year noise from locale string when long
// e.g. "2026/9/18 09:35:53" -> "9/18 09:35"
const m = String(full).match(/(?:\d{4}[/-])?(\d{1,2})[/-](\d{1,2})\s+(\d{2}):(\d{2})/);
if (m) return `${m[1]}/${m[2]} ${m[3]}:${m[4]}`;
return full;
} }
function enrichSideFromTask( type SideInfo = {
ne_name?: string;
ne_ip?: string;
label?: string;
batch_id?: string;
started_at?: string | null;
};
function enrichSide(
side: Record<string, unknown> | null | undefined, side: Record<string, unknown> | null | undefined,
taskId: string, taskId: string,
tasks: TaskOpt[], tasks: TaskOpt[],
): { ne_name?: string; ne_ip?: string; label?: string; batch_id?: string; started_at?: string | null } { batches: BatchOpt[],
const base = { ...(side || {}) } as { ): SideInfo {
ne_name?: string; const base: SideInfo = { ...(side || {}) } as SideInfo;
ne_ip?: string; const bid = String(base.batch_id || "").trim();
label?: string; if (!base.started_at && bid) {
batch_id?: string; const batch = batches.find((b) => b.id === bid);
started_at?: string | null; if (batch?.started_at) base.started_at = batch.started_at;
}; }
if (sideDeviceName(base) !== "—") return base; if (sideDeviceName(base) === "—") {
const task = tasks.find((t) => t.id === taskId); const task = tasks.find((t) => t.id === taskId);
if (!task) return base; if (task) {
return { base.ne_name = task.ne_name || base.ne_name;
...base, base.ne_ip = task.ne_ip || base.ne_ip;
ne_name: task.ne_name || base.ne_name, base.label = task.ne_name || task.ne_ip || base.label;
ne_ip: task.ne_ip || base.ne_ip, }
label: task.ne_name || task.ne_ip || base.label, }
}; return base;
} }
function taskLabel(row: TaskOpt) { function taskLabel(row: TaskOpt) {
@ -2113,15 +2144,17 @@ export function BizComparePage() {
> >
<option value="">{t("bizCompare.pickRun")}</option> <option value="">{t("bizCompare.pickRun")}</option>
{runs.map((r) => { {runs.map((r) => {
const before = enrichSideFromTask( const before = enrichSide(
(r as any).before, (r as any).before,
beforeTaskId, beforeTaskId,
tasks, tasks,
beforeBatches,
); );
const after = enrichSideFromTask( const after = enrichSide(
(r as any).after, (r as any).after,
afterTaskId || beforeTaskId, afterTaskId || beforeTaskId,
tasks, tasks,
afterBatches.length ? afterBatches : beforeBatches,
); );
const bl = sideDeviceName(before); const bl = sideDeviceName(before);
const al = sideDeviceName(after); const al = sideDeviceName(after);
@ -2135,28 +2168,31 @@ export function BizComparePage() {
{runDetail ? ( {runDetail ? (
<div className="bs-cmp-sides" aria-label={t("bizCompare.sidesTitle")}> <div className="bs-cmp-sides" aria-label={t("bizCompare.sidesTitle")}>
{(() => { {(() => {
const before = enrichSideFromTask( const before = enrichSide(
runDetail.before, runDetail.before,
beforeTaskId, beforeTaskId,
tasks, tasks,
beforeBatches,
); );
const after = enrichSideFromTask( const after = enrichSide(
runDetail.after, runDetail.after,
afterTaskId || beforeTaskId, afterTaskId || beforeTaskId,
tasks, tasks,
afterBatches.length ? afterBatches : beforeBatches,
); );
const beforeName = sideDeviceName(before);
const afterName = sideDeviceName(after);
const beforeTime = sideCollectTime(before);
const afterTime = sideCollectTime(after);
return ( return (
<> <>
<div className="bs-cmp-sides__side is-before"> <div className="bs-cmp-sides__side is-before">
<span className="bs-cmp-sides__tag">{t("bizCompare.sideBefore")}</span> <span className="bs-cmp-sides__tag">{t("bizCompare.sideBefore")}</span>
<strong <strong className="bs-cmp-sides__name" title={`${beforeName} ${beforeTime}`}>
className="bs-cmp-sides__name" {beforeName}
title={sideDeviceName(before)}
>
{sideDeviceName(before)}
</strong> </strong>
<span className="bs-cmp-sides__meta muted"> <span className="bs-cmp-sides__meta" title={beforeTime}>
{sideCollectTime(before)} {beforeTime}
</span> </span>
</div> </div>
<span className="bs-cmp-sides__arrow" aria-hidden> <span className="bs-cmp-sides__arrow" aria-hidden>
@ -2164,14 +2200,11 @@ export function BizComparePage() {
</span> </span>
<div className="bs-cmp-sides__side is-after"> <div className="bs-cmp-sides__side is-after">
<span className="bs-cmp-sides__tag">{t("bizCompare.sideAfter")}</span> <span className="bs-cmp-sides__tag">{t("bizCompare.sideAfter")}</span>
<strong <strong className="bs-cmp-sides__name" title={`${afterName} ${afterTime}`}>
className="bs-cmp-sides__name" {afterName}
title={sideDeviceName(after)}
>
{sideDeviceName(after)}
</strong> </strong>
<span className="bs-cmp-sides__meta muted"> <span className="bs-cmp-sides__meta" title={afterTime}>
{sideCollectTime(after)} {afterTime}
</span> </span>
</div> </div>
</> </>
@ -2212,6 +2245,32 @@ export function BizComparePage() {
? ` · ${t("bizCompare.sheetFailCount", { n: String(sheetFailCount) })}` ? ` · ${t("bizCompare.sheetFailCount", { n: String(sheetFailCount) })}`
: ""} : ""}
</div> </div>
<div className="bs-cmp-nav__legend" aria-hidden>
<span
className="bs-cmp-nav__num bs-cmp-nav__num--removed is-hot"
title={t("bizCompare.missCount")}
>
-
</span>
<span
className="bs-cmp-nav__num bs-cmp-nav__num--added is-hot"
title={t("bizCompare.extraCount")}
>
+
</span>
<span
className="bs-cmp-nav__num bs-cmp-nav__num--changed is-hot"
title={t("bizCompare.mismatchCount")}
>
~
</span>
<span
className="bs-cmp-nav__num bs-cmp-nav__num--unchanged"
title={t("bizCompare.matchCount")}
>
=
</span>
</div>
</div> </div>
<div className="bs-cmp-nav__list" role="tablist"> <div className="bs-cmp-nav__list" role="tablist">
{(sheetCards.length {(sheetCards.length
@ -2248,39 +2307,34 @@ export function BizComparePage() {
> >
<span className="bs-cmp-nav__dot" aria-hidden /> <span className="bs-cmp-nav__dot" aria-hidden />
<span className="bs-cmp-nav__name">{c.metric_id}</span> <span className="bs-cmp-nav__name">{c.metric_id}</span>
<span className="bs-cmp-nav__badges"> <span
{dirty > 0 ? ( className="bs-cmp-nav__stats"
<> title={`${t("bizCompare.missCount")} ${Number(c.removed || 0)} · ${t("bizCompare.extraCount")} ${Number(c.added || 0)} · ${t("bizCompare.mismatchCount")} ${Number(c.changed || 0)} · ${t("bizCompare.matchCount")} ${Number(c.unchanged || 0)}`}
{Number(c.removed || 0) > 0 ? ( >
<span className="bs-cmp-nav__badge bs-cmp-nav__badge--removed is-hot"> <span
{t("bizCompare.missCount")}{" "} className={`bs-cmp-nav__num bs-cmp-nav__num--removed${
<b>{Number(c.removed || 0)}</b> Number(c.removed || 0) > 0 ? " is-hot" : ""
</span> }`}
) : null} >
{Number(c.added || 0) > 0 ? ( {Number(c.removed || 0)}
<span className="bs-cmp-nav__badge bs-cmp-nav__badge--added is-hot"> </span>
{t("bizCompare.extraCount")}{" "} <span
<b>{Number(c.added || 0)}</b> className={`bs-cmp-nav__num bs-cmp-nav__num--added${
</span> Number(c.added || 0) > 0 ? " is-hot" : ""
) : null} }`}
{Number(c.changed || 0) > 0 ? ( >
<span className="bs-cmp-nav__badge bs-cmp-nav__badge--changed is-hot"> {Number(c.added || 0)}
{t("bizCompare.mismatchCount")}{" "} </span>
<b>{Number(c.changed || 0)}</b> <span
</span> className={`bs-cmp-nav__num bs-cmp-nav__num--changed${
) : null} Number(c.changed || 0) > 0 ? " is-hot" : ""
</> }`}
) : ( >
<span className="bs-cmp-nav__badge bs-cmp-nav__badge--pass is-hot"> {Number(c.changed || 0)}
{t("bizCompare.kindPass")} </span>
{Number(c.unchanged || 0) > 0 ? ( <span className="bs-cmp-nav__num bs-cmp-nav__num--unchanged">
<> {Number(c.unchanged || 0)}
{" "} </span>
<b>{Number(c.unchanged || 0)}</b>
</>
) : null}
</span>
)}
</span> </span>
</button> </button>
); );