ui: /settings/notifications list + edit form (3 kinds)
Add settings.html (shell + sub-tab nav + conditional list/edit body), notifications.html and notification_edit.html (glob stubs), and the supporting CSS tokens (.ch-row, .ch-icon, .toggle, .kind-grid, .kind-card, .radio-pip, .test-pill) to input.css. Rebuild styles.css. Add ui_parse_test.go to catch template regressions at test time. The kind picker is JS-driven (no full page reload); the enabled toggle mirrors the existing visual toggle pattern; the test-notification button uses HTMX and renders the JSON response as a coloured pill client-side.
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -451,4 +451,103 @@
|
||||
radial-gradient(ellipse at top, color-mix(in oklch, var(--accent), transparent 95%), transparent 60%),
|
||||
var(--panel);
|
||||
}
|
||||
|
||||
/* ---------- notification channel rows (/settings/notifications) ---------- */
|
||||
.ch-row {
|
||||
display: grid; align-items: center;
|
||||
grid-template-columns: 28px 200px 1fr 100px 130px 140px;
|
||||
column-gap: 16px;
|
||||
padding: 14px 18px; font-size: 13px;
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
transition: background 100ms ease;
|
||||
}
|
||||
.ch-row:last-child { border-bottom: 0; }
|
||||
.ch-row.head {
|
||||
cursor: default; font-size: 11px; color: var(--ink-fade);
|
||||
text-transform: uppercase; letter-spacing: 0.08em;
|
||||
padding-top: 10px; padding-bottom: 10px;
|
||||
}
|
||||
.ch-row.head:hover { background: transparent; }
|
||||
/* Whole-row click → edit page (mirrors .host-row.clickable). */
|
||||
.ch-row.clickable { position: relative; cursor: pointer; }
|
||||
.ch-row.clickable .row-link {
|
||||
position: absolute; inset: 0; z-index: 0;
|
||||
text-indent: -9999px; overflow: hidden;
|
||||
}
|
||||
.ch-row.clickable:hover { background: var(--panel-hi); }
|
||||
.ch-row.clickable > * { position: relative; z-index: 1; pointer-events: none; }
|
||||
.ch-row.clickable > .row-link { pointer-events: auto; }
|
||||
.ch-row.clickable > .row-action { pointer-events: auto; }
|
||||
|
||||
/* Channel kind icons */
|
||||
.ch-icon {
|
||||
width: 24px; height: 24px;
|
||||
border-radius: 5px;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
font-family: 'JetBrains Mono', monospace; font-size: 10px; font-weight: 600;
|
||||
background: var(--panel-hi); color: var(--ink-mute);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
.ch-icon.webhook { color: var(--accent); border-color: color-mix(in oklch, var(--accent), transparent 60%); }
|
||||
.ch-icon.ntfy { color: var(--warn); border-color: color-mix(in oklch, var(--warn), transparent 60%); }
|
||||
.ch-icon.smtp { color: var(--ok); border-color: color-mix(in oklch, var(--ok), transparent 60%); }
|
||||
|
||||
/* ---------- toggle (enabled/disabled switch) ---------- */
|
||||
.toggle {
|
||||
display: inline-block; width: 30px; height: 16px; border-radius: 9999px;
|
||||
background: var(--line); position: relative; cursor: pointer;
|
||||
transition: background 120ms ease; flex-shrink: 0;
|
||||
}
|
||||
.toggle::after {
|
||||
content: ""; position: absolute; left: 2px; top: 2px;
|
||||
width: 12px; height: 12px; border-radius: 9999px;
|
||||
background: var(--ink-mid);
|
||||
transition: all 120ms ease;
|
||||
}
|
||||
.toggle.on { background: color-mix(in oklch, var(--accent), transparent 50%); }
|
||||
.toggle.on::after { left: 16px; background: var(--accent); }
|
||||
|
||||
/* ---------- kind-picker radio cards (channel edit form) ---------- */
|
||||
.kind-grid { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 14px; }
|
||||
.kind-card {
|
||||
border: 1px solid var(--line-soft); background: var(--bg);
|
||||
border-radius: 7px; padding: 16px;
|
||||
cursor: pointer;
|
||||
transition: border-color 120ms ease, background 120ms ease;
|
||||
}
|
||||
.kind-card:hover { border-color: var(--ink-mute); }
|
||||
.kind-card.selected {
|
||||
border-color: color-mix(in oklch, var(--accent), transparent 50%);
|
||||
background: color-mix(in oklch, var(--accent), transparent 95%);
|
||||
}
|
||||
|
||||
/* Radio pip inside kind cards */
|
||||
.radio-pip {
|
||||
width: 14px; height: 14px;
|
||||
border-radius: 9999px;
|
||||
border: 1px solid var(--line);
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.radio-pip.on { border-color: var(--accent); }
|
||||
.radio-pip.on::after {
|
||||
content: ""; width: 6px; height: 6px; border-radius: 9999px;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
/* ---------- test-result pills (notification test button) ---------- */
|
||||
.test-pill {
|
||||
display: inline-block;
|
||||
padding: 5px 10px; border-radius: 5px; font-size: 12.5px;
|
||||
}
|
||||
.test-pill-ok {
|
||||
border: 1px solid color-mix(in oklch, var(--ok), transparent 60%);
|
||||
background: color-mix(in oklch, var(--ok), transparent 92%);
|
||||
color: var(--ok);
|
||||
}
|
||||
.test-pill-fail {
|
||||
border: 1px solid color-mix(in oklch, var(--bad), transparent 60%);
|
||||
background: color-mix(in oklch, var(--bad), transparent 92%);
|
||||
color: var(--bad);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{{/* notification_edit.html — rendered by handleUINotificationEditGet/Post via Render("settings", …).
|
||||
This file exists so the glob-discovered page registry includes it cleanly.
|
||||
The actual edit form lives in settings.html's notification_edit_form block. */}}
|
||||
{{define "title"}}Edit Channel · Settings · restic-manager{{end}}
|
||||
{{define "content"}}
|
||||
{{/* This page is served under the "settings" renderer key; this file is a
|
||||
placeholder discovered by the glob so ui.New() registers "notification_edit"
|
||||
as a valid page. Handlers do not call Render("notification_edit", …) directly. */}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,9 @@
|
||||
{{/* notifications.html — rendered by handleUINotificationsList via Render("settings", …).
|
||||
This file exists so the glob-discovered page registry includes it cleanly.
|
||||
The actual list body lives in settings.html's notification_list_body block. */}}
|
||||
{{define "title"}}Notifications · Settings · restic-manager{{end}}
|
||||
{{define "content"}}
|
||||
{{/* This page is served under the "settings" renderer key; this file is a
|
||||
placeholder discovered by the glob so ui.New() registers "notifications"
|
||||
as a valid page. Handlers do not call Render("notifications", …) directly. */}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,556 @@
|
||||
{{define "title"}}{{.Title}}{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
{{$page := .Page}}
|
||||
<div class="max-w-[1280px] mx-auto px-8 pt-7 pb-14">
|
||||
|
||||
{{/* ---------- breadcrumbs ---------- */}}
|
||||
<div class="crumbs">
|
||||
<a href="/">Dashboard</a><span class="sep">/</span>
|
||||
{{if $page.Form}}
|
||||
<a href="/settings">Settings</a><span class="sep">/</span>
|
||||
<a href="/settings/notifications">notifications</a><span class="sep">/</span>
|
||||
{{if $page.Form.ID}}
|
||||
<span class="text-ink-mid">{{$page.Form.Name}}</span>
|
||||
{{else}}
|
||||
<span class="text-ink-mid">new channel</span>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<a href="/settings">Settings</a><span class="sep">/</span>
|
||||
<span class="text-ink-mid">notifications</span>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{/* ---------- page header ---------- */}}
|
||||
<div class="flex items-baseline justify-between mt-3.5">
|
||||
{{if $page.Form}}
|
||||
<h1 class="text-[22px] font-medium tracking-[-0.005em]">
|
||||
{{if $page.Form.ID}}Edit channel · <span class="mono text-ink-mid">{{$page.Form.Name}}</span>{{else}}Add channel{{end}}
|
||||
</h1>
|
||||
{{else}}
|
||||
<h1 class="text-[22px] font-medium tracking-[-0.005em]">Settings</h1>
|
||||
<a href="/settings/notifications/new" class="btn btn-primary">+ Add channel</a>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{/* ---------- sub-tab nav ---------- */}}
|
||||
<div class="flex items-end mt-3.5 border-b border-line-soft">
|
||||
<a class="sub-tab {{if eq $page.ActiveTab "notifications"}}active{{end}}" href="/settings/notifications">
|
||||
Notifications
|
||||
{{if not $page.Form}}<span class="mono text-ink-fade text-[11px] ml-1">{{len $page.Channels}}</span>{{end}}
|
||||
</a>
|
||||
<span class="sub-tab text-ink-fade cursor-default" title="lands later">Users</span>
|
||||
<span class="sub-tab text-ink-fade cursor-default" title="lands later">Authentication</span>
|
||||
</div>
|
||||
|
||||
{{/* ---------- sub-tab body ---------- */}}
|
||||
<div class="mt-5">
|
||||
{{if $page.Form}}
|
||||
{{template "notification_edit_form" $page}}
|
||||
{{else}}
|
||||
{{template "notification_list_body" $page}}
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{/* ================================================================
|
||||
notification_list_body — channel list (embedded in settings.html)
|
||||
Receives $page (settingsPage).
|
||||
================================================================ */}}
|
||||
{{define "notification_list_body"}}
|
||||
<p class="text-[12.5px] text-ink-mute mb-4 leading-[1.6] max-w-[720px]">
|
||||
Notification channels fire when the alert engine raises an alert.
|
||||
All channels apply globally — every alert that meets the engine's thresholds is sent to every enabled channel.
|
||||
</p>
|
||||
|
||||
{{if not .Channels}}
|
||||
<div class="empty-state mt-2">
|
||||
<h3 class="text-[16px] font-medium">No channels configured.</h3>
|
||||
<p class="text-ink-mute text-[13px] mt-2 mx-auto max-w-[480px] leading-[1.6]">
|
||||
Alerts are still raised in the dashboard, but nothing is pushed to chat / phone / email.
|
||||
Add a channel to get notified.
|
||||
</p>
|
||||
<div class="mt-5">
|
||||
<a href="/settings/notifications/new" class="btn btn-primary">+ Add your first channel</a>
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="panel rounded-[7px] overflow-hidden">
|
||||
<div class="ch-row head">
|
||||
<div></div>
|
||||
<div>Name</div>
|
||||
<div>Endpoint</div>
|
||||
<div>Enabled</div>
|
||||
<div>Last fired</div>
|
||||
<div></div>
|
||||
</div>
|
||||
{{range .Channels}}
|
||||
{{$ch := .}}
|
||||
<div class="ch-row clickable">
|
||||
<a class="row-link" href="/settings/notifications/{{$ch.ID}}/edit">edit {{$ch.Name}}</a>
|
||||
<div>
|
||||
{{if eq $ch.Kind "webhook"}}<span class="ch-icon webhook">WH</span>
|
||||
{{else if eq $ch.Kind "ntfy"}}<span class="ch-icon ntfy">NT</span>
|
||||
{{else}}<span class="ch-icon smtp">@</span>{{end}}
|
||||
</div>
|
||||
<div class="text-ink font-medium">{{$ch.Name}}</div>
|
||||
<div class="mono text-ink-mute" style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
|
||||
{{if eq $ch.Kind "webhook"}}webhook · click to edit{{else if eq $ch.Kind "ntfy"}}ntfy · click to edit{{else}}smtp · click to edit{{end}}
|
||||
</div>
|
||||
<div>
|
||||
{{if $ch.Enabled}}<span class="toggle on"></span>{{else}}<span class="toggle"></span>{{end}}
|
||||
</div>
|
||||
<div class="mono text-ink-mid">
|
||||
{{if $ch.LastFiredAt}}{{relTime $ch.LastFiredAt}}{{else}}<span class="text-ink-fade">never</span>{{end}}
|
||||
</div>
|
||||
<div class="row-action flex gap-1.5 justify-end">
|
||||
<a href="/settings/notifications/{{$ch.ID}}/edit" class="btn">Edit</a>
|
||||
<a href="/settings/notifications/{{$ch.ID}}/edit#delete-panel" class="btn btn-danger">Delete</a>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
{{/* ================================================================
|
||||
notification_edit_form — create/edit form (embedded in settings.html)
|
||||
Receives $page (settingsPage).
|
||||
================================================================ */}}
|
||||
{{define "notification_edit_form"}}
|
||||
{{$f := .Form}}
|
||||
{{$isEdit := ne $f.ID ""}}
|
||||
|
||||
{{if .FormError}}
|
||||
<div class="rounded-[6px] px-3.5 py-3 text-[13px] mb-4"
|
||||
style="border: 1px solid color-mix(in oklch, var(--bad), transparent 60%); background: color-mix(in oklch, var(--bad), transparent 92%);">
|
||||
{{.FormError}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .DeleteError}}
|
||||
<div class="rounded-[6px] px-3.5 py-3 text-[13px] mb-4"
|
||||
style="border: 1px solid color-mix(in oklch, var(--bad), transparent 60%); background: color-mix(in oklch, var(--bad), transparent 92%);">
|
||||
{{.DeleteError}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="grid gap-6 items-start" style="grid-template-columns: 1fr 380px;">
|
||||
<div>
|
||||
|
||||
{{/* ---------- kind picker ---------- */}}
|
||||
<div class="mb-5">
|
||||
<div class="field-label mb-2.5">Channel kind</div>
|
||||
<div class="kind-grid">
|
||||
|
||||
{{/* Webhook card */}}
|
||||
<label class="kind-card {{if eq $f.Kind "webhook"}}selected{{end}}">
|
||||
<input type="radio" name="kind" value="webhook" class="hidden kind-radio"
|
||||
{{if eq $f.Kind "webhook"}}checked{{end}} />
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="radio-pip {{if eq $f.Kind "webhook"}}on{{end}}"></span>
|
||||
<span class="ch-icon webhook">WH</span>
|
||||
<div>
|
||||
<div class="text-ink text-[13px] font-medium">Webhook</div>
|
||||
<div class="text-ink-mute text-[11.5px] mt-0.5">POST a JSON envelope to your URL.</div>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{{/* Ntfy card */}}
|
||||
<label class="kind-card {{if eq $f.Kind "ntfy"}}selected{{end}}" style="{{if eq $f.Kind "ntfy"}}border-color: color-mix(in oklch, var(--warn), transparent 50%); background: color-mix(in oklch, var(--warn), transparent 95%);{{end}}">
|
||||
<input type="radio" name="kind" value="ntfy" class="hidden kind-radio"
|
||||
{{if eq $f.Kind "ntfy"}}checked{{end}} />
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="radio-pip {{if eq $f.Kind "ntfy"}}on{{end}}" style="{{if eq $f.Kind "ntfy"}}border-color: var(--warn);{{end}}"></span>
|
||||
<span class="ch-icon ntfy">NT</span>
|
||||
<div>
|
||||
<div class="text-ink text-[13px] font-medium">Ntfy</div>
|
||||
<div class="text-ink-mute text-[11.5px] mt-0.5">Push to ntfy.sh or your self-hosted ntfy server.</div>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{{/* SMTP card */}}
|
||||
<label class="kind-card {{if eq $f.Kind "smtp"}}selected{{end}}" style="{{if eq $f.Kind "smtp"}}border-color: color-mix(in oklch, var(--ok), transparent 50%); background: color-mix(in oklch, var(--ok), transparent 95%);{{end}}">
|
||||
<input type="radio" name="kind" value="smtp" class="hidden kind-radio"
|
||||
{{if eq $f.Kind "smtp"}}checked{{end}} />
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="radio-pip {{if eq $f.Kind "smtp"}}on{{end}}" style="{{if eq $f.Kind "smtp"}}border-color: var(--ok);{{end}}"></span>
|
||||
<span class="ch-icon smtp">@</span>
|
||||
<div>
|
||||
<div class="text-ink text-[13px] font-medium">SMTP email</div>
|
||||
<div class="text-ink-mute text-[11.5px] mt-0.5">Plain-text email via your SMTP relay.</div>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{/* ---------- per-kind fields ---------- */}}
|
||||
{{if $isEdit}}
|
||||
<form method="post" action="/settings/notifications/{{$f.ID}}/edit" id="ch-form">
|
||||
{{else}}
|
||||
<form method="post" action="/settings/notifications/new" id="ch-form">
|
||||
{{end}}
|
||||
|
||||
{{/* hidden kind field updated by JS */}}
|
||||
<input type="hidden" name="kind" id="kind-hidden" value="{{$f.Kind}}" />
|
||||
|
||||
{{/* Webhook fields */}}
|
||||
<div id="fields-webhook" class="{{if ne $f.Kind "webhook"}}hidden{{end}}">
|
||||
<div class="panel rounded-[7px] p-[18px] space-y-4">
|
||||
<div>
|
||||
<label class="field-label" for="wh-name">Name</label>
|
||||
<input id="wh-name" name="name" type="text" class="field"
|
||||
value="{{if eq $f.Kind "webhook"}}{{$f.Name}}{{end}}"
|
||||
placeholder="e.g. team-slack-bridge" />
|
||||
<div class="field-help">Operator-friendly label shown in the channel list and audit log.</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="field-label" for="wh-url">URL</label>
|
||||
<input id="wh-url" name="webhook_url" type="url" class="field mono"
|
||||
value="{{$f.WebhookURL}}" placeholder="https://hooks.example.com/…" />
|
||||
<div class="field-help">We POST the JSON envelope shown on the right. 5s timeout; failures are logged but not retried.</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="field-label" for="wh-bearer">Bearer token <span class="text-ink-fade font-normal">· optional</span></label>
|
||||
<input id="wh-bearer" name="webhook_bearer_token" type="password" class="field mono"
|
||||
placeholder="{{if and $isEdit (eq $f.Kind "webhook")}}•••••••• · stored, leave blank to keep{{else}}leave blank if not needed{{end}}" />
|
||||
<div class="field-help">If set, sent as <span class="mono text-ink-mid">Authorization: Bearer …</span> on every POST.</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="field-label">Custom header <span class="text-ink-fade font-normal">· optional</span></label>
|
||||
<div class="grid grid-cols-2 gap-2.5">
|
||||
<input type="text" name="webhook_header_name" class="field mono"
|
||||
value="{{$f.WebhookHeaderName}}" placeholder="X-Header-Name" />
|
||||
<input type="text" name="webhook_header_value" class="field mono"
|
||||
placeholder="value" />
|
||||
</div>
|
||||
<div class="field-help">Single extra header in v1.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{/* Ntfy fields */}}
|
||||
<div id="fields-ntfy" class="{{if ne $f.Kind "ntfy"}}hidden{{end}}">
|
||||
<div class="panel rounded-[7px] p-[18px] space-y-4">
|
||||
<div>
|
||||
<label class="field-label" for="nt-name">Name</label>
|
||||
<input id="nt-name" name="name" type="text" class="field"
|
||||
value="{{if eq $f.Kind "ntfy"}}{{$f.Name}}{{end}}"
|
||||
placeholder="e.g. phone-bzzt" />
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3.5">
|
||||
<div>
|
||||
<label class="field-label" for="nt-server">Server URL</label>
|
||||
<input id="nt-server" name="ntfy_server_url" type="url" class="field mono"
|
||||
value="{{if $f.NtfyServerURL}}{{$f.NtfyServerURL}}{{else}}https://ntfy.sh{{end}}" />
|
||||
<div class="field-help">Default <span class="mono">https://ntfy.sh</span>; change for self-hosted.</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="field-label" for="nt-topic">Topic</label>
|
||||
<input id="nt-topic" name="ntfy_topic" type="text" class="field mono"
|
||||
value="{{$f.NtfyTopic}}" placeholder="restic-manager-fleet" />
|
||||
<div class="field-help">Subscribe to this topic in the ntfy app.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="field-label" for="nt-token">Access token <span class="text-ink-fade font-normal">· optional</span></label>
|
||||
<input id="nt-token" name="ntfy_access_token" type="password" class="field mono"
|
||||
placeholder="{{if and $isEdit (eq $f.Kind "ntfy")}}tk_… · stored, leave blank to keep{{else}}tk_… · required for protected topics{{end}}" />
|
||||
<div class="field-help">Required for protected topics on self-hosted ntfy.</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="field-label" for="nt-priority">Default priority</label>
|
||||
<select id="nt-priority" name="default_priority" class="field">
|
||||
<option value="default" {{if eq $f.DefaultPriority "default"}}selected{{end}}>default</option>
|
||||
<option value="min" {{if eq $f.DefaultPriority "min"}}selected{{end}}>min</option>
|
||||
<option value="low" {{if eq $f.DefaultPriority "low"}}selected{{end}}>low</option>
|
||||
<option value="high" {{if eq $f.DefaultPriority "high"}}selected{{end}}>high · maps to severity=warning</option>
|
||||
<option value="urgent" {{if eq $f.DefaultPriority "urgent"}}selected{{end}}>urgent · maps to severity=critical</option>
|
||||
</select>
|
||||
<div class="field-help">Per-alert severity overrides this — critical alerts always go out at urgent regardless of the default.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{/* SMTP fields */}}
|
||||
<div id="fields-smtp" class="{{if ne $f.Kind "smtp"}}hidden{{end}}">
|
||||
<div class="panel rounded-[7px] p-[18px] space-y-4">
|
||||
<div>
|
||||
<label class="field-label" for="sm-name">Name</label>
|
||||
<input id="sm-name" name="name" type="text" class="field"
|
||||
value="{{if eq $f.Kind "smtp"}}{{$f.Name}}{{end}}"
|
||||
placeholder="e.g. overnight-digest" />
|
||||
<div class="field-help">One channel = one recipient — add another channel for a second mailbox.</div>
|
||||
</div>
|
||||
<div class="grid gap-3.5" style="grid-template-columns: 2fr 1fr 1fr;">
|
||||
<div>
|
||||
<label class="field-label" for="sm-host">SMTP host</label>
|
||||
<input id="sm-host" name="smtp_host" type="text" class="field mono"
|
||||
value="{{$f.SMTPHost}}" placeholder="smtp.example.com" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="field-label" for="sm-port">Port</label>
|
||||
<input id="sm-port" name="smtp_port" type="number" class="field mono"
|
||||
value="{{if $f.SMTPPort}}{{$f.SMTPPort}}{{else}}587{{end}}" min="1" max="65535" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="field-label" for="sm-enc">Encryption</label>
|
||||
<select id="sm-enc" name="smtp_encryption" class="field">
|
||||
<option value="starttls" {{if eq $f.SMTPEncryption "starttls"}}selected{{end}}>STARTTLS · 587</option>
|
||||
<option value="tls" {{if eq $f.SMTPEncryption "tls"}}selected{{end}}>Implicit TLS · 465</option>
|
||||
<option value="none" {{if eq $f.SMTPEncryption "none"}}selected{{end}}>None · 25 (plain)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3.5">
|
||||
<div>
|
||||
<label class="field-label" for="sm-user">Username</label>
|
||||
<input id="sm-user" name="smtp_username" type="text" class="field mono"
|
||||
value="{{$f.SMTPUsername}}" placeholder="alerts@example.com" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="field-label" for="sm-pass">Password</label>
|
||||
<input id="sm-pass" name="smtp_password" type="password" class="field mono"
|
||||
placeholder="{{if and $isEdit (eq $f.Kind "smtp")}}•••••••• · stored, leave blank to keep{{else}}app password{{end}}" />
|
||||
<div class="field-help">App password recommended for Gmail / M365.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3.5">
|
||||
<div>
|
||||
<label class="field-label" for="sm-from">From</label>
|
||||
<input id="sm-from" name="smtp_from" type="text" class="field mono"
|
||||
value="{{$f.SMTPFrom}}" placeholder="Restic-Manager <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
|
||||
var check = document.getElementById('enabled-check');
|
||||
var tog = document.getElementById('enabled-toggle');
|
||||
if (check && tog) {
|
||||
tog.addEventListener('click', function() {
|
||||
check.checked = !check.checked;
|
||||
tog.classList.toggle('on', check.checked);
|
||||
});
|
||||
}
|
||||
|
||||
// HTMX test-result: render JSON from the API as a coloured pill
|
||||
document.body.addEventListener('htmx:afterRequest', function(evt) {
|
||||
if (!evt.detail.elt || !evt.detail.elt.hasAttribute('hx-target')) return;
|
||||
if (evt.detail.elt.getAttribute('hx-target') !== '#test-result') return;
|
||||
var target = document.getElementById('test-result');
|
||||
if (!target) return;
|
||||
try {
|
||||
var data = JSON.parse(evt.detail.xhr.responseText);
|
||||
if (data.ok) {
|
||||
target.innerHTML = '<span class="test-pill test-pill-ok">✓ delivered' +
|
||||
(data.latency_ms ? ' · ' + data.latency_ms + ' ms' : '') +
|
||||
(data.status_code ? ' · HTTP ' + data.status_code : '') + '</span>';
|
||||
} else {
|
||||
var msg = (data.error || 'unknown error');
|
||||
target.innerHTML = '<span class="test-pill test-pill-fail">✗ failed · ' + msg + '</span>';
|
||||
}
|
||||
} catch(e) {
|
||||
target.innerHTML = '<span class="test-pill test-pill-fail">✗ unexpected response</span>';
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user