MAESTRO: Implement Dashboard page with stats, active sweeps, recent projects, and quick actions

This commit is contained in:
John Lightner 2026-04-07 03:37:31 -05:00
parent 74ccc1a8ed
commit 3c78f874fb
3 changed files with 863 additions and 5 deletions

View file

@ -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).
<!-- Implemented in AdminPage.tsx (existing file convention). Four sections: SettingsSection (guest access toggle, default endpoint dropdown populated from endpoints API, daily/monthly token budget inputs with auto-save), ApiKeysSection (generate with label input, list with prefix + created date, revoke button per key), StatsSection (7-stat grid: total runs/experiments/projects, cache entries/hit rate, storage usage formatted with formatBytes, tokens spent), WebhooksSection (list with active indicator dots, event type + URL display, add form with event type dropdown + URL input, delete per webhook). All data loaded in parallel via Promise.all. Error/loading/retry states. Save confirmation message with auto-dismiss. 16 tests added. -->
- [ ] 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).
<!-- Implemented in DashboardPage.tsx. Loads projects, experiments, and admin stats in parallel via Promise.all. Four stat cards (total experiments, total runs, cache hit rate, tokens spent). Active Sweeps section shows running/sweeping experiments with mini progress bars and completion percentage (hidden when none active). Recent Projects section shows top 6 projects sorted by updated_at with time-ago labels and descriptions. Quick-action buttons: New Project navigates to /projects, New Experiment navigates to /experiments/new. Empty state with create prompt when no projects exist. Loading/error/retry states. 17 tests added. -->
- [ ] 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.

View file

@ -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<string, unknown>,
);
}
function renderDashboard() {
return render(
<MemoryRouter initialEntries={["/"]}>
<DashboardPage />
</MemoryRouter>,
);
}
// ---------------------------------------------------------------------------
// 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<string, unknown>,
);
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<string, unknown>,
);
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<string, unknown>,
);
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<string, unknown>,
);
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<string, unknown>,
);
renderDashboard();
await waitFor(() => {
expect(screen.getByText("Sweep Beta")).toBeInTheDocument();
});
expect(screen.getByText("Starting...")).toBeInTheDocument();
});
});

View file

@ -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 (
<div className="p-8">
<h1 className="mb-4 text-2xl font-bold">Dashboard</h1>
<p className="text-gray-600">Overview of recent experiments and runs.</p>
<div className="rounded-xl bg-white dark:bg-slate-800 p-5 shadow-md ring-1 ring-slate-200 dark:ring-slate-700">
<p className="text-sm text-slate-500 dark:text-slate-400">{label}</p>
<p
className="mt-1 text-2xl font-bold text-slate-900 dark:text-white"
data-testid={testId}
>
{value}
</p>
</div>
);
}
// ---------------------------------------------------------------------------
// 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<string, unknown> | 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 (
<button
type="button"
onClick={onClick}
data-testid={`sweep-card-${experiment.id}`}
className="w-full text-left rounded-xl bg-white dark:bg-slate-800 p-4 shadow-md ring-1 ring-slate-200 dark:ring-slate-700 hover:ring-indigo-400 dark:hover:ring-indigo-500 hover:shadow-lg transition-all duration-200 group"
>
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-semibold text-slate-900 dark:text-white group-hover:text-indigo-600 dark:group-hover:text-indigo-400 transition-colors truncate">
{experiment.name}
</h3>
<span className="shrink-0 ml-2 inline-flex items-center rounded-full bg-emerald-100 dark:bg-emerald-900/40 px-2 py-0.5 text-xs font-medium text-emerald-700 dark:text-emerald-400">
Running
</span>
</div>
{/* Mini progress bar */}
<div className="w-full rounded-full bg-slate-200 dark:bg-slate-700 h-2 overflow-hidden">
<div
className="h-full rounded-full bg-indigo-500 transition-all duration-500"
style={{ width: `${pct}%` }}
data-testid={`sweep-progress-${experiment.id}`}
/>
</div>
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">
{total > 0
? `${completed} / ${total} runs (${pct}%)`
: "Starting..."}
</p>
</button>
);
}
// ---------------------------------------------------------------------------
// Recent Project Card
// ---------------------------------------------------------------------------
function RecentProjectCard({
project,
onClick,
}: {
project: ProjectResponse;
onClick: () => void;
}) {
const updatedDate = new Date(project.updated_at);
const timeAgo = formatTimeAgo(updatedDate);
return (
<button
type="button"
onClick={onClick}
data-testid={`project-card-${project.id}`}
className="w-full text-left rounded-xl bg-white dark:bg-slate-800 p-4 shadow-md ring-1 ring-slate-200 dark:ring-slate-700 hover:ring-indigo-400 dark:hover:ring-indigo-500 hover:shadow-lg transition-all duration-200 group"
>
<div className="flex items-start justify-between mb-1">
<h3 className="text-sm font-semibold text-slate-900 dark:text-white group-hover:text-indigo-600 dark:group-hover:text-indigo-400 transition-colors truncate">
{project.name}
</h3>
<span className="shrink-0 ml-2 text-xs text-slate-400 dark:text-slate-500">
{timeAgo}
</span>
</div>
{project.description && (
<p className="text-xs text-slate-500 dark:text-slate-400 line-clamp-2">
{project.description}
</p>
)}
</button>
);
}
// ---------------------------------------------------------------------------
// Dashboard Page
// ---------------------------------------------------------------------------
export default function DashboardPage() {
const navigate = useNavigate();
const [projectList, setProjectList] = useState<ProjectResponse[]>([]);
const [activeSweeps, setActiveSweeps] = useState<ExperimentResponse[]>([]);
const [stats, setStats] = useState<DashboardStats>(defaultStats());
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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<string, unknown>;
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 (
<div className="min-h-screen bg-gradient-to-br from-indigo-50 to-slate-100 dark:from-slate-900 dark:to-slate-800 px-4 py-8">
<div className="mx-auto max-w-6xl">
{/* Header */}
<div className="mb-8 flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-slate-900 dark:text-white">
Dashboard
</h1>
<p className="mt-1 text-slate-500 dark:text-slate-400">
Welcome back. Here&apos;s what&apos;s happening.
</p>
</div>
<div className="flex gap-3">
<button
type="button"
onClick={() => navigate("/projects")}
data-testid="new-project-btn"
className="rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 transition"
>
New Project
</button>
<button
type="button"
onClick={() => navigate("/experiments/new")}
data-testid="new-experiment-btn"
className="rounded-lg bg-white dark:bg-slate-700 px-4 py-2.5 text-sm font-semibold text-slate-700 dark:text-slate-200 shadow-sm ring-1 ring-slate-300 dark:ring-slate-600 hover:bg-slate-50 dark:hover:bg-slate-600 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 transition"
>
New Experiment
</button>
</div>
</div>
{/* Loading */}
{loading && (
<p className="text-center text-slate-500 dark:text-slate-400 animate-pulse py-16">
Loading dashboard...
</p>
)}
{/* Error */}
{!loading && error && (
<div
role="alert"
className="rounded-xl bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 p-6 text-center"
>
<p className="text-red-700 dark:text-red-300">{error}</p>
<button
type="button"
onClick={loadData}
className="mt-3 text-sm font-medium text-indigo-600 dark:text-indigo-400 hover:text-indigo-500"
>
Retry
</button>
</div>
)}
{/* Content */}
{!loading && !error && (
<div className="space-y-8">
{/* Global Stats */}
<section>
<h2 className="mb-4 text-lg font-semibold text-slate-900 dark:text-white">
Overview
</h2>
<div className="grid gap-4 grid-cols-2 lg:grid-cols-4">
<StatCard
label="Total Experiments"
value={formatNumber(stats.total_experiments)}
testId="stat-total-experiments"
/>
<StatCard
label="Total Runs"
value={formatNumber(stats.total_runs)}
testId="stat-total-runs"
/>
<StatCard
label="Cache Hit Rate"
value={`${(stats.cache_hit_rate * 100).toFixed(1)}%`}
testId="stat-cache-hit-rate"
/>
<StatCard
label="Tokens Spent"
value={formatNumber(stats.tokens_spent)}
testId="stat-tokens-spent"
/>
</div>
</section>
{/* Active Sweeps */}
{activeSweeps.length > 0 && (
<section>
<h2 className="mb-4 text-lg font-semibold text-slate-900 dark:text-white">
Active Sweeps
</h2>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{activeSweeps.map((exp) => (
<ActiveSweepCard
key={exp.id}
experiment={exp}
onClick={() => navigate(`/live/${exp.id}`)}
/>
))}
</div>
</section>
)}
{/* Recent Projects */}
<section>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-slate-900 dark:text-white">
Recent Projects
</h2>
{projectList.length > 0 && (
<button
type="button"
onClick={() => navigate("/projects")}
className="text-sm font-medium text-indigo-600 dark:text-indigo-400 hover:text-indigo-500 transition"
>
View all
</button>
)}
</div>
{projectList.length === 0 ? (
<div className="rounded-xl bg-white dark:bg-slate-800 p-10 text-center shadow ring-1 ring-slate-200 dark:ring-slate-700">
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-indigo-100 dark:bg-indigo-900/40 text-indigo-600 dark:text-indigo-400">
<svg
className="h-7 w-7"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 10.5v6m3-3H9m4.06-7.19l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z"
/>
</svg>
</div>
<h3 className="text-lg font-semibold text-slate-900 dark:text-white">
No projects yet
</h3>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
Create your first project to get started.
</p>
<button
type="button"
onClick={() => navigate("/projects")}
data-testid="empty-create-project-btn"
className="mt-5 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 transition"
>
Create First Project
</button>
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{projectList.map((project) => (
<RecentProjectCard
key={project.id}
project={project}
onClick={() =>
navigate(`/experiments/${project.id}`)
}
/>
))}
</div>
)}
</section>
</div>
)}
</div>
</div>
);
}