Alerts from templates, and every alert as a sentence #45
No files matched your search
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)");
|
||||
});
|
||||
});
|
||||
@@ -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)' : ''}`;
|
||||
}
|
||||
Reference in new issue
Block a user