Files
restic-manager/web/templates/pages/settings.html
T
steve 38683b4e64 feat(ntfy): support HTTP Basic auth alongside access tokens
Self-hosted ntfy that doesn't expose a token-mint endpoint can still
authenticate over HTTP Basic. Add Username + Password fields to
NtfyConfig; the channel sends 'Authorization: Basic …' when token is
empty and username is set. Token wins when both are configured.

Form-side: two new optional fields next to the access token, with
the same write-only placeholder treatment as smtp_password (blank
on edit means 'keep stored value'). Username is round-tripped on
edit; password is masked.
2026-05-04 22:25:42 +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 ---------- */}}
{{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: 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}}