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:
co-authored by
Claude Sonnet 5
parent
ae4e550c1f
commit
18c2d72d22
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
+12
-6
@@ -98,6 +98,10 @@ export interface Settings {
|
||||
max_logs: number | null
|
||||
/** Id of the selected custom theme entry, or `null` if `color_scheme` alone applies. */
|
||||
custom_theme_id: string | null
|
||||
/** Ntfy topic every alert rule delivers to; `null` until configured. */
|
||||
ntfy_topic: string | null
|
||||
/** Ntfy server override; `null` means `https://ntfy.sh`. */
|
||||
ntfy_server: string | null
|
||||
}
|
||||
|
||||
export type CssEntryKind = 'imported' | 'linked'
|
||||
@@ -148,15 +152,11 @@ export type AlertCondition =
|
||||
| { kind: 'newvalue'; field_regex: string; capture_group: string }
|
||||
| { kind: 'ratio'; query_a: string; query_b: string; op: CompareOp; ratio: number; window_ms: number }
|
||||
|
||||
// Only one sink kind exists today, but this stays a tagged union to match the backend.
|
||||
export type AlertSink = { kind: 'ntfy'; topic: string; server: string | null }
|
||||
|
||||
export interface AlertRule {
|
||||
id: string
|
||||
name: string
|
||||
enabled: boolean
|
||||
condition: AlertCondition
|
||||
sink: AlertSink
|
||||
}
|
||||
|
||||
async function getJson<T>(path: string): Promise<T> {
|
||||
@@ -297,10 +297,16 @@ export function updateSettings(update: {
|
||||
maxLogs?: number | null
|
||||
/** Omit to leave unchanged; `null` clears the selection (falls back to `colorScheme`). */
|
||||
customThemeId?: string | null
|
||||
/** Omit to leave unchanged; `null` clears the configured topic. */
|
||||
ntfyTopic?: string | null
|
||||
/** Omit to leave unchanged; `null` falls back to `https://ntfy.sh`. */
|
||||
ntfyServer?: string | null
|
||||
}): Promise<Settings> {
|
||||
const body: Record<string, unknown> = { color_scheme: update.colorScheme }
|
||||
if ('maxLogs' in update) body.max_logs = update.maxLogs
|
||||
if ('customThemeId' in update) body.custom_theme_id = update.customThemeId
|
||||
if ('ntfyTopic' in update) body.ntfy_topic = update.ntfyTopic
|
||||
if ('ntfyServer' in update) body.ntfy_server = update.ntfyServer
|
||||
return sendJson('/api/settings', 'PATCH', body)
|
||||
}
|
||||
|
||||
@@ -384,13 +390,13 @@ export function fetchAlertRules(): Promise<AlertRule[]> {
|
||||
return getJson('/api/settings/alerts')
|
||||
}
|
||||
|
||||
export function createAlertRule(rule: { name: string; enabled: boolean; condition: AlertCondition; sink: AlertSink }): Promise<AlertRule> {
|
||||
export function createAlertRule(rule: { name: string; enabled: boolean; condition: AlertCondition }): Promise<AlertRule> {
|
||||
return postJson('/api/settings/alerts', rule)
|
||||
}
|
||||
|
||||
export function updateAlertRule(
|
||||
id: string,
|
||||
rule: { name: string; enabled: boolean; condition: AlertCondition; sink: AlertSink },
|
||||
rule: { name: string; enabled: boolean; condition: AlertCondition },
|
||||
): Promise<AlertRule> {
|
||||
return sendJson(`/api/settings/alerts/${encodeURIComponent(id)}`, 'PUT', rule)
|
||||
}
|
||||
|
||||
@@ -153,6 +153,15 @@ export function useSettingsPanel() {
|
||||
[guarded],
|
||||
)
|
||||
|
||||
const setNtfySink = useCallback(
|
||||
(ntfyTopic: string | null, ntfyServer: string | null) =>
|
||||
guarded(async () => {
|
||||
const next = await updateSettings({ ntfyTopic, ntfyServer })
|
||||
setSettings(next)
|
||||
})(),
|
||||
[guarded],
|
||||
)
|
||||
|
||||
const importCssEntry = useCallback(
|
||||
(filename: string, content: string) =>
|
||||
guarded(async () => {
|
||||
@@ -230,6 +239,7 @@ export function useSettingsPanel() {
|
||||
setColorScheme,
|
||||
setCustomTheme,
|
||||
setMaxLogs,
|
||||
setNtfySink,
|
||||
importCssEntry,
|
||||
importThemeEntry,
|
||||
linkThemeEntry,
|
||||
|
||||
Reference in New Issue
Block a user