mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 15:15:24 +00:00
feat: unify alerts into one plugin, channels, rules and inbox
This commit is contained in:
1 parent
fbe736bc20
commit
7c02daccf4
129 files changed
+20957
-2687
No files matched your search
@@ -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("; ")}` : ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user