Collector now reports repos_public/repos_private (repos kept as the sum)
and a commits_daily[] series of 7 {date,commits} buckets (UTC days,
zero-filled), bucketed in Go from raw created_unix to avoid MySQL
session-timezone ambiguity. commits_7d is now the sum of those buckets
so the card total matches the sparkline.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
195 lines
5.1 KiB
Go
195 lines
5.1 KiB
Go
// 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"
|
|
)
|
|
|
|
// DailyCommit is one bucket of the last-7-days commit series (UTC days).
|
|
type DailyCommit struct {
|
|
Date string `json:"date"` // YYYY-MM-DD (UTC)
|
|
Commits int64 `json:"commits"`
|
|
}
|
|
|
|
type Stats struct {
|
|
Repos int64 `json:"repos"` // public + private
|
|
ReposPublic int64 `json:"repos_public"`
|
|
ReposPrivate int64 `json:"repos_private"`
|
|
Agents int64 `json:"agents"`
|
|
Commits7d int64 `json:"commits_7d"` // sum of CommitsDaily
|
|
CommitsDaily []DailyCommit `json:"commits_daily"`
|
|
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 WHERE is_private=0),
|
|
(SELECT COUNT(*) FROM repository WHERE is_private=1),
|
|
(SELECT COUNT(*) FROM user WHERE type=0 AND is_admin=0),
|
|
(SELECT COUNT(*) FROM pull_request),
|
|
(SELECT COUNT(*) FROM pull_request WHERE has_merged=1)
|
|
`).Scan(&s.ReposPublic, &s.ReposPrivate, &s.Agents, &s.PRs, &s.Merged)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s.Repos = s.ReposPublic + s.ReposPrivate
|
|
|
|
// Per-day commit counts for the last 7 calendar days (UTC), oldest first.
|
|
// We pull raw (created_unix, commit_count) rows and bucket them in Go to
|
|
// avoid any dependence on the MySQL session time zone in date functions.
|
|
now := time.Now().UTC()
|
|
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
|
start := today.AddDate(0, 0, -6) // 7 buckets: start .. today
|
|
startUnix := start.Unix()
|
|
|
|
rows, err := db.QueryContext(ctx, `
|
|
SELECT created_unix,
|
|
CASE WHEN JSON_VALID(content)
|
|
THEN COALESCE(JSON_LENGTH(content,'$.Commits'),0) ELSE 0 END
|
|
FROM action
|
|
WHERE op_type=5 AND created_unix >= ?`, startUnix)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
daily := make([]int64, 7)
|
|
for rows.Next() {
|
|
var cu, c int64
|
|
if err := rows.Scan(&cu, &c); err != nil {
|
|
return nil, err
|
|
}
|
|
if idx := int((cu - startUnix) / 86400); idx >= 0 && idx < 7 {
|
|
daily[idx] += c
|
|
}
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
s.CommitsDaily = make([]DailyCommit, 7)
|
|
for i := 0; i < 7; i++ {
|
|
s.CommitsDaily[i] = DailyCommit{
|
|
Date: start.AddDate(0, 0, i).Format("2006-01-02"),
|
|
Commits: daily[i],
|
|
}
|
|
s.Commits7d += daily[i]
|
|
}
|
|
|
|
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 (pub=%d priv=%d) agents=%d commits_7d=%d prs=%d merged=%d",
|
|
s.Repos, s.ReposPublic, s.ReposPrivate, 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))
|
|
}
|