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)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// Routes (wired in server.go):
|
||||
//
|
||||
// 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
|
||||
@@ -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
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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);
|
||||
|
||||
@@ -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}}
|
||||
|
||||
Reference in New Issue
Block a user