Skip to content

Commit 9745b6a

Browse files
serge-the-devclaude
andcommitted
feat(admin): Agents, Activity (errors+audit) views + richer Overview (#31/#33)
Adds the operator views clients most need, over existing data: - GET /v1/admin/overview — one-shot dashboard counts (users, agents+published, active instances, 24h errors, 24h AI calls, 30d spend + platform-paid spend). - GET /v1/admin/agents — all agents (incl. drafts) + owner + active-instance count, searchable/paginated. - GET /v1/admin/instances — all subscriptions across tenants. - GET /v1/admin/errors — cross-user error log (reuses listErrors all=true). - UI: Agents tab (agents + instances tables), Activity tab (error feed + admin audit log), Overview enriched with the one-shot stats. Nav grows to Overview/Users/Agents/Spending/Activity. - 8 new route/aggregation tests; full API suite green (685). Part of the PAGS Admin Portal epic (#27) — advances #31 (agents/instances) and #33 (errors/ops) API + UI. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 965779c commit 9745b6a

7 files changed

Lines changed: 366 additions & 22 deletions

File tree

‎store/admin/src/App.tsx‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
11
import { useEffect, useState } from "react";
2-
import { BarChart3, LayoutDashboard, Users as UsersIcon } from "lucide-react";
2+
import { Activity as ActivityIcon, BarChart3, Bot, LayoutDashboard, Users as UsersIcon } from "lucide-react";
33
import { api, captureOAuthSession, getToken, signIn } from "./lib/api";
44
import Overview from "./pages/Overview";
55
import Users from "./pages/Users";
66
import Spending from "./pages/Spending";
7+
import Agents from "./pages/Agents";
8+
import Activity from "./pages/Activity";
79

8-
type Nav = "overview" | "users" | "spending";
10+
type Nav = "overview" | "users" | "agents" | "spending" | "activity";
911
const NAV: Array<{ id: Nav; label: string; icon: typeof LayoutDashboard }> = [
1012
{ id: "overview", label: "Overview", icon: LayoutDashboard },
1113
{ id: "users", label: "Users", icon: UsersIcon },
14+
{ id: "agents", label: "Agents", icon: Bot },
1215
{ id: "spending", label: "Spending", icon: BarChart3 },
16+
{ id: "activity", label: "Activity", icon: ActivityIcon },
1317
];
1418

1519
type Gate = "loading" | "anon" | "denied" | "ok";
@@ -75,7 +79,9 @@ export default function App() {
7579
<main className="max-w-5xl mx-auto p-4">
7680
{nav === "overview" && <Overview />}
7781
{nav === "users" && <Users />}
82+
{nav === "agents" && <Agents />}
7883
{nav === "spending" && <Spending />}
84+
{nav === "activity" && <Activity />}
7985
</main>
8086
</div>
8187
);

‎store/admin/src/pages/Activity.tsx‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { useEffect, useState } from "react";
2+
import { api } from "../lib/api";
3+
import { Empty, ErrorBox, Loading, Panel } from "../lib/ui";
4+
5+
interface ErrRow { id: string; created_at: string; user_id: string | null; source: string; status: number | null; message: string }
6+
interface AuditRow { id: string; created_at: string; actor_user_id: string; action: string; target_type: string | null; target_id: string | null; detail: string | null }
7+
8+
export default function Activity() {
9+
const [errors, setErrors] = useState<ErrRow[] | null>(null);
10+
const [audit, setAudit] = useState<AuditRow[] | null>(null);
11+
const [err, setErr] = useState("");
12+
13+
useEffect(() => {
14+
api<{ errors: ErrRow[] }>("/v1/admin/errors?limit=100").then((r) => setErrors(r.errors)).catch((e) => setErr(e.message));
15+
api<{ audit: AuditRow[] }>("/v1/admin/audit?limit=100").then((r) => setAudit(r.audit)).catch((e) => setErr(e.message));
16+
}, []);
17+
18+
if (err) return <ErrorBox message={err} />;
19+
20+
return (
21+
<div>
22+
<Panel title={errors ? `Errors (${errors.length})` : "Errors"}>
23+
{!errors ? <Loading /> : !errors.length ? <Empty label="No errors logged. 🎉" /> : (
24+
<div className="space-y-1.5 text-sm">
25+
{errors.map((e) => (
26+
<div key={e.id} className="border-b border-line/50 pb-1.5">
27+
<div className="flex gap-2 text-xs text-muted">
28+
<span>{e.created_at}</span>
29+
<span className="text-accent">{e.source}</span>
30+
{e.status != null && <span className={e.status >= 500 ? "text-red" : "text-yellow"}>{e.status}</span>}
31+
{e.user_id && <span className="truncate max-w-[160px]">{e.user_id}</span>}
32+
</div>
33+
<div className="break-words">{e.message}</div>
34+
</div>
35+
))}
36+
</div>
37+
)}
38+
</Panel>
39+
40+
<Panel title={audit ? `Admin audit log (${audit.length})` : "Admin audit log"}>
41+
{!audit ? <Loading /> : !audit.length ? <Empty label="No admin actions yet." /> : (
42+
<table className="w-full text-sm">
43+
<thead><tr className="text-muted text-xs uppercase text-left border-b border-line">
44+
<th className="py-1.5">When</th><th>Actor</th><th>Action</th><th>Target</th>
45+
</tr></thead>
46+
<tbody>
47+
{audit.map((a) => (
48+
<tr key={a.id} className="border-b border-line/50">
49+
<td className="py-1.5 text-muted">{a.created_at}</td>
50+
<td className="truncate max-w-[160px]">{a.actor_user_id}</td>
51+
<td className="text-accent">{a.action}</td>
52+
<td className="text-muted">{a.target_type ? `${a.target_type}:${a.target_id ?? ""}` : "—"}</td>
53+
</tr>
54+
))}
55+
</tbody>
56+
</table>
57+
)}
58+
</Panel>
59+
</div>
60+
);
61+
}

‎store/admin/src/pages/Agents.tsx‎

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { useEffect, useState } from "react";
2+
import { api } from "../lib/api";
3+
import { Empty, ErrorBox, Loading, Panel } from "../lib/ui";
4+
5+
interface Agent { id: string; slug: string; name: string; category: string; model: string; visibility: string; status: string; owner_login: string | null; instances: number }
6+
interface Instance { id: string; agent_name: string | null; owner_login: string | null; status: string; created_at: string }
7+
8+
const badge = (v: string) => {
9+
const c = v === "published" || v === "active" ? "text-green" : v === "error" ? "text-red" : "text-muted";
10+
return <span className={`text-xs ${c}`}>{v}</span>;
11+
};
12+
13+
export default function Agents() {
14+
const [search, setSearch] = useState("");
15+
const [q, setQ] = useState("");
16+
const [agents, setAgents] = useState<{ agents: Agent[]; total: number } | null>(null);
17+
const [instances, setInstances] = useState<{ instances: Instance[]; total: number } | null>(null);
18+
const [err, setErr] = useState("");
19+
20+
useEffect(() => { const t = setTimeout(() => setQ(search.trim()), 300); return () => clearTimeout(t); }, [search]);
21+
22+
useEffect(() => {
23+
setErr("");
24+
api<{ agents: Agent[]; total: number }>(`/v1/admin/agents${q ? `?search=${encodeURIComponent(q)}` : ""}`).then(setAgents).catch((e) => setErr(e.message));
25+
}, [q]);
26+
useEffect(() => {
27+
api<{ instances: Instance[]; total: number }>("/v1/admin/instances").then(setInstances).catch((e) => setErr(e.message));
28+
}, []);
29+
30+
if (err) return <ErrorBox message={err} />;
31+
32+
return (
33+
<div>
34+
<div className="mb-4"><input placeholder="Search agents by slug / name / owner…" value={search} onChange={(e) => setSearch(e.target.value)} /></div>
35+
36+
<Panel title={agents ? `Agents (${agents.total})` : "Agents"}>
37+
{!agents ? <Loading /> : !agents.agents.length ? <Empty label="No agents." /> : (
38+
<table className="w-full text-sm">
39+
<thead><tr className="text-muted text-xs uppercase text-left border-b border-line">
40+
<th className="py-1.5">Slug</th><th>Name</th><th>Owner</th><th>Category</th><th>Model</th><th>Visibility</th><th className="text-right">Instances</th>
41+
</tr></thead>
42+
<tbody>
43+
{agents.agents.map((a) => (
44+
<tr key={a.id} className="border-b border-line/50">
45+
<td className="py-1.5">{a.slug}</td>
46+
<td className="truncate max-w-[160px]">{a.name}</td>
47+
<td>{a.owner_login || "—"}</td>
48+
<td>{a.category}</td>
49+
<td className="truncate max-w-[140px] text-muted">{a.model || "—"}</td>
50+
<td>{badge(a.visibility)}</td>
51+
<td className="text-right">{a.instances}</td>
52+
</tr>
53+
))}
54+
</tbody>
55+
</table>
56+
)}
57+
</Panel>
58+
59+
<Panel title={instances ? `Instances (${instances.total})` : "Instances"}>
60+
{!instances ? <Loading /> : !instances.instances.length ? <Empty label="No instances." /> : (
61+
<table className="w-full text-sm">
62+
<thead><tr className="text-muted text-xs uppercase text-left border-b border-line">
63+
<th className="py-1.5">Agent</th><th>Owner</th><th>Status</th><th>Created</th>
64+
</tr></thead>
65+
<tbody>
66+
{instances.instances.map((i) => (
67+
<tr key={i.id} className="border-b border-line/50">
68+
<td className="py-1.5">{i.agent_name || "—"}</td>
69+
<td>{i.owner_login || "—"}</td>
70+
<td>{badge(i.status)}</td>
71+
<td className="text-muted">{i.created_at?.slice(0, 10)}</td>
72+
</tr>
73+
))}
74+
</tbody>
75+
</table>
76+
)}
77+
</Panel>
78+
</div>
79+
);
80+
}

‎store/admin/src/pages/Overview.tsx‎

Lines changed: 27 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,46 +2,55 @@ import { useEffect, useState } from "react";
22
import { api, fmtInt, fmtUsd } from "../lib/api";
33
import { ErrorBox, Loading, Panel, Stat, TrendBars } from "../lib/ui";
44

5+
interface Overview {
6+
users: number;
7+
agents: number;
8+
agentsPublished: number;
9+
instancesActive: number;
10+
errors24h: number;
11+
aiCalls24h: number;
12+
spend30dMicros: number;
13+
platformSpend30dMicros: number;
14+
}
515
interface Spending {
6-
totals: { costMicros: number; calls: number };
716
daily: Array<{ date: string; costMicros: number }>;
817
byok: { costMicros: number };
918
platformAiEnabled: boolean;
10-
platformPaid: { costMicros: number };
1119
}
12-
interface UsersResp { total: number }
1320

14-
export default function Overview() {
15-
const [spend, setSpend] = useState<Spending | null>(null);
16-
const [users, setUsers] = useState<UsersResp | null>(null);
21+
export default function OverviewPage() {
22+
const [o, setO] = useState<Overview | null>(null);
23+
const [s, setS] = useState<Spending | null>(null);
1724
const [err, setErr] = useState("");
1825

1926
useEffect(() => {
2027
Promise.all([
28+
api<Overview>("/v1/admin/overview"),
2129
api<Spending>("/v1/admin/spending?range=30d"),
22-
api<UsersResp>("/v1/admin/users?limit=1"),
23-
])
24-
.then(([s, u]) => { setSpend(s); setUsers(u); })
25-
.catch((e) => setErr(e.message));
30+
]).then(([ov, sp]) => { setO(ov); setS(sp); }).catch((e) => setErr(e.message));
2631
}, []);
2732

2833
if (err) return <ErrorBox message={err} />;
29-
if (!spend || !users) return <Loading />;
34+
if (!o || !s) return <Loading />;
3035

3136
return (
3237
<div>
3338
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-4">
34-
<Stat label="Platform-paid AI (30d, est.)" value={fmtUsd(spend.platformPaid.costMicros)} accent />
35-
<Stat label="BYOK spend (30d)" value={fmtUsd(spend.byok.costMicros)} />
36-
<Stat label="AI calls (30d)" value={fmtInt(spend.totals.calls)} />
37-
<Stat label="Users" value={fmtInt(users.total)} />
39+
<Stat label="Users" value={fmtInt(o.users)} />
40+
<Stat label="Agents" value={`${fmtInt(o.agents)} (${o.agentsPublished} pub)`} />
41+
<Stat label="Active instances" value={fmtInt(o.instancesActive)} />
42+
<Stat label="Errors (24h)" value={fmtInt(o.errors24h)} accent={o.errors24h > 0} />
43+
<Stat label="Platform-paid AI (30d, est.)" value={fmtUsd(o.platformSpend30dMicros)} accent />
44+
<Stat label="BYOK spend (30d)" value={fmtUsd(s.byok.costMicros)} />
45+
<Stat label="AI calls (24h)" value={fmtInt(o.aiCalls24h)} />
46+
<Stat label="Platform AI" value={s.platformAiEnabled ? "ON" : "OFF"} />
3847
</div>
3948
<Panel title="AI spend — last 30 days">
40-
<TrendBars points={spend.daily} />
49+
<TrendBars points={s.daily} />
4150
</Panel>
4251
<p className="text-sm text-muted">
43-
Platform-paid AI is currently <strong className={spend.platformAiEnabled ? "text-green" : "text-muted"}>{spend.platformAiEnabled ? "ON" : "OFF"}</strong>.
44-
See <em>Spending</em> for the platform-vs-BYOK breakdown and <em>Users</em> for per-account detail.
52+
Platform-paid AI is <strong className={s.platformAiEnabled ? "text-green" : "text-muted"}>{s.platformAiEnabled ? "ON" : "OFF"}</strong>.
53+
See <em>Spending</em> for the platform-vs-BYOK breakdown, <em>Agents</em> for the catalog, and <em>Activity</em> for errors + the admin audit log.
4554
</p>
4655
</div>
4756
);

‎workers/api/src/lib/admin.ts‎

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,111 @@ export async function listUsers(
106106
return { users: (listRes.results ?? []).map(shapeUser), total: countRes?.n ?? 0 };
107107
}
108108

109+
// ── Agents & Instances (operator cross-tenant views) ───────────────────────
110+
111+
export interface AdminAgentRow {
112+
id: string;
113+
slug: string;
114+
name: string;
115+
category: string;
116+
model: string;
117+
visibility: string;
118+
status: string;
119+
created_at: string;
120+
owner_login: string | null;
121+
instances: number;
122+
}
123+
124+
export async function listAgents(
125+
env: Env,
126+
opts: { search?: string; limit?: number; offset?: number } = {},
127+
): Promise<{ agents: AdminAgentRow[]; total: number }> {
128+
const limit = Math.min(Math.max(opts.limit ?? 50, 1), 200);
129+
const offset = Math.max(opts.offset ?? 0, 0);
130+
const search = opts.search?.trim();
131+
let sql = `SELECT a.id, a.slug, a.name, a.category, a.model, a.visibility, a.status, a.created_at,
132+
u.github_login AS owner_login,
133+
(SELECT COUNT(*) FROM agent_instances i WHERE i.agent_id = a.id AND i.status = 'active') AS instances
134+
FROM agents a LEFT JOIN users u ON u.id = a.owner_id`;
135+
let countSql = "SELECT COUNT(*) AS n FROM agents a";
136+
const binds: unknown[] = [];
137+
const cbinds: unknown[] = [];
138+
if (search) {
139+
const like = `%${search}%`;
140+
const cond = " WHERE a.slug LIKE ? OR a.name LIKE ? OR u.github_login LIKE ?";
141+
sql += cond;
142+
countSql = "SELECT COUNT(*) AS n FROM agents a LEFT JOIN users u ON u.id = a.owner_id" + cond;
143+
binds.push(like, like, like);
144+
cbinds.push(like, like, like);
145+
}
146+
sql += " ORDER BY a.created_at DESC LIMIT ? OFFSET ?";
147+
binds.push(limit, offset);
148+
const [rows, count] = await Promise.all([
149+
env.DB.prepare(sql).bind(...binds).all<AdminAgentRow>(),
150+
env.DB.prepare(countSql).bind(...cbinds).first<{ n: number }>(),
151+
]);
152+
return { agents: rows.results ?? [], total: count?.n ?? 0 };
153+
}
154+
155+
export interface AdminInstanceRow {
156+
id: string;
157+
agent_id: string;
158+
agent_name: string | null;
159+
owner_login: string | null;
160+
status: string;
161+
created_at: string;
162+
}
163+
164+
export async function listInstances(
165+
env: Env,
166+
opts: { limit?: number; offset?: number } = {},
167+
): Promise<{ instances: AdminInstanceRow[]; total: number }> {
168+
const limit = Math.min(Math.max(opts.limit ?? 50, 1), 200);
169+
const offset = Math.max(opts.offset ?? 0, 0);
170+
const [rows, count] = await Promise.all([
171+
env.DB.prepare(
172+
`SELECT i.id, i.agent_id, a.name AS agent_name, u.github_login AS owner_login, i.status, i.created_at
173+
FROM agent_instances i
174+
LEFT JOIN agents a ON a.id = i.agent_id
175+
LEFT JOIN users u ON u.id = i.user_id
176+
ORDER BY i.created_at DESC LIMIT ? OFFSET ?`,
177+
).bind(limit, offset).all<AdminInstanceRow>(),
178+
env.DB.prepare("SELECT COUNT(*) AS n FROM agent_instances").first<{ n: number }>(),
179+
]);
180+
return { instances: rows.results ?? [], total: count?.n ?? 0 };
181+
}
182+
183+
// ── Overview stats (one round-trip for the dashboard header) ────────────────
184+
185+
export interface AdminOverviewStats {
186+
users: number;
187+
agents: number;
188+
agentsPublished: number;
189+
instancesActive: number;
190+
errors24h: number;
191+
aiCalls24h: number;
192+
spend30dMicros: number;
193+
platformSpend30dMicros: number;
194+
}
195+
196+
export async function getOverviewStats(env: Env): Promise<AdminOverviewStats> {
197+
const d1 = sinceTs(1);
198+
const d30 = sinceTs(30);
199+
const one = async (sql: string, ...b: unknown[]) =>
200+
(await env.DB.prepare(sql).bind(...b).first<{ n: number }>())?.n ?? 0;
201+
const [users, agents, agentsPublished, instancesActive, errors24h, aiCalls24h, spend30d, platformSpend30d] = await Promise.all([
202+
one("SELECT COUNT(*) AS n FROM users"),
203+
one("SELECT COUNT(*) AS n FROM agents"),
204+
one("SELECT COUNT(*) AS n FROM agents WHERE visibility = 'published'"),
205+
one("SELECT COUNT(*) AS n FROM agent_instances WHERE status = 'active'"),
206+
one("SELECT COUNT(*) AS n FROM error_log WHERE created_at >= ?", d1),
207+
one("SELECT COUNT(*) AS n FROM ai_usage WHERE created_at >= ?", d1),
208+
one("SELECT COALESCE(SUM(cost_micros),0) AS n FROM ai_usage WHERE created_at >= ?", d30),
209+
one("SELECT COALESCE(SUM(cost_micros),0) AS n FROM ai_usage WHERE provider = 'platform' AND created_at >= ?", d30),
210+
]);
211+
return { users, agents, agentsPublished, instancesActive, errors24h, aiCalls24h, spend30dMicros: spend30d, platformSpend30dMicros: platformSpend30d };
212+
}
213+
109214
export interface AdminUserDetail {
110215
user: AdminUserRow;
111216
agents: Array<{ id: string; slug: string; name: string; visibility: string; status: string; created_at: string }>;

0 commit comments

Comments
 (0)