feat: add stats helper service

Periodically snapshots Gitea instance metrics from a read-only MySQL
user and writes stats.json into Gitea custom/public/assets for the
home page to fetch same-origin. Config via env vars only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
h z
2026-05-17 12:01:53 +01:00
commit 2cfa6ca76a
6 changed files with 242 additions and 0 deletions

144
stats/main.go Normal file
View File

@@ -0,0 +1,144 @@
// git-kc-stats — periodically snapshots Gitea instance metrics from the
// (read-only) MySQL user and drops a static JSON into Gitea's custom/public
// assets dir, which Gitea serves same-origin at /assets/stats.json.
//
// No HTTP endpoint is exposed to browsers; output is a shared file. A tiny
// /healthz is bound locally only, for ops/debugging.
package main
import (
"context"
"database/sql"
"encoding/json"
"log"
"net/http"
"os"
"path/filepath"
"sync"
"time"
_ "github.com/go-sql-driver/mysql"
)
type Stats struct {
Repos int64 `json:"repos"`
Agents int64 `json:"agents"`
Commits7d int64 `json:"commits_7d"`
PRs int64 `json:"prs"`
Merged int64 `json:"merged"`
GeneratedAt string `json:"generated_at"`
IntervalH int `json:"interval_hours"`
}
var (
mu sync.RWMutex
last *Stats
)
func env(k, d string) string {
if v := os.Getenv(k); v != "" {
return v
}
return d
}
func collect(dsn string, interval time.Duration) (*Stats, error) {
db, err := sql.Open("mysql", dsn)
if err != nil {
return nil, err
}
defer db.Close()
db.SetConnMaxLifetime(time.Minute)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
var s Stats
err = db.QueryRowContext(ctx, `
SELECT
(SELECT COUNT(*) FROM repository),
(SELECT COUNT(*) FROM user WHERE type=0 AND is_admin=0),
(SELECT COALESCE(SUM(CASE WHEN JSON_VALID(content)
THEN COALESCE(JSON_LENGTH(content,'$.Commits'),0) ELSE 0 END),0)
FROM action
WHERE op_type=5 AND created_unix > UNIX_TIMESTAMP()-604800),
(SELECT COUNT(*) FROM pull_request),
(SELECT COUNT(*) FROM pull_request WHERE has_merged=1)
`).Scan(&s.Repos, &s.Agents, &s.Commits7d, &s.PRs, &s.Merged)
if err != nil {
return nil, err
}
s.GeneratedAt = time.Now().UTC().Format(time.RFC3339)
s.IntervalH = int(interval / time.Hour)
return &s, nil
}
func writeAtomic(path string, s *Stats) error {
b, err := json.MarshalIndent(s, "", " ")
if err != nil {
return err
}
b = append(b, '\n')
tmp := path + ".tmp"
if err := os.WriteFile(tmp, b, 0o644); err != nil {
return err
}
return os.Rename(tmp, path)
}
func refresh(dsn, out string, interval time.Duration) {
s, err := collect(dsn, interval)
if err != nil {
log.Printf("collect error: %v (keeping previous output)", err)
return
}
if err := writeAtomic(out, s); err != nil {
log.Printf("write error: %v", err)
return
}
mu.Lock()
last = s
mu.Unlock()
log.Printf("ok repos=%d agents=%d commits_7d=%d prs=%d merged=%d",
s.Repos, s.Agents, s.Commits7d, s.PRs, s.Merged)
}
func main() {
dsn := os.Getenv("STATS_DB_DSN")
if dsn == "" {
log.Fatal("STATS_DB_DSN required")
}
out := env("STATS_OUT", "/out/stats.json")
iv, err := time.ParseDuration(env("STATS_INTERVAL", "12h"))
if err != nil {
log.Fatalf("bad STATS_INTERVAL: %v", err)
}
if err := os.MkdirAll(filepath.Dir(out), 0o755); err != nil {
log.Printf("mkdir: %v", err)
}
refresh(dsn, out, iv) // immediate snapshot on start
go func() {
t := time.NewTicker(iv)
defer t.Stop()
for range t.C {
refresh(dsn, out, iv)
}
}()
http.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
mu.RLock()
l := last
mu.RUnlock()
if l == nil {
http.Error(w, `{"status":"no-data"}`, http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(l)
})
addr := env("STATS_ADDR", ":8080")
log.Printf("git-kc-stats up; out=%s interval=%s healthz=%s", out, iv, addr)
log.Fatal(http.ListenAndServe(addr, nil))
}