Skip to content

Commit 780f4c4

Browse files
gcmsgclaude
andcommitted
feat: implement complete admin dashboard (Phase 8)
Add full admin backend and frontend for system management: Backend: AdminOnlyMiddleware, 20 admin API handlers for user management, agent management (verify/unverify/delete), report moderation, category CRUD, global analytics, and invocation logs. Extended UserAuth, Review, Invocation, and Reputation store/service layers with admin-specific queries (ListUsers, ListReports, GlobalStats, TopAgents, etc). Frontend: AdminGuard, admin API client, data hooks, 7-link sidebar, and 8 admin pages (Overview, Users, Agents, AgentDetail, Reports, Categories, Analytics, Invocations) with search, filtering, pagination, and inline CRUD operations. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent f4961ae commit 780f4c4

33 files changed

+3746
-76
lines changed

internal/invocation/postgres.go

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,139 @@ func (s *PostgresStore) ProviderDashboardStats(ctx context.Context, ownerUserID
196196
return &stats, nil
197197
}
198198

199+
func (s *PostgresStore) ListAll(ctx context.Context, agentID, userID string, limit, offset int) ([]InvocationRecord, int, error) {
200+
if limit <= 0 {
201+
limit = 50
202+
}
203+
204+
where := "1=1"
205+
var args []interface{}
206+
argN := 1
207+
if agentID != "" {
208+
where += fmt.Sprintf(" AND agent_id = $%d", argN)
209+
args = append(args, agentID)
210+
argN++
211+
}
212+
if userID != "" {
213+
where += fmt.Sprintf(" AND user_id = $%d", argN)
214+
args = append(args, userID)
215+
argN++
216+
}
217+
218+
var total int
219+
countArgs := make([]interface{}, len(args))
220+
copy(countArgs, args)
221+
if err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM invocations WHERE "+where, countArgs...).Scan(&total); err != nil {
222+
return nil, 0, err
223+
}
224+
225+
args = append(args, limit, offset)
226+
rows, err := s.db.QueryContext(ctx,
227+
fmt.Sprintf("SELECT id, agent_id, user_id, protocol, status_code, duration_ms, error, ip_address, created_at FROM invocations WHERE %s ORDER BY created_at DESC LIMIT $%d OFFSET $%d", where, argN, argN+1),
228+
args...,
229+
)
230+
if err != nil {
231+
return nil, 0, err
232+
}
233+
defer func() { _ = rows.Close() }()
234+
235+
var records []InvocationRecord
236+
for rows.Next() {
237+
var r InvocationRecord
238+
if err := rows.Scan(&r.ID, &r.AgentID, &r.UserID, &r.Protocol, &r.StatusCode, &r.DurationMs, &r.Error, &r.IPAddress, &r.CreatedAt); err != nil {
239+
return nil, 0, err
240+
}
241+
records = append(records, r)
242+
}
243+
return records, total, rows.Err()
244+
}
245+
246+
func (s *PostgresStore) GlobalStats(ctx context.Context, since time.Time) (*AgentInvocationStats, error) {
247+
var stats AgentInvocationStats
248+
err := s.db.QueryRowContext(ctx,
249+
`SELECT COUNT(*),
250+
SUM(CASE WHEN status_code >= 200 AND status_code < 400 THEN 1 ELSE 0 END),
251+
SUM(CASE WHEN status_code >= 400 OR error != '' THEN 1 ELSE 0 END),
252+
COALESCE(AVG(duration_ms), 0)
253+
FROM invocations WHERE created_at >= $1`,
254+
since.UTC(),
255+
).Scan(&stats.TotalCalls, &stats.SuccessCalls, &stats.ErrorCalls, &stats.AvgDurationMs)
256+
if err != nil {
257+
return nil, err
258+
}
259+
return &stats, nil
260+
}
261+
262+
func (s *PostgresStore) GlobalTimeSeries(ctx context.Context, since time.Time, bucketMinutes int) ([]TimeSeriesPoint, error) {
263+
if bucketMinutes <= 0 {
264+
bucketMinutes = 60
265+
}
266+
rows, err := s.db.QueryContext(ctx,
267+
fmt.Sprintf(`SELECT
268+
date_trunc('hour', created_at) + (EXTRACT(minute FROM created_at)::int / %d * %d) * interval '1 minute' as bucket,
269+
COUNT(*),
270+
SUM(CASE WHEN status_code >= 200 AND status_code < 400 THEN 1 ELSE 0 END),
271+
SUM(CASE WHEN status_code >= 400 OR error != '' THEN 1 ELSE 0 END),
272+
COALESCE(AVG(duration_ms), 0)
273+
FROM invocations WHERE created_at >= $1
274+
GROUP BY bucket ORDER BY bucket`, bucketMinutes, bucketMinutes),
275+
since.UTC(),
276+
)
277+
if err != nil {
278+
return nil, err
279+
}
280+
defer func() { _ = rows.Close() }()
281+
282+
var points []TimeSeriesPoint
283+
for rows.Next() {
284+
var p TimeSeriesPoint
285+
if err := rows.Scan(&p.Timestamp, &p.TotalCalls, &p.SuccessCalls, &p.ErrorCalls, &p.AvgDurationMs); err != nil {
286+
return nil, err
287+
}
288+
points = append(points, p)
289+
}
290+
return points, rows.Err()
291+
}
292+
293+
func (s *PostgresStore) TopAgents(ctx context.Context, since time.Time, limit int) ([]AgentCallStats, error) {
294+
if limit <= 0 {
295+
limit = 10
296+
}
297+
rows, err := s.db.QueryContext(ctx,
298+
`SELECT i.agent_id, COALESCE(a.name, i.agent_id),
299+
COUNT(*),
300+
SUM(CASE WHEN i.status_code >= 200 AND i.status_code < 400 THEN 1 ELSE 0 END),
301+
SUM(CASE WHEN i.status_code >= 400 OR i.error != '' THEN 1 ELSE 0 END),
302+
COALESCE(AVG(i.duration_ms), 0)
303+
FROM invocations i
304+
LEFT JOIN agents a ON a.id = i.agent_id
305+
WHERE i.created_at >= $1
306+
GROUP BY i.agent_id, a.name
307+
ORDER BY COUNT(*) DESC LIMIT $2`,
308+
since.UTC(), limit,
309+
)
310+
if err != nil {
311+
return nil, err
312+
}
313+
defer func() { _ = rows.Close() }()
314+
315+
var stats []AgentCallStats
316+
for rows.Next() {
317+
var s AgentCallStats
318+
if err := rows.Scan(&s.AgentID, &s.AgentName, &s.TotalCalls, &s.SuccessCalls, &s.ErrorCalls, &s.AvgDurationMs); err != nil {
319+
return nil, err
320+
}
321+
stats = append(stats, s)
322+
}
323+
return stats, rows.Err()
324+
}
325+
326+
func (s *PostgresStore) CountInvocations(ctx context.Context) (int, error) {
327+
var count int
328+
err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM invocations").Scan(&count)
329+
return count, err
330+
}
331+
199332
func (s *PostgresStore) Close() error {
200333
return nil
201334
}

internal/invocation/service.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,3 +54,28 @@ func (s *Service) AgentTimeSeries(ctx context.Context, agentID string, since tim
5454
func (s *Service) ProviderDashboardStats(ctx context.Context, ownerUserID string) (*AgentInvocationStats, error) {
5555
return s.store.ProviderDashboardStats(ctx, ownerUserID)
5656
}
57+
58+
// ListAll returns all invocations with optional agent/user filters.
59+
func (s *Service) ListAll(ctx context.Context, agentID, userID string, limit, offset int) ([]InvocationRecord, int, error) {
60+
return s.store.ListAll(ctx, agentID, userID, limit, offset)
61+
}
62+
63+
// GlobalStats returns aggregated stats across all agents.
64+
func (s *Service) GlobalStats(ctx context.Context, since time.Time) (*AgentInvocationStats, error) {
65+
return s.store.GlobalStats(ctx, since)
66+
}
67+
68+
// GlobalTimeSeries returns time-bucketed invocation data across all agents.
69+
func (s *Service) GlobalTimeSeries(ctx context.Context, since time.Time, bucketMinutes int) ([]TimeSeriesPoint, error) {
70+
return s.store.GlobalTimeSeries(ctx, since, bucketMinutes)
71+
}
72+
73+
// TopAgents returns the top agents by call count.
74+
func (s *Service) TopAgents(ctx context.Context, since time.Time, limit int) ([]AgentCallStats, error) {
75+
return s.store.TopAgents(ctx, since, limit)
76+
}
77+
78+
// CountInvocations returns the total number of invocations.
79+
func (s *Service) CountInvocations(ctx context.Context) (int, error) {
80+
return s.store.CountInvocations(ctx)
81+
}

internal/invocation/sqlite.go

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,140 @@ func (s *SQLiteStore) ProviderDashboardStats(ctx context.Context, ownerUserID st
204204
return &stats, nil
205205
}
206206

207+
func (s *SQLiteStore) ListAll(ctx context.Context, agentID, userID string, limit, offset int) ([]InvocationRecord, int, error) {
208+
if limit <= 0 {
209+
limit = 50
210+
}
211+
212+
where := "1=1"
213+
var args []interface{}
214+
if agentID != "" {
215+
where += " AND agent_id = ?"
216+
args = append(args, agentID)
217+
}
218+
if userID != "" {
219+
where += " AND user_id = ?"
220+
args = append(args, userID)
221+
}
222+
223+
var total int
224+
countArgs := make([]interface{}, len(args))
225+
copy(countArgs, args)
226+
if err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM invocations WHERE "+where, countArgs...).Scan(&total); err != nil {
227+
return nil, 0, err
228+
}
229+
230+
args = append(args, limit, offset)
231+
rows, err := s.db.QueryContext(ctx,
232+
"SELECT id, agent_id, user_id, protocol, status_code, duration_ms, error, ip_address, created_at FROM invocations WHERE "+where+" ORDER BY created_at DESC LIMIT ? OFFSET ?",
233+
args...,
234+
)
235+
if err != nil {
236+
return nil, 0, err
237+
}
238+
defer func() { _ = rows.Close() }()
239+
240+
var records []InvocationRecord
241+
for rows.Next() {
242+
var r InvocationRecord
243+
var createdAt string
244+
if err := rows.Scan(&r.ID, &r.AgentID, &r.UserID, &r.Protocol, &r.StatusCode, &r.DurationMs, &r.Error, &r.IPAddress, &createdAt); err != nil {
245+
return nil, 0, err
246+
}
247+
r.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
248+
records = append(records, r)
249+
}
250+
return records, total, rows.Err()
251+
}
252+
253+
func (s *SQLiteStore) GlobalStats(ctx context.Context, since time.Time) (*AgentInvocationStats, error) {
254+
var stats AgentInvocationStats
255+
err := s.db.QueryRowContext(ctx,
256+
`SELECT COUNT(*),
257+
SUM(CASE WHEN status_code >= 200 AND status_code < 400 THEN 1 ELSE 0 END),
258+
SUM(CASE WHEN status_code >= 400 OR error != '' THEN 1 ELSE 0 END),
259+
COALESCE(AVG(duration_ms), 0)
260+
FROM invocations WHERE created_at >= ?`,
261+
since.UTC().Format(time.RFC3339),
262+
).Scan(&stats.TotalCalls, &stats.SuccessCalls, &stats.ErrorCalls, &stats.AvgDurationMs)
263+
if err != nil {
264+
return nil, err
265+
}
266+
return &stats, nil
267+
}
268+
269+
func (s *SQLiteStore) GlobalTimeSeries(ctx context.Context, since time.Time, bucketMinutes int) ([]TimeSeriesPoint, error) {
270+
if bucketMinutes <= 0 {
271+
bucketMinutes = 60
272+
}
273+
rows, err := s.db.QueryContext(ctx,
274+
fmt.Sprintf(`SELECT
275+
strftime('%%Y-%%m-%%dT%%H:%%M:00Z', created_at, 'utc', '-' || (strftime('%%M', created_at) %% %d) || ' minutes') as bucket,
276+
COUNT(*),
277+
SUM(CASE WHEN status_code >= 200 AND status_code < 400 THEN 1 ELSE 0 END),
278+
SUM(CASE WHEN status_code >= 400 OR error != '' THEN 1 ELSE 0 END),
279+
COALESCE(AVG(duration_ms), 0)
280+
FROM invocations WHERE created_at >= ?
281+
GROUP BY bucket ORDER BY bucket`, bucketMinutes),
282+
since.UTC().Format(time.RFC3339),
283+
)
284+
if err != nil {
285+
return nil, err
286+
}
287+
defer func() { _ = rows.Close() }()
288+
289+
var points []TimeSeriesPoint
290+
for rows.Next() {
291+
var p TimeSeriesPoint
292+
var ts string
293+
if err := rows.Scan(&ts, &p.TotalCalls, &p.SuccessCalls, &p.ErrorCalls, &p.AvgDurationMs); err != nil {
294+
return nil, err
295+
}
296+
p.Timestamp, _ = time.Parse(time.RFC3339, ts)
297+
points = append(points, p)
298+
}
299+
return points, rows.Err()
300+
}
301+
302+
func (s *SQLiteStore) TopAgents(ctx context.Context, since time.Time, limit int) ([]AgentCallStats, error) {
303+
if limit <= 0 {
304+
limit = 10
305+
}
306+
rows, err := s.db.QueryContext(ctx,
307+
`SELECT i.agent_id, COALESCE(a.name, i.agent_id),
308+
COUNT(*),
309+
SUM(CASE WHEN i.status_code >= 200 AND i.status_code < 400 THEN 1 ELSE 0 END),
310+
SUM(CASE WHEN i.status_code >= 400 OR i.error != '' THEN 1 ELSE 0 END),
311+
COALESCE(AVG(i.duration_ms), 0)
312+
FROM invocations i
313+
LEFT JOIN agents a ON a.id = i.agent_id
314+
WHERE i.created_at >= ?
315+
GROUP BY i.agent_id
316+
ORDER BY COUNT(*) DESC LIMIT ?`,
317+
since.UTC().Format(time.RFC3339), limit,
318+
)
319+
if err != nil {
320+
return nil, err
321+
}
322+
defer func() { _ = rows.Close() }()
323+
324+
var stats []AgentCallStats
325+
for rows.Next() {
326+
var s AgentCallStats
327+
if err := rows.Scan(&s.AgentID, &s.AgentName, &s.TotalCalls, &s.SuccessCalls, &s.ErrorCalls, &s.AvgDurationMs); err != nil {
328+
return nil, err
329+
}
330+
stats = append(stats, s)
331+
}
332+
return stats, rows.Err()
333+
}
334+
335+
func (s *SQLiteStore) CountInvocations(ctx context.Context) (int, error) {
336+
var count int
337+
err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM invocations").Scan(&count)
338+
return count, err
339+
}
340+
207341
func (s *SQLiteStore) Close() error {
208342
return nil
209343
}

internal/invocation/store.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,16 @@ type TimeSeriesPoint struct {
3838
AvgDurationMs float64 `json:"avg_duration_ms"`
3939
}
4040

41+
// AgentCallStats holds per-agent call statistics for admin top-agents report.
42+
type AgentCallStats struct {
43+
AgentID string `json:"agent_id"`
44+
AgentName string `json:"agent_name"`
45+
TotalCalls int64 `json:"total_calls"`
46+
SuccessCalls int64 `json:"success_calls"`
47+
ErrorCalls int64 `json:"error_calls"`
48+
AvgDurationMs float64 `json:"avg_duration_ms"`
49+
}
50+
4151
// Store defines the persistence interface for invocation records.
4252
type Store interface {
4353
// Insert records a new invocation.
@@ -61,6 +71,21 @@ type Store interface {
6171
// ProviderDashboardStats returns aggregated stats for all agents owned by a user.
6272
ProviderDashboardStats(ctx context.Context, ownerUserID string) (*AgentInvocationStats, error)
6373

74+
// ListAll returns all invocations with optional agent/user filters.
75+
ListAll(ctx context.Context, agentID, userID string, limit, offset int) ([]InvocationRecord, int, error)
76+
77+
// GlobalStats returns aggregated stats across all agents since the given time.
78+
GlobalStats(ctx context.Context, since time.Time) (*AgentInvocationStats, error)
79+
80+
// GlobalTimeSeries returns time-bucketed invocation data across all agents.
81+
GlobalTimeSeries(ctx context.Context, since time.Time, bucketMinutes int) ([]TimeSeriesPoint, error)
82+
83+
// TopAgents returns the top agents by call count since the given time.
84+
TopAgents(ctx context.Context, since time.Time, limit int) ([]AgentCallStats, error)
85+
86+
// CountInvocations returns the total number of invocations.
87+
CountInvocations(ctx context.Context) (int, error)
88+
6489
// Migrate creates the required tables.
6590
Migrate(ctx context.Context) error
6691

internal/reputation/engine.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,3 +125,8 @@ func (e *Engine) SetVerified(ctx context.Context, agentID string) error {
125125
}
126126
return e.RecordEvent(ctx, agentID, EventVerificationPass, "")
127127
}
128+
129+
// UnsetVerified removes the verified status from an agent.
130+
func (e *Engine) UnsetVerified(ctx context.Context, agentID string) error {
131+
return e.store.UnsetAgentVerified(ctx, agentID)
132+
}

internal/reputation/postgres.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,15 @@ func (s *PostgresStore) SetAgentVerified(ctx context.Context, agentID string) er
149149
return err
150150
}
151151

152+
// UnsetAgentVerified removes the verified status from an agent.
153+
func (s *PostgresStore) UnsetAgentVerified(ctx context.Context, agentID string) error {
154+
_, err := s.db.ExecContext(ctx,
155+
`UPDATE agents SET verified = FALSE, verified_at = NULL WHERE id = $1`,
156+
agentID,
157+
)
158+
return err
159+
}
160+
152161
// ListStaleOnlineAgents returns IDs of agents whose status is online but
153162
// whose last heartbeat is older than the given timeout.
154163
func (s *PostgresStore) ListStaleOnlineAgents(ctx context.Context, timeout time.Duration) ([]string, error) {

internal/reputation/sqlite.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,15 @@ func (s *SQLiteStore) SetAgentVerified(ctx context.Context, agentID string) erro
163163
return err
164164
}
165165

166+
// UnsetAgentVerified removes the verified status from an agent.
167+
func (s *SQLiteStore) UnsetAgentVerified(ctx context.Context, agentID string) error {
168+
_, err := s.db.ExecContext(ctx,
169+
`UPDATE agents SET verified = 0, verified_at = NULL WHERE id = ?`,
170+
agentID,
171+
)
172+
return err
173+
}
174+
166175
// ListStaleOnlineAgents returns IDs of agents whose status is online but
167176
// whose last heartbeat is older than the given timeout.
168177
func (s *SQLiteStore) ListStaleOnlineAgents(ctx context.Context, timeout time.Duration) ([]string, error) {

internal/reputation/store.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@ type Store interface {
5050
// SetAgentVerified marks an agent as verified.
5151
SetAgentVerified(ctx context.Context, agentID string) error
5252

53+
// UnsetAgentVerified removes the verified status from an agent.
54+
UnsetAgentVerified(ctx context.Context, agentID string) error
55+
5356
// ListStaleOnlineAgents returns IDs of agents whose status is online but
5457
// whose last heartbeat is older than the given timeout.
5558
ListStaleOnlineAgents(ctx context.Context, timeout time.Duration) ([]string, error)

0 commit comments

Comments
 (0)