cffad4b4f3
CI / Build (windows/amd64) (pull_request) Successful in 22s
CI / Build (linux/amd64) (pull_request) Successful in 24s
CI / Build (linux/arm64) (pull_request) Successful in 24s
CI / Lint (pull_request) Successful in 1m15s
CI / Test (linux/amd64) (pull_request) Successful in 1m36s
Two bugs in the channel-enabled affordance:
1. List-row toggle was a static span with no handler; the row's
row-link overlay swallowed every click and routed to /edit. Add
POST /settings/notifications/{id}/toggle backed by a new store
method SetNotificationChannelEnabled, and turn the row toggle
into an htmx-driven button that swaps in the new state. Use
event.stopPropagation() on the toggle so it beats the row link.
2. Edit-form toggle visually flipped but the underlying checkbox
reverted: the visual span lives inside the <label>, so clicking
it fired the inline JS handler AND the label's native
checkbox-toggle, cancelling out. Bind to the checkbox 'change'
event instead and let the label do the toggling — the JS just
mirrors check.checked into the .on class.
571 lines
27 KiB
HTML
571 lines
27 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">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 <alerts@example.com>" />
|
|
</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 /<topic> 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 <alerts@example.com>
|
|
To: ops-overnight@example.com
|
|
Subject: [restic-manager] [warning] alfa-01: backup_failed
|
|
Message-ID: <01KQTABCDEFGHJ@restic-manager.example>
|
|
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}}
|