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:
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -123,6 +123,8 @@ export type {
|
|||||||
SystemStatsRAM,
|
SystemStatsRAM,
|
||||||
SystemStatsDisk,
|
SystemStatsDisk,
|
||||||
SystemStatsMailInfo,
|
SystemStatsMailInfo,
|
||||||
|
SystemStatsActivity,
|
||||||
|
SystemStatsEstimate,
|
||||||
SystemStats,
|
SystemStats,
|
||||||
SecurityCheck,
|
SecurityCheck,
|
||||||
SecurityAuditResult,
|
SecurityAuditResult,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user