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:
@@ -207,6 +207,7 @@ func main() {
|
|||||||
srv := api.New(apiCfg, mailStore, idx, authMgr, users, audlog, logger)
|
srv := api.New(apiCfg, mailStore, idx, authMgr, users, audlog, logger)
|
||||||
srv.SetVersion(AppVersion, Modules)
|
srv.SetVersion(AppVersion, Modules)
|
||||||
srv.SetGlobalRetentionDays(cfg.Storage.RetentionDays)
|
srv.SetGlobalRetentionDays(cfg.Storage.RetentionDays)
|
||||||
|
srv.SetMetrics(cfg.Metrics)
|
||||||
|
|
||||||
// PROJ-28: Self-Service Onboarding — mailer + token store + FQDN
|
// PROJ-28: Self-Service Onboarding — mailer + token store + FQDN
|
||||||
mlr := mailer.New(cfg.SMTPOut)
|
mlr := mailer.New(cfg.SMTPOut)
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ type APIConfig struct {
|
|||||||
TrustedProxies []string `yaml:"trusted_proxies"`
|
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.
|
// Config is the full application configuration loaded from YAML.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Server ServerConfig `yaml:"server"`
|
Server ServerConfig `yaml:"server"`
|
||||||
@@ -31,6 +37,7 @@ type Config struct {
|
|||||||
Audit AuditConfig `yaml:"audit"`
|
Audit AuditConfig `yaml:"audit"`
|
||||||
Logging LoggingConfig `yaml:"logging"`
|
Logging LoggingConfig `yaml:"logging"`
|
||||||
IMAPServer IMAPServerConfig `yaml:"imap_server"`
|
IMAPServer IMAPServerConfig `yaml:"imap_server"`
|
||||||
|
Metrics MetricsConfig `yaml:"metrics"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// IMAPServerConfig holds settings for the embedded read-only IMAP archive server.
|
// IMAPServerConfig holds settings for the embedded read-only IMAP archive server.
|
||||||
|
|||||||
@@ -16,6 +16,32 @@ import (
|
|||||||
"archivmail/pkg/mailparser"
|
"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 ─────────────────────────────────────────────────────
|
// ── Audit Log handler ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
func (s *Server) handleAuditLog(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleAuditLog(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
+19
-11
@@ -8,6 +8,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"regexp"
|
"regexp"
|
||||||
|
|
||||||
@@ -61,6 +62,8 @@ const (
|
|||||||
// Server is the archivmail HTTP API server.
|
// Server is the archivmail HTTP API server.
|
||||||
type Server struct {
|
type Server struct {
|
||||||
cfg config.APIConfig
|
cfg config.APIConfig
|
||||||
|
metricsCfg config.MetricsConfig
|
||||||
|
startTime time.Time
|
||||||
store *storage.Store
|
store *storage.Store
|
||||||
idx index.Indexer
|
idx index.Indexer
|
||||||
authMgr *auth.Manager
|
authMgr *auth.Manager
|
||||||
@@ -122,6 +125,11 @@ func (s *Server) SetGlobalRetentionDays(days int) {
|
|||||||
s.globalRetentionDays = days
|
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).
|
// SetMailer wires the SMTP-Out mailer into the API server (PROJ-28).
|
||||||
func (s *Server) SetMailer(m *mailer.Mailer) {
|
func (s *Server) SetMailer(m *mailer.Mailer) {
|
||||||
s.mailer = m
|
s.mailer = m
|
||||||
@@ -153,14 +161,15 @@ func New(
|
|||||||
logger *slog.Logger,
|
logger *slog.Logger,
|
||||||
) *Server {
|
) *Server {
|
||||||
s := &Server{
|
s := &Server{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
store: store,
|
store: store,
|
||||||
idx: idx,
|
idx: idx,
|
||||||
authMgr: authMgr,
|
authMgr: authMgr,
|
||||||
users: users,
|
users: users,
|
||||||
audlog: audlog,
|
audlog: audlog,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
mux: http.NewServeMux(),
|
mux: http.NewServeMux(),
|
||||||
|
startTime: time.Now(),
|
||||||
}
|
}
|
||||||
s.routes()
|
s.routes()
|
||||||
return s
|
return s
|
||||||
@@ -178,6 +187,7 @@ func (s *Server) authAdmin(h http.HandlerFunc) http.HandlerFunc {
|
|||||||
|
|
||||||
func (s *Server) routes() {
|
func (s *Server) routes() {
|
||||||
s.mux.HandleFunc("GET /api/health", s.handleHealth)
|
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("GET /api/version", s.handleVersion)
|
||||||
s.mux.HandleFunc("POST /api/auth/login", s.handleLogin)
|
s.mux.HandleFunc("POST /api/auth/login", s.handleLogin)
|
||||||
s.mux.HandleFunc("GET /api/auth/me", s.auth(s.handleMe))
|
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("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/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))
|
s.mux.HandleFunc("GET /api/admin/security/audit", s.authAdmin(s.handleSecurityAudit))
|
||||||
// SEC-17: Security fix actions require superadmin, not just domain_admin.
|
// 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)))
|
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 ---
|
// --- handlers ---
|
||||||
|
|
||||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
|
||||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- middleware ---
|
// --- middleware ---
|
||||||
|
|
||||||
|
|||||||
@@ -1280,3 +1280,18 @@ func (s *Store) GetVerifyStatus(ctx context.Context, id string) (VerifyStatus, e
|
|||||||
vs.VerifiedAt = at
|
vs.VerifiedAt = at
|
||||||
return vs, nil
|
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 }
|
||||||
|
|||||||
@@ -5,6 +5,73 @@ import (
|
|||||||
"time"
|
"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.
|
// MailActivity holds mail counts over different time windows.
|
||||||
type MailActivity struct {
|
type MailActivity struct {
|
||||||
Last60Min int64 `json:"last_60_min"`
|
Last60Min int64 `json:"last_60_min"`
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
getServices,
|
getServices,
|
||||||
serviceAction,
|
serviceAction,
|
||||||
getSystemStats,
|
getSystemStats,
|
||||||
|
getMailTimeseries,
|
||||||
uploadMailFiles,
|
uploadMailFiles,
|
||||||
getUploadProgress,
|
getUploadProgress,
|
||||||
getSecurityAudit,
|
getSecurityAudit,
|
||||||
@@ -39,6 +40,7 @@ import {
|
|||||||
type StorageStats,
|
type StorageStats,
|
||||||
type ServiceStatus,
|
type ServiceStatus,
|
||||||
type SystemStats,
|
type SystemStats,
|
||||||
|
type TimeseriesPoint,
|
||||||
type UploadJob,
|
type UploadJob,
|
||||||
type SecurityAuditResult,
|
type SecurityAuditResult,
|
||||||
type Tenant,
|
type Tenant,
|
||||||
@@ -82,6 +84,7 @@ export default function AdminPage() {
|
|||||||
const [smtpStatus, setSmtpStatus] = useState<SMTPStatus | null>(null);
|
const [smtpStatus, setSmtpStatus] = useState<SMTPStatus | null>(null);
|
||||||
const [storageStats, setStorageStats] = useState<StorageStats | null>(null);
|
const [storageStats, setStorageStats] = useState<StorageStats | null>(null);
|
||||||
const [systemStats, setSystemStats] = useState<SystemStats | null>(null);
|
const [systemStats, setSystemStats] = useState<SystemStats | null>(null);
|
||||||
|
const [timeseries, setTimeseries] = useState<TimeseriesPoint[]>([]);
|
||||||
const [apiOnline, setApiOnline] = useState<boolean | null>(null);
|
const [apiOnline, setApiOnline] = useState<boolean | null>(null);
|
||||||
const [dashLoading, setDashLoading] = useState(true);
|
const [dashLoading, setDashLoading] = useState(true);
|
||||||
const [dashRefreshed, setDashRefreshed] = useState<Date | null>(null);
|
const [dashRefreshed, setDashRefreshed] = useState<Date | null>(null);
|
||||||
@@ -248,16 +251,18 @@ export default function AdminPage() {
|
|||||||
const loadDashboard = useCallback(async () => {
|
const loadDashboard = useCallback(async () => {
|
||||||
setDashLoading(true);
|
setDashLoading(true);
|
||||||
try {
|
try {
|
||||||
const [smtp, health, storage, sysStats] = await Promise.allSettled([
|
const [smtp, health, storage, sysStats, ts] = await Promise.allSettled([
|
||||||
getSMTPStatus(),
|
getSMTPStatus(),
|
||||||
getHealth(),
|
getHealth(),
|
||||||
getStorageStats(),
|
getStorageStats(),
|
||||||
getSystemStats(),
|
getSystemStats(),
|
||||||
|
getMailTimeseries(30),
|
||||||
]);
|
]);
|
||||||
setSmtpStatus(smtp.status === "fulfilled" ? smtp.value : null);
|
setSmtpStatus(smtp.status === "fulfilled" ? smtp.value : null);
|
||||||
setApiOnline(health.status === "fulfilled" && health.value.status === "ok");
|
setApiOnline(health.status === "fulfilled" && health.value.status === "ok");
|
||||||
setStorageStats(storage.status === "fulfilled" ? storage.value : null);
|
setStorageStats(storage.status === "fulfilled" ? storage.value : null);
|
||||||
setSystemStats(sysStats.status === "fulfilled" ? sysStats.value : null);
|
setSystemStats(sysStats.status === "fulfilled" ? sysStats.value : null);
|
||||||
|
setTimeseries(ts.status === "fulfilled" ? ts.value.points : []);
|
||||||
setDashRefreshed(new Date());
|
setDashRefreshed(new Date());
|
||||||
} finally {
|
} finally {
|
||||||
setDashLoading(false);
|
setDashLoading(false);
|
||||||
@@ -718,6 +723,7 @@ export default function AdminPage() {
|
|||||||
smtpStatus={smtpStatus}
|
smtpStatus={smtpStatus}
|
||||||
storageStats={storageStats}
|
storageStats={storageStats}
|
||||||
systemStats={systemStats}
|
systemStats={systemStats}
|
||||||
|
timeseries={timeseries}
|
||||||
apiOnline={apiOnline}
|
apiOnline={apiOnline}
|
||||||
dashLoading={dashLoading}
|
dashLoading={dashLoading}
|
||||||
dashRefreshed={dashRefreshed}
|
dashRefreshed={dashRefreshed}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
type SystemStats,
|
type SystemStats,
|
||||||
type SystemStatsActivity,
|
type SystemStatsActivity,
|
||||||
type SystemStatsEstimate,
|
type SystemStatsEstimate,
|
||||||
|
type TimeseriesPoint,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { type User } from "@/lib/api";
|
import { type User } from "@/lib/api";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -38,6 +39,55 @@ function formatDaysUntilFull(days: number): string {
|
|||||||
return `${(days / 365).toFixed(1)} Jahre`;
|
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 (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium text-muted-foreground">Mail-Eingang (letzte 30 Tage)</span>
|
||||||
|
<span className="text-xs text-muted-foreground font-mono">max {maxCount.toLocaleString("de-DE")}/Tag</span>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<div className="flex items-end gap-px overflow-hidden" style={{ height: BAR_H }}>
|
||||||
|
{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 (
|
||||||
|
<div
|
||||||
|
key={p.day}
|
||||||
|
className="flex-1 group relative flex flex-col justify-end"
|
||||||
|
style={{ height: BAR_H }}
|
||||||
|
title={`${label}: ${p.count.toLocaleString("de-DE")} Mails`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-full rounded-t bg-primary/70 group-hover:bg-primary transition-colors"
|
||||||
|
style={{ height: `${Math.max(heightPct, p.count > 0 ? 2 : 0)}%` }}
|
||||||
|
/>
|
||||||
|
{/* tooltip */}
|
||||||
|
<div className="absolute bottom-full mb-1 left-1/2 -translate-x-1/2 hidden group-hover:block z-10 pointer-events-none">
|
||||||
|
<div className="bg-popover text-popover-foreground border rounded px-2 py-1 text-xs whitespace-nowrap shadow">
|
||||||
|
{label}: {p.count.toLocaleString("de-DE")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-xs text-muted-foreground font-mono">
|
||||||
|
<span>{points[0]?.day.slice(5)}</span>
|
||||||
|
<span>{points[Math.floor(points.length / 2)]?.day.slice(5)}</span>
|
||||||
|
<span>{points[points.length - 1]?.day.slice(5)}</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ActivityCard({ activity }: { activity: SystemStatsActivity }) {
|
function ActivityCard({ activity }: { activity: SystemStatsActivity }) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
@@ -94,6 +144,7 @@ interface DashboardTabProps {
|
|||||||
smtpStatus: SMTPStatus | null;
|
smtpStatus: SMTPStatus | null;
|
||||||
storageStats: StorageStats | null;
|
storageStats: StorageStats | null;
|
||||||
systemStats: SystemStats | null;
|
systemStats: SystemStats | null;
|
||||||
|
timeseries: TimeseriesPoint[];
|
||||||
apiOnline: boolean | null;
|
apiOnline: boolean | null;
|
||||||
dashLoading: boolean;
|
dashLoading: boolean;
|
||||||
dashRefreshed: Date | null;
|
dashRefreshed: Date | null;
|
||||||
@@ -108,6 +159,7 @@ export function DashboardTab({
|
|||||||
smtpStatus,
|
smtpStatus,
|
||||||
storageStats,
|
storageStats,
|
||||||
systemStats,
|
systemStats,
|
||||||
|
timeseries,
|
||||||
apiOnline,
|
apiOnline,
|
||||||
dashLoading,
|
dashLoading,
|
||||||
dashRefreshed,
|
dashRefreshed,
|
||||||
@@ -368,10 +420,13 @@ export function DashboardTab({
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mail-Aktivität + Speicherprognose */}
|
{/* Mail-Aktivität + Speicherprognose + Zeitreihe */}
|
||||||
{(systemStats.activity || systemStats.estimate) && (
|
{(systemStats.activity || systemStats.estimate || timeseries.length > 0) && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">Archiv-Statistik</h3>
|
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">Archiv-Statistik</h3>
|
||||||
|
{timeseries.length > 0 && (
|
||||||
|
<MailTimeseriesChart points={timeseries} />
|
||||||
|
)}
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{systemStats.activity && <ActivityCard activity={systemStats.activity} />}
|
{systemStats.activity && <ActivityCard activity={systemStats.activity} />}
|
||||||
{systemStats.estimate && <EstimateCard estimate={systemStats.estimate} />}
|
{systemStats.estimate && <EstimateCard estimate={systemStats.estimate} />}
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ export type {
|
|||||||
SystemStatsActivity,
|
SystemStatsActivity,
|
||||||
SystemStatsEstimate,
|
SystemStatsEstimate,
|
||||||
SystemStats,
|
SystemStats,
|
||||||
|
TimeseriesPoint,
|
||||||
SecurityCheck,
|
SecurityCheck,
|
||||||
SecurityAuditResult,
|
SecurityAuditResult,
|
||||||
CertInfo,
|
CertInfo,
|
||||||
@@ -144,6 +145,7 @@ export {
|
|||||||
getSMTPStatus,
|
getSMTPStatus,
|
||||||
getStorageStats,
|
getStorageStats,
|
||||||
getSystemStats,
|
getSystemStats,
|
||||||
|
getMailTimeseries,
|
||||||
getServices,
|
getServices,
|
||||||
serviceAction,
|
serviceAction,
|
||||||
getAuditLog,
|
getAuditLog,
|
||||||
|
|||||||
@@ -110,6 +110,11 @@ export interface SystemStats {
|
|||||||
estimate: SystemStatsEstimate;
|
estimate: SystemStatsEstimate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TimeseriesPoint {
|
||||||
|
day: string; // "2026-04-05"
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SecurityCheck {
|
export interface SecurityCheck {
|
||||||
name: string;
|
name: string;
|
||||||
status: "ok" | "warning" | "error";
|
status: "ok" | "warning" | "error";
|
||||||
@@ -164,6 +169,10 @@ export async function getSystemStats(): Promise<SystemStats> {
|
|||||||
return request<SystemStats>("/api/admin/system/stats");
|
return request<SystemStats>("/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 ──────────────────────────────────────────────────────────────────
|
// ── Services ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function getServices(): Promise<ServiceStatus[]> {
|
export async function getServices(): Promise<ServiceStatus[]> {
|
||||||
|
|||||||
Reference in New Issue
Block a user