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)' : ''}`; +}