feat(audit): P3-08 — audit log UI with filters
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
Reference in New Issue
Block a user