import { useEffect, useState } from 'react' import { ApiError, createAlertRule, deleteAlertRule, fetchAlertRules, updateAlertRule, type AlertCondition, type AlertRule, type CompareOp, } from '../lib/api' import { CollapseToggle, handleSectionHeaderKeyDown, sectionHeaderInteractiveClassName, sectionHeaderLeftClassName, useSectionCollapsed, } from './CollapsibleSection' import { HelpIcon } from './HelpIcon' import { RegexHelpIcon } from './RegexHelpIcon' 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 } const EMPTY_FORM: FormState = { name: '', enabled: true, kind: 'immediate', query: '', op: 'ge', threshold: '', windowInput: '', fieldRegex: '', captureGroup: '', queryA: '', queryB: '', ratio: '', } 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 } 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 [collapsed, setCollapsed] = useSectionCollapsed('alerts') 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) } function startEdit(rule: AlertRule) { setEditingId(rule.id) setForm(formFromRule(rule)) setSubmitError(null) } async function handleSubmit(e: React.FormEvent) { e.preventDefault() setSubmitError(null) if (!form.name.trim()) { setSubmitError('Name 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 } 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 }) await loadRules() } catch { // best-effort; list refresh will show the previous state on failure } } return (
setCollapsed(!collapsed)} onKeyDown={handleSectionHeaderKeyDown(() => setCollapsed(!collapsed))} >
Alerts
) }