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

This commit is contained in:
2026-05-05 08:00:53 +01:00
parent 3f36bcd0b0
commit 489f831fc7
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
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -314,7 +314,7 @@
/* ---------- audit rows (/audit list) ---------- */
.audit-row {
display: grid; align-items: center;
grid-template-columns: 110px 80px 110px 1.4fr 1.6fr 110px;
grid-template-columns: 160px 80px 110px 1.4fr 1.5fr 90px;
column-gap: 16px;
padding: 11px 16px; font-size: 13px;
border-bottom: 1px solid var(--line-soft);
+95 -6
View File
@@ -22,6 +22,16 @@
</span>
</h1>
</div>
<div class="flex gap-2">
{{/* Export carries the current filter querystring so the
download is exactly what the operator sees on screen
(up to a higher row cap of 5000 vs 500 in the table). */}}
<a href="/audit.csv?range={{$rng}}{{if $filter.UserID}}&user_id={{$filter.UserID}}{{end}}{{if $filter.Actor}}&actor={{$filter.Actor}}{{end}}{{if $filter.ActionLike}}&action={{$filter.ActionLike}}{{end}}{{if $filter.TargetKind}}&target_kind={{$filter.TargetKind}}{{end}}"
class="btn"
title="Download the current filter as CSV (up to 5000 rows, UTF-8, RFC 4180)">
Export CSV ↓
</a>
</div>
</div>
<div class="text-ink-mute mt-2 leading-[1.55]" style="font-size: 11.5px; max-width: 760px;">
@@ -119,8 +129,8 @@
{{range $page.Entries}}
{{$e := .}}
<div class="audit-row">
<div class="mono text-[12px] text-ink-mute" title="{{absTime $e.TS}}">
{{relTime $e.TS}}
<div class="mono text-[12px] text-ink-mute" title="UTC">
{{absTime $e.TS}}
</div>
<div>
{{if eq $e.Actor "user"}}<span class="tag" style="background: color-mix(in oklch, var(--accent), transparent 92%); border-color: color-mix(in oklch, var(--accent), transparent 60%); color: var(--accent);">user</span>
@@ -145,10 +155,17 @@
</div>
<div class="text-right">
{{if and $e.Payload (gt (len $e.Payload) 2)}}
<details class="inline-block">
<summary class="text-ink-fade cursor-pointer" style="font-size: 11px;">payload</summary>
<pre class="mono text-[11px] text-ink-mute mt-2" style="white-space: pre-wrap; max-width: 400px; text-align: left; background: var(--bg); border: 1px solid var(--line-soft); border-radius: 4px; padding: 6px 8px;">{{printf "%s" $e.Payload}}</pre>
</details>
{{/* Payload is base64-encoded onto a data- attribute to
bypass html/template's contextual JS-string escaping
(which would double-escape arbitrary JSON inside a
<script type="application/json"> block). Decoded by
atob() in the modal opener. */}}
<button type="button" class="btn"
style="font-size: 11px; padding: 3px 8px;"
data-payload-action="{{$e.Action}}"
data-payload-id="{{$e.ID}}"
data-payload-b64="{{b64 $e.Payload}}"
onclick="window.__rmAuditOpenPayload(this)">payload </button>
{{end}}
</div>
</div>
@@ -157,5 +174,77 @@
</div>
{{/* Payload modal — single instance shared by every row. Centred
overlay with a max-height; the inner <pre> scrolls when the
payload is long. Closes on backdrop click, Escape key, or the
× button. Plain JSON is pretty-printed; non-JSON falls back to
the raw string. */}}
<div id="audit-payload-modal" class="fixed inset-0 z-50 hidden"
style="background: rgba(0,0,0,0.55); align-items: center; justify-content: center;"
onclick="if (event.target === this) window.__rmAuditClosePayload()">
<div class="panel rounded-[7px]"
style="width: min(720px, 90vw); max-height: 80vh; display: flex; flex-direction: column;"
onclick="event.stopPropagation()">
<div class="flex items-center justify-between"
style="padding: 14px 18px; border-bottom: 1px solid var(--line-soft);">
<div>
<div class="text-[13px] font-medium text-ink" id="audit-payload-title">payload</div>
<div class="text-[11px] text-ink-fade mono mt-0.5" id="audit-payload-subtitle"></div>
</div>
<div class="flex gap-2">
<button type="button" class="btn"
style="font-size: 11.5px;"
onclick="window.__rmAuditCopyPayload()">Copy</button>
<button type="button" class="btn"
style="font-size: 11.5px;"
onclick="window.__rmAuditClosePayload()">×</button>
</div>
</div>
<pre id="audit-payload-body" class="mono text-[12px] text-ink-mid"
style="margin: 0; padding: 16px 18px; overflow: auto; white-space: pre-wrap; word-break: break-all; flex: 1; background: var(--bg);"></pre>
</div>
</div>
</div>
<script>
(function() {
var modal = document.getElementById('audit-payload-modal');
var bodyEl = document.getElementById('audit-payload-body');
var titleEl = document.getElementById('audit-payload-title');
var subEl = document.getElementById('audit-payload-subtitle');
var current = '';
window.__rmAuditOpenPayload = function(btn) {
var id = btn.getAttribute('data-payload-id');
var action = btn.getAttribute('data-payload-action');
var b64 = btn.getAttribute('data-payload-b64') || '';
var raw = '';
try { raw = atob(b64); } catch (e) { raw = ''; }
try {
current = JSON.stringify(JSON.parse(raw), null, 2);
} catch (e) {
current = raw;
}
bodyEl.textContent = current;
titleEl.textContent = action;
subEl.textContent = id;
modal.style.display = 'flex';
modal.classList.remove('hidden');
};
window.__rmAuditClosePayload = function() {
modal.classList.add('hidden');
modal.style.display = 'none';
};
window.__rmAuditCopyPayload = function() {
if (!current) return;
navigator.clipboard.writeText(current).catch(function() {});
};
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && !modal.classList.contains('hidden')) {
window.__rmAuditClosePayload();
}
});
})();
</script>
{{end}}