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
444 lines
14 KiB
Go
444 lines
14 KiB
Go
package admin
|
|
|
|
import (
|
|
"context"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"strconv"
|
|
"sync"
|
|
"time"
|
|
|
|
"gitea.tbdevent.eu/TBD/reforger_crawler_main/initializers"
|
|
"gitea.tbdevent.eu/TBD/reforger_crawler_main/metrics"
|
|
"gitea.tbdevent.eu/TBD/reforger_crawler_main/models"
|
|
"github.com/gin-gonic/gin"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type addonCounts struct {
|
|
Total int64 `json:"total"`
|
|
Indexed int64 `json:"indexed"`
|
|
Queued int64 `json:"queued"`
|
|
Indexing int64 `json:"indexing"`
|
|
Priority int64 `json:"priority"`
|
|
Recheck int64 `json:"recheck"`
|
|
Blocked int64 `json:"blocked"`
|
|
Unlisted int64 `json:"unlisted"`
|
|
Private int64 `json:"private"`
|
|
TotalSize int64 `json:"totalSizeBytes"`
|
|
Added24h int64 `json:"added24h"`
|
|
Added7d int64 `json:"added7d"`
|
|
Indexed24h int64 `json:"indexed24h"`
|
|
|
|
SoftDeleted int64 `json:"softDeleted"`
|
|
}
|
|
|
|
type typeCount struct {
|
|
Type string `json:"type"`
|
|
Count int64 `json:"count"`
|
|
}
|
|
|
|
type miniAddon struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Author string `json:"author"`
|
|
SubscriberCount int64 `json:"subscriberCount"`
|
|
CurrentVersionSize int64 `json:"currentVersionSize"`
|
|
LastIndexedAt *time.Time `json:"lastIndexedAt,omitempty"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
}
|
|
|
|
type overviewData struct {
|
|
Addons addonCounts `json:"addons"`
|
|
Types []typeCount `json:"types"`
|
|
Whitelist int64 `json:"whitelistCount"`
|
|
Dependencies map[string]any `json:"dependencies"`
|
|
Traffic map[string]any `json:"traffic"`
|
|
Activity map[string]any `json:"activity"`
|
|
Services map[string]any `json:"services"`
|
|
TopSubscribed []miniAddon `json:"topSubscribed"`
|
|
Largest []miniAddon `json:"largest"`
|
|
RecentlyAdded []miniAddon `json:"recentlyAdded"`
|
|
RecentlyIndexed []miniAddon `json:"recentlyIndexed"`
|
|
Heavy *heavyStats `json:"heavy"`
|
|
}
|
|
|
|
func loadAddonCounts() (addonCounts, error) {
|
|
var ac addonCounts
|
|
now := time.Now()
|
|
err := initializers.DB.Raw(`SELECT COUNT(*) AS total,
|
|
COALESCE(SUM(CASE WHEN to_be_indexed = ? THEN 1 ELSE 0 END), 0) AS indexed,
|
|
COALESCE(SUM(CASE WHEN to_be_indexed = ? AND is_being_indexed = ? THEN 1 ELSE 0 END), 0) AS queued,
|
|
COALESCE(SUM(CASE WHEN is_being_indexed = ? THEN 1 ELSE 0 END), 0) AS indexing,
|
|
COALESCE(SUM(CASE WHEN priority_indexing = ? AND to_be_indexed = ? THEN 1 ELSE 0 END), 0) AS priority,
|
|
COALESCE(SUM(CASE WHEN recheck = ? THEN 1 ELSE 0 END), 0) AS recheck,
|
|
COALESCE(SUM(CASE WHEN blocked = ? THEN 1 ELSE 0 END), 0) AS blocked,
|
|
COALESCE(SUM(CASE WHEN unlisted = ? THEN 1 ELSE 0 END), 0) AS unlisted,
|
|
COALESCE(SUM(CASE WHEN private = ? THEN 1 ELSE 0 END), 0) AS private,
|
|
CAST(COALESCE(SUM(current_version_size), 0) AS BIGINT) AS total_size,
|
|
COALESCE(SUM(CASE WHEN created_at >= ? THEN 1 ELSE 0 END), 0) AS added24h,
|
|
COALESCE(SUM(CASE WHEN created_at >= ? THEN 1 ELSE 0 END), 0) AS added7d,
|
|
COALESCE(SUM(CASE WHEN last_indexed_at >= ? THEN 1 ELSE 0 END), 0) AS indexed24h
|
|
FROM addons WHERE deleted_at IS NULL`,
|
|
false, true, false, true, true, true, true, true, true, true,
|
|
now.Add(-24*time.Hour), now.Add(-7*24*time.Hour), now.Add(-24*time.Hour),
|
|
).Scan(&ac).Error
|
|
if err != nil {
|
|
return ac, err
|
|
}
|
|
err = initializers.DB.Unscoped().Model(&models.Addon{}).Where("deleted_at IS NOT NULL").Count(&ac.SoftDeleted).Error
|
|
return ac, err
|
|
}
|
|
|
|
func loadMiniAddons(order string, where string, args ...any) []miniAddon {
|
|
out := []miniAddon{}
|
|
q := initializers.DB.Model(&models.Addon{}).
|
|
Select("id, name, author, subscriber_count, current_version_size, last_indexed_at, created_at")
|
|
if where != "" {
|
|
q = q.Where(where, args...)
|
|
}
|
|
q.Order(order).Limit(8).Scan(&out)
|
|
return out
|
|
}
|
|
|
|
func buildOverview() (*overviewData, error) {
|
|
ac, err := loadAddonCounts()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
d := &overviewData{Addons: ac}
|
|
|
|
d.Types = []typeCount{}
|
|
initializers.DB.Model(&models.Addon{}).Select("type, COUNT(*) AS count").Group("type").Order("count DESC").Limit(12).Scan(&d.Types)
|
|
|
|
initializers.DB.Model(&models.WhitelistedHash{}).Count(&d.Whitelist)
|
|
|
|
var deps struct {
|
|
Edges int64
|
|
WithDeps int64
|
|
Dependents int64
|
|
}
|
|
initializers.DB.Raw(`SELECT COUNT(*) AS edges, COUNT(DISTINCT addon_id) AS with_deps,
|
|
COUNT(DISTINCT dependency_id) AS dependents FROM addon_dependencies`).Scan(&deps)
|
|
d.Dependencies = map[string]any{"edges": deps.Edges, "addonsWithDependencies": deps.WithDeps, "addonsDependedOn": deps.Dependents}
|
|
|
|
now := time.Now().Unix()
|
|
dayAgo := now - 86400
|
|
httpSel := metrics.Selector{Service: metrics.MainService, Name: "http_request_duration_ms"}
|
|
traffic := map[string]any{}
|
|
if t, err := metrics.Totals(metrics.Selector{Service: httpSel.Service, Name: httpSel.Name, GroupBy: []string{"group"}}, dayAgo, now); err == nil {
|
|
var total int64
|
|
for _, g := range t {
|
|
total += g.Count
|
|
if g.Labels["group"] == "v1" {
|
|
traffic["websiteRequests24h"] = g.Count
|
|
traffic["websiteP95Ms"] = g.P95
|
|
traffic["websiteP50Ms"] = g.P50
|
|
}
|
|
if g.Labels["group"] == "back" {
|
|
traffic["workerRequests24h"] = g.Count
|
|
}
|
|
}
|
|
traffic["requests24h"] = total
|
|
}
|
|
traffic["errors5xx24h"] = metrics.Sum(metrics.Selector{Service: httpSel.Service, Name: httpSel.Name, Match: map[string]string{"status": "5xx"}}, dayAgo, now)
|
|
humans, bots := metrics.Visitors()
|
|
traffic["visitorsToday"] = humans
|
|
traffic["botsToday"] = bots
|
|
d.Traffic = traffic
|
|
|
|
sum := func(name string, match map[string]string) float64 {
|
|
return metrics.Sum(metrics.Selector{Service: metrics.MainService, Name: name, Match: match}, dayAgo, now)
|
|
}
|
|
d.Activity = map[string]any{
|
|
"scraperCreated24h": sum("scraper_addons_total", map[string]string{"action": "created"}),
|
|
"scraperUpdated24h": sum("scraper_addons_total", map[string]string{"action": "updated"}),
|
|
"scraperCreateFailed24h": sum("scraper_addons_total", map[string]string{"action": "create_failed"}),
|
|
"deleted24h": sum("scraper_addons_deleted_total", map[string]string{"result": "deleted"}),
|
|
"indexed24h": sum("indexer_submissions_total", map[string]string{"result": "ok"}),
|
|
"claims24h": sum("indexer_claims_total", nil),
|
|
"timeoutsReset24h": sum("indexer_timeouts_reset_total", nil),
|
|
"dependencyReports24h": sum("dependency_reports_total", nil),
|
|
}
|
|
|
|
var instances []models.ServiceInstance
|
|
initializers.DB.Find(&instances)
|
|
online := 0
|
|
for i := range instances {
|
|
if metrics.Online(&instances[i], time.Now()) {
|
|
online++
|
|
}
|
|
}
|
|
d.Services = map[string]any{"online": online, "total": len(instances)}
|
|
|
|
d.TopSubscribed = loadMiniAddons("subscriber_count DESC", "")
|
|
d.Largest = loadMiniAddons("current_version_size DESC", "")
|
|
d.RecentlyAdded = loadMiniAddons("created_at DESC", "")
|
|
d.RecentlyIndexed = loadMiniAddons("last_indexed_at DESC", "last_indexed_at IS NOT NULL")
|
|
|
|
d.Heavy = currentHeavy()
|
|
return d, nil
|
|
}
|
|
|
|
func overview(c *gin.Context) {
|
|
data, at, err := cached("overview", 20*time.Second, buildOverview)
|
|
if err != nil {
|
|
fail(c, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"asOf": at.Unix(), "data": data})
|
|
}
|
|
|
|
// ---- heavy statistics (background) -----------------------------------------
|
|
|
|
type tableInfo struct {
|
|
Name string `json:"name"`
|
|
Rows int64 `json:"rows"`
|
|
DeadRows int64 `json:"deadRows"`
|
|
SizeBytes int64 `json:"sizeBytes"`
|
|
}
|
|
|
|
type dupHash struct {
|
|
Hash string `json:"hash"`
|
|
AddonCount int64 `json:"addonCount"`
|
|
FileCount int64 `json:"fileCount"`
|
|
SamplePath string `json:"samplePath"`
|
|
Whitelisted bool `json:"whitelisted"`
|
|
}
|
|
|
|
type heavyStats struct {
|
|
AsOf int64 `json:"asOf"`
|
|
FilesLive int64 `json:"filesLive"`
|
|
FilesSoftDeleted int64 `json:"filesSoftDeleted"`
|
|
FilesApprox bool `json:"filesApprox"`
|
|
DistinctHashes int64 `json:"distinctHashes"`
|
|
DistinctAsOf int64 `json:"distinctAsOf"`
|
|
DBDriver string `json:"dbDriver"`
|
|
DBSizeBytes int64 `json:"dbSizeBytes"`
|
|
Tables []tableInfo `json:"tables"`
|
|
Duplicates []dupHash `json:"-"`
|
|
DuplicatesAsOf int64 `json:"duplicatesAsOf"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
var (
|
|
heavyMu sync.Mutex
|
|
heavy *heavyStats
|
|
refreshCh = make(chan bool, 1)
|
|
)
|
|
|
|
func currentHeavy() *heavyStats {
|
|
heavyMu.Lock()
|
|
defer heavyMu.Unlock()
|
|
return heavy
|
|
}
|
|
|
|
// requestHeavyRefresh triggers a refresh; full=true also recomputes the
|
|
// expensive duplicate statistics.
|
|
func requestHeavyRefresh(full bool) {
|
|
select {
|
|
case refreshCh <- full:
|
|
default:
|
|
}
|
|
}
|
|
|
|
func heavyStatsLoop(ctx context.Context) {
|
|
lastFull := time.Time{}
|
|
run := func(full bool) {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
log.Printf("admin: heavy stats panicked: %v", r)
|
|
}
|
|
}()
|
|
full = full || time.Since(lastFull) > time.Hour
|
|
computeHeavy(full)
|
|
if full {
|
|
lastFull = time.Now()
|
|
}
|
|
}
|
|
// give the server a moment to start before the first scan
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-time.After(5 * time.Second):
|
|
}
|
|
run(true)
|
|
ticker := time.NewTicker(10 * time.Minute)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
run(false)
|
|
case full := <-refreshCh:
|
|
run(full)
|
|
}
|
|
}
|
|
}
|
|
|
|
// withTimeout runs fn in a transaction with a statement timeout on Postgres.
|
|
func withTimeout(seconds int, fn func(tx *gorm.DB) error) error {
|
|
return initializers.DB.Transaction(func(tx *gorm.DB) error {
|
|
if isPostgres() {
|
|
if err := tx.Exec("SET LOCAL statement_timeout = " + strconv.Itoa(seconds*1000)).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return fn(tx)
|
|
})
|
|
}
|
|
|
|
var ourTables = []string{"addons", "addon_files", "addon_dependencies", "whitelisted_hashes",
|
|
"metric_series", "metric_points", "service_instances", "service_events", "daily_top_items"}
|
|
|
|
func computeHeavy(full bool) {
|
|
started := time.Now()
|
|
h := &heavyStats{AsOf: time.Now().Unix(), DBDriver: initializers.DB.Dialector.Name(), Tables: []tableInfo{}}
|
|
prev := currentHeavy()
|
|
if prev != nil {
|
|
h.DistinctHashes, h.DistinctAsOf = prev.DistinctHashes, prev.DistinctAsOf
|
|
h.Duplicates, h.DuplicatesAsOf = prev.Duplicates, prev.DuplicatesAsOf
|
|
}
|
|
|
|
var errs []string
|
|
note := func(what string, err error) {
|
|
if err != nil {
|
|
errs = append(errs, what+": "+err.Error())
|
|
}
|
|
}
|
|
|
|
if isPostgres() {
|
|
note("tables", initializers.DB.Raw(`SELECT relname AS name, n_live_tup AS rows, n_dead_tup AS dead_rows,
|
|
pg_total_relation_size(relid) AS size_bytes FROM pg_stat_user_tables ORDER BY size_bytes DESC`).Scan(&h.Tables).Error)
|
|
note("db size", initializers.DB.Raw(`SELECT pg_database_size(current_database())`).Scan(&h.DBSizeBytes).Error)
|
|
note("soft-deleted files", withTimeout(60, func(tx *gorm.DB) error {
|
|
return tx.Raw(`SELECT COUNT(*) FROM addon_files WHERE deleted_at IS NOT NULL`).Scan(&h.FilesSoftDeleted).Error
|
|
}))
|
|
for _, t := range h.Tables {
|
|
if t.Name == "addon_files" {
|
|
h.FilesLive = max(0, t.Rows-h.FilesSoftDeleted)
|
|
h.FilesApprox = true
|
|
}
|
|
}
|
|
} else {
|
|
var pageCount, pageSize int64
|
|
initializers.DB.Raw("PRAGMA page_count").Scan(&pageCount)
|
|
initializers.DB.Raw("PRAGMA page_size").Scan(&pageSize)
|
|
h.DBSizeBytes = pageCount * pageSize
|
|
if st, err := os.Stat(sqlitePath() + "-wal"); err == nil {
|
|
h.DBSizeBytes += st.Size()
|
|
}
|
|
for _, name := range ourTables {
|
|
var n int64
|
|
if err := initializers.DB.Table(name).Count(&n).Error; err == nil {
|
|
h.Tables = append(h.Tables, tableInfo{Name: name, Rows: n})
|
|
}
|
|
}
|
|
note("files", initializers.DB.Raw(`SELECT
|
|
COALESCE(SUM(CASE WHEN deleted_at IS NULL THEN 1 ELSE 0 END), 0),
|
|
COALESCE(SUM(CASE WHEN deleted_at IS NOT NULL THEN 1 ELSE 0 END), 0) FROM addon_files`).Row().
|
|
Scan(&h.FilesLive, &h.FilesSoftDeleted))
|
|
}
|
|
|
|
if full {
|
|
var distinct int64
|
|
err := withTimeout(120, func(tx *gorm.DB) error {
|
|
return tx.Raw(`SELECT COUNT(DISTINCT hash) FROM addon_files WHERE deleted_at IS NULL`).Scan(&distinct).Error
|
|
})
|
|
note("distinct hashes", err)
|
|
if err == nil {
|
|
h.DistinctHashes, h.DistinctAsOf = distinct, time.Now().Unix()
|
|
}
|
|
|
|
var dups []dupHash
|
|
err = withTimeout(120, func(tx *gorm.DB) error {
|
|
return tx.Raw(`SELECT hash, COUNT(DISTINCT addon_id) AS addon_count, COUNT(*) AS file_count
|
|
FROM addon_files WHERE deleted_at IS NULL
|
|
GROUP BY hash HAVING COUNT(DISTINCT addon_id) > 1
|
|
ORDER BY addon_count DESC, file_count DESC LIMIT 200`).Scan(&dups).Error
|
|
})
|
|
note("duplicates", err)
|
|
if err == nil {
|
|
h.Duplicates, h.DuplicatesAsOf = dups, time.Now().Unix()
|
|
if len(dups) > 0 {
|
|
hashes := make([]string, len(dups))
|
|
for i, d := range dups {
|
|
hashes[i] = d.Hash
|
|
}
|
|
var samples []struct {
|
|
Hash string
|
|
Path string
|
|
}
|
|
initializers.DB.Raw(`SELECT hash, MIN(path) AS path FROM addon_files
|
|
WHERE deleted_at IS NULL AND hash IN ? GROUP BY hash`, hashes).Scan(&samples)
|
|
byHash := map[string]string{}
|
|
for _, s := range samples {
|
|
byHash[s.Hash] = s.Path
|
|
}
|
|
for i := range h.Duplicates {
|
|
h.Duplicates[i].SamplePath = byHash[h.Duplicates[i].Hash]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(errs) > 0 {
|
|
h.Error = errs[0]
|
|
for _, e := range errs {
|
|
log.Printf("admin: heavy stats: %s", e)
|
|
}
|
|
}
|
|
heavyMu.Lock()
|
|
heavy = h
|
|
heavyMu.Unlock()
|
|
invalidate("overview")
|
|
if time.Since(started) > 10*time.Second {
|
|
log.Printf("admin: heavy stats took %s", time.Since(started).Round(time.Millisecond))
|
|
}
|
|
}
|
|
|
|
func sqlitePath() string {
|
|
if initializers.SQLitePath != "" {
|
|
return initializers.SQLitePath
|
|
}
|
|
return "crawler.db"
|
|
}
|
|
|
|
// duplicateHashes lists the hashes shared by the most addons.
|
|
func duplicateHashes(c *gin.Context) {
|
|
h := currentHeavy()
|
|
if h == nil {
|
|
c.JSON(http.StatusOK, gin.H{"asOf": 0, "items": []dupHash{}, "pending": true})
|
|
return
|
|
}
|
|
items := make([]dupHash, len(h.Duplicates))
|
|
copy(items, h.Duplicates)
|
|
if len(items) > 0 {
|
|
hashes := make([]string, len(items))
|
|
for i, d := range items {
|
|
hashes[i] = d.Hash
|
|
}
|
|
var wl []string
|
|
initializers.DB.Model(&models.WhitelistedHash{}).Where("hash IN ?", hashes).Pluck("hash", &wl)
|
|
set := map[string]bool{}
|
|
for _, w := range wl {
|
|
set[w] = true
|
|
}
|
|
for i := range items {
|
|
items[i].Whitelisted = set[items[i].Hash]
|
|
}
|
|
}
|
|
if c.Query("hideWhitelisted") == "1" {
|
|
kept := items[:0]
|
|
for _, it := range items {
|
|
if !it.Whitelisted {
|
|
kept = append(kept, it)
|
|
}
|
|
}
|
|
items = kept
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"asOf": h.DuplicatesAsOf, "items": items})
|
|
}
|