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 (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
|
"database/sql"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/go-sql-driver/mysql"
|
"github.com/go-sql-driver/mysql"
|
||||||
"github.com/jmoiron/sqlx"
|
"github.com/jmoiron/sqlx"
|
||||||
|
|
||||||
@@ -123,3 +126,122 @@ func randomHexKey(byteLen int) (string, error) {
|
|||||||
}
|
}
|
||||||
return hex.EncodeToString(b), nil
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -97,10 +97,12 @@ func Mount(cfg *config.Config, db *sqlx.DB, version string) http.Handler {
|
|||||||
r.Get("/topics/{id}/signups", signupsH.List)
|
r.Get("/topics/{id}/signups", signupsH.List)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Admin: provision an agent api key. Auth is its own header
|
// Admin: provision an agent api key + per-agent activity summary.
|
||||||
// (x-dialectic-admin-key against env DIALECTIC_ADMIN_API_KEY),
|
// Auth is its own header (x-dialectic-admin-key against env
|
||||||
// not bearer — admin lifecycle is separate from agent identity.
|
// DIALECTIC_ADMIN_API_KEY), not bearer — admin lifecycle is
|
||||||
|
// separate from agent identity.
|
||||||
r.Post("/admin/agent-keys", adminH.ProvisionAgentKey)
|
r.Post("/admin/agent-keys", adminH.ProvisionAgentKey)
|
||||||
|
r.Get("/admin/agents/{id}", adminH.GetAgentSummary)
|
||||||
})
|
})
|
||||||
|
|
||||||
return r
|
return r
|
||||||
|
|||||||
Reference in New Issue
Block a user