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": "排行榜",