All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
532 lines
18 KiB
TypeScript
532 lines
18 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useState, useCallback } from 'react';
|
|
import { AdminRoleGuard } from '@/components/admin-role-guard';
|
|
import { api } from '@/lib/api';
|
|
import { cn } from '@/lib/cn';
|
|
|
|
// ── Types ──────────────────────────────────────────────────────────────────────
|
|
|
|
interface UserDto {
|
|
id: string;
|
|
name: string;
|
|
email: string;
|
|
role: string;
|
|
banned: boolean;
|
|
banReason: string | null;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
}
|
|
|
|
interface UserListDto {
|
|
users: UserDto[];
|
|
total: number;
|
|
}
|
|
|
|
interface ServiceStatusDto {
|
|
status: 'ok' | 'error';
|
|
latencyMs?: number;
|
|
error?: string;
|
|
}
|
|
|
|
interface ProviderStatusDto {
|
|
id: string;
|
|
name: string;
|
|
available: boolean;
|
|
modelCount: number;
|
|
}
|
|
|
|
interface HealthStatusDto {
|
|
status: 'ok' | 'degraded' | 'error';
|
|
database: ServiceStatusDto;
|
|
cache: ServiceStatusDto;
|
|
agentPool: { activeSessions: number };
|
|
providers: ProviderStatusDto[];
|
|
checkedAt: string;
|
|
}
|
|
|
|
// ── Admin Page ─────────────────────────────────────────────────────────────────
|
|
|
|
export default function AdminPage(): React.ReactElement {
|
|
return (
|
|
<AdminRoleGuard>
|
|
<AdminContent />
|
|
</AdminRoleGuard>
|
|
);
|
|
}
|
|
|
|
function AdminContent(): React.ReactElement {
|
|
const [activeTab, setActiveTab] = useState<'users' | 'health'>('users');
|
|
|
|
return (
|
|
<div className="mx-auto max-w-5xl space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<h1 className="text-2xl font-semibold text-text-primary">Admin Panel</h1>
|
|
</div>
|
|
|
|
<div className="flex gap-1 border-b border-surface-border">
|
|
{(['users', 'health'] as const).map((tab) => (
|
|
<button
|
|
key={tab}
|
|
type="button"
|
|
onClick={() => setActiveTab(tab)}
|
|
className={cn(
|
|
'px-4 py-2 text-sm font-medium capitalize transition-colors',
|
|
activeTab === tab
|
|
? 'border-b-2 border-blue-500 text-blue-400'
|
|
: 'text-text-secondary hover:text-text-primary',
|
|
)}
|
|
>
|
|
{tab === 'users' ? 'User Management' : 'System Health'}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{activeTab === 'users' ? <UsersTab /> : <HealthTab />}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Users Tab ──────────────────────────────────────────────────────────────────
|
|
|
|
function UsersTab(): React.ReactElement {
|
|
const [users, setUsers] = useState<UserDto[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [showCreate, setShowCreate] = useState(false);
|
|
|
|
const loadUsers = useCallback(async () => {
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const data = await api<UserListDto>('/api/admin/users');
|
|
setUsers(data.users);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to load users');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
void loadUsers();
|
|
}, [loadUsers]);
|
|
|
|
async function handleRoleToggle(user: UserDto): Promise<void> {
|
|
const newRole = user.role === 'admin' ? 'member' : 'admin';
|
|
try {
|
|
await api(`/api/admin/users/${user.id}/role`, {
|
|
method: 'PATCH',
|
|
body: { role: newRole },
|
|
});
|
|
await loadUsers();
|
|
} catch (err) {
|
|
alert(err instanceof Error ? err.message : 'Failed to update role');
|
|
}
|
|
}
|
|
|
|
async function handleBanToggle(user: UserDto): Promise<void> {
|
|
const endpoint = user.banned ? 'unban' : 'ban';
|
|
try {
|
|
await api(`/api/admin/users/${user.id}/${endpoint}`, { method: 'POST' });
|
|
await loadUsers();
|
|
} catch (err) {
|
|
alert(err instanceof Error ? err.message : 'Failed to update ban status');
|
|
}
|
|
}
|
|
|
|
async function handleDelete(user: UserDto): Promise<void> {
|
|
if (!confirm(`Delete user ${user.email}? This cannot be undone.`)) return;
|
|
try {
|
|
await api(`/api/admin/users/${user.id}`, { method: 'DELETE' });
|
|
await loadUsers();
|
|
} catch (err) {
|
|
alert(err instanceof Error ? err.message : 'Failed to delete user');
|
|
}
|
|
}
|
|
|
|
if (loading) {
|
|
return <p className="text-sm text-text-muted">Loading users...</p>;
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="rounded-lg border border-red-500/30 bg-red-500/10 p-4">
|
|
<p className="text-sm text-red-400">{error}</p>
|
|
<button
|
|
type="button"
|
|
onClick={() => void loadUsers()}
|
|
className="mt-2 text-xs text-red-300 underline hover:no-underline"
|
|
>
|
|
Retry
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<p className="text-sm text-text-muted">{users.length} user(s)</p>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowCreate(true)}
|
|
className="rounded-md bg-blue-600 px-3 py-1.5 text-sm text-white transition-colors hover:bg-blue-700"
|
|
>
|
|
+ New User
|
|
</button>
|
|
</div>
|
|
|
|
{showCreate && (
|
|
<CreateUserForm
|
|
onCancel={() => setShowCreate(false)}
|
|
onCreated={() => {
|
|
setShowCreate(false);
|
|
void loadUsers();
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{users.length === 0 ? (
|
|
<div className="rounded-lg border border-surface-border bg-surface-card p-6 text-center">
|
|
<p className="text-sm text-text-muted">No users found</p>
|
|
</div>
|
|
) : (
|
|
<div className="overflow-hidden rounded-lg border border-surface-border">
|
|
<table className="w-full">
|
|
<thead>
|
|
<tr className="border-b border-surface-border bg-surface-elevated text-left text-xs text-text-muted">
|
|
<th className="px-4 py-2 font-medium">Name / Email</th>
|
|
<th className="px-4 py-2 font-medium">Role</th>
|
|
<th className="hidden px-4 py-2 font-medium md:table-cell">Status</th>
|
|
<th className="hidden px-4 py-2 font-medium md:table-cell">Created</th>
|
|
<th className="px-4 py-2 font-medium">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{users.map((user) => (
|
|
<tr key={user.id} className="border-b border-surface-border last:border-b-0">
|
|
<td className="px-4 py-3">
|
|
<div className="text-sm font-medium text-text-primary">{user.name}</div>
|
|
<div className="text-xs text-text-muted">{user.email}</div>
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<span
|
|
className={cn(
|
|
'inline-flex rounded-full px-2 py-0.5 text-xs font-medium',
|
|
user.role === 'admin'
|
|
? 'bg-purple-500/20 text-purple-400'
|
|
: 'bg-surface-elevated text-text-secondary',
|
|
)}
|
|
>
|
|
{user.role}
|
|
</span>
|
|
</td>
|
|
<td className="hidden px-4 py-3 md:table-cell">
|
|
{user.banned ? (
|
|
<span className="inline-flex rounded-full bg-red-500/20 px-2 py-0.5 text-xs font-medium text-red-400">
|
|
Banned
|
|
</span>
|
|
) : (
|
|
<span className="inline-flex rounded-full bg-green-500/20 px-2 py-0.5 text-xs font-medium text-green-400">
|
|
Active
|
|
</span>
|
|
)}
|
|
</td>
|
|
<td className="hidden px-4 py-3 text-xs text-text-muted md:table-cell">
|
|
{new Date(user.createdAt).toLocaleDateString()}
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => void handleRoleToggle(user)}
|
|
className="text-xs text-blue-400 hover:text-blue-300"
|
|
title={user.role === 'admin' ? 'Demote to member' : 'Promote to admin'}
|
|
>
|
|
{user.role === 'admin' ? 'Demote' : 'Promote'}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => void handleBanToggle(user)}
|
|
className={cn(
|
|
'text-xs',
|
|
user.banned
|
|
? 'text-green-400 hover:text-green-300'
|
|
: 'text-yellow-400 hover:text-yellow-300',
|
|
)}
|
|
>
|
|
{user.banned ? 'Unban' : 'Ban'}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => void handleDelete(user)}
|
|
className="text-xs text-red-400 hover:text-red-300"
|
|
>
|
|
Delete
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Create User Form ──────────────────────────────────────────────────────────
|
|
|
|
interface CreateUserFormProps {
|
|
onCancel: () => void;
|
|
onCreated: () => void;
|
|
}
|
|
|
|
function CreateUserForm({ onCancel, onCreated }: CreateUserFormProps): React.ReactElement {
|
|
const [name, setName] = useState('');
|
|
const [email, setEmail] = useState('');
|
|
const [password, setPassword] = useState('');
|
|
const [role, setRole] = useState('member');
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
async function handleSubmit(e: React.FormEvent): Promise<void> {
|
|
e.preventDefault();
|
|
setSubmitting(true);
|
|
setError(null);
|
|
try {
|
|
await api('/api/admin/users', {
|
|
method: 'POST',
|
|
body: { name, email, password, role },
|
|
});
|
|
onCreated();
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to create user');
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="rounded-lg border border-surface-border bg-surface-card p-4">
|
|
<h3 className="mb-3 text-sm font-medium text-text-primary">Create New User</h3>
|
|
<form onSubmit={(e) => void handleSubmit(e)} className="space-y-3">
|
|
{error && <p className="text-xs text-red-400">{error}</p>}
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<label className="mb-1 block text-xs text-text-muted">Name</label>
|
|
<input
|
|
type="text"
|
|
required
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
className="w-full rounded-md border border-surface-border bg-surface-elevated px-3 py-1.5 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-blue-500"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="mb-1 block text-xs text-text-muted">Email</label>
|
|
<input
|
|
type="email"
|
|
required
|
|
value={email}
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
className="w-full rounded-md border border-surface-border bg-surface-elevated px-3 py-1.5 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-blue-500"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="mb-1 block text-xs text-text-muted">Password</label>
|
|
<input
|
|
type="password"
|
|
required
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
className="w-full rounded-md border border-surface-border bg-surface-elevated px-3 py-1.5 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-blue-500"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="mb-1 block text-xs text-text-muted">Role</label>
|
|
<select
|
|
value={role}
|
|
onChange={(e) => setRole(e.target.value)}
|
|
className="w-full rounded-md border border-surface-border bg-surface-elevated px-3 py-1.5 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-blue-500"
|
|
>
|
|
<option value="member">member</option>
|
|
<option value="admin">admin</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div className="flex justify-end gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={onCancel}
|
|
className="rounded-md px-3 py-1.5 text-sm text-text-muted hover:text-text-primary"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
disabled={submitting}
|
|
className="rounded-md bg-blue-600 px-3 py-1.5 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
|
|
>
|
|
{submitting ? 'Creating...' : 'Create'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Health Tab ────────────────────────────────────────────────────────────────
|
|
|
|
function HealthTab(): React.ReactElement {
|
|
const [health, setHealth] = useState<HealthStatusDto | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const loadHealth = useCallback(async () => {
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const data = await api<HealthStatusDto>('/api/admin/health');
|
|
setHealth(data);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to load health');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
void loadHealth();
|
|
}, [loadHealth]);
|
|
|
|
if (loading) {
|
|
return <p className="text-sm text-text-muted">Loading health status...</p>;
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="rounded-lg border border-red-500/30 bg-red-500/10 p-4">
|
|
<p className="text-sm text-red-400">{error}</p>
|
|
<button
|
|
type="button"
|
|
onClick={() => void loadHealth()}
|
|
className="mt-2 text-xs text-red-300 underline hover:no-underline"
|
|
>
|
|
Retry
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!health) return <></>;
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<StatusBadge status={health.status} />
|
|
<span className="text-sm text-text-muted">
|
|
Last checked: {new Date(health.checkedAt).toLocaleTimeString()}
|
|
</span>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => void loadHealth()}
|
|
className="text-xs text-blue-400 hover:text-blue-300"
|
|
>
|
|
Refresh
|
|
</button>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
{/* Database */}
|
|
<HealthCard title="Database (PostgreSQL)" status={health.database.status}>
|
|
{health.database.latencyMs !== undefined && (
|
|
<p className="text-xs text-text-muted">Latency: {health.database.latencyMs}ms</p>
|
|
)}
|
|
{health.database.error && <p className="text-xs text-red-400">{health.database.error}</p>}
|
|
</HealthCard>
|
|
|
|
{/* Cache */}
|
|
<HealthCard title="Cache (Valkey)" status={health.cache.status}>
|
|
{health.cache.latencyMs !== undefined && (
|
|
<p className="text-xs text-text-muted">Latency: {health.cache.latencyMs}ms</p>
|
|
)}
|
|
{health.cache.error && <p className="text-xs text-red-400">{health.cache.error}</p>}
|
|
</HealthCard>
|
|
|
|
{/* Agent Pool */}
|
|
<HealthCard title="Agent Pool" status="ok">
|
|
<p className="text-xs text-text-muted">
|
|
Active sessions: {health.agentPool.activeSessions}
|
|
</p>
|
|
</HealthCard>
|
|
|
|
{/* Providers */}
|
|
<HealthCard
|
|
title="LLM Providers"
|
|
status={health.providers.some((p) => p.available) ? 'ok' : 'error'}
|
|
>
|
|
{health.providers.length === 0 ? (
|
|
<p className="text-xs text-text-muted">No providers configured</p>
|
|
) : (
|
|
<ul className="space-y-1">
|
|
{health.providers.map((p) => (
|
|
<li key={p.id} className="flex items-center justify-between text-xs">
|
|
<span className="text-text-secondary">{p.name}</span>
|
|
<span
|
|
className={cn(
|
|
'rounded-full px-1.5 py-0.5',
|
|
p.available ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400',
|
|
)}
|
|
>
|
|
{p.available ? `${p.modelCount} models` : 'unavailable'}
|
|
</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</HealthCard>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Helper Components ─────────────────────────────────────────────────────────
|
|
|
|
function StatusBadge({ status }: { status: 'ok' | 'degraded' | 'error' }): React.ReactElement {
|
|
const map = {
|
|
ok: 'bg-green-500/20 text-green-400',
|
|
degraded: 'bg-yellow-500/20 text-yellow-400',
|
|
error: 'bg-red-500/20 text-red-400',
|
|
};
|
|
return (
|
|
<span className={cn('rounded-full px-2 py-0.5 text-xs font-medium capitalize', map[status])}>
|
|
{status}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
interface HealthCardProps {
|
|
title: string;
|
|
status: 'ok' | 'error';
|
|
children?: React.ReactNode;
|
|
}
|
|
|
|
function HealthCard({ title, status, children }: HealthCardProps): React.ReactElement {
|
|
return (
|
|
<div className="rounded-lg border border-surface-border bg-surface-card p-4">
|
|
<div className="mb-2 flex items-center justify-between">
|
|
<h3 className="text-sm font-medium text-text-primary">{title}</h3>
|
|
<span
|
|
className={cn('h-2 w-2 rounded-full', status === 'ok' ? 'bg-green-400' : 'bg-red-400')}
|
|
/>
|
|
</div>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|