Files
restic-manager/web/templates/pages/alerts.html
T
steve 9860b412f7 feat(alerts): live-refresh the table every 15s while the tab is visible
The alerts list is the one screen where staleness is genuinely
harmful — an operator can be looking at an Open tab that's already
been resolved by another admin or auto-resolved by the engine, and
take action on a row that no longer exists.

Add an htmx poll on just the table panel:

  hx-get        same URL with current querystring (filters preserved)
  hx-trigger    every 15s, only when document is visible (no idle CPU)
  hx-select     #alerts-table — pull this element out of the response
  hx-swap       outerHTML

Polling lives on the table div, not the page root, so the filter
strip and header don't flash on each tick. Header gains a small
'live ●' label so the polling is discoverable.

RefreshURL is r.URL.RequestURI() on the server side — keeps any
status/severity/host_id/q params intact across refreshes.

Other screens (dashboard, hosts, jobs) deliberately stay manual-
refresh per the project's anti-flicker stance.
2026-05-04 23:30:19 +01:00

142 lines
6.4 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 */}}
<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" {{if eq $filter.Severity "info"}}selected{{end}}>info</option>
<option value="warning" {{if eq $filter.Severity "warning"}}selected{{end}}>warning</option>
<option value="critical" {{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 15s while the tab is visible.
hx-get re-fetches the same URL (so filter querystring is preserved)
and hx-select pulls just this element out of the full response,
replacing the live one. Pauses automatically when the tab is
backgrounded so we're not burning CPU on inactive tabs.
The polling lives here (not on 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 15s [document.visibilityState==='visible']"
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>
<span class="text-ink-fade" style="font-size: 10px;" title="auto-refresh every 15s">live ●</span>
</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 "Filter" $page.Filter)}}
{{end}}
{{end}}
</div>
</div>
{{end}}