MAESTRO: Extract Leaderboard into standalone component with expand, sort, and animation
Extract the inline LeaderboardTable from LivePage into a standalone Leaderboard component with click-to-expand detail rows, sortable columns, smooth slide-in animation for new entries, and a subtle glow effect on the best run. 29 tests added.
This commit is contained in:
parent
b16454994e
commit
cf49e9c888
4 changed files with 738 additions and 170 deletions
|
|
@ -23,9 +23,11 @@ Build the React frontend: setup wizard, experiment builder, real-time observabil
|
|||
- [x] Build the model selector component (frontend/src/components/ModelSelector.tsx). Dropdown grouped by endpoint. Each option shows model name + endpoint label. Include a "refresh models" button that calls the endpoint test API to refresh available models. Show a connectivity indicator (green dot = reachable, red = error).
|
||||
<!-- Implemented ModelSelector with optgroup-based endpoint grouping, each option showing model name + endpoint label. Refresh button tests all endpoints in parallel via endpoints.test() API, updates connectivity indicators (green=reachable, red=error, yellow=testing, gray=unknown). Integrated into ExperimentPage PipelineStageCard replacing the inline select. 16 tests added. -->
|
||||
|
||||
- [ ] Implement the Live Observability page (frontend/src/pages/Live.tsx). This is the star of the show — the real-time dashboard during active sweeps. Layout: left column (60%) shows the activity timeline and current run details, right column (40%) shows the leaderboard and steering controls. Connect via WebSocket to /ws/experiments/{id}. Everything updates in real-time without page refresh.
|
||||
- [x] Implement the Live Observability page (frontend/src/pages/Live.tsx). This is the star of the show — the real-time dashboard during active sweeps. Layout: left column (60%) shows the activity timeline and current run details, right column (40%) shows the leaderboard and steering controls. Connect via WebSocket to /ws/experiments/{id}. Everything updates in real-time without page refresh.
|
||||
<!-- Implemented in LivePage.tsx. 60/40 grid layout with: Activity Timeline (color-coded event cards for run.started/completed/failed, new_best_found, cache_hit; event filter dropdown; auto-scroll toggle), Leaderboard (sortable columns, best-run amber highlight, status badges), Steering Controls (pause/resume/stop with confirmation dialogs, progress bar, token/cost/cache-rate stats), WebSocket connection with exponential backoff reconnect and connection status indicator. 35 tests added. App.test.tsx updated. -->
|
||||
|
||||
- [ ] Build the Leaderboard component (frontend/src/components/Leaderboard.tsx). Real-time ranked table of runs. Columns: rank, config summary (model + key params), individual scores, weighted total, status (completed/cached/running). Click a row to expand full details. Sortable by any column. New entries animate in smoothly. Highlight the current best with a subtle glow effect.
|
||||
- [x] Build the Leaderboard component (frontend/src/components/Leaderboard.tsx). Real-time ranked table of runs. Columns: rank, config summary (model + key params), individual scores, weighted total, status (completed/cached/running). Click a row to expand full details. Sortable by any column. New entries animate in smoothly. Highlight the current best with a subtle glow effect.
|
||||
<!-- Extracted from LivePage's inline LeaderboardTable into standalone component. Ranked table with sortable columns (individual scores + weighted total). Click-to-expand detail panel shows scores breakdown with visual bars, run ID, status, duration, token counts, and full config JSON. Best run highlighted with amber background + subtle glow shadow. New entries animate in with slide-in keyframe animation (auto-removed after 600ms). StatusBadge handles completed/running/failed/cached/unknown states. LivePage updated to import from new component. 29 tests added. -->
|
||||
|
||||
- [ ] Build the Activity Timeline component (frontend/src/components/Timeline.tsx). Chronological feed of events received via WebSocket. Each event is a card: run.started (blue), run.completed (green), new_best_found (gold), cache_hit (gray), run.failed (red). Include timestamps and key metrics. Auto-scroll to latest, with a "pause scroll" button. Filterable by event type.
|
||||
|
||||
|
|
|
|||
374
frontend/src/components/Leaderboard.test.tsx
Normal file
374
frontend/src/components/Leaderboard.test.tsx
Normal file
|
|
@ -0,0 +1,374 @@
|
|||
import { render, screen, within, act } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import Leaderboard from "./Leaderboard";
|
||||
import type { LeaderboardRow, LeaderboardProps } from "./Leaderboard";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeRow(overrides: Partial<LeaderboardRow> = {}): LeaderboardRow {
|
||||
return {
|
||||
run_id: "r1",
|
||||
config_summary: "gpt-4 t=0.7",
|
||||
scores: { length: 0.85, quality: 0.9 },
|
||||
weighted_total: 0.875,
|
||||
status: "completed",
|
||||
cached: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const ROW_A = makeRow({
|
||||
run_id: "r1",
|
||||
config_summary: "gpt-4 t=0.7",
|
||||
scores: { length: 0.85, quality: 0.9 },
|
||||
weighted_total: 0.875,
|
||||
});
|
||||
|
||||
const ROW_B = makeRow({
|
||||
run_id: "r2",
|
||||
config_summary: "gpt-3.5 t=1.0",
|
||||
scores: { length: 0.6, quality: 0.7 },
|
||||
weighted_total: 0.65,
|
||||
});
|
||||
|
||||
const ROW_C = makeRow({
|
||||
run_id: "r3",
|
||||
config_summary: "llama-3 t=0.5",
|
||||
scores: { length: 0.95, quality: 0.8 },
|
||||
weighted_total: 0.88,
|
||||
cached: true,
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function renderLeaderboard(overrides: Partial<LeaderboardProps> = {}) {
|
||||
const props: LeaderboardProps = {
|
||||
rows: [ROW_A, ROW_B],
|
||||
bestRunId: null,
|
||||
...overrides,
|
||||
};
|
||||
return { ...render(<Leaderboard {...props} />), props };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("Leaderboard", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Basic rendering
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it("renders the leaderboard table", () => {
|
||||
renderLeaderboard();
|
||||
expect(screen.getByTestId("leaderboard-table")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders rows for each entry", () => {
|
||||
renderLeaderboard();
|
||||
const rows = screen.getAllByTestId("leaderboard-row");
|
||||
expect(rows).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("shows rank numbers", () => {
|
||||
renderLeaderboard();
|
||||
const rows = screen.getAllByTestId("leaderboard-row");
|
||||
// Default sort is by weighted_total descending, so ROW_A (0.875) is #1
|
||||
expect(rows[0]).toHaveTextContent("1");
|
||||
expect(rows[1]).toHaveTextContent("2");
|
||||
});
|
||||
|
||||
it("shows config summary", () => {
|
||||
renderLeaderboard();
|
||||
expect(screen.getByText("gpt-4 t=0.7")).toBeInTheDocument();
|
||||
expect(screen.getByText("gpt-3.5 t=1.0")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows individual score columns", () => {
|
||||
renderLeaderboard();
|
||||
const rows = screen.getAllByTestId("leaderboard-row");
|
||||
// ROW_A scores: length=0.85, quality=0.9
|
||||
expect(rows[0]).toHaveTextContent("0.850");
|
||||
expect(rows[0]).toHaveTextContent("0.900");
|
||||
});
|
||||
|
||||
it("shows weighted total", () => {
|
||||
renderLeaderboard();
|
||||
const rows = screen.getAllByTestId("leaderboard-row");
|
||||
expect(rows[0]).toHaveTextContent("0.875");
|
||||
});
|
||||
|
||||
it("shows status badges", () => {
|
||||
renderLeaderboard({ rows: [ROW_A] });
|
||||
expect(screen.getByText("completed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows cached badge", () => {
|
||||
renderLeaderboard({ rows: [ROW_C] });
|
||||
expect(screen.getByText("cached")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows running status badge", () => {
|
||||
renderLeaderboard({
|
||||
rows: [makeRow({ run_id: "r-run", status: "running" })],
|
||||
});
|
||||
expect(screen.getByText("running")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows failed status badge", () => {
|
||||
renderLeaderboard({
|
||||
rows: [makeRow({ run_id: "r-fail", status: "failed" })],
|
||||
});
|
||||
expect(screen.getByText("failed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows fallback status badge for unknown status", () => {
|
||||
renderLeaderboard({
|
||||
rows: [makeRow({ run_id: "r-q", status: "queued" })],
|
||||
});
|
||||
expect(screen.getByText("queued")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows 'No runs yet' when empty", () => {
|
||||
renderLeaderboard({ rows: [] });
|
||||
expect(screen.getByText("No runs yet")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Sorting
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it("sorts by weighted total descending by default", () => {
|
||||
renderLeaderboard();
|
||||
const rows = screen.getAllByTestId("leaderboard-row");
|
||||
// ROW_A (0.875) should be first, ROW_B (0.65) second
|
||||
expect(rows[0]).toHaveTextContent("gpt-4 t=0.7");
|
||||
expect(rows[1]).toHaveTextContent("gpt-3.5 t=1.0");
|
||||
});
|
||||
|
||||
it("toggles sort direction on column click", async () => {
|
||||
renderLeaderboard();
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Click "Total" to toggle to ascending
|
||||
await user.click(screen.getByTestId("sort-total"));
|
||||
|
||||
const rows = screen.getAllByTestId("leaderboard-row");
|
||||
// Now ascending: ROW_B (0.65) first, ROW_A (0.875) second
|
||||
expect(rows[0]).toHaveTextContent("gpt-3.5 t=1.0");
|
||||
expect(rows[1]).toHaveTextContent("gpt-4 t=0.7");
|
||||
});
|
||||
|
||||
it("sorts by individual score column", async () => {
|
||||
renderLeaderboard({ rows: [ROW_A, ROW_B, ROW_C] });
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Click "length" column header
|
||||
await user.click(screen.getByTestId("sort-length"));
|
||||
|
||||
const rows = screen.getAllByTestId("leaderboard-row");
|
||||
// Descending by length: ROW_C (0.95), ROW_A (0.85), ROW_B (0.6)
|
||||
expect(rows[0]).toHaveTextContent("llama-3 t=0.5");
|
||||
expect(rows[1]).toHaveTextContent("gpt-4 t=0.7");
|
||||
expect(rows[2]).toHaveTextContent("gpt-3.5 t=1.0");
|
||||
});
|
||||
|
||||
it("shows sort indicator arrow", () => {
|
||||
renderLeaderboard();
|
||||
// Default sort is weighted_total descending
|
||||
expect(screen.getByTestId("sort-total")).toHaveTextContent("\u25BC");
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Best run highlighting
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it("highlights the best run with glow effect", () => {
|
||||
renderLeaderboard({ bestRunId: "r1" });
|
||||
const rows = screen.getAllByTestId("leaderboard-row");
|
||||
expect(rows[0].className).toContain("bg-amber");
|
||||
expect(rows[0].className).toContain("shadow-");
|
||||
});
|
||||
|
||||
it("does not highlight non-best rows", () => {
|
||||
renderLeaderboard({ bestRunId: "r1" });
|
||||
const rows = screen.getAllByTestId("leaderboard-row");
|
||||
expect(rows[1].className).not.toContain("bg-amber");
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Click to expand details
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it("expands row details on click", async () => {
|
||||
renderLeaderboard();
|
||||
const user = userEvent.setup();
|
||||
|
||||
expect(screen.queryByTestId("leaderboard-detail")).not.toBeInTheDocument();
|
||||
|
||||
const rows = screen.getAllByTestId("leaderboard-row");
|
||||
await user.click(rows[0]);
|
||||
|
||||
expect(screen.getByTestId("leaderboard-detail")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows run ID in expanded details", async () => {
|
||||
renderLeaderboard();
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(screen.getAllByTestId("leaderboard-row")[0]);
|
||||
|
||||
const detail = screen.getByTestId("leaderboard-detail");
|
||||
expect(detail).toHaveTextContent("r1");
|
||||
});
|
||||
|
||||
it("shows scores breakdown in expanded details", async () => {
|
||||
renderLeaderboard();
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(screen.getAllByTestId("leaderboard-row")[0]);
|
||||
|
||||
const detail = screen.getByTestId("leaderboard-detail");
|
||||
expect(detail).toHaveTextContent("length");
|
||||
expect(detail).toHaveTextContent("quality");
|
||||
expect(detail).toHaveTextContent("Weighted Total");
|
||||
});
|
||||
|
||||
it("shows duration when available", async () => {
|
||||
renderLeaderboard({
|
||||
rows: [makeRow({ run_id: "r-dur", duration_ms: 2500 })],
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(screen.getAllByTestId("leaderboard-row")[0]);
|
||||
|
||||
const detail = screen.getByTestId("leaderboard-detail");
|
||||
expect(detail).toHaveTextContent("2.50s");
|
||||
});
|
||||
|
||||
it("shows token counts when available", async () => {
|
||||
renderLeaderboard({
|
||||
rows: [makeRow({ run_id: "r-tok", tokens_in: 100, tokens_out: 200 })],
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(screen.getAllByTestId("leaderboard-row")[0]);
|
||||
|
||||
const detail = screen.getByTestId("leaderboard-detail");
|
||||
expect(detail).toHaveTextContent("100 in / 200 out");
|
||||
});
|
||||
|
||||
it("shows config JSON when available", async () => {
|
||||
renderLeaderboard({
|
||||
rows: [
|
||||
makeRow({
|
||||
run_id: "r-cfg",
|
||||
config: { model: "gpt-4", temperature: 0.7 },
|
||||
}),
|
||||
],
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(screen.getAllByTestId("leaderboard-row")[0]);
|
||||
|
||||
const detail = screen.getByTestId("leaderboard-detail");
|
||||
expect(detail).toHaveTextContent('"model": "gpt-4"');
|
||||
});
|
||||
|
||||
it("collapses expanded row on second click", async () => {
|
||||
renderLeaderboard();
|
||||
const user = userEvent.setup();
|
||||
|
||||
const rows = screen.getAllByTestId("leaderboard-row");
|
||||
await user.click(rows[0]);
|
||||
expect(screen.getByTestId("leaderboard-detail")).toBeInTheDocument();
|
||||
|
||||
await user.click(rows[0]);
|
||||
expect(screen.queryByTestId("leaderboard-detail")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("only expands one row at a time", async () => {
|
||||
renderLeaderboard();
|
||||
const user = userEvent.setup();
|
||||
|
||||
const rows = screen.getAllByTestId("leaderboard-row");
|
||||
await user.click(rows[0]);
|
||||
expect(screen.getAllByTestId("leaderboard-detail")).toHaveLength(1);
|
||||
|
||||
// Click a different row
|
||||
await user.click(rows[1]);
|
||||
expect(screen.getAllByTestId("leaderboard-detail")).toHaveLength(1);
|
||||
// Detail should now show the second row's data
|
||||
expect(screen.getByTestId("leaderboard-detail")).toHaveTextContent("r2");
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// onRowClick callback
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it("calls onRowClick with run_id when row is clicked", async () => {
|
||||
const onRowClick = vi.fn();
|
||||
renderLeaderboard({ onRowClick });
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(screen.getAllByTestId("leaderboard-row")[0]);
|
||||
|
||||
expect(onRowClick).toHaveBeenCalledWith("r1");
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// New entry animation
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it("applies animation class to new entries", () => {
|
||||
const { rerender } = render(
|
||||
<Leaderboard rows={[ROW_A]} bestRunId={null} />,
|
||||
);
|
||||
|
||||
// Add a new row
|
||||
rerender(
|
||||
<Leaderboard rows={[ROW_A, ROW_B]} bestRunId={null} />,
|
||||
);
|
||||
|
||||
const rows = screen.getAllByTestId("leaderboard-row");
|
||||
// The new row (ROW_B) should have the animation class
|
||||
const hasAnimation = rows.some((r) =>
|
||||
r.className.includes("animate-slide-in"),
|
||||
);
|
||||
expect(hasAnimation).toBe(true);
|
||||
});
|
||||
|
||||
it("removes animation class after timeout", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const { rerender } = render(
|
||||
<Leaderboard rows={[ROW_A]} bestRunId={null} />,
|
||||
);
|
||||
|
||||
rerender(
|
||||
<Leaderboard rows={[ROW_A, ROW_B]} bestRunId={null} />,
|
||||
);
|
||||
|
||||
// Advance past animation cleanup timeout
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(700);
|
||||
});
|
||||
|
||||
const rows = screen.getAllByTestId("leaderboard-row");
|
||||
const hasAnimation = rows.some((r) =>
|
||||
r.className.includes("animate-slide-in"),
|
||||
);
|
||||
expect(hasAnimation).toBe(false);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
357
frontend/src/components/Leaderboard.tsx
Normal file
357
frontend/src/components/Leaderboard.tsx
Normal file
|
|
@ -0,0 +1,357 @@
|
|||
import { Fragment, useState, useRef, useEffect } from "react";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface LeaderboardRow {
|
||||
run_id: string;
|
||||
config_summary: string;
|
||||
scores: Record<string, number>;
|
||||
weighted_total: number;
|
||||
status: string;
|
||||
cached: boolean;
|
||||
/** Optional full config for expanded detail view */
|
||||
config?: Record<string, unknown>;
|
||||
/** Optional timing information */
|
||||
duration_ms?: number | null;
|
||||
/** Optional token counts */
|
||||
tokens_in?: number | null;
|
||||
tokens_out?: number | null;
|
||||
}
|
||||
|
||||
export interface LeaderboardProps {
|
||||
rows: LeaderboardRow[];
|
||||
bestRunId: string | null;
|
||||
onRowClick?: (runId: string) => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Status Badge
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function StatusBadge({ status, cached }: { status: string; cached: boolean }) {
|
||||
if (cached)
|
||||
return (
|
||||
<span className="inline-flex items-center rounded-full bg-slate-100 dark:bg-slate-700 px-2 py-0.5 text-xs text-slate-500 dark:text-slate-400">
|
||||
cached
|
||||
</span>
|
||||
);
|
||||
if (status === "completed")
|
||||
return (
|
||||
<span className="inline-flex items-center rounded-full bg-green-100 dark:bg-green-900/30 px-2 py-0.5 text-xs text-green-700 dark:text-green-400">
|
||||
completed
|
||||
</span>
|
||||
);
|
||||
if (status === "running")
|
||||
return (
|
||||
<span className="inline-flex items-center rounded-full bg-blue-100 dark:bg-blue-900/30 px-2 py-0.5 text-xs text-blue-700 dark:text-blue-400">
|
||||
running
|
||||
</span>
|
||||
);
|
||||
if (status === "failed")
|
||||
return (
|
||||
<span className="inline-flex items-center rounded-full bg-red-100 dark:bg-red-900/30 px-2 py-0.5 text-xs text-red-700 dark:text-red-400">
|
||||
failed
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<span className="inline-flex items-center rounded-full bg-slate-100 dark:bg-slate-700 px-2 py-0.5 text-xs text-slate-500 dark:text-slate-400">
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Score Bar (visual indicator for individual scores)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ScoreBar({ value }: { value: number }) {
|
||||
const pct = Math.max(0, Math.min(100, value * 100));
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="tabular-nums text-slate-700 dark:text-slate-300 w-12 text-right">
|
||||
{value.toFixed(3)}
|
||||
</span>
|
||||
<div className="flex-1 h-1.5 rounded-full bg-slate-200 dark:bg-slate-700 min-w-[40px] max-w-[60px]">
|
||||
<div
|
||||
className="h-full rounded-full bg-indigo-500 transition-all duration-300"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Expanded Row Detail
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function RowDetail({ row }: { row: LeaderboardRow }) {
|
||||
return (
|
||||
<tr data-testid="leaderboard-detail">
|
||||
<td
|
||||
colSpan={100}
|
||||
className="px-4 py-3 bg-slate-50 dark:bg-slate-800/80 border-t border-slate-100 dark:border-slate-700"
|
||||
>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
|
||||
{/* Scores breakdown */}
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-slate-400 dark:text-slate-500 mb-2">
|
||||
Scores
|
||||
</h4>
|
||||
<div className="space-y-1.5">
|
||||
{Object.entries(row.scores).map(([name, value]) => (
|
||||
<div key={name} className="flex items-center justify-between gap-2">
|
||||
<span className="text-slate-600 dark:text-slate-400 capitalize">
|
||||
{name}
|
||||
</span>
|
||||
<ScoreBar value={value} />
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center justify-between gap-2 pt-1 border-t border-slate-200 dark:border-slate-700">
|
||||
<span className="font-medium text-slate-700 dark:text-slate-300">
|
||||
Weighted Total
|
||||
</span>
|
||||
<span className="tabular-nums font-semibold text-slate-900 dark:text-white">
|
||||
{row.weighted_total.toFixed(3)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Config & metadata */}
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-slate-400 dark:text-slate-500 mb-2">
|
||||
Details
|
||||
</h4>
|
||||
<dl className="space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-slate-500 dark:text-slate-400">Run ID</dt>
|
||||
<dd className="text-slate-700 dark:text-slate-300 font-mono text-xs">
|
||||
{row.run_id}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-slate-500 dark:text-slate-400">Status</dt>
|
||||
<dd>
|
||||
<StatusBadge status={row.status} cached={row.cached} />
|
||||
</dd>
|
||||
</div>
|
||||
{row.duration_ms != null && (
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-slate-500 dark:text-slate-400">Duration</dt>
|
||||
<dd className="text-slate-700 dark:text-slate-300 tabular-nums">
|
||||
{(row.duration_ms / 1000).toFixed(2)}s
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
{(row.tokens_in != null || row.tokens_out != null) && (
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-slate-500 dark:text-slate-400">Tokens</dt>
|
||||
<dd className="text-slate-700 dark:text-slate-300 tabular-nums">
|
||||
{row.tokens_in ?? 0} in / {row.tokens_out ?? 0} out
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
{row.config && (
|
||||
<div className="mt-2">
|
||||
<dt className="text-slate-500 dark:text-slate-400 mb-1">Config</dt>
|
||||
<dd>
|
||||
<pre className="text-xs bg-slate-100 dark:bg-slate-900 rounded p-2 overflow-x-auto text-slate-700 dark:text-slate-300">
|
||||
{JSON.stringify(row.config, null, 2)}
|
||||
</pre>
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Leaderboard Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function Leaderboard({ rows, bestRunId, onRowClick }: LeaderboardProps) {
|
||||
const [sortKey, setSortKey] = useState<string>("weighted_total");
|
||||
const [sortAsc, setSortAsc] = useState(false);
|
||||
const [expandedRunId, setExpandedRunId] = useState<string | null>(null);
|
||||
const [newRunIds, setNewRunIds] = useState<Set<string>>(new Set());
|
||||
const prevRowCountRef = useRef(rows.length);
|
||||
|
||||
// Track newly added rows for entry animation
|
||||
useEffect(() => {
|
||||
if (rows.length > prevRowCountRef.current) {
|
||||
const existingIds = new Set(
|
||||
rows.slice(0, prevRowCountRef.current).map((r) => r.run_id),
|
||||
);
|
||||
const added = rows
|
||||
.filter((r) => !existingIds.has(r.run_id))
|
||||
.map((r) => r.run_id);
|
||||
|
||||
if (added.length > 0) {
|
||||
setNewRunIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
added.forEach((id) => next.add(id));
|
||||
return next;
|
||||
});
|
||||
|
||||
// Remove animation class after transition completes
|
||||
const timer = setTimeout(() => {
|
||||
setNewRunIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
added.forEach((id) => next.delete(id));
|
||||
return next;
|
||||
});
|
||||
}, 600);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
prevRowCountRef.current = rows.length;
|
||||
}, [rows]);
|
||||
|
||||
function handleSort(key: string) {
|
||||
if (key === sortKey) {
|
||||
setSortAsc(!sortAsc);
|
||||
} else {
|
||||
setSortKey(key);
|
||||
setSortAsc(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleRowClick(runId: string) {
|
||||
setExpandedRunId((prev) => (prev === runId ? null : runId));
|
||||
onRowClick?.(runId);
|
||||
}
|
||||
|
||||
const sortedRows = [...rows].sort((a, b) => {
|
||||
let aVal: number;
|
||||
let bVal: number;
|
||||
if (sortKey === "weighted_total") {
|
||||
aVal = a.weighted_total;
|
||||
bVal = b.weighted_total;
|
||||
} else {
|
||||
aVal = a.scores[sortKey] ?? 0;
|
||||
bVal = b.scores[sortKey] ?? 0;
|
||||
}
|
||||
return sortAsc ? aVal - bVal : bVal - aVal;
|
||||
});
|
||||
|
||||
// Collect all score keys across all rows
|
||||
const scoreKeys = Array.from(
|
||||
new Set(rows.flatMap((r) => Object.keys(r.scores))),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-lg border border-slate-200 dark:border-slate-700">
|
||||
<table className="w-full text-sm" data-testid="leaderboard-table">
|
||||
<thead>
|
||||
<tr className="bg-slate-50 dark:bg-slate-800 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider">
|
||||
<th className="px-3 py-2">#</th>
|
||||
<th className="px-3 py-2">Config</th>
|
||||
{scoreKeys.map((k) => (
|
||||
<th
|
||||
key={k}
|
||||
className="px-3 py-2 cursor-pointer hover:text-indigo-600 dark:hover:text-indigo-400 select-none"
|
||||
onClick={() => handleSort(k)}
|
||||
data-testid={`sort-${k}`}
|
||||
>
|
||||
{k}
|
||||
{sortKey === k ? (sortAsc ? " \u25B2" : " \u25BC") : ""}
|
||||
</th>
|
||||
))}
|
||||
<th
|
||||
className="px-3 py-2 cursor-pointer hover:text-indigo-600 dark:hover:text-indigo-400 select-none"
|
||||
onClick={() => handleSort("weighted_total")}
|
||||
data-testid="sort-total"
|
||||
>
|
||||
Total
|
||||
{sortKey === "weighted_total"
|
||||
? sortAsc
|
||||
? " \u25B2"
|
||||
: " \u25BC"
|
||||
: ""}
|
||||
</th>
|
||||
<th className="px-3 py-2">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedRows.map((row, idx) => {
|
||||
const isExpanded = expandedRunId === row.run_id;
|
||||
const isBest = row.run_id === bestRunId;
|
||||
const isNew = newRunIds.has(row.run_id);
|
||||
|
||||
return (
|
||||
<Fragment key={row.run_id}>
|
||||
<tr
|
||||
data-testid="leaderboard-row"
|
||||
onClick={() => handleRowClick(row.run_id)}
|
||||
className={[
|
||||
"border-t border-slate-100 dark:border-slate-700 transition-all duration-300 cursor-pointer",
|
||||
isBest
|
||||
? "bg-amber-50 dark:bg-amber-900/20 ring-1 ring-amber-300 dark:ring-amber-700 shadow-[0_0_8px_rgba(251,191,36,0.3)] dark:shadow-[0_0_8px_rgba(251,191,36,0.15)]"
|
||||
: "hover:bg-slate-50 dark:hover:bg-slate-800/50",
|
||||
isNew ? "animate-slide-in" : "",
|
||||
].join(" ")}
|
||||
>
|
||||
<td className="px-3 py-2 font-medium text-slate-700 dark:text-slate-300">
|
||||
{idx + 1}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-slate-600 dark:text-slate-400 max-w-[180px] truncate">
|
||||
{row.config_summary}
|
||||
</td>
|
||||
{scoreKeys.map((k) => (
|
||||
<td
|
||||
key={k}
|
||||
className="px-3 py-2 tabular-nums text-slate-700 dark:text-slate-300"
|
||||
>
|
||||
{row.scores[k] != null ? row.scores[k].toFixed(3) : "\u2014"}
|
||||
</td>
|
||||
))}
|
||||
<td className="px-3 py-2 font-semibold tabular-nums text-slate-900 dark:text-white">
|
||||
{row.weighted_total.toFixed(3)}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<StatusBadge status={row.status} cached={row.cached} />
|
||||
</td>
|
||||
</tr>
|
||||
{isExpanded && <RowDetail row={row} />}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
{sortedRows.length === 0 && (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={3 + scoreKeys.length}
|
||||
className="px-3 py-6 text-center text-slate-400 dark:text-slate-500"
|
||||
>
|
||||
No runs yet
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* Animation keyframes injected via style tag */}
|
||||
<style>{`
|
||||
@keyframes leaderboard-slide-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
.animate-slide-in {
|
||||
animation: leaderboard-slide-in 0.4s ease-out;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -11,6 +11,8 @@ import type {
|
|||
RunResponse,
|
||||
WsConnection,
|
||||
} from "../api/client";
|
||||
import Leaderboard from "../components/Leaderboard";
|
||||
import type { LeaderboardRow } from "../components/Leaderboard";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
|
|
@ -54,15 +56,6 @@ export interface TimelineEntry {
|
|||
timestamp: Date;
|
||||
}
|
||||
|
||||
export interface LeaderboardRow {
|
||||
run_id: string;
|
||||
config_summary: string;
|
||||
scores: Record<string, number>;
|
||||
weighted_total: number;
|
||||
status: string;
|
||||
cached: boolean;
|
||||
}
|
||||
|
||||
type ConnectionStatus = "connecting" | "connected" | "disconnected";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -137,164 +130,6 @@ function TimelineCard({ entry }: { entry: TimelineEntry }) {
|
|||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Leaderboard Table
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function LeaderboardTable({
|
||||
rows,
|
||||
bestRunId,
|
||||
}: {
|
||||
rows: LeaderboardRow[];
|
||||
bestRunId: string | null;
|
||||
}) {
|
||||
const [sortKey, setSortKey] = useState<string>("weighted_total");
|
||||
const [sortAsc, setSortAsc] = useState(false);
|
||||
|
||||
function handleSort(key: string) {
|
||||
if (key === sortKey) {
|
||||
setSortAsc(!sortAsc);
|
||||
} else {
|
||||
setSortKey(key);
|
||||
setSortAsc(false);
|
||||
}
|
||||
}
|
||||
|
||||
const sortedRows = [...rows].sort((a, b) => {
|
||||
let aVal: number;
|
||||
let bVal: number;
|
||||
if (sortKey === "weighted_total") {
|
||||
aVal = a.weighted_total;
|
||||
bVal = b.weighted_total;
|
||||
} else {
|
||||
aVal = a.scores[sortKey] ?? 0;
|
||||
bVal = b.scores[sortKey] ?? 0;
|
||||
}
|
||||
return sortAsc ? aVal - bVal : bVal - aVal;
|
||||
});
|
||||
|
||||
// Collect all score keys
|
||||
const scoreKeys = Array.from(
|
||||
new Set(rows.flatMap((r) => Object.keys(r.scores))),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-lg border border-slate-200 dark:border-slate-700">
|
||||
<table className="w-full text-sm" data-testid="leaderboard-table">
|
||||
<thead>
|
||||
<tr className="bg-slate-50 dark:bg-slate-800 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider">
|
||||
<th className="px-3 py-2">#</th>
|
||||
<th className="px-3 py-2">Config</th>
|
||||
{scoreKeys.map((k) => (
|
||||
<th
|
||||
key={k}
|
||||
className="px-3 py-2 cursor-pointer hover:text-indigo-600 dark:hover:text-indigo-400 select-none"
|
||||
onClick={() => handleSort(k)}
|
||||
>
|
||||
{k}
|
||||
{sortKey === k ? (sortAsc ? " \u25B2" : " \u25BC") : ""}
|
||||
</th>
|
||||
))}
|
||||
<th
|
||||
className="px-3 py-2 cursor-pointer hover:text-indigo-600 dark:hover:text-indigo-400 select-none"
|
||||
onClick={() => handleSort("weighted_total")}
|
||||
>
|
||||
Total
|
||||
{sortKey === "weighted_total"
|
||||
? sortAsc
|
||||
? " \u25B2"
|
||||
: " \u25BC"
|
||||
: ""}
|
||||
</th>
|
||||
<th className="px-3 py-2">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedRows.map((row, idx) => (
|
||||
<tr
|
||||
key={row.run_id}
|
||||
data-testid="leaderboard-row"
|
||||
className={`border-t border-slate-100 dark:border-slate-700 transition-colors ${
|
||||
row.run_id === bestRunId
|
||||
? "bg-amber-50 dark:bg-amber-900/20 ring-1 ring-amber-300 dark:ring-amber-700"
|
||||
: "hover:bg-slate-50 dark:hover:bg-slate-800/50"
|
||||
}`}
|
||||
>
|
||||
<td className="px-3 py-2 font-medium text-slate-700 dark:text-slate-300">
|
||||
{idx + 1}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-slate-600 dark:text-slate-400 max-w-[180px] truncate">
|
||||
{row.config_summary}
|
||||
</td>
|
||||
{scoreKeys.map((k) => (
|
||||
<td
|
||||
key={k}
|
||||
className="px-3 py-2 tabular-nums text-slate-700 dark:text-slate-300"
|
||||
>
|
||||
{row.scores[k] != null ? row.scores[k].toFixed(3) : "—"}
|
||||
</td>
|
||||
))}
|
||||
<td className="px-3 py-2 font-semibold tabular-nums text-slate-900 dark:text-white">
|
||||
{row.weighted_total.toFixed(3)}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<StatusBadge status={row.status} cached={row.cached} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{sortedRows.length === 0 && (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={3 + scoreKeys.length}
|
||||
className="px-3 py-6 text-center text-slate-400 dark:text-slate-500"
|
||||
>
|
||||
No runs yet
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({
|
||||
status,
|
||||
cached,
|
||||
}: {
|
||||
status: string;
|
||||
cached: boolean;
|
||||
}) {
|
||||
if (cached)
|
||||
return (
|
||||
<span className="inline-flex items-center rounded-full bg-slate-100 dark:bg-slate-700 px-2 py-0.5 text-xs text-slate-500 dark:text-slate-400">
|
||||
cached
|
||||
</span>
|
||||
);
|
||||
if (status === "completed")
|
||||
return (
|
||||
<span className="inline-flex items-center rounded-full bg-green-100 dark:bg-green-900/30 px-2 py-0.5 text-xs text-green-700 dark:text-green-400">
|
||||
completed
|
||||
</span>
|
||||
);
|
||||
if (status === "running")
|
||||
return (
|
||||
<span className="inline-flex items-center rounded-full bg-blue-100 dark:bg-blue-900/30 px-2 py-0.5 text-xs text-blue-700 dark:text-blue-400">
|
||||
running
|
||||
</span>
|
||||
);
|
||||
if (status === "failed")
|
||||
return (
|
||||
<span className="inline-flex items-center rounded-full bg-red-100 dark:bg-red-900/30 px-2 py-0.5 text-xs text-red-700 dark:text-red-400">
|
||||
failed
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<span className="inline-flex items-center rounded-full bg-slate-100 dark:bg-slate-700 px-2 py-0.5 text-xs text-slate-500 dark:text-slate-400">
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Steering Controls
|
||||
|
|
@ -935,7 +770,7 @@ export default function LivePage() {
|
|||
</h2>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<LeaderboardTable rows={leaderboard} bestRunId={bestRunId} />
|
||||
<Leaderboard rows={leaderboard} bestRunId={bestRunId} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue