// 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)) }