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:
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
# build artifacts
|
||||
/stats/stats
|
||||
*.bak
|
||||
*.bak.*
|
||||
*.tmp
|
||||
stats.json
|
||||
|
||||
# local env / secrets — never commit
|
||||
.env
|
||||
*.env
|
||||
50
README.md
Normal file
50
README.md
Normal file
@@ -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.
|
||||
22
publish.sh
Executable file
22
publish.sh
Executable file
@@ -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}"
|
||||
11
stats/Dockerfile
Normal file
11
stats/Dockerfile
Normal file
@@ -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"]
|
||||
5
stats/go.mod
Normal file
5
stats/go.mod
Normal file
@@ -0,0 +1,5 @@
|
||||
module hlx-stats
|
||||
|
||||
go 1.23
|
||||
|
||||
require github.com/go-sql-driver/mysql v1.8.1
|
||||
144
stats/main.go
Normal file
144
stats/main.go
Normal 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))
|
||||
}
|
||||
Reference in New Issue
Block a user