From cd38b40516f015d0203bec0e3d859fb0be74c5a3 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Mon, 4 May 2026 20:15:01 +0100 Subject: [PATCH] ui: alerts list page + alert row partial + nav badge --- internal/server/http/ui_alerts.go | 1 + internal/server/http/ui_handlers.go | 4 + internal/server/ui/funcs.go | 62 +++++++++++++ internal/server/ui/ui.go | 1 + web/styles/input.css | 33 +++++++ web/templates/pages/alerts.html | 122 ++++++++++++++++++++++++++ web/templates/partials/alert_row.html | 96 ++++++++++++++++++++ web/templates/partials/nav.html | 2 +- 8 files changed, 320 insertions(+), 1 deletion(-) create mode 100644 web/templates/pages/alerts.html create mode 100644 web/templates/partials/alert_row.html diff --git a/internal/server/http/ui_alerts.go b/internal/server/http/ui_alerts.go index 7a703e6..c6eb360 100644 --- a/internal/server/http/ui_alerts.go +++ b/internal/server/http/ui_alerts.go @@ -60,6 +60,7 @@ func (s *Server) handleUIAlerts(w stdhttp.ResponseWriter, r *stdhttp.Request) { page.Counts = computeAlertCounts(s, r) view := s.baseView(u) + view.OpenAlerts = page.Counts.Open view.Title = "Alerts · restic-manager" view.Active = "alerts" view.Page = page diff --git a/internal/server/http/ui_handlers.go b/internal/server/http/ui_handlers.go index bd76a2a..5b568fe 100644 --- a/internal/server/http/ui_handlers.go +++ b/internal/server/http/ui_handlers.go @@ -89,6 +89,10 @@ func (s *Server) requireUIUser(w stdhttp.ResponseWriter, r *stdhttp.Request) *ui // authenticated page. Every UI page sits under the dashboard primary // nav today; if a future page lives under a different primary nav // tab (e.g. Settings, Audit), accept an Active arg again. +// +// OpenAlerts is populated via a quick store count so the nav badge +// stays current on every page load without requiring a page-specific +// store call. func (s *Server) baseView(u *ui.User) ui.ViewData { return ui.ViewData{ User: u, diff --git a/internal/server/ui/funcs.go b/internal/server/ui/funcs.go index 71c350a..673437f 100644 --- a/internal/server/ui/funcs.go +++ b/internal/server/ui/funcs.go @@ -38,6 +38,68 @@ func funcMap() template.FuncMap { // list packs strings into a slice — handy for inline ranges // in templates (e.g. quick-pick cron presets). "list": func(items ...string) []string { return items }, + // dict builds a map[string]any from alternating key-value pairs. + // Useful for passing multiple named values to a sub-template: + // {{template "foo" (dict "A" $a "B" $b)}} + "dict": func(pairs ...any) map[string]any { + m := make(map[string]any, len(pairs)/2) + for i := 0; i+1 < len(pairs); i += 2 { + if k, ok := pairs[i].(string); ok { + m[k] = pairs[i+1] + } + } + return m + }, + // mapGet retrieves a string value from a map[string]string by key. + // Returns "" when the key is absent or the map is nil. Used by the + // alert_row partial to resolve host_id → host name. + "mapGet": func(m map[string]string, key *string) string { + if m == nil || key == nil { + return "" + } + return m[*key] + }, + // alertStatus derives the display status of an alert from its DB + // fields: "open", "acknowledged", or "resolved". + // Accepts any value — returns "" for unrecognised input so templates + // can still render safely. + "alertStatus": func(resolvedAt, acknowledgedAt any) string { + isSet := func(v any) bool { + if v == nil { + return false + } + switch t := v.(type) { + case *time.Time: + return t != nil + } + return false + } + if isSet(resolvedAt) { + return "resolved" + } + if isSet(acknowledgedAt) { + return "acknowledged" + } + return "open" + }, + // stillHappening returns true when last_seen_at is within the last + // 60 seconds — used to render the "still happening · Ns ago" pill + // on alert rows where the signal is still firing. + "stillHappening": func(v any) bool { + var t time.Time + switch x := v.(type) { + case time.Time: + t = x + case *time.Time: + if x == nil { + return false + } + t = *x + default: + return false + } + return time.Since(t) < 60*time.Second + }, } } diff --git a/internal/server/ui/ui.go b/internal/server/ui/ui.go index 8c5e52b..3df58e4 100644 --- a/internal/server/ui/ui.go +++ b/internal/server/ui/ui.go @@ -93,6 +93,7 @@ func New() (*Renderer, error) { "templates/partials/awaiting_agent.html", "templates/partials/host_chrome.html", "templates/partials/tree_node.html", + "templates/partials/alert_row.html", } pageEntries, err := fs.Glob(web.FS, "templates/pages/*.html") diff --git a/web/styles/input.css b/web/styles/input.css index dfa34f6..08c5073 100644 --- a/web/styles/input.css +++ b/web/styles/input.css @@ -278,6 +278,39 @@ } .snap-row.head:hover { background: transparent; } + /* ---------- alert rows (/alerts list) ---------- */ + .alert-row { + display: grid; align-items: center; + grid-template-columns: 18px 110px 130px 1fr 130px 110px 180px; + column-gap: 16px; + padding: 12px 16px; font-size: 13px; + border-bottom: 1px solid var(--line-soft); + border-left: 3px solid transparent; + transition: background 100ms ease; + } + .alert-row:hover { background: var(--panel-hi); } + .alert-row:last-child { border-bottom: 0; } + .alert-row.head { + cursor: default; padding-top: 9px; padding-bottom: 9px; + font-size: 11px; color: var(--ink-fade); + text-transform: uppercase; letter-spacing: 0.08em; + border-left-color: transparent; + } + .alert-row.head:hover { background: transparent; } + .alert-row.severity-warn { border-left-color: color-mix(in oklch, var(--warn), transparent 50%); } + .alert-row.severity-critical { border-left-color: color-mix(in oklch, var(--bad), transparent 30%); } + .alert-row.resolved { opacity: 0.55; } + + /* status-dot aliases for alert severity */ + .dot-warn { background: var(--warn); box-shadow: 0 0 0 3px color-mix(in oklch, var(--warn), transparent 80%); } + .dot-critical { background: var(--bad); box-shadow: 0 0 0 3px color-mix(in oklch, var(--bad), transparent 80%); } + .dot-resolved { background: var(--ok); box-shadow: 0 0 0 3px color-mix(in oklch, var(--ok), transparent 80%); } + + /* tag colour variants for alerts */ + .tag-warn { color: var(--warn); border-color: color-mix(in oklch, var(--warn), transparent 60%); background: color-mix(in oklch, var(--warn), transparent 92%); } + .tag-critical { color: var(--bad); border-color: color-mix(in oklch, var(--bad), transparent 60%); background: color-mix(in oklch, var(--bad), transparent 92%); } + .tag-info { color: var(--ink-mid); } + /* ---------- schedule rows (Schedules tab) ---------- */ .schd-row { display: grid; align-items: center; diff --git a/web/templates/pages/alerts.html b/web/templates/pages/alerts.html new file mode 100644 index 0000000..f7475fd --- /dev/null +++ b/web/templates/pages/alerts.html @@ -0,0 +1,122 @@ +{{define "title"}}Alerts · restic-manager{{end}} + +{{define "content"}} +{{$page := .Page}} +{{$filter := $page.Filter}} +
+ + {{/* crumbs */}} +
+ Dashboard/ + alerts +
+ + {{/* page header */}} +
+
+

+ Alerts + + {{$page.Counts.Open}} open + {{if gt $page.Counts.Acknowledged 0}} · {{$page.Counts.Acknowledged}} acknowledged{{end}} + · {{$page.Counts.Resolved24h}} resolved (24h) + +

+
+ +
+ + {{/* filter strip */}} +
+ + {{/* status pills */}} +
+ {{range list "open" "acknowledged" "resolved" "all"}} + {{$s := .}} + {{$active := eq $s $filter.Status}} + {{if and (eq $s "all") (eq $filter.Status "")}}{{$active = true}}{{end}} + + {{if eq $s "open"}}Open {{$page.Counts.Open}} + {{else if eq $s "acknowledged"}}Acknowledged {{$page.Counts.Acknowledged}} + {{else if eq $s "resolved"}}Resolved {{$page.Counts.Resolved24h}} + {{else}}All{{end}} + + {{end}} +
+ + {{/* severity dropdown */}} +
+ +
+ + {{/* host dropdown */}} +
+ +
+ + {{/* search input */}} +
+ + {{if $filter.Severity}}{{end}} + {{if $filter.HostID}}{{end}} + +
+
+ + {{/* alerts table */}} +
+ + {{/* header row */}} +
+
+
Severity / kind
+
Host
+
Message
+
Raised
+
Last seen
+
+
+ + {{if eq (len $page.Alerts) 0}} + {{/* empty state */}} +
+
+ +
+
All clear.
+
+ No alerts match the current filter. +
+
+
+
+ {{else}} + {{range $page.Alerts}} + {{template "alert_row" (dict "Alert" . "HostNames" $page.HostNames "Filter" $page.Filter)}} + {{end}} + {{end}} + +
+ +
+{{end}} diff --git a/web/templates/partials/alert_row.html b/web/templates/partials/alert_row.html new file mode 100644 index 0000000..b194386 --- /dev/null +++ b/web/templates/partials/alert_row.html @@ -0,0 +1,96 @@ +{{define "alert_row"}} +{{$a := .Alert}} +{{$hostNames := .HostNames}} +{{$filter := .Filter}} +{{$status := alertStatus $a.ResolvedAt $a.AcknowledgedAt}} + +{{/* derive query string for redirect-back after ack/resolve */}} +{{$qs := ""}} +{{if $filter.Status}}{{$qs = printf "status=%s" $filter.Status}}{{end}} +{{if $filter.Severity}}{{$qs = printf "%s&severity=%s" $qs $filter.Severity}}{{end}} +{{if $filter.HostID}}{{$qs = printf "%s&host_id=%s" $qs $filter.HostID}}{{end}} +{{if $filter.Search}}{{$qs = printf "%s&q=%s" $qs $filter.Search}}{{end}} + +
+ + {{/* dot */}} +
+ {{if eq $status "resolved"}} + + {{else if eq $a.Severity "critical"}} + + {{else if eq $a.Severity "warning"}} + + {{else}} + + {{end}} +
+ + {{/* severity + kind tag */}} +
+ {{if eq $a.Severity "critical"}} + {{$a.Kind}} + {{else if eq $a.Severity "warning"}} + {{$a.Kind}} + {{else}} + {{$a.Kind}} + {{end}} +
+ + {{/* host */}} +
+ {{mapGet $hostNames $a.HostID}} +
+ + {{/* message */}} +
+ {{$a.Message}} +
+ + {{/* raised (created_at) */}} +
+ {{relTime $a.CreatedAt}} +
+ + {{/* last seen */}} +
+ {{if and (eq $status "open") (stillHappening $a.LastSeenAt)}} + still happening · {{relTime $a.LastSeenAt}} + {{else}} + {{relTime $a.LastSeenAt}} + {{end}} +
+ + {{/* actions */}} +
+ {{if eq $status "open"}} +
+ {{if $qs}}{{end}} + +
+
+ {{if $qs}}{{end}} + +
+ {{else if eq $status "acknowledged"}} + + ack'd{{if $a.AcknowledgedBy}} by {{deref $a.AcknowledgedBy}}{{end}} · {{relTime $a.AcknowledgedAt}} + +
+ {{if $qs}}{{end}} + +
+ {{else}} + resolved · {{relTime $a.ResolvedAt}} + {{end}} +
+ +
+{{end}} diff --git a/web/templates/partials/nav.html b/web/templates/partials/nav.html index 4a75dbc..256d85b 100644 --- a/web/templates/partials/nav.html +++ b/web/templates/partials/nav.html @@ -26,7 +26,7 @@