mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 22:55: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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user