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)
|
r.Post("/alerts/{id}/resolve", s.handleUIAlertResolve)
|
||||||
// Audit log (read-only).
|
// Audit log (read-only).
|
||||||
r.Get("/audit", s.handleUIAudit)
|
r.Get("/audit", s.handleUIAudit)
|
||||||
|
r.Get("/audit.csv", s.handleUIAuditCSV)
|
||||||
// Settings shell + Notifications sub-tab CRUD.
|
// Settings shell + Notifications sub-tab CRUD.
|
||||||
r.Get("/settings", s.handleUISettings)
|
r.Get("/settings", s.handleUISettings)
|
||||||
r.Get("/settings/notifications", s.handleUINotificationsList)
|
r.Get("/settings/notifications", s.handleUINotificationsList)
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
//
|
//
|
||||||
// Routes (wired in server.go):
|
// Routes (wired in server.go):
|
||||||
//
|
//
|
||||||
// GET /audit → handleUIAudit (HTML)
|
// GET /audit → handleUIAudit (HTML)
|
||||||
// GET /api/audit → handleAPIAudit (JSON)
|
// GET /audit.csv → handleUIAuditCSV (CSV download honouring current filters)
|
||||||
|
// GET /api/audit → handleAPIAudit (JSON)
|
||||||
//
|
//
|
||||||
// Filters: user, actor, action (substring), target_kind, time-range
|
// Filters: user, actor, action (substring), target_kind, time-range
|
||||||
// preset (24h | 7d | 30d | all). Page-level live refresh is *not*
|
// preset (24h | 7d | 30d | all). Page-level live refresh is *not*
|
||||||
@@ -12,7 +13,9 @@
|
|||||||
package http
|
package http
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/csv"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
stdhttp "net/http"
|
stdhttp "net/http"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -45,23 +48,34 @@ func rangeToSince(r string, now time.Time) time.Time {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleUIAudit(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
// auditFilterFromQuery extracts the AuditFilter + range preset from
|
||||||
u := s.requireUIUser(w, r)
|
// the request querystring. Shared by the HTML, CSV, and JSON handlers
|
||||||
if u == nil {
|
// so all three honour the same filter URL.
|
||||||
return
|
func auditFilterFromQuery(r *stdhttp.Request) (store.AuditFilter, string) {
|
||||||
}
|
|
||||||
q := r.URL.Query()
|
q := r.URL.Query()
|
||||||
rng := q.Get("range")
|
rng := q.Get("range")
|
||||||
if rng == "" {
|
if rng == "" {
|
||||||
rng = "24h"
|
rng = "24h"
|
||||||
}
|
}
|
||||||
f := store.AuditFilter{
|
return store.AuditFilter{
|
||||||
UserID: q.Get("user_id"),
|
UserID: q.Get("user_id"),
|
||||||
Actor: q.Get("actor"),
|
Actor: q.Get("actor"),
|
||||||
ActionLike: strings.TrimSpace(q.Get("action")),
|
ActionLike: strings.TrimSpace(q.Get("action")),
|
||||||
TargetKind: q.Get("target_kind"),
|
TargetKind: q.Get("target_kind"),
|
||||||
Since: rangeToSince(rng, time.Now().UTC()),
|
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)
|
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", "")
|
writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorised", "")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
q := r.URL.Query()
|
f, _ := auditFilterFromQuery(r)
|
||||||
rng := q.Get("range")
|
if f.Limit > 500 {
|
||||||
if rng == "" {
|
f.Limit = 500
|
||||||
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)
|
entries, err := s.deps.Store.ListAudit(r.Context(), f)
|
||||||
if err != nil {
|
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")
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
_ = json.NewEncoder(w).Encode(map[string]any{"entries": entries})
|
_ = 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
|
package ui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -25,6 +27,22 @@ func funcMap() template.FuncMap {
|
|||||||
}
|
}
|
||||||
return t.Format("2006-01-02 15:04:05")
|
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 {
|
"derefInt": func(p *int) int {
|
||||||
if p == nil {
|
if p == nil {
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -314,7 +314,7 @@
|
|||||||
/* ---------- audit rows (/audit list) ---------- */
|
/* ---------- audit rows (/audit list) ---------- */
|
||||||
.audit-row {
|
.audit-row {
|
||||||
display: grid; align-items: center;
|
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;
|
column-gap: 16px;
|
||||||
padding: 11px 16px; font-size: 13px;
|
padding: 11px 16px; font-size: 13px;
|
||||||
border-bottom: 1px solid var(--line-soft);
|
border-bottom: 1px solid var(--line-soft);
|
||||||
|
|||||||
@@ -22,6 +22,16 @@
|
|||||||
</span>
|
</span>
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div class="text-ink-mute mt-2 leading-[1.55]" style="font-size: 11.5px; max-width: 760px;">
|
<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}}
|
{{range $page.Entries}}
|
||||||
{{$e := .}}
|
{{$e := .}}
|
||||||
<div class="audit-row">
|
<div class="audit-row">
|
||||||
<div class="mono text-[12px] text-ink-mute" title="{{absTime $e.TS}}">
|
<div class="mono text-[12px] text-ink-mute" title="UTC">
|
||||||
{{relTime $e.TS}}
|
{{absTime $e.TS}}
|
||||||
</div>
|
</div>
|
||||||
<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>
|
{{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>
|
||||||
<div class="text-right">
|
<div class="text-right">
|
||||||
{{if and $e.Payload (gt (len $e.Payload) 2)}}
|
{{if and $e.Payload (gt (len $e.Payload) 2)}}
|
||||||
<details class="inline-block">
|
{{/* Payload is base64-encoded onto a data- attribute to
|
||||||
<summary class="text-ink-fade cursor-pointer" style="font-size: 11px;">payload</summary>
|
bypass html/template's contextual JS-string escaping
|
||||||
<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>
|
(which would double-escape arbitrary JSON inside a
|
||||||
</details>
|
<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}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -157,5 +174,77 @@
|
|||||||
|
|
||||||
</div>
|
</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>
|
</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}}
|
{{end}}
|
||||||
|
|||||||
Reference in New Issue
Block a user