Files
restic-manager/web/templates/pages/settings.html
T
steve 7f2a9964db
CI / Build (windows/amd64) (pull_request) Successful in 21s
CI / Build (linux/amd64) (pull_request) Successful in 22s
CI / Build (linux/arm64) (pull_request) Successful in 21s
CI / Lint (pull_request) Successful in 1m11s
CI / Test (linux/amd64) (pull_request) Successful in 1m22s
fix: move channel delete-panel out of edit form (nested form bug)
The delete-panel <form action='.../delete'> was nested inside the
main <form action='.../edit'>. HTML doesn't allow nested forms —
browsers parse the inner form as if it didn't exist, so clicking
'Delete permanently' submitted the outer edit form to /edit
instead of /delete, leaving the channel intact.

Move the delete-panel block to a sibling of the main form. The
'Delete channel…' button still toggles its visibility via JS, the
panel still renders inside the page layout, and now its form
actually posts to the delete handler.
2026-05-04 22:35:58 +01:00

584 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 ---------- */}}
<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: 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}}