From 7bf323dd50af08f05c56c8d71e5e5dbf98694536 Mon Sep 17 00:00:00 2001 From: Hugh Kaznowski Date: Sat, 5 Sep 2026 16:34:14 +0100 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o --- src/api.rs | 18 +- src/dto.rs | 10 + tickets/020-alerts-webapp-ui.md | 41 --- webapp/src/App.tsx | 2 + webapp/src/components/Alerts.tsx | 514 ++++++++++++++++++++++++++++ webapp/src/components/Docs.tsx | 45 +++ webapp/src/components/FilterBar.tsx | 8 +- webapp/src/lib/api.ts | 50 +++ 8 files changed, 644 insertions(+), 44 deletions(-) delete mode 100644 tickets/020-alerts-webapp-ui.md create mode 100644 webapp/src/components/Alerts.tsx diff --git a/src/api.rs b/src/api.rs index daa1d13..85cb883 100644 --- a/src/api.rs +++ b/src/api.rs @@ -6,13 +6,13 @@ use crate::dto::{ BucketValuesParams, CreateAlertRuleRequest, CreateDashboardRequest, CreateProfileRequest, CssEntryDto, DashboardDto, ErrorDto, ImportCssRequest, LinkCssRequest, MetricDeclarationDto, ParamDeclarationDto, LogsParams, ProfileSummaryDto, PromqlParams, SaveDashboardRequest, - SettingsDto, TimelineParams, UpdateAlertRuleRequest, UpdateSettingsRequest, + SettingsDto, TestAlertNotificationRequest, TimelineParams, UpdateAlertRuleRequest, UpdateSettingsRequest, }; use crate::engine::{ DeclareMetricRequest, DeclareParamRequest, EngineHandle, EngineMessage, EngineQuery, EngineResponse, }; -use crate::alerts::AlertError; +use crate::alerts::{deliver_ntfy, AlertError}; use crate::profile::{self, Profile, ProfileError, ProfileStore}; use crate::settings::{CssError, SettingsHandle}; use axum::extract::{Path as AxumPath, Query, State}; @@ -110,6 +110,7 @@ pub fn router(engine: EngineHandle, settings: SettingsHandle, settings_path: Pat "/api/settings/alerts/{id}", put(update_alert_rule).delete(delete_alert_rule), ) + .route("/api/settings/alerts/test", post(test_alert_notification)) .route("/api/settings/profiles", get(list_profiles).post(create_profile)) .route("/api/settings/profiles/{name}", delete(delete_profile)) .route("/api/settings/profiles/{name}/activate", post(activate_profile)) @@ -523,6 +524,19 @@ async fn delete_alert_rule(State(state): State, AxumPath(id): AxumPath } } +/// `POST /api/settings/alerts/test` — delivers a test notification to an +/// ntfy topic/server without requiring a saved rule, so the sink can be +/// verified before saving. Body: `{ "topic": "...", "server": "..." | null }`. +/// Returns `204 No Content` on success, `502 Bad Gateway` if ntfy rejects +/// or is unreachable. +async fn test_alert_notification(Json(body): Json) -> impl IntoResponse { + let server = body.server.unwrap_or_else(|| "https://ntfy.sh".to_string()); + match deliver_ntfy(&server, &body.topic, "Test notification from ottyl").await { + Ok(()) => StatusCode::NO_CONTENT.into_response(), + Err(err) => (StatusCode::BAD_GATEWAY, err).into_response(), + } +} + /// `GET /api/settings` — returns the subset of `Settings` the NavMenu UI /// needs (colour scheme, active profile name) as `SettingsDto`. CSS entries /// have their own dedicated list endpoint (`GET /api/settings/css`). diff --git a/src/dto.rs b/src/dto.rs index b29edfc..b37b597 100644 --- a/src/dto.rs +++ b/src/dto.rs @@ -259,6 +259,16 @@ pub struct UpdateAlertRuleRequest { pub sink: AlertSink, } +/// Request body for `POST /api/settings/alerts/test` (ticket 020): delivers +/// a test notification to an ntfy topic/server without requiring a saved +/// rule. `server` defaults to `https://ntfy.sh` when absent, same as +/// `AlertSink::Ntfy`. +#[derive(Debug, Deserialize)] +pub struct TestAlertNotificationRequest { + pub topic: String, + pub server: Option, +} + /// Summary of one profile for `GET /api/settings/profiles` (ticket 011): /// just the name and declaration/dashboard counts the Presets panel needs to /// render a list, rather than the full param/metric declaration bodies. diff --git a/tickets/020-alerts-webapp-ui.md b/tickets/020-alerts-webapp-ui.md deleted file mode 100644 index 14339a9..0000000 --- a/tickets/020-alerts-webapp-ui.md +++ /dev/null @@ -1,41 +0,0 @@ -# 020 — Alerts webapp UI - -## Summary - -A webapp page for managing `AlertRule`s ([017](017-ntfy-alerts.md)) end to -end: create/edit/enable/delete rules and configure their ntfy sink, without -touching `settings.json` by hand. - -## Scope - -- A new route/panel (matching how `Dashboards`/`Metrics` are already surfaced - in `App.tsx`) listing existing alert rules: name, condition summary, - enabled state. -- A rule editor: - - Condition type picker (Immediate / Threshold / New value / Ratio, see - [018](018-alert-trigger-evaluation.md)) with type-specific fields, reusing - `FilterBar`'s query input for the query-based conditions rather than a - separate text field. - - Ntfy sink fields: topic and optional server override. - - Enabled toggle. -- "Send test notification" button wired to the sink delivery endpoint from - [019](019-ntfy-sink-delivery.md), so a topic/server can be verified without - waiting for a real trigger. -- Contextual "?" help icons on the condition-type picker and sink fields, - matching [021](021-docs-contextual-help.md)'s pattern. - -## Acceptance criteria - -- Rules created/edited/deleted in the UI persist across a page reload (i.e. - round-trip through the CRUD endpoints from 017). -- Each condition type's fields validate before save (e.g. Ratio requires two - queries and a threshold). -- "Send test notification" delivers to the currently-entered topic/server - without requiring the rule to be saved first. - -## Depends on - -- [017 — Alert definitions & settings persistence](017-ntfy-alerts.md) -- [018 — Alert trigger evaluation](018-alert-trigger-evaluation.md) -- [019 — Ntfy sink delivery](019-ntfy-sink-delivery.md) -- [021 — Docs contextual help](021-docs-contextual-help.md) diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index b7086be..ddbc43c 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -5,6 +5,7 @@ import { LogTimeline, computePresetRange, type TimeRange, type TimeRangePresetId import { LogPreview } from './components/LogPreview' import { Metrics } from './components/Metrics' import { Dashboards } from './components/Dashboards' +import { Alerts } from './components/Alerts' import { fetchLogs, fetchStats, fetchTimeline, type LogEntry, type Stats, type TimelineBucket } from './lib/api' import { DocsNavProvider } from './lib/docsNav' @@ -110,6 +111,7 @@ export default function App() { + diff --git a/webapp/src/components/Alerts.tsx b/webapp/src/components/Alerts.tsx new file mode 100644 index 0000000..7765f16 --- /dev/null +++ b/webapp/src/components/Alerts.tsx @@ -0,0 +1,514 @@ +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([]) + 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)}
+
+ + +
+ ))} +
+ )} +
+
+
+ ) +} diff --git a/webapp/src/components/Docs.tsx b/webapp/src/components/Docs.tsx index ae5e6bc..870367c 100644 --- a/webapp/src/components/Docs.tsx +++ b/webapp/src/components/Docs.tsx @@ -233,6 +233,51 @@ const SECTIONS: DocSection[] = [ ), }, + { + id: 'alerts', + title: 'Alerts', + body: ( +
+

+ An alert rule fires a notification when its condition matches a newly-ingested log line — evaluated per + line as it arrives, not backfilled against history. There are four condition types: +

+
    +
  • + Immediate — fires once per line matching a query. +
  • +
  • + Threshold — fires when a query's match count within a + rolling window crosses a numeric threshold. +
  • +
  • + NewValue — fires the first time a field regex captures a + value not seen before by this rule (seen values live only in memory, so a restart forgets them). +
  • +
  • + Ratio — fires when the ratio of two queries' match + counts within a rolling window crosses a numeric ratio. +
  • +
+

+ Query-based conditions (Immediate/Threshold/Ratio) reuse the filter bar's query syntax, but only the subset + that evaluates against a single line's own fields — regex(...), sequence,{' '} + timestamp, and and/or. A metric_name[param] comparison + isn't supported in an alert query. +

+

+ A window accepts a plain number of milliseconds or a humantime-style duration like 30s,{' '} + 5m, 1h. +

+

+ Every rule delivers to an ntfy sink: a topic (required) and an optional server override (defaults + to https://ntfy.sh). "Send test notification" delivers to whatever topic/server is currently + entered in the form, without needing to save the rule first — handy for verifying the sink before + committing to it. +

+
+ ), + }, { id: 'settings', title: 'Settings', diff --git a/webapp/src/components/FilterBar.tsx b/webapp/src/components/FilterBar.tsx index dc061ed..88b0fb6 100644 --- a/webapp/src/components/FilterBar.tsx +++ b/webapp/src/components/FilterBar.tsx @@ -1,6 +1,12 @@ import { useState } from 'react' import { HelpIcon } from './HelpIcon' +/// Shared styling for a single FilterBar-syntax query input, reused by +/// `Alerts.tsx`'s condition editor so query-based alert conditions look like +/// the top-level filter bar without dragging in its own draft/submit state. +export const queryInputClassName = + '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' + /** * Input for the syntax-aware pest-parsed filter language described in the * README (regex + metric comparisons combined with `and`/`or`). The raw @@ -30,7 +36,7 @@ export function FilterBar({
setDraft(e.target.value)} diff --git a/webapp/src/lib/api.ts b/webapp/src/lib/api.ts index a627fb4..c94118e 100644 --- a/webapp/src/lib/api.ts +++ b/webapp/src/lib/api.ts @@ -144,6 +144,30 @@ export interface Dashboard { panels: Panel[] } +/// Comparison operator shared by `AlertCondition::Threshold`/`Ratio` and +/// filter-query metric comparisons (see `CompareOp` on the backend). +export type CompareOp = 'lt' | 'gt' | 'le' | 'ge' | 'eq' | 'ne' + +/// What triggers an alert rule (ticket 017/018). Externally tagged on +/// `kind`, matching `AlertCondition`'s `#[serde(tag = "kind")]`. +export type AlertCondition = + | { kind: 'immediate'; query: string } + | { kind: 'threshold'; query: string; op: CompareOp; threshold: number; window_ms: number } + | { kind: 'newvalue'; field_regex: string; capture_group: string } + | { kind: 'ratio'; query_a: string; query_b: string; op: CompareOp; ratio: number; window_ms: number } + +/// Where a fired alert is delivered (ticket 019). 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(path: string): Promise { const res = await fetch(path) if (!res.ok) { @@ -360,3 +384,29 @@ export function deleteDashboard(profileName: string, dashboardId: string): Promi 'DELETE', ) } + +export function fetchAlertRules(): Promise { + return getJson('/api/settings/alerts') +} + +export function createAlertRule(rule: { name: string; enabled: boolean; condition: AlertCondition; sink: AlertSink }): Promise { + return postJson('/api/settings/alerts', rule) +} + +export function updateAlertRule( + id: string, + rule: { name: string; enabled: boolean; condition: AlertCondition; sink: AlertSink }, +): Promise { + return sendJson(`/api/settings/alerts/${encodeURIComponent(id)}`, 'PUT', rule) +} + +export function deleteAlertRule(id: string): Promise { + return sendJson(`/api/settings/alerts/${encodeURIComponent(id)}`, 'DELETE') +} + +/// Delivers a test notification to `topic`/`server` (ticket 020) without +/// requiring the rule to be saved first. Throws `ApiError` on failure (e.g. +/// ntfy unreachable) so callers can surface it inline. +export function sendTestAlertNotification(topic: string, server: string | null): Promise { + return sendJson('/api/settings/alerts/test', 'POST', { topic, server }) +}