diff --git a/Auto Run Docs/02b-frontend-dashboard.md b/Auto Run Docs/02b-frontend-dashboard.md index e1f861b..396cc25 100644 --- a/Auto Run Docs/02b-frontend-dashboard.md +++ b/Auto Run Docs/02b-frontend-dashboard.md @@ -29,7 +29,8 @@ Build the React frontend: setup wizard, experiment builder, real-time observabil - [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. -- [ ] 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. +- [x] 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 Steering Controls component (frontend/src/components/SteeringControls.tsx). Buttons for: Pause (yellow, shows confirmation), Resume (green), Stop (red, shows confirmation), Fork (opens modal to create new experiment from current best), Export Best (dropdown: JSON/YAML/.env). Also show: progress bar (X of Y runs), token counter (running total), estimated cost, cache hit rate percentage, and estimated time remaining. diff --git a/frontend/src/components/Timeline.test.tsx b/frontend/src/components/Timeline.test.tsx new file mode 100644 index 0000000..bfc83a5 --- /dev/null +++ b/frontend/src/components/Timeline.test.tsx @@ -0,0 +1,334 @@ +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, vi } from "vitest"; +import Timeline from "./Timeline"; +import type { TimelineEntry, TimelineProps } from "./Timeline"; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +function makeEntry(overrides: Partial = {}): TimelineEntry { + return { + id: "tl-1", + type: "run.started", + message: "Run started: gpt-4 t=0.7", + timestamp: new Date("2026-04-07T10:00:00Z"), + ...overrides, + }; +} + +const ENTRY_STARTED = makeEntry({ + id: "tl-1", + type: "run.started", + run_id: "r1", + message: "Run started: gpt-4 t=0.7", +}); + +const ENTRY_COMPLETED = makeEntry({ + id: "tl-2", + type: "run.completed", + run_id: "r1", + message: "Run completed: gpt-4 t=0.7", + detail: "Score: 0.875", +}); + +const ENTRY_BEST = makeEntry({ + id: "tl-3", + type: "new_best_found", + run_id: "r1", + message: "New best config found!", + detail: "Score: 0.950", +}); + +const ENTRY_CACHE = makeEntry({ + id: "tl-4", + type: "cache_hit", + run_id: "r2", + message: "Cache hit: gpt-3.5 t=1.0", +}); + +const ENTRY_FAILED = makeEntry({ + id: "tl-5", + type: "run.failed", + run_id: "r3", + message: "Run failed: timeout", +}); + +const ALL_ENTRIES = [ENTRY_STARTED, ENTRY_COMPLETED, ENTRY_BEST, ENTRY_CACHE, ENTRY_FAILED]; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function renderTimeline(overrides: Partial = {}) { + const props: TimelineProps = { + entries: ALL_ENTRIES, + eventFilter: "all", + onEventFilterChange: vi.fn(), + autoScroll: true, + onAutoScrollToggle: vi.fn(), + ...overrides, + }; + return { ...render(), props }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("Timeline", () => { + // --- Rendering --- + + it("renders the heading", () => { + renderTimeline(); + expect(screen.getByText("Activity Timeline")).toBeInTheDocument(); + }); + + it("renders all entries when filter is 'all'", () => { + renderTimeline(); + const entries = screen.getAllByTestId("timeline-entry"); + expect(entries).toHaveLength(5); + }); + + it("renders entry messages", () => { + renderTimeline(); + expect(screen.getByText("Run started: gpt-4 t=0.7")).toBeInTheDocument(); + expect(screen.getByText("Run completed: gpt-4 t=0.7")).toBeInTheDocument(); + expect(screen.getByText("New best config found!")).toBeInTheDocument(); + }); + + it("renders entry detail text", () => { + renderTimeline(); + expect(screen.getByText("Score: 0.875")).toBeInTheDocument(); + expect(screen.getByText("Score: 0.950")).toBeInTheDocument(); + }); + + it("renders event type labels", () => { + renderTimeline(); + expect(screen.getByText("Run Started")).toBeInTheDocument(); + expect(screen.getByText("Run Completed")).toBeInTheDocument(); + expect(screen.getByText("New Best!")).toBeInTheDocument(); + expect(screen.getByText("Cache Hit")).toBeInTheDocument(); + expect(screen.getByText("Run Failed")).toBeInTheDocument(); + }); + + it("sets data-event-type attribute on entries", () => { + renderTimeline(); + const entries = screen.getAllByTestId("timeline-entry"); + expect(entries[0]).toHaveAttribute("data-event-type", "run.started"); + expect(entries[4]).toHaveAttribute("data-event-type", "run.failed"); + }); + + // --- Color coding --- + + it("applies blue color classes for run.started", () => { + renderTimeline({ entries: [ENTRY_STARTED] }); + const entry = screen.getByTestId("timeline-entry"); + expect(entry.className).toContain("bg-blue-100"); + }); + + it("applies green color classes for run.completed", () => { + renderTimeline({ entries: [ENTRY_COMPLETED] }); + const entry = screen.getByTestId("timeline-entry"); + expect(entry.className).toContain("bg-green-100"); + }); + + it("applies amber color classes for new_best_found", () => { + renderTimeline({ entries: [ENTRY_BEST] }); + const entry = screen.getByTestId("timeline-entry"); + expect(entry.className).toContain("bg-amber-100"); + }); + + it("applies slate color classes for cache_hit", () => { + renderTimeline({ entries: [ENTRY_CACHE] }); + const entry = screen.getByTestId("timeline-entry"); + expect(entry.className).toContain("bg-slate-100"); + }); + + it("applies red color classes for run.failed", () => { + renderTimeline({ entries: [ENTRY_FAILED] }); + const entry = screen.getByTestId("timeline-entry"); + expect(entry.className).toContain("bg-red-100"); + }); + + // --- Timestamps --- + + it("renders formatted timestamps", () => { + renderTimeline({ entries: [ENTRY_STARTED] }); + // The exact format depends on locale, but it should contain time info + const entry = screen.getByTestId("timeline-entry"); + expect(entry.textContent).toContain(":"); + }); + + // --- Empty state --- + + it("shows 'Waiting for events…' when entries is empty", () => { + renderTimeline({ entries: [] }); + expect(screen.getByText("Waiting for events…")).toBeInTheDocument(); + expect(screen.queryAllByTestId("timeline-entry")).toHaveLength(0); + }); + + it("shows 'No matching events' when filter excludes all entries", () => { + renderTimeline({ entries: ALL_ENTRIES, eventFilter: "sweep.completed" }); + expect(screen.getByText("No matching events")).toBeInTheDocument(); + }); + + // --- Filtering --- + + it("filters entries by event type", () => { + renderTimeline({ eventFilter: "run.started" }); + const entries = screen.getAllByTestId("timeline-entry"); + expect(entries).toHaveLength(1); + expect(entries[0]).toHaveAttribute("data-event-type", "run.started"); + }); + + it("filters to show only completed runs", () => { + renderTimeline({ eventFilter: "run.completed" }); + const entries = screen.getAllByTestId("timeline-entry"); + expect(entries).toHaveLength(1); + expect(entries[0]).toHaveAttribute("data-event-type", "run.completed"); + }); + + it("filters to show only cache hits", () => { + renderTimeline({ eventFilter: "cache_hit" }); + const entries = screen.getAllByTestId("timeline-entry"); + expect(entries).toHaveLength(1); + expect(entries[0]).toHaveAttribute("data-event-type", "cache_hit"); + }); + + it("filters to show only failed runs", () => { + renderTimeline({ eventFilter: "run.failed" }); + const entries = screen.getAllByTestId("timeline-entry"); + expect(entries).toHaveLength(1); + expect(entries[0]).toHaveAttribute("data-event-type", "run.failed"); + }); + + it("filters to show only new best found", () => { + renderTimeline({ eventFilter: "new_best_found" }); + const entries = screen.getAllByTestId("timeline-entry"); + expect(entries).toHaveLength(1); + expect(entries[0]).toHaveAttribute("data-event-type", "new_best_found"); + }); + + // --- Filter dropdown --- + + it("renders filter dropdown with correct value", () => { + renderTimeline({ eventFilter: "run.failed" }); + const select = screen.getByTestId("event-filter") as HTMLSelectElement; + expect(select.value).toBe("run.failed"); + }); + + it("calls onEventFilterChange when filter is changed", async () => { + const user = userEvent.setup(); + const { props } = renderTimeline(); + const select = screen.getByTestId("event-filter"); + await user.selectOptions(select, "run.completed"); + expect(props.onEventFilterChange).toHaveBeenCalledWith("run.completed"); + }); + + it("renders all filter options", () => { + renderTimeline(); + const select = screen.getByTestId("event-filter"); + const options = within(select).getAllByRole("option"); + expect(options).toHaveLength(6); + expect(options.map((o) => o.textContent)).toEqual([ + "All events", + "Started", + "Completed", + "New best", + "Cache hits", + "Failed", + ]); + }); + + // --- Auto-scroll toggle --- + + it("shows 'Auto-scroll ON' when autoScroll is true", () => { + renderTimeline({ autoScroll: true }); + expect(screen.getByTestId("toggle-autoscroll")).toHaveTextContent( + "Auto-scroll ON", + ); + }); + + it("shows 'Auto-scroll OFF' when autoScroll is false", () => { + renderTimeline({ autoScroll: false }); + expect(screen.getByTestId("toggle-autoscroll")).toHaveTextContent( + "Auto-scroll OFF", + ); + }); + + it("calls onAutoScrollToggle when toggle button is clicked", async () => { + const user = userEvent.setup(); + const { props } = renderTimeline(); + await user.click(screen.getByTestId("toggle-autoscroll")); + expect(props.onAutoScrollToggle).toHaveBeenCalledTimes(1); + }); + + it("applies active styling when autoScroll is true", () => { + renderTimeline({ autoScroll: true }); + const btn = screen.getByTestId("toggle-autoscroll"); + expect(btn.className).toContain("bg-indigo-100"); + }); + + it("applies inactive styling when autoScroll is false", () => { + renderTimeline({ autoScroll: false }); + const btn = screen.getByTestId("toggle-autoscroll"); + expect(btn.className).toContain("bg-slate-100"); + }); + + // --- Timeline container --- + + it("renders the timeline container", () => { + renderTimeline(); + expect(screen.getByTestId("timeline-container")).toBeInTheDocument(); + }); + + it("timeline container has scrollable max height", () => { + renderTimeline(); + const container = screen.getByTestId("timeline-container"); + expect(container.className).toContain("max-h-[500px]"); + expect(container.className).toContain("overflow-y-auto"); + }); + + // --- Detail text --- + + it("does not render detail paragraph when detail is undefined", () => { + renderTimeline({ entries: [ENTRY_STARTED] }); + const entry = screen.getByTestId("timeline-entry"); + // ENTRY_STARTED has no detail + const paragraphs = entry.querySelectorAll("p"); + expect(paragraphs).toHaveLength(1); // only the message + }); + + it("renders detail paragraph when detail is provided", () => { + renderTimeline({ entries: [ENTRY_COMPLETED] }); + const entry = screen.getByTestId("timeline-entry"); + const paragraphs = entry.querySelectorAll("p"); + expect(paragraphs).toHaveLength(2); // message + detail + expect(paragraphs[1].textContent).toBe("Score: 0.875"); + }); + + // --- Unknown event types --- + + it("handles unknown event types gracefully", () => { + const unknownEntry = makeEntry({ + id: "tl-99", + type: "custom.event", + message: "Something custom happened", + }); + renderTimeline({ entries: [unknownEntry] }); + const entry = screen.getByTestId("timeline-entry"); + expect(entry).toHaveAttribute("data-event-type", "custom.event"); + // Falls back to showing the raw type name as label + expect(entry.textContent).toContain("custom.event"); + }); + + // --- Animation --- + + it("applies animate-in class to entries", () => { + renderTimeline({ entries: [ENTRY_STARTED] }); + const entry = screen.getByTestId("timeline-entry"); + expect(entry.className).toContain("animate-in"); + }); +}); diff --git a/frontend/src/components/Timeline.tsx b/frontend/src/components/Timeline.tsx new file mode 100644 index 0000000..2ffd94b --- /dev/null +++ b/frontend/src/components/Timeline.tsx @@ -0,0 +1,213 @@ +import { useRef, useEffect } from "react"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type TimelineEventType = + | "run.started" + | "run.completed" + | "run.failed" + | "new_best_found" + | "cache_hit" + | "sweep.progress" + | "sweep.completed"; + +export interface TimelineEntry { + id: string; + type: TimelineEventType | string; + run_id?: string; + message: string; + detail?: string; + timestamp: Date; +} + +export interface TimelineProps { + /** Timeline entries to display */ + entries: TimelineEntry[]; + /** Current event filter value */ + eventFilter: string; + /** Callback when filter changes */ + onEventFilterChange: (filter: string) => void; + /** Whether auto-scroll is enabled */ + autoScroll: boolean; + /** Toggle auto-scroll */ + onAutoScrollToggle: () => void; +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const EVENT_COLORS: Record = { + "run.started": + "bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300 border-blue-200 dark:border-blue-800", + "run.completed": + "bg-green-100 dark:bg-green-900/40 text-green-700 dark:text-green-300 border-green-200 dark:border-green-800", + "new_best_found": + "bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-300 border-amber-200 dark:border-amber-800", + "cache_hit": + "bg-slate-100 dark:bg-slate-700/40 text-slate-600 dark:text-slate-400 border-slate-200 dark:border-slate-600", + "run.failed": + "bg-red-100 dark:bg-red-900/40 text-red-700 dark:text-red-300 border-red-200 dark:border-red-800", + "sweep.progress": + "bg-indigo-100 dark:bg-indigo-900/40 text-indigo-700 dark:text-indigo-300 border-indigo-200 dark:border-indigo-800", + "sweep.completed": + "bg-emerald-100 dark:bg-emerald-900/40 text-emerald-700 dark:text-emerald-300 border-emerald-200 dark:border-emerald-800", +}; + +const EVENT_LABELS: Record = { + "run.started": "Run Started", + "run.completed": "Run Completed", + "new_best_found": "New Best!", + "cache_hit": "Cache Hit", + "run.failed": "Run Failed", + "sweep.progress": "Progress", + "sweep.completed": "Sweep Done", +}; + +const FILTER_OPTIONS: { value: string; label: string }[] = [ + { value: "all", label: "All events" }, + { value: "run.started", label: "Started" }, + { value: "run.completed", label: "Completed" }, + { value: "new_best_found", label: "New best" }, + { value: "cache_hit", label: "Cache hits" }, + { value: "run.failed", label: "Failed" }, +]; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function formatTime(date: Date): string { + return date.toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); +} + +// --------------------------------------------------------------------------- +// Timeline Event Card +// --------------------------------------------------------------------------- + +function TimelineCard({ entry }: { entry: TimelineEntry }) { + const colorClass = EVENT_COLORS[entry.type] ?? EVENT_COLORS["run.started"]; + return ( +
+
+ + {EVENT_LABELS[entry.type] ?? entry.type} + + {formatTime(entry.timestamp)} +
+

{entry.message}

+ {entry.detail && ( +

{entry.detail}

+ )} +
+ ); +} + +// --------------------------------------------------------------------------- +// Timeline Component +// --------------------------------------------------------------------------- + +export default function Timeline({ + entries, + eventFilter, + onEventFilterChange, + autoScroll, + onAutoScrollToggle, +}: TimelineProps) { + const timelineEndRef = useRef(null); + + const filteredEntries = + eventFilter === "all" + ? entries + : entries.filter((e) => e.type === eventFilter); + + // Auto-scroll to bottom when new entries arrive + useEffect(() => { + if (autoScroll && timelineEndRef.current) { + timelineEndRef.current.scrollIntoView?.({ behavior: "smooth" }); + } + }, [filteredEntries, autoScroll]); + + return ( +
+
+

+ Activity Timeline +

+
+ {/* Filter */} + + + {/* Pause scroll */} + +
+
+ +
+ {filteredEntries.length === 0 && ( +

+ {entries.length === 0 + ? "Waiting for events…" + : "No matching events"} +

+ )} + {filteredEntries.map((entry) => ( + + ))} +
+
+ + {/* Animation keyframes */} + +
+ ); +} diff --git a/frontend/src/pages/LivePage.tsx b/frontend/src/pages/LivePage.tsx index 9a8aba5..4923c73 100644 --- a/frontend/src/pages/LivePage.tsx +++ b/frontend/src/pages/LivePage.tsx @@ -13,6 +13,8 @@ import type { } from "../api/client"; import Leaderboard from "../components/Leaderboard"; import type { LeaderboardRow } from "../components/Leaderboard"; +import Timeline from "../components/Timeline"; +import type { TimelineEntry } from "../components/Timeline"; // --------------------------------------------------------------------------- // Types @@ -47,15 +49,6 @@ export interface WsEvent { timestamp?: string; } -export interface TimelineEntry { - id: string; - type: WsEventType; - run_id?: string; - message: string; - detail?: string; - timestamp: Date; -} - type ConnectionStatus = "connecting" | "connected" | "disconnected"; // --------------------------------------------------------------------------- @@ -76,60 +69,6 @@ function configSummary(config?: Record): string { return parts.length > 0 ? parts.join(" ") : JSON.stringify(config).slice(0, 60); } -function formatTime(date: Date): string { - return date.toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - }); -} - -const EVENT_COLORS: Record = { - "run.started": "bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300 border-blue-200 dark:border-blue-800", - "run.completed": "bg-green-100 dark:bg-green-900/40 text-green-700 dark:text-green-300 border-green-200 dark:border-green-800", - "new_best_found": "bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-300 border-amber-200 dark:border-amber-800", - "cache_hit": "bg-slate-100 dark:bg-slate-700/40 text-slate-600 dark:text-slate-400 border-slate-200 dark:border-slate-600", - "run.failed": "bg-red-100 dark:bg-red-900/40 text-red-700 dark:text-red-300 border-red-200 dark:border-red-800", - "sweep.progress": "bg-indigo-100 dark:bg-indigo-900/40 text-indigo-700 dark:text-indigo-300 border-indigo-200 dark:border-indigo-800", - "sweep.completed": "bg-emerald-100 dark:bg-emerald-900/40 text-emerald-700 dark:text-emerald-300 border-emerald-200 dark:border-emerald-800", -}; - -const EVENT_LABELS: Record = { - "run.started": "Run Started", - "run.completed": "Run Completed", - "new_best_found": "New Best!", - "cache_hit": "Cache Hit", - "run.failed": "Run Failed", - "sweep.progress": "Progress", - "sweep.completed": "Sweep Done", -}; - -// --------------------------------------------------------------------------- -// Timeline Event Card -// --------------------------------------------------------------------------- - -function TimelineCard({ entry }: { entry: TimelineEntry }) { - const colorClass = EVENT_COLORS[entry.type] ?? EVENT_COLORS["run.started"]; - return ( -
-
- - {EVENT_LABELS[entry.type] ?? entry.type} - - {formatTime(entry.timestamp)} -
-

{entry.message}

- {entry.detail && ( -

{entry.detail}

- )} -
- ); -} - // --------------------------------------------------------------------------- // Steering Controls @@ -362,8 +301,6 @@ export default function LivePage() { const [timeline, setTimeline] = useState([]); const [autoScroll, setAutoScroll] = useState(true); const [eventFilter, setEventFilter] = useState("all"); - const timelineEndRef = useRef(null); - // Leaderboard const [leaderboard, setLeaderboard] = useState([]); const [bestRunId, setBestRunId] = useState(null); @@ -603,25 +540,6 @@ export default function LivePage() { }; }, [id, loading, error, connectWs]); - // ------------------------------------------------------------------------- - // Auto-scroll timeline - // ------------------------------------------------------------------------- - - useEffect(() => { - if (autoScroll && timelineEndRef.current) { - timelineEndRef.current.scrollIntoView?.({ behavior: "smooth" }); - } - }, [timeline, autoScroll]); - - // ------------------------------------------------------------------------- - // Filter timeline - // ------------------------------------------------------------------------- - - const filteredTimeline = - eventFilter === "all" - ? timeline - : timeline.filter((e) => e.type === eventFilter); - // ------------------------------------------------------------------------- // Render // ------------------------------------------------------------------------- @@ -688,63 +606,13 @@ export default function LivePage() {
{/* Left column — 60% */}
- {/* Activity Timeline */} -
-
-

- Activity Timeline -

-
- {/* Filter */} - - - {/* Pause scroll */} - -
-
- -
- {filteredTimeline.length === 0 && ( -

- {timeline.length === 0 - ? "Waiting for events…" - : "No matching events"} -

- )} - {filteredTimeline.map((entry) => ( - - ))} -
-
-
+ setEventFilter(f as WsEventType | "all")} + autoScroll={autoScroll} + onAutoScrollToggle={() => setAutoScroll(!autoScroll)} + />
{/* Right column — 40% */}