Alerts from templates, and every alert as a sentence #45

Merged
jcoffey-dev merged 1 commits from feature/alert-templates into main 2026-09-28 22:01:41 +00:00
5 changed files with 480 additions and 0 deletions

No files matched your search

+4
View File
@@ -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
</div>
);
}
if (viewName === 'x:Alert') {
return <AlertsPage viewName={viewName} />;
}
return <DynamicList viewName={viewName} />;
}
+266
View File
@@ -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<string, unknown>[] } | 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<AlertTemplate | null>(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<string | null>(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 (
<div className="space-y-4 rounded-xl border bg-card p-4">
<div className="flex items-center gap-3">
<BellRing className="h-5 w-5 shrink-0 text-primary" />
<p className="font-medium">
{data.alerts.length === 0
? t('alertTemplates.none', 'No alerts yet: nothing tells you when something goes wrong.')
: t('alertTemplates.title', 'In plain words')}
</p>
</div>
{data.alerts.length > 0 && (
<ul className="space-y-1 text-sm">
{data.alerts.map((a) => (
<li key={a.id} className={a.enable === false ? 'text-muted-foreground' : undefined}>
{describeAlert(a)}
</li>
))}
</ul>
)}
{canCreate && (
<div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{t('alertTemplates.add', 'Add one from a template')}
</p>
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{TEMPLATES.map((tpl) => (
<button
key={tpl.id}
type="button"
onClick={() => start(tpl)}
className={cn(
'flex flex-col items-start gap-1 rounded-lg border p-3 text-left transition-colors hover:border-primary/60',
inUse.has(tpl.id) ? 'bg-muted/40' : 'bg-background',
)}
>
<span className="flex w-full items-center gap-2 text-sm font-medium">
{tpl.title}
{inUse.has(tpl.id) && (
<span className="ml-auto text-xs font-normal text-muted-foreground">
{t('alertTemplates.inUse', 'In use')}
</span>
)}
</span>
<span className="text-xs text-muted-foreground">
{t('alertTemplates.when', 'When {{when}}.', { when: tpl.when(tpl.threshold?.value ?? 0) })}
</span>
</button>
))}
</div>
<p className="text-xs text-muted-foreground">
{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.',
)}
</p>
</div>
)}
<Dialog open={open !== null} onOpenChange={(v) => !v && setOpen(null)}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{open?.title}</DialogTitle>
<DialogDescription>
{open &&
t('alertTemplates.whenLong', 'Email when {{when}}.', {
when: open.when(open.threshold && Number.isInteger(n) && n > 0 ? n : (open.threshold?.value ?? 0)),
})}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{open?.threshold && (
<div className="space-y-1.5">
<Label htmlFor="alert-threshold">{open.threshold.label}</Label>
<div className="flex items-center gap-2">
<Input
id="alert-threshold"
type="number"
min={1}
value={threshold}
onChange={(e) => setThreshold(e.target.value)}
className="w-32"
/>
<span className="text-sm text-muted-foreground">{open.threshold.unit}</span>
</div>
</div>
)}
<div className="space-y-1.5">
<Label htmlFor="alert-to">{t('alertTemplates.to', 'Email')}</Label>
<Input id="alert-to" value={to} onChange={(e) => setTo(e.target.value)} placeholder="[email protected]" />
<p className="text-xs text-muted-foreground">
{t(
'alertTemplates.toHint',
'One or more addresses, separated by commas. Preferably one not hosted on this server.',
)}
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="alert-from">{t('alertTemplates.from', 'From')}</Label>
<Input id="alert-from" value={from} onChange={(e) => setFrom(e.target.value)} />
</div>
<label className="flex items-center gap-2 text-sm">
<Checkbox checked={event} onCheckedChange={(v) => setEvent(Boolean(v))} />
{t('alertTemplates.event', 'Also raise an event, for webhooks')}
</label>
{error && <p className="text-sm text-destructive">{error}</p>}
<div className="flex justify-end gap-2">
<Button type="button" variant="ghost" onClick={() => setOpen(null)}>
{t('common.cancel', 'Cancel')}
</Button>
<Button type="button" onClick={() => void create()} disabled={!valid || busy}>
{busy && <Loader2 className="h-4 w-4 animate-spin" />}
{t('alertTemplates.create', 'Add alert')}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}
+20
View File
@@ -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 (
<div className="space-y-4">
<AlertTemplates onCreated={() => setVersion((v) => v + 1)} />
<DynamicList key={version} viewName={viewName} />
</div>
);
}
+46
View File
@@ -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: { '[email protected]': true } },
eventAlert: { '@type': 'Disabled' },
}),
).toBe('When more than 500 messages are waiting to be delivered, email [email protected].');
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)");
});
});
+144
View File
@@ -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<string, unknown>; else?: string };
emailAlert?: { '@type'?: string; to?: Record<string, boolean>; subject?: string };
eventAlert?: { '@type'?: string };
}
/** An alert as a sentence: "When more than 500 messages are waiting, email [email protected]." */
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)' : ''}`;
}