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
+63 -3
View File
@@ -42,9 +42,17 @@ var errModelDenied = errors.New("llmgateway: model access denied")
// stale-while-error — a failed refresh leaves the previous discovered set
// in place rather than clearing it. mu guards every mutable field below it.
type providerState struct {
lastRefresh time.Time
explicit map[string]bool
discovered map[string]bool
lastRefresh time.Time
explicit map[string]bool
discovered map[string]bool
// lastErr is the most recent finishRefresh call's error message, or
// "" when that call succeeded (or discovery is disabled and
// finishRefresh was never called at all). Read by snapshot for the
// admin dashboard (spec §4, v0.2) — it reflects the latest attempt's
// outcome, not the stale-while-error discovered set: a provider can
// show a non-empty lastErr while modelCount still reflects its last
// successful discovery.
lastErr string
interval time.Duration
mu sync.Mutex
discoveryEnabled bool
@@ -106,8 +114,10 @@ func (st *providerState) finishRefresh(now time.Time, ids []string, err error) {
st.inFlight = false
st.lastRefresh = now
if err != nil {
st.lastErr = err.Error()
return
}
st.lastErr = ""
discovered := make(map[string]bool, len(ids))
for _, id := range ids {
discovered[id] = true
@@ -115,6 +125,22 @@ func (st *providerState) finishRefresh(now time.Time, ids []string, err error) {
st.discovered = discovered
}
// snapshot returns st's read-only view for the admin dashboard (spec §4,
// v0.2): its known model count (explicit ∪ discovered, mirroring
// knownIDs), last refresh time, and last refresh error message.
func (st *providerState) snapshot() (modelCount int, lastRefresh time.Time, lastErr string) {
st.mu.Lock()
defer st.mu.Unlock()
set := make(map[string]bool, len(st.explicit)+len(st.discovered))
for id := range st.explicit {
set[id] = true
}
for id := range st.discovered {
set[id] = true
}
return len(set), st.lastRefresh, st.lastErr
}
// modelRegistry aggregates every configured provider's explicit and
// discovered model ids into the gateway's model-resolution and /v1/models
// listing surfaces. adapters, providerNames, and states are all fixed at
@@ -443,3 +469,37 @@ func (m *modelRegistry) listFor(grp *group) []map[string]any {
func modelObject(id, ownedBy string) map[string]any {
return map[string]any{"id": id, "object": "model", "owned_by": ownedBy}
}
// providerSnapshot is one provider's read-only view for the admin
// dashboard (spec §4, v0.2). baseURL is not secret (the spec's NEVER-
// exposed list is API keys, digests, redis password, and users-file path
// contents only) — it is included so an operator can see which upstream
// a provider actually targets.
type providerSnapshot struct {
lastRefresh time.Time
name string
typeName string
baseURL string
lastErr string
modelCount int
}
// snapshot returns every configured provider's read-only view, sorted by
// name (m.providerNames is already sorted at construction) — the admin
// dashboard's provider table (spec §4, v0.2).
func (m *modelRegistry) snapshot() []providerSnapshot {
out := make([]providerSnapshot, 0, len(m.providerNames))
for _, name := range m.providerNames {
adapter := m.adapters[name]
modelCount, lastRefresh, lastErr := m.states[name].snapshot()
out = append(out, providerSnapshot{
name: adapter.name(),
typeName: adapter.typeName(),
baseURL: adapter.base(),
modelCount: modelCount,
lastRefresh: lastRefresh,
lastErr: lastErr,
})
}
return out
}