feat: unify alerts into one plugin, channels, rules and inbox

This commit is contained in:
LukeGus committed 2026-09-25 16:26:37 -05:00
1 parent fbe736bc20
commit 7c02daccf4
129 files changed
+20957 -2687

No files matched your search

+4 -10
View File
@@ -34,14 +34,8 @@
"editTitle": "Edit Automation",
"empty": "No automations yet. Create one to react to a metric, a schedule, or an event.",
"emptyRuns": "No runs yet.",
"emptyChannels": "No channels yet. Add one to send notifications somewhere.",
"noChannelsHint": "No channels set up yet. Add one in the Channels tab.",
"addChannel": "Add Channel",
"testChannel": "Test",
"channelTestSent": "Test notification sent",
"channelTestFailed": "Test notification failed",
"channelDeleted": "Channel deleted",
"deleteChannelConfirm": "Delete this channel? Automations using it will stop notifying.",
"channelsHint": "Channels are set up in Alerts",
"noChannelsHint": "The alert always goes to your inbox. Add channels in Alerts to also send it somewhere else.",
"nameLabel": "Name",
"namePlaceholder": "e.g., Clean up /data when it fills",
"nameRequired": "Name is required",
@@ -101,7 +95,7 @@
"webhook": "Incoming webhook"
},
"stepTypes": {
"notify": "Send a notification",
"notify": "Send an alert",
"http": "Call a URL",
"run_snippet": "Run a snippet",
"run_command": "Run a command",
@@ -140,7 +134,7 @@
"url": "URL",
"method": "Method",
"body": "Body",
"channels": "Channels",
"channels": "Also send to",
"title": "Title",
"message": "Message",
"snippet": "Snippet",
@@ -0,0 +1,15 @@
-- automations 0002: unlink_channels
-- Hand-written: channels moved from core's notification_channels to the
-- alerts plugin, so the link table drops its foreign key to the old table.
-- The constraint's name differs between installs, so the table is rebuilt.
CREATE TABLE IF NOT EXISTS `p_automations_channels_v2` (
`id` int AUTO_INCREMENT PRIMARY KEY,
`automation_id` int NOT NULL,
`channel_id` int NOT NULL,
FOREIGN KEY (`automation_id`) REFERENCES `p_automations_automations` (`id`) ON DELETE CASCADE
);
INSERT INTO `p_automations_channels_v2` (`id`, `automation_id`, `channel_id`) SELECT `id`, `automation_id`, `channel_id` FROM `p_automations_channels`;
DROP TABLE `p_automations_channels`;
RENAME TABLE `p_automations_channels_v2` TO `p_automations_channels`;
CREATE UNIQUE INDEX `idx_automation_channels_pair` ON `p_automations_channels` (`automation_id`, `channel_id`);
@@ -0,0 +1,16 @@
-- automations 0002: unlink_channels
-- Hand-written: channels moved from core's notification_channels to the
-- alerts plugin, so the link table drops its foreign key to the old table.
-- The constraint's name differs between installs, so the table is rebuilt.
CREATE TABLE IF NOT EXISTS "p_automations_channels_v2" (
"id" serial PRIMARY KEY,
"automation_id" integer NOT NULL,
"channel_id" integer NOT NULL,
FOREIGN KEY ("automation_id") REFERENCES "p_automations_automations" ("id") ON DELETE CASCADE
);
INSERT INTO "p_automations_channels_v2" ("id", "automation_id", "channel_id") SELECT "id", "automation_id", "channel_id" FROM "p_automations_channels";
DROP TABLE "p_automations_channels";
ALTER TABLE "p_automations_channels_v2" RENAME TO "p_automations_channels";
SELECT setval(pg_get_serial_sequence('p_automations_channels', 'id'), COALESCE((SELECT MAX("id") FROM "p_automations_channels"), 1));
CREATE UNIQUE INDEX IF NOT EXISTS "idx_automation_channels_pair" ON "p_automations_channels" ("automation_id", "channel_id");
@@ -0,0 +1,15 @@
-- automations 0002: unlink_channels
-- Hand-written: channels moved from core's notification_channels to the
-- alerts plugin, so the link table drops its foreign key to the old table.
-- SQLite cannot drop a constraint, so the table is rebuilt.
CREATE TABLE IF NOT EXISTS "p_automations_channels_v2" (
"id" integer PRIMARY KEY AUTOINCREMENT,
"automation_id" integer NOT NULL,
"channel_id" integer NOT NULL,
FOREIGN KEY ("automation_id") REFERENCES "p_automations_automations" ("id") ON DELETE CASCADE
);
INSERT INTO "p_automations_channels_v2" ("id", "automation_id", "channel_id") SELECT "id", "automation_id", "channel_id" FROM "p_automations_channels";
DROP TABLE "p_automations_channels";
ALTER TABLE "p_automations_channels_v2" RENAME TO "p_automations_channels";
CREATE UNIQUE INDEX IF NOT EXISTS "idx_automation_channels_pair" ON "p_automations_channels" ("automation_id", "channel_id");
@@ -67,22 +67,26 @@ async function runNotify(
const title = renderTemplate(step.title ?? "", context.template);
const body = renderTemplate(step.body ?? "", context.template);
if (step.channelIds.length === 0) {
return fail("No notification channels selected");
}
const channelIds = step.channelIds ?? [];
if (context.dryRun) {
return ok(
`Would notify ${step.channelIds.length} channel(s): ${title || body}`,
channelIds.length > 0
? `Would send an alert to the inbox and ${channelIds.length} channel(s): ${title || body}`
: `Would send an alert to the inbox: ${title || body}`,
);
}
const trigger = context.template.trigger ?? {};
let result;
try {
result = await runtime.ctx.notify.send(step.channelIds, {
result = await runtime.ctx.notify.send({
title: title || "Termix automation",
body,
severity: step.severity ?? "warning",
category: "automations.notify",
audience: { userId: context.userId },
channelIds,
link: { tab: "automations" },
context: {
hostId:
context.template.host?.id ??
@@ -102,12 +106,15 @@ async function runNotify(
return fail(errorText(error));
}
if (result.recipients === 0) {
return fail("Alerts are turned off on this server");
}
const errors = result.failures.map((f) => `${f.name}: ${f.error}`);
if (result.delivered === 0) {
return fail(errors.join("; ") || "No enabled channels to notify");
if (channelIds.length > 0 && result.delivered === 0 && errors.length > 0) {
return fail(errors.join("; "));
}
return ok(
`Notified ${result.delivered} channel(s)${errors.length ? `; ${errors.join("; ")}` : ""}`,
`Sent an alert${result.delivered ? ` and notified ${result.delivered} channel(s)` : ""}${errors.length ? `; ${errors.join("; ")}` : ""}`,
);
}
+21
View File
@@ -302,6 +302,27 @@ export class AutomationEngine {
// An automation_failed handler that itself fails must not re-announce
// its own failure, so the event is not emitted for runs that this event
// already started.
await this.ctx.notify
.send({
title: `Automation "${automation.name}" failed`,
body: error ?? undefined,
severity: "warning",
category: "automations.run_failed",
audience: { userId: automation.userId },
link: { tab: "automations" },
dedupeKey: `automations.run_failed:${automation.id}`,
context: {
sourceId: automation.id,
sourceName: automation.name,
triggerType: request.triggerType,
},
})
.catch((notifyError: unknown) =>
this.ctx.log.warn(
`Could not send the failure alert for automation ${automation.id}: ${errorText(notifyError)}`,
),
);
if (request.triggerType !== "internal_event") {
this.ctx.events.emit(TOPIC_AUTOMATION_FAILED, {
userId: automation.userId,
@@ -99,7 +99,7 @@ export type AutomationRepository = Awaited<
>;
/**
* Automation storage. `ownedChannelIds` answers which of core's notification
* Automation storage. `ownedChannelIds` answers which alert
* channels the user owns, so a link can never point at another user's.
*/
export async function createAutomationRepository(
+5 -5
View File
@@ -15,10 +15,10 @@ import {
* Adopted from core's automation tables, so column and index names are the
* legacy ones and the rename carries every row across.
*
* The links between these tables, and a channel link to core's
* notification_channels, are plain integers here: the SDK only references
* users and ssh_data. The adoption migrations keep the legacy FOREIGN KEY
* clauses by hand, so deleting an automation still cascades.
* The links between these tables are plain integers here: the SDK only
* references users and ssh_data. The adoption migrations keep the legacy
* FOREIGN KEY clauses by hand, so deleting an automation still cascades. A
* channel id points into the alerts plugin's table and has no foreign key.
*/
export const automations = adoptLegacyTable(
@@ -166,7 +166,7 @@ export const runSteps = adoptLegacyTable(
),
);
/** Which of core's notification channels an automation is linked to. */
/** Which alert channels an automation is linked to. */
export const channels = adoptLegacyTable(
"automation_channels",
defineTable(
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { invokeAction, useTranslation } from "@termix/plugin-sdk/frontend";
import { toast } from "sonner";
import {
ArrowLeft,
@@ -11,16 +11,7 @@ import {
Trash2,
Workflow,
} from "lucide-react";
import {
Badge,
Button,
NotificationChannelDialog,
deleteNotificationChannel,
getBasePath,
getNotificationChannels,
testNotificationChannel,
type NotificationChannel,
} from "@termix/plugin-sdk/ui";
import { Badge, Button, getBasePath } from "@termix/plugin-sdk/ui";
import {
useAutomationsApi,
type AutomationRow,
@@ -42,7 +33,7 @@ function getErrorMessage(error: unknown, fallback: string): string {
return error instanceof Error ? error.message : fallback;
}
type PanelTab = "automations" | "runs" | "channels";
type PanelTab = "automations" | "runs";
const STATUS_CLASS: Record<string, string> = {
success: "text-green-500",
@@ -96,11 +87,6 @@ export function AutomationsPanel({
const [expandedRun, setExpandedRun] = useState<number | null>(null);
const [runSteps, setRunSteps] = useState<AutomationRunStepRow[]>([]);
const [channels, setChannels] = useState<NotificationChannel[]>([]);
const [channelDialogOpen, setChannelDialogOpen] = useState(false);
const [editingChannel, setEditingChannel] =
useState<NotificationChannel | null>(null);
const loadAutomations = useCallback(async () => {
setLoading(true);
try {
@@ -121,15 +107,10 @@ export function AutomationsPanel({
}, [api]);
const loadOptions = useCallback(async () => {
const [editorOptions, channelsResult] = await Promise.allSettled([
api.editorOptions(),
getNotificationChannels(),
]);
if (channelsResult.status === "fulfilled") {
setChannels(channelsResult.value);
}
if (editorOptions.status === "fulfilled") {
setOptions(editorOptions.value);
try {
setOptions(await api.editorOptions());
} catch {
// The editor falls back to empty pickers.
}
}, [api]);
@@ -241,27 +222,6 @@ export function AutomationsPanel({
}
}
async function testChannel(channel: NotificationChannel) {
try {
// Throws with the reason when delivery fails.
await testNotificationChannel(channel.id);
toast.success(t(`${base}.channelTestSent`));
} catch (error) {
toast.error(getErrorMessage(error, String(error)));
}
}
async function removeChannel(channel: NotificationChannel) {
if (!confirm(t(`${base}.deleteChannelConfirm`))) return;
try {
await deleteNotificationChannel(channel.id);
toast.success(t(`${base}.channelDeleted`));
await loadOptions();
} catch (error) {
toast.error(getErrorMessage(error, String(error)));
}
}
async function toggleRun(runId: number) {
if (expandedRun === runId) {
setExpandedRun(null);
@@ -372,7 +332,7 @@ export function AutomationsPanel({
return (
<div className="flex flex-col h-full">
<div className="flex items-center gap-1 p-2 border-b border-border">
{(["automations", "runs", "channels"] as PanelTab[]).map((key) => (
{(["automations", "runs"] as PanelTab[]).map((key) => (
<Button
key={key}
variant={tab === key ? "outline" : "ghost"}
@@ -386,11 +346,20 @@ export function AutomationsPanel({
>
<span className="truncate">
{t(
`${base}.${key === "automations" ? "tabAutomations" : key === "runs" ? "tabRuns" : "tabChannels"}`,
`${base}.${key === "automations" ? "tabAutomations" : "tabRuns"}`,
)}
</span>
</Button>
))}
<Button
variant="ghost"
size="sm"
className="min-w-0 flex-shrink rounded-none h-7 px-2 text-xs"
title={t(`${base}.channelsHint`)}
onClick={() => void invokeAction("alerts.openChannels")}
>
<span className="truncate">{t(`${base}.tabChannels`)}</span>
</Button>
<a
href="https://docs.termix.site/features/automations/overview"
target="_blank"
@@ -576,83 +545,7 @@ export function AutomationsPanel({
))}
</div>
)}
{tab === "channels" && (
<>
<Button
size="sm"
variant="outline"
className="rounded-none w-full mb-2 border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={() => {
setEditingChannel(null);
setChannelDialogOpen(true);
}}
>
<Plus size={14} className="mr-1" />
{t(`${base}.addChannel`)}
</Button>
{channels.length === 0 && (
<p className="text-xs text-muted-foreground p-2">
{t(`${base}.emptyChannels`)}
</p>
)}
<div className="space-y-1">
{channels.map((channel) => (
<div
key={channel.id}
className="border border-border p-2.5 hover:bg-muted/40"
>
<div className="flex items-center gap-2">
<button
className="flex-1 text-left text-sm truncate"
onClick={() => {
setEditingChannel(channel);
setChannelDialogOpen(true);
}}
>
{channel.name}
</button>
<Badge variant="outline" className="text-[10px] uppercase">
{channel.type}
</Badge>
</div>
<div className="flex items-center gap-1 mt-1">
<span className="flex-1" />
<Button
variant="ghost"
size="sm"
className="h-6 rounded-none text-[11px]"
onClick={() => testChannel(channel)}
>
{t(`${base}.testChannel`)}
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 rounded-none"
onClick={() => removeChannel(channel)}
>
<Trash2 size={12} />
</Button>
</div>
</div>
))}
</div>
</>
)}
</div>
<NotificationChannelDialog
open={channelDialogOpen}
onOpenChange={setChannelDialogOpen}
channel={editingChannel}
onSaved={() => {
setChannelDialogOpen(false);
void loadOptions();
}}
/>
</div>
);
}
@@ -62,10 +62,12 @@ describe("scheduled automations", () => {
expect(server.mock.notifications).toEqual([
{
actor: "alice",
channelIds: [1],
notification: expect.objectContaining({
title: "Ping schedule",
body: "hello",
category: "automations.notify",
audience: { userId: "alice" },
channelIds: [1],
}),
},
]);
@@ -381,3 +383,76 @@ describe("watchers", () => {
});
});
});
describe("alerts", () => {
const schedule = { kind: "schedule", intervalSeconds: 300 };
it("sends a notify step to the owner's inbox when no channel is picked", async () => {
server = await startServer();
const id = await create(
server,
"Inbox only",
definition(schedule, [
{ id: "n", type: "notify", channelIds: [], title: "Heads up" },
]),
);
const outcome = await server.request("POST", `/${id}/run`, { body: {} });
expect(outcome.body.status).toBe("success");
expect(server.mock.notifications).toEqual([
{
actor: "alice",
notification: expect.objectContaining({
title: "Heads up",
audience: { userId: "alice" },
channelIds: [],
link: { tab: "automations" },
}),
},
]);
});
it("only describes the alert on a dry run", async () => {
server = await startServer();
const id = await create(
server,
"Dry",
definition(schedule, [
{ id: "n", type: "notify", channelIds: [1], title: "Heads up" },
]),
);
const outcome = await server.request("POST", `/${id}/run`, {
body: { dryRun: true },
});
expect(outcome.body.status).toBe("success");
expect(server.mock.notifications).toEqual([]);
});
it("alerts the owner when a run fails", async () => {
server = await startServer();
const id = await create(
server,
"Breaks",
definition(schedule, [{ id: "s", type: "stop", status: "failed" }]),
);
const outcome = await server.request("POST", `/${id}/run`, { body: {} });
expect(outcome.body.status).toBe("failed");
expect(server.mock.notifications).toEqual([
{
actor: "alice",
notification: expect.objectContaining({
title: 'Automation "Breaks" failed',
severity: "warning",
category: "automations.run_failed",
audience: { userId: "alice" },
dedupeKey: `automations.run_failed:${id}`,
}),
},
]);
});
});
@@ -175,6 +175,23 @@ describe("adopting the automation tables", () => {
"idx_automation_runs_user",
]);
// Channels moved to the alerts plugin, so a link may name a channel the
// old table never had, and the old one's link is still there.
expect(indexes(db, "p_automations_channels")).toEqual([
"idx_automation_channels_pair",
]);
expect(
db.sqlite.prepare("SELECT channel_id FROM p_automations_channels").all(),
).toEqual([{ channel_id: 4 }]);
db.sqlite
.prepare(
"INSERT INTO p_automations_channels (automation_id, channel_id) VALUES (7, 99)",
)
.run();
db.sqlite
.prepare("DELETE FROM p_automations_channels WHERE channel_id = 99")
.run();
// The foreign keys followed the rename: deleting the automation still
// takes its state, schedule, runs, steps and channel links with it.
db.sqlite