9298216ce0
- 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>
156 lines
4.2 KiB
Go
156 lines
4.2 KiB
Go
package storage
|
|
|
|
import (
|
|
"context"
|
|
"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"`
|
|
Last24h int64 `json:"last_24h"`
|
|
Last7d int64 `json:"last_7d"`
|
|
Last30d int64 `json:"last_30d"`
|
|
}
|
|
|
|
// StorageEstimate holds a rough prediction of when disk space will run out.
|
|
type StorageEstimate struct {
|
|
AvgMailsPerDay float64 `json:"avg_mails_per_day"`
|
|
AvgMailBytes int64 `json:"avg_mail_bytes"`
|
|
DaysUntilFull int64 `json:"days_until_full"` // -1 = unknown/infinite
|
|
ArchiveAgeDays int64 `json:"archive_age_days"`
|
|
}
|
|
|
|
// MailActivityStats returns how many mails arrived in the last 60min / 24h / 7d / 30d.
|
|
func (s *Store) MailActivityStats(ctx context.Context) (*MailActivity, error) {
|
|
if s.db == nil {
|
|
return &MailActivity{}, nil
|
|
}
|
|
now := time.Now()
|
|
windows := []time.Duration{
|
|
60 * time.Minute,
|
|
24 * time.Hour,
|
|
7 * 24 * time.Hour,
|
|
30 * 24 * time.Hour,
|
|
}
|
|
counts := make([]int64, len(windows))
|
|
for i, w := range windows {
|
|
cutoff := now.Add(-w)
|
|
row := s.db.QueryRow(ctx,
|
|
`SELECT COUNT(*) FROM emails WHERE received_at >= $1`, cutoff)
|
|
_ = row.Scan(&counts[i])
|
|
}
|
|
return &MailActivity{
|
|
Last60Min: counts[0],
|
|
Last24h: counts[1],
|
|
Last7d: counts[2],
|
|
Last30d: counts[3],
|
|
}, nil
|
|
}
|
|
|
|
// StorageEstimateStats calculates avg mails/day and avg mail size from the DB.
|
|
// freeDiskBytes: free bytes on the archive partition (passed in from caller).
|
|
func (s *Store) StorageEstimateStats(ctx context.Context, freeDiskBytes uint64) (*StorageEstimate, error) {
|
|
if s.db == nil {
|
|
return &StorageEstimate{DaysUntilFull: -1}, nil
|
|
}
|
|
|
|
var totalMails, totalBytes int64
|
|
var firstReceived *time.Time
|
|
if err := s.db.QueryRow(ctx,
|
|
`SELECT COUNT(*), COALESCE(SUM(size_bytes), 0), MIN(received_at) FROM emails`,
|
|
).Scan(&totalMails, &totalBytes, &firstReceived); err != nil || totalMails == 0 {
|
|
return &StorageEstimate{DaysUntilFull: -1}, nil
|
|
}
|
|
|
|
ageDays := int64(1)
|
|
if firstReceived != nil {
|
|
d := time.Since(*firstReceived).Hours() / 24
|
|
if d > 1 {
|
|
ageDays = int64(d)
|
|
}
|
|
}
|
|
|
|
avgMailsPerDay := float64(totalMails) / float64(ageDays)
|
|
avgMailBytes := totalBytes / totalMails
|
|
|
|
daysUntilFull := int64(-1)
|
|
if avgMailsPerDay > 0 && avgMailBytes > 0 && freeDiskBytes > 0 {
|
|
daysUntilFull = int64(float64(freeDiskBytes) / (avgMailsPerDay * float64(avgMailBytes)))
|
|
}
|
|
|
|
return &StorageEstimate{
|
|
AvgMailsPerDay: avgMailsPerDay,
|
|
AvgMailBytes: avgMailBytes,
|
|
DaysUntilFull: daysUntilFull,
|
|
ArchiveAgeDays: ageDays,
|
|
}, nil
|
|
}
|