Move the ntfy alert sink to Settings, shared by all alert rules

Alert rules no longer carry their own ntfy topic/server; Settings now
holds one process-wide ntfy sink (with its own "send test notification"),
and the Alerts panel is left to just define rule conditions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
This commit is contained in:
Hugh Kaznowski
2026-09-05 18:13:51 +01:00
co-authored by Claude Sonnet 5
parent ae4e550c1f
commit 18c2d72d22
9 changed files with 148 additions and 127 deletions
+5 -69
View File
@@ -4,7 +4,6 @@ import {
createAlertRule,
deleteAlertRule,
fetchAlertRules,
sendTestAlertNotification,
updateAlertRule,
type AlertCondition,
type AlertRule,
@@ -68,8 +67,6 @@ interface FormState {
queryA: string
queryB: string
ratio: string
topic: string
server: string
}
const EMPTY_FORM: FormState = {
@@ -85,8 +82,6 @@ const EMPTY_FORM: FormState = {
queryA: '',
queryB: '',
ratio: '',
topic: '',
server: '',
}
function formFromRule(rule: AlertRule): FormState {
@@ -113,8 +108,6 @@ function formFromRule(rule: AlertRule): FormState {
base.windowInput = String(rule.condition.window_ms)
break
}
base.topic = rule.sink.topic
base.server = rule.sink.server ?? ''
return base
}
@@ -158,9 +151,6 @@ export function Alerts() {
const [form, setForm] = useState<FormState>(EMPTY_FORM)
const [submitError, setSubmitError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
const [testError, setTestError] = useState<string | null>(null)
const [testSuccess, setTestSuccess] = useState(false)
const [testing, setTesting] = useState(false)
async function loadRules() {
try {
@@ -182,16 +172,12 @@ export function Alerts() {
setEditingId(null)
setForm(EMPTY_FORM)
setSubmitError(null)
setTestError(null)
setTestSuccess(false)
}
function startEdit(rule: AlertRule) {
setEditingId(rule.id)
setForm(formFromRule(rule))
setSubmitError(null)
setTestError(null)
setTestSuccess(false)
}
async function handleSubmit(e: React.FormEvent) {
@@ -201,10 +187,6 @@ export function Alerts() {
setSubmitError('Name is required.')
return
}
if (!form.topic.trim()) {
setSubmitError('Ntfy topic is required.')
return
}
const { error, condition } = buildCondition(form)
if (error || !condition) {
setSubmitError(error ?? 'Invalid condition.')
@@ -212,7 +194,7 @@ export function Alerts() {
}
setSubmitting(true)
try {
const rule = { name: form.name, enabled: form.enabled, condition, sink: { kind: 'ntfy' as const, topic: form.topic, server: form.server.trim() || null } }
const rule = { name: form.name, enabled: form.enabled, condition }
if (editingId) {
await updateAlertRule(editingId, rule)
} else {
@@ -239,31 +221,13 @@ export function Alerts() {
async function handleToggleEnabled(rule: AlertRule) {
try {
await updateAlertRule(rule.id, { name: rule.name, enabled: !rule.enabled, condition: rule.condition, sink: rule.sink })
await updateAlertRule(rule.id, { name: rule.name, enabled: !rule.enabled, condition: rule.condition })
await loadRules()
} catch {
// best-effort; list refresh will show the previous state on failure
}
}
async function handleTestNotification() {
setTestError(null)
setTestSuccess(false)
if (!form.topic.trim()) {
setTestError('Enter a topic to send a test notification.')
return
}
setTesting(true)
try {
await sendTestAlertNotification(form.topic, form.server.trim() || null)
setTestSuccess(true)
} catch (err) {
setTestError(err instanceof Error ? err.message : 'Failed to send test notification')
} finally {
setTesting(false)
}
}
return (
<div className="border-b border-border text-sm">
<div className={sectionHeaderClassName}>
@@ -272,6 +236,9 @@ export function Alerts() {
<HelpIcon sectionId="alerts" label="Help: Alerts" />
</div>
</div>
<p className="px-3 pt-3 text-xs text-text-faint">
Every rule delivers to the ntfy sink configured in Settings.
</p>
<div className="flex gap-4 p-3">
<form onSubmit={handleSubmit} className="w-96 shrink-0 space-y-2">
<div>
@@ -424,35 +391,12 @@ export function Alerts() {
</div>
)}
<div>
<div className="mb-1 flex items-center gap-1.5 text-xs text-text-subtle">
<span>Ntfy sink</span>
<HelpIcon sectionId="alerts" label="Help: Alert sink" />
</div>
<div className="flex gap-1">
<input
className="w-1/2 min-w-0 flex-1 rounded border border-border-soft bg-surface-1 px-2 py-1 text-sm text-text placeholder:text-text-subtle focus:border-border-strong focus:outline-none"
value={form.topic}
onChange={(e) => patch({ topic: e.target.value })}
placeholder="topic"
/>
<input
className="w-1/2 min-w-0 flex-1 rounded border border-border-soft bg-surface-1 px-2 py-1 text-sm text-text placeholder:text-text-subtle focus:border-border-strong focus:outline-none"
value={form.server}
onChange={(e) => patch({ server: e.target.value })}
placeholder="https://ntfy.sh"
/>
</div>
</div>
<label className="flex items-center gap-1.5 text-xs text-text-secondary">
<input type="checkbox" checked={form.enabled} onChange={(e) => patch({ enabled: e.target.checked })} />
Enabled
</label>
{submitError && <div className="rounded border border-danger-border bg-danger-bg/50 px-2 py-1 text-xs text-danger-text">{submitError}</div>}
{testError && <div className="rounded border border-danger-border bg-danger-bg/50 px-2 py-1 text-xs text-danger-text">{testError}</div>}
{testSuccess && <div className="rounded border border-border-soft px-2 py-1 text-xs text-text-secondary">Test notification sent.</div>}
<div className="flex gap-2">
<button
@@ -462,14 +406,6 @@ export function Alerts() {
>
{submitting ? 'Saving…' : editingId ? 'Save rule' : 'Create rule'}
</button>
<button
type="button"
onClick={handleTestNotification}
disabled={testing}
className="rounded border border-border-soft px-3 py-1 text-sm text-text-secondary hover:bg-surface-2 disabled:opacity-50"
>
{testing ? 'Sending…' : 'Send test notification'}
</button>
{editingId && (
<button type="button" onClick={startCreate} className="rounded border border-border-soft px-3 py-1 text-sm text-text-secondary hover:bg-surface-2">
Cancel
+73 -1
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
import type { Stats } from '../lib/api'
import { cssContentUrl, type ColorScheme, type CssEntry } from '../lib/api'
import { cssContentUrl, sendTestAlertNotification, type ColorScheme, type CssEntry } from '../lib/api'
import { useSettingsPanel } from '../lib/useSettingsPanel'
import { useDocsNav, type DocsTarget } from '../lib/docsNav'
import { DocsPanel } from './Docs'
@@ -147,11 +147,47 @@ function SettingsPanel({ panel }: { panel: ReturnType<typeof useSettingsPanel> }
const [themeFilename, setThemeFilename] = useState('')
const [themeContent, setThemeContent] = useState('')
const [themeLinkUrl, setThemeLinkUrl] = useState('')
const [ntfyTopicInput, setNtfyTopicInput] = useState('')
const [ntfyServerInput, setNtfyServerInput] = useState('')
const [ntfyTestError, setNtfyTestError] = useState<string | null>(null)
const [ntfyTestSuccess, setNtfyTestSuccess] = useState(false)
const [ntfyTesting, setNtfyTesting] = useState(false)
useEffect(() => {
setMaxLogsInput(panel.settings?.max_logs != null ? String(panel.settings.max_logs) : '')
}, [panel.settings?.max_logs])
useEffect(() => {
setNtfyTopicInput(panel.settings?.ntfy_topic ?? '')
setNtfyServerInput(panel.settings?.ntfy_server ?? '')
}, [panel.settings?.ntfy_topic, panel.settings?.ntfy_server])
function handleNtfyTopicBlur() {
panel.setNtfySink(ntfyTopicInput.trim() || null, panel.settings?.ntfy_server ?? null)
}
function handleNtfyServerBlur() {
panel.setNtfySink(panel.settings?.ntfy_topic ?? null, ntfyServerInput.trim() || null)
}
async function handleNtfyTest() {
setNtfyTestError(null)
setNtfyTestSuccess(false)
if (!ntfyTopicInput.trim()) {
setNtfyTestError('Enter a topic to send a test notification.')
return
}
setNtfyTesting(true)
try {
await sendTestAlertNotification(ntfyTopicInput.trim(), ntfyServerInput.trim() || null)
setNtfyTestSuccess(true)
} catch (err) {
setNtfyTestError(err instanceof Error ? err.message : 'Failed to send test notification')
} finally {
setNtfyTesting(false)
}
}
function handleMaxLogsBlur() {
const trimmed = maxLogsInput.trim()
if (trimmed === '') {
@@ -369,6 +405,42 @@ function SettingsPanel({ panel }: { panel: ReturnType<typeof useSettingsPanel> }
</div>
</section>
<section>
<div className="mb-2 flex items-center gap-1.5">
<h3 className="text-xs font-semibold uppercase tracking-wide text-text-subtle">Ntfy sink</h3>
<HelpIcon sectionId="alerts" label="Help: Ntfy sink" />
</div>
<div className="flex flex-col gap-2">
<input
type="text"
placeholder="topic"
value={ntfyTopicInput}
onChange={(e) => setNtfyTopicInput(e.target.value)}
onBlur={handleNtfyTopicBlur}
className="rounded border border-border-soft bg-surface-1 px-2 py-1 text-xs text-text placeholder:text-text-subtle focus:border-border-strong focus:outline-none"
/>
<input
type="text"
placeholder="https://ntfy.sh"
value={ntfyServerInput}
onChange={(e) => setNtfyServerInput(e.target.value)}
onBlur={handleNtfyServerBlur}
className="rounded border border-border-soft bg-surface-1 px-2 py-1 text-xs text-text placeholder:text-text-subtle focus:border-border-strong focus:outline-none"
/>
{ntfyTestError && <div className="text-xs text-danger-text-strong">{ntfyTestError}</div>}
{ntfyTestSuccess && <div className="text-xs text-text-secondary">Test notification sent.</div>}
<button
type="button"
onClick={handleNtfyTest}
disabled={ntfyTesting}
className="self-start rounded border border-border-soft px-2 py-1 text-xs text-text hover:bg-surface-2 disabled:opacity-50"
>
{ntfyTesting ? 'Sending…' : 'Send test notification'}
</button>
<p className="text-[11px] text-text-faint">Every alert rule delivers here — see the Alerts panel to define rules.</p>
</div>
</section>
<section>
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-text-subtle">Import CSS</h3>
<form onSubmit={handleImport} className="flex flex-col gap-2">