feat(PROJ-40,PROJ-41): Prometheus Metriken + Dashboard Zeitreihe
- PROJ-40: /api/health mit Version+Uptime, /metrics Prometheus-Format (mails_last_60min/24h/7d/30d, mails_total, storage_bytes, tenants_total, users_total, uptime_seconds) — Token-Schutz optional konfigurierbar - PROJ-41: GET /api/admin/stats/timeseries (30-Tage tagesgenau, Tenant-scoped) + SVG-Balkendiagramm im Dashboard (Mail-Eingang letzte 30 Tage) - storage.DBQueryRow() Helper für Metrics-Queries ohne Pool-Exposition - config.MetricsConfig (enabled, token) in config.go Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,73 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// TimeseriesPoint holds mail count for a single day.
|
||||
type TimeseriesPoint struct {
|
||||
Day string `json:"day"` // "2026-04-05"
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
// MailTimeseries returns daily mail counts for the last `days` days.
|
||||
// Tenant-scoped when tenantID is non-nil.
|
||||
func (s *Store) MailTimeseries(ctx context.Context, days int, tenantID *int64) ([]TimeseriesPoint, error) {
|
||||
if s.db == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if days <= 0 {
|
||||
days = 30
|
||||
}
|
||||
|
||||
var rows interface {
|
||||
Next() bool
|
||||
Scan(dest ...interface{}) error
|
||||
Close()
|
||||
}
|
||||
var err error
|
||||
|
||||
if tenantID != nil {
|
||||
rows, err = s.db.Query(ctx, `
|
||||
SELECT date_trunc('day', e.received_at AT TIME ZONE 'UTC')::date AS day,
|
||||
COUNT(*) AS cnt
|
||||
FROM emails e
|
||||
JOIN email_refs r ON r.email_id = e.id AND r.tenant_id = $2
|
||||
WHERE e.received_at >= NOW() - ($1 || ' days')::interval
|
||||
GROUP BY day
|
||||
ORDER BY day ASC
|
||||
`, days, *tenantID)
|
||||
} else {
|
||||
rows, err = s.db.Query(ctx, `
|
||||
SELECT date_trunc('day', received_at AT TIME ZONE 'UTC')::date AS day,
|
||||
COUNT(*) AS cnt
|
||||
FROM emails
|
||||
WHERE received_at >= NOW() - ($1 || ' days')::interval
|
||||
GROUP BY day
|
||||
ORDER BY day ASC
|
||||
`, days)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
// Build a map day→count then fill in zeros for missing days
|
||||
counts := map[string]int64{}
|
||||
for rows.Next() {
|
||||
var day time.Time
|
||||
var cnt int64
|
||||
if err := rows.Scan(&day, &cnt); err == nil {
|
||||
counts[day.Format("2006-01-02")] = cnt
|
||||
}
|
||||
}
|
||||
|
||||
result := make([]TimeseriesPoint, days)
|
||||
for i := range result {
|
||||
d := time.Now().UTC().AddDate(0, 0, -(days-1-i))
|
||||
key := d.Format("2006-01-02")
|
||||
result[i] = TimeseriesPoint{Day: key, Count: counts[key]}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// MailActivity holds mail counts over different time windows.
|
||||
type MailActivity struct {
|
||||
Last60Min int64 `json:"last_60_min"`
|
||||
|
||||
Reference in New Issue
Block a user