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,
"row_count": b.row_count,
"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,
"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: 1px solid var(--bs-cmp-border, rgba(148, 163, 184, 0.22));
background: rgba(15, 23, 42, 0.45);
overflow: hidden;
overflow: visible;
}
.bs-cmp-sides__side {
@ -10139,9 +10139,11 @@ html.login-page--paused .login-page__flare {
align-items: center;
gap: 6px;
min-width: 0;
max-width: 100%;
padding: 0 6px;
border-radius: 6px;
height: 24px;
flex: 0 1 auto;
}
.bs-cmp-sides__side.is-before {
@ -10177,7 +10179,9 @@ html.login-page--paused .login-page__flare {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 10rem;
max-width: 8rem;
min-width: 0;
flex: 0 1 auto;
line-height: 1.2;
}
@ -10186,6 +10190,7 @@ html.login-page--paused .login-page__flare {
font-variant-numeric: tabular-nums;
white-space: nowrap;
flex: 0 0 auto;
color: #94a3b8;
}
.bs-cmp-sides__arrow {
@ -10256,6 +10261,18 @@ html.login-page--paused .login-page__flare {
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 {
flex: 1 1 auto;
min-height: 0;
@ -10269,10 +10286,9 @@ html.login-page--paused .login-page__flare {
.bs-cmp-nav__item {
appearance: none;
display: grid;
grid-template-columns: 8px minmax(0, 1fr);
grid-template-rows: auto auto;
gap: 4px 8px;
align-items: start;
grid-template-columns: 8px minmax(0, 1fr) auto;
gap: 0 8px;
align-items: center;
width: 100%;
text-align: left;
border: 1px solid transparent;
@ -10300,7 +10316,6 @@ html.login-page--paused .login-page__flare {
background: #64748b;
justify-self: center;
align-self: center;
grid-row: 1;
}
.bs-cmp-nav__item.is-clean .bs-cmp-nav__dot {
@ -10318,81 +10333,45 @@ html.login-page--paused .login-page__flare {
text-overflow: ellipsis;
white-space: nowrap;
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;
}
.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;
white-space: nowrap;
padding: 1px 6px;
border-radius: 4px;
font-size: 10px;
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;
margin-left: 2px;
color: #cbd5e1;
line-height: 1;
color: #475569;
opacity: 0.55;
}
.bs-cmp-nav__badge--removed.is-hot {
color: #fecaca;
background: rgba(239, 68, 68, 0.14);
border-color: rgba(248, 113, 113, 0.35);
.bs-cmp-nav__num--removed.is-hot {
color: #f87171;
opacity: 1;
}
.bs-cmp-nav__badge--removed.is-hot b {
color: #fca5a5;
.bs-cmp-nav__num--added.is-hot {
color: #4ade80;
opacity: 1;
}
.bs-cmp-nav__badge--added.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 {
.bs-cmp-nav__num--changed.is-hot {
color: #fbbf24;
opacity: 1;
}
.bs-cmp-nav__badge--unchanged b {
.bs-cmp-nav__num--unchanged {
color: #94a3b8;
}
.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;
opacity: 0.85;
}
.bs-cmp-main {

View file

@ -224,30 +224,61 @@ function sideDeviceName(side?: {
}
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,
taskId: string,
tasks: TaskOpt[],
): { ne_name?: string; ne_ip?: string; label?: string; batch_id?: string; started_at?: string | null } {
const base = { ...(side || {}) } as {
ne_name?: string;
ne_ip?: string;
label?: string;
batch_id?: string;
started_at?: string | null;
};
if (sideDeviceName(base) !== "—") return base;
const task = tasks.find((t) => t.id === taskId);
if (!task) return base;
return {
...base,
ne_name: task.ne_name || base.ne_name,
ne_ip: task.ne_ip || base.ne_ip,
label: task.ne_name || task.ne_ip || base.label,
};
batches: BatchOpt[],
): SideInfo {
const base: SideInfo = { ...(side || {}) } as SideInfo;
const bid = String(base.batch_id || "").trim();
if (!base.started_at && bid) {
const batch = batches.find((b) => b.id === bid);
if (batch?.started_at) base.started_at = batch.started_at;
}
if (sideDeviceName(base) === "—") {
const task = tasks.find((t) => t.id === taskId);
if (task) {
base.ne_name = task.ne_name || base.ne_name;
base.ne_ip = task.ne_ip || base.ne_ip;
base.label = task.ne_name || task.ne_ip || base.label;
}
}
return base;
}
function taskLabel(row: TaskOpt) {
@ -2113,15 +2144,17 @@ export function BizComparePage() {
>
<option value="">{t("bizCompare.pickRun")}</option>
{runs.map((r) => {
const before = enrichSideFromTask(
const before = enrichSide(
(r as any).before,
beforeTaskId,
tasks,
beforeBatches,
);
const after = enrichSideFromTask(
const after = enrichSide(
(r as any).after,
afterTaskId || beforeTaskId,
tasks,
afterBatches.length ? afterBatches : beforeBatches,
);
const bl = sideDeviceName(before);
const al = sideDeviceName(after);
@ -2135,28 +2168,31 @@ export function BizComparePage() {
{runDetail ? (
<div className="bs-cmp-sides" aria-label={t("bizCompare.sidesTitle")}>
{(() => {
const before = enrichSideFromTask(
const before = enrichSide(
runDetail.before,
beforeTaskId,
tasks,
beforeBatches,
);
const after = enrichSideFromTask(
const after = enrichSide(
runDetail.after,
afterTaskId || beforeTaskId,
tasks,
afterBatches.length ? afterBatches : beforeBatches,
);
const beforeName = sideDeviceName(before);
const afterName = sideDeviceName(after);
const beforeTime = sideCollectTime(before);
const afterTime = sideCollectTime(after);
return (
<>
<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={sideDeviceName(before)}
>
{sideDeviceName(before)}
<strong className="bs-cmp-sides__name" title={`${beforeName} ${beforeTime}`}>
{beforeName}
</strong>
<span className="bs-cmp-sides__meta muted">
{sideCollectTime(before)}
<span className="bs-cmp-sides__meta" title={beforeTime}>
{beforeTime}
</span>
</div>
<span className="bs-cmp-sides__arrow" aria-hidden>
@ -2164,14 +2200,11 @@ export function BizComparePage() {
</span>
<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={sideDeviceName(after)}
>
{sideDeviceName(after)}
<strong className="bs-cmp-sides__name" title={`${afterName} ${afterTime}`}>
{afterName}
</strong>
<span className="bs-cmp-sides__meta muted">
{sideCollectTime(after)}
<span className="bs-cmp-sides__meta" title={afterTime}>
{afterTime}
</span>
</div>
</>
@ -2212,6 +2245,32 @@ export function BizComparePage() {
? ` · ${t("bizCompare.sheetFailCount", { n: String(sheetFailCount) })}`
: ""}
</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 className="bs-cmp-nav__list" role="tablist">
{(sheetCards.length
@ -2248,39 +2307,34 @@ export function BizComparePage() {
>
<span className="bs-cmp-nav__dot" aria-hidden />
<span className="bs-cmp-nav__name">{c.metric_id}</span>
<span className="bs-cmp-nav__badges">
{dirty > 0 ? (
<>
{Number(c.removed || 0) > 0 ? (
<span className="bs-cmp-nav__badge bs-cmp-nav__badge--removed is-hot">
{t("bizCompare.missCount")}{" "}
<b>{Number(c.removed || 0)}</b>
</span>
) : null}
{Number(c.added || 0) > 0 ? (
<span className="bs-cmp-nav__badge bs-cmp-nav__badge--added is-hot">
{t("bizCompare.extraCount")}{" "}
<b>{Number(c.added || 0)}</b>
</span>
) : null}
{Number(c.changed || 0) > 0 ? (
<span className="bs-cmp-nav__badge bs-cmp-nav__badge--changed is-hot">
{t("bizCompare.mismatchCount")}{" "}
<b>{Number(c.changed || 0)}</b>
</span>
) : null}
</>
) : (
<span className="bs-cmp-nav__badge bs-cmp-nav__badge--pass is-hot">
{t("bizCompare.kindPass")}
{Number(c.unchanged || 0) > 0 ? (
<>
{" "}
<b>{Number(c.unchanged || 0)}</b>
</>
) : null}
</span>
)}
<span
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)}`}
>
<span
className={`bs-cmp-nav__num bs-cmp-nav__num--removed${
Number(c.removed || 0) > 0 ? " is-hot" : ""
}`}
>
{Number(c.removed || 0)}
</span>
<span
className={`bs-cmp-nav__num bs-cmp-nav__num--added${
Number(c.added || 0) > 0 ? " is-hot" : ""
}`}
>
{Number(c.added || 0)}
</span>
<span
className={`bs-cmp-nav__num bs-cmp-nav__num--changed${
Number(c.changed || 0) > 0 ? " is-hot" : ""
}`}
>
{Number(c.changed || 0)}
</span>
<span className="bs-cmp-nav__num bs-cmp-nav__num--unchanged">
{Number(c.unchanged || 0)}
</span>
</span>
</button>
);