Files
restic-manager/web/templates/pages/alerts.html
T
2026-08-22 13:42:42 +01:00

178 lines
8.6 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">
<form method="post" action="/alerts/abandon-stuck"
onsubmit="return confirm('Mark every currently alerted stuck job as cancelled? Use this only for jobs no longer running on their agents.');">
<button type="submit" class="btn btn-danger">Abandon all stuck jobs</button>
</form>
<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}}