From 105010977749d6561345a1f60fde1083e44c5fc0 Mon Sep 17 00:00:00 2001 From: John Lightner Date: Tue, 7 Apr 2026 02:34:00 -0500 Subject: [PATCH] MAESTRO: Implement Setup page with first-boot admin creation flow - Full setup form with username, password, confirm password - Auth detection on mount (redirects if already authenticated) - Client-side validation (empty username, short password, mismatch) - Server error handling (409 conflict, network errors) - Welcoming UI with gradient background, dark mode support - 9 new tests covering all states and error paths - Updated App.test.tsx to handle async SetupPage rendering - Added @testing-library/user-event dependency --- Auto Run Docs/02b-frontend-dashboard.md | 42 ++++ frontend/package-lock.json | 15 ++ frontend/package.json | 1 + frontend/src/App.test.tsx | 15 +- frontend/src/pages/SetupPage.test.tsx | 246 ++++++++++++++++++++++++ frontend/src/pages/SetupPage.tsx | 201 ++++++++++++++++++- 6 files changed, 512 insertions(+), 8 deletions(-) create mode 100644 Auto Run Docs/02b-frontend-dashboard.md create mode 100644 frontend/src/pages/SetupPage.test.tsx diff --git a/Auto Run Docs/02b-frontend-dashboard.md b/Auto Run Docs/02b-frontend-dashboard.md new file mode 100644 index 0000000..9c67a88 --- /dev/null +++ b/Auto Run Docs/02b-frontend-dashboard.md @@ -0,0 +1,42 @@ +# Phase 2b — Frontend Dashboard + +Build the React frontend: setup wizard, experiment builder, real-time observability dashboard, and steering controls. The UI should feel dynamic, responsive, and fun to use — not like a boring admin panel. + +- [x] Implement the Setup page (frontend/src/pages/Setup.tsx). This is the first-boot experience: detect via /api/v1/auth/me whether an admin exists. If not, show a clean setup form with username + password + confirm password. On submit, call /api/v1/auth/setup. Redirect to Dashboard on success. Make this feel welcoming — this is the user's first impression. + + +- [ ] Implement the Login page (frontend/src/pages/Login.tsx). Simple form with username + password. Call /api/v1/auth/login, store JWT in memory (React context, NOT localStorage). Redirect to Dashboard. Include a subtle "guest access" link if the system has it enabled. Handle error states clearly. + +- [ ] Build the auth context provider (frontend/src/contexts/AuthContext.tsx). Manage JWT state, provide login/logout functions, expose current user info, handle token expiry with automatic redirect to login. Wrap the entire app in this provider. + +- [ ] Implement the Projects page (frontend/src/pages/Projects.tsx). Card grid showing all projects with name, description, experiment count, last activity timestamp, and a progress indicator showing best score across all experiments. Include a "New Project" button that opens a creation modal. Click a card to navigate to its experiments. + +- [ ] Implement the Experiment Builder (frontend/src/pages/Experiment.tsx). This is the most complex page. It has several sections: (1) Basic info (name, description), (2) Sample data input (paste text, upload file, or enter JSON), (3) Pipeline stage builder (add/remove stages, each with a prompt template editor with syntax highlighting, model selector dropdown populated from configured endpoints, and parameter controls), (4) Scoring config (checkboxes for which scorers to enable, weight sliders for each), (5) Parameter space definition (for each parameter, set type: fixed/range/options and values), (6) Action buttons: Save Draft, Run Single, Start Sweep. + +- [ ] Build the prompt template editor component (frontend/src/components/PromptEditor.tsx). Use a code editor library (CodeMirror or Monaco, loaded from CDN). Support Jinja2 template syntax highlighting. Show available template variables in a sidebar (input_data, previous_stage_output, etc.). Include a "Preview" button that renders the template with sample data. + +- [ ] Build the model selector component (frontend/src/components/ModelSelector.tsx). Dropdown grouped by endpoint. Each option shows model name + endpoint label. Include a "refresh models" button that calls the endpoint test API to refresh available models. Show a connectivity indicator (green dot = reachable, red = error). + +- [ ] Implement the Live Observability page (frontend/src/pages/Live.tsx). This is the star of the show — the real-time dashboard during active sweeps. Layout: left column (60%) shows the activity timeline and current run details, right column (40%) shows the leaderboard and steering controls. Connect via WebSocket to /ws/experiments/{id}. Everything updates in real-time without page refresh. + +- [ ] 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. + +- [ ] 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. + +- [ ] Build the Run Card component (frontend/src/components/RunCard.tsx). Expandable card showing: config summary, all scores with visual bars, prompt sent (collapsible), raw response (collapsible with copy button), timing breakdown per stage, cache status badge. Used in both the leaderboard detail view and the Compare page. + +- [ ] Implement the Compare page (frontend/src/pages/Compare.tsx). Side-by-side comparison of any two runs. Two columns, each with a run selector (dropdown or search). Show: config diff (highlight what changed), response diff (inline text diff with highlights), score comparison (bar chart overlay), and a "pick winner" button for human rating. + +- [ ] Build the Score Chart component (frontend/src/components/ScoreChart.tsx). Multiple chart types: (1) scatter plot of score vs parameter value (e.g. score vs temperature), (2) bar chart comparing top N configs, (3) line chart showing score progression over time as sweep runs. Use a lightweight charting library (recharts via CDN). + +- [ ] 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). + +- [ ] 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. + +- [ ] Style pass — go through every page and component ensuring consistent Tailwind usage, proper dark mode support (use Tailwind dark: prefix), responsive layout (works on tablet+), smooth transitions on state changes, and accessible form inputs. The UI should feel alive and dynamic, not static. Use subtle animations for new data arriving. + +- [ ] Build a "Wizard" flow for first-time users (frontend/src/components/Wizard.tsx). A guided multi-step flow: (1) Configure your first LLM endpoint, (2) Paste some sample text, (3) Write a prompt, (4) Run it and see the result, (5) Congratulations, now try a sweep! This should be accessible from the Dashboard for new users. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ca5b348..f196da9 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -15,6 +15,7 @@ "devDependencies": { "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.4", @@ -1528,6 +1529,20 @@ } } }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", diff --git a/frontend/package.json b/frontend/package.json index ae1c6d4..5379e6d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,6 +17,7 @@ "devDependencies": { "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.4", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 0efc77a..89bd72b 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -1,7 +1,8 @@ -import { render, screen } from "@testing-library/react"; +import { render, screen, waitFor } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import App from "./App"; +import * as client from "./api/client"; function renderWithRouter(route: string) { return render( @@ -12,9 +13,15 @@ function renderWithRouter(route: string) { } describe("App routing", () => { - it("renders SetupPage at /setup", () => { + it("renders SetupPage at /setup", async () => { + vi.spyOn(client.auth, "me").mockRejectedValue( + new client.ApiError(401, "Unauthorized", {}), + ); renderWithRouter("/setup"); - expect(screen.getByText("PromptLooper Setup")).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByText("PromptLooper Setup")).toBeInTheDocument(); + }); + vi.restoreAllMocks(); }); it("renders LoginPage at /login", () => { diff --git a/frontend/src/pages/SetupPage.test.tsx b/frontend/src/pages/SetupPage.test.tsx new file mode 100644 index 0000000..a2a77b6 --- /dev/null +++ b/frontend/src/pages/SetupPage.test.tsx @@ -0,0 +1,246 @@ +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 SetupPage from "./SetupPage"; +import * as client from "../api/client"; + +// Mock the navigate function +const mockNavigate = vi.fn(); +vi.mock("react-router-dom", async () => { + const actual = await vi.importActual("react-router-dom"); + return { + ...actual, + useNavigate: () => mockNavigate, + }; +}); + +function renderSetup() { + return render( + + + , + ); +} + +describe("SetupPage", () => { + beforeEach(() => { + vi.restoreAllMocks(); + mockNavigate.mockReset(); + }); + + it("shows loading state then the setup form when no admin exists", async () => { + vi.spyOn(client.auth, "me").mockRejectedValue( + new client.ApiError(401, "Unauthorized", { detail: "Not authenticated" }), + ); + + renderSetup(); + + // Initially shows checking state + expect(screen.getByText("Checking setup status…")).toBeInTheDocument(); + + // Then shows the setup form + await waitFor(() => { + expect(screen.getByText("PromptLooper Setup")).toBeInTheDocument(); + }); + expect(screen.getByLabelText("Username")).toBeInTheDocument(); + expect(screen.getByLabelText("Password")).toBeInTheDocument(); + expect(screen.getByLabelText("Confirm password")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Create Admin Account" }), + ).toBeInTheDocument(); + }); + + it("redirects to dashboard if already authenticated", async () => { + vi.spyOn(client.auth, "me").mockResolvedValue({ + id: "u1", + username: "admin", + is_admin: true, + created_at: "2026-01-01T00:00:00Z", + }); + + renderSetup(); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith("/", { replace: true }); + }); + }); + + it("shows validation error when passwords do not match", async () => { + vi.spyOn(client.auth, "me").mockRejectedValue( + new client.ApiError(401, "Unauthorized", {}), + ); + + renderSetup(); + const user = userEvent.setup(); + + await waitFor(() => { + expect(screen.getByLabelText("Username")).toBeInTheDocument(); + }); + + await user.type(screen.getByLabelText("Username"), "admin"); + await user.type(screen.getByLabelText("Password"), "password123"); + await user.type(screen.getByLabelText("Confirm password"), "different"); + await user.click( + screen.getByRole("button", { name: "Create Admin Account" }), + ); + + expect(screen.getByRole("alert")).toHaveTextContent( + "Passwords do not match", + ); + }); + + it("shows validation error when password is too short", async () => { + vi.spyOn(client.auth, "me").mockRejectedValue( + new client.ApiError(401, "Unauthorized", {}), + ); + + renderSetup(); + const user = userEvent.setup(); + + await waitFor(() => { + expect(screen.getByLabelText("Username")).toBeInTheDocument(); + }); + + await user.type(screen.getByLabelText("Username"), "admin"); + await user.type(screen.getByLabelText("Password"), "short"); + await user.type(screen.getByLabelText("Confirm password"), "short"); + await user.click( + screen.getByRole("button", { name: "Create Admin Account" }), + ); + + expect(screen.getByRole("alert")).toHaveTextContent( + "Password must be at least 8 characters", + ); + }); + + it("shows validation error when username is empty", async () => { + vi.spyOn(client.auth, "me").mockRejectedValue( + new client.ApiError(401, "Unauthorized", {}), + ); + + renderSetup(); + const user = userEvent.setup(); + + await waitFor(() => { + expect(screen.getByLabelText("Username")).toBeInTheDocument(); + }); + + await user.type(screen.getByLabelText("Password"), "password123"); + await user.type(screen.getByLabelText("Confirm password"), "password123"); + await user.click( + screen.getByRole("button", { name: "Create Admin Account" }), + ); + + expect(screen.getByRole("alert")).toHaveTextContent( + "Username is required", + ); + }); + + it("submits successfully and redirects to dashboard", async () => { + vi.spyOn(client.auth, "me").mockRejectedValue( + new client.ApiError(401, "Unauthorized", {}), + ); + vi.spyOn(client.auth, "setup").mockResolvedValue({ + access_token: "test-jwt-token", + token_type: "bearer", + }); + const setTokenSpy = vi.spyOn(client, "setToken"); + + renderSetup(); + const user = userEvent.setup(); + + await waitFor(() => { + expect(screen.getByLabelText("Username")).toBeInTheDocument(); + }); + + await user.type(screen.getByLabelText("Username"), "admin"); + await user.type(screen.getByLabelText("Password"), "password123"); + await user.type(screen.getByLabelText("Confirm password"), "password123"); + await user.click( + screen.getByRole("button", { name: "Create Admin Account" }), + ); + + await waitFor(() => { + expect(client.auth.setup).toHaveBeenCalledWith({ + username: "admin", + password: "password123", + }); + }); + expect(setTokenSpy).toHaveBeenCalledWith("test-jwt-token"); + expect(mockNavigate).toHaveBeenCalledWith("/", { replace: true }); + }); + + it("shows error when admin already exists (409)", async () => { + vi.spyOn(client.auth, "me").mockRejectedValue( + new client.ApiError(401, "Unauthorized", {}), + ); + vi.spyOn(client.auth, "setup").mockRejectedValue( + new client.ApiError(409, "Conflict", { detail: "Admin already exists" }), + ); + + renderSetup(); + const user = userEvent.setup(); + + await waitFor(() => { + expect(screen.getByLabelText("Username")).toBeInTheDocument(); + }); + + await user.type(screen.getByLabelText("Username"), "admin"); + await user.type(screen.getByLabelText("Password"), "password123"); + await user.type(screen.getByLabelText("Confirm password"), "password123"); + await user.click( + screen.getByRole("button", { name: "Create Admin Account" }), + ); + + await waitFor(() => { + expect(screen.getByRole("alert")).toHaveTextContent( + "admin account already exists", + ); + }); + }); + + it("shows network error on failure", async () => { + vi.spyOn(client.auth, "me").mockRejectedValue( + new client.ApiError(401, "Unauthorized", {}), + ); + vi.spyOn(client.auth, "setup").mockRejectedValue(new Error("fetch failed")); + + renderSetup(); + const user = userEvent.setup(); + + await waitFor(() => { + expect(screen.getByLabelText("Username")).toBeInTheDocument(); + }); + + await user.type(screen.getByLabelText("Username"), "admin"); + await user.type(screen.getByLabelText("Password"), "password123"); + await user.type(screen.getByLabelText("Confirm password"), "password123"); + await user.click( + screen.getByRole("button", { name: "Create Admin Account" }), + ); + + await waitFor(() => { + expect(screen.getByRole("alert")).toHaveTextContent( + "Network error", + ); + }); + }); + + it("has a link to the login page", async () => { + vi.spyOn(client.auth, "me").mockRejectedValue( + new client.ApiError(401, "Unauthorized", {}), + ); + + renderSetup(); + + await waitFor(() => { + expect(screen.getByText("Sign in")).toBeInTheDocument(); + }); + + expect(screen.getByText("Sign in").closest("a")).toHaveAttribute( + "href", + "/login", + ); + }); +}); diff --git a/frontend/src/pages/SetupPage.tsx b/frontend/src/pages/SetupPage.tsx index 65791e6..e2d1324 100644 --- a/frontend/src/pages/SetupPage.tsx +++ b/frontend/src/pages/SetupPage.tsx @@ -1,9 +1,202 @@ +import { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { auth, ApiError, setToken } from "../api/client"; + +type Phase = "checking" | "setup" | "done"; + export default function SetupPage() { + const navigate = useNavigate(); + const [phase, setPhase] = useState("checking"); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + // On mount, check whether an admin already exists. + useEffect(() => { + let cancelled = false; + auth + .me() + .then(() => { + // Already authenticated — admin exists, go to dashboard + if (!cancelled) navigate("/", { replace: true }); + }) + .catch((err: unknown) => { + if (cancelled) return; + if (err instanceof ApiError && err.status === 401) { + // Not authenticated — but that doesn't tell us if an admin exists. + // The setup endpoint will return 409 if admin already exists, so + // we show the form and let the server decide. + setPhase("setup"); + } else { + // Could be a network error or server down + setPhase("setup"); + } + }); + return () => { + cancelled = true; + }; + }, [navigate]); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + + // Client-side validation + if (!username.trim()) { + setError("Username is required."); + return; + } + if (password.length < 8) { + setError("Password must be at least 8 characters."); + return; + } + if (password !== confirmPassword) { + setError("Passwords do not match."); + return; + } + + setSubmitting(true); + try { + const resp = await auth.setup({ username: username.trim(), password }); + setToken(resp.access_token); + setPhase("done"); + navigate("/", { replace: true }); + } catch (err: unknown) { + if (err instanceof ApiError) { + if (err.status === 409) { + setError("An admin account already exists. Please log in instead."); + } else { + const detail = + err.body && typeof err.body === "object" && "detail" in err.body + ? String((err.body as Record).detail) + : err.statusText; + setError(`Setup failed: ${detail}`); + } + } else { + setError("Network error. Is the server running?"); + } + } finally { + setSubmitting(false); + } + } + + if (phase === "checking") { + return ( +
+

+ Checking setup status… +

+
+ ); + } + return ( -
-
-

PromptLooper Setup

-

Create your admin account to get started.

+
+
+ {/* Logo / header area */} +
+
+ PL +
+

+ PromptLooper Setup +

+

+ Create your admin account to get started. +

+
+ + {/* Setup form */} +
+ {error && ( +
+ {error} +
+ )} + +
+ + setUsername(e.target.value)} + className="w-full rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 px-3 py-2.5 text-slate-900 dark:text-white placeholder-slate-400 focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 transition" + placeholder="admin" + disabled={submitting} + /> +
+ +
+ + setPassword(e.target.value)} + className="w-full rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 px-3 py-2.5 text-slate-900 dark:text-white placeholder-slate-400 focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 transition" + placeholder="At least 8 characters" + disabled={submitting} + /> +
+ +
+ + setConfirmPassword(e.target.value)} + className="w-full rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 px-3 py-2.5 text-slate-900 dark:text-white placeholder-slate-400 focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 transition" + placeholder="Repeat your password" + disabled={submitting} + /> +
+ + + +

+ Already have an account?{" "} + + Sign in + +

+
);