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
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
dd3320812c
commit
7bf323dd50
+16
-2
@@ -6,13 +6,13 @@ use crate::dto::{
|
|||||||
BucketValuesParams, CreateAlertRuleRequest, CreateDashboardRequest, CreateProfileRequest,
|
BucketValuesParams, CreateAlertRuleRequest, CreateDashboardRequest, CreateProfileRequest,
|
||||||
CssEntryDto, DashboardDto, ErrorDto, ImportCssRequest, LinkCssRequest, MetricDeclarationDto,
|
CssEntryDto, DashboardDto, ErrorDto, ImportCssRequest, LinkCssRequest, MetricDeclarationDto,
|
||||||
ParamDeclarationDto, LogsParams, ProfileSummaryDto, PromqlParams, SaveDashboardRequest,
|
ParamDeclarationDto, LogsParams, ProfileSummaryDto, PromqlParams, SaveDashboardRequest,
|
||||||
SettingsDto, TimelineParams, UpdateAlertRuleRequest, UpdateSettingsRequest,
|
SettingsDto, TestAlertNotificationRequest, TimelineParams, UpdateAlertRuleRequest, UpdateSettingsRequest,
|
||||||
};
|
};
|
||||||
use crate::engine::{
|
use crate::engine::{
|
||||||
DeclareMetricRequest, DeclareParamRequest, EngineHandle, EngineMessage, EngineQuery,
|
DeclareMetricRequest, DeclareParamRequest, EngineHandle, EngineMessage, EngineQuery,
|
||||||
EngineResponse,
|
EngineResponse,
|
||||||
};
|
};
|
||||||
use crate::alerts::AlertError;
|
use crate::alerts::{deliver_ntfy, AlertError};
|
||||||
use crate::profile::{self, Profile, ProfileError, ProfileStore};
|
use crate::profile::{self, Profile, ProfileError, ProfileStore};
|
||||||
use crate::settings::{CssError, SettingsHandle};
|
use crate::settings::{CssError, SettingsHandle};
|
||||||
use axum::extract::{Path as AxumPath, Query, State};
|
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}",
|
"/api/settings/alerts/{id}",
|
||||||
put(update_alert_rule).delete(delete_alert_rule),
|
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", get(list_profiles).post(create_profile))
|
||||||
.route("/api/settings/profiles/{name}", delete(delete_profile))
|
.route("/api/settings/profiles/{name}", delete(delete_profile))
|
||||||
.route("/api/settings/profiles/{name}/activate", post(activate_profile))
|
.route("/api/settings/profiles/{name}/activate", post(activate_profile))
|
||||||
@@ -523,6 +524,19 @@ async fn delete_alert_rule(State(state): State<ApiState>, 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<TestAlertNotificationRequest>) -> 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
|
/// `GET /api/settings` — returns the subset of `Settings` the NavMenu UI
|
||||||
/// needs (colour scheme, active profile name) as `SettingsDto`. CSS entries
|
/// needs (colour scheme, active profile name) as `SettingsDto`. CSS entries
|
||||||
/// have their own dedicated list endpoint (`GET /api/settings/css`).
|
/// have their own dedicated list endpoint (`GET /api/settings/css`).
|
||||||
|
|||||||
+10
@@ -259,6 +259,16 @@ pub struct UpdateAlertRuleRequest {
|
|||||||
pub sink: AlertSink,
|
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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Summary of one profile for `GET /api/settings/profiles` (ticket 011):
|
/// Summary of one profile for `GET /api/settings/profiles` (ticket 011):
|
||||||
/// just the name and declaration/dashboard counts the Presets panel needs to
|
/// just the name and declaration/dashboard counts the Presets panel needs to
|
||||||
/// render a list, rather than the full param/metric declaration bodies.
|
/// render a list, rather than the full param/metric declaration bodies.
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -5,6 +5,7 @@ import { LogTimeline, computePresetRange, type TimeRange, type TimeRangePresetId
|
|||||||
import { LogPreview } from './components/LogPreview'
|
import { LogPreview } from './components/LogPreview'
|
||||||
import { Metrics } from './components/Metrics'
|
import { Metrics } from './components/Metrics'
|
||||||
import { Dashboards } from './components/Dashboards'
|
import { Dashboards } from './components/Dashboards'
|
||||||
|
import { Alerts } from './components/Alerts'
|
||||||
import { fetchLogs, fetchStats, fetchTimeline, type LogEntry, type Stats, type TimelineBucket } from './lib/api'
|
import { fetchLogs, fetchStats, fetchTimeline, type LogEntry, type Stats, type TimelineBucket } from './lib/api'
|
||||||
import { DocsNavProvider } from './lib/docsNav'
|
import { DocsNavProvider } from './lib/docsNav'
|
||||||
|
|
||||||
@@ -110,6 +111,7 @@ export default function App() {
|
|||||||
<LogPreview logs={logs} />
|
<LogPreview logs={logs} />
|
||||||
<Metrics range={range} />
|
<Metrics range={range} />
|
||||||
<Dashboards range={range} />
|
<Dashboards range={range} />
|
||||||
|
<Alerts />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DocsNavProvider>
|
</DocsNavProvider>
|
||||||
|
|||||||
@@ -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<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -233,6 +233,51 @@ const SECTIONS: DocSection[] = [
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'alerts',
|
||||||
|
title: 'Alerts',
|
||||||
|
body: (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<p>
|
||||||
|
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:
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc pl-5">
|
||||||
|
<li>
|
||||||
|
<span className="font-semibold text-text">Immediate</span> — fires once per line matching a query.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span className="font-semibold text-text">Threshold</span> — fires when a query's match count within a
|
||||||
|
rolling window crosses a numeric threshold.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span className="font-semibold text-text">NewValue</span> — 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).
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span className="font-semibold text-text">Ratio</span> — fires when the ratio of two queries' match
|
||||||
|
counts within a rolling window crosses a numeric ratio.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
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 — <Code>regex(...)</Code>, <Code>sequence</Code>,{' '}
|
||||||
|
<Code>timestamp</Code>, and <Code>and</Code>/<Code>or</Code>. A <Code>metric_name[param]</Code> comparison
|
||||||
|
isn't supported in an alert query.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
A window accepts a plain number of milliseconds or a humantime-style duration like <Code>30s</Code>,{' '}
|
||||||
|
<Code>5m</Code>, <Code>1h</Code>.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Every rule delivers to an <em>ntfy</em> sink: a topic (required) and an optional server override (defaults
|
||||||
|
to <Code>https://ntfy.sh</Code>). "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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'settings',
|
id: 'settings',
|
||||||
title: 'Settings',
|
title: 'Settings',
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { HelpIcon } from './HelpIcon'
|
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
|
* Input for the syntax-aware pest-parsed filter language described in the
|
||||||
* README (regex + metric comparisons combined with `and`/`or`). The raw
|
* README (regex + metric comparisons combined with `and`/`or`). The raw
|
||||||
@@ -30,7 +36,7 @@ export function FilterBar({
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<HelpIcon sectionId="filtering" label="Help: Filtering" />
|
<HelpIcon sectionId="filtering" label="Help: Filtering" />
|
||||||
<input
|
<input
|
||||||
className="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"
|
className={queryInputClassName}
|
||||||
placeholder='regex("error") and sequence<5 or timestamp>12:00'
|
placeholder='regex("error") and sequence<5 or timestamp>12:00'
|
||||||
value={draft}
|
value={draft}
|
||||||
onChange={(e) => setDraft(e.target.value)}
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
|||||||
@@ -144,6 +144,30 @@ export interface Dashboard {
|
|||||||
panels: Panel[]
|
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<T>(path: string): Promise<T> {
|
async function getJson<T>(path: string): Promise<T> {
|
||||||
const res = await fetch(path)
|
const res = await fetch(path)
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -360,3 +384,29 @@ export function deleteDashboard(profileName: string, dashboardId: string): Promi
|
|||||||
'DELETE',
|
'DELETE',
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function fetchAlertRules(): Promise<AlertRule[]> {
|
||||||
|
return getJson('/api/settings/alerts')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAlertRule(rule: { name: string; enabled: boolean; condition: AlertCondition; sink: AlertSink }): Promise<AlertRule> {
|
||||||
|
return postJson('/api/settings/alerts', rule)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateAlertRule(
|
||||||
|
id: string,
|
||||||
|
rule: { name: string; enabled: boolean; condition: AlertCondition; sink: AlertSink },
|
||||||
|
): Promise<AlertRule> {
|
||||||
|
return sendJson(`/api/settings/alerts/${encodeURIComponent(id)}`, 'PUT', rule)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteAlertRule(id: string): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
return sendJson('/api/settings/alerts/test', 'POST', { topic, server })
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user