Files
restic-manager/web/templates/pages/alerts.html
T
steve 3800b34a2b
CI / Test (rest) (pull_request) Successful in 29s
CI / Lint (pull_request) Successful in 32s
CI / Build (windows/amd64) (pull_request) Successful in 22s
CI / Test (store) (pull_request) Successful in 1m22s
CI / Test (server-http) (pull_request) Successful in 1m30s
CI / Build (linux/amd64) (pull_request) Successful in 22s
CI / Build (linux/arm64) (pull_request) Successful in 41s
testing: bootstrap UI, agent reliability, NS-01..04 + alert username
Smoothes the rough edges that came up exercising a live deployment.

First-run bootstrap UI: /bootstrap renders a username + password form
that uses the in-memory token directly (operator no longer copies it
out of the log); /login redirects there while bootstrap is available.

Agent reliability: failJob synthetic envelopes so command.run early
returns no longer hang the server-side job; runtime probe of restic
restore --help drives --no-ownership instead of version sniffing
(0.18.x had it removed). Server unit re-shaped: ProtectSystem=full
plus ReadWritePaths=/etc/restic-manager, no ProtectHome — restore
can now write anywhere a user might want.

Restore wizard: default target is /root/rm-restore/<job-id>/ with
clearer help text. Re-init confirm input uses .field (was .input,
which doesn't exist — text was invisible).

NS-01 host delete: store DeleteHost, admin-band /hosts/{id}/delete
with hostname-confirm danger zone, audit, FK cascade, live WS close.

NS-02 enrollment-token recovery: outstanding-tokens panel on
/hosts/new, regenerate (preserves attachments) and revoke handlers
+ audit, store-level ListOutstandingEnrollmentTokens and
DeleteEnrollmentToken.

NS-03 repo init / probe surface: migration 0020 adds
hosts.repo_status + repo_status_error; WS handler projects every
init job's outcome onto the host row (idempotent already-initialised
collapses to ready); creds-save resets status and dispatches a fresh
probe; /hosts/{id}/repo/probe retry endpoint with banner.

NS-04 dashboard live + sort + filter: query-string filter
(q/status/repo_status/tag/sort/dir), 5s htmx live poll mirroring the
alerts pattern with a localStorage live toggle, sortable column
headers, filter row + clear.

Alerts page: ack'd-by line resolves user_id ULID to username.

Compose.yaml ignored — host-specific.
2026-05-05 22:03:15 +01:00

174 lines
8.3 KiB
HTML

{{define "title"}}Alerts · restic-manager{{end}}
{{define "content"}}
{{$page := .Page}}
{{$filter := $page.Filter}}
<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">alerts</span>
</div>
{{/* page header */}}
<div class="flex items-baseline justify-between mt-3.5">
<div>
<h1 class="text-[22px] font-medium tracking-[-0.005em]">
Alerts
<span class="text-ink-fade font-normal text-[14px] ml-2">
{{$page.Counts.Open}} open
{{if gt $page.Counts.Acknowledged 0}} · {{$page.Counts.Acknowledged}} acknowledged{{end}}
· {{$page.Counts.Resolved24h}} resolved (24h)
</span>
</h1>
</div>
<div class="flex gap-2">
<a href="/settings/notifications" class="btn">Channel settings →</a>
</div>
</div>
{{/* triage legend — Acknowledge vs Resolve looks identical on screen
(both leave the Open tab) so the difference needs spelling out. */}}
<div class="text-ink-mute mt-2 leading-[1.55]" style="font-size: 11.5px; max-width: 760px;">
<span class="text-ink-mid">Acknowledge</span> silences fan-out while the underlying problem is still happening — the alert moves to the Acknowledged tab.
<span class="text-ink-mid">Resolve</span> closes the alert; the next failure raises a fresh one with a new notification.
</div>
{{/* filter strip */}}
<div class="panel mt-4 px-4 py-3 rounded-[7px]"
style="display: grid; grid-template-columns: auto auto auto 1fr; gap: 14px; align-items: center;">
{{/* status pills */}}
<div class="inline-flex gap-1 p-[3px]" style="border: 1px solid var(--line-soft); border-radius: 5px;">
{{range list "open" "acknowledged" "resolved" "all"}}
{{$s := .}}
{{$active := eq $s $filter.Status}}
{{if and (eq $s "all") (eq $filter.Status "")}}{{$active = true}}{{end}}
<a href="/alerts?status={{$s}}{{if $filter.Severity}}&severity={{$filter.Severity}}{{end}}{{if $filter.HostID}}&host_id={{$filter.HostID}}{{end}}{{if $filter.Search}}&q={{$filter.Search}}{{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 $s "open"}}Open <span class="text-ink-fade mono ml-1">{{$page.Counts.Open}}</span>
{{else if eq $s "acknowledged"}}Acknowledged <span class="text-ink-fade mono ml-1">{{$page.Counts.Acknowledged}}</span>
{{else if eq $s "resolved"}}Resolved <span class="text-ink-fade mono ml-1">{{$page.Counts.Resolved24h}}</span>
{{else}}All{{end}}
</a>
{{end}}
</div>
{{/* severity dropdown — option text tinted to match the colour
already used in the row (dot, left border, kind chip). The
severity word is otherwise invisible to operators because the
table column shows kind only; the colour bridges the two. */}}
<div>
<select class="field" style="padding: 6px 10px; font-size: 11.5px; min-width: 130px;"
onchange="window.location='/alerts?status={{$filter.Status}}&severity='+this.value+'{{if $filter.HostID}}&host_id={{$filter.HostID}}{{end}}{{if $filter.Search}}&q={{$filter.Search}}{{end}}'">
<option value="" {{if eq $filter.Severity ""}}selected{{end}}>Severity · any</option>
<option value="info" style="color: oklch(0.78 0.005 250);" {{if eq $filter.Severity "info"}}selected{{end}}>● info</option>
<option value="warning" style="color: oklch(0.82 0.13 80);" {{if eq $filter.Severity "warning"}}selected{{end}}>● warning</option>
<option value="critical" style="color: oklch(0.70 0.20 25);" {{if eq $filter.Severity "critical"}}selected{{end}}>● critical</option>
</select>
</div>
{{/* host dropdown */}}
<div>
<select class="field" style="padding: 6px 10px; font-size: 11.5px; min-width: 160px;"
onchange="window.location='/alerts?status={{$filter.Status}}{{if $filter.Severity}}&severity={{$filter.Severity}}{{end}}&host_id='+this.value+'{{if $filter.Search}}&q={{$filter.Search}}{{end}}'">
<option value="" {{if eq $filter.HostID ""}}selected{{end}}>Host · all</option>
{{range $id, $name := $page.HostNames}}
<option value="{{$id}}" {{if eq $filter.HostID $id}}selected{{end}}>{{$name}}</option>
{{end}}
</select>
</div>
{{/* search input */}}
<form method="get" action="/alerts">
<input type="hidden" name="status" value="{{$filter.Status}}">
{{if $filter.Severity}}<input type="hidden" name="severity" value="{{$filter.Severity}}">{{end}}
{{if $filter.HostID}}<input type="hidden" name="host_id" value="{{$filter.HostID}}">{{end}}
<input type="text" name="q" value="{{$filter.Search}}"
placeholder="search message…"
class="field mono"
style="padding: 6px 10px; font-size: 11.5px;">
</form>
</div>
{{/* alerts table — polled every 5s when the tab is visible AND the
live toggle is on. The localStorage check is part of the htmx
trigger predicate, so flipping the toggle just sets the flag and
the next tick (or the absence of one) honours it. No need to
re-process the element when the toggle changes.
The polling lives on this div (not the page root) so the filter
strip and header don't flash on each tick. */}}
<div id="alerts-table" class="panel mt-3.5 rounded-[7px] overflow-hidden"
hx-get="{{$page.RefreshURL}}"
hx-trigger="every 5s [document.visibilityState==='visible' && localStorage.getItem('rm-alerts-live')!=='off']"
hx-select="#alerts-table"
hx-swap="outerHTML">
{{/* header row */}}
<div class="alert-row head">
<div></div>
<div>Kind</div>
<div>Host</div>
<div>Message</div>
<div>Raised</div>
<div>Last seen</div>
<div style="display: flex; align-items: center; gap: 6px; justify-content: flex-end;">
<label style="display: inline-flex; align-items: center; gap: 5px; cursor: pointer; font-size: 10px;"
class="text-ink-fade" title="auto-refresh every 5s">
<input type="checkbox" id="alerts-live-toggle" checked
onchange="localStorage.setItem('rm-alerts-live', this.checked ? 'on' : 'off'); document.getElementById('alerts-live-dot').style.opacity = this.checked ? '1' : '0.3';"
style="width: 11px; height: 11px; cursor: pointer; margin: 0;" />
<span>live</span>
<span id="alerts-live-dot" class="text-accent"></span>
</label>
</div>
</div>
{{if eq (len $page.Alerts) 0}}
{{/* empty state */}}
<div style="padding: 40px; text-align: center;">
<div class="inline-flex items-center gap-3.5">
<span class="dot dot-online" style="width: 10px; height: 10px;"></span>
<div style="text-align: left;">
<div class="text-ink text-[14px] font-medium">All clear.</div>
<div class="text-ink-mute text-[12px] mt-0.5">
No alerts match the current filter.
</div>
</div>
</div>
</div>
{{else}}
{{range $page.Alerts}}
{{template "alert_row" (dict "Alert" . "HostNames" $page.HostNames "Usernames" $page.Usernames "Filter" $page.Filter)}}
{{end}}
{{end}}
</div>
</div>
<script>
// Restore the live-refresh toggle from localStorage so the operator's
// last choice survives full-page navigations. Re-runs after every htmx
// swap so the freshly-rendered checkbox + dot stay in sync.
(function syncLiveToggle() {
var on = localStorage.getItem('rm-alerts-live') !== 'off';
var cb = document.getElementById('alerts-live-toggle');
var dot = document.getElementById('alerts-live-dot');
if (cb) cb.checked = on;
if (dot) dot.style.opacity = on ? '1' : '0.3';
})();
document.body.addEventListener('htmx:afterSwap', function(e) {
if (e.detail.target && e.detail.target.id === 'alerts-table') {
var on = localStorage.getItem('rm-alerts-live') !== 'off';
var cb = document.getElementById('alerts-live-toggle');
var dot = document.getElementById('alerts-live-dot');
if (cb) cb.checked = on;
if (dot) dot.style.opacity = on ? '1' : '0.3';
}
});
</script>
{{end}}