feat(audit): CSV export, absolute timestamps, payload modal
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user