- Enable Tailwind darkMode: 'class' with system preference detection - Add shared Layout component with sidebar navigation, dark mode toggle, and mobile hamburger menu - Add global CSS: focus-visible rings, smooth transitions, custom scrollbar, entrance animations - Update all authenticated pages to use Layout wrapper via App.tsx route restructure - Responsive improvements: stack headers on mobile, responsive padding, modal safe areas - Add fade-in animations for stat cards and scale-in for modals - 11 tests added for Layout component. All 430 tests pass.
733 lines
24 KiB
TypeScript
733 lines
24 KiB
TypeScript
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="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-full bg-gradient-to-br from-indigo-50/50 to-slate-100/50 dark:from-slate-900/50 dark:to-slate-800/50 px-4 py-8 sm:px-6 lg:px-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>
|
|
);
|
|
}
|