- 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:
1 parent
0ef27b0072
commit
56d9e10621
79 files changed
+13146
-118
No files matched your search
@@ -0,0 +1,526 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.tbdevent.eu/TBD/reforger_crawler_main/initializers"
|
||||
"gitea.tbdevent.eu/TBD/reforger_crawler_main/models"
|
||||
)
|
||||
|
||||
const (
|
||||
maxQuerySeries = 50
|
||||
maxQueryPoints = 1500
|
||||
)
|
||||
|
||||
// Selector picks series by service, name and exact label matches, and groups
|
||||
// them by the GroupBy labels ("service" and "instance" work as labels too).
|
||||
type Selector struct {
|
||||
Service string
|
||||
Name string
|
||||
Match map[string]string
|
||||
GroupBy []string
|
||||
}
|
||||
|
||||
// Aggregations supported by Query and Totals.
|
||||
var Aggregations = []string{"sum", "count", "rate", "avg", "min", "max", "last", "p50", "p90", "p95", "p99"}
|
||||
|
||||
func validAgg(agg string) bool {
|
||||
for _, a := range Aggregations {
|
||||
if a == agg {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// DefaultAgg picks a sensible aggregation for a metric kind.
|
||||
func DefaultAgg(kind Kind) string {
|
||||
switch kind {
|
||||
case Counter:
|
||||
return "sum"
|
||||
case Gauge:
|
||||
return "last"
|
||||
case Histogram:
|
||||
return "p95"
|
||||
default:
|
||||
return "avg"
|
||||
}
|
||||
}
|
||||
|
||||
// Point is [unix seconds, value or null] in JSON.
|
||||
type Point struct {
|
||||
T int64
|
||||
V *float64
|
||||
}
|
||||
|
||||
func (p Point) MarshalJSON() ([]byte, error) {
|
||||
if p.V == nil {
|
||||
return []byte("[" + strconv.FormatInt(p.T, 10) + ",null]"), nil
|
||||
}
|
||||
return []byte("[" + strconv.FormatInt(p.T, 10) + "," + strconv.FormatFloat(*p.V, 'g', 10, 64) + "]"), nil
|
||||
}
|
||||
|
||||
type SeriesResult struct {
|
||||
Labels map[string]string `json:"labels"`
|
||||
Points []Point `json:"points"`
|
||||
}
|
||||
|
||||
type QueryResult struct {
|
||||
Name string `json:"name"`
|
||||
Kind Kind `json:"kind"`
|
||||
Agg string `json:"agg"`
|
||||
Resolution int64 `json:"resolution"`
|
||||
Step int64 `json:"step"`
|
||||
From int64 `json:"from"`
|
||||
To int64 `json:"to"`
|
||||
Series []SeriesResult `json:"series"`
|
||||
Truncated bool `json:"truncated"`
|
||||
}
|
||||
|
||||
// matched is a series selected for a query, possibly only known from the DB.
|
||||
type matched struct {
|
||||
id uint
|
||||
live *Series // nil when this process never saw the series
|
||||
labels map[string]string
|
||||
svc string
|
||||
inst string
|
||||
kind Kind
|
||||
bounds []float64
|
||||
}
|
||||
|
||||
func (m *matched) label(name string) string {
|
||||
switch name {
|
||||
case "service":
|
||||
if v, ok := m.labels["service"]; ok {
|
||||
return v
|
||||
}
|
||||
return m.svc
|
||||
case "instance":
|
||||
if v, ok := m.labels["instance"]; ok {
|
||||
return v
|
||||
}
|
||||
return m.inst
|
||||
}
|
||||
return m.labels[name]
|
||||
}
|
||||
|
||||
func (sel Selector) matches(m *matched) bool {
|
||||
if sel.Service != "" && m.svc != sel.Service {
|
||||
return false
|
||||
}
|
||||
for k, v := range sel.Match {
|
||||
if m.label(k) != v {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func findSeries(sel Selector) ([]*matched, error) {
|
||||
byKey := map[seriesKey]*matched{}
|
||||
|
||||
q := initializers.DB.Model(&models.MetricSeries{}).Where("name = ?", sel.Name)
|
||||
if sel.Service != "" {
|
||||
q = q.Where("service = ?", sel.Service)
|
||||
}
|
||||
var rows []models.MetricSeries
|
||||
if err := q.Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, r := range rows {
|
||||
m := &matched{id: r.ID, svc: r.Service, inst: r.Instance, kind: Kind(r.Kind), labels: map[string]string{}}
|
||||
_ = json.Unmarshal([]byte(r.Labels), &m.labels)
|
||||
if r.Bounds != "" {
|
||||
_ = json.Unmarshal([]byte(r.Bounds), &m.bounds)
|
||||
}
|
||||
byKey[seriesKey{r.Service, r.Instance, r.Name, r.Labels}] = m
|
||||
}
|
||||
|
||||
for _, s := range reg.all() {
|
||||
if s.Name != sel.Name || (sel.Service != "" && s.Service != sel.Service) {
|
||||
continue
|
||||
}
|
||||
k := s.key()
|
||||
if m := byKey[k]; m != nil {
|
||||
m.live = s
|
||||
continue
|
||||
}
|
||||
byKey[k] = &matched{live: s, svc: s.Service, inst: s.Instance, kind: s.Kind, bounds: s.Bounds, labels: s.Labels}
|
||||
}
|
||||
|
||||
out := make([]*matched, 0, len(byKey))
|
||||
for _, m := range byKey {
|
||||
if sel.matches(m) {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type groupAcc struct {
|
||||
labels map[string]string
|
||||
steps map[int64]*Agg
|
||||
lastSum map[int64]float64
|
||||
}
|
||||
|
||||
// collect loads all matching data in [from, to] and folds it into groups and
|
||||
// step buckets chosen by bucketFn. Gauge "last" values are summed across the
|
||||
// series of a group (e.g. queue depth over all instances).
|
||||
func collect(sel Selector, from, to, res int64, bucketFn func(int64) int64) (map[string]*groupAcc, Kind, []float64, error) {
|
||||
series, err := findSeries(sel)
|
||||
if err != nil {
|
||||
return nil, "", nil, err
|
||||
}
|
||||
if len(series) == 0 {
|
||||
return map[string]*groupAcc{}, "", nil, nil
|
||||
}
|
||||
kind, bounds := series[0].kind, series[0].bounds
|
||||
|
||||
perSeries := map[*matched]map[int64]*Agg{}
|
||||
add := func(m *matched, ts int64, a *Agg) {
|
||||
b := bucketFn(ts)
|
||||
steps := perSeries[m]
|
||||
if steps == nil {
|
||||
steps = map[int64]*Agg{}
|
||||
perSeries[m] = steps
|
||||
}
|
||||
if cur := steps[b]; cur != nil {
|
||||
cur.merge(a)
|
||||
} else {
|
||||
steps[b] = a.clone()
|
||||
}
|
||||
}
|
||||
|
||||
byID := map[uint]*matched{}
|
||||
var ids []uint
|
||||
for _, m := range series {
|
||||
if m.id != 0 {
|
||||
byID[m.id] = m
|
||||
ids = append(ids, m.id)
|
||||
}
|
||||
}
|
||||
|
||||
load := func(resolution int, lo, hi int64) error {
|
||||
if lo > hi || len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
for start := 0; start < len(ids); start += 300 {
|
||||
end := min(start+300, len(ids))
|
||||
var rows []models.MetricPoint
|
||||
err := initializers.DB.
|
||||
Where("resolution = ? AND series_id IN ? AND bucket >= ? AND bucket <= ?", resolution, ids[start:end], lo, hi).
|
||||
Find(&rows).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range rows {
|
||||
add(byID[rows[i].SeriesID], rows[i].Bucket, pointToAgg(&rows[i]))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if res == resHour {
|
||||
cut := rolledUntil.Load()
|
||||
if err := load(resHour, hourOf(from), cut-1); err != nil {
|
||||
return nil, "", nil, err
|
||||
}
|
||||
if err := load(resMinute, max(cut, from), to); err != nil {
|
||||
return nil, "", nil, err
|
||||
}
|
||||
} else if err := load(resMinute, minuteOf(from), to); err != nil {
|
||||
return nil, "", nil, err
|
||||
}
|
||||
|
||||
// not yet flushed buckets
|
||||
live := map[*Series]*matched{}
|
||||
want := map[*Series]bool{}
|
||||
for _, m := range series {
|
||||
if m.live != nil {
|
||||
live[m.live] = m
|
||||
want[m.live] = true
|
||||
}
|
||||
}
|
||||
for k, a := range defaultAgg.snapshotOpen(want) {
|
||||
if k.bucket >= minuteOf(from) && k.bucket <= to {
|
||||
add(live[k.s], k.bucket, a)
|
||||
}
|
||||
}
|
||||
|
||||
groups := map[string]*groupAcc{}
|
||||
for m, steps := range perSeries {
|
||||
labels := make(map[string]string, len(sel.GroupBy))
|
||||
parts := make([]string, len(sel.GroupBy))
|
||||
for i, g := range sel.GroupBy {
|
||||
labels[g] = m.label(g)
|
||||
parts[i] = labels[g]
|
||||
}
|
||||
gk := strings.Join(parts, "\x00")
|
||||
g := groups[gk]
|
||||
if g == nil {
|
||||
g = &groupAcc{labels: labels, steps: map[int64]*Agg{}, lastSum: map[int64]float64{}}
|
||||
groups[gk] = g
|
||||
}
|
||||
for b, a := range steps {
|
||||
if cur := g.steps[b]; cur != nil {
|
||||
cur.merge(a)
|
||||
} else {
|
||||
g.steps[b] = a.clone()
|
||||
}
|
||||
g.lastSum[b] += a.Last
|
||||
}
|
||||
}
|
||||
return groups, kind, bounds, nil
|
||||
}
|
||||
|
||||
func evaluate(a *Agg, lastSum float64, agg string, kind Kind, bounds []float64, span int64) (float64, bool) {
|
||||
if a == nil || a.Count == 0 {
|
||||
return 0, false
|
||||
}
|
||||
switch agg {
|
||||
case "count":
|
||||
return float64(a.Count), true
|
||||
case "sum":
|
||||
return a.Sum, true
|
||||
case "rate":
|
||||
if span <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
if kind == Counter {
|
||||
return a.Sum / float64(span), true
|
||||
}
|
||||
return float64(a.Count) / float64(span), true
|
||||
case "avg":
|
||||
return a.Sum / float64(a.Count), true
|
||||
case "min":
|
||||
return a.Min, true
|
||||
case "max":
|
||||
return a.Max, true
|
||||
case "last":
|
||||
return lastSum, true
|
||||
case "p50", "p90", "p95", "p99":
|
||||
q, _ := strconv.ParseFloat(agg[1:], 64)
|
||||
return quantile(a, bounds, q/100)
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func pickResolution(from, to, step int64) int64 {
|
||||
minuteHorizon := time.Now().Unix() - int64(initializers.MetricsMinuteRetentionDays)*86400
|
||||
if to-from > 48*3600 || step >= resHour || from < minuteHorizon {
|
||||
return resHour
|
||||
}
|
||||
return resMinute
|
||||
}
|
||||
|
||||
// Query returns one time series per group with values at every step.
|
||||
func Query(sel Selector, agg string, from, to, step int64) (*QueryResult, error) {
|
||||
if sel.Name == "" {
|
||||
return nil, fmt.Errorf("name is required")
|
||||
}
|
||||
if to <= from {
|
||||
return nil, fmt.Errorf("to must be after from")
|
||||
}
|
||||
res := pickResolution(from, to, step)
|
||||
if step < res {
|
||||
step = res
|
||||
}
|
||||
if (to-from)/step > maxQueryPoints {
|
||||
step = (to - from) / maxQueryPoints
|
||||
}
|
||||
step = int64(math.Ceil(float64(step)/float64(res))) * res
|
||||
align := func(ts int64) int64 { return ts - ((ts%step)+step)%step }
|
||||
|
||||
groups, kind, bounds, err := collect(sel, from, to, res, align)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if agg == "" {
|
||||
agg = DefaultAgg(kind)
|
||||
}
|
||||
if !validAgg(agg) {
|
||||
return nil, fmt.Errorf("unknown aggregation %q", agg)
|
||||
}
|
||||
|
||||
result := &QueryResult{Name: sel.Name, Kind: kind, Agg: agg, Resolution: res, Step: step, From: from, To: to, Series: []SeriesResult{}}
|
||||
keys := make([]string, 0, len(groups))
|
||||
for k := range groups {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
if len(keys) > maxQuerySeries {
|
||||
keys = keys[:maxQuerySeries]
|
||||
result.Truncated = true
|
||||
}
|
||||
for _, k := range keys {
|
||||
g := groups[k]
|
||||
sr := SeriesResult{Labels: g.labels}
|
||||
for b := align(from); b <= to; b += step {
|
||||
p := Point{T: b}
|
||||
if v, ok := evaluate(g.steps[b], g.lastSum[b], agg, kind, bounds, step); ok && !math.IsNaN(v) && !math.IsInf(v, 0) {
|
||||
p.V = &v
|
||||
}
|
||||
sr.Points = append(sr.Points, p)
|
||||
}
|
||||
result.Series = append(result.Series, sr)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Total is the whole-range aggregate of one group.
|
||||
type Total struct {
|
||||
Labels map[string]string `json:"labels"`
|
||||
Count int64 `json:"count"`
|
||||
Sum float64 `json:"sum"`
|
||||
Min *float64 `json:"min"`
|
||||
Max *float64 `json:"max"`
|
||||
Avg *float64 `json:"avg"`
|
||||
Last *float64 `json:"last"`
|
||||
Rate *float64 `json:"rate"`
|
||||
P50 *float64 `json:"p50"`
|
||||
P95 *float64 `json:"p95"`
|
||||
P99 *float64 `json:"p99"`
|
||||
}
|
||||
|
||||
// Totals aggregates [from, to] into one value set per group.
|
||||
func Totals(sel Selector, from, to int64) ([]Total, error) {
|
||||
res := pickResolution(from, to, 0)
|
||||
groups, kind, bounds, err := collect(sel, from, to, res, func(int64) int64 { return 0 })
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
span := to - from
|
||||
out := make([]Total, 0, len(groups))
|
||||
for _, g := range groups {
|
||||
a := g.steps[0]
|
||||
if a == nil {
|
||||
continue
|
||||
}
|
||||
t := Total{Labels: g.labels, Count: a.Count, Sum: a.Sum}
|
||||
val := func(agg string) *float64 {
|
||||
if v, ok := evaluate(a, g.lastSum[0], agg, kind, bounds, span); ok && !math.IsNaN(v) && !math.IsInf(v, 0) {
|
||||
return &v
|
||||
}
|
||||
return nil
|
||||
}
|
||||
t.Min, t.Max, t.Avg, t.Last, t.Rate = val("min"), val("max"), val("avg"), val("last"), val("rate")
|
||||
t.P50, t.P95, t.P99 = val("p50"), val("p95"), val("p99")
|
||||
out = append(out, t)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Count > out[j].Count })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Sum returns the summed value of a counter (or count of observations for
|
||||
// other kinds) over the range, across all matching series.
|
||||
func Sum(sel Selector, from, to int64) float64 {
|
||||
sel.GroupBy = nil
|
||||
totals, err := Totals(sel, from, to)
|
||||
if err != nil || len(totals) == 0 {
|
||||
return 0
|
||||
}
|
||||
if f := familyByName(sel.Name); f != nil && f.Kind != Counter {
|
||||
return float64(totals[0].Count)
|
||||
}
|
||||
return totals[0].Sum
|
||||
}
|
||||
|
||||
// CatalogEntry describes one metric name of one service.
|
||||
type CatalogEntry struct {
|
||||
Service string `json:"service"`
|
||||
Name string `json:"name"`
|
||||
Kind Kind `json:"kind"`
|
||||
Help string `json:"help"`
|
||||
Labels map[string][]string `json:"labels"`
|
||||
Instances []string `json:"instances"`
|
||||
Series int `json:"series"`
|
||||
LastSeen int64 `json:"lastSeen"`
|
||||
}
|
||||
|
||||
// Catalog lists every metric known in the DB or in memory.
|
||||
func Catalog(service string) ([]CatalogEntry, error) {
|
||||
type acc struct {
|
||||
e CatalogEntry
|
||||
labels map[string]map[string]bool
|
||||
inst map[string]bool
|
||||
keys map[seriesKey]bool
|
||||
}
|
||||
entries := map[string]*acc{}
|
||||
addSeries := func(svc, inst, name string, kind Kind, help, labelsJSON string, labels map[string]string, lastSeen int64) {
|
||||
k := svc + "/" + name
|
||||
a := entries[k]
|
||||
if a == nil {
|
||||
a = &acc{e: CatalogEntry{Service: svc, Name: name, Kind: kind, Help: help}, labels: map[string]map[string]bool{}, inst: map[string]bool{}, keys: map[seriesKey]bool{}}
|
||||
entries[k] = a
|
||||
}
|
||||
if a.e.Help == "" {
|
||||
a.e.Help = help
|
||||
}
|
||||
sk := seriesKey{svc, inst, name, labelsJSON}
|
||||
if !a.keys[sk] {
|
||||
a.keys[sk] = true
|
||||
a.e.Series++
|
||||
}
|
||||
a.inst[inst] = true
|
||||
for lk, lv := range labels {
|
||||
if a.labels[lk] == nil {
|
||||
a.labels[lk] = map[string]bool{}
|
||||
}
|
||||
a.labels[lk][lv] = true
|
||||
}
|
||||
if lastSeen > a.e.LastSeen {
|
||||
a.e.LastSeen = lastSeen
|
||||
}
|
||||
}
|
||||
|
||||
q := initializers.DB.Model(&models.MetricSeries{})
|
||||
if service != "" {
|
||||
q = q.Where("service = ?", service)
|
||||
}
|
||||
var rows []models.MetricSeries
|
||||
if err := q.Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, r := range rows {
|
||||
labels := map[string]string{}
|
||||
_ = json.Unmarshal([]byte(r.Labels), &labels)
|
||||
addSeries(r.Service, r.Instance, r.Name, Kind(r.Kind), r.Help, r.Labels, labels, r.LastSeen)
|
||||
}
|
||||
for _, s := range reg.all() {
|
||||
if service != "" && s.Service != service {
|
||||
continue
|
||||
}
|
||||
addSeries(s.Service, s.Instance, s.Name, s.Kind, s.Help, s.LabelsJSON, s.Labels, reg.seen(s))
|
||||
}
|
||||
|
||||
out := make([]CatalogEntry, 0, len(entries))
|
||||
for _, a := range entries {
|
||||
a.e.Labels = map[string][]string{}
|
||||
for lk, vals := range a.labels {
|
||||
list := make([]string, 0, len(vals))
|
||||
for v := range vals {
|
||||
list = append(list, v)
|
||||
}
|
||||
sort.Strings(list)
|
||||
a.e.Labels[lk] = list
|
||||
}
|
||||
for inst := range a.inst {
|
||||
a.e.Instances = append(a.e.Instances, inst)
|
||||
}
|
||||
sort.Strings(a.e.Instances)
|
||||
out = append(out, a.e)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].Service != out[j].Service {
|
||||
return out[i].Service < out[j].Service
|
||||
}
|
||||
return out[i].Name < out[j].Name
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
Reference in new issue
Block a user