feat(audit): CSV export, absolute timestamps, payload modal

This commit is contained in:
2026-05-05 08:00:53 +01:00
parent 16c77a8cc5
commit 86fe569ea0
6 changed files with 209 additions and 29 deletions
+1
View File
@@ -322,6 +322,7 @@ func (s *Server) routes(r chi.Router) {
r.Post("/alerts/{id}/resolve", s.handleUIAlertResolve)
// Audit log (read-only).
r.Get("/audit", s.handleUIAudit)
r.Get("/audit.csv", s.handleUIAuditCSV)
// Settings shell + Notifications sub-tab CRUD.
r.Get("/settings", s.handleUISettings)
r.Get("/settings/notifications", s.handleUINotificationsList)
+93 -21
View File
@@ -2,8 +2,9 @@
//
// Routes (wired in server.go):
//
// GET /audit → handleUIAudit (HTML)
// GET /api/audit → handleAPIAudit (JSON)
// GET /audit → handleUIAudit (HTML)
// GET /audit.csv → handleUIAuditCSV (CSV download honouring current filters)
// 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*
@@ -12,7 +13,9 @@
package http
import (
"encoding/csv"
"encoding/json"
"fmt"
"log/slog"
stdhttp "net/http"
"strings"
@@ -45,23 +48,34 @@ func rangeToSince(r string, now time.Time) time.Time {
}
}
func (s *Server) handleUIAudit(w stdhttp.ResponseWriter, r *stdhttp.Request) {
u := s.requireUIUser(w, r)
if u == nil {
return
}
// auditFilterFromQuery extracts the AuditFilter + range preset from
// the request querystring. Shared by the HTML, CSV, and JSON handlers
// so all three honour the same filter URL.
func auditFilterFromQuery(r *stdhttp.Request) (store.AuditFilter, string) {
q := r.URL.Query()
rng := q.Get("range")
if rng == "" {
rng = "24h"
}
f := store.AuditFilter{
return 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,
Limit: 5000, // CSV export tolerates more rows; HTML clamps via paging later
}, rng
}
func (s *Server) handleUIAudit(w stdhttp.ResponseWriter, r *stdhttp.Request) {
u := s.requireUIUser(w, r)
if u == nil {
return
}
f, rng := auditFilterFromQuery(r)
// HTML page caps lower than CSV — keeps the table snappy.
if f.Limit > 500 {
f.Limit = 500
}
entries, err := s.deps.Store.ListAudit(r.Context(), f)
@@ -107,18 +121,9 @@ func (s *Server) handleAPIAudit(w stdhttp.ResponseWriter, r *stdhttp.Request) {
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,
f, _ := auditFilterFromQuery(r)
if f.Limit > 500 {
f.Limit = 500
}
entries, err := s.deps.Store.ListAudit(r.Context(), f)
if err != nil {
@@ -128,3 +133,70 @@ func (s *Server) handleAPIAudit(w stdhttp.ResponseWriter, r *stdhttp.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(map[string]any{"entries": entries})
}
// handleUIAuditCSV streams the filtered audit log as CSV. Auth-gated
// like the HTML page; honours the same filter querystring so an
// operator can refine the view in the browser, hit Export, and get
// exactly what's on screen (plus more rows up to the 5000 cap).
func (s *Server) handleUIAuditCSV(w stdhttp.ResponseWriter, r *stdhttp.Request) {
u := s.requireUIUser(w, r)
if u == nil {
return
}
f, _ := auditFilterFromQuery(r)
entries, err := s.deps.Store.ListAudit(r.Context(), f)
if err != nil {
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
// Resolve user_id → username and host_id → name once for the
// human-friendly columns.
userNames := map[string]string{}
if users, err := s.deps.Store.ListUsers(r.Context()); err == nil {
for _, ux := range users {
userNames[ux.ID] = ux.Username
}
}
hostNames := map[string]string{}
if hosts, err := s.deps.Store.ListHosts(r.Context()); err == nil {
for _, h := range hosts {
hostNames[h.ID] = h.Name
}
}
stamp := time.Now().UTC().Format("20060102-150405")
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
w.Header().Set("Content-Disposition",
fmt.Sprintf(`attachment; filename="audit-%s.csv"`, stamp))
cw := csv.NewWriter(w)
defer cw.Flush()
_ = cw.Write([]string{"timestamp_utc", "actor", "user", "user_id", "action", "target_kind", "target_id", "target_name", "payload"})
for _, e := range entries {
var uid, uname string
if e.UserID != nil {
uid = *e.UserID
uname = userNames[uid]
}
var tk, tid, tname string
if e.TargetKind != nil {
tk = *e.TargetKind
}
if e.TargetID != nil {
tid = *e.TargetID
}
if tk == "host" {
tname = hostNames[tid]
}
payload := ""
if len(e.Payload) > 0 {
payload = string(e.Payload)
}
_ = cw.Write([]string{
e.TS.UTC().Format("2006-01-02 15:04:05"),
e.Actor, uname, uid, e.Action,
tk, tid, tname, payload,
})
}
}
+18
View File
@@ -1,6 +1,8 @@
package ui
import (
"encoding/base64"
"encoding/json"
"fmt"
"html/template"
"strconv"
@@ -25,6 +27,22 @@ func funcMap() template.FuncMap {
}
return t.Format("2006-01-02 15:04:05")
},
// b64 encodes a json.RawMessage (or any []byte / string) as
// base64 — used by audit.html to stash arbitrary JSON in a
// data- attribute without fighting html/template's contextual
// escaping. JS atob() decodes on click.
"b64": func(v any) string {
switch x := v.(type) {
case json.RawMessage:
return base64.StdEncoding.EncodeToString(x)
case []byte:
return base64.StdEncoding.EncodeToString(x)
case string:
return base64.StdEncoding.EncodeToString([]byte(x))
default:
return base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%v", x)))
}
},
"derefInt": func(p *int) int {
if p == nil {
return 0