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
+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}}