From 3c78f874fb7d54fa1bf713da114385f2c9e7a800 Mon Sep 17 00:00:00 2001 From: John Lightner Date: Tue, 7 Apr 2026 03:37:31 -0500 Subject: [PATCH] MAESTRO: Implement Dashboard page with stats, active sweeps, recent projects, and quick actions --- Auto Run Docs/02b-frontend-dashboard.md | 3 +- frontend/src/pages/DashboardPage.test.tsx | 450 ++++++++++++++++++++++ frontend/src/pages/DashboardPage.tsx | 415 +++++++++++++++++++- 3 files changed, 863 insertions(+), 5 deletions(-) create mode 100644 frontend/src/pages/DashboardPage.test.tsx diff --git a/Auto Run Docs/02b-frontend-dashboard.md b/Auto Run Docs/02b-frontend-dashboard.md index 73bad72..0e209ad 100644 --- a/Auto Run Docs/02b-frontend-dashboard.md +++ b/Auto Run Docs/02b-frontend-dashboard.md @@ -47,7 +47,8 @@ Build the React frontend: setup wizard, experiment builder, real-time observabil - [x] Implement the Admin page (frontend/src/pages/Admin.tsx). Settings management: toggle guest access, manage API keys (generate/revoke), configure default endpoint, set token budgets, view system stats (total runs, cache entries, storage usage). Include a section for webhook management (list/create/delete). -- [ ] Implement the Dashboard page (frontend/src/pages/Dashboard.tsx). Landing page after login. Show: recent projects with activity, any actively running sweeps (with mini progress bars), global stats (total experiments, total runs, cache hit rate, tokens spent), and quick-action buttons (New Project, New Experiment). +- [x] Implement the Dashboard page (frontend/src/pages/Dashboard.tsx). Landing page after login. Show: recent projects with activity, any actively running sweeps (with mini progress bars), global stats (total experiments, total runs, cache hit rate, tokens spent), and quick-action buttons (New Project, New Experiment). + - [ ] Build the WebSocket hook (frontend/src/hooks/useExperimentWS.ts). Custom React hook that manages WebSocket connection to /ws/experiments/{id}. Handles connect/disconnect/reconnect, parses incoming events, exposes typed event stream, and provides connection status. Reconnect with exponential backoff on disconnect. diff --git a/frontend/src/pages/DashboardPage.test.tsx b/frontend/src/pages/DashboardPage.test.tsx new file mode 100644 index 0000000..10ae225 --- /dev/null +++ b/frontend/src/pages/DashboardPage.test.tsx @@ -0,0 +1,450 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter } from "react-router-dom"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import DashboardPage from "./DashboardPage"; +import * as client from "../api/client"; + +// --------------------------------------------------------------------------- +// Mock data +// --------------------------------------------------------------------------- + +const MOCK_PROJECTS: client.ProjectResponse[] = [ + { + id: "p1", + name: "Summarizer Tuning", + description: "Tuning summarization prompts", + owner_id: "u1", + created_at: "2026-04-01T10:00:00Z", + updated_at: "2026-04-07T08:00:00Z", + }, + { + id: "p2", + name: "Classifier v2", + description: null, + owner_id: "u1", + created_at: "2026-03-15T10:00:00Z", + updated_at: "2026-04-06T14:00:00Z", + }, + { + id: "p3", + name: "Old Project", + description: "An old project", + owner_id: "u1", + created_at: "2026-01-01T10:00:00Z", + updated_at: "2026-01-10T10:00:00Z", + }, +]; + +const MOCK_EXPERIMENTS: client.ExperimentResponse[] = [ + { + id: "e1", + project_id: "p1", + name: "Sweep Alpha", + description: null, + sample_data: null, + pipeline_stages: { + progress: { completed: 15, total: 50 }, + }, + scoring_config: null, + parameter_space: null, + status: "running", + created_at: "2026-04-07T06:00:00Z", + updated_at: "2026-04-07T08:00:00Z", + }, + { + id: "e2", + project_id: "p1", + name: "Completed Exp", + description: null, + sample_data: null, + pipeline_stages: null, + scoring_config: null, + parameter_space: null, + status: "completed", + created_at: "2026-04-05T06:00:00Z", + updated_at: "2026-04-05T10:00:00Z", + }, + { + id: "e3", + project_id: "p2", + name: "Sweep Beta", + description: null, + sample_data: null, + pipeline_stages: null, + scoring_config: null, + parameter_space: null, + status: "sweeping", + created_at: "2026-04-07T07:00:00Z", + updated_at: "2026-04-07T07:30:00Z", + }, +]; + +const MOCK_STATS = { + total_experiments: 42, + total_runs: 1500, + cache_hit_rate: 0.65, + tokens_spent: 250000, + total_projects: 5, + cache_entries: 800, + storage_bytes: 10485760, +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const mockedNavigate = vi.fn(); + +vi.mock("react-router-dom", async () => { + const actual = await vi.importActual("react-router-dom"); + return { + ...actual, + useNavigate: () => mockedNavigate, + }; +}); + +function mockAllApis() { + vi.spyOn(client.projects, "list").mockResolvedValue({ + items: MOCK_PROJECTS, + total: MOCK_PROJECTS.length, + }); + vi.spyOn(client.experiments, "list").mockResolvedValue({ + items: MOCK_EXPERIMENTS, + total: MOCK_EXPERIMENTS.length, + }); + vi.spyOn(client.admin, "getStats").mockResolvedValue( + MOCK_STATS as unknown as Record, + ); +} + +function renderDashboard() { + return render( + + + , + ); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("DashboardPage", () => { + beforeEach(() => { + vi.restoreAllMocks(); + mockedNavigate.mockClear(); + }); + + it("shows loading state initially", () => { + vi.spyOn(client.projects, "list").mockReturnValue(new Promise(() => {})); + vi.spyOn(client.experiments, "list").mockReturnValue( + new Promise(() => {}), + ); + vi.spyOn(client.admin, "getStats").mockReturnValue(new Promise(() => {})); + + renderDashboard(); + expect(screen.getByText("Loading dashboard...")).toBeInTheDocument(); + }); + + it("shows error state on API failure", async () => { + vi.spyOn(client.projects, "list").mockRejectedValue( + new client.ApiError(500, "Internal Server Error", {}), + ); + vi.spyOn(client.experiments, "list").mockRejectedValue( + new client.ApiError(500, "Internal Server Error", {}), + ); + vi.spyOn(client.admin, "getStats").mockRejectedValue( + new client.ApiError(500, "Internal Server Error", {}), + ); + + renderDashboard(); + + await waitFor(() => { + expect(screen.getByRole("alert")).toBeInTheDocument(); + }); + expect( + screen.getByText(/Failed to load dashboard data/), + ).toBeInTheDocument(); + }); + + it("shows network error on non-API errors", async () => { + vi.spyOn(client.projects, "list").mockRejectedValue( + new Error("fetch failed"), + ); + vi.spyOn(client.experiments, "list").mockResolvedValue({ + items: [], + total: 0, + }); + vi.spyOn(client.admin, "getStats").mockResolvedValue({}); + + renderDashboard(); + + await waitFor(() => { + expect( + screen.getByText("Network error. Is the server running?"), + ).toBeInTheDocument(); + }); + }); + + it("renders global stats after loading", async () => { + mockAllApis(); + renderDashboard(); + + await waitFor(() => { + expect( + screen.getByTestId("stat-total-experiments"), + ).toBeInTheDocument(); + }); + + expect(screen.getByTestId("stat-total-experiments")).toHaveTextContent( + "42", + ); + expect(screen.getByTestId("stat-total-runs")).toHaveTextContent("1,500"); + expect(screen.getByTestId("stat-cache-hit-rate")).toHaveTextContent( + "65.0%", + ); + expect(screen.getByTestId("stat-tokens-spent")).toHaveTextContent( + "250,000", + ); + }); + + it("renders recent projects sorted by updated_at", async () => { + mockAllApis(); + renderDashboard(); + + await waitFor(() => { + expect(screen.getByText("Summarizer Tuning")).toBeInTheDocument(); + }); + + expect(screen.getByText("Classifier v2")).toBeInTheDocument(); + expect(screen.getByText("Old Project")).toBeInTheDocument(); + }); + + it("shows active sweeps with running/sweeping status", async () => { + mockAllApis(); + renderDashboard(); + + await waitFor(() => { + expect(screen.getByText("Active Sweeps")).toBeInTheDocument(); + }); + + // Should show running and sweeping experiments + expect(screen.getByText("Sweep Alpha")).toBeInTheDocument(); + expect(screen.getByText("Sweep Beta")).toBeInTheDocument(); + // Should NOT show completed experiment + expect(screen.queryByText("Completed Exp")).not.toBeInTheDocument(); + + // Check progress info for Sweep Alpha (15/50) + expect(screen.getByText("15 / 50 runs (30%)")).toBeInTheDocument(); + }); + + it("hides Active Sweeps section when no sweeps are running", async () => { + vi.spyOn(client.projects, "list").mockResolvedValue({ + items: MOCK_PROJECTS, + total: MOCK_PROJECTS.length, + }); + vi.spyOn(client.experiments, "list").mockResolvedValue({ + items: [MOCK_EXPERIMENTS[1]], // only completed + total: 1, + }); + vi.spyOn(client.admin, "getStats").mockResolvedValue( + MOCK_STATS as unknown as Record, + ); + + renderDashboard(); + + await waitFor(() => { + expect(screen.getByText("Overview")).toBeInTheDocument(); + }); + + expect(screen.queryByText("Active Sweeps")).not.toBeInTheDocument(); + }); + + it("shows empty state when no projects exist", async () => { + vi.spyOn(client.projects, "list").mockResolvedValue({ + items: [], + total: 0, + }); + vi.spyOn(client.experiments, "list").mockResolvedValue({ + items: [], + total: 0, + }); + vi.spyOn(client.admin, "getStats").mockResolvedValue( + {} as Record, + ); + + renderDashboard(); + + await waitFor(() => { + expect(screen.getByText("No projects yet")).toBeInTheDocument(); + }); + + expect( + screen.getByText("Create your first project to get started."), + ).toBeInTheDocument(); + expect( + screen.getByTestId("empty-create-project-btn"), + ).toBeInTheDocument(); + }); + + it("navigates to /projects when New Project is clicked", async () => { + mockAllApis(); + const user = userEvent.setup(); + renderDashboard(); + + await waitFor(() => { + expect(screen.getByTestId("new-project-btn")).toBeInTheDocument(); + }); + + await user.click(screen.getByTestId("new-project-btn")); + expect(mockedNavigate).toHaveBeenCalledWith("/projects"); + }); + + it("navigates to /experiments/new when New Experiment is clicked", async () => { + mockAllApis(); + const user = userEvent.setup(); + renderDashboard(); + + await waitFor(() => { + expect(screen.getByTestId("new-experiment-btn")).toBeInTheDocument(); + }); + + await user.click(screen.getByTestId("new-experiment-btn")); + expect(mockedNavigate).toHaveBeenCalledWith("/experiments/new"); + }); + + it("navigates to project experiments on project card click", async () => { + mockAllApis(); + const user = userEvent.setup(); + renderDashboard(); + + await waitFor(() => { + expect(screen.getByTestId("project-card-p1")).toBeInTheDocument(); + }); + + await user.click(screen.getByTestId("project-card-p1")); + expect(mockedNavigate).toHaveBeenCalledWith("/experiments/p1"); + }); + + it("navigates to live page on active sweep card click", async () => { + mockAllApis(); + const user = userEvent.setup(); + renderDashboard(); + + await waitFor(() => { + expect(screen.getByTestId("sweep-card-e1")).toBeInTheDocument(); + }); + + await user.click(screen.getByTestId("sweep-card-e1")); + expect(mockedNavigate).toHaveBeenCalledWith("/live/e1"); + }); + + it("retry button reloads data after error", async () => { + const listSpy = vi + .spyOn(client.projects, "list") + .mockRejectedValueOnce(new client.ApiError(500, "Error", {})) + .mockResolvedValueOnce({ + items: MOCK_PROJECTS, + total: MOCK_PROJECTS.length, + }); + vi.spyOn(client.experiments, "list") + .mockRejectedValueOnce(new client.ApiError(500, "Error", {})) + .mockResolvedValueOnce({ + items: MOCK_EXPERIMENTS, + total: MOCK_EXPERIMENTS.length, + }); + vi.spyOn(client.admin, "getStats") + .mockRejectedValueOnce(new client.ApiError(500, "Error", {})) + .mockResolvedValueOnce( + MOCK_STATS as unknown as Record, + ); + + const user = userEvent.setup(); + renderDashboard(); + + await waitFor(() => { + expect(screen.getByRole("alert")).toBeInTheDocument(); + }); + + await user.click(screen.getByText("Retry")); + + await waitFor(() => { + expect(screen.getByText("Overview")).toBeInTheDocument(); + }); + + expect(listSpy).toHaveBeenCalledTimes(2); + }); + + it("shows View all link for projects", async () => { + mockAllApis(); + const user = userEvent.setup(); + renderDashboard(); + + await waitFor(() => { + expect(screen.getByText("View all")).toBeInTheDocument(); + }); + + await user.click(screen.getByText("View all")); + expect(mockedNavigate).toHaveBeenCalledWith("/projects"); + }); + + it("renders project descriptions when available", async () => { + mockAllApis(); + renderDashboard(); + + await waitFor(() => { + expect( + screen.getByText("Tuning summarization prompts"), + ).toBeInTheDocument(); + }); + }); + + it("handles stats with missing fields gracefully", async () => { + vi.spyOn(client.projects, "list").mockResolvedValue({ + items: [], + total: 0, + }); + vi.spyOn(client.experiments, "list").mockResolvedValue({ + items: [], + total: 0, + }); + vi.spyOn(client.admin, "getStats").mockResolvedValue( + {} as Record, + ); + + renderDashboard(); + + await waitFor(() => { + expect(screen.getByTestId("stat-total-runs")).toHaveTextContent("0"); + }); + expect(screen.getByTestId("stat-total-experiments")).toHaveTextContent( + "0", + ); + expect(screen.getByTestId("stat-cache-hit-rate")).toHaveTextContent( + "0.0%", + ); + expect(screen.getByTestId("stat-tokens-spent")).toHaveTextContent("0"); + }); + + it("shows sweep with no progress as Starting...", async () => { + vi.spyOn(client.projects, "list").mockResolvedValue({ + items: [], + total: 0, + }); + vi.spyOn(client.experiments, "list").mockResolvedValue({ + items: [MOCK_EXPERIMENTS[2]], // Sweep Beta has no progress info + total: 1, + }); + vi.spyOn(client.admin, "getStats").mockResolvedValue( + MOCK_STATS as unknown as Record, + ); + + renderDashboard(); + + await waitFor(() => { + expect(screen.getByText("Sweep Beta")).toBeInTheDocument(); + }); + expect(screen.getByText("Starting...")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index 53ef767..0457f0f 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -1,8 +1,415 @@ -export default function DashboardPage() { +import { useEffect, useState, useCallback } from "react"; +import { useNavigate } from "react-router-dom"; +import { + projects, + experiments, + admin, + ApiError, +} from "../api/client"; +import type { + ProjectResponse, + ExperimentResponse, +} from "../api/client"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface DashboardStats { + total_experiments: number; + total_runs: number; + cache_hit_rate: number; + tokens_spent: number; +} + +function defaultStats(): DashboardStats { + return { + total_experiments: 0, + total_runs: 0, + cache_hit_rate: 0, + tokens_spent: 0, + }; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function formatNumber(n: number): string { + return n.toLocaleString(); +} + +function formatTimeAgo(date: Date): string { + const now = Date.now(); + const diffMs = now - date.getTime(); + const diffSec = Math.floor(diffMs / 1000); + + if (diffSec < 60) return "just now"; + const diffMin = Math.floor(diffSec / 60); + if (diffMin < 60) return `${diffMin}m ago`; + const diffHr = Math.floor(diffMin / 60); + if (diffHr < 24) return `${diffHr}h ago`; + const diffDay = Math.floor(diffHr / 24); + if (diffDay < 30) return `${diffDay}d ago`; + return date.toLocaleDateString(); +} + +// --------------------------------------------------------------------------- +// Stat Card +// --------------------------------------------------------------------------- + +function StatCard({ + label, + value, + testId, +}: { + label: string; + value: string; + testId: string; +}) { return ( -
-

Dashboard

-

Overview of recent experiments and runs.

+
+

{label}

+

+ {value} +

+
+ ); +} + +// --------------------------------------------------------------------------- +// Active Sweep Card +// --------------------------------------------------------------------------- + +function ActiveSweepCard({ + experiment, + onClick, +}: { + experiment: ExperimentResponse; + onClick: () => void; +}) { + // Extract progress from experiment metadata if available + const pipeline = experiment.pipeline_stages as Record | null; + const progress = pipeline?.progress as + | { completed: number; total: number } + | undefined; + const completed = progress?.completed ?? 0; + const total = progress?.total ?? 0; + const pct = total > 0 ? Math.round((completed / total) * 100) : 0; + + return ( + + ); +} + +// --------------------------------------------------------------------------- +// Recent Project Card +// --------------------------------------------------------------------------- + +function RecentProjectCard({ + project, + onClick, +}: { + project: ProjectResponse; + onClick: () => void; +}) { + const updatedDate = new Date(project.updated_at); + const timeAgo = formatTimeAgo(updatedDate); + + return ( + + ); +} + +// --------------------------------------------------------------------------- +// Dashboard Page +// --------------------------------------------------------------------------- + +export default function DashboardPage() { + const navigate = useNavigate(); + const [projectList, setProjectList] = useState([]); + const [activeSweeps, setActiveSweeps] = useState([]); + const [stats, setStats] = useState(defaultStats()); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const loadData = useCallback(async () => { + setLoading(true); + setError(null); + try { + const [projectsResp, experimentsResp, statsResp] = await Promise.all([ + projects.list(), + experiments.list(), + admin.getStats(), + ]); + + // Sort projects by updated_at descending, take top 6 + const sorted = [...projectsResp.items].sort( + (a, b) => + new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime(), + ); + setProjectList(sorted.slice(0, 6)); + + // Filter experiments with running/sweeping status + const running = experimentsResp.items.filter( + (exp) => + exp.status === "running" || + exp.status === "sweeping", + ); + setActiveSweeps(running); + + // Map stats + const raw = statsResp as Record; + setStats({ + total_experiments: + typeof raw.total_experiments === "number" + ? raw.total_experiments + : 0, + total_runs: + typeof raw.total_runs === "number" ? raw.total_runs : 0, + cache_hit_rate: + typeof raw.cache_hit_rate === "number" ? raw.cache_hit_rate : 0, + tokens_spent: + typeof raw.tokens_spent === "number" ? raw.tokens_spent : 0, + }); + } catch (err: unknown) { + if (err instanceof ApiError) { + setError(`Failed to load dashboard data (${err.status}).`); + } else { + setError("Network error. Is the server running?"); + } + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + loadData(); + }, [loadData]); + + return ( +
+
+ {/* Header */} +
+
+

+ Dashboard +

+

+ Welcome back. Here's what's happening. +

+
+
+ + +
+
+ + {/* Loading */} + {loading && ( +

+ Loading dashboard... +

+ )} + + {/* Error */} + {!loading && error && ( +
+

{error}

+ +
+ )} + + {/* Content */} + {!loading && !error && ( +
+ {/* Global Stats */} +
+

+ Overview +

+
+ + + + +
+
+ + {/* Active Sweeps */} + {activeSweeps.length > 0 && ( +
+

+ Active Sweeps +

+
+ {activeSweeps.map((exp) => ( + navigate(`/live/${exp.id}`)} + /> + ))} +
+
+ )} + + {/* Recent Projects */} +
+
+

+ Recent Projects +

+ {projectList.length > 0 && ( + + )} +
+ + {projectList.length === 0 ? ( +
+
+ + + +
+

+ No projects yet +

+

+ Create your first project to get started. +

+ +
+ ) : ( +
+ {projectList.map((project) => ( + + navigate(`/experiments/${project.id}`) + } + /> + ))} +
+ )} +
+
+ )} +
); }