diff --git a/cmd/archivmail/version.go b/cmd/archivmail/version.go index dee9141..23ffb4e 100644 --- a/cmd/archivmail/version.go +++ b/cmd/archivmail/version.go @@ -12,7 +12,7 @@ const AppVersion = "0.9.1" // MAJOR: Interface-Änderungen, Breaking changes innerhalb des Moduls // MINOR: Neue Funktionen, Bugfixes, Security-Patches var Modules = map[string]string{ - "storage": "1.8", // PROJ-34 per-tenant retention lookup in Save() + "storage": "1.9", // Dashboard: MailActivityStats, StorageEstimateStats (storage_stats.go) "smtpd": "1.3", // PROJ-28 FQDN-Fallback für EHLO-Banner "imapserver": "1.3", // PROJ-28 FQDN-Greeting (RFC 3501) "auth": "1.3", // JWT, bcrypt cost 12, TOTP diff --git a/internal/api/dashboard_handlers.go b/internal/api/dashboard_handlers.go index 175bc43..fb855f6 100644 --- a/internal/api/dashboard_handlers.go +++ b/internal/api/dashboard_handlers.go @@ -154,6 +154,17 @@ func (s *Server) handleSystemStats(w http.ResponseWriter, r *http.Request) { 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() @@ -166,11 +177,25 @@ func (s *Server) handleSystemStats(w http.ResponseWriter, r *http.Request) { } } + // 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, - "archive": archiveResp, + "cpu": cpuResp, + "ram": ramResp, + "disks": disks, + "uptime": uptimeResp, + "archive": archiveResp, + "activity": activity, + "estimate": estimate, }) } diff --git a/internal/storage/storage_stats.go b/internal/storage/storage_stats.go new file mode 100644 index 0000000..c212513 --- /dev/null +++ b/internal/storage/storage_stats.go @@ -0,0 +1,88 @@ +package storage + +import ( + "context" + "time" +) + +// 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 +} diff --git a/src/components/admin/tabs/DashboardTab.tsx b/src/components/admin/tabs/DashboardTab.tsx index 16058a2..b2077ab 100644 --- a/src/components/admin/tabs/DashboardTab.tsx +++ b/src/components/admin/tabs/DashboardTab.tsx @@ -4,6 +4,8 @@ import { type SMTPStatus, type StorageStats, type SystemStats, + type SystemStatsActivity, + type SystemStatsEstimate, } from "@/lib/api"; import { type User } from "@/lib/api"; import { Button } from "@/components/ui/button"; @@ -20,6 +22,73 @@ function formatBytes(bytes: number): string { return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; } +function formatUptime(seconds: number): string { + const d = Math.floor(seconds / 86400); + const h = Math.floor((seconds % 86400) / 3600); + const m = Math.floor((seconds % 3600) / 60); + if (d > 0) return `${d}d ${h}h ${m}m`; + if (h > 0) return `${h}h ${m}m`; + return `${m}m`; +} + +function formatDaysUntilFull(days: number): string { + if (days < 0) return "∞"; + if (days < 30) return `${days} Tage`; + if (days < 365) return `${Math.round(days / 30)} Monate`; + return `${(days / 365).toFixed(1)} Jahre`; +} + +function ActivityCard({ activity }: { activity: SystemStatsActivity }) { + return ( + + +
+ Mail-Eingang +
+ +
+ Letzte 60 min + {activity.last_60_min.toLocaleString("de-DE")} + Letzte 24 h + {activity.last_24h.toLocaleString("de-DE")} + Letzte 7 Tage + {activity.last_7d.toLocaleString("de-DE")} + Letzte 30 Tage + {activity.last_30d.toLocaleString("de-DE")} +
+
+
+ ); +} + +function EstimateCard({ estimate }: { estimate: SystemStatsEstimate }) { + return ( + + +
+ Speicherprognose + {estimate.days_until_full >= 0 && estimate.days_until_full < 90 && ( + Knapp! + )} +
+ +
+ Ø Mails/Tag + {estimate.avg_mails_per_day.toFixed(1)} + Ø Mail-Größe + {formatBytes(estimate.avg_mail_bytes)} + Archiv-Alter + {estimate.archive_age_days} Tage + Partition voll in + = 0 && estimate.days_until_full < 90 ? "font-semibold text-destructive" : "font-semibold"}> + {formatDaysUntilFull(estimate.days_until_full)} + +
+
+
+ ); +} + interface DashboardTabProps { isSuperAdmin: boolean; smtpStatus: SMTPStatus | null; @@ -74,7 +143,7 @@ export function DashboardTab({ {/* Status-Kacheln */}
- {/* API */} + {/* API + Uptime */}
@@ -89,6 +158,12 @@ export function DashboardTab({ :8080 Protokoll HTTP + {systemStats?.uptime && ( + <> + System-Uptime + {formatUptime(systemStats.uptime.seconds)} + + )}
@@ -293,6 +368,17 @@ export function DashboardTab({
+ {/* Mail-Aktivität + Speicherprognose */} + {(systemStats.activity || systemStats.estimate) && ( +
+

Archiv-Statistik

+
+ {systemStats.activity && } + {systemStats.estimate && } +
+
+ )} + {/* Festplatten */} {systemStats.disks.length > 0 && (
diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 7b29d36..479da65 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -123,6 +123,8 @@ export type { SystemStatsRAM, SystemStatsDisk, SystemStatsMailInfo, + SystemStatsActivity, + SystemStatsEstimate, SystemStats, SecurityCheck, SecurityAuditResult, diff --git a/src/lib/api/system.ts b/src/lib/api/system.ts index e8d73b0..8d09832 100644 --- a/src/lib/api/system.ts +++ b/src/lib/api/system.ts @@ -83,14 +83,31 @@ export interface SystemStatsMailInfo { subject: string; } +export interface SystemStatsActivity { + last_60_min: number; + last_24h: number; + last_7d: number; + last_30d: number; +} + +export interface SystemStatsEstimate { + avg_mails_per_day: number; + avg_mail_bytes: number; + days_until_full: number; // -1 = unknown + archive_age_days: number; +} + export interface SystemStats { cpu: SystemStatsCPU; ram: SystemStatsRAM; disks: SystemStatsDisk[]; + uptime: { seconds: number }; archive: { first_mail: SystemStatsMailInfo | null; last_mail: SystemStatsMailInfo | null; }; + activity: SystemStatsActivity; + estimate: SystemStatsEstimate; } export interface SecurityCheck {