feat: Dashboard-Metriken nach Mailpiler-Vorbild (Uptime, Aktivität, Prognose)

- storage_stats.go (neu): MailActivityStats (60min/24h/7d/30d), StorageEstimateStats
- dashboard_handlers.go: Uptime (/proc/uptime), activity + estimate in System-Stats-Response
- DashboardTab: Uptime in API-Kachel, neue Kacheln "Mail-Eingang" + "Speicherprognose"
- Warnung (Badge "Knapp!") wenn Partition in <90 Tagen voll

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-03-31 10:44:02 +02:00
parent 5bbf6d0ff3
commit 58bcfb1586
6 changed files with 224 additions and 6 deletions
+1 -1
View File
@@ -12,7 +12,7 @@ const AppVersion = "0.9.1"
// MAJOR: Interface-Änderungen, Breaking changes innerhalb des Moduls // MAJOR: Interface-Änderungen, Breaking changes innerhalb des Moduls
// MINOR: Neue Funktionen, Bugfixes, Security-Patches // MINOR: Neue Funktionen, Bugfixes, Security-Patches
var Modules = map[string]string{ 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 "smtpd": "1.3", // PROJ-28 FQDN-Fallback für EHLO-Banner
"imapserver": "1.3", // PROJ-28 FQDN-Greeting (RFC 3501) "imapserver": "1.3", // PROJ-28 FQDN-Greeting (RFC 3501)
"auth": "1.3", // JWT, bcrypt cost 12, TOTP "auth": "1.3", // JWT, bcrypt cost 12, TOTP
+29 -4
View File
@@ -154,6 +154,17 @@ func (s *Server) handleSystemStats(w http.ResponseWriter, r *http.Request) {
disks = []diskStat{} 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 // Archive: first & last mail
archiveResp := map[string]interface{}{"first_mail": nil, "last_mail": nil} archiveResp := map[string]interface{}{"first_mail": nil, "last_mail": nil}
first, last, err := s.store.FirstAndLastMail() 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{}{ writeJSON(w, http.StatusOK, map[string]interface{}{
"cpu": cpuResp, "cpu": cpuResp,
"ram": ramResp, "ram": ramResp,
"disks": disks, "disks": disks,
"archive": archiveResp, "uptime": uptimeResp,
"archive": archiveResp,
"activity": activity,
"estimate": estimate,
}) })
} }
+88
View File
@@ -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
}
+87 -1
View File
@@ -4,6 +4,8 @@ import {
type SMTPStatus, type SMTPStatus,
type StorageStats, type StorageStats,
type SystemStats, type SystemStats,
type SystemStatsActivity,
type SystemStatsEstimate,
} 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";
@@ -20,6 +22,73 @@ function formatBytes(bytes: number): string {
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; 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 (
<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</span>
</div>
<Separator />
<div className="grid grid-cols-2 gap-1 text-sm">
<span className="text-muted-foreground">Letzte 60 min</span>
<span className="font-semibold">{activity.last_60_min.toLocaleString("de-DE")}</span>
<span className="text-muted-foreground">Letzte 24 h</span>
<span className="font-semibold">{activity.last_24h.toLocaleString("de-DE")}</span>
<span className="text-muted-foreground">Letzte 7 Tage</span>
<span className="font-semibold">{activity.last_7d.toLocaleString("de-DE")}</span>
<span className="text-muted-foreground">Letzte 30 Tage</span>
<span className="font-semibold">{activity.last_30d.toLocaleString("de-DE")}</span>
</div>
</CardContent>
</Card>
);
}
function EstimateCard({ estimate }: { estimate: SystemStatsEstimate }) {
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">Speicherprognose</span>
{estimate.days_until_full >= 0 && estimate.days_until_full < 90 && (
<Badge variant="destructive">Knapp!</Badge>
)}
</div>
<Separator />
<div className="grid grid-cols-2 gap-1 text-sm">
<span className="text-muted-foreground">Ø Mails/Tag</span>
<span className="font-semibold">{estimate.avg_mails_per_day.toFixed(1)}</span>
<span className="text-muted-foreground">Ø Mail-Größe</span>
<span>{formatBytes(estimate.avg_mail_bytes)}</span>
<span className="text-muted-foreground">Archiv-Alter</span>
<span>{estimate.archive_age_days} Tage</span>
<span className="text-muted-foreground">Partition voll in</span>
<span className={estimate.days_until_full >= 0 && estimate.days_until_full < 90 ? "font-semibold text-destructive" : "font-semibold"}>
{formatDaysUntilFull(estimate.days_until_full)}
</span>
</div>
</CardContent>
</Card>
);
}
interface DashboardTabProps { interface DashboardTabProps {
isSuperAdmin: boolean; isSuperAdmin: boolean;
smtpStatus: SMTPStatus | null; smtpStatus: SMTPStatus | null;
@@ -74,7 +143,7 @@ export function DashboardTab({
{/* Status-Kacheln */} {/* Status-Kacheln */}
<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">
{/* API */} {/* API + Uptime */}
<Card> <Card>
<CardContent className="pt-6 space-y-3"> <CardContent className="pt-6 space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -89,6 +158,12 @@ export function DashboardTab({
<span className="font-mono">:8080</span> <span className="font-mono">:8080</span>
<span className="text-muted-foreground">Protokoll</span> <span className="text-muted-foreground">Protokoll</span>
<span>HTTP</span> <span>HTTP</span>
{systemStats?.uptime && (
<>
<span className="text-muted-foreground">System-Uptime</span>
<span>{formatUptime(systemStats.uptime.seconds)}</span>
</>
)}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -293,6 +368,17 @@ export function DashboardTab({
</Card> </Card>
</div> </div>
{/* Mail-Aktivität + Speicherprognose */}
{(systemStats.activity || systemStats.estimate) && (
<div className="space-y-2">
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">Archiv-Statistik</h3>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{systemStats.activity && <ActivityCard activity={systemStats.activity} />}
{systemStats.estimate && <EstimateCard estimate={systemStats.estimate} />}
</div>
</div>
)}
{/* Festplatten */} {/* Festplatten */}
{systemStats.disks.length > 0 && ( {systemStats.disks.length > 0 && (
<div className="space-y-2"> <div className="space-y-2">
+2
View File
@@ -123,6 +123,8 @@ export type {
SystemStatsRAM, SystemStatsRAM,
SystemStatsDisk, SystemStatsDisk,
SystemStatsMailInfo, SystemStatsMailInfo,
SystemStatsActivity,
SystemStatsEstimate,
SystemStats, SystemStats,
SecurityCheck, SecurityCheck,
SecurityAuditResult, SecurityAuditResult,
+17
View File
@@ -83,14 +83,31 @@ export interface SystemStatsMailInfo {
subject: string; 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 { export interface SystemStats {
cpu: SystemStatsCPU; cpu: SystemStatsCPU;
ram: SystemStatsRAM; ram: SystemStatsRAM;
disks: SystemStatsDisk[]; disks: SystemStatsDisk[];
uptime: { seconds: number };
archive: { archive: {
first_mail: SystemStatsMailInfo | null; first_mail: SystemStatsMailInfo | null;
last_mail: SystemStatsMailInfo | null; last_mail: SystemStatsMailInfo | null;
}; };
activity: SystemStatsActivity;
estimate: SystemStatsEstimate;
} }
export interface SecurityCheck { export interface SecurityCheck {