Files
restic-manager/web/templates/pages/settings.html
T
steve 3cdaee63d4
CI / Lint (pull_request) Successful in 32s
CI / Build (windows/amd64) (pull_request) Successful in 43s
CI / Build (linux/amd64) (pull_request) Successful in 21s
CI / Test (linux/amd64) (pull_request) Successful in 1m18s
CI / Build (linux/arm64) (pull_request) Successful in 43s
fix: payload-preview rail follows kind switcher
Right-rail preview was rendered server-side via {{if eq $f.Kind ...}},
so it stayed on whatever kind the page loaded with. Editing an SMTP
channel and flipping to ntfy in the picker left the email RFC 5322
sample on screen.

Render all three preview panels with id='preview-<kind>' (only the
matching one visible on first render) and toggle their .hidden class
in the kind-switcher JS alongside the field panels. Same pattern
used for fields-<kind>.
2026-05-04 22:40:46 +01:00

591 lines
28 KiB
HTML

{{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 class="row-action">
{{if $ch.Enabled}}
<span class="toggle on" hx-post="/settings/notifications/{{$ch.ID}}/toggle"
hx-target="this" hx-swap="outerHTML"
onclick="event.stopPropagation()" style="cursor:pointer"
title="click to disable"></span>
{{else}}
<span class="toggle" hx-post="/settings/notifications/{{$ch.ID}}/toggle"
hx-target="this" hx-swap="outerHTML"
onclick="event.stopPropagation()" style="cursor:pointer"
title="click to enable"></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">Use this OR username+password below. Token wins when both are set.</div>
</div>
<div class="grid grid-cols-2 gap-3.5">
<div>
<label class="field-label" for="nt-user">Username <span class="text-ink-fade font-normal">· optional</span></label>
<input id="nt-user" name="ntfy_username" type="text" class="field mono"
value="{{$f.NtfyUsername}}" placeholder="ntfy basic-auth user" />
</div>
<div>
<label class="field-label" for="nt-pass">Password <span class="text-ink-fade font-normal">· optional</span></label>
<input id="nt-pass" name="ntfy_password" type="password" class="field mono"
placeholder="{{if and $isEdit (eq $f.Kind "ntfy")}}stored, leave blank to keep{{else}}ntfy basic-auth password{{end}}" />
<div class="field-help">Sent as HTTP Basic auth when no token is set.</div>
</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 ---------- */}}
</form>{{/* close ch-form — delete panel must live OUTSIDE because HTML forbids nested forms */}}
{{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}}
</div>
{{/* ---------- right rail — payload preview ----------
All three are rendered; the kind-switcher JS toggles which is
visible. Server-side {{if}} would freeze the panel at whichever
kind was loaded, so flipping the picker leaves it stale. */}}
<aside>
<div id="preview-webhook" class="{{if ne $f.Kind "webhook"}}hidden{{end}}">
<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>
</div>
<div id="preview-ntfy" class="{{if ne $f.Kind "ntfy"}}hidden{{end}}">
<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>
</div>
<div id="preview-smtp" class="{{if ne $f.Kind "smtp"}}hidden{{end}}">
<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>
</div>
</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 + matching right-rail payload preview.
kinds.forEach(function(k) {
var fields = document.getElementById('fields-' + k);
var preview = document.getElementById('preview-' + k);
if (fields) fields .classList.toggle('hidden', k !== kind);
if (preview) preview.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: the visual <span class="toggle"> is inside a <label>
// wrapping a hidden checkbox, so clicking the span already flips the
// checkbox via the label's native behaviour. We only need to mirror
// that into the .on class — listening on the toggle's own click would
// race the label and cancel out, leaving check.checked at its original
// value (so Save would persist the unchanged setting).
var check = document.getElementById('enabled-check');
var tog = document.getElementById('enabled-toggle');
if (check && tog) {
check.addEventListener('change', function() {
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}}