feat(audit): CSV export, absolute timestamps, payload modal
This commit is contained in:
@@ -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