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).
|
- [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. -->
|
<!-- 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.
|
- [ ] 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,
|
RunResponse,
|
||||||
WsConnection,
|
WsConnection,
|
||||||
} from "../api/client";
|
} from "../api/client";
|
||||||
|
import Leaderboard from "../components/Leaderboard";
|
||||||
|
import type { LeaderboardRow } from "../components/Leaderboard";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types
|
// Types
|
||||||
|
|
@ -54,15 +56,6 @@ export interface TimelineEntry {
|
||||||
timestamp: Date;
|
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";
|
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
|
// Steering Controls
|
||||||
|
|
@ -935,7 +770,7 @@ export default function LivePage() {
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<LeaderboardTable rows={leaderboard} bestRunId={bestRunId} />
|
<Leaderboard rows={leaderboard} bestRunId={bestRunId} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue