From 4f70627c8fc9dbc3e8b1215e2e2d0ed75970379a Mon Sep 17 00:00:00 2001 From: Bohan-J Date: Mon, 27 Jul 2026 19:12:29 +0800 Subject: [PATCH] fix(usage): move the Errors card to the page bottom and rebalance its layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../components/dashboard-page.test.tsx | 79 +++++- .../dashboard/components/dashboard-page.tsx | 265 ++++++++++++------ packages/views/locales/en/usage.json | 4 +- packages/views/locales/ja/usage.json | 4 +- packages/views/locales/ko/usage.json | 4 +- packages/views/locales/zh-Hans/usage.json | 4 +- 6 files changed, 261 insertions(+), 99 deletions(-) diff --git a/packages/views/dashboard/components/dashboard-page.test.tsx b/packages/views/dashboard/components/dashboard-page.test.tsx index f2360a856f..1f7a49b8d8 100644 --- a/packages/views/dashboard/components/dashboard-page.test.tsx +++ b/packages/views/dashboard/components/dashboard-page.test.tsx @@ -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(); + }); +}); diff --git a/packages/views/dashboard/components/dashboard-page.tsx b/packages/views/dashboard/components/dashboard-page.tsx index d0928dd73c..b52c438694 100644 --- a/packages/views/dashboard/components/dashboard-page.tsx +++ b/packages/views/dashboard/components/dashboard-page.tsx @@ -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. */} + $.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. */} - $.duration.less_than_minute)} - /> )} @@ -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 (
@@ -904,9 +917,9 @@ function ErrorsBreakdown({
{totals.failed === 0 ? null : ( -
-
-
+ <> +
+
{t(($) => $.errors.by_class)}
@@ -921,64 +934,31 @@ function ErrorsBreakdown({
{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. -
    $.errors.by_class)} className="space-y-1.5"> - {reasonRows.map((row) => ( -
  • - - - - {row.reason} - - - - {row.count} - -
  • - ))} -
+ ) : ( -
    $.errors.by_class)} className="space-y-2"> - {classRows.map((row) => ( -
  • -
    - {classLabel(row.failureClass)} - - {row.count} - -
    -
    -
    0 ? (row.count / maxClass) * 100 : 0}%`, - backgroundColor: FAILURE_CLASS_COLOR[row.failureClass], - }} - /> -
    -
  • - ))} -
+ )}
-
-
- {t(($) => $.errors.by_agent)} -
-
    $.errors.by_agent)} className="space-y-2"> - {agentRows.map((row) => ( +
    +
    +
    + {t(($) => $.errors.by_agent)} +
    + {agentRows.length > TOP_OFFENDER_LIMIT ? ( + + ) : null} +
    +
      $.errors.by_agent)} className="divide-y"> + {visibleAgents.map((row) => (
    -
+ )}
); } +/** + * 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 ( +
+ {/* Segments are ordered by count desc (the aggregator's order), so the + bar reads heaviest-first left to right. */} +
+ {rows.map((row) => ( +
+ ))} +
+
    $.errors.by_class)} + className="flex flex-wrap items-center gap-x-4 gap-y-1.5" + > + {rows.map((row) => ( +
  • + + {classLabel(row.failureClass)} + + {row.count} + +
  • + ))} +
+
+ ); +} + +/** + * 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 ( +
    $.errors.by_class)} + className="grid grid-cols-1 gap-x-6 gap-y-1.5 sm:grid-cols-2" + > + {rows.map((row) => ( +
  • + + + + {row.reason} + + + {row.count} +
  • + ))} +
+ ); +} + +/** + * 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 ( -
  • -
    - {name ? ( - - {label} - - ) : ( - label - )} - - {t(($) => $.errors.agent_rate, { - failed: row.failed, - total: row.total, - rate: formatRate(row.failed, row.total), - })} - -
    +
  • + {name ? ( + + {label} + + ) : ( + label + )}
    + + {t(($) => $.errors.agent_rate, { + failed: row.failed, + total: row.total, + rate: formatRate(row.failed, row.total), + })} +
  • ); } diff --git a/packages/views/locales/en/usage.json b/packages/views/locales/en/usage.json index 3af467dc3f..afeedb1822 100644 --- a/packages/views/locales/en/usage.json +++ b/packages/views/locales/en/usage.json @@ -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", diff --git a/packages/views/locales/ja/usage.json b/packages/views/locales/ja/usage.json index ee4187b022..51776b16a5 100644 --- a/packages/views/locales/ja/usage.json +++ b/packages/views/locales/ja/usage.json @@ -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": "リーダーボード", diff --git a/packages/views/locales/ko/usage.json b/packages/views/locales/ko/usage.json index 7534c93bd1..ad172224af 100644 --- a/packages/views/locales/ko/usage.json +++ b/packages/views/locales/ko/usage.json @@ -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": "리더보드", diff --git a/packages/views/locales/zh-Hans/usage.json b/packages/views/locales/zh-Hans/usage.json index 165e4eb115..f60c4bf08f 100644 --- a/packages/views/locales/zh-Hans/usage.json +++ b/packages/views/locales/zh-Hans/usage.json @@ -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": "排行榜",