feat: read-only admin dashboard and JSON API

Adds GET /admin (single-page dashboard), GET /admin/api/overview, and
GET /admin/api/usage per spec §4: providers/redis/cache/groups summary
and per-user/group current-window usage+limits. Gated by Config.Admin
(default off, not registered when disabled) and UserConfig.Admin.

Gate order: disabled -> 404 fall-through; unauthenticated -> 401;
authenticated non-admin -> 403; admin -> serve, counted against the
admin's own request limits like any other route.

Adds small, read-only, mutex-guarded hooks: modelRegistry.snapshot(),
limiter.currentUsage()/redisStatus(), authStore.snapshot(). No secret
field (API key, digest, redis password) is ever read into any admin
response type; verified by a canary-literal redaction test.

HTML/CSS/JS ship as a single Go raw-string const (Yaegi forbids
go:embed); confirmed interpretable via make yaegi-check.
This commit is contained in:
2026-08-20 19:26:22 +01:00
parent e23da896db
commit 547b61f5a9
6 changed files with 1198 additions and 6 deletions
+31
View File
@@ -22,6 +22,11 @@ type Config struct {
Agents map[string]*AgentConfig `json:"agents,omitempty"`
Users *UsersConfig `json:"users,omitempty"`
Redis *RedisConfig `json:"redis,omitempty"`
// Admin gates the read-only admin dashboard (spec §4, v0.2): nil or
// Admin.Enabled false means the /admin* routes are not registered at
// all — ServeHTTP falls through to its existing 404/passthroughUnknown
// handling for those paths, preserving v0.1 behavior exactly.
Admin *AdminConfig `json:"admin,omitempty"`
// Retry is a struct value, not a pointer, because its own Enabled
// field is the on/off signal (unlike Redis/Users, where the block's
// mere presence is the signal) — so its tag omits "omitempty":
@@ -84,6 +89,13 @@ type UserConfig struct {
Name string `json:"name"`
Group string `json:"group"`
APIKey string `json:"apiKey"`
// Admin grants this user access to the read-only admin dashboard
// (spec §4, v0.2), whether the user is inline or file-sourced —
// file-users granting admin is operator-controlled via the Secret
// backing Users.File, which is an accepted trust boundary. An admin
// user is otherwise an ordinary user: their own keys, group, and
// limits still apply, including to the admin routes themselves.
Admin bool `json:"admin,omitempty"`
}
// RedisConfig configures the distributed limit-state backend.
@@ -136,6 +148,15 @@ type CacheConfig struct {
Enabled bool `json:"enabled,omitempty"`
}
// AdminConfig configures the read-only admin dashboard (spec §4, v0.2).
// The zero value (Enabled: false) disables it, preserving v0.1 behavior
// exactly: no /admin* route is registered, so those paths fall through
// to ServeHTTP's existing 404/passthroughUnknown handling like any other
// unrecognized path.
type AdminConfig struct {
Enabled bool `json:"enabled,omitempty"`
}
// ModelPricing overrides the built-in per-model price table.
type ModelPricing struct {
InputPerM float64 `json:"inputPerM"`
@@ -383,6 +404,16 @@ func (g *Gateway) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
// Admin routes (spec §4, v0.2) are matched only when adminEnabled: a
// nil or disabled Config.Admin means none of these paths are
// registered at all, so a request to /admin* falls through to the
// same 404/passthroughUnknown handling as any other unrecognized
// path — no special-casing needed for the disabled case.
if adminEnabled(g.cfg) && r.Method == http.MethodGet && isAdminPath(r.URL.Path) {
g.handleAdmin(sw, r)
return
}
if r.Method == http.MethodGet && (r.URL.Path == "/v1/mcp/servers" || r.URL.Path == "/v1/agents") {
u, grp, ok := g.auth.identify(r)
g.logAuthEvent(ok, authEventUserName(u), r)