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:
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user