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 (`30s`/`5m`/`1h`) to mirror the backend's `window_ms` without a full humantime parser here. 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 } 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([]) const [editingId, setEditingId] = useState(null) const [form, setForm] = useState(EMPTY_FORM) const [submitError, setSubmitError] = useState(null) const [submitting, setSubmitting] = useState(false) const [testError, setTestError] = useState(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) { 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 (
Alerts
patch({ name: e.target.value })} placeholder="error spike" required />
Condition
{form.kind === 'immediate' && (
patch({ query: e.target.value })} placeholder='regex("error")' />
)} {form.kind === 'threshold' && (
patch({ query: e.target.value })} placeholder='regex("error")' />
patch({ threshold: e.target.value })} placeholder="threshold (e.g. 10)" /> patch({ windowInput: e.target.value })} placeholder="window (e.g. 30s, 5m)" />
)} {form.kind === 'newvalue' && (
patch({ fieldRegex: e.target.value })} placeholder={String.raw`agent=(?P\S+)`} />
patch({ captureGroup: e.target.value })} placeholder="agent" />
)} {form.kind === 'ratio' && (
patch({ queryA: e.target.value })} placeholder='regex("error")' />
patch({ queryB: e.target.value })} placeholder='regex("request")' />
patch({ ratio: e.target.value })} placeholder="ratio (e.g. 0.5)" /> patch({ windowInput: e.target.value })} placeholder="window (e.g. 30s, 5m)" />
)}
Ntfy sink
patch({ topic: e.target.value })} placeholder="topic" /> patch({ server: e.target.value })} placeholder="https://ntfy.sh" />
{submitError &&
{submitError}
} {testError &&
{testError}
} {testSuccess &&
Test notification sent.
}
{editingId && ( )}
Alert rules
{rules.length === 0 ? (
No alert rules yet.
) : (
{rules.map((rule) => (
handleToggleEnabled(rule)} title="Enabled" />
{rule.name}
{conditionSummary(rule.condition)}
))}
)}
) }