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}} +