From 2cfa6ca76abe9ecfec91bfa13afd656a49e43943 Mon Sep 17 00:00:00 2001 From: hzhang Date: Sun, 17 May 2026 12:01:53 +0100 Subject: [PATCH] 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) --- .gitignore | 10 ++++ README.md | 50 ++++++++++++++++ publish.sh | 22 ++++++++ stats/Dockerfile | 11 ++++ stats/go.mod | 5 ++ stats/main.go | 144 +++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 242 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100755 publish.sh create mode 100644 stats/Dockerfile create mode 100644 stats/go.mod create mode 100644 stats/main.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1b3e00f --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +# build artifacts +/stats/stats +*.bak +*.bak.* +*.tmp +stats.json + +# local env / secrets — never commit +.env +*.env diff --git a/README.md b/README.md new file mode 100644 index 0000000..b36b7c5 --- /dev/null +++ b/README.md @@ -0,0 +1,50 @@ +# GiteaHelpers + +Helper services and tooling for the **Hangman Lab** Gitea instance +(`git.hangman-lab.top`). + +## `stats/` — instance metrics collector + +A tiny Go service that periodically snapshots Gitea instance metrics from a +**read-only** MySQL user and writes a static `stats.json` into Gitea's +`custom/public/assets/` directory. Gitea then serves it same-origin at +`/assets/stats.json`, which the custom home page fetches to render the +metrics card. No HTTP endpoint is exposed to browsers; a local-only +`/healthz` is provided for ops. + +Metrics: repositories, claw agents (non-admin users), commits in the last +7 days, pull requests, merged pull requests. + +### Configuration (all via environment variables — no secrets in the image) + +| Env | Required | Default | Description | +|-----|----------|---------|-------------| +| `STATS_DB_DSN` | yes | — | MySQL DSN, e.g. `gitea_ro:PASSWORD@tcp(mysql:3306)/giteadb?timeout=5s&readTimeout=10s&loc=UTC` | +| `STATS_OUT` | no | `/out/stats.json` | Output path (mount Gitea's `custom/public/assets` here) | +| `STATS_INTERVAL` | no | `12h` | Refresh interval (Go duration) | +| `STATS_ADDR` | no | `:8080` | Address for the local `/healthz` endpoint | + +The service fetches once on start, then every `STATS_INTERVAL`. On a failed +refresh it keeps the previous output (stale-while-error). + +### Build & publish (image to the Gitea container registry) + +```sh +# from repo root +IMAGE=git.hangman-lab.top/hzhang/gitea-stats TAG=0.1.0 ./publish.sh +``` + +### Run + +```sh +docker run -d --name git-kc-stats \ + --network git-kc-net \ + -e STATS_DB_DSN='gitea_ro:***@tcp(mysql:3306)/giteadb?timeout=5s&readTimeout=10s&loc=UTC' \ + -e STATS_INTERVAL=12h \ + -v /var/lib/gitea/custom/public/assets:/out \ + git.hangman-lab.top/hzhang/gitea-stats:0.1.0 +``` + +> The read-only MySQL user (`gitea_ro`, `SELECT` only on `giteadb`) and its +> password are provisioned out-of-band; the password is passed only through +> `STATS_DB_DSN` at runtime and is never committed. diff --git a/publish.sh b/publish.sh new file mode 100755 index 0000000..5af0bbb --- /dev/null +++ b/publish.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# Build and publish the stats helper image to the Gitea container registry. +# +# IMAGE=git.hangman-lab.top/hzhang/gitea-stats TAG=0.1.0 ./publish.sh +# +# Requires: docker logged in to the registry +# echo "$PASSWORD" | docker login git.hangman-lab.top -u hzhang --password-stdin +set -euo pipefail + +IMAGE="${IMAGE:-git.hangman-lab.top/hzhang/gitea-stats}" +TAG="${TAG:-latest}" +CONTEXT="$(cd "$(dirname "${BASH_SOURCE[0]}")/stats" && pwd)" + +echo "building ${IMAGE}:${TAG} (and :latest) from ${CONTEXT}" +docker build -t "${IMAGE}:${TAG}" -t "${IMAGE}:latest" "${CONTEXT}" + +echo "pushing ${IMAGE}:${TAG}" +docker push "${IMAGE}:${TAG}" +echo "pushing ${IMAGE}:latest" +docker push "${IMAGE}:latest" + +echo "done: ${IMAGE}:${TAG}" diff --git a/stats/Dockerfile b/stats/Dockerfile new file mode 100644 index 0000000..b8f0a37 --- /dev/null +++ b/stats/Dockerfile @@ -0,0 +1,11 @@ +# build +FROM golang:1.23-alpine AS build +WORKDIR /src +COPY go.mod ./ +COPY main.go ./ +RUN go mod tidy && CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags "-s -w" -o /stats . + +# runtime (minimal) +FROM scratch +COPY --from=build /stats /stats +ENTRYPOINT ["/stats"] diff --git a/stats/go.mod b/stats/go.mod new file mode 100644 index 0000000..0e41856 --- /dev/null +++ b/stats/go.mod @@ -0,0 +1,5 @@ +module hlx-stats + +go 1.23 + +require github.com/go-sql-driver/mysql v1.8.1 diff --git a/stats/main.go b/stats/main.go new file mode 100644 index 0000000..ebb2689 --- /dev/null +++ b/stats/main.go @@ -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)) +}