From db8b0b1d26ba30aabb42207626e0e1d66043d3a1 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Mon, 28 Sep 2026 13:17:47 -0700 Subject: [PATCH] Alerts from templates, and every alert as a sentence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Monitoring › Alerts needed a metrics expression to do anything. It now opens with each alert in words ("When more than 500 messages are waiting to be delivered, email ops@example.org.") and five templates: pick one, set the number if it has one, say who to email, done. - The queue is backing up (queue.count, a gauge: re-arms when it drains). - Memory is running high (server.memory in MB, a gauge). - Certificate renewal failed, the data store reported an error, and publishing DNS records failed: counters, which are totals since the server started, so each fires the first time and again only after a restart. The page says so. No template watches a rate: counters in a condition are totals since start, so "failures per hour" can't be written honestly. Templates are recognised again on the page ("In use"), and an empty page says that nothing will tell you when something goes wrong. --- src/components/layout/MainContent.tsx | 4 + src/features/alerts/AlertTemplates.tsx | 266 +++++++++++++++++++++++++ src/features/alerts/AlertsPage.tsx | 20 ++ src/features/alerts/templates.test.ts | 46 +++++ src/features/alerts/templates.ts | 144 +++++++++++++ 5 files changed, 480 insertions(+) create mode 100644 src/features/alerts/AlertTemplates.tsx create mode 100644 src/features/alerts/AlertsPage.tsx create mode 100644 src/features/alerts/templates.test.ts create mode 100644 src/features/alerts/templates.ts diff --git a/src/components/layout/MainContent.tsx b/src/components/layout/MainContent.tsx index de3e74b..5774400 100644 --- a/src/components/layout/MainContent.tsx +++ b/src/components/layout/MainContent.tsx @@ -17,6 +17,7 @@ import { DynamicForm } from '@/components/forms/DynamicForm'; import { DynamicViewPage } from '@/components/views/DynamicViewPage'; import { LoadingFallback } from '@/components/common/LoadingFallback'; import { LegacyProtocolsBanner } from '@/features/hardening/LegacyProtocolsBanner'; +import { AlertsPage } from '@/features/alerts/AlertsPage'; import { ReportsPage } from '@/features/reports/ReportsPage'; import { RetentionPage } from '@/features/retention/RetentionPage'; import { LimitsSummary } from '@/features/limits/LimitsSummary'; @@ -358,5 +359,8 @@ function renderView(schema: Schema | null, viewName?: string, id?: string, secti ); } + if (viewName === 'x:Alert') { + return ; + } return ; } diff --git a/src/features/alerts/AlertTemplates.tsx b/src/features/alerts/AlertTemplates.tsx new file mode 100644 index 0000000..b74979b --- /dev/null +++ b/src/features/alerts/AlertTemplates.tsx @@ -0,0 +1,266 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * inbuxa: above Monitoring › Alerts, every alert as a sentence and a set of + * templates: pick one, say who to email, done (settings-reorg, second wave). + * No expression to write. + */ + +import { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { BellRing, Loader2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { getAccountId, jmapRequest, jmapSet } from '@/services/jmap/client'; +import { useAccountStore } from '@/stores/accountStore'; +import { useAuthStore } from '@/stores/authStore'; +import type { JmapSetResponse } from '@/types/jmap'; +import { cn } from '@/lib/utils'; +import { + describeAlert, + recognize, + templateCondition, + TEMPLATES, + type AlertRecord, + type AlertTemplate, +} from './templates'; + +async function load(): Promise<{ alerts: AlertRecord[]; defaultDomain: string }> { + const res = await jmapRequest([ + ['x:Alert/get', { accountId: getAccountId('x:Alert'), ids: null }, 'a'], + [ + 'x:SystemSettings/get', + { accountId: getAccountId('x:SystemSettings'), ids: ['singleton'], properties: ['defaultDomainId'] }, + 's', + ], + ['x:Domain/get', { accountId: getAccountId('x:Domain'), ids: null, properties: ['name'] }, 'd'], + ]); + const list = (id: string) => + (res.find(([, , i]) => i === id)?.[1] as { list?: Record[] } | undefined)?.list ?? []; + const defaultId = list('s')[0]?.defaultDomainId; + return { + alerts: list('a') as AlertRecord[], + defaultDomain: String(list('d').find((d) => d.id === defaultId)?.name ?? ''), + }; +} + +export function AlertTemplates({ onCreated }: { onCreated?: () => void }) { + const { t } = useTranslation(); + const me = useAuthStore((s) => s.username) ?? ''; + const canCreate = useAccountStore((s) => s.hasObjectPermission('sysAlert', 'Create')); + const [data, setData] = useState<{ alerts: AlertRecord[]; defaultDomain: string } | null>(null); + const [open, setOpen] = useState(null); + const [threshold, setThreshold] = useState(''); + const [to, setTo] = useState(''); + const [from, setFrom] = useState(''); + const [event, setEvent] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const reload = useCallback(() => load().then(setData), []); + useEffect(() => { + let live = true; + load() + .then((d) => live && setData(d)) + .catch(() => undefined); + return () => { + live = false; + }; + }, []); + + if (!data) return null; + + const inUse = new Set( + data.alerts + .map((a) => (Object.keys(a.condition?.match ?? {}).length === 0 ? recognize(a.condition?.else ?? '') : null)) + .filter(Boolean) + .map((r) => r!.template.id), + ); + + const start = (tpl: AlertTemplate) => { + setOpen(tpl); + setThreshold(String(tpl.threshold?.value ?? '')); + setTo(me.includes('@') ? me : ''); + setFrom(data.defaultDomain ? `postmaster@${data.defaultDomain}` : ''); + setEvent(false); + setError(null); + }; + + const recipients = to + .split(/[\s,]+/) + .map((a) => a.trim()) + .filter(Boolean); + const emailOk = (a: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(a); + const n = Number(threshold); + const valid = + open !== null && + (!open.threshold || (Number.isInteger(n) && n > 0)) && + recipients.length > 0 && + recipients.every(emailOk) && + emailOk(from); + + const create = async () => { + if (!open) return; + setBusy(true); + setError(null); + try { + const [res] = await jmapSet('x:Alert', getAccountId('x:Alert'), { + create: { + alert: { + enable: true, + condition: { match: {}, else: templateCondition(open, open.threshold ? n : 0) }, + emailAlert: { + '@type': 'Enabled', + fromName: 'Mail server alerts', + fromAddress: from.trim(), + to: Object.fromEntries(recipients.map((r) => [r, true])), + subject: open.subject, + body: open.body, + }, + eventAlert: event ? { '@type': 'Enabled', eventMessage: open.subject } : { '@type': 'Disabled' }, + }, + }, + }); + const body = res?.[1] as unknown as JmapSetResponse | undefined; + const bad = body?.notCreated?.alert; + if (bad || !body?.created?.alert) { + throw new Error(bad?.description ?? bad?.type ?? t('alertTemplates.failed', 'The alert could not be added.')); + } + setOpen(null); + await reload(); + onCreated?.(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(false); + } + }; + + return ( +
+
+ +

+ {data.alerts.length === 0 + ? t('alertTemplates.none', 'No alerts yet: nothing tells you when something goes wrong.') + : t('alertTemplates.title', 'In plain words')} +

+
+ {data.alerts.length > 0 && ( +
    + {data.alerts.map((a) => ( +
  • + {describeAlert(a)} +
  • + ))} +
+ )} + + {canCreate && ( +
+

+ {t('alertTemplates.add', 'Add one from a template')} +

+
+ {TEMPLATES.map((tpl) => ( + + ))} +
+

+ {t( + 'alertTemplates.howOften', + 'Alerts are checked every minute and email once when their condition turns true. The queue and memory alerts can fire again after things recover; the others fire the first time, and again only after a restart.', + )} +

+
+ )} + + !v && setOpen(null)}> + + + {open?.title} + + {open && + t('alertTemplates.whenLong', 'Email when {{when}}.', { + when: open.when(open.threshold && Number.isInteger(n) && n > 0 ? n : (open.threshold?.value ?? 0)), + })} + + +
+ {open?.threshold && ( +
+ +
+ setThreshold(e.target.value)} + className="w-32" + /> + {open.threshold.unit} +
+
+ )} +
+ + setTo(e.target.value)} placeholder="ops@example.org" /> +

+ {t( + 'alertTemplates.toHint', + 'One or more addresses, separated by commas. Preferably one not hosted on this server.', + )} +

+
+
+ + setFrom(e.target.value)} /> +
+ + {error &&

{error}

} +
+ + +
+
+
+
+
+ ); +} diff --git a/src/features/alerts/AlertsPage.tsx b/src/features/alerts/AlertsPage.tsx new file mode 100644 index 0000000..74c5bef --- /dev/null +++ b/src/features/alerts/AlertsPage.tsx @@ -0,0 +1,20 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { useState } from 'react'; +import { DynamicList } from '@/components/lists/DynamicList'; +import { AlertTemplates } from './AlertTemplates'; + +/** Monitoring › Alerts: the alerts in words and the templates, then the list, refreshed when one is added. */ +export function AlertsPage({ viewName }: { viewName: string }) { + const [version, setVersion] = useState(0); + return ( +
+ setVersion((v) => v + 1)} /> + +
+ ); +} diff --git a/src/features/alerts/templates.test.ts b/src/features/alerts/templates.test.ts new file mode 100644 index 0000000..c7066e8 --- /dev/null +++ b/src/features/alerts/templates.test.ts @@ -0,0 +1,46 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, it, expect } from 'vitest'; +import { describeAlert, recognize, templateCondition, TEMPLATES } from './templates'; + +const byId = (id: string) => TEMPLATES.find((t) => t.id === id)!; + +describe('alert templates', () => { + it('write conditions over metrics', () => { + expect(templateCondition(byId('queue'), 500)).toBe("metric('queue.count') > 500"); + expect(templateCondition(byId('memory'), 2048)).toBe(`metric('server.memory') > ${2048 * 1024 * 1024}`); + expect(templateCondition(byId('dns'), 0)).toBe( + "metric('dns.record-creation-failed') + metric('dns.record-deletion-failed') > 0", + ); + }); + + it('recognise every condition they write, with its threshold', () => { + for (const t of TEMPLATES) { + const n = t.threshold?.value ?? 0; + expect(recognize(templateCondition(t, n))).toEqual({ template: t, n }); + } + expect(recognize("metric('queue.count') > 10 && true")).toBeNull(); + }); + + it('describe an alert in a sentence', () => { + expect( + describeAlert({ + condition: { match: {}, else: "metric('queue.count') > 500" }, + emailAlert: { '@type': 'Enabled', to: { 'ops@example.org': true } }, + eventAlert: { '@type': 'Disabled' }, + }), + ).toBe('When more than 500 messages are waiting to be delivered, email ops@example.org.'); + expect( + describeAlert({ + enable: false, + condition: { match: {}, else: "metric('smtp.connection-start') > 9" }, + emailAlert: { '@type': 'Disabled' }, + eventAlert: { '@type': 'Enabled' }, + }), + ).toBe("When the condition metric('smtp.connection-start') > 9 is true, raise an event for webhooks. (off)"); + }); +}); diff --git a/src/features/alerts/templates.ts b/src/features/alerts/templates.ts new file mode 100644 index 0000000..d412d23 --- /dev/null +++ b/src/features/alerts/templates.ts @@ -0,0 +1,144 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * inbuxa: alerts people actually want, as templates (settings-reorg, second + * wave). How alerts behave (monitoring spec MON-25 to MON-29): the condition + * is checked every minute and the alert fires when it turns true, then again + * only after it has been false. Gauges (the queue, memory) go back down, so + * those alerts re-arm. Counters are totals since the server started, so a + * counter alert ("> 0") fires once, the first time it happens, and not again + * until a restart: right for rare, serious events, wrong for rates, which is + * why no template watches a rate. + */ + +export interface AlertTemplate { + id: string; + title: string; + /** When it fires, in words. */ + when: (n: number) => string; + /** A threshold to ask for, if the template takes one. */ + threshold?: { label: string; unit: string; value: number; toMetric?: (n: number) => number }; + condition: (n: number) => string; + subject: string; + body: string; +} + +const MB = 1024 * 1024; + +const STORE_ERRORS = [ + 'store.data-corruption', + 'store.rocksdb-error', + 'store.sqlite-error', + 'store.postgresql-error', + 'store.mysql-error', + 'store.foundationdb-error', + 'store.s3-error', + 'store.azure-error', + 'store.filesystem-error', +]; + +const sum = (metrics: string[]) => metrics.map((m) => `metric('${m}')`).join(' + '); + +export const TEMPLATES: AlertTemplate[] = [ + { + id: 'queue', + title: 'The queue is backing up', + when: (n) => `more than ${n} messages are waiting to be delivered`, + threshold: { label: 'Messages waiting', unit: 'messages', value: 500 }, + condition: (n) => `metric('queue.count') > ${n}`, + subject: 'Mail queue: %{queue.count}% messages waiting', + body: + 'The outgoing mail queue holds %{queue.count}% messages.\n\n' + + 'Open Management › Emails › Queued in the console to see what is waiting and why.\n', + }, + { + id: 'memory', + title: 'Memory is running high', + when: (n) => `the server uses more than ${n} MB of memory`, + threshold: { label: 'Memory', unit: 'MB', value: 2048, toMetric: (n) => n * MB }, + condition: (n) => `metric('server.memory') > ${n}`, + subject: 'Mail server memory: %{server.memory}% bytes', + body: 'The mail server is using %{server.memory}% bytes of memory, above the limit you set.\n', + }, + { + id: 'certificates', + title: 'Certificate renewal failed', + when: () => 'renewing a certificate fails for the first time since the server started', + condition: () => sum(['acme.order-invalid', 'acme.auth-error', 'acme.error']) + ' > 0', + subject: 'Certificate renewal failed', + body: + 'A certificate order or renewal failed on the mail server.\n\n' + + 'Open Management › Tasks › Failed in the console for the reason. The current certificate keeps working until it expires.\n', + }, + { + id: 'storage', + title: 'The data store reported an error', + when: () => 'the data store reports an error for the first time since the server started', + condition: () => sum(STORE_ERRORS) + ' > 0', + subject: 'Mail server storage error', + body: 'The mail server’s data store reported an error. Check the server logs, and the disk or database behind it.\n', + }, + { + id: 'dns', + title: 'Publishing DNS records failed', + when: () => 'writing DNS records to your DNS provider fails for the first time since the server started', + condition: () => sum(['dns.record-creation-failed', 'dns.record-deletion-failed']) + ' > 0', + subject: 'DNS publishing failed', + body: + 'The mail server could not write a DNS record at your DNS provider.\n\n' + + 'The API key may have expired or lost access. Open the domain in the console to check its DNS.\n', + }, +]; + +export function templateCondition(t: AlertTemplate, n: number): string { + return t.condition(t.threshold?.toMetric ? t.threshold.toMetric(n) : n); +} + +/** Which template, and threshold, an alert's condition came from. */ +export function recognize(condition: string): { template: AlertTemplate; n: number } | null { + const c = condition.replace(/\s+/g, ' ').trim(); + for (const t of TEMPLATES) { + if (!t.threshold) { + if (c === t.condition(0).replace(/\s+/g, ' ').trim()) return { template: t, n: 0 }; + continue; + } + const [head] = t.condition(0).split('> '); + if (c.startsWith(head) && /> \d+$/.test(c)) { + const raw = Number(c.slice(head.length).replace('> ', '')); + const n = t.threshold.toMetric ? Math.round(raw / t.threshold.toMetric(1)) : raw; + return { template: t, n }; + } + } + return null; +} + +export interface AlertRecord { + id?: string; + enable?: boolean; + condition?: { match?: Record; else?: string }; + emailAlert?: { '@type'?: string; to?: Record; subject?: string }; + eventAlert?: { '@type'?: string }; +} + +/** An alert as a sentence: "When more than 500 messages are waiting, email ops@example.org." */ +export function describeAlert(a: AlertRecord): string { + const cond = a.condition ?? {}; + const plain = Object.keys(cond.match ?? {}).length === 0 ? (cond.else ?? '') : null; + const known = plain !== null ? recognize(plain) : null; + const when = known + ? known.template.when(known.n) + : plain + ? `the condition ${plain} is true` + : 'its condition is true'; + const actions: string[] = []; + if (a.emailAlert?.['@type'] === 'Enabled') { + actions.push(`email ${Object.keys(a.emailAlert.to ?? {}).join(', ') || 'nobody'}`); + } + if (a.eventAlert?.['@type'] === 'Enabled') actions.push('raise an event for webhooks'); + const does = actions.length > 0 ? actions.join(' and ') : 'do nothing (no email or event is set)'; + return `When ${when}, ${does}.${a.enable === false ? ' (off)' : ''}`; +} -- 2.54.0