package main import ( "context" "encoding/json" "flag" "log" "os" "path/filepath" "time" "github.com/bergabruh/stats-gateway/internal/storage" ) type sample struct { TS string `json:"ts"` HTTPMs float64 `json:"http_ms"` TLSMs float64 `json:"tls_ms"` TCPMs float64 `json:"tcp_ms"` } type nodeData struct { Recent []sample `json:"recent"` } // SpeedtestSnapshot exposes recent speedtest rows + day/week/month median // aggregates per node. Recent uses storage.SpeedTest's capitalized field // names (no JSON tags on the struct); the frontend reads them as-is. type SpeedtestSnapshot struct { Recent []storage.SpeedTest `json:"recent"` AggDay map[string]storage.Agg `json:"agg_day"` AggWeek map[string]storage.Agg `json:"agg_week"` AggMonth map[string]storage.Agg `json:"agg_month"` GeneratedAt int64 `json:"generated_at"` } type snapshot struct { GeneratedAt string `json:"generated_at"` Nodes map[string]nodeData `json:"nodes"` Speedtest *SpeedtestSnapshot `json:"speedtest,omitempty"` } // buildSpeedtestSnapshot collects the last 200 speedtest rows + per-node // medians over 24h/7d/30d windows. Aggregate errors are logged but // non-fatal — the rest of the snapshot still ships. // // Side effect: prunes speedtests older than 30 days. Retention is colocated // with snapshot rebuild because both run on the same timer cadence and the // storage layer already has the DeleteSpeedTestsBefore primitive. func buildSpeedtestSnapshot(ctx context.Context, st *storage.Store) (*SpeedtestSnapshot, error) { if deleted, err := st.DeleteSpeedTestsBefore(ctx, time.Now().Add(-30*24*time.Hour).Unix()); err != nil { log.Printf("speedtests retention (non-fatal): %v", err) } else if deleted > 0 { log.Printf("speedtests retention: pruned %d rows older than 30 days", deleted) } recent, err := st.ListRecentSpeedTests(ctx, 200) if err != nil { return nil, err } now := time.Now().Unix() day, week, month := now-86400, now-7*86400, now-30*86400 aggDay, errDay := st.AggregateSpeedTestsSince(ctx, day) if errDay != nil { log.Printf("aggregate day: %v", errDay) } aggWeek, errWeek := st.AggregateSpeedTestsSince(ctx, week) if errWeek != nil { log.Printf("aggregate week: %v", errWeek) } aggMonth, errMonth := st.AggregateSpeedTestsSince(ctx, month) if errMonth != nil { log.Printf("aggregate month: %v", errMonth) } return &SpeedtestSnapshot{ Recent: recent, AggDay: aggDay, AggWeek: aggWeek, AggMonth: aggMonth, GeneratedAt: now, }, nil } func main() { dbPath := flag.String("db", "/var/lib/stats-gateway/stats.db", "") out := flag.String("out", "/var/www/stats-site/data/snapshot.json", "") limitPerNode := flag.Int("limit", 288, "max samples per node (288 = ~24h at 5min interval)") flag.Parse() st, err := storage.Open(*dbPath) if err != nil { log.Fatalf("open db: %v", err) } defer st.Close() s := snapshot{ GeneratedAt: time.Now().UTC().Format(time.RFC3339), Nodes: map[string]nodeData{}, } ctx := context.Background() for _, node := range []string{"fl", "pl1"} { samples, err := st.QueryRecent(ctx, node, *limitPerNode, time.Time{}) if err != nil { log.Printf("query %s: %v", node, err) continue } nd := nodeData{Recent: make([]sample, 0, len(samples))} for _, sm := range samples { nd.Recent = append(nd.Recent, sample{ TS: sm.Timestamp.Format(time.RFC3339Nano), HTTPMs: sm.HTTPLatencyMs, TLSMs: sm.TLSHandshakeMs, TCPMs: sm.TCPRttMs, }) } s.Nodes[node] = nd } if stSnap, err := buildSpeedtestSnapshot(ctx, st); err != nil { log.Printf("build speedtest snapshot: %v", err) } else { s.Speedtest = stSnap } if err := os.MkdirAll(filepath.Dir(*out), 0755); err != nil { log.Fatalf("mkdir %s: %v", filepath.Dir(*out), err) } // Atomic write: write to .tmp then rename. tmp := *out + ".tmp" f, err := os.Create(tmp) if err != nil { log.Fatalf("create %s: %v", tmp, err) } enc := json.NewEncoder(f) enc.SetIndent("", " ") if err := enc.Encode(s); err != nil { f.Close() os.Remove(tmp) log.Fatalf("encode: %v", err) } f.Close() if err := os.Rename(tmp, *out); err != nil { log.Fatalf("rename: %v", err) } log.Printf("snapshot: nodes=%d → %s", len(s.Nodes), *out) }