MAESTRO: Implement Admin page with settings, API keys, stats, and webhook management

This commit is contained in:
John Lightner 2026-04-07 03:34:32 -05:00
parent 30fd15ec7a
commit 74ccc1a8ed
3 changed files with 1211 additions and 5 deletions

View file

@ -44,7 +44,8 @@ Build the React frontend: setup wizard, experiment builder, real-time observabil
- [x] 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).
<!-- Implemented ScoreChart with custom SVG-based charts (zero external dependencies, consistent with project's dependency-minimal approach). Three chart types: ScatterPlot (score vs parameter value with filtered points, grid lines, axis labels), BarChart (top N configs sorted by score, truncated labels, score annotations), LineChart (score progression with area gradient fill, timestamp-sorted, adaptive tick labels). All charts share: interactive hover tooltips, click-to-select callbacks, dark mode support, responsive SVG viewBox. Chart type selector allows switching between views at runtime. Handles edge cases: identical scores, negative values, single data point, missing paramValue. 30 tests added. -->
- [ ] 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. -->
- [ ] 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).

View file

@ -0,0 +1,480 @@
import { render, screen, waitFor, within } 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 AdminPage from "./AdminPage";
import * as client from "../api/client";
// ---------------------------------------------------------------------------
// Mock data
// ---------------------------------------------------------------------------
const MOCK_SETTINGS = {
guest_access_enabled: false,
default_endpoint_id: "ep1",
token_budget_daily: 100000,
token_budget_monthly: 2000000,
api_keys: [
{
id: "key1",
label: "CI Pipeline",
prefix: "pl_abc",
created_at: "2026-04-01T10:00:00Z",
},
],
};
const MOCK_STATS = {
total_runs: 1234,
total_experiments: 56,
total_projects: 8,
cache_entries: 890,
cache_hit_rate: 0.73,
storage_bytes: 52428800,
tokens_spent: 456789,
};
const MOCK_ENDPOINTS: client.EndpointResponse[] = [
{ id: "ep1", name: "OpenAI", url: "https://api.openai.com", default_model: "gpt-4" },
{ id: "ep2", name: "Local vLLM", url: "http://localhost:8080", default_model: null },
];
const MOCK_WEBHOOKS: client.WebhookResponse[] = [
{
id: "wh1",
event_type: "run.completed",
url: "https://example.com/hook",
headers: null,
is_active: true,
},
];
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function mockAllApis() {
vi.spyOn(client.admin, "getSettings").mockResolvedValue(
MOCK_SETTINGS as unknown as Record<string, unknown>,
);
vi.spyOn(client.admin, "getStats").mockResolvedValue(
MOCK_STATS as unknown as Record<string, unknown>,
);
vi.spyOn(client.admin, "updateSettings").mockResolvedValue(
{} as Record<string, unknown>,
);
vi.spyOn(client.endpoints, "list").mockResolvedValue({
items: MOCK_ENDPOINTS,
total: MOCK_ENDPOINTS.length,
});
vi.spyOn(client.webhooks, "list").mockResolvedValue({
items: MOCK_WEBHOOKS,
total: MOCK_WEBHOOKS.length,
});
vi.spyOn(client.webhooks, "create").mockResolvedValue({
id: "wh2",
event_type: "run.started",
url: "https://new.com/hook",
headers: null,
is_active: true,
});
vi.spyOn(client.webhooks, "delete").mockResolvedValue(undefined as never);
}
function renderAdmin() {
return render(
<MemoryRouter initialEntries={["/admin"]}>
<AdminPage />
</MemoryRouter>,
);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("AdminPage", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it("shows loading state initially", () => {
vi.spyOn(client.admin, "getSettings").mockReturnValue(
new Promise(() => {}),
);
vi.spyOn(client.admin, "getStats").mockReturnValue(new Promise(() => {}));
vi.spyOn(client.endpoints, "list").mockReturnValue(new Promise(() => {}));
vi.spyOn(client.webhooks, "list").mockReturnValue(new Promise(() => {}));
renderAdmin();
expect(screen.getByText("Loading admin data...")).toBeInTheDocument();
});
it("shows error state on API failure", async () => {
vi.spyOn(client.admin, "getSettings").mockRejectedValue(
new client.ApiError(500, "Internal Server Error", {}),
);
vi.spyOn(client.admin, "getStats").mockRejectedValue(
new client.ApiError(500, "Internal Server Error", {}),
);
vi.spyOn(client.endpoints, "list").mockRejectedValue(
new client.ApiError(500, "Internal Server Error", {}),
);
vi.spyOn(client.webhooks, "list").mockRejectedValue(
new client.ApiError(500, "Internal Server Error", {}),
);
renderAdmin();
await waitFor(() => {
expect(screen.getByRole("alert")).toBeInTheDocument();
});
expect(screen.getByText(/Failed to load admin data/)).toBeInTheDocument();
});
it("shows network error message on non-API errors", async () => {
vi.spyOn(client.admin, "getSettings").mockRejectedValue(
new Error("fetch failed"),
);
vi.spyOn(client.admin, "getStats").mockResolvedValue({});
vi.spyOn(client.endpoints, "list").mockResolvedValue({
items: [],
total: 0,
});
vi.spyOn(client.webhooks, "list").mockResolvedValue({
items: [],
total: 0,
});
renderAdmin();
await waitFor(() => {
expect(
screen.getByText("Network error. Is the server running?"),
).toBeInTheDocument();
});
});
it("renders settings section after loading", async () => {
mockAllApis();
renderAdmin();
await waitFor(() => {
expect(screen.getByText("Settings")).toBeInTheDocument();
});
// Guest access toggle (should be off)
const toggle = screen.getByTestId("guest-access-toggle");
expect(toggle).toHaveAttribute("aria-checked", "false");
// Default endpoint should be selected
const endpointSelect = screen.getByTestId(
"default-endpoint-select",
) as HTMLSelectElement;
expect(endpointSelect.value).toBe("ep1");
// Token budgets
const daily = screen.getByTestId("budget-daily") as HTMLInputElement;
expect(daily.value).toBe("100000");
const monthly = screen.getByTestId("budget-monthly") as HTMLInputElement;
expect(monthly.value).toBe("2000000");
});
it("toggles guest access and saves", async () => {
mockAllApis();
const user = userEvent.setup();
renderAdmin();
await waitFor(() => {
expect(screen.getByTestId("guest-access-toggle")).toBeInTheDocument();
});
await user.click(screen.getByTestId("guest-access-toggle"));
await waitFor(() => {
expect(client.admin.updateSettings).toHaveBeenCalledWith(
expect.objectContaining({ guest_access_enabled: true }),
);
});
});
it("renders system stats", async () => {
mockAllApis();
renderAdmin();
await waitFor(() => {
expect(screen.getByText("System Stats")).toBeInTheDocument();
});
expect(screen.getByTestId("stat-total-runs")).toHaveTextContent("1,234");
expect(screen.getByTestId("stat-total-experiments")).toHaveTextContent("56");
expect(screen.getByTestId("stat-cache-hit-rate")).toHaveTextContent("73.0%");
expect(screen.getByTestId("stat-tokens-spent")).toHaveTextContent("456,789");
});
it("renders API keys and revoke works", async () => {
mockAllApis();
const user = userEvent.setup();
renderAdmin();
await waitFor(() => {
expect(screen.getByText("API Keys")).toBeInTheDocument();
});
expect(screen.getByText("CI Pipeline")).toBeInTheDocument();
expect(screen.getByText(/pl_abc/)).toBeInTheDocument();
// Revoke key
await user.click(screen.getByTestId("revoke-key-key1"));
await waitFor(() => {
expect(client.admin.updateSettings).toHaveBeenCalledWith(
expect.objectContaining({
action: "revoke_api_key",
key_id: "key1",
}),
);
});
// Key should be removed from UI
await waitFor(() => {
expect(screen.queryByText("CI Pipeline")).not.toBeInTheDocument();
});
});
it("generates a new API key", async () => {
mockAllApis();
vi.spyOn(client.admin, "updateSettings").mockResolvedValue({
api_key: {
id: "key2",
label: "Dev Key",
prefix: "pl_xyz",
created_at: "2026-04-07T12:00:00Z",
},
} as unknown as Record<string, unknown>);
const user = userEvent.setup();
renderAdmin();
await waitFor(() => {
expect(screen.getByTestId("api-key-label")).toBeInTheDocument();
});
await user.type(screen.getByTestId("api-key-label"), "Dev Key");
await user.click(screen.getByTestId("generate-key-btn"));
await waitFor(() => {
expect(client.admin.updateSettings).toHaveBeenCalledWith(
expect.objectContaining({
action: "generate_api_key",
label: "Dev Key",
}),
);
});
await waitFor(() => {
expect(screen.getByText("Dev Key")).toBeInTheDocument();
});
});
it("renders webhooks and supports delete", async () => {
mockAllApis();
const user = userEvent.setup();
renderAdmin();
await waitFor(() => {
expect(screen.getByText("Webhooks")).toBeInTheDocument();
});
expect(screen.getByText("1 webhook configured")).toBeInTheDocument();
expect(screen.getByText("run.completed")).toBeInTheDocument();
expect(screen.getByText("https://example.com/hook")).toBeInTheDocument();
// Delete webhook
await user.click(screen.getByTestId("delete-webhook-wh1"));
await waitFor(() => {
expect(client.webhooks.delete).toHaveBeenCalledWith("wh1");
});
await waitFor(() => {
expect(
screen.queryByText("https://example.com/hook"),
).not.toBeInTheDocument();
});
});
it("creates a new webhook", async () => {
mockAllApis();
const user = userEvent.setup();
renderAdmin();
await waitFor(() => {
expect(screen.getByTestId("add-webhook-btn")).toBeInTheDocument();
});
// Open form
await user.click(screen.getByTestId("add-webhook-btn"));
await waitFor(() => {
expect(screen.getByTestId("webhook-form")).toBeInTheDocument();
});
await user.type(
screen.getByTestId("webhook-url-input"),
"https://new.com/hook",
);
await user.click(screen.getByTestId("create-webhook-btn"));
await waitFor(() => {
expect(client.webhooks.create).toHaveBeenCalledWith({
event_type: "run.started",
url: "https://new.com/hook",
});
});
// New webhook should appear
await waitFor(() => {
expect(screen.getByText("https://new.com/hook")).toBeInTheDocument();
});
});
it("retry button reloads data after error", async () => {
const getSettingsSpy = vi
.spyOn(client.admin, "getSettings")
.mockRejectedValueOnce(new client.ApiError(500, "Error", {}))
.mockResolvedValueOnce(MOCK_SETTINGS as unknown as Record<string, unknown>);
vi.spyOn(client.admin, "getStats")
.mockRejectedValueOnce(new client.ApiError(500, "Error", {}))
.mockResolvedValueOnce(MOCK_STATS as unknown as Record<string, unknown>);
vi.spyOn(client.endpoints, "list")
.mockRejectedValueOnce(new client.ApiError(500, "Error", {}))
.mockResolvedValueOnce({
items: MOCK_ENDPOINTS,
total: MOCK_ENDPOINTS.length,
});
vi.spyOn(client.webhooks, "list")
.mockRejectedValueOnce(new client.ApiError(500, "Error", {}))
.mockResolvedValueOnce({
items: MOCK_WEBHOOKS,
total: MOCK_WEBHOOKS.length,
});
const user = userEvent.setup();
renderAdmin();
await waitFor(() => {
expect(screen.getByRole("alert")).toBeInTheDocument();
});
await user.click(screen.getByText("Retry"));
await waitFor(() => {
expect(screen.getByText("Settings")).toBeInTheDocument();
});
expect(getSettingsSpy).toHaveBeenCalledTimes(2);
});
it("shows empty API keys message when none exist", async () => {
vi.spyOn(client.admin, "getSettings").mockResolvedValue({
...MOCK_SETTINGS,
api_keys: [],
} as unknown as Record<string, unknown>);
vi.spyOn(client.admin, "getStats").mockResolvedValue(
MOCK_STATS as unknown as Record<string, unknown>,
);
vi.spyOn(client.endpoints, "list").mockResolvedValue({
items: MOCK_ENDPOINTS,
total: MOCK_ENDPOINTS.length,
});
vi.spyOn(client.webhooks, "list").mockResolvedValue({
items: [],
total: 0,
});
renderAdmin();
await waitFor(() => {
expect(
screen.getByText("No API keys generated yet."),
).toBeInTheDocument();
});
});
it("updates default endpoint selection", async () => {
mockAllApis();
const user = userEvent.setup();
renderAdmin();
await waitFor(() => {
expect(screen.getByTestId("default-endpoint-select")).toBeInTheDocument();
});
await user.selectOptions(
screen.getByTestId("default-endpoint-select"),
"ep2",
);
await waitFor(() => {
expect(client.admin.updateSettings).toHaveBeenCalledWith(
expect.objectContaining({ default_endpoint_id: "ep2" }),
);
});
});
it("updates token budget values", async () => {
mockAllApis();
const user = userEvent.setup();
renderAdmin();
await waitFor(() => {
expect(screen.getByTestId("budget-daily")).toBeInTheDocument();
});
const dailyInput = screen.getByTestId("budget-daily");
await user.clear(dailyInput);
await user.type(dailyInput, "50000");
await waitFor(() => {
expect(client.admin.updateSettings).toHaveBeenCalled();
});
});
it("shows no webhooks configured message when empty", async () => {
vi.spyOn(client.admin, "getSettings").mockResolvedValue(
MOCK_SETTINGS as unknown as Record<string, unknown>,
);
vi.spyOn(client.admin, "getStats").mockResolvedValue(
MOCK_STATS as unknown as Record<string, unknown>,
);
vi.spyOn(client.endpoints, "list").mockResolvedValue({
items: MOCK_ENDPOINTS,
total: MOCK_ENDPOINTS.length,
});
vi.spyOn(client.webhooks, "list").mockResolvedValue({
items: [],
total: 0,
});
renderAdmin();
await waitFor(() => {
expect(
screen.getByText("No webhooks configured."),
).toBeInTheDocument();
});
});
it("shows storage usage formatted correctly", async () => {
mockAllApis();
renderAdmin();
await waitFor(() => {
expect(screen.getByTestId("stat-storage-usage")).toHaveTextContent(
"50.0 MB",
);
});
});
});

View file

@ -1,8 +1,733 @@
export default function AdminPage() {
import { useEffect, useState, useCallback } from "react";
import {
admin,
endpoints,
webhooks,
ApiError,
} from "../api/client";
import type {
EndpointResponse,
WebhookResponse,
WebhookCreate,
} from "../api/client";
// ---------------------------------------------------------------------------
// Types for admin settings / stats
// ---------------------------------------------------------------------------
interface AdminSettings {
guest_access_enabled: boolean;
default_endpoint_id: string | null;
token_budget_daily: number | null;
token_budget_monthly: number | null;
api_keys: ApiKeyEntry[];
}
interface ApiKeyEntry {
id: string;
label: string;
prefix: string;
created_at: string;
}
interface SystemStats {
total_runs: number;
total_experiments: number;
total_projects: number;
cache_entries: number;
cache_hit_rate: number;
storage_bytes: number;
tokens_spent: number;
}
function defaultSettings(): AdminSettings {
return {
guest_access_enabled: false,
default_endpoint_id: null,
token_budget_daily: null,
token_budget_monthly: null,
api_keys: [],
};
}
function defaultStats(): SystemStats {
return {
total_runs: 0,
total_experiments: 0,
total_projects: 0,
cache_entries: 0,
cache_hit_rate: 0,
storage_bytes: 0,
tokens_spent: 0,
};
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function formatBytes(bytes: number): string {
if (bytes === 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
const val = bytes / Math.pow(1024, i);
return `${val.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
}
function formatNumber(n: number): string {
return n.toLocaleString();
}
// ---------------------------------------------------------------------------
// Section Card wrapper
// ---------------------------------------------------------------------------
function SectionCard({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<div className="p-8">
<h1 className="mb-4 text-2xl font-bold">Admin</h1>
<p className="text-gray-600">System administration and user management.</p>
<div className="rounded-xl bg-white dark:bg-slate-800 p-6 shadow-md ring-1 ring-slate-200 dark:ring-slate-700">
<h2 className="mb-4 text-lg font-semibold text-slate-900 dark:text-white">
{title}
</h2>
{children}
</div>
);
}
// ---------------------------------------------------------------------------
// Settings Section
// ---------------------------------------------------------------------------
function SettingsSection({
settings,
endpointList,
onUpdate,
saving,
}: {
settings: AdminSettings;
endpointList: EndpointResponse[];
onUpdate: (patch: Partial<AdminSettings>) => void;
saving: boolean;
}) {
return (
<SectionCard title="Settings">
<div className="space-y-5">
{/* Guest access toggle */}
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-slate-700 dark:text-slate-300">
Guest Access
</p>
<p className="text-xs text-slate-500 dark:text-slate-400">
Allow unauthenticated users to view experiments.
</p>
</div>
<button
type="button"
role="switch"
aria-checked={settings.guest_access_enabled}
data-testid="guest-access-toggle"
disabled={saving}
onClick={() =>
onUpdate({
guest_access_enabled: !settings.guest_access_enabled,
})
}
className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 ${
settings.guest_access_enabled
? "bg-indigo-600"
: "bg-slate-300 dark:bg-slate-600"
}`}
>
<span
className={`pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transition-transform duration-200 ${
settings.guest_access_enabled
? "translate-x-5"
: "translate-x-0"
}`}
/>
</button>
</div>
{/* Default endpoint */}
<div>
<label
htmlFor="default-endpoint"
className="mb-1.5 block text-sm font-medium text-slate-700 dark:text-slate-300"
>
Default Endpoint
</label>
<select
id="default-endpoint"
data-testid="default-endpoint-select"
value={settings.default_endpoint_id ?? ""}
disabled={saving}
onChange={(e) =>
onUpdate({
default_endpoint_id: e.target.value || null,
})
}
className="w-full rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 px-3 py-2 text-sm text-slate-900 dark:text-white focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 transition"
>
<option value="">None</option>
{endpointList.map((ep) => (
<option key={ep.id} value={ep.id}>
{ep.name}
</option>
))}
</select>
</div>
{/* Token budgets */}
<div className="grid grid-cols-2 gap-4">
<div>
<label
htmlFor="budget-daily"
className="mb-1.5 block text-sm font-medium text-slate-700 dark:text-slate-300"
>
Daily Token Budget
</label>
<input
id="budget-daily"
type="number"
min={0}
data-testid="budget-daily"
value={settings.token_budget_daily ?? ""}
disabled={saving}
onChange={(e) =>
onUpdate({
token_budget_daily: e.target.value
? Number(e.target.value)
: null,
})
}
placeholder="Unlimited"
className="w-full rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 px-3 py-2 text-sm 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"
/>
</div>
<div>
<label
htmlFor="budget-monthly"
className="mb-1.5 block text-sm font-medium text-slate-700 dark:text-slate-300"
>
Monthly Token Budget
</label>
<input
id="budget-monthly"
type="number"
min={0}
data-testid="budget-monthly"
value={settings.token_budget_monthly ?? ""}
disabled={saving}
onChange={(e) =>
onUpdate({
token_budget_monthly: e.target.value
? Number(e.target.value)
: null,
})
}
placeholder="Unlimited"
className="w-full rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 px-3 py-2 text-sm 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"
/>
</div>
</div>
</div>
</SectionCard>
);
}
// ---------------------------------------------------------------------------
// API Keys Section
// ---------------------------------------------------------------------------
function ApiKeysSection({
keys,
onGenerate,
onRevoke,
generating,
}: {
keys: ApiKeyEntry[];
onGenerate: (label: string) => void;
onRevoke: (id: string) => void;
generating: boolean;
}) {
const [newLabel, setNewLabel] = useState("");
function handleGenerate(e: React.FormEvent) {
e.preventDefault();
if (!newLabel.trim()) return;
onGenerate(newLabel.trim());
setNewLabel("");
}
return (
<SectionCard title="API Keys">
<form onSubmit={handleGenerate} className="mb-4 flex gap-2">
<input
type="text"
value={newLabel}
onChange={(e) => setNewLabel(e.target.value)}
placeholder="Key label (e.g. CI pipeline)"
data-testid="api-key-label"
disabled={generating}
className="flex-1 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 px-3 py-2 text-sm 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"
/>
<button
type="submit"
disabled={generating || !newLabel.trim()}
data-testid="generate-key-btn"
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed transition"
>
{generating ? "Generating..." : "Generate"}
</button>
</form>
{keys.length === 0 ? (
<p className="text-sm text-slate-500 dark:text-slate-400">
No API keys generated yet.
</p>
) : (
<ul className="divide-y divide-slate-200 dark:divide-slate-700">
{keys.map((key) => (
<li
key={key.id}
className="flex items-center justify-between py-3"
>
<div>
<p className="text-sm font-medium text-slate-900 dark:text-white">
{key.label}
</p>
<p className="text-xs text-slate-500 dark:text-slate-400">
{key.prefix}... &middot; Created{" "}
{new Date(key.created_at).toLocaleDateString()}
</p>
</div>
<button
type="button"
onClick={() => onRevoke(key.id)}
data-testid={`revoke-key-${key.id}`}
className="rounded-lg px-3 py-1.5 text-xs font-medium text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/30 transition"
>
Revoke
</button>
</li>
))}
</ul>
)}
</SectionCard>
);
}
// ---------------------------------------------------------------------------
// System Stats Section
// ---------------------------------------------------------------------------
function StatsSection({ stats }: { stats: SystemStats }) {
const statItems = [
{ label: "Total Runs", value: formatNumber(stats.total_runs) },
{
label: "Total Experiments",
value: formatNumber(stats.total_experiments),
},
{ label: "Total Projects", value: formatNumber(stats.total_projects) },
{ label: "Cache Entries", value: formatNumber(stats.cache_entries) },
{
label: "Cache Hit Rate",
value: `${(stats.cache_hit_rate * 100).toFixed(1)}%`,
},
{ label: "Storage Usage", value: formatBytes(stats.storage_bytes) },
{ label: "Tokens Spent", value: formatNumber(stats.tokens_spent) },
];
return (
<SectionCard title="System Stats">
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
{statItems.map((item) => (
<div
key={item.label}
className="rounded-lg bg-slate-50 dark:bg-slate-700/50 p-3"
>
<p className="text-xs text-slate-500 dark:text-slate-400">
{item.label}
</p>
<p
className="mt-1 text-lg font-semibold text-slate-900 dark:text-white"
data-testid={`stat-${item.label.toLowerCase().replace(/\s+/g, "-")}`}
>
{item.value}
</p>
</div>
))}
</div>
</SectionCard>
);
}
// ---------------------------------------------------------------------------
// Webhooks Section
// ---------------------------------------------------------------------------
const EVENT_TYPES = [
"run.started",
"run.completed",
"run.failed",
"sweep.started",
"sweep.completed",
"new_best_found",
];
function WebhooksSection({
webhookList,
onDelete,
onCreate,
creating,
}: {
webhookList: WebhookResponse[];
onDelete: (id: string) => void;
onCreate: (data: WebhookCreate) => void;
creating: boolean;
}) {
const [showForm, setShowForm] = useState(false);
const [eventType, setEventType] = useState(EVENT_TYPES[0]);
const [url, setUrl] = useState("");
function handleCreate(e: React.FormEvent) {
e.preventDefault();
if (!url.trim()) return;
onCreate({ event_type: eventType, url: url.trim() });
setUrl("");
setShowForm(false);
}
return (
<SectionCard title="Webhooks">
<div className="mb-4 flex items-center justify-between">
<p className="text-sm text-slate-500 dark:text-slate-400">
{webhookList.length} webhook{webhookList.length !== 1 ? "s" : ""}{" "}
configured
</p>
<button
type="button"
onClick={() => setShowForm(!showForm)}
data-testid="add-webhook-btn"
className="rounded-lg bg-indigo-600 px-3 py-1.5 text-xs font-semibold text-white shadow-sm hover:bg-indigo-500 transition"
>
{showForm ? "Cancel" : "Add Webhook"}
</button>
</div>
{showForm && (
<form
onSubmit={handleCreate}
className="mb-4 rounded-lg border border-slate-200 dark:border-slate-700 p-4 space-y-3"
data-testid="webhook-form"
>
<div>
<label
htmlFor="webhook-event"
className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300"
>
Event Type
</label>
<select
id="webhook-event"
value={eventType}
onChange={(e) => setEventType(e.target.value)}
disabled={creating}
className="w-full rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 px-3 py-2 text-sm text-slate-900 dark:text-white focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 transition"
>
{EVENT_TYPES.map((et) => (
<option key={et} value={et}>
{et}
</option>
))}
</select>
</div>
<div>
<label
htmlFor="webhook-url"
className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300"
>
URL
</label>
<input
id="webhook-url"
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://example.com/webhook"
data-testid="webhook-url-input"
disabled={creating}
className="w-full rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 px-3 py-2 text-sm 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"
/>
</div>
<button
type="submit"
disabled={creating || !url.trim()}
data-testid="create-webhook-btn"
className="rounded-lg bg-emerald-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-emerald-500 disabled:opacity-50 disabled:cursor-not-allowed transition"
>
{creating ? "Creating..." : "Create Webhook"}
</button>
</form>
)}
{webhookList.length === 0 && !showForm ? (
<p className="text-sm text-slate-500 dark:text-slate-400">
No webhooks configured.
</p>
) : (
<ul className="divide-y divide-slate-200 dark:divide-slate-700">
{webhookList.map((wh) => (
<li
key={wh.id}
className="flex items-center justify-between py-3"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span
className={`inline-block h-2 w-2 rounded-full ${
wh.is_active
? "bg-green-500"
: "bg-slate-400 dark:bg-slate-500"
}`}
/>
<p className="text-sm font-medium text-slate-900 dark:text-white truncate">
{wh.event_type}
</p>
</div>
<p className="text-xs text-slate-500 dark:text-slate-400 truncate mt-0.5">
{wh.url}
</p>
</div>
<button
type="button"
onClick={() => onDelete(wh.id)}
data-testid={`delete-webhook-${wh.id}`}
className="ml-3 shrink-0 rounded-lg px-3 py-1.5 text-xs font-medium text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/30 transition"
>
Delete
</button>
</li>
))}
</ul>
)}
</SectionCard>
);
}
// ---------------------------------------------------------------------------
// Admin Page
// ---------------------------------------------------------------------------
export default function AdminPage() {
const [settings, setSettings] = useState<AdminSettings>(defaultSettings());
const [stats, setStats] = useState<SystemStats>(defaultStats());
const [endpointList, setEndpointList] = useState<EndpointResponse[]>([]);
const [webhookList, setWebhookList] = useState<WebhookResponse[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [generating, setGenerating] = useState(false);
const [creatingWebhook, setCreatingWebhook] = useState(false);
const [saveMessage, setSaveMessage] = useState<string | null>(null);
const loadData = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [settingsResp, statsResp, endpointsResp, webhooksResp] =
await Promise.all([
admin.getSettings(),
admin.getStats(),
endpoints.list(),
webhooks.list(),
]);
setSettings({
...defaultSettings(),
...(settingsResp as Partial<AdminSettings>),
});
setStats({ ...defaultStats(), ...(statsResp as Partial<SystemStats>) });
setEndpointList(endpointsResp.items);
setWebhookList(webhooksResp.items);
} catch (err: unknown) {
if (err instanceof ApiError) {
setError(`Failed to load admin data (${err.status}).`);
} else {
setError("Network error. Is the server running?");
}
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadData();
}, [loadData]);
async function handleUpdateSettings(patch: Partial<AdminSettings>) {
const updated = { ...settings, ...patch };
setSettings(updated);
setSaving(true);
setSaveMessage(null);
try {
await admin.updateSettings(
updated as unknown as Record<string, unknown>,
);
setSaveMessage("Settings saved.");
setTimeout(() => setSaveMessage(null), 2000);
} catch {
setSaveMessage("Failed to save settings.");
} finally {
setSaving(false);
}
}
async function handleGenerateKey(label: string) {
setGenerating(true);
try {
const resp = await admin.updateSettings({
action: "generate_api_key",
label,
});
const newKey = resp as unknown as {
api_key?: ApiKeyEntry;
api_keys?: ApiKeyEntry[];
};
if (newKey.api_keys) {
setSettings((prev) => ({ ...prev, api_keys: newKey.api_keys! }));
} else if (newKey.api_key) {
setSettings((prev) => ({
...prev,
api_keys: [...prev.api_keys, newKey.api_key!],
}));
}
} catch {
setSaveMessage("Failed to generate API key.");
setTimeout(() => setSaveMessage(null), 2000);
} finally {
setGenerating(false);
}
}
async function handleRevokeKey(keyId: string) {
try {
await admin.updateSettings({
action: "revoke_api_key",
key_id: keyId,
});
setSettings((prev) => ({
...prev,
api_keys: prev.api_keys.filter((k) => k.id !== keyId),
}));
} catch {
setSaveMessage("Failed to revoke API key.");
setTimeout(() => setSaveMessage(null), 2000);
}
}
async function handleCreateWebhook(data: WebhookCreate) {
setCreatingWebhook(true);
try {
const created = await webhooks.create(data);
setWebhookList((prev) => [...prev, created]);
} catch {
setSaveMessage("Failed to create webhook.");
setTimeout(() => setSaveMessage(null), 2000);
} finally {
setCreatingWebhook(false);
}
}
async function handleDeleteWebhook(id: string) {
try {
await webhooks.delete(id);
setWebhookList((prev) => prev.filter((w) => w.id !== id));
} catch {
setSaveMessage("Failed to delete webhook.");
setTimeout(() => setSaveMessage(null), 2000);
}
}
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-4xl">
{/* Header */}
<div className="mb-8">
<h1 className="text-3xl font-bold text-slate-900 dark:text-white">
Admin
</h1>
<p className="mt-1 text-slate-500 dark:text-slate-400">
System administration and settings.
</p>
{saveMessage && (
<p
className="mt-2 text-sm font-medium text-indigo-600 dark:text-indigo-400"
data-testid="save-message"
>
{saveMessage}
</p>
)}
</div>
{/* Loading */}
{loading && (
<p className="text-center text-slate-500 dark:text-slate-400 animate-pulse py-16">
Loading admin data...
</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-6">
<SettingsSection
settings={settings}
endpointList={endpointList}
onUpdate={handleUpdateSettings}
saving={saving}
/>
<ApiKeysSection
keys={settings.api_keys}
onGenerate={handleGenerateKey}
onRevoke={handleRevokeKey}
generating={generating}
/>
<StatsSection stats={stats} />
<WebhooksSection
webhookList={webhookList}
onDelete={handleDeleteWebhook}
onCreate={handleCreateWebhook}
creating={creatingWebhook}
/>
</div>
)}
</div>
</div>
);
}