admin dashboard, metrics and telemetry
Cross Compile Go / build (push) Canceled after 0s

- embedded React admin UI at /admin/ui (go:embed), login with ADMIN_SECRET
  via signed HttpOnly session cookie, X-ADMIN-KEY still works for scripts
- admin API: overview stats, indexing queue, addon management (priority,
  reindex, delete/restore/purge, bulk), whitelist CRUD, duplicate hashes,
  traffic, metrics explorer, services, events/audit log, system info
- built-in metrics: request latency per route, visitors, top addons and
  searches, indexer/scraper counters; minute buckets in the DB with hourly
  rollups and retention; optional Prometheus /metrics (METRICS_TOKEN)
- POST /back/telemetry for indexers/scrapers (metrics, events, heartbeat)
  and a stdlib-only Go client in telemetry/
- auth hardening: empty secrets no longer allow access, constant-time
  compare, CORS not applied to /admin and /back, TRUSTED_PROXIES setting
- Dockerfile builds the UI and no longer bakes config.yaml into the image;
  CI builds the UI, runs tests and builds arm64 with CGO
This commit is contained in:
ilbinek committed 2026-10-03 18:10:57 +02:00
1 parent 0ef27b0072
commit 56d9e10621
79 files changed
+13146 -118

No files matched your search

+136
View File
@@ -0,0 +1,136 @@
package admin
import (
"strconv"
"strings"
"sync"
"time"
"gitea.tbdevent.eu/TBD/reforger_crawler_main/initializers"
"gitea.tbdevent.eu/TBD/reforger_crawler_main/metrics"
"github.com/gin-gonic/gin"
)
// ---- TTL cache with request coalescing -------------------------------------
type cacheEntry struct {
val any
at time.Time
}
type inflight struct {
wg sync.WaitGroup
val any
err error
}
var (
cacheMu sync.Mutex
cacheData = map[string]cacheEntry{}
cacheRun = map[string]*inflight{}
)
// cached returns a cached value younger than ttl, or computes it once even if
// many requests ask at the same time. It also returns when the value was made.
func cached[T any](key string, ttl time.Duration, fn func() (T, error)) (T, time.Time, error) {
cacheMu.Lock()
if e, ok := cacheData[key]; ok && time.Since(e.at) < ttl {
cacheMu.Unlock()
return e.val.(T), e.at, nil
}
if call, ok := cacheRun[key]; ok {
cacheMu.Unlock()
call.wg.Wait()
var zero T
if call.err != nil {
return zero, time.Time{}, call.err
}
return call.val.(T), time.Now(), nil
}
call := &inflight{}
call.wg.Add(1)
cacheRun[key] = call
cacheMu.Unlock()
val, err := fn()
call.val, call.err = val, err
now := time.Now()
cacheMu.Lock()
delete(cacheRun, key)
if err == nil {
cacheData[key] = cacheEntry{val: val, at: now}
}
cacheMu.Unlock()
call.wg.Done()
return val, now, err
}
// invalidate drops cached values whose key starts with one of the prefixes.
func invalidate(prefixes ...string) {
cacheMu.Lock()
defer cacheMu.Unlock()
for k := range cacheData {
for _, p := range prefixes {
if strings.HasPrefix(k, p) {
delete(cacheData, k)
}
}
}
}
// ---- request helpers -----------------------------------------------------
func fail(c *gin.Context, code int, msg string) {
c.AbortWithStatusJSON(code, gin.H{"error": msg})
}
func queryInt(c *gin.Context, name string, def, lo, hi int) int {
v, err := strconv.Atoi(c.Query(name))
if err != nil {
return def
}
return max(lo, min(hi, v))
}
func queryInt64(c *gin.Context, name string, def int64) int64 {
v, err := strconv.ParseInt(c.Query(name), 10, 64)
if err != nil {
return def
}
return v
}
// timeRange reads from/to (unix seconds) with a default window ending now.
func timeRange(c *gin.Context, def time.Duration) (int64, int64) {
now := time.Now().Unix()
to := queryInt64(c, "to", now)
if to > now {
to = now
}
from := queryInt64(c, "from", to-int64(def.Seconds()))
if from >= to {
from = to - int64(def.Seconds())
}
return from, to
}
func isPostgres() bool {
return initializers.DB.Dialector.Name() == "postgres"
}
// likeEscape escapes LIKE wildcards in user input.
func likeEscape(s string) string {
return strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(s)
}
// audit records an admin action as a metric and an event.
func audit(c *gin.Context, action string, fields map[string]any) {
if fields == nil {
fields = map[string]any{}
}
fields["kind"] = "audit"
fields["ip"] = c.ClientIP()
fields["auth"] = c.GetString("adminAuth")
metrics.AdminActions.Inc(action)
metrics.Event("info", "admin: "+action, fields)
}