MAESTRO: Implement Admin page with settings, API keys, stats, and webhook management
This commit is contained in:
parent
30fd15ec7a
commit
74ccc1a8ed
3 changed files with 1211 additions and 5 deletions
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
480
frontend/src/pages/AdminPage.test.tsx
Normal file
480
frontend/src/pages/AdminPage.test.tsx
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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}... · 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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue