Files
ottyl/webapp/src/components/Alerts.tsx
T
Hugh KaznowskiandClaude Sonnet 5 7bf323dd50 Add Alerts webapp UI (ticket 020)
New Alerts panel: lists rules with condition summaries and an enabled
toggle, and a condition-type-specific editor (Immediate/Threshold/NewValue/
Ratio) with client-side validation before save, reusing FilterBar's query
input styling for query-based conditions. Adds a test-notification endpoint
(POST /api/settings/alerts/test) so an ntfy topic/server can be verified
without saving a rule first, plus a docs section and help icons matching
ticket 021's pattern.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
2026-09-05 16:34:14 +01:00

515 lines
20 KiB
TypeScript

import { useEffect, useState } from 'react'
import {
ApiError,
createAlertRule,
deleteAlertRule,
fetchAlertRules,
sendTestAlertNotification,
updateAlertRule,
type AlertCondition,
type AlertRule,
type CompareOp,
} from '../lib/api'
import { HelpIcon } from './HelpIcon'
import { queryInputClassName } from './FilterBar'
type ConditionKind = AlertCondition['kind']
const OPS: { value: CompareOp; label: string }[] = [
{ value: 'lt', label: '<' },
{ value: 'le', label: '<=' },
{ value: 'gt', label: '>' },
{ value: 'ge', label: '>=' },
{ value: 'eq', label: '==' },
{ value: 'ne', label: '!=' },
]
/// Parses a plain millisecond number or a humantime-style duration like
/// `30s`/`5m`/`1h` into milliseconds. Mirrors the backend's `window_ms`
/// field without pulling in a full humantime parser for the webapp.
function parseWindowMs(input: string): number | null {
const trimmed = input.trim()
if (!trimmed) return null
const bare = Number(trimmed)
if (Number.isFinite(bare)) return bare
const match = /^(\d+(?:\.\d+)?)(ms|s|m|h|d)$/.exec(trimmed)
if (!match) return null
const value = Number(match[1])
const unitMs = { ms: 1, s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 }[match[2] as 's' | 'm' | 'h' | 'd' | 'ms']
return value * unitMs
}
function conditionSummary(condition: AlertCondition): string {
switch (condition.kind) {
case 'immediate':
return `Immediate: ${condition.query}`
case 'threshold': {
const op = OPS.find((o) => o.value === condition.op)?.label ?? condition.op
return `Threshold: ${condition.query} ${op} ${condition.threshold} over ${condition.window_ms}ms`
}
case 'newvalue':
return `NewValue: ${condition.field_regex} (${condition.capture_group})`
case 'ratio': {
const op = OPS.find((o) => o.value === condition.op)?.label ?? condition.op
return `Ratio: ${condition.query_a} / ${condition.query_b} ${op} ${condition.ratio} over ${condition.window_ms}ms`
}
}
}
interface FormState {
name: string
enabled: boolean
kind: ConditionKind
query: string
op: CompareOp
threshold: string
windowInput: string
fieldRegex: string
captureGroup: string
queryA: string
queryB: string
ratio: string
topic: string
server: string
}
const EMPTY_FORM: FormState = {
name: '',
enabled: true,
kind: 'immediate',
query: '',
op: 'ge',
threshold: '',
windowInput: '',
fieldRegex: '',
captureGroup: '',
queryA: '',
queryB: '',
ratio: '',
topic: '',
server: '',
}
function formFromRule(rule: AlertRule): FormState {
const base: FormState = { ...EMPTY_FORM, name: rule.name, enabled: rule.enabled, kind: rule.condition.kind }
switch (rule.condition.kind) {
case 'immediate':
base.query = rule.condition.query
break
case 'threshold':
base.query = rule.condition.query
base.op = rule.condition.op
base.threshold = String(rule.condition.threshold)
base.windowInput = String(rule.condition.window_ms)
break
case 'newvalue':
base.fieldRegex = rule.condition.field_regex
base.captureGroup = rule.condition.capture_group
break
case 'ratio':
base.queryA = rule.condition.query_a
base.queryB = rule.condition.query_b
base.op = rule.condition.op
base.ratio = String(rule.condition.ratio)
base.windowInput = String(rule.condition.window_ms)
break
}
base.topic = rule.sink.topic
base.server = rule.sink.server ?? ''
return base
}
/// Validates the form's condition-specific fields, returning an error
/// message (or null if valid) plus the built `AlertCondition` when valid.
function buildCondition(form: FormState): { error: string | null; condition: AlertCondition | null } {
switch (form.kind) {
case 'immediate':
if (!form.query.trim()) return { error: 'Immediate requires a query.', condition: null }
return { error: null, condition: { kind: 'immediate', query: form.query } }
case 'threshold': {
if (!form.query.trim()) return { error: 'Threshold requires a query.', condition: null }
const threshold = Number(form.threshold)
if (!form.threshold.trim() || !Number.isFinite(threshold)) {
return { error: 'Threshold requires a numeric threshold.', condition: null }
}
const windowMs = parseWindowMs(form.windowInput)
if (windowMs === null) return { error: 'Threshold requires a valid window (e.g. 30s, 5m, or a plain ms number).', condition: null }
return { error: null, condition: { kind: 'threshold', query: form.query, op: form.op, threshold, window_ms: windowMs } }
}
case 'newvalue':
if (!form.fieldRegex.trim() || !form.captureGroup.trim()) {
return { error: 'NewValue requires both a field regex and a capture group name.', condition: null }
}
return { error: null, condition: { kind: 'newvalue', field_regex: form.fieldRegex, capture_group: form.captureGroup } }
case 'ratio': {
if (!form.queryA.trim() || !form.queryB.trim()) return { error: 'Ratio requires two queries.', condition: null }
const ratio = Number(form.ratio)
if (!form.ratio.trim() || !Number.isFinite(ratio)) return { error: 'Ratio requires a numeric ratio.', condition: null }
const windowMs = parseWindowMs(form.windowInput)
if (windowMs === null) return { error: 'Ratio requires a valid window (e.g. 30s, 5m, or a plain ms number).', condition: null }
return {
error: null,
condition: { kind: 'ratio', query_a: form.queryA, query_b: form.queryB, op: form.op, ratio, window_ms: windowMs },
}
}
}
}
export function Alerts() {
const [rules, setRules] = useState<AlertRule[]>([])
const [editingId, setEditingId] = useState<string | null>(null)
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 {
setRules(await fetchAlertRules())
} catch {
// engine not reachable yet; leave the previous list in place
}
}
useEffect(() => {
loadRules()
}, [])
function patch(fields: Partial<FormState>) {
setForm((prev) => ({ ...prev, ...fields }))
}
function startCreate() {
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) {
e.preventDefault()
setSubmitError(null)
if (!form.name.trim()) {
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.')
return
}
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 } }
if (editingId) {
await updateAlertRule(editingId, rule)
} else {
await createAlertRule(rule)
}
await loadRules()
startCreate()
} catch (err) {
setSubmitError(err instanceof ApiError ? err.message : 'Failed to save alert rule')
} finally {
setSubmitting(false)
}
}
async function handleDelete(id: string) {
try {
await deleteAlertRule(id)
if (editingId === id) startCreate()
await loadRules()
} catch {
// best-effort; list refresh will show the rule is still there on failure
}
}
async function handleToggleEnabled(rule: AlertRule) {
try {
await updateAlertRule(rule.id, { name: rule.name, enabled: !rule.enabled, condition: rule.condition, sink: rule.sink })
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 p-3 text-sm">
<div className="mb-2 flex items-center gap-1.5 text-xs text-text-subtle">
Alerts
<HelpIcon sectionId="alerts" label="Help: Alerts" />
</div>
<div className="flex gap-4">
<form onSubmit={handleSubmit} className="w-96 shrink-0 space-y-2">
<div>
<label className="mb-1 block text-xs text-text-subtle">Name</label>
<input
className="w-full 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.name}
onChange={(e) => patch({ name: e.target.value })}
placeholder="error spike"
required
/>
</div>
<div>
<div className="mb-1 flex items-center gap-1.5 text-xs text-text-subtle">
<span>Condition</span>
<HelpIcon sectionId="alerts" label="Help: Alert conditions" />
</div>
<select
className="w-full rounded border border-border-soft bg-surface-1 px-2 py-1 text-sm text-text focus:border-border-strong focus:outline-none"
value={form.kind}
onChange={(e) => patch({ kind: e.target.value as ConditionKind })}
>
<option value="immediate">Immediate</option>
<option value="threshold">Threshold</option>
<option value="newvalue">NewValue</option>
<option value="ratio">Ratio</option>
</select>
</div>
{form.kind === 'immediate' && (
<div>
<label className="mb-1 block text-xs text-text-subtle">Query</label>
<input
className={queryInputClassName}
value={form.query}
onChange={(e) => patch({ query: e.target.value })}
placeholder='regex("error")'
/>
</div>
)}
{form.kind === 'threshold' && (
<div className="space-y-1">
<div>
<label className="mb-1 block text-xs text-text-subtle">Query</label>
<input
className={queryInputClassName}
value={form.query}
onChange={(e) => patch({ query: e.target.value })}
placeholder='regex("error")'
/>
</div>
<div className="flex gap-1">
<select
className="rounded border border-border-soft bg-surface-1 px-1 py-1 text-xs text-text focus:border-border-strong focus:outline-none"
value={form.op}
onChange={(e) => patch({ op: e.target.value as CompareOp })}
>
{OPS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
<input
className="w-24 min-w-0 flex-1 rounded border border-border-soft bg-surface-1 px-1.5 py-1 text-xs text-text placeholder:text-text-subtle focus:border-border-strong focus:outline-none"
value={form.threshold}
onChange={(e) => patch({ threshold: e.target.value })}
placeholder="threshold (e.g. 10)"
/>
<input
className="w-24 min-w-0 flex-1 rounded border border-border-soft bg-surface-1 px-1.5 py-1 text-xs text-text placeholder:text-text-subtle focus:border-border-strong focus:outline-none"
value={form.windowInput}
onChange={(e) => patch({ windowInput: e.target.value })}
placeholder="window (e.g. 30s, 5m)"
/>
</div>
</div>
)}
{form.kind === 'newvalue' && (
<div className="space-y-1">
<div>
<label className="mb-1 block text-xs text-text-subtle">Field regex</label>
<input
className="w-full rounded border border-border-soft bg-surface-1 px-2 py-1 font-mono text-sm text-text placeholder:text-text-subtle focus:border-border-strong focus:outline-none"
value={form.fieldRegex}
onChange={(e) => patch({ fieldRegex: e.target.value })}
placeholder={String.raw`agent=(?P<agent>\S+)`}
/>
</div>
<div>
<label className="mb-1 block text-xs text-text-subtle">Capture group</label>
<input
className="w-full 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.captureGroup}
onChange={(e) => patch({ captureGroup: e.target.value })}
placeholder="agent"
/>
</div>
</div>
)}
{form.kind === 'ratio' && (
<div className="space-y-1">
<div>
<label className="mb-1 block text-xs text-text-subtle">Query A</label>
<input
className={queryInputClassName}
value={form.queryA}
onChange={(e) => patch({ queryA: e.target.value })}
placeholder='regex("error")'
/>
</div>
<div>
<label className="mb-1 block text-xs text-text-subtle">Query B</label>
<input
className={queryInputClassName}
value={form.queryB}
onChange={(e) => patch({ queryB: e.target.value })}
placeholder='regex("request")'
/>
</div>
<div className="flex gap-1">
<select
className="rounded border border-border-soft bg-surface-1 px-1 py-1 text-xs text-text focus:border-border-strong focus:outline-none"
value={form.op}
onChange={(e) => patch({ op: e.target.value as CompareOp })}
>
{OPS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
<input
className="w-24 min-w-0 flex-1 rounded border border-border-soft bg-surface-1 px-1.5 py-1 text-xs text-text placeholder:text-text-subtle focus:border-border-strong focus:outline-none"
value={form.ratio}
onChange={(e) => patch({ ratio: e.target.value })}
placeholder="ratio (e.g. 0.5)"
/>
<input
className="w-24 min-w-0 flex-1 rounded border border-border-soft bg-surface-1 px-1.5 py-1 text-xs text-text placeholder:text-text-subtle focus:border-border-strong focus:outline-none"
value={form.windowInput}
onChange={(e) => patch({ windowInput: e.target.value })}
placeholder="window (e.g. 30s, 5m)"
/>
</div>
</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
type="submit"
disabled={submitting}
className="rounded border border-border-soft px-3 py-1 text-sm text-text-secondary hover:bg-surface-2 disabled:opacity-50"
>
{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
</button>
)}
</div>
</form>
<div className="min-w-0 flex-1">
<div className="mb-1 text-xs text-text-subtle">Alert rules</div>
{rules.length === 0 ? (
<div className="text-xs text-text-faint">No alert rules yet.</div>
) : (
<div className="space-y-1">
{rules.map((rule) => (
<div
key={rule.id}
className={`flex items-center gap-2 rounded border px-2 py-1 text-xs ${
editingId === rule.id ? 'border-accent bg-accent/10' : 'border-border-soft'
}`}
>
<input type="checkbox" checked={rule.enabled} onChange={() => handleToggleEnabled(rule)} title="Enabled" />
<div className="min-w-0 flex-1">
<div className="truncate font-semibold text-text">{rule.name}</div>
<div className="truncate text-text-secondary">{conditionSummary(rule.condition)}</div>
</div>
<button type="button" onClick={() => startEdit(rule)} className="rounded border border-border-soft px-1.5 py-0.5 text-text-secondary hover:bg-surface-2">
Edit
</button>
<button type="button" onClick={() => handleDelete(rule.id)} className="rounded border border-border-soft px-1.5 py-0.5 text-text-muted hover:bg-surface-2">
Delete
</button>
</div>
))}
</div>
)}
</div>
</div>
</div>
)
}