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 (
+