fix(usage): move the Errors card to the page bottom and rebalance its layout

Two problems with the card as shipped, both visible on real data (283
failures across 28 agents):

1. It sat between the trend chart and the leaderboard. Spend is the headline
   of this page; failures are the follow-up question you ask after seeing who
   is spending. Moved below the leaderboard.

2. The two-column split put a 7-row list next to an unbounded one. In
   practice that was 7 rows of classes beside 28 rows of agents — roughly
   400px of content next to 1500px, so the left half was mostly whitespace
   and the card was taller than the rest of the page combined.

Rebalanced by stacking two full-width sections instead:

- Class breakdown is now a single 100%-stacked bar plus a legend, replacing
  seven individual progress bars. The question is "what is the mix", and one
  bar answers it directly instead of making the reader compare seven lengths
  and total them mentally. ~340px becomes ~70px.
- Offender rows fold onto one line, borrowing the leaderboard's grid shape
  (identity, bar, numbers) instead of stacking a full-width bar under each
  name. Halves the per-row height and lines the numbers into a scannable
  column.
- The list caps at 8 with a "Show all N" toggle. It is ranked by absolute
  failure count, so the tail is agents that failed once or twice — real, but
  not what anyone opens this card to see. The toggle label carries the full
  count, so the cap is never silent.
- The raw error-code list gets two columns on wide viewports now that the
  section has the full page width.

Card height on the screenshot's data drops from ~1500px to ~420px.

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
Bohan-J
2026-07-27 19:12:29 +08:00
parent 4d0475ce89
commit 4f70627c8f
6 changed files with 261 additions and 99 deletions

View File

@@ -14,6 +14,10 @@ import type { NavigationAdapter } from "../../navigation";
// dashboard options builders runs for real, so the key is the production key.
const queryKeys = vi.hoisted(() => [] as unknown[][]);
const dashboardDataRef = vi.hoisted(() => ({ current: false }));
// Swaps the by-agent fixture for one with enough agents to exercise the
// top-offenders cap. Kept off by default so the other tests keep their exact
// 4-of-10 arithmetic.
const manyAgentsRef = vi.hoisted(() => ({ current: false }));
function todayIso() {
return new Date().toISOString().slice(0, 10);
@@ -33,7 +37,12 @@ vi.mock("@tanstack/react-query", async () => {
// resolve agent-1 to a name and render its drill-down link.
if (opts.queryKey[0] === "workspaces" && opts.queryKey[2] === "agents") {
return {
data: [{ id: "agent-1", name: "Agent One" }],
data: manyAgentsRef.current
? Array.from({ length: 12 }, (_, i) => ({
id: `bulk-${i}`,
name: `Bulk Agent ${i}`,
}))
: [{ id: "agent-1", name: "Agent One" }],
isLoading: false,
isSuccess: true,
};
@@ -84,7 +93,21 @@ vi.mock("@tanstack/react-query", async () => {
{ date: todayIso(), failure_reason: "timeout", task_count: 1 },
]
: kind === "failures-by-agent"
? [
? manyAgentsRef.current
? Array.from({ length: 12 }, (_, i) => [
{
agent_id: `bulk-${i}`,
failure_reason: "",
task_count: 100,
},
{
agent_id: `bulk-${i}`,
failure_reason: "timeout",
// Descending so rank order is unambiguous.
task_count: 12 - i,
},
]).flat()
: [
{ agent_id: "agent-1", failure_reason: "", task_count: 6 },
{
agent_id: "agent-1",
@@ -339,3 +362,55 @@ describe("DashboardPage — the Errors list never exposes an agent the viewer ca
).not.toBeInTheDocument();
});
});
describe("DashboardPage — Errors card placement and density", () => {
beforeEach(() => {
queryKeys.length = 0;
dashboardDataRef.current = true;
manyAgentsRef.current = false;
tzRef.current = "UTC";
cleanup();
});
it("renders the Errors card after the leaderboard, at the bottom of the page", () => {
// Spend is the headline; failures are the follow-up question you ask
// after seeing who is spending. Asserting document order rather than a
// class name keeps this about the reading sequence.
renderDashboard();
// By heading, not by text: "Errors" also names the trend-chart toggle.
const leaderboard = screen.getByRole("heading", { name: "Leaderboard" });
const errors = screen.getByRole("heading", { name: "Errors" });
expect(
leaderboard.compareDocumentPosition(errors) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
});
it("caps the offender list and expands it on demand", async () => {
manyAgentsRef.current = true;
const user = userEvent.setup();
renderDashboard();
const list = () => screen.getByRole("list", { name: "Top offenders" });
// 12 agents have failures, but an unbounded list is what made this card
// taller than the rest of the page put together.
expect(within(list()).getAllByRole("listitem")).toHaveLength(8);
// The toggle is the truncation signal — its label carries the full count,
// so the cap is never silent.
await user.click(screen.getByRole("button", { name: "Show all 12" }));
expect(within(list()).getAllByRole("listitem")).toHaveLength(12);
await user.click(screen.getByRole("button", { name: "Show top 8" }));
expect(within(list()).getAllByRole("listitem")).toHaveLength(8);
});
it("shows no expand affordance when every offender already fits", () => {
renderDashboard();
expect(
screen.queryByRole("button", { name: /Show all/ }),
).not.toBeInTheDocument();
});
});

View File

@@ -595,6 +595,15 @@ export function DashboardPage() {
lessThanMinuteLabel={t(($) => $.duration.less_than_minute)}
/>
{/* Per-agent leaderboard — user picks the ranking metric;
the progress bar and column emphasis follow the metric. */}
<Leaderboard
rows={visibleAgentRows}
agents={agents}
deletedAgentCount={deletedAgentCount}
lessThanMinuteLabel={t(($) => $.duration.less_than_minute)}
/>
{/* Failure breakdown — what broke and who it broke for. Rendered
unconditionally (not only when failures exist) so "no failed
runs" is an answer the page gives rather than an absence the
@@ -606,15 +615,6 @@ export function DashboardPage() {
agentRows={agentFailureRows}
agents={agents}
/>
{/* Per-agent leaderboard — user picks the ranking metric;
the progress bar and column emphasis follow the metric. */}
<Leaderboard
rows={visibleAgentRows}
agents={agents}
deletedAgentCount={deletedAgentCount}
lessThanMinuteLabel={t(($) => $.duration.less_than_minute)}
/>
</>
)}
</div>
@@ -859,14 +859,24 @@ function useFailureClassLabel(): (c: FailureClass) => string {
};
}
// How many offenders the list shows before collapsing the tail behind a
// toggle. The list is ranked by absolute failure count, so the tail is
// agents that failed once or twice — real, but not what anyone opens this
// card to see. Eight keeps the card roughly as tall as the class summary
// plus a header, instead of running to 30+ rows on a busy workspace.
const TOP_OFFENDER_LIMIT = 8;
/**
* Failure breakdown for the selected window: what class of thing broke, and
* which agents it broke for.
*
* Two ranked lists rather than a second chart — with seven classes and an
* unbounded agent list, bar length plus an exact number is easier to read
* than more stacked colour, and it leaves room for the raw error codes
* behind a disclosure.
* Laid out as two stacked full-width sections rather than side-by-side
* columns. The two halves have structurally different lengths — classes are
* capped at seven and usually show three or four, while the agent list is
* unbounded — so a 2-column grid left one side stranded next to a column of
* whitespace. Stacking also lets each section use the full width for what it
* actually needs: proportion for the classes, a leaderboard-shaped row for
* the agents.
*/
function ErrorsBreakdown({
totals,
@@ -884,9 +894,12 @@ function ErrorsBreakdown({
const { t } = useT("usage");
const classLabel = useFailureClassLabel();
const [showReasons, setShowReasons] = useState(false);
const [showAllAgents, setShowAllAgents] = useState(false);
const maxClass = classRows.reduce((m, r) => Math.max(m, r.count), 0);
const maxAgent = agentRows.reduce((m, r) => Math.max(m, r.failed), 0);
const visibleAgents = showAllAgents
? agentRows
: agentRows.slice(0, TOP_OFFENDER_LIMIT);
return (
<div className="rounded-lg border bg-card">
@@ -904,9 +917,9 @@ function ErrorsBreakdown({
</div>
{totals.failed === 0 ? null : (
<div className="grid grid-cols-1 divide-y md:grid-cols-2 md:divide-x md:divide-y-0">
<div className="min-w-0 p-4">
<div className="mb-2 flex items-center justify-between gap-2">
<>
<div className="border-b p-4">
<div className="mb-2.5 flex items-center justify-between gap-2">
<h5 className="text-xs font-medium text-muted-foreground">
{t(($) => $.errors.by_class)}
</h5>
@@ -921,64 +934,31 @@ function ErrorsBreakdown({
</button>
</div>
{showReasons ? (
// Raw failure_reason values, unlocalised on purpose: they are
// the backend's wire enum, and an operator pasting one into a
// log search or an issue needs the exact string.
<ul aria-label={t(($) => $.errors.by_class)} className="space-y-1.5">
{reasonRows.map((row) => (
<li
key={row.reason}
className="flex items-center justify-between gap-2"
>
<span className="flex min-w-0 items-center gap-2">
<span
aria-hidden
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: FAILURE_CLASS_COLOR[row.failureClass],
}}
/>
<code className="truncate text-xs text-muted-foreground">
{row.reason}
</code>
</span>
<span className="shrink-0 text-xs tabular-nums">
{row.count}
</span>
</li>
))}
</ul>
<ReasonList rows={reasonRows} />
) : (
<ul aria-label={t(($) => $.errors.by_class)} className="space-y-2">
{classRows.map((row) => (
<li key={row.failureClass} className="space-y-1">
<div className="flex items-center justify-between gap-2 text-xs">
<span className="truncate">{classLabel(row.failureClass)}</span>
<span className="shrink-0 tabular-nums text-muted-foreground">
{row.count}
</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full transition-[width] duration-300 ease-out"
style={{
width: `${maxClass > 0 ? (row.count / maxClass) * 100 : 0}%`,
backgroundColor: FAILURE_CLASS_COLOR[row.failureClass],
}}
/>
</div>
</li>
))}
</ul>
<ClassComposition rows={classRows} classLabel={classLabel} />
)}
</div>
<div className="min-w-0 p-4">
<h5 className="mb-2 text-xs font-medium text-muted-foreground">
{t(($) => $.errors.by_agent)}
</h5>
<ul aria-label={t(($) => $.errors.by_agent)} className="space-y-2">
{agentRows.map((row) => (
<div className="p-4">
<div className="mb-2 flex items-center justify-between gap-2">
<h5 className="text-xs font-medium text-muted-foreground">
{t(($) => $.errors.by_agent)}
</h5>
{agentRows.length > TOP_OFFENDER_LIMIT ? (
<button
type="button"
onClick={() => setShowAllAgents((v) => !v)}
className="text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
>
{showAllAgents
? t(($) => $.errors.show_less, { count: TOP_OFFENDER_LIMIT })
: t(($) => $.errors.show_all, { count: agentRows.length })}
</button>
) : null}
</div>
<ul aria-label={t(($) => $.errors.by_agent)} className="divide-y">
{visibleAgents.map((row) => (
<AgentFailureItem
key={row.agentId}
row={row}
@@ -989,12 +969,113 @@ function ErrorsBreakdown({
))}
</ul>
</div>
</div>
</>
)}
</div>
);
}
/**
* Class breakdown as one 100%-stacked bar plus a legend.
*
* Replaces seven stacked progress bars. The question this answers is "what is
* the mix", and a single bar shows share-of-total directly — with separate
* bars the reader has to compare lengths and mentally total them. It also
* collapses ~340px of vertical space into ~70px, which is what let the card
* stop being a column of whitespace.
*/
function ClassComposition({
rows,
classLabel,
}: {
rows: FailureClassRow[];
classLabel: (c: FailureClass) => string;
}) {
const { t } = useT("usage");
const total = rows.reduce((sum, r) => sum + r.count, 0);
if (total === 0) return null;
return (
<div className="space-y-2.5">
{/* Segments are ordered by count desc (the aggregator's order), so the
bar reads heaviest-first left to right. */}
<div className="flex h-2 w-full overflow-hidden rounded-full bg-muted">
{rows.map((row) => (
<div
key={row.failureClass}
className="h-full transition-[width] duration-300 ease-out"
style={{
width: `${(row.count / total) * 100}%`,
backgroundColor: FAILURE_CLASS_COLOR[row.failureClass],
}}
/>
))}
</div>
<ul
aria-label={t(($) => $.errors.by_class)}
className="flex flex-wrap items-center gap-x-4 gap-y-1.5"
>
{rows.map((row) => (
<li key={row.failureClass} className="flex items-center gap-1.5">
<span
aria-hidden
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{ backgroundColor: FAILURE_CLASS_COLOR[row.failureClass] }}
/>
<span className="text-xs">{classLabel(row.failureClass)}</span>
<span className="text-xs tabular-nums text-muted-foreground">
{row.count}
</span>
</li>
))}
</ul>
</div>
);
}
/**
* Raw `failure_reason` values behind the class summary. Unlocalised on
* purpose: they are the backend's wire enum, and an operator pasting one into
* a log search or an issue needs the exact string.
*
* Two columns on wide viewports — the list runs to ~20 rows at its longest,
* and the card now has the full page width to spend on it.
*/
function ReasonList({ rows }: { rows: FailureReasonRow[] }) {
const { t } = useT("usage");
return (
<ul
aria-label={t(($) => $.errors.by_class)}
className="grid grid-cols-1 gap-x-6 gap-y-1.5 sm:grid-cols-2"
>
{rows.map((row) => (
<li key={row.reason} className="flex items-center justify-between gap-2">
<span className="flex min-w-0 items-center gap-2">
<span
aria-hidden
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{ backgroundColor: FAILURE_CLASS_COLOR[row.failureClass] }}
/>
<code className="truncate text-xs text-muted-foreground">
{row.reason}
</code>
</span>
<span className="shrink-0 text-xs tabular-nums">{row.count}</span>
</li>
))}
</ul>
);
}
/**
* One offender row, shaped like the leaderboard row directly above this card:
* identity, then a proportional bar, then the numbers.
*
* Was two lines (name+stats over a full-width bar), which at 30 agents made
* the card taller than the rest of the page combined. Folding the bar into
* its own grid column halves the height and lines the numbers up into a
* scannable column.
*/
function AgentFailureItem({
row,
name,
@@ -1036,27 +1117,18 @@ function AgentFailureItem({
);
return (
<li className="space-y-1">
<div className="flex items-center justify-between gap-2">
{name ? (
<AppLink
href={`${wsPaths.agentDetail(row.agentId)}?view=overview`}
newTabTitle={name}
className="min-w-0 hover:underline"
>
{label}
</AppLink>
) : (
label
)}
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
{t(($) => $.errors.agent_rate, {
failed: row.failed,
total: row.total,
rate: formatRate(row.failed, row.total),
})}
</span>
</div>
<li className="grid grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)_auto] items-center gap-3 py-2">
{name ? (
<AppLink
href={`${wsPaths.agentDetail(row.agentId)}?view=overview`}
newTabTitle={name}
className="min-w-0 hover:underline"
>
{label}
</AppLink>
) : (
label
)}
<div className="h-1.5 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full transition-[width] duration-300 ease-out"
@@ -1066,6 +1138,13 @@ function AgentFailureItem({
}}
/>
</div>
<span className="whitespace-nowrap text-right text-xs tabular-nums text-muted-foreground">
{t(($) => $.errors.agent_rate, {
failed: row.failed,
total: row.total,
rate: formatRate(row.failed, row.total),
})}
</span>
</li>
);
}

View File

@@ -60,7 +60,9 @@
"agent_rate": "{{failed}} / {{total}} · {{rate}}",
"no_data": "No failed runs in this window.",
"show_reasons": "Show error codes",
"hide_reasons": "Hide error codes"
"hide_reasons": "Hide error codes",
"show_all": "Show all {{count}}",
"show_less": "Show top {{count}}"
},
"leaderboard": {
"title": "Leaderboard",

View File

@@ -60,7 +60,9 @@
"agent_rate": "{{failed}} / {{total}} · {{rate}}",
"no_data": "この期間に失敗した実行はありません。",
"show_reasons": "エラーコードを表示",
"hide_reasons": "エラーコードを隠す"
"hide_reasons": "エラーコードを隠す",
"show_all": "全 {{count}} 件を表示",
"show_less": "上位 {{count}} 件のみ"
},
"leaderboard": {
"title": "リーダーボード",

View File

@@ -60,7 +60,9 @@
"agent_rate": "{{failed}} / {{total}} · {{rate}}",
"no_data": "이 기간에는 실패한 실행이 없습니다.",
"show_reasons": "오류 코드 표시",
"hide_reasons": "오류 코드 숨기기"
"hide_reasons": "오류 코드 숨기기",
"show_all": "전체 {{count}}개 보기",
"show_less": "상위 {{count}}개만"
},
"leaderboard": {
"title": "리더보드",

View File

@@ -60,7 +60,9 @@
"agent_rate": "{{failed}} / {{total}} · {{rate}}",
"no_data": "所选时间范围内没有失败的运行。",
"show_reasons": "展开错误码",
"hide_reasons": "收起错误码"
"hide_reasons": "收起错误码",
"show_all": "展开全部 {{count}} 个",
"show_less": "只看前 {{count}} 个"
},
"leaderboard": {
"title": "排行榜",