feat(admin): GET /api/admin/agents/{id} activity summary
New endpoint for operator diagnostics (used by the Dialectic.Frontend
AgentActivity page). Same x-dialectic-admin-key gate as
ProvisionAgentKey. Returns:
- key_provisioned (bool) + last_used_at if available
- signups_count / arguments_count / verdicts_count
- recent_topics[]: up to 20 topics the agent touched in any role
(volunteer → camp-allocated → pro/con poster → judge),
deduped by (topic_id, role), most recent action_at first
Implementation: 3 small COUNT queries + one UNION-ALL across signups +
camps + arguments + verdicts joined to topics. Caps at 20 rows; bounded
by per-table indexes on agent_id / posted_at / created_at. <50ms at
current sim row counts.
No new tables / migrations. Roll out: re-deploy backend; frontend
prompts for the admin key on first visit and stores in localStorage.
This commit is contained in:
@@ -3,12 +3,15 @@ package handlers
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-sql-driver/mysql"
|
||||
"github.com/jmoiron/sqlx"
|
||||
|
||||
@@ -123,3 +126,122 @@ func randomHexKey(byteLen int) (string, error) {
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// GET /api/admin/agents/{id}
|
||||
//
|
||||
// Same x-dialectic-admin-key gate as ProvisionAgentKey. Returns a
|
||||
// rolled-up activity summary for one agent: whether a dialectic key is
|
||||
// provisioned (and when last used), per-action counts (signups /
|
||||
// arguments / verdicts), and the 20 most-recent topics the agent
|
||||
// touched in any role. Used by the frontend AgentActivity page for
|
||||
// operator diagnostics ("did sherlock get a key?", "how much is mirror
|
||||
// participating?").
|
||||
//
|
||||
// Joins are wide but capped at 20 recent topics; total query cost is
|
||||
// bounded by index scans on (agent_id, posted_at/created_at). At
|
||||
// current row counts (low thousands) returns in <50ms.
|
||||
type recentTopic struct {
|
||||
TopicID string `db:"topic_id" json:"topic_id"`
|
||||
Title string `db:"title" json:"title"`
|
||||
Status string `db:"status" json:"status"`
|
||||
Role string `db:"role" json:"role"`
|
||||
LastActionAt time.Time `db:"last_action_at" json:"last_action_at"`
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetAgentSummary(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.checkAdminAuth(r) {
|
||||
http.Error(w, "admin auth required (x-dialectic-admin-key)", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
agentID := chi.URLParam(r, "id")
|
||||
if agentID == "" {
|
||||
http.Error(w, "agent_id required in path", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
|
||||
// agent_keys row (may be missing — agent never provisioned)
|
||||
var lastUsedAt sql.NullTime
|
||||
var keyProvisioned bool
|
||||
if err := h.db.QueryRowxContext(ctx,
|
||||
`SELECT last_used_at FROM agent_keys WHERE agent_id = ?`, agentID,
|
||||
).Scan(&lastUsedAt); err != nil {
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
http.Error(w, "agent_keys lookup: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
keyProvisioned = false
|
||||
} else {
|
||||
keyProvisioned = true
|
||||
}
|
||||
|
||||
// Counts (3 small queries; could be one UNION but readability wins
|
||||
// at this scale).
|
||||
count := func(q string) (int, error) {
|
||||
var n int
|
||||
err := h.db.GetContext(ctx, &n, q, agentID)
|
||||
return n, err
|
||||
}
|
||||
signups, err := count(`SELECT COUNT(*) FROM signups WHERE agent_id = ?`)
|
||||
if err != nil {
|
||||
http.Error(w, "signups count: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
args, err := count(`SELECT COUNT(*) FROM arguments WHERE agent_id = ?`)
|
||||
if err != nil {
|
||||
http.Error(w, "arguments count: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
verdicts, err := count(`SELECT COUNT(*) FROM verdicts WHERE judge_agent_id = ?`)
|
||||
if err != nil {
|
||||
http.Error(w, "verdicts count: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Recent topics: union 3 sources, dedup by topic_id keeping the
|
||||
// latest action_at + most-specific role. Allocator camps win over
|
||||
// raw signups (an allocated agent's role is meaningful; a signup
|
||||
// without allocation is just "volunteer"). Verdicts row is judge.
|
||||
var recent []recentTopic
|
||||
if err := h.db.SelectContext(ctx, &recent,
|
||||
`SELECT topic_id, title, status, role, MAX(last_action_at) AS last_action_at FROM (
|
||||
SELECT s.topic_id, t.title, t.status, 'volunteer' AS role, s.created_at AS last_action_at
|
||||
FROM signups s JOIN topics t ON t.id = s.topic_id
|
||||
WHERE s.agent_id = ?
|
||||
UNION ALL
|
||||
SELECT c.topic_id, t.title, t.status, c.camp AS role, c.allocated_at AS last_action_at
|
||||
FROM camps c JOIN topics t ON t.id = c.topic_id
|
||||
WHERE c.agent_id = ?
|
||||
UNION ALL
|
||||
SELECT a.topic_id, t.title, t.status, a.camp AS role, a.posted_at AS last_action_at
|
||||
FROM arguments a JOIN topics t ON t.id = a.topic_id
|
||||
WHERE a.agent_id = ?
|
||||
UNION ALL
|
||||
SELECT v.topic_id, t.title, t.status, 'judge' AS role, v.produced_at AS last_action_at
|
||||
FROM verdicts v JOIN topics t ON t.id = v.topic_id
|
||||
WHERE v.judge_agent_id = ?
|
||||
) AS u
|
||||
GROUP BY topic_id, title, status, role
|
||||
ORDER BY last_action_at DESC
|
||||
LIMIT 20`,
|
||||
agentID, agentID, agentID, agentID,
|
||||
); err != nil {
|
||||
http.Error(w, "recent topics: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
resp := map[string]any{
|
||||
"agent_id": agentID,
|
||||
"key_provisioned": keyProvisioned,
|
||||
"signups_count": signups,
|
||||
"arguments_count": args,
|
||||
"verdicts_count": verdicts,
|
||||
"recent_topics": recent,
|
||||
}
|
||||
if lastUsedAt.Valid {
|
||||
resp["last_used_at"] = lastUsedAt.Time
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user