mirror of
https://github.com/furyhawk/agent_alpha.git
synced 2026-07-21 10:35:34 +00:00
451 lines
16 KiB
TypeScript
451 lines
16 KiB
TypeScript
import { useState, useEffect } from "react";
|
|
import {
|
|
getAdminStats,
|
|
adminListUsers,
|
|
adminUpdateUser,
|
|
adminListSessions,
|
|
adminDeleteSession,
|
|
type AdminStats,
|
|
type AdminUserData,
|
|
type AdminSessionData,
|
|
} from "./api";
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Props */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
interface AdminProps {
|
|
onBack: () => void;
|
|
}
|
|
|
|
const ROLE_COLORS: Record<string, string> = {
|
|
admin: "text-purple-400",
|
|
user: "text-indigo-400",
|
|
viewer: "text-gray-400",
|
|
};
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Admin Dashboard */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
export default function AdminDashboard({ onBack }: AdminProps) {
|
|
const [tab, setTab] = useState<"overview" | "users" | "sessions">("overview");
|
|
|
|
const [stats, setStats] = useState<AdminStats | null>(null);
|
|
const [users, setUsers] = useState<AdminUserData[]>([]);
|
|
const [sessions, setSessions] = useState<AdminSessionData[]>([]);
|
|
const [busy, setBusy] = useState(true);
|
|
const [error, setError] = useState("");
|
|
|
|
/* ── Load data ────────────────────────────────────────────────────── */
|
|
|
|
const loadAll = async () => {
|
|
setBusy(true);
|
|
setError("");
|
|
try {
|
|
const [s, u, ses] = await Promise.all([
|
|
getAdminStats(),
|
|
adminListUsers(),
|
|
adminListSessions(),
|
|
]);
|
|
setStats(s);
|
|
setUsers(u);
|
|
setSessions(ses);
|
|
} catch (err: unknown) {
|
|
setError(err instanceof Error ? err.message : "Failed to load data");
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
loadAll();
|
|
}, []);
|
|
|
|
/* ── User role toggle ─────────────────────────────────────────────── */
|
|
|
|
const handleRoleChange = async (userId: string, newRole: string) => {
|
|
try {
|
|
await adminUpdateUser(userId, { role: newRole });
|
|
await loadAll();
|
|
} catch (err: unknown) {
|
|
alert(err instanceof Error ? err.message : "Update failed");
|
|
}
|
|
};
|
|
|
|
const handleToggleActive = async (userId: string, current: boolean) => {
|
|
try {
|
|
await adminUpdateUser(userId, { is_active: !current });
|
|
await loadAll();
|
|
} catch (err: unknown) {
|
|
alert(err instanceof Error ? err.message : "Update failed");
|
|
}
|
|
};
|
|
|
|
const handleDeleteSession = async (sessionId: string) => {
|
|
if (!confirm("Delete this session and all its messages?")) return;
|
|
try {
|
|
await adminDeleteSession(sessionId);
|
|
await loadAll();
|
|
} catch (err: unknown) {
|
|
alert(err instanceof Error ? err.message : "Delete failed");
|
|
}
|
|
};
|
|
|
|
/* ── Render ───────────────────────────────────────────────────────── */
|
|
|
|
return (
|
|
<div className="mx-auto flex h-dvh max-w-6xl flex-col">
|
|
{/* Header */}
|
|
<header className="flex items-center gap-3 border-b border-gray-800 px-6 py-4">
|
|
<button
|
|
onClick={onBack}
|
|
className="rounded-lg border border-gray-700 p-2 text-gray-400 transition hover:border-indigo-500 hover:text-indigo-400"
|
|
title="Back to chat"
|
|
>
|
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
|
</svg>
|
|
</button>
|
|
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-gradient-to-br from-purple-600 to-pink-500 text-sm font-bold text-white shadow-lg shadow-purple-500/25">
|
|
A
|
|
</div>
|
|
<div className="flex-1">
|
|
<h1 className="text-lg font-semibold leading-tight tracking-tight">
|
|
Admin Dashboard
|
|
</h1>
|
|
<p className="text-xs text-gray-500">System management</p>
|
|
</div>
|
|
|
|
<button
|
|
onClick={loadAll}
|
|
disabled={busy}
|
|
className="rounded-lg border border-gray-700 px-3 py-1.5 text-sm text-gray-400 transition hover:border-indigo-500 hover:text-indigo-400 disabled:opacity-40"
|
|
>
|
|
Refresh
|
|
</button>
|
|
</header>
|
|
|
|
{/* Tabs */}
|
|
<div className="flex gap-1 border-b border-gray-800 px-6 pt-3">
|
|
{(["overview", "users", "sessions"] as const).map((t) => (
|
|
<button
|
|
key={t}
|
|
onClick={() => setTab(t)}
|
|
className={`rounded-t-lg px-4 py-2 text-sm font-medium capitalize transition ${
|
|
tab === t
|
|
? "border border-b-0 border-gray-700 bg-gray-900 text-gray-100"
|
|
: "text-gray-500 hover:text-gray-300"
|
|
}`}
|
|
>
|
|
{t}
|
|
{t === "users" && stats && (
|
|
<span className="ml-1.5 rounded-full bg-gray-800 px-1.5 py-0.5 text-xs">
|
|
{stats.total_users}
|
|
</span>
|
|
)}
|
|
{t === "sessions" && stats && (
|
|
<span className="ml-1.5 rounded-full bg-gray-800 px-1.5 py-0.5 text-xs">
|
|
{stats.total_sessions}
|
|
</span>
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Body */}
|
|
<div className="flex-1 overflow-y-auto px-6 py-6">
|
|
{error && (
|
|
<div className="mb-4 rounded-lg bg-red-900/40 px-4 py-3 text-sm text-red-400">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{busy && !stats ? (
|
|
<div className="flex items-center justify-center py-20">
|
|
<p className="text-sm text-gray-500">Loading…</p>
|
|
</div>
|
|
) : tab === "overview" ? (
|
|
<OverviewTab stats={stats} />
|
|
) : tab === "users" ? (
|
|
<UsersTab
|
|
users={users}
|
|
onRoleChange={handleRoleChange}
|
|
onToggleActive={handleToggleActive}
|
|
/>
|
|
) : (
|
|
<SessionsTab sessions={sessions} onDelete={handleDeleteSession} />
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Overview Tab */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
function OverviewTab({ stats }: { stats: AdminStats | null }) {
|
|
if (!stats) return null;
|
|
|
|
const cards = [
|
|
{
|
|
label: "Total Users",
|
|
value: stats.total_users,
|
|
color: "from-indigo-500 to-purple-600",
|
|
},
|
|
{
|
|
label: "Active Users",
|
|
value: stats.users_by_active?.active ?? 0,
|
|
color: "from-emerald-500 to-teal-600",
|
|
},
|
|
{
|
|
label: "Chat Sessions",
|
|
value: stats.total_sessions,
|
|
color: "from-amber-500 to-orange-600",
|
|
},
|
|
{
|
|
label: "Admins",
|
|
value: stats.users_by_role?.admin ?? 0,
|
|
color: "from-purple-500 to-pink-600",
|
|
},
|
|
];
|
|
|
|
return (
|
|
<div className="space-y-8">
|
|
{/* Stat cards */}
|
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
|
{cards.map((card) => (
|
|
<div
|
|
key={card.label}
|
|
className="rounded-xl border border-gray-800 bg-gray-900/60 p-5"
|
|
>
|
|
<p className="text-sm text-gray-500">{card.label}</p>
|
|
<p className="mt-1 text-3xl font-bold text-gray-100">
|
|
{card.value}
|
|
</p>
|
|
<div
|
|
className={`mt-3 h-1 w-full rounded-full bg-gradient-to-r ${card.color}`}
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Breakdowns */}
|
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
|
{/* Users by role */}
|
|
<div className="rounded-xl border border-gray-800 bg-gray-900/60 p-5">
|
|
<h3 className="mb-3 text-sm font-semibold text-gray-300">
|
|
Users by Role
|
|
</h3>
|
|
<div className="space-y-2">
|
|
{Object.entries(stats.users_by_role).map(([role, count]) => (
|
|
<div key={role} className="flex items-center gap-3">
|
|
<span className={`w-20 text-sm capitalize ${ROLE_COLORS[role] ?? "text-gray-400"}`}>
|
|
{role}
|
|
</span>
|
|
<div className="flex-1">
|
|
<div className="h-2 rounded-full bg-gray-800">
|
|
<div
|
|
className="h-2 rounded-full bg-gradient-to-r from-indigo-500 to-purple-600"
|
|
style={{
|
|
width: `${stats.total_users > 0 ? (count / stats.total_users) * 100 : 0}%`,
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<span className="w-8 text-right text-sm text-gray-400">
|
|
{count}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Active vs inactive */}
|
|
<div className="rounded-xl border border-gray-800 bg-gray-900/60 p-5">
|
|
<h3 className="mb-3 text-sm font-semibold text-gray-300">
|
|
User Status
|
|
</h3>
|
|
<div className="space-y-2">
|
|
{Object.entries(stats.users_by_active).map(([status, count]) => (
|
|
<div key={status} className="flex items-center gap-3">
|
|
<span className="w-20 text-sm capitalize text-gray-400">
|
|
{status}
|
|
</span>
|
|
<div className="flex-1">
|
|
<div className="h-2 rounded-full bg-gray-800">
|
|
<div
|
|
className={`h-2 rounded-full ${
|
|
status === "active"
|
|
? "bg-gradient-to-r from-emerald-500 to-teal-500"
|
|
: "bg-gradient-to-r from-red-500 to-rose-500"
|
|
}`}
|
|
style={{
|
|
width: `${stats.total_users > 0 ? (count / stats.total_users) * 100 : 0}%`,
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<span className="w-8 text-right text-sm text-gray-400">
|
|
{count}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Users Tab */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
function UsersTab({
|
|
users,
|
|
onRoleChange,
|
|
onToggleActive,
|
|
}: {
|
|
users: AdminUserData[];
|
|
onRoleChange: (id: string, role: string) => void;
|
|
onToggleActive: (id: string, current: boolean) => void;
|
|
}) {
|
|
return (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-left text-sm">
|
|
<thead>
|
|
<tr className="border-b border-gray-800 text-xs uppercase tracking-wider text-gray-500">
|
|
<th className="pb-3 pr-4">User</th>
|
|
<th className="pb-3 pr-4">Role</th>
|
|
<th className="pb-3 pr-4">Team</th>
|
|
<th className="pb-3 pr-4">Status</th>
|
|
<th className="pb-3 pr-4">Sessions</th>
|
|
<th className="pb-3 pr-4">Created</th>
|
|
<th className="pb-3" />
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{users.map((u) => (
|
|
<tr
|
|
key={u.id}
|
|
className="border-b border-gray-800/50 transition hover:bg-gray-800/30"
|
|
>
|
|
<td className="py-3 pr-4">
|
|
<div>
|
|
<p className="font-medium text-gray-200">{u.display_name}</p>
|
|
<p className="text-xs text-gray-500">@{u.username}</p>
|
|
</div>
|
|
</td>
|
|
<td className="py-3 pr-4">
|
|
<select
|
|
value={u.role}
|
|
onChange={(e) => onRoleChange(u.id, e.target.value)}
|
|
className="rounded-lg border border-gray-700 bg-gray-800 px-2 py-1 text-sm text-gray-200 outline-none focus:border-indigo-500"
|
|
>
|
|
<option value="admin">admin</option>
|
|
<option value="user">user</option>
|
|
<option value="viewer">viewer</option>
|
|
</select>
|
|
</td>
|
|
<td className="py-3 pr-4 text-gray-400">{u.team ?? "—"}</td>
|
|
<td className="py-3 pr-4">
|
|
<button
|
|
onClick={() => onToggleActive(u.id, u.is_active)}
|
|
className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${
|
|
u.is_active
|
|
? "bg-emerald-900/50 text-emerald-400"
|
|
: "bg-red-900/50 text-red-400"
|
|
}`}
|
|
>
|
|
{u.is_active ? "Active" : "Inactive"}
|
|
</button>
|
|
</td>
|
|
<td className="py-3 pr-4 text-gray-400">{u.session_count}</td>
|
|
<td className="py-3 pr-4 text-xs text-gray-500">
|
|
{new Date(u.created_at).toLocaleDateString()}
|
|
</td>
|
|
<td className="py-3">
|
|
<span
|
|
className={`inline-block h-2 w-2 rounded-full ${
|
|
ROLE_COLORS[u.role]?.replace("text-", "bg-") ?? "bg-gray-500"
|
|
}`}
|
|
/>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Sessions Tab */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
function SessionsTab({
|
|
sessions,
|
|
onDelete,
|
|
}: {
|
|
sessions: AdminSessionData[];
|
|
onDelete: (id: string) => void;
|
|
}) {
|
|
return (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-left text-sm">
|
|
<thead>
|
|
<tr className="border-b border-gray-800 text-xs uppercase tracking-wider text-gray-500">
|
|
<th className="pb-3 pr-4">Session ID</th>
|
|
<th className="pb-3 pr-4">Messages</th>
|
|
<th className="pb-3 pr-4">User ID</th>
|
|
<th className="pb-3" />
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{sessions.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={4} className="py-8 text-center text-gray-500">
|
|
No sessions yet.
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
sessions.map((s) => (
|
|
<tr
|
|
key={s.session_id}
|
|
className="border-b border-gray-800/50 transition hover:bg-gray-800/30"
|
|
>
|
|
<td className="py-3 pr-4">
|
|
<code className="text-xs text-gray-300">
|
|
{s.session_id.slice(0, 16)}…
|
|
</code>
|
|
</td>
|
|
<td className="py-3 pr-4 text-gray-400">{s.message_count}</td>
|
|
<td className="py-3 pr-4">
|
|
{s.user_id ? (
|
|
<code className="text-xs text-gray-500">
|
|
{s.user_id.slice(0, 12)}…
|
|
</code>
|
|
) : (
|
|
<span className="text-gray-600">—</span>
|
|
)}
|
|
</td>
|
|
<td className="py-3">
|
|
<button
|
|
onClick={() => onDelete(s.session_id)}
|
|
className="rounded-lg px-2 py-1 text-xs text-red-400 transition hover:bg-red-900/30"
|
|
title="Delete session"
|
|
>
|
|
Delete
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
);
|
|
}
|