Files
restic-manager/web/templates/pages/audit.html
T
steve ba425c9766
CI / Build (windows/amd64) (pull_request) Successful in 23s
CI / Lint (pull_request) Successful in 34s
CI / Build (linux/amd64) (pull_request) Successful in 23s
CI / Build (linux/arm64) (pull_request) Successful in 21s
CI / Test (linux/amd64) (pull_request) Successful in 3m41s
feat(audit): clickable column headers with asc/desc sort
2026-05-05 08:15:22 +01:00

270 lines
13 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{{define "title"}}Audit · restic-manager{{end}}
{{define "content"}}
{{$page := .Page}}
{{$filter := $page.Filter}}
{{$rng := $page.Range}}
<div class="max-w-[1280px] mx-auto px-8 pb-14">
{{/* crumbs */}}
<div class="crumbs pt-6">
<a href="/">Dashboard</a><span class="sep">/</span>
<span class="text-ink-mid">audit</span>
</div>
{{/* page header */}}
<div class="flex items-baseline justify-between mt-3.5">
<div>
<h1 class="text-[22px] font-medium tracking-[-0.005em]">
Audit log
<span class="text-ink-fade font-normal text-[14px] ml-2">
{{len $page.Entries}} entries · last {{if eq $rng "all"}}all-time{{else}}{{$rng}}{{end}}
</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="{{$page.CSVHref}}"
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;">
Append-only history of every operator action, agent message, and system-driven change.
Read-only — entries cannot be edited or deleted.
</div>
{{/* filter strip */}}
<div class="panel mt-4 px-4 py-3 rounded-[7px]"
style="display: grid; grid-template-columns: auto auto auto auto 1fr; gap: 14px; align-items: center;">
{{/* time-range pills */}}
<div class="inline-flex gap-1 p-[3px]" style="border: 1px solid var(--line-soft); border-radius: 5px;">
{{range list "24h" "7d" "30d" "all"}}
{{$r := .}}
{{$active := eq $r $rng}}
<a href="/audit?range={{$r}}{{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 btn-ghost"
style="padding: 5px 10px; font-size: 11.5px;{{if $active}} background: var(--panel-hi); color: var(--ink);{{end}}">
{{if eq $r "all"}}All{{else}}{{$r}}{{end}}
</a>
{{end}}
</div>
{{/* user dropdown */}}
<div>
<select class="field" style="padding: 6px 10px; font-size: 11.5px; min-width: 140px;"
onchange="window.location='/audit?range={{$rng}}&user_id='+this.value+'{{if $filter.Actor}}&actor={{$filter.Actor}}{{end}}{{if $filter.ActionLike}}&action={{$filter.ActionLike}}{{end}}{{if $filter.TargetKind}}&target_kind={{$filter.TargetKind}}{{end}}'">
<option value="" {{if eq $filter.UserID ""}}selected{{end}}>User · any</option>
{{range $id, $name := $page.UserNames}}
<option value="{{$id}}" {{if eq $filter.UserID $id}}selected{{end}}>{{$name}}</option>
{{end}}
</select>
</div>
{{/* actor dropdown — user/agent/system */}}
<div>
<select class="field" style="padding: 6px 10px; font-size: 11.5px; min-width: 130px;"
onchange="window.location='/audit?range={{$rng}}{{if $filter.UserID}}&user_id={{$filter.UserID}}{{end}}&actor='+this.value+'{{if $filter.ActionLike}}&action={{$filter.ActionLike}}{{end}}{{if $filter.TargetKind}}&target_kind={{$filter.TargetKind}}{{end}}'">
<option value="" {{if eq $filter.Actor ""}}selected{{end}}>Actor · any</option>
<option value="user" {{if eq $filter.Actor "user"}}selected{{end}}>user</option>
<option value="agent" {{if eq $filter.Actor "agent"}}selected{{end}}>agent</option>
<option value="system" {{if eq $filter.Actor "system"}}selected{{end}}>system</option>
</select>
</div>
{{/* target kind dropdown */}}
<div>
<select class="field" style="padding: 6px 10px; font-size: 11.5px; min-width: 160px;"
onchange="window.location='/audit?range={{$rng}}{{if $filter.UserID}}&user_id={{$filter.UserID}}{{end}}{{if $filter.Actor}}&actor={{$filter.Actor}}{{end}}{{if $filter.ActionLike}}&action={{$filter.ActionLike}}{{end}}&target_kind='+this.value">
<option value="" {{if eq $filter.TargetKind ""}}selected{{end}}>Target · any</option>
<option value="host" {{if eq $filter.TargetKind "host"}}selected{{end}}>host</option>
<option value="schedule" {{if eq $filter.TargetKind "schedule"}}selected{{end}}>schedule</option>
<option value="source_group" {{if eq $filter.TargetKind "source_group"}}selected{{end}}>source_group</option>
<option value="alert" {{if eq $filter.TargetKind "alert"}}selected{{end}}>alert</option>
<option value="notification_channel" {{if eq $filter.TargetKind "notification_channel"}}selected{{end}}>notification_channel</option>
<option value="job" {{if eq $filter.TargetKind "job"}}selected{{end}}>job</option>
<option value="user" {{if eq $filter.TargetKind "user"}}selected{{end}}>user</option>
</select>
</div>
{{/* action substring search */}}
<form method="get" action="/audit">
<input type="hidden" name="range" value="{{$rng}}">
{{if $filter.UserID}}<input type="hidden" name="user_id" value="{{$filter.UserID}}">{{end}}
{{if $filter.Actor}}<input type="hidden" name="actor" value="{{$filter.Actor}}">{{end}}
{{if $filter.TargetKind}}<input type="hidden" name="target_kind" value="{{$filter.TargetKind}}">{{end}}
<input type="text" name="action" value="{{$filter.ActionLike}}"
placeholder="action contains… (e.g. alert., host.)"
class="field mono"
style="padding: 6px 10px; font-size: 11.5px;">
</form>
</div>
{{/* table */}}
<div class="panel mt-3.5 rounded-[7px] overflow-hidden">
{{/* Header — every column except the payload one is a clickable
sort link. Hrefs are pre-built server-side ($page.SortHrefs)
so the URL escaping rules don't trip on the '=' chars when
html/template encodes <a href> attributes. */}}
<div class="audit-row head">
<div>
<a href="{{index $page.SortHrefs "ts"}}"
class="sort-header">When <span class="sort-glyph">{{sortGlyph "ts" $page.Sort $page.Dir}}</span></a>
</div>
<div>
<a href="{{index $page.SortHrefs "actor"}}"
class="sort-header">Actor <span class="sort-glyph">{{sortGlyph "actor" $page.Sort $page.Dir}}</span></a>
</div>
<div>
<a href="{{index $page.SortHrefs "user_id"}}"
class="sort-header">User <span class="sort-glyph">{{sortGlyph "user_id" $page.Sort $page.Dir}}</span></a>
</div>
<div>
<a href="{{index $page.SortHrefs "action"}}"
class="sort-header">Action <span class="sort-glyph">{{sortGlyph "action" $page.Sort $page.Dir}}</span></a>
</div>
<div>
<a href="{{index $page.SortHrefs "target_kind"}}"
class="sort-header">Target <span class="sort-glyph">{{sortGlyph "target_kind" $page.Sort $page.Dir}}</span></a>
</div>
<div></div>
</div>
{{if eq (len $page.Entries) 0}}
<div style="padding: 40px; text-align: center;">
<div class="text-ink text-[14px] font-medium">No matching entries.</div>
<div class="text-ink-mute text-[12px] mt-1">
{{if eq $rng "24h"}}Try widening the time range.{{else}}Adjust filters or pick a longer range.{{end}}
</div>
</div>
{{else}}
{{range $page.Entries}}
{{$e := .}}
<div class="audit-row">
<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>
{{else if eq $e.Actor "agent"}}<span class="tag" style="background: color-mix(in oklch, var(--ok), transparent 92%); border-color: color-mix(in oklch, var(--ok), transparent 60%); color: var(--ok);">agent</span>
{{else}}<span class="tag" style="background: color-mix(in oklch, var(--ink-fade), transparent 92%); color: var(--ink-mute);">system</span>{{end}}
</div>
<div class="mono text-[12px] text-ink-mid">
{{if $e.UserID}}{{$un := index $page.UserNames (deref $e.UserID)}}{{if $un}}{{$un}}{{else}}<span class="text-ink-fade">{{deref $e.UserID}}</span>{{end}}{{else}}<span class="text-ink-fade"></span>{{end}}
</div>
<div class="mono text-[12px] text-ink">{{$e.Action}}</div>
<div class="mono text-[12px] text-ink-mute">
{{if $e.TargetKind}}
<span class="text-ink-fade">{{deref $e.TargetKind}}</span>
{{if $e.TargetID}}
{{$tid := deref $e.TargetID}}
{{if eq (deref $e.TargetKind) "host"}}{{$hn := index $page.HostNames $tid}}{{if $hn}} · {{$hn}}{{else}} · {{$tid}}{{end}}
{{else}} · {{$tid}}{{end}}
{{end}}
{{else}}
<span class="text-ink-fade"></span>
{{end}}
</div>
<div class="text-right">
{{if and $e.Payload (gt (len $e.Payload) 2)}}
{{/* 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>
{{end}}
{{end}}
</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}}