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

+286
View File
@@ -0,0 +1,286 @@
package metrics
import (
"encoding/json"
"math"
"sort"
"sync"
"sync/atomic"
)
// disabled turns all recording into no-ops (METRICS_ENABLED=false).
var disabled atomic.Bool
// SetEnabled switches metric recording on or off.
func SetEnabled(on bool) { disabled.Store(!on) }
// Agg is the aggregate of all samples of one series inside one bucket.
type Agg struct {
Count int64
Sum float64
Min float64
Max float64
Last float64
LastTs int64
Hist []uint64 // len(bounds)+1, last element is +Inf
}
func (a *Agg) add(v float64, ts int64, bounds []float64) {
if a.Count == 0 {
a.Min, a.Max = v, v
} else {
a.Min = math.Min(a.Min, v)
a.Max = math.Max(a.Max, v)
}
a.Count++
a.Sum += v
if ts >= a.LastTs {
a.Last, a.LastTs = v, ts
}
if len(bounds) > 0 {
if a.Hist == nil {
a.Hist = make([]uint64, len(bounds)+1)
}
a.Hist[bucketIndex(bounds, v)]++
}
}
func (a *Agg) addSummary(count int64, sum, min, max float64, ts int64) {
if count <= 0 {
return
}
if a.Count == 0 {
a.Min, a.Max = min, max
} else {
a.Min = math.Min(a.Min, min)
a.Max = math.Max(a.Max, max)
}
a.Count += count
a.Sum += sum
if ts >= a.LastTs {
a.Last, a.LastTs = sum/float64(count), ts
}
}
// merge folds b into a.
func (a *Agg) merge(b *Agg) {
if b == nil || b.Count == 0 {
return
}
if a.Count == 0 {
a.Min, a.Max = b.Min, b.Max
} else {
a.Min = math.Min(a.Min, b.Min)
a.Max = math.Max(a.Max, b.Max)
}
a.Count += b.Count
a.Sum += b.Sum
if b.LastTs >= a.LastTs {
a.Last, a.LastTs = b.Last, b.LastTs
}
if len(b.Hist) > 0 {
if len(a.Hist) == 0 {
a.Hist = make([]uint64, len(b.Hist))
}
if len(a.Hist) == len(b.Hist) {
for i, v := range b.Hist {
a.Hist[i] += v
}
}
}
}
func (a *Agg) clone() *Agg {
c := *a
if a.Hist != nil {
c.Hist = append([]uint64(nil), a.Hist...)
}
return &c
}
func (a *Agg) histJSON() string {
if len(a.Hist) == 0 {
return ""
}
b, _ := json.Marshal(a.Hist)
return string(b)
}
func bucketIndex(bounds []float64, v float64) int {
return sort.SearchFloat64s(bounds, v) // first bound >= v; len(bounds) = +Inf
}
// quantile estimates the q-quantile from histogram buckets with linear
// interpolation inside the bucket (like Prometheus), clamped to [min, max].
func quantile(a *Agg, bounds []float64, q float64) (float64, bool) {
if a == nil || a.Count == 0 || len(a.Hist) != len(bounds)+1 {
return 0, false
}
var total uint64
for _, c := range a.Hist {
total += c
}
if total == 0 {
return 0, false
}
rank := q * float64(total)
var cum uint64
for i, c := range a.Hist {
if c == 0 {
continue
}
if float64(cum+c) >= rank {
lower := a.Min
if i > 0 {
lower = math.Max(bounds[i-1], a.Min)
}
upper := a.Max
if i < len(bounds) {
upper = math.Min(bounds[i], a.Max)
}
if upper < lower {
upper = lower
}
frac := (rank - float64(cum)) / float64(c)
return lower + (upper-lower)*frac, true
}
cum += c
}
return a.Max, true
}
type pointKey struct {
s *Series
bucket int64
}
// cumulative is the since-start state of a series, used for /metrics.
type cumulative struct {
count int64
sum float64
last float64
hist []uint64
}
// aggregator holds the not yet persisted minute buckets and cumulative state.
type aggregator struct {
mu sync.Mutex
open map[pointKey]*Agg
cum map[*Series]*cumulative
}
var defaultAgg = &aggregator{
open: make(map[pointKey]*Agg),
cum: make(map[*Series]*cumulative),
}
func minuteOf(ts int64) int64 { return ts - ts%60 }
func (g *aggregator) entry(s *Series, ts int64) (*Agg, *cumulative) {
k := pointKey{s, minuteOf(ts)}
a := g.open[k]
if a == nil {
a = &Agg{}
g.open[k] = a
}
c := g.cum[s]
if c == nil {
c = &cumulative{}
g.cum[s] = c
}
return a, c
}
func (g *aggregator) observe(s *Series, ts int64, v float64) {
if disabled.Load() {
return
}
g.mu.Lock()
a, c := g.entry(s, ts)
a.add(v, ts, s.Bounds)
c.count++
c.sum += v
c.last = v
if len(s.Bounds) > 0 {
if c.hist == nil {
c.hist = make([]uint64, len(s.Bounds)+1)
}
c.hist[bucketIndex(s.Bounds, v)]++
}
g.mu.Unlock()
reg.touch(s, ts)
}
func (g *aggregator) observeSummary(s *Series, ts int64, count int64, sum, min, max float64) {
if disabled.Load() {
return
}
g.mu.Lock()
a, c := g.entry(s, ts)
a.addSummary(count, sum, min, max, ts)
c.count += count
c.sum += sum
c.last = sum / float64(count)
g.mu.Unlock()
reg.touch(s, ts)
}
// takeClosed removes and returns all buckets that started before `before`.
func (g *aggregator) takeClosed(before int64) map[pointKey]*Agg {
g.mu.Lock()
defer g.mu.Unlock()
out := make(map[pointKey]*Agg)
for k, a := range g.open {
if k.bucket < before {
out[k] = a
delete(g.open, k)
}
}
return out
}
// putBack re-queues buckets after a failed flush.
func (g *aggregator) putBack(points map[pointKey]*Agg) {
g.mu.Lock()
defer g.mu.Unlock()
for k, a := range points {
if cur := g.open[k]; cur != nil {
cur.merge(a)
} else {
g.open[k] = a
}
}
}
// snapshotOpen copies the open buckets of the given series.
func (g *aggregator) snapshotOpen(want map[*Series]bool) map[pointKey]*Agg {
g.mu.Lock()
defer g.mu.Unlock()
out := make(map[pointKey]*Agg)
for k, a := range g.open {
if want[k.s] {
out[k] = a.clone()
}
}
return out
}
func (g *aggregator) openCount() int {
g.mu.Lock()
defer g.mu.Unlock()
return len(g.open)
}
func (g *aggregator) snapshotCum() map[*Series]cumulative {
g.mu.Lock()
defer g.mu.Unlock()
out := make(map[*Series]cumulative, len(g.cum))
for s, c := range g.cum {
cc := *c
if c.hist != nil {
cc.hist = append([]uint64(nil), c.hist...)
}
out[s] = cc
}
return out
}