feat(PROJ-17): Admin Dashboard Systemauslastung immer anzeigen

- Systemauslastungs-Sektion wird immer gerendert (nicht nur bei Erfolg)
- Fehlermeldung wenn /api/admin/system/stats nicht erreichbar ist
- Feature-Status auf In Review gesetzt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-03-14 11:43:19 +01:00
parent a893084a88
commit d360c9a5ba
68 changed files with 11938 additions and 435 deletions
+145
View File
@@ -0,0 +1,145 @@
package storage
import (
"crypto/sha256"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"time"
)
// Store is a file-based email storage using SHA256 for deduplication.
type Store struct {
dir string
}
// StoreStats reports total mail count and size in bytes.
type StoreStats struct {
TotalMails int64
TotalBytes int64
}
// New initialises the storage directory, creating required subdirectories.
func New(dir string) (*Store, error) {
for _, sub := range []string{"store", "attachments", "meta"} {
if err := os.MkdirAll(filepath.Join(dir, sub), 0o755); err != nil {
return nil, fmt.Errorf("storage: mkdir %s: %w", sub, err)
}
}
return &Store{dir: dir}, nil
}
// Save writes raw email bytes to storage. The ID is the hex-encoded SHA256 of
// the content. If the file already exists, Save is a no-op (deduplication).
func (s *Store) Save(raw []byte, _ time.Time) (string, error) {
sum := sha256.Sum256(raw)
id := fmt.Sprintf("%x", sum[:]) // 64 hex chars
path := s.filePath(id)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return "", fmt.Errorf("storage: mkdir shard: %w", err)
}
// If file already exists, dedup: return same id without error.
if _, err := os.Stat(path); err == nil {
return id, nil
}
if err := os.WriteFile(path, raw, 0o644); err != nil {
return "", fmt.Errorf("storage: write: %w", err)
}
return id, nil
}
// Load reads a stored email by its ID.
func (s *Store) Load(id string) ([]byte, error) {
path := s.filePath(id)
data, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("storage: not found: %s", id)
}
return nil, fmt.Errorf("storage: read: %w", err)
}
return data, nil
}
// Delete removes a stored email by its ID.
func (s *Store) Delete(id string) error {
path := s.filePath(id)
if err := os.Remove(path); err != nil {
if errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("storage: not found: %s", id)
}
return fmt.Errorf("storage: delete: %w", err)
}
return nil
}
// Stats walks the store directory and returns aggregate statistics.
func (s *Store) Stats() (*StoreStats, error) {
var stats StoreStats
err := filepath.WalkDir(filepath.Join(s.dir, "store"), func(_ string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
info, err := d.Info()
if err != nil {
return err
}
stats.TotalMails++
stats.TotalBytes += info.Size()
return nil
})
if err != nil {
return nil, fmt.Errorf("storage: stats: %w", err)
}
return &stats, nil
}
// MailRef holds the ID and modification time of a stored mail.
type MailRef struct {
ID string
ModTime time.Time
}
// FirstAndLastMail walks the store and returns the oldest and newest mail by
// file modification time. Returns nil for either if the store is empty.
func (s *Store) FirstAndLastMail() (first, last *MailRef, err error) {
err = filepath.WalkDir(filepath.Join(s.dir, "store"), func(path string, d fs.DirEntry, werr error) error {
if werr != nil {
return werr
}
if d.IsDir() {
return nil
}
info, err := d.Info()
if err != nil {
return err
}
ref := &MailRef{ID: d.Name(), ModTime: info.ModTime()}
if first == nil || ref.ModTime.Before(first.ModTime) {
first = ref
}
if last == nil || ref.ModTime.After(last.ModTime) {
last = ref
}
return nil
})
if err != nil {
return nil, nil, fmt.Errorf("storage: first/last: %w", err)
}
return first, last, nil
}
// filePath returns the on-disk path for a given mail ID.
// Uses 2-char prefix sharding: {dir}/store/{id[:2]}/{id}
func (s *Store) filePath(id string) string {
return filepath.Join(s.dir, "store", id[:2], id)
}
+126
View File
@@ -0,0 +1,126 @@
package storage_test
import (
"bytes"
"os"
"path/filepath"
"testing"
"time"
"github.com/archivmail/internal/storage"
)
func TestSaveAndLoad(t *testing.T) {
dir := t.TempDir()
store, err := storage.New(dir)
if err != nil {
t.Fatalf("New: %v", err)
}
raw := []byte("From: alice@example.com\r\nSubject: Test\r\n\r\nHello World")
id, err := store.Save(raw, time.Now())
if err != nil {
t.Fatalf("Save: %v", err)
}
if len(id) != 64 {
t.Errorf("expected 64-char SHA256 hex, got %d chars", len(id))
}
got, err := store.Load(id)
if err != nil {
t.Fatalf("Load: %v", err)
}
if !bytes.Equal(raw, got) {
t.Errorf("loaded content mismatch")
}
}
func TestDeduplication(t *testing.T) {
dir := t.TempDir()
store, err := storage.New(dir)
if err != nil {
t.Fatal(err)
}
raw := []byte("From: alice@example.com\r\n\r\nDuplicate test")
id1, err := store.Save(raw, time.Now())
if err != nil {
t.Fatal(err)
}
id2, err := store.Save(raw, time.Now())
if err != nil {
t.Fatal(err)
}
if id1 != id2 {
t.Errorf("duplicate mail produced different IDs: %s vs %s", id1, id2)
}
// Only one file should exist
count := 0
filepath.Walk(filepath.Join(dir, "store"), func(p string, info os.FileInfo, _ error) error {
if !info.IsDir() { count++ }
return nil
})
if count != 1 {
t.Errorf("expected 1 stored file after dedup, got %d", count)
}
}
func TestDelete(t *testing.T) {
dir := t.TempDir()
store, err := storage.New(dir)
if err != nil {
t.Fatal(err)
}
raw := []byte("From: alice@example.com\r\n\r\nDelete me")
id, _ := store.Save(raw, time.Now())
if err := store.Delete(id); err != nil {
t.Fatalf("Delete: %v", err)
}
if _, err := store.Load(id); err == nil {
t.Error("Load after Delete should return error")
}
}
func TestStats(t *testing.T) {
dir := t.TempDir()
store, err := storage.New(dir)
if err != nil {
t.Fatal(err)
}
mails := [][]byte{
[]byte("From: a@x.com\r\n\r\nMail 1"),
[]byte("From: b@x.com\r\n\r\nMail 2"),
[]byte("From: c@x.com\r\n\r\nMail 3"),
}
for _, m := range mails {
store.Save(m, time.Now())
}
stats, err := store.Stats()
if err != nil {
t.Fatalf("Stats: %v", err)
}
if stats.TotalMails != 3 {
t.Errorf("expected 3 mails, got %d", stats.TotalMails)
}
if stats.TotalBytes <= 0 {
t.Error("expected positive TotalBytes")
}
}
func TestStorageDirectoryCreation(t *testing.T) {
dir := filepath.Join(t.TempDir(), "nested", "path")
_, err := storage.New(dir)
if err != nil {
t.Fatalf("New with nested path: %v", err)
}
for _, sub := range []string{"store", "attachments", "meta"} {
if _, err := os.Stat(filepath.Join(dir, sub)); os.IsNotExist(err) {
t.Errorf("expected subdirectory %s to be created", sub)
}
}
}