From 74ccc1a8edc336cde5f61e0611e73236b34734c2 Mon Sep 17 00:00:00 2001 From: John Lightner Date: Tue, 7 Apr 2026 03:34:32 -0500 Subject: [PATCH] MAESTRO: Implement Admin page with settings, API keys, stats, and webhook management --- Auto Run Docs/02b-frontend-dashboard.md | 3 +- frontend/src/pages/AdminPage.test.tsx | 480 ++++++++++++++++ frontend/src/pages/AdminPage.tsx | 733 +++++++++++++++++++++++- 3 files changed, 1211 insertions(+), 5 deletions(-) create mode 100644 frontend/src/pages/AdminPage.test.tsx diff --git a/Auto Run Docs/02b-frontend-dashboard.md b/Auto Run Docs/02b-frontend-dashboard.md index 8a449b3..73bad72 100644 --- a/Auto Run Docs/02b-frontend-dashboard.md +++ b/Auto Run Docs/02b-frontend-dashboard.md @@ -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). -- [ ] 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). + - [ ] 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). diff --git a/frontend/src/pages/AdminPage.test.tsx b/frontend/src/pages/AdminPage.test.tsx new file mode 100644 index 0000000..6b44058 --- /dev/null +++ b/frontend/src/pages/AdminPage.test.tsx @@ -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, + ); + vi.spyOn(client.admin, "getStats").mockResolvedValue( + MOCK_STATS as unknown as Record, + ); + vi.spyOn(client.admin, "updateSettings").mockResolvedValue( + {} as Record, + ); + 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( + + + , + ); +} + +// --------------------------------------------------------------------------- +// 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); + + 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); + + vi.spyOn(client.admin, "getStats") + .mockRejectedValueOnce(new client.ApiError(500, "Error", {})) + .mockResolvedValueOnce(MOCK_STATS as unknown as Record); + 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); + vi.spyOn(client.admin, "getStats").mockResolvedValue( + MOCK_STATS as unknown as Record, + ); + 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, + ); + vi.spyOn(client.admin, "getStats").mockResolvedValue( + MOCK_STATS as unknown as Record, + ); + 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", + ); + }); + }); +}); diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx index b0e32bd..056e481 100644 --- a/frontend/src/pages/AdminPage.tsx +++ b/frontend/src/pages/AdminPage.tsx @@ -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 ( -
-

Admin

-

System administration and user management.

+
+

+ {title} +

+ {children} +
+ ); +} + +// --------------------------------------------------------------------------- +// Settings Section +// --------------------------------------------------------------------------- + +function SettingsSection({ + settings, + endpointList, + onUpdate, + saving, +}: { + settings: AdminSettings; + endpointList: EndpointResponse[]; + onUpdate: (patch: Partial) => void; + saving: boolean; +}) { + return ( + +
+ {/* Guest access toggle */} +
+
+

+ Guest Access +

+

+ Allow unauthenticated users to view experiments. +

+
+ +
+ + {/* Default endpoint */} +
+ + +
+ + {/* Token budgets */} +
+
+ + + 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" + /> +
+
+ + + 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" + /> +
+
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// 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 ( + +
+ 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" + /> + +
+ + {keys.length === 0 ? ( +

+ No API keys generated yet. +

+ ) : ( +
    + {keys.map((key) => ( +
  • +
    +

    + {key.label} +

    +

    + {key.prefix}... · Created{" "} + {new Date(key.created_at).toLocaleDateString()} +

    +
    + +
  • + ))} +
+ )} +
+ ); +} + +// --------------------------------------------------------------------------- +// 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 ( + +
+ {statItems.map((item) => ( +
+

+ {item.label} +

+

+ {item.value} +

+
+ ))} +
+
+ ); +} + +// --------------------------------------------------------------------------- +// 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 ( + +
+

+ {webhookList.length} webhook{webhookList.length !== 1 ? "s" : ""}{" "} + configured +

+ +
+ + {showForm && ( +
+
+ + +
+
+ + 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" + /> +
+ +
+ )} + + {webhookList.length === 0 && !showForm ? ( +

+ No webhooks configured. +

+ ) : ( +
    + {webhookList.map((wh) => ( +
  • +
    +
    + +

    + {wh.event_type} +

    +
    +

    + {wh.url} +

    +
    + +
  • + ))} +
+ )} +
+ ); +} + +// --------------------------------------------------------------------------- +// Admin Page +// --------------------------------------------------------------------------- + +export default function AdminPage() { + const [settings, setSettings] = useState(defaultSettings()); + const [stats, setStats] = useState(defaultStats()); + const [endpointList, setEndpointList] = useState([]); + const [webhookList, setWebhookList] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [saving, setSaving] = useState(false); + const [generating, setGenerating] = useState(false); + const [creatingWebhook, setCreatingWebhook] = useState(false); + const [saveMessage, setSaveMessage] = useState(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), + }); + setStats({ ...defaultStats(), ...(statsResp as Partial) }); + 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) { + const updated = { ...settings, ...patch }; + setSettings(updated); + setSaving(true); + setSaveMessage(null); + try { + await admin.updateSettings( + updated as unknown as Record, + ); + 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 ( +
+
+ {/* Header */} +
+

+ Admin +

+

+ System administration and settings. +

+ {saveMessage && ( +

+ {saveMessage} +

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

+ Loading admin data... +

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

{error}

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