Files
archivmail/internal/api/dashboard_handlers.go
T
sysops 9298216ce0 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>
2026-04-05 21:10:42 +02:00

268 lines
7.5 KiB
Go

package api
import (
"bufio"
"math"
"net/http"
"os"
"runtime"
"strconv"
"strings"
"syscall"
"time"
"archivmail/internal/audit"
"archivmail/internal/storage"
"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) {
pageStr := r.URL.Query().Get("page")
pageSizeStr := r.URL.Query().Get("page_size")
username := r.URL.Query().Get("username")
eventType := r.URL.Query().Get("event_type")
page, _ := strconv.Atoi(pageStr)
pageSize, _ := strconv.Atoi(pageSizeStr)
if pageSize <= 0 {
pageSize = 50
}
entries, total, err := s.audlog.Query(audit.QueryFilter{
Username: username,
EventType: eventType,
PageSize: pageSize,
Page: page,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "audit query failed")
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"total": total,
"entries": entries,
})
}
// ── System stats handler ─────────────────────────────────────────────────
type diskStat struct {
Mount string `json:"mount"`
TotalBytes uint64 `json:"total_bytes"`
UsedBytes uint64 `json:"used_bytes"`
FreeBytes uint64 `json:"free_bytes"`
UsedPct float64 `json:"used_pct"`
FSType string `json:"fstype"`
}
type mailInfo struct {
ID string `json:"id"`
Date string `json:"date"`
From string `json:"from"`
Subject string `json:"subject"`
}
var excludedFSTypes = map[string]bool{
"tmpfs": true, "proc": true, "sysfs": true, "devtmpfs": true,
"cgroup": true, "cgroup2": true, "overlay": true, "squashfs": true,
"debugfs": true, "tracefs": true, "securityfs": true, "pstore": true,
"efivarfs": true, "bpf": true, "hugetlbfs": true, "mqueue": true,
"ramfs": true, "devpts": true, "fusectl": true, "configfs": true,
"autofs": true, "nsfs": true, "rpc_pipefs": true,
"fuse.lxcfs": true, "fuse": true,
}
func (s *Server) handleSystemStats(w http.ResponseWriter, r *http.Request) {
// CPU: /proc/loadavg
cpuResp := map[string]interface{}{"load1": 0.0, "load5": 0.0, "load15": 0.0, "num_cpu": runtime.NumCPU()}
if data, err := os.ReadFile("/proc/loadavg"); err == nil {
parts := strings.Fields(string(data))
if len(parts) >= 3 {
l1, _ := strconv.ParseFloat(parts[0], 64)
l5, _ := strconv.ParseFloat(parts[1], 64)
l15, _ := strconv.ParseFloat(parts[2], 64)
cpuResp = map[string]interface{}{"load1": l1, "load5": l5, "load15": l15, "num_cpu": runtime.NumCPU()}
}
}
// RAM: /proc/meminfo
ramResp := map[string]interface{}{"total_bytes": uint64(0), "used_bytes": uint64(0), "free_bytes": uint64(0), "used_pct": 0.0}
if data, err := os.ReadFile("/proc/meminfo"); err == nil {
kv := parseMeminfo(string(data))
total := kv["MemTotal"] * 1024
available := kv["MemAvailable"] * 1024
used := total - available
var usedPct float64
if total > 0 {
usedPct = math.Round(float64(used)/float64(total)*1000) / 10
}
ramResp = map[string]interface{}{
"total_bytes": total,
"used_bytes": used,
"free_bytes": available,
"used_pct": usedPct,
}
}
// Disks: /proc/mounts + syscall.Statfs
var disks []diskStat
seenMounts := map[string]bool{} // deduplicate by mountpoint
seenDevices := map[string]bool{} // deduplicate by device (catches ZFS bind-mounts)
if data, err := os.ReadFile("/proc/mounts"); err == nil {
scanner := bufio.NewScanner(strings.NewReader(string(data)))
for scanner.Scan() {
fields := strings.Fields(scanner.Text())
if len(fields) < 3 {
continue
}
device := fields[0]
mount := fields[1]
fstype := fields[2]
if excludedFSTypes[fstype] || seenMounts[mount] || seenDevices[device] {
continue
}
seenMounts[mount] = true
var stat syscall.Statfs_t
if err := syscall.Statfs(mount, &stat); err != nil {
continue
}
total := stat.Blocks * uint64(stat.Bsize)
free := stat.Bavail * uint64(stat.Bsize)
used := total - free
if total == 0 {
continue // skip pseudo-mounts with no storage (e.g. lxcfs overlays)
}
seenDevices[device] = true
var usedPct float64
if total > 0 {
usedPct = math.Round(float64(used)/float64(total)*1000) / 10
}
disks = append(disks, diskStat{
Mount: mount,
TotalBytes: total,
UsedBytes: used,
FreeBytes: free,
UsedPct: usedPct,
FSType: fstype,
})
}
}
if disks == nil {
disks = []diskStat{}
}
// Uptime: /proc/uptime
uptimeResp := map[string]interface{}{"seconds": 0.0}
if data, err := os.ReadFile("/proc/uptime"); err == nil {
parts := strings.Fields(string(data))
if len(parts) >= 1 {
if secs, err := strconv.ParseFloat(parts[0], 64); err == nil {
uptimeResp["seconds"] = secs
}
}
}
// Archive: first & last mail
archiveResp := map[string]interface{}{"first_mail": nil, "last_mail": nil}
first, last, err := s.store.FirstAndLastMail()
if err == nil {
if first != nil {
archiveResp["first_mail"] = mailRefToInfo(s.store, first)
}
if last != nil {
archiveResp["last_mail"] = mailRefToInfo(s.store, last)
}
}
// Mail activity (60min / 24h / 7d / 30d)
activity, _ := s.store.MailActivityStats(r.Context())
// Storage estimate — use free bytes of the first non-root disk (or root)
var freeDiskBytes uint64
for _, d := range disks {
freeDiskBytes = d.FreeBytes
break
}
estimate, _ := s.store.StorageEstimateStats(r.Context(), freeDiskBytes)
writeJSON(w, http.StatusOK, map[string]interface{}{
"cpu": cpuResp,
"ram": ramResp,
"disks": disks,
"uptime": uptimeResp,
"archive": archiveResp,
"activity": activity,
"estimate": estimate,
})
}
func parseMeminfo(content string) map[string]uint64 {
result := make(map[string]uint64)
scanner := bufio.NewScanner(strings.NewReader(content))
for scanner.Scan() {
k, v, ok := strings.Cut(scanner.Text(), ":")
if !ok {
continue
}
fields := strings.Fields(strings.TrimSpace(v))
if len(fields) == 0 {
continue
}
val, err := strconv.ParseUint(fields[0], 10, 64)
if err == nil {
result[k] = val
}
}
return result
}
func mailRefToInfo(store *storage.Store, ref *storage.MailRef) *mailInfo {
dateStr := ref.ModTime.UTC().Format(time.RFC3339)
raw, err := store.Load(ref.ID)
if err != nil {
return &mailInfo{ID: ref.ID, Date: dateStr}
}
pm, err := mailparser.Parse(raw)
if err != nil {
return &mailInfo{ID: ref.ID, Date: dateStr}
}
if !pm.Date.IsZero() {
dateStr = pm.Date.UTC().Format(time.RFC3339)
}
return &mailInfo{
ID: ref.ID,
Date: dateStr,
From: pm.From,
Subject: pm.Subject,
}
}