diff --git a/cmd/archivmail/main.go b/cmd/archivmail/main.go index c50771e..7cc2162 100644 --- a/cmd/archivmail/main.go +++ b/cmd/archivmail/main.go @@ -207,6 +207,7 @@ func main() { srv := api.New(apiCfg, mailStore, idx, authMgr, users, audlog, logger) srv.SetVersion(AppVersion, Modules) srv.SetGlobalRetentionDays(cfg.Storage.RetentionDays) + srv.SetMetrics(cfg.Metrics) // PROJ-28: Self-Service Onboarding — mailer + token store + FQDN mlr := mailer.New(cfg.SMTPOut) diff --git a/config/config.go b/config/config.go index c8b065e..48e1f3f 100644 --- a/config/config.go +++ b/config/config.go @@ -19,6 +19,12 @@ type APIConfig struct { TrustedProxies []string `yaml:"trusted_proxies"` } +// MetricsConfig holds settings for the Prometheus /metrics endpoint. +type MetricsConfig struct { + Enabled bool `yaml:"enabled"` // default: false + Token string `yaml:"token"` // optional Bearer token to protect /metrics +} + // Config is the full application configuration loaded from YAML. type Config struct { Server ServerConfig `yaml:"server"` @@ -31,6 +37,7 @@ type Config struct { Audit AuditConfig `yaml:"audit"` Logging LoggingConfig `yaml:"logging"` IMAPServer IMAPServerConfig `yaml:"imap_server"` + Metrics MetricsConfig `yaml:"metrics"` } // IMAPServerConfig holds settings for the embedded read-only IMAP archive server. diff --git a/internal/api/dashboard_handlers.go b/internal/api/dashboard_handlers.go index 0801a7d..e34c499 100644 --- a/internal/api/dashboard_handlers.go +++ b/internal/api/dashboard_handlers.go @@ -16,6 +16,32 @@ import ( "archivmail/pkg/mailparser" ) +// ── Mail Timeseries handler ─────────────────────────────────────────────── + +// handleMailTimeseries returns daily mail counts for the last 30 days. +// GET /api/admin/stats/timeseries?days=30 +func (s *Server) handleMailTimeseries(w http.ResponseWriter, r *http.Request) { + days := 30 + sess := sessionFromCtx(r.Context()) + tenantID := tenantFromCtx(r.Context()) + + // domain_admin sees only own tenant; superadmin sees all + var tid *int64 + if sess.TenantID != nil { + tid = tenantID + } + + points, err := s.store.MailTimeseries(r.Context(), days, tid) + if err != nil { + writeError(w, http.StatusInternalServerError, "timeseries query failed") + return + } + writeJSON(w, http.StatusOK, map[string]interface{}{ + "days": days, + "points": points, + }) +} + // ── Audit Log handler ───────────────────────────────────────────────────── func (s *Server) handleAuditLog(w http.ResponseWriter, r *http.Request) { diff --git a/internal/api/metrics_handlers.go b/internal/api/metrics_handlers.go new file mode 100644 index 0000000..05858fe --- /dev/null +++ b/internal/api/metrics_handlers.go @@ -0,0 +1,92 @@ +package api + +import ( + "fmt" + "net/http" + "strings" + "time" +) + +// handleHealth returns a simple health check response. +// GET /api/health (public, no auth) +func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]interface{}{ + "status": "ok", + "version": s.appVersion, + "uptime_sec": int64(time.Since(s.startTime).Seconds()), + }) +} + +// handleMetrics serves Prometheus-compatible metrics. +// GET /metrics (public or token-protected) +// +// Metrics are DB-backed gauges; no in-process counters needed. +// Format: https://prometheus.io/docs/instrumenting/exposition_formats/ +func (s *Server) handleMetrics(w http.ResponseWriter, r *http.Request) { + if !s.metricsCfg.Enabled { + writeError(w, http.StatusNotFound, "metrics disabled") + return + } + + // Optional token protection + if s.metricsCfg.Token != "" { + tok := extractBearerToken(r) + if tok == "" { + // Also accept ?token= query param for scraper convenience + tok = r.URL.Query().Get("token") + } + if tok != s.metricsCfg.Token { + w.Header().Set("WWW-Authenticate", `Bearer realm="archivmail metrics"`) + writeError(w, http.StatusUnauthorized, "invalid metrics token") + return + } + } + + ctx := r.Context() + var sb strings.Builder + + metric := func(name, help, typ string, val interface{}) { + fmt.Fprintf(&sb, "# HELP %s %s\n", name, help) + fmt.Fprintf(&sb, "# TYPE %s %s\n", name, typ) + fmt.Fprintf(&sb, "%s %v\n", name, val) + } + + // ── Mail counts ─────────────────────────────────────────────────────── + activity, _ := s.store.MailActivityStats(ctx) + if activity != nil { + metric("archivmail_mails_last_60min", "Mails received in the last 60 minutes", "gauge", activity.Last60Min) + metric("archivmail_mails_last_24h", "Mails received in the last 24 hours", "gauge", activity.Last24h) + metric("archivmail_mails_last_7d", "Mails received in the last 7 days", "gauge", activity.Last7d) + metric("archivmail_mails_last_30d", "Mails received in the last 30 days", "gauge", activity.Last30d) + } + + // Total mails + storage bytes from DB + var totalMails int64 + var totalBytes int64 + if s.store != nil { + _ = s.store.DBQueryRow(ctx, + `SELECT COUNT(*), COALESCE(SUM(size_bytes),0) FROM emails`, + ).Scan(&totalMails, &totalBytes) + } + metric("archivmail_mails_total", "Total number of archived mails", "gauge", totalMails) + metric("archivmail_storage_bytes", "Total size of archived mails in bytes", "gauge", totalBytes) + + // Tenant count + var tenantCount int64 + if s.tenantStore != nil { + _ = s.store.DBQueryRow(ctx, `SELECT COUNT(*) FROM tenants`).Scan(&tenantCount) + } + metric("archivmail_tenants_total", "Total number of tenants", "gauge", tenantCount) + + // User count + var userCount int64 + _ = s.store.DBQueryRow(ctx, `SELECT COUNT(*) FROM users WHERE active = true`).Scan(&userCount) + metric("archivmail_users_total", "Total number of active users", "gauge", userCount) + + // Process uptime + metric("archivmail_uptime_seconds", "Process uptime in seconds", "gauge", int64(time.Since(s.startTime).Seconds())) + + w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8") + w.WriteHeader(http.StatusOK) + w.Write([]byte(sb.String())) //nolint:errcheck +} diff --git a/internal/api/server.go b/internal/api/server.go index dd6605d..d68828e 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -8,6 +8,7 @@ import ( "net/http" "strings" "sync" + "time" "regexp" @@ -61,6 +62,8 @@ const ( // Server is the archivmail HTTP API server. type Server struct { cfg config.APIConfig + metricsCfg config.MetricsConfig + startTime time.Time store *storage.Store idx index.Indexer authMgr *auth.Manager @@ -122,6 +125,11 @@ func (s *Server) SetGlobalRetentionDays(days int) { s.globalRetentionDays = days } +// SetMetrics wires the metrics config into the API server. +func (s *Server) SetMetrics(cfg config.MetricsConfig) { + s.metricsCfg = cfg +} + // SetMailer wires the SMTP-Out mailer into the API server (PROJ-28). func (s *Server) SetMailer(m *mailer.Mailer) { s.mailer = m @@ -153,14 +161,15 @@ func New( logger *slog.Logger, ) *Server { s := &Server{ - cfg: cfg, - store: store, - idx: idx, - authMgr: authMgr, - users: users, - audlog: audlog, - logger: logger, - mux: http.NewServeMux(), + cfg: cfg, + store: store, + idx: idx, + authMgr: authMgr, + users: users, + audlog: audlog, + logger: logger, + mux: http.NewServeMux(), + startTime: time.Now(), } s.routes() return s @@ -178,6 +187,7 @@ func (s *Server) authAdmin(h http.HandlerFunc) http.HandlerFunc { func (s *Server) routes() { s.mux.HandleFunc("GET /api/health", s.handleHealth) + s.mux.HandleFunc("GET /metrics", s.handleMetrics) s.mux.HandleFunc("GET /api/version", s.handleVersion) s.mux.HandleFunc("POST /api/auth/login", s.handleLogin) s.mux.HandleFunc("GET /api/auth/me", s.auth(s.handleMe)) @@ -206,6 +216,7 @@ func (s *Server) routes() { s.mux.HandleFunc("POST /api/admin/services/{name}/action", s.authAdmin(s.handleServiceAction)) s.mux.HandleFunc("GET /api/admin/system/stats", s.authAdmin(s.handleSystemStats)) + s.mux.HandleFunc("GET /api/admin/stats/timeseries", s.authAdmin(s.handleMailTimeseries)) s.mux.HandleFunc("GET /api/admin/security/audit", s.authAdmin(s.handleSecurityAudit)) // SEC-17: Security fix actions require superadmin, not just domain_admin. s.mux.HandleFunc("POST /api/admin/security/fix", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleSecurityFix))) @@ -288,9 +299,6 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { // --- handlers --- -func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { - writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) -} // --- middleware --- diff --git a/internal/storage/storage.go b/internal/storage/storage.go index ee05b04..dce9aaf 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -1280,3 +1280,18 @@ func (s *Store) GetVerifyStatus(ctx context.Context, id string) (VerifyStatus, e vs.VerifiedAt = at return vs, nil } + +// DBQueryRow exposes a single DB query row for use by the API metrics handler. +// Returns a no-op row if no DB is configured. +func (s *Store) DBQueryRow(ctx context.Context, sql string, args ...interface{}) interface { + Scan(dest ...interface{}) error +} { + if s.db == nil { + return &noopRow{} + } + return s.db.QueryRow(ctx, sql, args...) +} + +type noopRow struct{} + +func (n *noopRow) Scan(dest ...interface{}) error { return nil } diff --git a/internal/storage/storage_stats.go b/internal/storage/storage_stats.go index c212513..f4301fa 100644 --- a/internal/storage/storage_stats.go +++ b/internal/storage/storage_stats.go @@ -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"` diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 8b95e93..88c747e 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -17,6 +17,7 @@ import { getServices, serviceAction, getSystemStats, + getMailTimeseries, uploadMailFiles, getUploadProgress, getSecurityAudit, @@ -39,6 +40,7 @@ import { type StorageStats, type ServiceStatus, type SystemStats, + type TimeseriesPoint, type UploadJob, type SecurityAuditResult, type Tenant, @@ -82,6 +84,7 @@ export default function AdminPage() { const [smtpStatus, setSmtpStatus] = useState(null); const [storageStats, setStorageStats] = useState(null); const [systemStats, setSystemStats] = useState(null); + const [timeseries, setTimeseries] = useState([]); const [apiOnline, setApiOnline] = useState(null); const [dashLoading, setDashLoading] = useState(true); const [dashRefreshed, setDashRefreshed] = useState(null); @@ -248,16 +251,18 @@ export default function AdminPage() { const loadDashboard = useCallback(async () => { setDashLoading(true); try { - const [smtp, health, storage, sysStats] = await Promise.allSettled([ + const [smtp, health, storage, sysStats, ts] = await Promise.allSettled([ getSMTPStatus(), getHealth(), getStorageStats(), getSystemStats(), + getMailTimeseries(30), ]); setSmtpStatus(smtp.status === "fulfilled" ? smtp.value : null); setApiOnline(health.status === "fulfilled" && health.value.status === "ok"); setStorageStats(storage.status === "fulfilled" ? storage.value : null); setSystemStats(sysStats.status === "fulfilled" ? sysStats.value : null); + setTimeseries(ts.status === "fulfilled" ? ts.value.points : []); setDashRefreshed(new Date()); } finally { setDashLoading(false); @@ -718,6 +723,7 @@ export default function AdminPage() { smtpStatus={smtpStatus} storageStats={storageStats} systemStats={systemStats} + timeseries={timeseries} apiOnline={apiOnline} dashLoading={dashLoading} dashRefreshed={dashRefreshed} diff --git a/src/components/admin/tabs/DashboardTab.tsx b/src/components/admin/tabs/DashboardTab.tsx index b2077ab..cac266d 100644 --- a/src/components/admin/tabs/DashboardTab.tsx +++ b/src/components/admin/tabs/DashboardTab.tsx @@ -6,6 +6,7 @@ import { type SystemStats, type SystemStatsActivity, type SystemStatsEstimate, + type TimeseriesPoint, } from "@/lib/api"; import { type User } from "@/lib/api"; import { Button } from "@/components/ui/button"; @@ -38,6 +39,55 @@ function formatDaysUntilFull(days: number): string { return `${(days / 365).toFixed(1)} Jahre`; } +function MailTimeseriesChart({ points }: { points: TimeseriesPoint[] }) { + if (!points || points.length === 0) return null; + const maxCount = Math.max(...points.map((p) => p.count), 1); + const BAR_H = 80; // px height of chart area + + return ( + + +
+ Mail-Eingang (letzte 30 Tage) + max {maxCount.toLocaleString("de-DE")}/Tag +
+ +
+ {points.map((p) => { + const heightPct = maxCount > 0 ? (p.count / maxCount) * 100 : 0; + const date = new Date(p.day); + const label = date.toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit" }); + return ( +
+
0 ? 2 : 0)}%` }} + /> + {/* tooltip */} +
+
+ {label}: {p.count.toLocaleString("de-DE")} +
+
+
+ ); + })} +
+
+ {points[0]?.day.slice(5)} + {points[Math.floor(points.length / 2)]?.day.slice(5)} + {points[points.length - 1]?.day.slice(5)} +
+ + + ); +} + function ActivityCard({ activity }: { activity: SystemStatsActivity }) { return ( @@ -94,6 +144,7 @@ interface DashboardTabProps { smtpStatus: SMTPStatus | null; storageStats: StorageStats | null; systemStats: SystemStats | null; + timeseries: TimeseriesPoint[]; apiOnline: boolean | null; dashLoading: boolean; dashRefreshed: Date | null; @@ -108,6 +159,7 @@ export function DashboardTab({ smtpStatus, storageStats, systemStats, + timeseries, apiOnline, dashLoading, dashRefreshed, @@ -368,10 +420,13 @@ export function DashboardTab({
- {/* Mail-Aktivität + Speicherprognose */} - {(systemStats.activity || systemStats.estimate) && ( + {/* Mail-Aktivität + Speicherprognose + Zeitreihe */} + {(systemStats.activity || systemStats.estimate || timeseries.length > 0) && (

Archiv-Statistik

+ {timeseries.length > 0 && ( + + )}
{systemStats.activity && } {systemStats.estimate && } diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 2619820..87514d1 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -133,6 +133,7 @@ export type { SystemStatsActivity, SystemStatsEstimate, SystemStats, + TimeseriesPoint, SecurityCheck, SecurityAuditResult, CertInfo, @@ -144,6 +145,7 @@ export { getSMTPStatus, getStorageStats, getSystemStats, + getMailTimeseries, getServices, serviceAction, getAuditLog, diff --git a/src/lib/api/system.ts b/src/lib/api/system.ts index 064fca8..89d67a0 100644 --- a/src/lib/api/system.ts +++ b/src/lib/api/system.ts @@ -110,6 +110,11 @@ export interface SystemStats { estimate: SystemStatsEstimate; } +export interface TimeseriesPoint { + day: string; // "2026-04-05" + count: number; +} + export interface SecurityCheck { name: string; status: "ok" | "warning" | "error"; @@ -164,6 +169,10 @@ export async function getSystemStats(): Promise { return request("/api/admin/system/stats"); } +export async function getMailTimeseries(days = 30): Promise<{ days: number; points: TimeseriesPoint[] }> { + return request<{ days: number; points: TimeseriesPoint[] }>(`/api/admin/stats/timeseries?days=${days}`); +} + // ── Services ────────────────────────────────────────────────────────────────── export async function getServices(): Promise {