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
270 lines
13 KiB
HTML
270 lines
13 KiB
HTML
{{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}}
|