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
+196
View File
@@ -0,0 +1,196 @@
package audit
import (
"context"
"fmt"
"log/slog"
"strings"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
const (
EventLogin = "login"
EventLogout = "logout"
EventSearch = "search"
EventMailView = "mail_view"
EventImport = "import"
EventExport = "export"
EventUserMgmt = "user_mgmt"
)
// Entry is a single audit log record.
type Entry struct {
ID int64 `json:"id"`
Timestamp time.Time `json:"timestamp"`
EventType string `json:"event_type"`
Username string `json:"username"`
IPAddress string `json:"ip_address"`
Query string `json:"query"`
MailID string `json:"mail_id"`
Success bool `json:"success"`
Detail string `json:"detail"`
}
// QueryFilter specifies filtering options for audit log queries.
type QueryFilter struct {
Username string
EventType string
MailID string
From *time.Time
To *time.Time
PageSize int
Page int
}
// Logger is a PostgreSQL-backed, append-only audit log.
type Logger struct {
pool *pgxpool.Pool
logger *slog.Logger
}
// New connects to PostgreSQL using the given DSN and initialises the schema.
// logDir is reserved for future flat-file logging.
func New(dsn, logDir string, logger *slog.Logger) (*Logger, error) {
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
return nil, fmt.Errorf("audit: connect: %w", err)
}
_, err = pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS audit_log (
id BIGSERIAL PRIMARY KEY,
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
event_type VARCHAR(50) NOT NULL,
username VARCHAR(255) NOT NULL DEFAULT '',
ip_address VARCHAR(45) NOT NULL DEFAULT '',
query TEXT NOT NULL DEFAULT '',
mail_id VARCHAR(255) NOT NULL DEFAULT '',
success BOOLEAN NOT NULL DEFAULT true,
detail TEXT NOT NULL DEFAULT ''
);
`)
if err != nil {
pool.Close()
return nil, fmt.Errorf("audit: create schema: %w", err)
}
return &Logger{pool: pool, logger: logger}, nil
}
// Log appends an entry to the audit log. Errors are logged but not returned.
func (l *Logger) Log(entry Entry) {
ts := entry.Timestamp
if ts.IsZero() {
ts = time.Now().UTC()
}
ctx := context.Background()
_, err := l.pool.Exec(ctx,
`INSERT INTO audit_log (timestamp, event_type, username, ip_address, query, mail_id, success, detail)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
ts.UTC(),
entry.EventType,
entry.Username,
entry.IPAddress,
entry.Query,
entry.MailID,
entry.Success,
entry.Detail,
)
if err != nil {
l.logger.Error("audit: insert failed", "err", err)
}
}
// Query retrieves audit entries matching the given filter, returning the
// matched entries, the total count (ignoring pagination), and any error.
func (l *Logger) Query(filter QueryFilter) ([]Entry, int, error) {
pageSize := filter.PageSize
if pageSize <= 0 {
pageSize = 50
}
where, args := buildWhere(filter)
ctx := context.Background()
// Count total
countSQL := "SELECT COUNT(*) FROM audit_log" + where
var total int
if err := l.pool.QueryRow(ctx, countSQL, args...).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("audit: count: %w", err)
}
offset := filter.Page * pageSize
// Append limit and offset as next positional args
limitArg := len(args) + 1
offsetArg := len(args) + 2
querySQL := fmt.Sprintf(
"SELECT id, timestamp, event_type, username, ip_address, query, mail_id, success, detail FROM audit_log%s ORDER BY timestamp DESC LIMIT $%d OFFSET $%d",
where, limitArg, offsetArg,
)
allArgs := append(args, pageSize, offset)
rows, err := l.pool.Query(ctx, querySQL, allArgs...)
if err != nil {
return nil, 0, fmt.Errorf("audit: query: %w", err)
}
defer rows.Close()
var entries []Entry
for rows.Next() {
var e Entry
if err := rows.Scan(&e.ID, &e.Timestamp, &e.EventType, &e.Username, &e.IPAddress, &e.Query, &e.MailID, &e.Success, &e.Detail); err != nil {
return nil, 0, fmt.Errorf("audit: scan: %w", err)
}
entries = append(entries, e)
}
return entries, total, rows.Err()
}
// Close closes the audit connection pool.
func (l *Logger) Close() error {
l.pool.Close()
return nil
}
// buildWhere constructs a SQL WHERE clause from QueryFilter fields using
// positional parameters ($1, $2, ...) for PostgreSQL.
func buildWhere(f QueryFilter) (string, []interface{}) {
var clauses []string
var args []interface{}
n := 1
if f.Username != "" {
clauses = append(clauses, fmt.Sprintf("username = $%d", n))
args = append(args, f.Username)
n++
}
if f.EventType != "" {
clauses = append(clauses, fmt.Sprintf("event_type = $%d", n))
args = append(args, f.EventType)
n++
}
if f.MailID != "" {
clauses = append(clauses, fmt.Sprintf("mail_id = $%d", n))
args = append(args, f.MailID)
n++
}
if f.From != nil {
clauses = append(clauses, fmt.Sprintf("timestamp >= $%d", n))
args = append(args, f.From.UTC())
n++
}
if f.To != nil {
clauses = append(clauses, fmt.Sprintf("timestamp <= $%d", n))
args = append(args, f.To.UTC())
n++
}
if len(clauses) == 0 {
return "", args
}
return " WHERE " + strings.Join(clauses, " AND "), args
}