MAESTRO: Implement Dashboard page with stats, active sweeps, recent projects, and quick actions
This commit is contained in:
parent
74ccc1a8ed
commit
3c78f874fb
3 changed files with 863 additions and 5 deletions
|
|
@ -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).
|
- [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. -->
|
<!-- 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.
|
- [ ] 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.
|
||||||
|
|
||||||
|
|
|
||||||
450
frontend/src/pages/DashboardPage.test.tsx
Normal file
450
frontend/src/pages/DashboardPage.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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 (
|
return (
|
||||||
<div className="p-8">
|
<div className="rounded-xl bg-white dark:bg-slate-800 p-5 shadow-md ring-1 ring-slate-200 dark:ring-slate-700">
|
||||||
<h1 className="mb-4 text-2xl font-bold">Dashboard</h1>
|
<p className="text-sm text-slate-500 dark:text-slate-400">{label}</p>
|
||||||
<p className="text-gray-600">Overview of recent experiments and runs.</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's what'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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue