ui: /settings/notifications list + edit form (3 kinds)

Add settings.html (shell + sub-tab nav + conditional list/edit body),
notifications.html and notification_edit.html (glob stubs), and the
supporting CSS tokens (.ch-row, .ch-icon, .toggle, .kind-grid,
.kind-card, .radio-pip, .test-pill) to input.css. Rebuild styles.css.
Add ui_parse_test.go to catch template regressions at test time.

The kind picker is JS-driven (no full page reload); the enabled toggle
mirrors the existing visual toggle pattern; the test-notification button
uses HTMX and renders the JSON response as a coloured pill client-side.
This commit is contained in:
2026-05-04 20:25:06 +01:00
parent 9dbed025e0
commit e0847517a8
6 changed files with 686 additions and 1 deletions
+12
View File
@@ -0,0 +1,12 @@
package ui
import "testing"
// TestNewParsesAllTemplates ensures ui.New() can parse every template
// registered under templates/pages/ without error. Run this after
// adding or editing any template file.
func TestNewParsesAllTemplates(t *testing.T) {
if _, err := New(); err != nil {
t.Fatalf("ui.New() returned error: %v", err)
}
}
File diff suppressed because one or more lines are too long
+99
View File
@@ -451,4 +451,103 @@
radial-gradient(ellipse at top, color-mix(in oklch, var(--accent), transparent 95%), transparent 60%), radial-gradient(ellipse at top, color-mix(in oklch, var(--accent), transparent 95%), transparent 60%),
var(--panel); var(--panel);
} }
/* ---------- notification channel rows (/settings/notifications) ---------- */
.ch-row {
display: grid; align-items: center;
grid-template-columns: 28px 200px 1fr 100px 130px 140px;
column-gap: 16px;
padding: 14px 18px; font-size: 13px;
border-bottom: 1px solid var(--line-soft);
transition: background 100ms ease;
}
.ch-row:last-child { border-bottom: 0; }
.ch-row.head {
cursor: default; font-size: 11px; color: var(--ink-fade);
text-transform: uppercase; letter-spacing: 0.08em;
padding-top: 10px; padding-bottom: 10px;
}
.ch-row.head:hover { background: transparent; }
/* Whole-row click → edit page (mirrors .host-row.clickable). */
.ch-row.clickable { position: relative; cursor: pointer; }
.ch-row.clickable .row-link {
position: absolute; inset: 0; z-index: 0;
text-indent: -9999px; overflow: hidden;
}
.ch-row.clickable:hover { background: var(--panel-hi); }
.ch-row.clickable > * { position: relative; z-index: 1; pointer-events: none; }
.ch-row.clickable > .row-link { pointer-events: auto; }
.ch-row.clickable > .row-action { pointer-events: auto; }
/* Channel kind icons */
.ch-icon {
width: 24px; height: 24px;
border-radius: 5px;
display: inline-flex; align-items: center; justify-content: center;
font-family: 'JetBrains Mono', monospace; font-size: 10px; font-weight: 600;
background: var(--panel-hi); color: var(--ink-mute);
border: 1px solid var(--line);
}
.ch-icon.webhook { color: var(--accent); border-color: color-mix(in oklch, var(--accent), transparent 60%); }
.ch-icon.ntfy { color: var(--warn); border-color: color-mix(in oklch, var(--warn), transparent 60%); }
.ch-icon.smtp { color: var(--ok); border-color: color-mix(in oklch, var(--ok), transparent 60%); }
/* ---------- toggle (enabled/disabled switch) ---------- */
.toggle {
display: inline-block; width: 30px; height: 16px; border-radius: 9999px;
background: var(--line); position: relative; cursor: pointer;
transition: background 120ms ease; flex-shrink: 0;
}
.toggle::after {
content: ""; position: absolute; left: 2px; top: 2px;
width: 12px; height: 12px; border-radius: 9999px;
background: var(--ink-mid);
transition: all 120ms ease;
}
.toggle.on { background: color-mix(in oklch, var(--accent), transparent 50%); }
.toggle.on::after { left: 16px; background: var(--accent); }
/* ---------- kind-picker radio cards (channel edit form) ---------- */
.kind-grid { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 14px; }
.kind-card {
border: 1px solid var(--line-soft); background: var(--bg);
border-radius: 7px; padding: 16px;
cursor: pointer;
transition: border-color 120ms ease, background 120ms ease;
}
.kind-card:hover { border-color: var(--ink-mute); }
.kind-card.selected {
border-color: color-mix(in oklch, var(--accent), transparent 50%);
background: color-mix(in oklch, var(--accent), transparent 95%);
}
/* Radio pip inside kind cards */
.radio-pip {
width: 14px; height: 14px;
border-radius: 9999px;
border: 1px solid var(--line);
display: inline-flex; align-items: center; justify-content: center;
flex-shrink: 0;
}
.radio-pip.on { border-color: var(--accent); }
.radio-pip.on::after {
content: ""; width: 6px; height: 6px; border-radius: 9999px;
background: var(--accent);
}
/* ---------- test-result pills (notification test button) ---------- */
.test-pill {
display: inline-block;
padding: 5px 10px; border-radius: 5px; font-size: 12.5px;
}
.test-pill-ok {
border: 1px solid color-mix(in oklch, var(--ok), transparent 60%);
background: color-mix(in oklch, var(--ok), transparent 92%);
color: var(--ok);
}
.test-pill-fail {
border: 1px solid color-mix(in oklch, var(--bad), transparent 60%);
background: color-mix(in oklch, var(--bad), transparent 92%);
color: var(--bad);
}
} }
@@ -0,0 +1,9 @@
{{/* notification_edit.html — rendered by handleUINotificationEditGet/Post via Render("settings", …).
This file exists so the glob-discovered page registry includes it cleanly.
The actual edit form lives in settings.html's notification_edit_form block. */}}
{{define "title"}}Edit Channel · Settings · restic-manager{{end}}
{{define "content"}}
{{/* This page is served under the "settings" renderer key; this file is a
placeholder discovered by the glob so ui.New() registers "notification_edit"
as a valid page. Handlers do not call Render("notification_edit", …) directly. */}}
{{end}}
+9
View File
@@ -0,0 +1,9 @@
{{/* notifications.html — rendered by handleUINotificationsList via Render("settings", …).
This file exists so the glob-discovered page registry includes it cleanly.
The actual list body lives in settings.html's notification_list_body block. */}}
{{define "title"}}Notifications · Settings · restic-manager{{end}}
{{define "content"}}
{{/* This page is served under the "settings" renderer key; this file is a
placeholder discovered by the glob so ui.New() registers "notifications"
as a valid page. Handlers do not call Render("notifications", …) directly. */}}
{{end}}
+556
View File
@@ -0,0 +1,556 @@
{{define "title"}}{{.Title}}{{end}}
{{define "content"}}
{{$page := .Page}}
<div class="max-w-[1280px] mx-auto px-8 pt-7 pb-14">
{{/* ---------- breadcrumbs ---------- */}}
<div class="crumbs">
<a href="/">Dashboard</a><span class="sep">/</span>
{{if $page.Form}}
<a href="/settings">Settings</a><span class="sep">/</span>
<a href="/settings/notifications">notifications</a><span class="sep">/</span>
{{if $page.Form.ID}}
<span class="text-ink-mid">{{$page.Form.Name}}</span>
{{else}}
<span class="text-ink-mid">new channel</span>
{{end}}
{{else}}
<a href="/settings">Settings</a><span class="sep">/</span>
<span class="text-ink-mid">notifications</span>
{{end}}
</div>
{{/* ---------- page header ---------- */}}
<div class="flex items-baseline justify-between mt-3.5">
{{if $page.Form}}
<h1 class="text-[22px] font-medium tracking-[-0.005em]">
{{if $page.Form.ID}}Edit channel · <span class="mono text-ink-mid">{{$page.Form.Name}}</span>{{else}}Add channel{{end}}
</h1>
{{else}}
<h1 class="text-[22px] font-medium tracking-[-0.005em]">Settings</h1>
<a href="/settings/notifications/new" class="btn btn-primary">+ Add channel</a>
{{end}}
</div>
{{/* ---------- sub-tab nav ---------- */}}
<div class="flex items-end mt-3.5 border-b border-line-soft">
<a class="sub-tab {{if eq $page.ActiveTab "notifications"}}active{{end}}" href="/settings/notifications">
Notifications
{{if not $page.Form}}<span class="mono text-ink-fade text-[11px] ml-1">{{len $page.Channels}}</span>{{end}}
</a>
<span class="sub-tab text-ink-fade cursor-default" title="lands later">Users</span>
<span class="sub-tab text-ink-fade cursor-default" title="lands later">Authentication</span>
</div>
{{/* ---------- sub-tab body ---------- */}}
<div class="mt-5">
{{if $page.Form}}
{{template "notification_edit_form" $page}}
{{else}}
{{template "notification_list_body" $page}}
{{end}}
</div>
</div>
{{end}}
{{/* ================================================================
notification_list_body — channel list (embedded in settings.html)
Receives $page (settingsPage).
================================================================ */}}
{{define "notification_list_body"}}
<p class="text-[12.5px] text-ink-mute mb-4 leading-[1.6] max-w-[720px]">
Notification channels fire when the alert engine raises an alert.
All channels apply globally — every alert that meets the engine's thresholds is sent to every enabled channel.
</p>
{{if not .Channels}}
<div class="empty-state mt-2">
<h3 class="text-[16px] font-medium">No channels configured.</h3>
<p class="text-ink-mute text-[13px] mt-2 mx-auto max-w-[480px] leading-[1.6]">
Alerts are still raised in the dashboard, but nothing is pushed to chat / phone / email.
Add a channel to get notified.
</p>
<div class="mt-5">
<a href="/settings/notifications/new" class="btn btn-primary">+ Add your first channel</a>
</div>
</div>
{{else}}
<div class="panel rounded-[7px] overflow-hidden">
<div class="ch-row head">
<div></div>
<div>Name</div>
<div>Endpoint</div>
<div>Enabled</div>
<div>Last fired</div>
<div></div>
</div>
{{range .Channels}}
{{$ch := .}}
<div class="ch-row clickable">
<a class="row-link" href="/settings/notifications/{{$ch.ID}}/edit">edit {{$ch.Name}}</a>
<div>
{{if eq $ch.Kind "webhook"}}<span class="ch-icon webhook">WH</span>
{{else if eq $ch.Kind "ntfy"}}<span class="ch-icon ntfy">NT</span>
{{else}}<span class="ch-icon smtp">@</span>{{end}}
</div>
<div class="text-ink font-medium">{{$ch.Name}}</div>
<div class="mono text-ink-mute" style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
{{if eq $ch.Kind "webhook"}}webhook · click to edit{{else if eq $ch.Kind "ntfy"}}ntfy · click to edit{{else}}smtp · click to edit{{end}}
</div>
<div>
{{if $ch.Enabled}}<span class="toggle on"></span>{{else}}<span class="toggle"></span>{{end}}
</div>
<div class="mono text-ink-mid">
{{if $ch.LastFiredAt}}{{relTime $ch.LastFiredAt}}{{else}}<span class="text-ink-fade">never</span>{{end}}
</div>
<div class="row-action flex gap-1.5 justify-end">
<a href="/settings/notifications/{{$ch.ID}}/edit" class="btn">Edit</a>
<a href="/settings/notifications/{{$ch.ID}}/edit#delete-panel" class="btn btn-danger">Delete</a>
</div>
</div>
{{end}}
</div>
{{end}}
{{end}}
{{/* ================================================================
notification_edit_form — create/edit form (embedded in settings.html)
Receives $page (settingsPage).
================================================================ */}}
{{define "notification_edit_form"}}
{{$f := .Form}}
{{$isEdit := ne $f.ID ""}}
{{if .FormError}}
<div class="rounded-[6px] px-3.5 py-3 text-[13px] mb-4"
style="border: 1px solid color-mix(in oklch, var(--bad), transparent 60%); background: color-mix(in oklch, var(--bad), transparent 92%);">
{{.FormError}}
</div>
{{end}}
{{if .DeleteError}}
<div class="rounded-[6px] px-3.5 py-3 text-[13px] mb-4"
style="border: 1px solid color-mix(in oklch, var(--bad), transparent 60%); background: color-mix(in oklch, var(--bad), transparent 92%);">
{{.DeleteError}}
</div>
{{end}}
<div class="grid gap-6 items-start" style="grid-template-columns: 1fr 380px;">
<div>
{{/* ---------- kind picker ---------- */}}
<div class="mb-5">
<div class="field-label mb-2.5">Channel kind</div>
<div class="kind-grid">
{{/* Webhook card */}}
<label class="kind-card {{if eq $f.Kind "webhook"}}selected{{end}}">
<input type="radio" name="kind" value="webhook" class="hidden kind-radio"
{{if eq $f.Kind "webhook"}}checked{{end}} />
<div class="flex items-center gap-3">
<span class="radio-pip {{if eq $f.Kind "webhook"}}on{{end}}"></span>
<span class="ch-icon webhook">WH</span>
<div>
<div class="text-ink text-[13px] font-medium">Webhook</div>
<div class="text-ink-mute text-[11.5px] mt-0.5">POST a JSON envelope to your URL.</div>
</div>
</div>
</label>
{{/* Ntfy card */}}
<label class="kind-card {{if eq $f.Kind "ntfy"}}selected{{end}}" style="{{if eq $f.Kind "ntfy"}}border-color: color-mix(in oklch, var(--warn), transparent 50%); background: color-mix(in oklch, var(--warn), transparent 95%);{{end}}">
<input type="radio" name="kind" value="ntfy" class="hidden kind-radio"
{{if eq $f.Kind "ntfy"}}checked{{end}} />
<div class="flex items-center gap-3">
<span class="radio-pip {{if eq $f.Kind "ntfy"}}on{{end}}" style="{{if eq $f.Kind "ntfy"}}border-color: var(--warn);{{end}}"></span>
<span class="ch-icon ntfy">NT</span>
<div>
<div class="text-ink text-[13px] font-medium">Ntfy</div>
<div class="text-ink-mute text-[11.5px] mt-0.5">Push to ntfy.sh or your self-hosted ntfy server.</div>
</div>
</div>
</label>
{{/* SMTP card */}}
<label class="kind-card {{if eq $f.Kind "smtp"}}selected{{end}}" style="{{if eq $f.Kind "smtp"}}border-color: color-mix(in oklch, var(--ok), transparent 50%); background: color-mix(in oklch, var(--ok), transparent 95%);{{end}}">
<input type="radio" name="kind" value="smtp" class="hidden kind-radio"
{{if eq $f.Kind "smtp"}}checked{{end}} />
<div class="flex items-center gap-3">
<span class="radio-pip {{if eq $f.Kind "smtp"}}on{{end}}" style="{{if eq $f.Kind "smtp"}}border-color: var(--ok);{{end}}"></span>
<span class="ch-icon smtp">@</span>
<div>
<div class="text-ink text-[13px] font-medium">SMTP email</div>
<div class="text-ink-mute text-[11.5px] mt-0.5">Plain-text email via your SMTP relay.</div>
</div>
</div>
</label>
</div>
</div>
{{/* ---------- per-kind fields ---------- */}}
{{if $isEdit}}
<form method="post" action="/settings/notifications/{{$f.ID}}/edit" id="ch-form">
{{else}}
<form method="post" action="/settings/notifications/new" id="ch-form">
{{end}}
{{/* hidden kind field updated by JS */}}
<input type="hidden" name="kind" id="kind-hidden" value="{{$f.Kind}}" />
{{/* Webhook fields */}}
<div id="fields-webhook" class="{{if ne $f.Kind "webhook"}}hidden{{end}}">
<div class="panel rounded-[7px] p-[18px] space-y-4">
<div>
<label class="field-label" for="wh-name">Name</label>
<input id="wh-name" name="name" type="text" class="field"
value="{{if eq $f.Kind "webhook"}}{{$f.Name}}{{end}}"
placeholder="e.g. team-slack-bridge" />
<div class="field-help">Operator-friendly label shown in the channel list and audit log.</div>
</div>
<div>
<label class="field-label" for="wh-url">URL</label>
<input id="wh-url" name="webhook_url" type="url" class="field mono"
value="{{$f.WebhookURL}}" placeholder="https://hooks.example.com/…" />
<div class="field-help">We POST the JSON envelope shown on the right. 5s timeout; failures are logged but not retried.</div>
</div>
<div>
<label class="field-label" for="wh-bearer">Bearer token <span class="text-ink-fade font-normal">· optional</span></label>
<input id="wh-bearer" name="webhook_bearer_token" type="password" class="field mono"
placeholder="{{if and $isEdit (eq $f.Kind "webhook")}} · stored, leave blank to keep{{else}}leave blank if not needed{{end}}" />
<div class="field-help">If set, sent as <span class="mono text-ink-mid">Authorization: Bearer …</span> on every POST.</div>
</div>
<div>
<label class="field-label">Custom header <span class="text-ink-fade font-normal">· optional</span></label>
<div class="grid grid-cols-2 gap-2.5">
<input type="text" name="webhook_header_name" class="field mono"
value="{{$f.WebhookHeaderName}}" placeholder="X-Header-Name" />
<input type="text" name="webhook_header_value" class="field mono"
placeholder="value" />
</div>
<div class="field-help">Single extra header in v1.</div>
</div>
</div>
</div>
{{/* Ntfy fields */}}
<div id="fields-ntfy" class="{{if ne $f.Kind "ntfy"}}hidden{{end}}">
<div class="panel rounded-[7px] p-[18px] space-y-4">
<div>
<label class="field-label" for="nt-name">Name</label>
<input id="nt-name" name="name" type="text" class="field"
value="{{if eq $f.Kind "ntfy"}}{{$f.Name}}{{end}}"
placeholder="e.g. phone-bzzt" />
</div>
<div class="grid grid-cols-2 gap-3.5">
<div>
<label class="field-label" for="nt-server">Server URL</label>
<input id="nt-server" name="ntfy_server_url" type="url" class="field mono"
value="{{if $f.NtfyServerURL}}{{$f.NtfyServerURL}}{{else}}https://ntfy.sh{{end}}" />
<div class="field-help">Default <span class="mono">https://ntfy.sh</span>; change for self-hosted.</div>
</div>
<div>
<label class="field-label" for="nt-topic">Topic</label>
<input id="nt-topic" name="ntfy_topic" type="text" class="field mono"
value="{{$f.NtfyTopic}}" placeholder="restic-manager-fleet" />
<div class="field-help">Subscribe to this topic in the ntfy app.</div>
</div>
</div>
<div>
<label class="field-label" for="nt-token">Access token <span class="text-ink-fade font-normal">· optional</span></label>
<input id="nt-token" name="ntfy_access_token" type="password" class="field mono"
placeholder="{{if and $isEdit (eq $f.Kind "ntfy")}}tk_ · stored, leave blank to keep{{else}}tk_ · required for protected topics{{end}}" />
<div class="field-help">Required for protected topics on self-hosted ntfy.</div>
</div>
<div>
<label class="field-label" for="nt-priority">Default priority</label>
<select id="nt-priority" name="default_priority" class="field">
<option value="default" {{if eq $f.DefaultPriority "default"}}selected{{end}}>default</option>
<option value="min" {{if eq $f.DefaultPriority "min"}}selected{{end}}>min</option>
<option value="low" {{if eq $f.DefaultPriority "low"}}selected{{end}}>low</option>
<option value="high" {{if eq $f.DefaultPriority "high"}}selected{{end}}>high · maps to severity=warning</option>
<option value="urgent" {{if eq $f.DefaultPriority "urgent"}}selected{{end}}>urgent · maps to severity=critical</option>
</select>
<div class="field-help">Per-alert severity overrides this — critical alerts always go out at urgent regardless of the default.</div>
</div>
</div>
</div>
{{/* SMTP fields */}}
<div id="fields-smtp" class="{{if ne $f.Kind "smtp"}}hidden{{end}}">
<div class="panel rounded-[7px] p-[18px] space-y-4">
<div>
<label class="field-label" for="sm-name">Name</label>
<input id="sm-name" name="name" type="text" class="field"
value="{{if eq $f.Kind "smtp"}}{{$f.Name}}{{end}}"
placeholder="e.g. overnight-digest" />
<div class="field-help">One channel = one recipient — add another channel for a second mailbox.</div>
</div>
<div class="grid gap-3.5" style="grid-template-columns: 2fr 1fr 1fr;">
<div>
<label class="field-label" for="sm-host">SMTP host</label>
<input id="sm-host" name="smtp_host" type="text" class="field mono"
value="{{$f.SMTPHost}}" placeholder="smtp.example.com" />
</div>
<div>
<label class="field-label" for="sm-port">Port</label>
<input id="sm-port" name="smtp_port" type="number" class="field mono"
value="{{if $f.SMTPPort}}{{$f.SMTPPort}}{{else}}587{{end}}" min="1" max="65535" />
</div>
<div>
<label class="field-label" for="sm-enc">Encryption</label>
<select id="sm-enc" name="smtp_encryption" class="field">
<option value="starttls" {{if eq $f.SMTPEncryption "starttls"}}selected{{end}}>STARTTLS · 587</option>
<option value="tls" {{if eq $f.SMTPEncryption "tls"}}selected{{end}}>Implicit TLS · 465</option>
<option value="none" {{if eq $f.SMTPEncryption "none"}}selected{{end}}>None · 25 (plain)</option>
</select>
</div>
</div>
<div class="grid grid-cols-2 gap-3.5">
<div>
<label class="field-label" for="sm-user">Username</label>
<input id="sm-user" name="smtp_username" type="text" class="field mono"
value="{{$f.SMTPUsername}}" placeholder="alerts@example.com" />
</div>
<div>
<label class="field-label" for="sm-pass">Password</label>
<input id="sm-pass" name="smtp_password" type="password" class="field mono"
placeholder="{{if and $isEdit (eq $f.Kind "smtp")}} · stored, leave blank to keep{{else}}app password{{end}}" />
<div class="field-help">App password recommended for Gmail / M365.</div>
</div>
</div>
<div class="grid grid-cols-2 gap-3.5">
<div>
<label class="field-label" for="sm-from">From</label>
<input id="sm-from" name="smtp_from" type="text" class="field mono"
value="{{$f.SMTPFrom}}" placeholder="Restic-Manager &lt;alerts@example.com&gt;" />
</div>
<div>
<label class="field-label" for="sm-to">To</label>
<input id="sm-to" name="smtp_to" type="text" class="field mono"
value="{{$f.SMTPTo}}" placeholder="ops@example.com" />
<div class="field-help">Single address or distribution list.</div>
</div>
</div>
</div>
</div>
{{/* ---------- enabled + test ---------- */}}
<div class="panel rounded-[7px] p-[18px] mt-3.5">
<div class="flex items-center gap-3">
<label class="flex items-center gap-3 cursor-pointer">
<input type="hidden" name="enabled" value="0" />
<input type="checkbox" name="enabled" value="1" {{if $f.Enabled}}checked{{end}}
class="hidden enabled-check" id="enabled-check" />
<span class="toggle {{if $f.Enabled}}on{{end}}" id="enabled-toggle"></span>
</label>
<div>
<div class="text-ink text-[13px]">Enabled</div>
<div class="text-ink-mute text-[11.5px] mt-0.5">When off, this channel is skipped on alert dispatch.</div>
</div>
</div>
{{if $isEdit}}
<div class="mt-4 pt-4 border-t border-line-soft">
<div class="flex items-center gap-3">
<button type="button" class="btn"
hx-post="/api/notifications/{{$f.ID}}/test"
hx-target="#test-result"
hx-swap="innerHTML">Send test notification</button>
<div id="test-result"></div>
</div>
<div class="text-[11.5px] text-ink-fade mt-2">
Sends severity=info, kind=test_notification, message="Test from restic-manager".
</div>
</div>
{{end}}
</div>
{{/* ---------- action row ---------- */}}
<div class="mt-5 flex items-center justify-between">
<a href="/settings/notifications" class="btn">Cancel</a>
<div class="flex gap-2">
{{if $isEdit}}
<button type="button" class="btn btn-danger" id="delete-btn"
onclick="document.getElementById('delete-panel').classList.toggle('hidden')">Delete channel…</button>
{{end}}
<button type="submit" class="btn btn-primary btn-lg">
{{if $isEdit}}Save changes{{else}}Create channel{{end}}
</button>
</div>
</div>
{{/* ---------- typed-confirm delete ---------- */}}
{{if $isEdit}}
<div id="delete-panel" class="hidden mt-4 panel rounded-[7px] p-[18px]"
style="border-color: color-mix(in oklch, var(--bad), transparent 60%);">
<div class="text-[13px] font-medium text-bad mb-2">Delete channel</div>
<p class="text-[12.5px] text-ink-mute mb-3 leading-[1.55]">
Type the channel name to confirm permanent deletion. This cannot be undone.
</p>
<form method="post" action="/settings/notifications/{{$f.ID}}/delete">
<input type="text" name="confirm_name" class="field mono mb-3"
placeholder="{{$f.Name}}" autocomplete="off" />
<button type="submit" class="btn btn-danger">Delete permanently</button>
</form>
</div>
{{end}}
</form>{{/* close ch-form */}}
</div>
{{/* ---------- right rail — payload preview ---------- */}}
<aside>
{{if eq $f.Kind "webhook"}}
<div class="panel rounded-[7px] p-4">
<div class="text-[11px] text-ink-fade uppercase tracking-[0.08em] mb-2.5">Payload preview</div>
<div class="text-[12.5px] text-ink-mute mb-3 leading-[1.6]">
Every alert raise POSTs this JSON envelope.
Switch on <span class="mono text-ink-mid">severity</span> or
<span class="mono text-ink-mid">kind</span> in your bridge.
</div>
<div class="snippet">
<div class="snippet-head">application/json</div>
<pre>{
"event": "alert.raised",
"alert_id": "01KQTABCDEFGHJ",
"severity": "warning",
"kind": "backup_failed",
"host_id": "01KQPCD5T1QRYH9",
"host_name": "alfa-01",
"message": "Backup 'system-config' failed: …",
"raised_at": "2026-05-04T15:42:01Z",
"link": "https://restic-manager.example/alerts/01KQTABCDEFGHJ"
}</pre>
</div>
<div class="text-[11px] text-ink-fade mt-2.5 leading-[1.55]">
On <span class="mono text-ink-mid">alert.acknowledged</span> /
<span class="mono text-ink-mid">alert.resolved</span> the same shape is sent
with the updated event field.
</div>
</div>
{{else if eq $f.Kind "ntfy"}}
<div class="panel rounded-[7px] p-4">
<div class="text-[11px] text-ink-fade uppercase tracking-[0.08em] mb-2.5">Ntfy delivery shape</div>
<div class="text-[12.5px] text-ink-mute mb-3 leading-[1.6]">
Ntfy POSTs use the standard publish format — title, body, click-URL, tags.
Tap the notification to open the alert in the UI.
</div>
<div class="snippet">
<div class="snippet-head">POST /&lt;topic&gt; HTTP/1.1</div>
<pre>Host: ntfy.sh
Title: [warning] alfa-01 backup failed
Priority: 4
Tags: warning,backup_failed
Click: https://restic-manager.example/alerts/01KQTABCDEFGHJ
Backup 'system-config' failed: rest-server returned 401</pre>
</div>
</div>
{{else if eq $f.Kind "smtp"}}
<div class="panel rounded-[7px] p-4">
<div class="text-[11px] text-ink-fade uppercase tracking-[0.08em] mb-2.5">Email shape</div>
<div class="text-[12.5px] text-ink-mute mb-3 leading-[1.6]">
Plain text only in v1. Message-ID includes the alert id so the
raised → acknowledged → resolved exchange threads in mail clients.
</div>
<div class="snippet">
<div class="snippet-head">RFC 5322 message</div>
<pre>From: Restic-Manager &lt;alerts@example.com&gt;
To: ops-overnight@example.com
Subject: [restic-manager] [warning] alfa-01: backup_failed
Message-ID: &lt;01KQTABCDEFGHJ@restic-manager.example&gt;
Date: Mon, 04 May 2026 15:42:01 +0000
Backup 'system-config' failed: rest-server returned 401
Raised at: 2026-05-04T15:42:01Z
Severity: warning
Host: alfa-01
Kind: backup_failed
Open in restic-manager:
https://restic-manager.example/alerts/01KQTABCDEFGHJ</pre>
</div>
</div>
{{end}}
</aside>
</div>
{{/* JS: kind-picker interactivity + enabled toggle + HTMX test-result rendering */}}
<script>
(function() {
var kinds = ['webhook', 'ntfy', 'smtp'];
// Kind switcher
var radios = document.querySelectorAll('.kind-radio');
radios.forEach(function(radio) {
radio.closest('label').addEventListener('click', function() {
var kind = radio.value;
document.getElementById('kind-hidden').value = kind;
// Show/hide field panels
kinds.forEach(function(k) {
var el = document.getElementById('fields-' + k);
if (el) el.classList.toggle('hidden', k !== kind);
});
// Update card styles
radios.forEach(function(r) {
var card = r.closest('label');
var pip = card.querySelector('.radio-pip');
var k = r.value;
card.classList.toggle('selected', k === kind);
if (k === kind) {
r.checked = true;
if (pip) pip.classList.add('on');
if (k === 'webhook') { card.style.borderColor = ''; card.style.background = ''; }
else if (k === 'ntfy') { card.style.borderColor = 'color-mix(in oklch, var(--warn), transparent 50%)'; card.style.background = 'color-mix(in oklch, var(--warn), transparent 95%)'; }
else if (k === 'smtp') { card.style.borderColor = 'color-mix(in oklch, var(--ok), transparent 50%)'; card.style.background = 'color-mix(in oklch, var(--ok), transparent 95%)'; }
} else {
if (pip) pip.classList.remove('on');
pip && (pip.style.borderColor = '');
card.style.borderColor = '';
card.style.background = '';
}
});
});
});
// Enabled toggle
var check = document.getElementById('enabled-check');
var tog = document.getElementById('enabled-toggle');
if (check && tog) {
tog.addEventListener('click', function() {
check.checked = !check.checked;
tog.classList.toggle('on', check.checked);
});
}
// HTMX test-result: render JSON from the API as a coloured pill
document.body.addEventListener('htmx:afterRequest', function(evt) {
if (!evt.detail.elt || !evt.detail.elt.hasAttribute('hx-target')) return;
if (evt.detail.elt.getAttribute('hx-target') !== '#test-result') return;
var target = document.getElementById('test-result');
if (!target) return;
try {
var data = JSON.parse(evt.detail.xhr.responseText);
if (data.ok) {
target.innerHTML = '<span class="test-pill test-pill-ok">✓ delivered' +
(data.latency_ms ? ' · ' + data.latency_ms + ' ms' : '') +
(data.status_code ? ' · HTTP ' + data.status_code : '') + '</span>';
} else {
var msg = (data.error || 'unknown error');
target.innerHTML = '<span class="test-pill test-pill-fail">✗ failed · ' + msg + '</span>';
}
} catch(e) {
target.innerHTML = '<span class="test-pill test-pill-fail">✗ unexpected response</span>';
}
});
})();
</script>
{{end}}