feat(audit): P3-08 — audit log UI with filters

This commit is contained in:
2026-05-05 07:49:25 +01:00
parent cb3260b89c
commit 3f36bcd0b0
9 changed files with 617 additions and 3 deletions
+5
View File
@@ -207,6 +207,9 @@ func (s *Server) routes(r chi.Router) {
// Alert list (JSON variant). Same filter shape as the UI page.
r.Get("/alerts", s.handleAPIAlerts)
// Audit log (JSON variant).
r.Get("/audit", s.handleAPIAudit)
// Notification channel test-fire. Dispatches a synthetic payload
// through a single named channel; returns JSON result.
r.Post("/notifications/{id}/test", s.handleAPINotificationTest)
@@ -317,6 +320,8 @@ func (s *Server) routes(r chi.Router) {
r.Get("/alerts", s.handleUIAlerts)
r.Post("/alerts/{id}/acknowledge", s.handleUIAlertAcknowledge)
r.Post("/alerts/{id}/resolve", s.handleUIAlertResolve)
// Audit log (read-only).
r.Get("/audit", s.handleUIAudit)
// Settings shell + Notifications sub-tab CRUD.
r.Get("/settings", s.handleUISettings)
r.Get("/settings/notifications", s.handleUINotificationsList)
+130
View File
@@ -0,0 +1,130 @@
// ui_audit.go — Audit log read-only surfaces.
//
// Routes (wired in server.go):
//
// GET /audit → handleUIAudit (HTML)
// GET /api/audit → handleAPIAudit (JSON)
//
// Filters: user, actor, action (substring), target_kind, time-range
// preset (24h | 7d | 30d | all). Page-level live refresh is *not*
// added here — audit is append-only and operators inspect history,
// not current state.
package http
import (
"encoding/json"
"log/slog"
stdhttp "net/http"
"strings"
"time"
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
)
type auditPage struct {
Filter store.AuditFilter
Range string // "24h" | "7d" | "30d" | "all"
Entries []store.AuditEntry
UserNames map[string]string // user_id → username for row rendering
HostNames map[string]string // host_id → name (for target_kind=host display)
Actions []string // distinct actions seen so far, for the dropdown
}
// rangeToSince converts the time-range preset to a Since cutoff. "all"
// (or unrecognised) returns the zero time, meaning "no lower bound".
func rangeToSince(r string, now time.Time) time.Time {
switch r {
case "24h", "":
return now.Add(-24 * time.Hour)
case "7d":
return now.Add(-7 * 24 * time.Hour)
case "30d":
return now.Add(-30 * 24 * time.Hour)
default:
return time.Time{}
}
}
func (s *Server) handleUIAudit(w stdhttp.ResponseWriter, r *stdhttp.Request) {
u := s.requireUIUser(w, r)
if u == nil {
return
}
q := r.URL.Query()
rng := q.Get("range")
if rng == "" {
rng = "24h"
}
f := store.AuditFilter{
UserID: q.Get("user_id"),
Actor: q.Get("actor"),
ActionLike: strings.TrimSpace(q.Get("action")),
TargetKind: q.Get("target_kind"),
Since: rangeToSince(rng, time.Now().UTC()),
Limit: 500,
}
entries, err := s.deps.Store.ListAudit(r.Context(), f)
if err != nil {
slog.Error("ui audit: list", "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
page := auditPage{
Filter: f,
Range: rng,
Entries: entries,
UserNames: map[string]string{},
HostNames: map[string]string{},
}
if users, err := s.deps.Store.ListUsers(r.Context()); err == nil {
for _, ux := range users {
page.UserNames[ux.ID] = ux.Username
}
}
if hosts, err := s.deps.Store.ListHosts(r.Context()); err == nil {
for _, h := range hosts {
page.HostNames[h.ID] = h.Name
}
}
if actions, err := s.deps.Store.DistinctAuditActions(r.Context()); err == nil {
page.Actions = actions
}
view := s.baseView(r, u)
view.Title = "Audit · restic-manager"
view.Active = "audit"
view.Page = page
if err := s.deps.UI.Render(w, "audit", view); err != nil {
slog.Error("ui audit: render", "err", err)
}
}
// handleAPIAudit is the JSON variant — same filters as the HTML page.
func (s *Server) handleAPIAudit(w stdhttp.ResponseWriter, r *stdhttp.Request) {
if _, ok := s.requireUser(r); !ok {
writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorised", "")
return
}
q := r.URL.Query()
rng := q.Get("range")
if rng == "" {
rng = "24h"
}
f := store.AuditFilter{
UserID: q.Get("user_id"),
Actor: q.Get("actor"),
ActionLike: strings.TrimSpace(q.Get("action")),
TargetKind: q.Get("target_kind"),
Since: rangeToSince(rng, time.Now().UTC()),
Limit: 500,
}
entries, err := s.deps.Store.ListAudit(r.Context(), f)
if err != nil {
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error())
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(map[string]any{"entries": entries})
}