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

+1
View File
@@ -20,6 +20,7 @@
"secrets:own",
"network:outbound",
"network:serve",
"notify:send",
"ui:surface"
],
"contributes": {
+12 -1
View File
@@ -134,10 +134,21 @@ export function createAcmeRunner(
await runner.issue();
return true;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
ctx.log.error(
"Certificate renewal failed",
error instanceof Error ? error : new Error(String(error)),
error instanceof Error ? error : new Error(message),
);
await ctx.notify
.send({
title: `Certificate renewal failed for ${settings.domain}`,
body: message,
severity: "critical",
category: "acme-ssl.renewal_failed",
audience: "admins",
dedupeKey: `acme-ssl.renewal_failed:${settings.domain}`,
})
.catch(() => {});
return false;
}
},
@@ -176,6 +176,23 @@ describe("scheduled renewal", () => {
await vi.waitFor(() => expect(mock.tls.renewers).toBe(0));
});
it("alerts every admin when a renewal fails", async () => {
const { mock, runner } = await setup({ settings: CONFIGURED });
issue.mockRejectedValueOnce(new Error("rate limited"));
expect(await runner.check()).toBe(false);
expect(mock.notifications).toEqual([
{
actor: undefined,
notification: expect.objectContaining({
body: "rate limited",
severity: "critical",
category: "acme-ssl.renewal_failed",
audience: "admins",
}),
},
]);
});
it("waits before retrying after a failed attempt", async () => {
const { mock, runner } = await setup({ settings: CONFIGURED });
issue.mockRejectedValueOnce(new Error("rate limited"));
+5
View File
@@ -0,0 +1,5 @@
# Changelog
## 1.0.0
Bundled with Termix 2.9.0.
+23
View File
@@ -0,0 +1,23 @@
# Alerts
One inbox for every alert, with delivery to webhooks, ntfy, Discord and email.
Bundled with Termix and enabled by default. Any plugin sends through
`ctx.notify.send`; this plugin is the hub that stores and delivers them.
## Layout
manifest.json id, capabilities and what this plugin contributes
src/backend/ activate(ctx) / deactivate(), plus the routes
src/frontend/ the UI it registers
locales/en.json its strings (English only; the rest are translated)
tests/backend/ vitest, node
tests/frontend/ vitest, jsdom
## Commands
npm run build bundle into dist/
npm run test run this plugin's tests
npm run typecheck
See packages/plugin-sdk/ARCHITECTURE.md for the contract this follows.
+164
View File
@@ -0,0 +1,164 @@
{
"nav": {
"alerts": "Alerts"
},
"permissions": {
"use": {
"title": "Use alerts",
"description": "Get alerts in the inbox and send them to your own channels."
}
},
"onboarding": {
"feature": "Alerts",
"featureDesc": "Every alert in one inbox, sent on to Discord, ntfy, email or a webhook if you want."
},
"settings": {
"group": {
"general": "General",
"email": "Email"
},
"admin": {
"announcements": {
"label": "Termix announcements",
"description": "Show news from the Termix team, like security notices and releases, in everyone's inbox."
},
"retentionDays": {
"label": "Keep alerts for (days)",
"description": "Older alerts are removed from every inbox."
},
"smtpHost": {
"label": "SMTP server",
"description": "Needed for email channels. Leave empty to turn email off.",
"placeholder": "smtp.example.com"
},
"smtpPort": {
"label": "SMTP port",
"description": "Usually 587 with STARTTLS or 465 with TLS."
},
"smtpSecure": {
"label": "Use TLS from the start",
"description": "Turn on for port 465. Leave off for 587, which upgrades to TLS on its own."
},
"smtpUser": {
"label": "SMTP username",
"description": "Leave empty if the server does not need a login."
},
"smtpPassword": {
"label": "SMTP password",
"description": "Stored encrypted."
},
"smtpFrom": {
"label": "From address",
"description": "The sender alerts come from.",
"placeholder": "Termix <alerts@example.com>"
}
},
"user": {
"popups": {
"label": "Alert popups",
"description": "Which new alerts pop up while you use Termix. They always land in the inbox.",
"all": "All alerts",
"warning": "Warnings and critical",
"critical": "Critical only",
"off": "None"
}
}
},
"sections": {
"inbox": "Inbox",
"channels": "Channels",
"rules": "Delivery rules"
},
"severity": {
"info": "Info",
"success": "Success",
"warning": "Warning",
"critical": "Critical"
},
"inbox": {
"sourceTermix": "Termix",
"openLink": "Open",
"view": "View",
"unread": "Unread",
"deliveryFailed_one": "{{count}} channel failed",
"deliveryFailed_other": "{{count}} channels failed",
"markRead": "Mark as read",
"markUnread": "Mark as unread",
"markAllRead": "Mark all as read",
"delete": "Delete",
"clearRead": "Delete read alerts",
"cleared_one": "Deleted {{count}} alert",
"cleared_other": "Deleted {{count}} alerts",
"filterAll": "All",
"filterUnread": "Unread",
"anySeverity": "Any severity",
"anySource": "Any source",
"openAll": "Open in a tab",
"empty": "No alerts yet.",
"emptyUnread": "You are all caught up.",
"loadMore": "Load more"
},
"channels": {
"intro": "Channels are where alerts can go outside Termix. Delivery rules pick which alerts use them.",
"add": "Add channel",
"edit": "Edit channel",
"test": "Send a test",
"testSent": "Test alert sent",
"testFailed": "Test failed: {{error}}",
"deleteConfirm": "Delete the channel \"{{name}}\"?",
"deleted": "Channel deleted",
"empty": "No channels yet.",
"enabled": "Enabled",
"needsSave": "This channel needs to be opened and saved again before it can send.",
"name": "Name",
"nameRequired": "Name is required",
"type": "Type",
"types": {
"webhook": "Webhook",
"ntfy": "ntfy",
"discord": "Discord",
"email": "Email"
},
"emailUnavailable": "Email needs an SMTP server, which an admin sets up in the Alerts settings.",
"webhookUrl": "URL",
"webhookHint": "Gets a JSON body for every alert.",
"method": "Method",
"headers": "Headers",
"headersHint": "One per line, like Name: value.",
"ntfyServer": "Server URL",
"ntfyTopic": "Topic",
"ntfyToken": "Access token (optional)",
"discordUrl": "Discord webhook URL",
"discordHint": "From the channel's settings in Discord, under Integrations.",
"discordUsername": "Display name (optional)",
"discordAvatar": "Avatar URL (optional)",
"emailTo": "Send to",
"emailHint": "Separate more than one address with commas.",
"privateNetwork": "Allow a private network address",
"privateNetworkHint": "Only works for hosts an admin allows in the private endpoint list."
},
"rules": {
"intro": "A delivery rule sends matching alerts to your channels. Alerts always stay in the inbox too.",
"add": "Add rule",
"edit": "Edit rule",
"empty": "No rules yet, so alerts only show up in the inbox.",
"needChannel": "Add a channel first.",
"enabled": "Enabled",
"name": "Name",
"nameRequired": "Name is required",
"match": "Alerts to send",
"matchHint": "* for everything, automations.* for everything from automations, or one exact kind.",
"minSeverity": "At least",
"channels": "Send to",
"channelRequired": "Pick at least one channel",
"deleteConfirm": "Delete the rule \"{{name}}\"?",
"everything": "Everything",
"summary": "{{match}}, {{severity}} or higher, to {{channels}}"
},
"actions": {
"cancel": "Cancel",
"save": "Save",
"saving": "Saving...",
"delete": "Delete"
}
}
+148
View File
@@ -0,0 +1,148 @@
{
"id": "alerts",
"name": "Alerts",
"version": "1.0.0",
"description": "One inbox for every alert, with delivery to webhooks, ntfy, Discord and email.",
"author": {
"name": "Termix"
},
"license": "MIT",
"repository": "https://github.com/Termix-SSH/Termix",
"category": "Monitoring",
"icon": "Bell",
"engine": {
"termix": ">=2.9.0",
"api": "1"
},
"capabilities": [
"db:own",
"events:core",
"network:outbound",
"network:serve",
"notify:hub",
"notify:send",
"secrets:own",
"settings:read-core",
"ui:surface"
],
"contributes": {
"tabs": [
{
"id": "alerts",
"titleKey": "nav.alerts",
"icon": "Bell",
"openFrom": ["rail", "palette"]
}
],
"permissions": [
{
"name": "use",
"titleKey": "permissions.use.title",
"descriptionKey": "permissions.use.description",
"defaultRoles": ["admin", "user"]
}
],
"settings": {
"admin": [
{
"key": "announcements",
"type": "boolean",
"group": "settings.group.general",
"labelKey": "settings.admin.announcements.label",
"descriptionKey": "settings.admin.announcements.description",
"default": true
},
{
"key": "retentionDays",
"type": "number",
"group": "settings.group.general",
"labelKey": "settings.admin.retentionDays.label",
"descriptionKey": "settings.admin.retentionDays.description",
"default": 90,
"min": 1,
"max": 3650
},
{
"key": "smtpHost",
"type": "string",
"group": "settings.group.email",
"labelKey": "settings.admin.smtpHost.label",
"descriptionKey": "settings.admin.smtpHost.description",
"placeholderKey": "settings.admin.smtpHost.placeholder",
"default": ""
},
{
"key": "smtpPort",
"type": "number",
"group": "settings.group.email",
"labelKey": "settings.admin.smtpPort.label",
"descriptionKey": "settings.admin.smtpPort.description",
"default": 587,
"min": 1,
"max": 65535
},
{
"key": "smtpSecure",
"type": "boolean",
"group": "settings.group.email",
"labelKey": "settings.admin.smtpSecure.label",
"descriptionKey": "settings.admin.smtpSecure.description",
"default": false
},
{
"key": "smtpUser",
"type": "string",
"group": "settings.group.email",
"labelKey": "settings.admin.smtpUser.label",
"descriptionKey": "settings.admin.smtpUser.description",
"default": ""
},
{
"key": "smtpPassword",
"type": "secret",
"group": "settings.group.email",
"labelKey": "settings.admin.smtpPassword.label",
"descriptionKey": "settings.admin.smtpPassword.description"
},
{
"key": "smtpFrom",
"type": "string",
"group": "settings.group.email",
"labelKey": "settings.admin.smtpFrom.label",
"descriptionKey": "settings.admin.smtpFrom.description",
"placeholderKey": "settings.admin.smtpFrom.placeholder",
"default": ""
}
],
"user": [
{
"key": "popups",
"type": "select",
"labelKey": "settings.user.popups.label",
"descriptionKey": "settings.user.popups.description",
"default": "warning",
"options": [
{
"value": "all",
"labelKey": "settings.user.popups.all"
},
{
"value": "warning",
"labelKey": "settings.user.popups.warning"
},
{
"value": "critical",
"labelKey": "settings.user.popups.critical"
},
{
"value": "off",
"labelKey": "settings.user.popups.off"
}
]
}
]
}
},
"locales": "locales",
"platforms": ["linux", "win32", "darwin"]
}
@@ -0,0 +1,54 @@
-- alerts 0001: create_alerts
-- Generated by termix-plugin migrations. Review before committing.
CREATE TABLE IF NOT EXISTS `p_alerts_channels` (
`id` int AUTO_INCREMENT PRIMARY KEY,
`user_id` varchar(255) NOT NULL,
`name` text NOT NULL,
`type` varchar(32) NOT NULL,
`config` text,
`enabled` boolean NOT NULL DEFAULT true,
`created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
`updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
);
CREATE INDEX `idx_alerts_channels_user` ON `p_alerts_channels` (`user_id`);
CREATE TABLE IF NOT EXISTS `p_alerts_items` (
`id` int AUTO_INCREMENT PRIMARY KEY,
`user_id` varchar(255) NOT NULL,
`source` varchar(64) NOT NULL,
`category` varchar(128) NOT NULL,
`severity` varchar(16) NOT NULL,
`title` text NOT NULL,
`body` text,
`link` text,
`context` text,
`dedupe_key` varchar(255),
`deliveries` text,
`read_at` varchar(255),
`created_at` varchar(255) NOT NULL DEFAULT (CURRENT_TIMESTAMP),
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
);
CREATE INDEX `idx_alerts_items_user` ON `p_alerts_items` (`user_id`, `created_at`);
CREATE INDEX `idx_alerts_items_unread` ON `p_alerts_items` (`user_id`, `read_at`);
CREATE INDEX `idx_alerts_items_dedupe` ON `p_alerts_items` (`user_id`, `dedupe_key`);
CREATE TABLE IF NOT EXISTS `p_alerts_rules` (
`id` int AUTO_INCREMENT PRIMARY KEY,
`user_id` varchar(255) NOT NULL,
`name` text NOT NULL,
`pattern` varchar(128) NOT NULL DEFAULT '*',
`min_severity` varchar(16) NOT NULL DEFAULT 'warning',
`channel_ids` text NOT NULL,
`enabled` boolean NOT NULL DEFAULT true,
`created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
);
CREATE INDEX `idx_alerts_rules_user` ON `p_alerts_rules` (`user_id`);
CREATE TABLE IF NOT EXISTS `dismissed_alerts` (
`id` int AUTO_INCREMENT PRIMARY KEY,
`user_id` varchar(255) NOT NULL,
`alert_id` text NOT NULL,
`dismissed_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
);
RENAME TABLE `dismissed_alerts` TO `p_alerts_dismissed`;
@@ -0,0 +1,55 @@
-- alerts 0001: create_alerts
-- Generated by termix-plugin migrations. Review before committing.
CREATE TABLE IF NOT EXISTS "p_alerts_channels" (
"id" serial PRIMARY KEY,
"user_id" varchar(255) NOT NULL,
"name" text NOT NULL,
"type" varchar(32) NOT NULL,
"config" text,
"enabled" boolean NOT NULL DEFAULT true,
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS "idx_alerts_channels_user" ON "p_alerts_channels" ("user_id");
CREATE TABLE IF NOT EXISTS "p_alerts_items" (
"id" serial PRIMARY KEY,
"user_id" varchar(255) NOT NULL,
"source" varchar(64) NOT NULL,
"category" varchar(128) NOT NULL,
"severity" varchar(16) NOT NULL,
"title" text NOT NULL,
"body" text,
"link" text,
"context" text,
"dedupe_key" varchar(255),
"deliveries" text,
"read_at" varchar(255),
"created_at" varchar(255) NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS "idx_alerts_items_user" ON "p_alerts_items" ("user_id", "created_at");
CREATE INDEX IF NOT EXISTS "idx_alerts_items_unread" ON "p_alerts_items" ("user_id", "read_at");
CREATE INDEX IF NOT EXISTS "idx_alerts_items_dedupe" ON "p_alerts_items" ("user_id", "dedupe_key");
CREATE TABLE IF NOT EXISTS "p_alerts_rules" (
"id" serial PRIMARY KEY,
"user_id" varchar(255) NOT NULL,
"name" text NOT NULL,
"pattern" varchar(128) NOT NULL DEFAULT '*',
"min_severity" varchar(16) NOT NULL DEFAULT 'warning',
"channel_ids" text NOT NULL,
"enabled" boolean NOT NULL DEFAULT true,
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS "idx_alerts_rules_user" ON "p_alerts_rules" ("user_id");
CREATE TABLE IF NOT EXISTS "dismissed_alerts" (
"id" serial PRIMARY KEY,
"user_id" varchar(255) NOT NULL,
"alert_id" text NOT NULL,
"dismissed_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS "idx_dismissed_alerts_user_id" ON "dismissed_alerts" ("user_id");
ALTER TABLE "dismissed_alerts" RENAME TO "p_alerts_dismissed";
+203
View File
@@ -0,0 +1,203 @@
{
"version": 1,
"tables": {
"channels": {
"columns": {
"id": {
"type": "id",
"primaryKey": true,
"notNull": true
},
"userId": {
"type": "refUser",
"length": 255,
"notNull": true
},
"name": {
"type": "text",
"notNull": true
},
"type": {
"type": "varchar",
"length": 32,
"notNull": true
},
"config": {
"type": "encryptedText"
},
"enabled": {
"type": "boolean",
"notNull": true,
"defaultValue": true
},
"createdAt": {
"type": "timestamp",
"notNull": true,
"defaultNow": true
},
"updatedAt": {
"type": "timestamp",
"notNull": true,
"defaultNow": true
}
},
"indexes": [
{
"name": "idx_alerts_channels_user",
"columns": ["userId"]
}
]
},
"items": {
"columns": {
"id": {
"type": "id",
"primaryKey": true,
"notNull": true
},
"userId": {
"type": "refUser",
"length": 255,
"notNull": true
},
"source": {
"type": "varchar",
"length": 64,
"notNull": true
},
"category": {
"type": "varchar",
"length": 128,
"notNull": true
},
"severity": {
"type": "varchar",
"length": 16,
"notNull": true
},
"title": {
"type": "text",
"notNull": true
},
"body": {
"type": "text"
},
"link": {
"type": "text"
},
"context": {
"type": "text"
},
"dedupeKey": {
"type": "varchar",
"length": 255
},
"deliveries": {
"type": "text"
},
"readAt": {
"type": "timestamp"
},
"createdAt": {
"type": "timestamp",
"notNull": true,
"defaultNow": true
}
},
"indexes": [
{
"name": "idx_alerts_items_user",
"columns": ["userId", "createdAt"]
},
{
"name": "idx_alerts_items_unread",
"columns": ["userId", "readAt"]
},
{
"name": "idx_alerts_items_dedupe",
"columns": ["userId", "dedupeKey"]
}
]
},
"rules": {
"columns": {
"id": {
"type": "id",
"primaryKey": true,
"notNull": true
},
"userId": {
"type": "refUser",
"length": 255,
"notNull": true
},
"name": {
"type": "text",
"notNull": true
},
"pattern": {
"type": "varchar",
"length": 128,
"notNull": true,
"defaultValue": "*"
},
"minSeverity": {
"type": "varchar",
"length": 16,
"notNull": true,
"defaultValue": "warning"
},
"channelIds": {
"type": "text",
"notNull": true
},
"enabled": {
"type": "boolean",
"notNull": true,
"defaultValue": true
},
"createdAt": {
"type": "timestamp",
"notNull": true,
"defaultNow": true
}
},
"indexes": [
{
"name": "idx_alerts_rules_user",
"columns": ["userId"]
}
]
},
"dismissed": {
"columns": {
"id": {
"type": "id",
"primaryKey": true,
"notNull": true
},
"userId": {
"type": "refUser",
"length": 255,
"notNull": true
},
"alertId": {
"type": "text",
"notNull": true
},
"dismissedAt": {
"type": "timestamp",
"notNull": true,
"defaultNow": true
}
},
"indexes": [
{
"name": "idx_dismissed_alerts_user_id",
"columns": ["userId"]
}
],
"adopts": "dismissed_alerts"
}
}
}
@@ -0,0 +1,55 @@
-- alerts 0001: create_alerts
-- Generated by termix-plugin migrations. Review before committing.
CREATE TABLE IF NOT EXISTS "p_alerts_channels" (
"id" integer PRIMARY KEY AUTOINCREMENT,
"user_id" text NOT NULL,
"name" text NOT NULL,
"type" text NOT NULL,
"config" text,
"enabled" integer NOT NULL DEFAULT 1,
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS "idx_alerts_channels_user" ON "p_alerts_channels" ("user_id");
CREATE TABLE IF NOT EXISTS "p_alerts_items" (
"id" integer PRIMARY KEY AUTOINCREMENT,
"user_id" text NOT NULL,
"source" text NOT NULL,
"category" text NOT NULL,
"severity" text NOT NULL,
"title" text NOT NULL,
"body" text,
"link" text,
"context" text,
"dedupe_key" text,
"deliveries" text,
"read_at" text,
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS "idx_alerts_items_user" ON "p_alerts_items" ("user_id", "created_at");
CREATE INDEX IF NOT EXISTS "idx_alerts_items_unread" ON "p_alerts_items" ("user_id", "read_at");
CREATE INDEX IF NOT EXISTS "idx_alerts_items_dedupe" ON "p_alerts_items" ("user_id", "dedupe_key");
CREATE TABLE IF NOT EXISTS "p_alerts_rules" (
"id" integer PRIMARY KEY AUTOINCREMENT,
"user_id" text NOT NULL,
"name" text NOT NULL,
"pattern" text NOT NULL DEFAULT '*',
"min_severity" text NOT NULL DEFAULT 'warning',
"channel_ids" text NOT NULL,
"enabled" integer NOT NULL DEFAULT 1,
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS "idx_alerts_rules_user" ON "p_alerts_rules" ("user_id");
CREATE TABLE IF NOT EXISTS "dismissed_alerts" (
"id" integer PRIMARY KEY AUTOINCREMENT,
"user_id" text NOT NULL,
"alert_id" text NOT NULL,
"dismissed_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS "idx_dismissed_alerts_user_id" ON "dismissed_alerts" ("user_id");
ALTER TABLE "dismissed_alerts" RENAME TO "p_alerts_dismissed";
+19
View File
@@ -0,0 +1,19 @@
{
"name": "@termix-plugin/alerts",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json",
"validate": "termix-plugin validate"
},
"dependencies": {
"nodemailer": "^10.0.10"
},
"devDependencies": {
"@termix/plugin-sdk": "*",
"@types/nodemailer": "^8.0.2"
}
}
+164
View File
@@ -0,0 +1,164 @@
import type { PluginContext } from "@termix/plugin-sdk/backend";
import {
ANNOUNCEMENT_SOURCE,
type AlertItem,
type Severity,
} from "../types.js";
import type { AlertsRepository } from "./repository.js";
export const FEED_URL =
"https://raw.githubusercontent.com/Termix-SSH/Docs/main/termix-alerts.json";
export const REFRESH_MS = 30 * 60 * 1000;
export const ANNOUNCEMENT_CATEGORY = "termix.announcement";
/** One entry of termix-alerts.json, as the Docs repo publishes it. */
export interface Announcement {
id: string;
title: string;
message: string;
expiresAt?: string;
priority?: "low" | "medium" | "high" | "critical";
type?: "info" | "warning" | "error" | "success";
actionUrl?: string;
actionText?: string;
}
export function announcementSeverity(entry: Announcement): Severity {
if (entry.priority === "critical" || entry.type === "error") {
return "critical";
}
if (entry.type === "warning" || entry.priority === "high") return "warning";
if (entry.type === "success") return "success";
return "info";
}
export function parseFeed(raw: unknown, now = Date.now()): Announcement[] {
if (!Array.isArray(raw)) return [];
return raw.filter((entry): entry is Announcement => {
if (!entry || typeof entry !== "object") return false;
const candidate = entry as Record<string, unknown>;
if (typeof candidate.id !== "string" || !candidate.id) return false;
if (typeof candidate.title !== "string") return false;
if (typeof candidate.expiresAt === "string") {
const expires = Date.parse(candidate.expiresAt);
if (Number.isFinite(expires) && expires <= now) return false;
}
return true;
});
}
const dedupeKey = (id: string) => `announcement:${id}`;
/**
* Termix announcements from the Docs repo, brought into each user's inbox
* the first time they look after one is published. A user who deletes one
* never gets it back.
*/
export function createAnnouncements(
ctx: PluginContext,
repository: AlertsRepository,
) {
let feed: Announcement[] = [];
let fetchedAt = 0;
let refreshing: Promise<void> | null = null;
const syncing = new Map<string, Promise<AlertItem[]>>();
async function enabled(): Promise<boolean> {
return (await ctx.settings.get<boolean>("announcements")) !== false;
}
async function refresh(): Promise<void> {
if (refreshing) return refreshing;
refreshing = (async () => {
try {
const response = await ctx.fetch(FEED_URL, {
headers: {
Accept: "application/json",
"User-Agent": "TermixAlertChecker/1.0",
},
timeoutMs: 15_000,
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
feed = parseFeed(await response.json());
} catch (error) {
ctx.log.warn(
`Could not fetch Termix announcements: ${error instanceof Error ? error.message : String(error)}`,
);
} finally {
fetchedAt = Date.now();
refreshing = null;
}
})();
return refreshing;
}
async function current(): Promise<Announcement[]> {
if (!(await enabled())) return [];
if (Date.now() - fetchedAt > REFRESH_MS) await refresh();
return parseFeed(feed);
}
async function syncUser(userId: string): Promise<AlertItem[]> {
const added: AlertItem[] = [];
const active = await current();
if (active.length === 0) return added;
const dismissed = await repository.dismissedIds(userId);
for (const entry of active) {
if (dismissed.has(entry.id)) continue;
if (await repository.hasItemWithKey(userId, dedupeKey(entry.id))) {
continue;
}
added.push(
await repository.insertItem({
userId,
source: ANNOUNCEMENT_SOURCE,
category: ANNOUNCEMENT_CATEGORY,
severity: announcementSeverity(entry),
title: entry.title.slice(0, 200),
body: typeof entry.message === "string" ? entry.message : null,
link:
typeof entry.actionUrl === "string" &&
/^https?:\/\//i.test(entry.actionUrl)
? { url: entry.actionUrl }
: null,
context: {
announcementId: entry.id,
...(entry.actionText ? { actionText: entry.actionText } : {}),
},
dedupeKey: dedupeKey(entry.id),
}),
);
}
return added;
}
return {
refresh,
/** Adds any announcement the user has not seen yet. Returns what was added. */
sync(userId: string): Promise<AlertItem[]> {
const running = syncing.get(userId);
if (running) return running;
const run = syncUser(userId)
.catch((error) => {
ctx.log.warn(
`Could not add announcements to the inbox: ${error instanceof Error ? error.message : String(error)}`,
);
return [] as AlertItem[];
})
.finally(() => syncing.delete(userId));
syncing.set(userId, run);
return run;
},
/** Remembers that the user removed an announcement from their inbox. */
async forget(userId: string, item: AlertItem): Promise<void> {
const id = item.context?.announcementId;
if (item.source === ANNOUNCEMENT_SOURCE && typeof id === "string") {
await repository.dismiss(userId, id);
}
},
};
}
export type Announcements = ReturnType<typeof createAnnouncements>;
+193
View File
@@ -0,0 +1,193 @@
import type {
PluginNotification,
PluginNotifyHub,
PluginNotifyResult,
} from "@termix/plugin-sdk/backend";
import {
isSeverity,
matchesCategory,
severityRank,
type AlertLink,
type DeliveryResult,
} from "../types.js";
import type { AlertsRepository, DeliverableChannel } from "./repository.js";
import {
sendToChannel,
type OutgoingAlert,
type SenderDeps,
} from "./senders.js";
import type { AlertStream } from "./stream.js";
const MAX_TITLE = 200;
const MAX_BODY = 4000;
const MAX_CONTEXT = 8000;
const TAB_ID = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
export interface HubDeps {
repository: AlertsRepository;
stream: AlertStream;
senders: SenderDeps;
}
function clip(value: unknown, max: number): string {
return typeof value === "string" ? value.trim().slice(0, max) : "";
}
export function cleanLink(link: unknown): AlertLink | null {
if (!link || typeof link !== "object") return null;
const { tab, url } = link as Record<string, unknown>;
const clean: AlertLink = {};
if (typeof tab === "string" && TAB_ID.test(tab)) clean.tab = tab;
if (typeof url === "string" && /^https?:\/\/\S+$/i.test(url.trim())) {
clean.url = url.trim().slice(0, 2000);
}
return clean.tab || clean.url ? clean : null;
}
function cleanContext(context: unknown): Record<string, unknown> | null {
if (!context || typeof context !== "object" || Array.isArray(context)) {
return null;
}
try {
const text = JSON.stringify(context);
return text.length <= MAX_CONTEXT
? (JSON.parse(text) as Record<string, unknown>)
: null;
} catch {
return null;
}
}
/** Sends to each channel and reports how each one went. */
export async function deliverToChannels(
targets: DeliverableChannel[],
alert: OutgoingAlert,
senders: SenderDeps,
): Promise<DeliveryResult[]> {
const results: DeliveryResult[] = [];
for (const channel of targets) {
if (!channel.config) {
results.push({
channelId: channel.id,
name: channel.name,
ok: false,
error: "Channel needs to be saved again before it can send",
});
continue;
}
try {
await sendToChannel(
{ type: channel.type, config: channel.config },
alert,
senders,
);
results.push({ channelId: channel.id, name: channel.name, ok: true });
} catch (error) {
results.push({
channelId: channel.id,
name: channel.name,
ok: false,
error: error instanceof Error ? error.message : String(error),
});
}
}
return results;
}
export function createHub(deps: HubDeps): PluginNotifyHub {
const { repository, stream, senders } = deps;
async function deliverOne(
userId: string,
source: string,
notification: PluginNotification,
result: PluginNotifyResult,
): Promise<void> {
const severity = isSeverity(notification.severity)
? notification.severity
: "warning";
const category = clip(notification.category, 128) || source;
const dedupeKey = clip(notification.dedupeKey, 255) || null;
if (dedupeKey && (await repository.hasUnreadWithKey(userId, dedupeKey))) {
return;
}
const title = clip(notification.title, MAX_TITLE) || category;
const body = clip(notification.body, MAX_BODY);
const link = cleanLink(notification.link);
const context = cleanContext(notification.context);
const item = await repository.insertItem({
userId,
source,
category,
severity,
title,
body: body || null,
link,
context,
dedupeKey,
});
result.recipients++;
stream.publish(userId, "item", item);
stream.publish(userId, "unread", {
count: await repository.unreadCount(userId),
});
const wanted = new Set<number>(
(notification.channelIds ?? []).filter(Number.isInteger),
);
for (const rule of await repository.listRules(userId)) {
if (!rule.enabled) continue;
if (severityRank(severity) < severityRank(rule.minSeverity)) continue;
if (!matchesCategory(rule.match, category)) continue;
for (const id of rule.channelIds) wanted.add(id);
}
if (wanted.size === 0) return;
const targets = (await repository.deliverableChannels(userId)).filter(
(channel) => channel.enabled && wanted.has(channel.id),
);
if (targets.length === 0) return;
const deliveries = await deliverToChannels(
targets,
{ title, body, severity, category, source, link, context },
senders,
);
for (const delivery of deliveries) {
if (delivery.ok) result.delivered++;
else {
result.failures.push({
channelId: delivery.channelId,
name: delivery.name,
error: delivery.error ?? "",
});
}
}
await repository.setDeliveries(item.id, deliveries);
stream.publish(userId, "item", { ...item, deliveries });
}
return {
async deliver({ source, recipients, notification }) {
const result: PluginNotifyResult = {
recipients: 0,
delivered: 0,
failures: [],
};
for (const userId of new Set(recipients)) {
await deliverOne(userId, source, notification, result);
}
return result;
},
async channels(userId) {
return (await repository.listChannels(userId)).map((channel) => ({
id: channel.id,
name: channel.name,
type: channel.type,
enabled: channel.enabled,
}));
},
};
}
+84
View File
@@ -0,0 +1,84 @@
import type { Router } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { createAnnouncements, REFRESH_MS } from "./announcements.js";
import { createHub } from "./hub.js";
import { readSmtp, sendMail } from "./mail.js";
import { createAlertsRepository } from "./repository.js";
import { registerRoutes } from "./routes.js";
import type { SenderDeps } from "./senders.js";
import { createAlertStream } from "./stream.js";
/** The admin allowlist of private hosts a channel may reach. */
const PRIVATE_ALLOWLIST_KEY = "notification_private_endpoint_allowlist";
const PRUNE_MS = 6 * 60 * 60 * 1000;
const DAY_MS = 24 * 60 * 60 * 1000;
export function parseAllowlist(raw: unknown): string[] {
if (typeof raw !== "string" || !raw) return [];
try {
const value = JSON.parse(raw);
return Array.isArray(value)
? value
.filter((entry): entry is string => typeof entry === "string")
.map((entry) => entry.trim().toLowerCase())
.filter(Boolean)
: [];
} catch {
return [];
}
}
export async function activate(ctx: PluginContext) {
const repository = await createAlertsRepository(ctx.db, ctx.secrets);
const stream = createAlertStream();
ctx.disposables.add(() => stream.closeAll());
const senders: SenderDeps = {
fetch: ctx.fetch,
privateAllowlist: async () =>
parseAllowlist(await ctx.settings.readCore(PRIVATE_ALLOWLIST_KEY)),
smtp: () => readSmtp(ctx),
sendMail,
};
const announcements = createAnnouncements(ctx, repository);
ctx.notify.serve(createHub({ repository, stream, senders }));
registerRoutes(ctx.http.router<Router>(), {
ctx,
repository,
stream,
senders,
announcements,
});
ctx.events.on("user.data_wiped", async (payload) => {
const userId = (payload as { userId?: string } | undefined)?.userId;
if (userId) await repository.wipeUser(userId);
});
const prune = async () => {
const days = Number(await ctx.settings.get("retentionDays"));
const keep = Number.isFinite(days) && days >= 1 ? days : 90;
await repository.pruneItems(
new Date(Date.now() - keep * DAY_MS).toISOString(),
);
};
ctx.schedule.after(60_000, prune);
ctx.schedule.every(PRUNE_MS, prune);
// Users with the app open get a new announcement without reloading.
ctx.schedule.every(REFRESH_MS, async () => {
await announcements.refresh();
for (const userId of stream.users()) {
const added = await announcements.sync(userId);
if (added.length === 0) continue;
for (const item of added) stream.publish(userId, "item", item);
stream.publish(userId, "unread", {
count: await repository.unreadCount(userId),
});
}
});
}
export async function deactivate() {}
+46
View File
@@ -0,0 +1,46 @@
import nodemailer from "nodemailer";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import type { SendMail, SmtpSettings } from "./senders.js";
const str = (value: unknown) => (typeof value === "string" ? value.trim() : "");
/** The admin's SMTP server, or null while it is not set up. */
export async function readSmtp(
ctx: PluginContext,
): Promise<SmtpSettings | null> {
const all = await ctx.settings.getAll("admin");
const host = str(all.smtpHost);
const from = str(all.smtpFrom);
if (!host || !from) return null;
const port = Number(all.smtpPort);
return {
host,
port: Number.isInteger(port) && port > 0 ? port : 587,
secure: all.smtpSecure === true,
user: str(all.smtpUser),
password: typeof all.smtpPassword === "string" ? all.smtpPassword : "",
from,
};
}
export const sendMail: SendMail = async (smtp, message) => {
const transport = nodemailer.createTransport({
host: smtp.host,
port: smtp.port,
secure: smtp.secure,
auth: smtp.user ? { user: smtp.user, pass: smtp.password } : undefined,
connectionTimeout: 15_000,
greetingTimeout: 15_000,
socketTimeout: 30_000,
});
try {
await transport.sendMail({
from: smtp.from,
to: message.to.join(", "),
subject: message.subject,
text: message.text,
});
} finally {
transport.close();
}
};
+552
View File
@@ -0,0 +1,552 @@
import {
and,
desc,
eq,
inArray,
isNotNull,
isNull,
lt,
sql,
} from "drizzle-orm";
import type { PluginDatabase, PluginSecrets } from "@termix/plugin-sdk/backend";
import {
CHANNEL_TYPES,
isSeverity,
type AlertItem,
type AlertLink,
type AlertRule,
type ChannelDetail,
type ChannelSummary,
type ChannelType,
type DeliveryResult,
type Severity,
} from "../types.js";
import {
channels as channelsDef,
dismissed as dismissedDef,
items as itemsDef,
rules as rulesDef,
} from "./tables.js";
/* eslint-disable @typescript-eslint/no-explicit-any */
// Tables come from ctx.db.define, which the SDK hands back untyped, and the
// drizzle handle is the server's own. Typed at this module's edge instead.
type Table = any;
type Drizzle = any;
type Row = Record<string, any>;
/* eslint-enable @typescript-eslint/no-explicit-any */
export interface NewItem {
userId: string;
source: string;
category: string;
severity: Severity;
title: string;
body?: string | null;
link?: AlertLink | null;
context?: Record<string, unknown> | null;
dedupeKey?: string | null;
createdAt?: string;
}
export interface ItemFilter {
unread?: boolean;
source?: string;
severity?: Severity;
limit?: number;
beforeId?: number;
}
/** A channel with its config opened, for delivery. */
export interface DeliverableChannel {
id: number;
name: string;
type: ChannelType;
enabled: boolean;
config: Record<string, unknown> | null;
}
export type AlertsRepository = Awaited<
ReturnType<typeof createAlertsRepository>
>;
type Sealer = Pick<PluginSecrets, "seal" | "unseal">;
function parseJson<T>(value: unknown): T | null {
if (typeof value !== "string" || !value) return null;
try {
return JSON.parse(value) as T;
} catch {
return null;
}
}
function asChannelType(value: unknown): ChannelType {
return (CHANNEL_TYPES as readonly string[]).includes(String(value))
? (value as ChannelType)
: "webhook";
}
function toItem(row: Row): AlertItem {
return {
id: Number(row.id),
source: row.source,
category: row.category,
severity: isSeverity(row.severity) ? row.severity : "info",
title: row.title,
body: row.body ?? null,
link: parseJson<AlertLink>(row.link),
context: parseJson<Record<string, unknown>>(row.context),
deliveries: parseJson<DeliveryResult[]>(row.deliveries),
readAt: row.readAt ?? null,
createdAt: String(row.createdAt),
};
}
function toRule(row: Row): AlertRule {
const channelIds = parseJson<unknown[]>(row.channelIds) ?? [];
return {
id: Number(row.id),
name: row.name,
match: row.pattern,
minSeverity: isSeverity(row.minSeverity) ? row.minSeverity : "warning",
channelIds: channelIds.map(Number).filter(Number.isInteger),
enabled: !!row.enabled,
};
}
export async function createAlertsRepository(
db: PluginDatabase,
secrets: Sealer,
) {
const channels: Table = await db.define(channelsDef);
const items: Table = await db.define(itemsDef);
const rules: Table = await db.define(rulesDef);
const dismissed: Table = await db.define(dismissedDef);
const client = () => db.client<Drizzle>();
/** Inserts one row and returns its id. MySQL has no RETURNING. */
async function insertId(
table: Table,
values: Record<string, unknown>,
): Promise<number> {
const drizzle = await client();
if (db.dialect === "mysql") {
const result = await drizzle.insert(table).values(values);
const header = Array.isArray(result) ? result[0] : result;
return Number(header?.insertId);
}
const rows = await drizzle
.insert(table)
.values(values)
.returning({ id: table.id });
return Number(rows[0].id);
}
/** Null when the config was never sealed here, i.e. still waiting on its upgrade. */
async function openConfig(
stored: string | null,
): Promise<Record<string, unknown> | null> {
if (!stored) return null;
const plain = await secrets.unseal(stored);
return plain ? parseJson<Record<string, unknown>>(plain) : null;
}
async function summarize(row: Row): Promise<ChannelSummary> {
return {
id: Number(row.id),
name: row.name,
type: asChannelType(row.type),
enabled: !!row.enabled,
createdAt: String(row.createdAt),
usable: (await openConfig(row.config)) !== null,
};
}
async function channelRows(userId: string): Promise<Row[]> {
const drizzle = await client();
return drizzle
.select()
.from(channels)
.where(eq(channels.userId, userId))
.orderBy(channels.id);
}
async function findChannelRow(
userId: string,
id: number,
): Promise<Row | null> {
const drizzle = await client();
const [row] = await drizzle
.select()
.from(channels)
.where(and(eq(channels.id, id), eq(channels.userId, userId)));
return row ?? null;
}
const repository = {
async listChannels(userId: string): Promise<ChannelSummary[]> {
return Promise.all((await channelRows(userId)).map(summarize));
},
async getChannel(
userId: string,
id: number,
): Promise<ChannelDetail | null> {
const row = await findChannelRow(userId, id);
if (!row) return null;
const config = await openConfig(row.config);
return {
...(await summarize(row)),
config: config ?? {},
};
},
async deliverableChannels(userId: string): Promise<DeliverableChannel[]> {
const rows = await channelRows(userId);
return Promise.all(
rows.map(async (row) => ({
id: Number(row.id),
name: row.name,
type: asChannelType(row.type),
enabled: !!row.enabled,
config: await openConfig(row.config),
})),
);
},
async createChannel(
userId: string,
input: {
name: string;
type: ChannelType;
config: Record<string, unknown>;
enabled?: boolean;
},
): Promise<number> {
const now = new Date().toISOString();
const id = await insertId(channels, {
userId,
name: input.name,
type: input.type,
config: await secrets.seal(JSON.stringify(input.config)),
enabled: input.enabled !== false,
createdAt: now,
updatedAt: now,
});
await db.persist();
return id;
},
async updateChannel(
userId: string,
id: number,
patch: {
name?: string;
type?: ChannelType;
config?: Record<string, unknown>;
enabled?: boolean;
},
): Promise<boolean> {
if (!(await findChannelRow(userId, id))) return false;
const values: Record<string, unknown> = {
updatedAt: new Date().toISOString(),
};
if (patch.name !== undefined) values.name = patch.name;
if (patch.type !== undefined) values.type = patch.type;
if (patch.enabled !== undefined) values.enabled = patch.enabled;
if (patch.config !== undefined) {
values.config = await secrets.seal(JSON.stringify(patch.config));
}
const drizzle = await client();
await drizzle
.update(channels)
.set(values)
.where(and(eq(channels.id, id), eq(channels.userId, userId)));
await db.persist();
return true;
},
async deleteChannel(userId: string, id: number): Promise<boolean> {
if (!(await findChannelRow(userId, id))) return false;
const drizzle = await client();
await drizzle
.delete(channels)
.where(and(eq(channels.id, id), eq(channels.userId, userId)));
// Rules keep working with the channels they still name.
for (const rule of await repository.listRules(userId)) {
if (!rule.channelIds.includes(id)) continue;
await drizzle
.update(rules)
.set({
channelIds: JSON.stringify(
rule.channelIds.filter((channelId) => channelId !== id),
),
})
.where(eq(rules.id, rule.id));
}
await db.persist();
return true;
},
async insertItem(item: NewItem): Promise<AlertItem> {
const createdAt = item.createdAt ?? new Date().toISOString();
const id = await insertId(items, {
userId: item.userId,
source: item.source,
category: item.category,
severity: item.severity,
title: item.title,
body: item.body ?? null,
link: item.link ? JSON.stringify(item.link) : null,
context: item.context ? JSON.stringify(item.context) : null,
dedupeKey: item.dedupeKey ?? null,
createdAt,
});
await db.persist();
return toItem({
id,
...item,
link: item.link ? JSON.stringify(item.link) : null,
context: item.context ? JSON.stringify(item.context) : null,
deliveries: null,
readAt: null,
createdAt,
});
},
async setDeliveries(id: number, results: DeliveryResult[]): Promise<void> {
const drizzle = await client();
await drizzle
.update(items)
.set({ deliveries: JSON.stringify(results) })
.where(eq(items.id, id));
await db.persist();
},
async hasUnreadWithKey(userId: string, key: string): Promise<boolean> {
const drizzle = await client();
const rows: Row[] = await drizzle
.select({ id: items.id })
.from(items)
.where(
and(
eq(items.userId, userId),
eq(items.dedupeKey, key),
isNull(items.readAt),
),
)
.limit(1);
return rows.length > 0;
},
async hasItemWithKey(userId: string, key: string): Promise<boolean> {
const drizzle = await client();
const rows: Row[] = await drizzle
.select({ id: items.id })
.from(items)
.where(and(eq(items.userId, userId), eq(items.dedupeKey, key)))
.limit(1);
return rows.length > 0;
},
async listItems(
userId: string,
filter: ItemFilter = {},
): Promise<AlertItem[]> {
const conditions = [eq(items.userId, userId)];
if (filter.unread) conditions.push(isNull(items.readAt));
if (filter.source) conditions.push(eq(items.source, filter.source));
if (filter.severity) conditions.push(eq(items.severity, filter.severity));
if (filter.beforeId) conditions.push(lt(items.id, filter.beforeId));
const drizzle = await client();
const rows: Row[] = await drizzle
.select()
.from(items)
.where(and(...conditions))
.orderBy(desc(items.id))
.limit(Math.min(Math.max(filter.limit ?? 50, 1), 200));
return rows.map(toItem);
},
async getItem(userId: string, id: number): Promise<AlertItem | null> {
const drizzle = await client();
const [row] = await drizzle
.select()
.from(items)
.where(and(eq(items.id, id), eq(items.userId, userId)));
return row ? toItem(row) : null;
},
async unreadCount(userId: string): Promise<number> {
const drizzle = await client();
const [row] = await drizzle
.select({ count: sql<number>`count(*)` })
.from(items)
.where(and(eq(items.userId, userId), isNull(items.readAt)));
return Number(row?.count ?? 0);
},
async setRead(
userId: string,
ids: number[] | "all",
read: boolean,
): Promise<void> {
const conditions = [eq(items.userId, userId)];
if (ids !== "all") {
if (ids.length === 0) return;
conditions.push(inArray(items.id, ids));
}
conditions.push(read ? isNull(items.readAt) : isNotNull(items.readAt));
const drizzle = await client();
await drizzle
.update(items)
.set({ readAt: read ? new Date().toISOString() : null })
.where(and(...conditions));
await db.persist();
},
async deleteItem(userId: string, id: number): Promise<AlertItem | null> {
const item = await repository.getItem(userId, id);
if (!item) return null;
const drizzle = await client();
await drizzle
.delete(items)
.where(and(eq(items.id, id), eq(items.userId, userId)));
await db.persist();
return item;
},
/** Deletes the user's read alerts, or every alert. Returns what went. */
async clearItems(userId: string, onlyRead: boolean): Promise<AlertItem[]> {
const conditions = [eq(items.userId, userId)];
if (onlyRead) conditions.push(isNotNull(items.readAt));
const drizzle = await client();
const rows: Row[] = await drizzle
.select()
.from(items)
.where(and(...conditions));
if (rows.length === 0) return [];
await drizzle.delete(items).where(and(...conditions));
await db.persist();
return rows.map(toItem);
},
async pruneItems(olderThan: string): Promise<void> {
const drizzle = await client();
await drizzle.delete(items).where(lt(items.createdAt, olderThan));
await db.persist({ lazy: true });
},
/** Every source and category the user has alerts from, for the rule editor. */
async listCategories(
userId: string,
): Promise<Array<{ source: string; category: string }>> {
const drizzle = await client();
const rows: Row[] = await drizzle
.selectDistinct({ source: items.source, category: items.category })
.from(items)
.where(eq(items.userId, userId));
return rows
.map((row) => ({ source: row.source, category: row.category }))
.sort((a, b) => a.category.localeCompare(b.category));
},
async listRules(userId: string): Promise<AlertRule[]> {
const drizzle = await client();
const rows: Row[] = await drizzle
.select()
.from(rules)
.where(eq(rules.userId, userId))
.orderBy(rules.id);
return rows.map(toRule);
},
async createRule(
userId: string,
input: Omit<AlertRule, "id">,
): Promise<number> {
const id = await insertId(rules, {
userId,
name: input.name,
pattern: input.match,
minSeverity: input.minSeverity,
channelIds: JSON.stringify(input.channelIds),
enabled: input.enabled,
createdAt: new Date().toISOString(),
});
await db.persist();
return id;
},
async updateRule(
userId: string,
id: number,
input: Omit<AlertRule, "id">,
): Promise<boolean> {
const drizzle = await client();
const [row] = await drizzle
.select({ id: rules.id })
.from(rules)
.where(and(eq(rules.id, id), eq(rules.userId, userId)));
if (!row) return false;
await drizzle
.update(rules)
.set({
name: input.name,
pattern: input.match,
minSeverity: input.minSeverity,
channelIds: JSON.stringify(input.channelIds),
enabled: input.enabled,
})
.where(and(eq(rules.id, id), eq(rules.userId, userId)));
await db.persist();
return true;
},
async deleteRule(userId: string, id: number): Promise<boolean> {
const drizzle = await client();
const [row] = await drizzle
.select({ id: rules.id })
.from(rules)
.where(and(eq(rules.id, id), eq(rules.userId, userId)));
if (!row) return false;
await drizzle
.delete(rules)
.where(and(eq(rules.id, id), eq(rules.userId, userId)));
await db.persist();
return true;
},
async dismissedIds(userId: string): Promise<Set<string>> {
const drizzle = await client();
const rows: Row[] = await drizzle
.select({ alertId: dismissed.alertId })
.from(dismissed)
.where(eq(dismissed.userId, userId));
return new Set(rows.map((row) => String(row.alertId)));
},
async dismiss(userId: string, alertId: string): Promise<void> {
if ((await repository.dismissedIds(userId)).has(alertId)) return;
const drizzle = await client();
await drizzle.insert(dismissed).values({
userId,
alertId,
dismissedAt: new Date().toISOString(),
});
await db.persist();
},
/** A user's data was wiped: nothing of theirs stays. */
async wipeUser(userId: string): Promise<void> {
const drizzle = await client();
for (const table of [items, rules, channels, dismissed]) {
await drizzle.delete(table).where(eq(table.userId, userId));
}
await db.persist();
},
};
return repository;
}
+694
View File
@@ -0,0 +1,694 @@
import type { Request, Response, Router } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import {
CHANNEL_TYPES,
isSeverity,
type AlertRule,
type ChannelType,
} from "../types.js";
import type { Announcements } from "./announcements.js";
import { deliverToChannels } from "./hub.js";
import type { AlertsRepository } from "./repository.js";
import { validateChannelConfig, type SenderDeps } from "./senders.js";
import type { AlertStream } from "./stream.js";
interface RouteDeps {
ctx: PluginContext;
repository: AlertsRepository;
stream: AlertStream;
senders: SenderDeps;
announcements: Announcements;
}
function actor(ctx: PluginContext): string {
// Core's plugin router authenticates every request and runs it as that user.
return ctx.currentActor() as string;
}
function positiveInt(value: unknown): number | null {
const parsed = Number.parseInt(String(value), 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
}
function isChannelType(value: unknown): value is ChannelType {
return (CHANNEL_TYPES as readonly string[]).includes(String(value));
}
function idList(value: unknown): number[] {
return Array.isArray(value)
? value.map(positiveInt).filter((id): id is number => id !== null)
: [];
}
export function registerRoutes(router: Router, deps: RouteDeps): void {
const { ctx, repository, stream, senders, announcements } = deps;
router.use(ctx.rbac.require("use") as never);
const publishUnread = async (userId: string) => {
stream.publish(userId, "unread", {
count: await repository.unreadCount(userId),
});
};
/**
* @openapi
* /plugin-api/alerts/items:
* get:
* summary: List the caller's alerts
* description: Newest first. Brings in any Termix announcement the caller has not seen yet.
* tags:
* - Alerts
* parameters:
* - in: query
* name: unread
* schema:
* type: boolean
* - in: query
* name: source
* schema:
* type: string
* - in: query
* name: severity
* schema:
* type: string
* enum: [info, success, warning, critical]
* - in: query
* name: limit
* schema:
* type: integer
* - in: query
* name: before
* description: Only alerts with a lower id, for paging.
* schema:
* type: integer
* responses:
* 200:
* description: The alerts and the unread count.
* 403:
* description: Missing the alerts.use permission.
*/
router.get("/items", async (req: Request, res: Response) => {
const userId = actor(ctx);
await announcements.sync(userId);
const severity = isSeverity(req.query.severity)
? req.query.severity
: undefined;
const items = await repository.listItems(userId, {
unread: req.query.unread === "true",
source: typeof req.query.source === "string" ? req.query.source : "",
severity,
limit: positiveInt(req.query.limit) ?? 50,
beforeId: positiveInt(req.query.before) ?? undefined,
});
res.json({ items, unread: await repository.unreadCount(userId) });
});
/**
* @openapi
* /plugin-api/alerts/unread:
* get:
* summary: Count the caller's unread alerts
* tags:
* - Alerts
* responses:
* 200:
* description: The unread count.
*/
router.get("/unread", async (_req: Request, res: Response) => {
const userId = actor(ctx);
await announcements.sync(userId);
res.json({ count: await repository.unreadCount(userId) });
});
/**
* @openapi
* /plugin-api/alerts/stream:
* get:
* summary: Stream new alerts
* description: Server-sent events. Sends "unread" with the count on connect and after every change, and "item" with each new or updated alert.
* tags:
* - Alerts
* responses:
* 200:
* description: An event stream.
*/
router.get("/stream", async (req: Request, res: Response) => {
const userId = actor(ctx);
await announcements.sync(userId);
stream.open(req, res, userId, {
count: await repository.unreadCount(userId),
});
});
/**
* @openapi
* /plugin-api/alerts/items/read:
* post:
* summary: Mark alerts read or unread
* tags:
* - Alerts
* requestBody:
* content:
* application/json:
* schema:
* type: object
* properties:
* ids:
* type: array
* items:
* type: integer
* all:
* type: boolean
* read:
* type: boolean
* description: False marks them unread. Defaults to true.
* responses:
* 200:
* description: The new unread count.
*/
router.post("/items/read", async (req: Request, res: Response) => {
const userId = actor(ctx);
const body = (req.body ?? {}) as Record<string, unknown>;
const read = body.read !== false;
await repository.setRead(
userId,
body.all === true ? "all" : idList(body.ids),
read,
);
await publishUnread(userId);
res.json({ count: await repository.unreadCount(userId) });
});
/**
* @openapi
* /plugin-api/alerts/items/{id}:
* delete:
* summary: Delete an alert
* description: A deleted Termix announcement does not come back.
* tags:
* - Alerts
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Deleted.
* 404:
* description: No such alert for the caller.
*/
router.delete("/items/:id", async (req: Request, res: Response) => {
const userId = actor(ctx);
const id = positiveInt(req.params.id);
const item = id ? await repository.deleteItem(userId, id) : null;
if (!item) return res.status(404).json({ error: "Alert not found" });
await announcements.forget(userId, item);
await publishUnread(userId);
res.json({ success: true });
});
/**
* @openapi
* /plugin-api/alerts/items:
* delete:
* summary: Clear the caller's alerts
* tags:
* - Alerts
* parameters:
* - in: query
* name: read
* description: Only clear alerts already read.
* schema:
* type: boolean
* responses:
* 200:
* description: How many alerts were removed.
*/
router.delete("/items", async (req: Request, res: Response) => {
const userId = actor(ctx);
const removed = await repository.clearItems(
userId,
req.query.read === "true",
);
for (const item of removed) await announcements.forget(userId, item);
await publishUnread(userId);
res.json({ removed: removed.length });
});
/**
* @openapi
* /plugin-api/alerts/categories:
* get:
* summary: List the sources and categories of the caller's alerts
* description: For building delivery rules.
* tags:
* - Alerts
* responses:
* 200:
* description: Source and category pairs.
*/
router.get("/categories", async (_req: Request, res: Response) => {
res.json(await repository.listCategories(actor(ctx)));
});
/**
* @openapi
* /plugin-api/alerts/meta:
* get:
* summary: What channel types this server can use
* tags:
* - Alerts
* responses:
* 200:
* description: The channel types and whether email is set up.
*/
router.get("/meta", async (_req: Request, res: Response) => {
res.json({
emailAvailable: (await senders.smtp()) !== null,
channelTypes: CHANNEL_TYPES,
});
});
/**
* @openapi
* /plugin-api/alerts/channels:
* get:
* summary: List the caller's channels
* description: Without their config.
* tags:
* - Alerts
* responses:
* 200:
* description: The channels.
*/
router.get("/channels", async (_req: Request, res: Response) => {
res.json(await repository.listChannels(actor(ctx)));
});
/**
* @openapi
* /plugin-api/alerts/channels/{id}:
* get:
* summary: Get one of the caller's channels with its config
* tags:
* - Alerts
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: The channel.
* 404:
* description: No such channel for the caller.
*/
router.get("/channels/:id", async (req: Request, res: Response) => {
const id = positiveInt(req.params.id);
const channel = id ? await repository.getChannel(actor(ctx), id) : null;
if (!channel) return res.status(404).json({ error: "Channel not found" });
res.json(channel);
});
const readChannel = (
body: Record<string, unknown>,
partial: boolean,
):
| { error: string }
| {
name?: string;
type?: ChannelType;
config?: Record<string, unknown>;
enabled?: boolean;
} => {
const out: {
name?: string;
type?: ChannelType;
config?: Record<string, unknown>;
enabled?: boolean;
} = {};
if (body.name !== undefined || !partial) {
const name = typeof body.name === "string" ? body.name.trim() : "";
if (!name) return { error: "name is required" };
out.name = name.slice(0, 100);
}
if (body.type !== undefined || !partial) {
if (!isChannelType(body.type)) {
return { error: `type must be one of ${CHANNEL_TYPES.join(", ")}` };
}
out.type = body.type;
}
if (body.config !== undefined || !partial) {
if (
!body.config ||
typeof body.config !== "object" ||
Array.isArray(body.config)
) {
return { error: "config is required" };
}
out.config = body.config as Record<string, unknown>;
}
if (body.enabled !== undefined) out.enabled = body.enabled !== false;
return out;
};
/**
* @openapi
* /plugin-api/alerts/channels:
* post:
* summary: Create a channel
* tags:
* - Alerts
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [name, type, config]
* properties:
* name:
* type: string
* type:
* type: string
* enum: [webhook, ntfy, discord, email]
* config:
* type: object
* enabled:
* type: boolean
* responses:
* 201:
* description: The new channel.
* 400:
* description: The channel is not valid.
*/
router.post("/channels", async (req: Request, res: Response) => {
const userId = actor(ctx);
const input = readChannel(
(req.body ?? {}) as Record<string, unknown>,
false,
);
if ("error" in input) return res.status(400).json(input);
const problem = validateChannelConfig(input.type!, input.config!);
if (problem) return res.status(400).json({ error: problem });
const id = await repository.createChannel(userId, {
name: input.name!,
type: input.type!,
config: input.config!,
enabled: input.enabled,
});
res.status(201).json(await repository.getChannel(userId, id));
});
/**
* @openapi
* /plugin-api/alerts/channels/{id}:
* put:
* summary: Update a channel
* tags:
* - Alerts
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* requestBody:
* content:
* application/json:
* schema:
* type: object
* properties:
* name:
* type: string
* type:
* type: string
* enum: [webhook, ntfy, discord, email]
* config:
* type: object
* enabled:
* type: boolean
* responses:
* 200:
* description: The updated channel.
* 400:
* description: The channel is not valid.
* 404:
* description: No such channel for the caller.
*/
router.put("/channels/:id", async (req: Request, res: Response) => {
const userId = actor(ctx);
const id = positiveInt(req.params.id);
const existing = id ? await repository.getChannel(userId, id) : null;
if (!id || !existing) {
return res.status(404).json({ error: "Channel not found" });
}
const input = readChannel(
(req.body ?? {}) as Record<string, unknown>,
true,
);
if ("error" in input) return res.status(400).json(input);
const type = input.type ?? existing.type;
if (input.config !== undefined || input.type !== undefined) {
const problem = validateChannelConfig(
type,
input.config ?? existing.config,
);
if (problem) return res.status(400).json({ error: problem });
}
await repository.updateChannel(userId, id, input);
res.json(await repository.getChannel(userId, id));
});
/**
* @openapi
* /plugin-api/alerts/channels/{id}:
* delete:
* summary: Delete a channel
* tags:
* - Alerts
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Deleted.
* 404:
* description: No such channel for the caller.
*/
router.delete("/channels/:id", async (req: Request, res: Response) => {
const id = positiveInt(req.params.id);
const deleted = id ? await repository.deleteChannel(actor(ctx), id) : false;
if (!deleted) return res.status(404).json({ error: "Channel not found" });
res.json({ success: true });
});
/**
* @openapi
* /plugin-api/alerts/channels/{id}/test:
* post:
* summary: Send a test alert to a channel
* description: Takes the same path a real alert does, private network opt-in included.
* tags:
* - Alerts
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Whether the channel accepted it, with the error when it did not.
* 404:
* description: No such channel for the caller.
*/
router.post("/channels/:id/test", async (req: Request, res: Response) => {
const userId = actor(ctx);
const id = positiveInt(req.params.id);
const channel = id
? (await repository.deliverableChannels(userId)).find(
(candidate) => candidate.id === id,
)
: undefined;
if (!channel) return res.status(404).json({ error: "Channel not found" });
const [result] = await deliverToChannels(
[channel],
{
title: "Termix",
body: "This is a test alert from Termix.",
severity: "info",
category: "alerts.test",
source: ctx.pluginId,
context: { sourceName: "Test", triggerType: "test" },
},
senders,
);
res.json(
result.ok ? { success: true } : { success: false, error: result.error },
);
});
const readRule = async (
userId: string,
body: Record<string, unknown>,
): Promise<{ error: string } | Omit<AlertRule, "id">> => {
const name = typeof body.name === "string" ? body.name.trim() : "";
if (!name) return { error: "name is required" };
const match =
typeof body.match === "string" && body.match.trim()
? body.match.trim().slice(0, 128)
: "*";
const minSeverity = isSeverity(body.minSeverity)
? body.minSeverity
: "warning";
const owned = new Set(
(await repository.listChannels(userId)).map((channel) => channel.id),
);
const channelIds = [...new Set(idList(body.channelIds))];
if (channelIds.length === 0) {
return { error: "Pick at least one channel" };
}
if (channelIds.some((id) => !owned.has(id))) {
return { error: "Unknown channel" };
}
return {
name: name.slice(0, 100),
match,
minSeverity,
channelIds,
enabled: body.enabled !== false,
};
};
/**
* @openapi
* /plugin-api/alerts/rules:
* get:
* summary: List the caller's delivery rules
* tags:
* - Alerts
* responses:
* 200:
* description: The rules.
*/
router.get("/rules", async (_req: Request, res: Response) => {
res.json(await repository.listRules(actor(ctx)));
});
/**
* @openapi
* /plugin-api/alerts/rules:
* post:
* summary: Create a delivery rule
* description: Sends every alert whose category matches and whose severity is at least minSeverity to the listed channels.
* tags:
* - Alerts
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [name, channelIds]
* properties:
* name:
* type: string
* match:
* type: string
* description: '"*", "automations.*" or an exact category.'
* minSeverity:
* type: string
* enum: [info, success, warning, critical]
* channelIds:
* type: array
* items:
* type: integer
* enabled:
* type: boolean
* responses:
* 201:
* description: The new rule.
* 400:
* description: The rule is not valid.
*/
router.post("/rules", async (req: Request, res: Response) => {
const userId = actor(ctx);
const input = await readRule(
userId,
(req.body ?? {}) as Record<string, unknown>,
);
if ("error" in input) return res.status(400).json(input);
const id = await repository.createRule(userId, input);
res.status(201).json({ id, ...input });
});
/**
* @openapi
* /plugin-api/alerts/rules/{id}:
* put:
* summary: Update a delivery rule
* tags:
* - Alerts
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: The updated rule.
* 400:
* description: The rule is not valid.
* 404:
* description: No such rule for the caller.
*/
router.put("/rules/:id", async (req: Request, res: Response) => {
const userId = actor(ctx);
const id = positiveInt(req.params.id);
if (!id) return res.status(404).json({ error: "Rule not found" });
const input = await readRule(
userId,
(req.body ?? {}) as Record<string, unknown>,
);
if ("error" in input) return res.status(400).json(input);
if (!(await repository.updateRule(userId, id, input))) {
return res.status(404).json({ error: "Rule not found" });
}
res.json({ id, ...input });
});
/**
* @openapi
* /plugin-api/alerts/rules/{id}:
* delete:
* summary: Delete a delivery rule
* tags:
* - Alerts
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Deleted.
* 404:
* description: No such rule for the caller.
*/
router.delete("/rules/:id", async (req: Request, res: Response) => {
const id = positiveInt(req.params.id);
const deleted = id ? await repository.deleteRule(actor(ctx), id) : false;
if (!deleted) return res.status(404).json({ error: "Rule not found" });
res.json({ success: true });
});
}
+224
View File
@@ -0,0 +1,224 @@
import type { PluginFetch } from "@termix/plugin-sdk/backend";
import type { AlertLink, ChannelType, Severity } from "../types.js";
export interface OutgoingAlert {
title: string;
body: string;
severity: Severity;
category: string;
source: string;
link?: AlertLink | null;
context?: Record<string, unknown> | null;
}
export interface SmtpSettings {
host: string;
port: number;
secure: boolean;
user: string;
password: string;
from: string;
}
export interface SendMail {
(
smtp: SmtpSettings,
message: {
to: string[];
subject: string;
text: string;
},
): Promise<void>;
}
export interface SenderDeps {
fetch: PluginFetch;
/** Hosts the admin lets channels reach on a private network. */
privateAllowlist: () => Promise<string[]>;
smtp: () => Promise<SmtpSettings | null>;
sendMail: SendMail;
}
const NTFY_PRIORITY: Record<Severity, number> = {
info: 2,
success: 2,
warning: 3,
critical: 5,
};
const NTFY_TAGS: Record<Severity, string> = {
info: "information_source",
success: "white_check_mark",
warning: "warning",
critical: "rotating_light",
};
const DISCORD_COLORS: Record<Severity, number> = {
info: 3447003,
success: 3066993,
warning: 16753920,
critical: 15158332,
};
export const DISCORD_WEBHOOK =
/^https:\/\/(?:canary\.|ptb\.)?(?:discord\.com|discordapp\.com)\/api\/webhooks\/.+/i;
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const str = (value: unknown) => (typeof value === "string" ? value.trim() : "");
export function parseRecipients(value: unknown): string[] {
return str(value)
.split(/[,;\s]+/)
.map((entry) => entry.trim())
.filter(Boolean);
}
/** What is wrong with a channel's config, or null when it can be saved. */
export function validateChannelConfig(
type: ChannelType,
config: Record<string, unknown>,
): string | null {
switch (type) {
case "webhook":
return /^https?:\/\//i.test(str(config.url))
? null
: "webhook config requires an http(s) url";
case "ntfy":
if (!/^https?:\/\//i.test(str(config.url))) {
return "ntfy config requires an http(s) url";
}
return str(config.topic) ? null : "ntfy config requires a topic";
case "discord":
return DISCORD_WEBHOOK.test(str(config.url))
? null
: "discord config requires a Discord webhook URL";
case "email": {
const to = parseRecipients(config.to);
if (to.length === 0) return "email config requires at least one address";
return to.every((address) => EMAIL.test(address))
? null
: "email config has an invalid address";
}
}
}
function linkUrl(alert: OutgoingAlert): string | undefined {
const url = alert.link?.url;
return url && /^https?:\/\//i.test(url) ? url : undefined;
}
/**
* Sends one alert to one channel and throws on failure, so the caller can say
* which channel failed. Private and loopback targets need both the channel's
* allowPrivateNetwork opt-in and an exact host in the admin allowlist.
*/
export async function sendToChannel(
channel: { type: ChannelType; config: Record<string, unknown> },
alert: OutgoingAlert,
deps: SenderDeps,
): Promise<void> {
const { config } = channel;
if (channel.type === "email") {
const smtp = await deps.smtp();
if (!smtp) throw new Error("Email is not set up on this server");
const to = parseRecipients(config.to);
if (to.length === 0) throw new Error("Channel has no email address");
const url = linkUrl(alert);
await deps.sendMail(smtp, {
to,
subject: `[Termix] ${alert.title}`,
text: [alert.body || alert.title, url].filter(Boolean).join("\n\n"),
});
return;
}
const allowPrivateHosts =
config.allowPrivateNetwork === true ? await deps.privateAllowlist() : [];
const post = async (
url: string,
init: { method: string; headers: Record<string, string>; body: string },
) => {
const response = await deps.fetch(url, {
...init,
timeoutMs: 30_000,
allowPrivateHosts,
});
if (!response.ok) {
const detail = await response.text().catch(() => "");
throw new Error(
`HTTP ${response.status} ${response.statusText}${detail ? `: ${detail.slice(0, 200)}` : ""}`,
);
}
};
const url = str(config.url);
if (!url) throw new Error("Channel is missing a URL");
const context = alert.context ?? {};
switch (channel.type) {
case "webhook": {
const headers =
config.headers && typeof config.headers === "object"
? (config.headers as Record<string, string>)
: {};
return post(url, {
method: config.method === "PUT" ? "PUT" : "POST",
headers: { "Content-Type": "application/json", ...headers },
body: JSON.stringify({
title: alert.title,
message: alert.body,
severity: alert.severity,
category: alert.category,
source: alert.source,
link: linkUrl(alert),
hostName: context.hostName,
hostId: context.hostId,
ruleName: context.sourceName ?? alert.title,
ruleId: context.sourceId,
triggerType: context.triggerType,
value: context.value,
threshold: context.threshold,
timestamp: new Date().toISOString(),
}),
});
}
case "ntfy": {
const topic = str(config.topic);
if (!topic) throw new Error("ntfy channel is missing a topic");
const headers: Record<string, string> = {
Title: alert.title || "Termix",
Priority: String(NTFY_PRIORITY[alert.severity]),
Tags: NTFY_TAGS[alert.severity],
};
const click = linkUrl(alert);
if (click) headers.Click = click;
const token = str(config.token);
if (token) headers.Authorization = `Bearer ${token}`;
return post(`${url.replace(/\/$/, "")}/${encodeURIComponent(topic)}`, {
method: "POST",
headers,
body: alert.body || alert.title,
});
}
case "discord": {
const embed: Record<string, unknown> = {
title: alert.title || "Termix",
description: alert.body || undefined,
color: DISCORD_COLORS[alert.severity],
timestamp: new Date().toISOString(),
};
const click = linkUrl(alert);
if (click) embed.url = click;
const payload: Record<string, unknown> = { embeds: [embed] };
if (str(config.username)) payload.username = str(config.username);
if (str(config.avatar_url)) payload.avatar_url = str(config.avatar_url);
return post(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
}
}
}
+77
View File
@@ -0,0 +1,77 @@
import type { Request, Response } from "express";
/**
* Open event streams by user, so a new alert shows up in the browser the
* moment it is stored instead of on the next poll.
*/
export function createAlertStream() {
const clients = new Map<string, Set<Response>>();
const write = (res: Response, event: string, data: unknown) => {
try {
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
} catch {
// The close handler removes it.
}
};
return {
open(req: Request, res: Response, userId: string, hello: unknown): void {
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-store, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
});
res.flushHeaders?.();
let set = clients.get(userId);
if (!set) {
set = new Set();
clients.set(userId, set);
}
set.add(res);
write(res, "unread", hello);
const heartbeat = setInterval(() => {
try {
res.write(": keepalive\n\n");
} catch {
close();
}
}, 30_000);
const close = () => {
clearInterval(heartbeat);
const current = clients.get(userId);
current?.delete(res);
if (current?.size === 0) clients.delete(userId);
};
req.on("close", close);
},
/** Users with at least one stream open. */
users(): string[] {
return [...clients.keys()];
},
publish(userId: string, event: string, data: unknown): void {
for (const res of clients.get(userId) ?? []) write(res, event, data);
},
closeAll(): void {
for (const set of clients.values()) {
for (const res of set) {
try {
res.end();
} catch {
// Already gone.
}
}
}
clients.clear();
},
};
}
export type AlertStream = ReturnType<typeof createAlertStream>;
+101
View File
@@ -0,0 +1,101 @@
import {
adoptLegacyTable,
boolean,
defineTable,
encryptedText,
id,
refUser,
text,
timestamp,
varchar,
} from "@termix/plugin-sdk/db";
/**
* Where a user's alerts go. The config holds webhook URLs, ntfy tokens and
* email addresses, sealed with ctx.secrets.seal so an alert sent with no
* acting user (a certificate that failed to renew at night) can still read it.
* 2.8 kept channels in core's notification_channels; the upgrade copies them
* here with their ids, which automations' notify steps still name.
*/
export const channels = defineTable(
"channels",
{
id: id(),
userId: refUser(),
name: text().notNull(),
type: varchar(32).notNull(),
config: encryptedText(),
enabled: boolean().notNull().default(true),
createdAt: timestamp().notNull().defaultNow(),
updatedAt: timestamp().notNull().defaultNow(),
},
{ indexes: [{ name: "idx_alerts_channels_user", columns: ["userId"] }] },
);
/** The inbox. One row per recipient, so read state is per user. */
export const items = defineTable(
"items",
{
id: id(),
userId: refUser(),
// The plugin that sent it, or "termix" for an announcement.
source: varchar(64).notNull(),
category: varchar(128).notNull(),
severity: varchar(16).notNull(),
title: text().notNull(),
body: text(),
link: text(),
context: text(),
dedupeKey: varchar(),
// What each channel said when the alert was delivered.
deliveries: text(),
readAt: timestamp(),
createdAt: timestamp().notNull().defaultNow(),
},
{
indexes: [
{ name: "idx_alerts_items_user", columns: ["userId", "createdAt"] },
{ name: "idx_alerts_items_unread", columns: ["userId", "readAt"] },
{ name: "idx_alerts_items_dedupe", columns: ["userId", "dedupeKey"] },
],
},
);
/** Which alerts a user sends to which of their channels. */
export const rules = defineTable(
"rules",
{
id: id(),
userId: refUser(),
name: text().notNull(),
// A category pattern: "*", "automations.*" or an exact category.
pattern: varchar(128).notNull().default("*"),
minSeverity: varchar(16).notNull().default("warning"),
channelIds: text().notNull(),
enabled: boolean().notNull().default(true),
createdAt: timestamp().notNull().defaultNow(),
},
{ indexes: [{ name: "idx_alerts_rules_user", columns: ["userId"] }] },
);
/**
* Announcements a user removed from their inbox, so they are not brought
* back. Adopted from core's dismissed_alerts, which held the same thing.
*/
export const dismissed = adoptLegacyTable(
"dismissed_alerts",
defineTable(
"dismissed",
{
id: id(),
userId: refUser(),
alertId: text().notNull(),
dismissedAt: timestamp().notNull().defaultNow(),
},
{
indexes: [{ name: "idx_dismissed_alerts_user_id", columns: ["userId"] }],
},
),
);
export const tables = [channels, items, rules, dismissed];
+166
View File
@@ -0,0 +1,166 @@
import {
AlertTriangle,
CheckCircle2,
ExternalLink,
Info,
Mail,
MailOpen,
OctagonAlert,
Trash2,
} from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { Button, cn } from "@termix/plugin-sdk/ui";
import type { AlertItem, Severity } from "../types";
import { sourceLabel, timeAgo } from "./format";
const SEVERITY_ICON: Record<Severity, typeof Info> = {
info: Info,
success: CheckCircle2,
warning: AlertTriangle,
critical: OctagonAlert,
};
const SEVERITY_CLASS: Record<Severity, string> = {
info: "text-muted-foreground",
success: "text-green-500",
warning: "text-amber-500",
critical: "text-destructive",
};
export function SeverityIcon({
severity,
className,
}: {
severity: Severity;
className?: string;
}) {
const Icon = SEVERITY_ICON[severity];
return <Icon className={cn(SEVERITY_CLASS[severity], className)} />;
}
export function AlertList({
items,
compact = false,
onOpen,
onToggleRead,
onDelete,
}: {
items: AlertItem[];
compact?: boolean;
onOpen: (item: AlertItem) => void;
onToggleRead: (item: AlertItem) => void;
onDelete: (item: AlertItem) => void;
}) {
const { t, language } = useTranslation();
return (
<div className="flex flex-col">
{items.map((item) => {
const unread = !item.readAt;
const failed = (item.deliveries ?? []).filter((entry) => !entry.ok);
const actionText =
typeof item.context?.actionText === "string"
? item.context.actionText
: t("inbox.openLink");
return (
<div
key={item.id}
className={cn(
"group relative flex gap-2.5 border-b border-border px-3 py-2.5 hover:bg-muted/40",
unread && "bg-accent-brand/5",
)}
>
{unread && (
<span
className="absolute left-0 top-0 bottom-0 w-0.5 bg-accent-brand"
aria-label={t("inbox.unread")}
/>
)}
<SeverityIcon
severity={item.severity}
className="size-4 shrink-0 mt-0.5"
/>
<button
type="button"
className="flex-1 min-w-0 text-left"
onClick={() => onOpen(item)}
>
<div
className={cn(
"text-sm leading-snug break-words",
unread ? "font-semibold" : "font-medium",
)}
>
{item.title}
</div>
{item.body && (
<div
className={cn(
"text-xs text-muted-foreground mt-0.5 whitespace-pre-wrap break-words",
compact && "line-clamp-2",
)}
>
{item.body}
</div>
)}
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 mt-1 text-[11px] text-muted-foreground">
<span title={item.category}>{sourceLabel(item.source, t)}</span>
<span>{timeAgo(item.createdAt, language)}</span>
{failed.length > 0 && (
<span
className="text-destructive"
title={failed
.map((entry) => `${entry.name}: ${entry.error ?? ""}`)
.join("\n")}
>
{t("inbox.deliveryFailed", { count: failed.length })}
</span>
)}
{item.link?.url && (
<a
href={item.link.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-accent-brand hover:underline"
onClick={(event) => event.stopPropagation()}
>
{actionText}
<ExternalLink className="size-3" />
</a>
)}
</div>
</button>
<div className="flex items-start gap-0.5 shrink-0 opacity-60 group-hover:opacity-100">
<Button
variant="ghost"
size="icon"
className="h-6 w-6 rounded-none"
title={unread ? t("inbox.markRead") : t("inbox.markUnread")}
aria-label={
unread ? t("inbox.markRead") : t("inbox.markUnread")
}
onClick={() => onToggleRead(item)}
>
{unread ? (
<MailOpen className="size-3.5" />
) : (
<Mail className="size-3.5" />
)}
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 rounded-none"
title={t("inbox.delete")}
aria-label={t("inbox.delete")}
onClick={() => onDelete(item)}
>
<Trash2 className="size-3.5" />
</Button>
</div>
</div>
);
})}
</div>
);
}
@@ -0,0 +1,49 @@
import { useEffect, useRef } from "react";
import { toast } from "sonner";
import {
useSettings,
useTabs,
useTranslation,
} from "@termix/plugin-sdk/frontend";
import { shouldPopUp, type PopupLevel } from "./popups";
import type { AlertsStore } from "./store";
/** Pops up alerts as they arrive, as far as the user's setting allows. */
export function AlertToaster({
store,
viewId,
}: {
store: AlertsStore;
viewId: string;
}) {
const { t } = useTranslation();
const tabs = useTabs();
const { values } = useSettings("user");
const level = useRef<PopupLevel>("warning");
level.current = (values.popups as PopupLevel | undefined) ?? "warning";
useEffect(
() =>
store.onItem((item, isNew) => {
if (!isNew || !shouldPopUp(item, level.current)) return;
const show =
item.severity === "critical"
? toast.error
: item.severity === "warning"
? toast.warning
: item.severity === "success"
? toast.success
: toast.info;
show(item.title, {
description: item.body ? item.body.slice(0, 200) : undefined,
action: {
label: t("inbox.view"),
onClick: () => tabs.openRailView(viewId),
},
});
}),
[store, t, tabs, viewId],
);
return null;
}
@@ -0,0 +1,69 @@
import { useEffect, useMemo, useState } from "react";
import { Bell, Radio, Route } from "lucide-react";
import { usePluginApi, useTranslation } from "@termix/plugin-sdk/frontend";
import { cn } from "@termix/plugin-sdk/ui";
import { createAlertsApi } from "./api";
import { ChannelsView } from "./ChannelsView";
import { InboxView } from "./InboxView";
import { RulesView } from "./RulesView";
import type { AlertsStore } from "./store";
import type { Section, SectionRequests } from "./sections";
const SECTIONS: Array<{ id: Section; icon: typeof Bell; labelKey: string }> = [
{ id: "inbox", icon: Bell, labelKey: "sections.inbox" },
{ id: "channels", icon: Radio, labelKey: "sections.channels" },
{ id: "rules", icon: Route, labelKey: "sections.rules" },
];
export function AlertsView({
store,
sections,
}: {
store: AlertsStore;
sections: SectionRequests;
}) {
const { t } = useTranslation();
const client = usePluginApi();
const api = useMemo(() => createAlertsApi(client), [client]);
const [section, setSection] = useState<Section>(
() => sections.take() ?? "inbox",
);
useEffect(
() =>
sections.subscribe((requested) => {
sections.take();
setSection(requested);
}),
[sections],
);
return (
<div className="flex flex-col flex-1 min-h-0 h-full bg-background">
<div className="flex items-center gap-1 border-b border-border px-2 shrink-0">
{SECTIONS.map(({ id, icon: Icon, labelKey }) => (
<button
key={id}
type="button"
onClick={() => setSection(id)}
className={cn(
"flex items-center gap-1.5 px-3 py-2 text-xs font-medium border-b-2 -mb-px transition-colors",
section === id
? "border-accent-brand text-accent-brand"
: "border-transparent text-muted-foreground hover:text-foreground",
)}
>
<Icon className="size-3.5" />
{t(labelKey)}
</button>
))}
</div>
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto">
{section === "inbox" && <InboxView store={store} />}
{section === "channels" && <ChannelsView api={api} />}
{section === "rules" && <RulesView api={api} />}
</div>
</div>
);
}
@@ -0,0 +1,325 @@
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import {
Button,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
Input,
PasswordInput,
Switch,
Textarea,
cn,
} from "@termix/plugin-sdk/ui";
import { CHANNEL_TYPES, type ChannelType } from "../types";
import { EMPTY, configFrom, draftFrom, type Draft } from "./channel-config";
import type { AlertsApi } from "./api";
function Field({
label,
hint,
children,
}: {
label: string;
hint?: string;
children: React.ReactNode;
}) {
return (
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{label}
</label>
{children}
{hint && (
<span className="text-[10px] text-muted-foreground">{hint}</span>
)}
</div>
);
}
export function ChannelDialog({
api,
open,
channelId,
emailAvailable,
onOpenChange,
onSaved,
}: {
api: AlertsApi;
open: boolean;
/** Null to create a new channel. */
channelId: number | null;
emailAvailable: boolean;
onOpenChange: (open: boolean) => void;
onSaved: () => void;
}) {
const { t } = useTranslation();
const [draft, setDraft] = useState<Draft>(EMPTY);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!open) return;
if (channelId === null) {
setDraft(EMPTY);
return;
}
api
.channel(channelId)
.then((channel) => setDraft(draftFrom(channel)))
.catch((error) =>
toast.error(error instanceof Error ? error.message : String(error)),
);
}, [api, channelId, open]);
const set = <K extends keyof Draft>(key: K, value: Draft[K]) =>
setDraft((current) => ({ ...current, [key]: value }));
const pickType = (type: ChannelType) =>
setDraft((current) => ({
...current,
type,
url:
type === "ntfy" && !current.url
? "https://ntfy.sh"
: type !== "ntfy" && current.url === "https://ntfy.sh"
? ""
: current.url,
}));
const save = async () => {
if (!draft.name.trim()) {
toast.error(t("channels.nameRequired"));
return;
}
setSaving(true);
try {
const input = {
name: draft.name.trim(),
type: draft.type,
config: configFrom(draft),
};
if (channelId === null) await api.createChannel(input);
else await api.updateChannel(channelId, input);
onSaved();
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
} finally {
setSaving(false);
}
};
const canReachPrivate = draft.type === "webhook" || draft.type === "ntfy";
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md rounded-none">
<DialogHeader>
<DialogTitle>
{channelId === null ? t("channels.add") : t("channels.edit")}
</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-3 py-2">
<Field label={t("channels.name")}>
<Input
value={draft.name}
onChange={(event) => set("name", event.target.value)}
className="text-sm rounded-none"
/>
</Field>
<Field label={t("channels.type")}>
<div className="flex flex-wrap gap-2">
{CHANNEL_TYPES.map((type) => {
const disabled = type === "email" && !emailAvailable;
return (
<button
key={type}
type="button"
disabled={disabled}
title={
disabled ? t("channels.emailUnavailable") : undefined
}
onClick={() => pickType(type)}
className={cn(
"px-3 py-1 text-xs font-semibold border transition-colors disabled:opacity-40 disabled:cursor-not-allowed",
draft.type === type
? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand"
: "border-border text-muted-foreground hover:border-accent-brand/30",
)}
>
{t(`channels.types.${type}`)}
</button>
);
})}
</div>
{draft.type === "email" && !emailAvailable && (
<span className="text-[10px] text-destructive">
{t("channels.emailUnavailable")}
</span>
)}
</Field>
{draft.type === "webhook" && (
<>
<Field
label={t("channels.webhookUrl")}
hint={t("channels.webhookHint")}
>
<Input
value={draft.url}
onChange={(event) => set("url", event.target.value)}
placeholder="https://example.com/webhook"
className="text-sm font-mono rounded-none"
/>
</Field>
<Field label={t("channels.method")}>
<div className="flex gap-2">
{(["POST", "PUT"] as const).map((method) => (
<button
key={method}
type="button"
onClick={() => set("method", method)}
className={cn(
"px-3 py-1 text-xs font-mono border transition-colors",
draft.method === method
? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand"
: "border-border text-muted-foreground",
)}
>
{method}
</button>
))}
</div>
</Field>
<Field
label={t("channels.headers")}
hint={t("channels.headersHint")}
>
<Textarea
value={draft.headers}
onChange={(event) => set("headers", event.target.value)}
placeholder="Authorization: Bearer ..."
rows={3}
className="text-xs font-mono rounded-none"
/>
</Field>
</>
)}
{draft.type === "ntfy" && (
<>
<Field label={t("channels.ntfyServer")}>
<Input
value={draft.url}
onChange={(event) => set("url", event.target.value)}
placeholder="https://ntfy.sh"
className="text-sm font-mono rounded-none"
/>
</Field>
<Field label={t("channels.ntfyTopic")}>
<Input
value={draft.topic}
onChange={(event) => set("topic", event.target.value)}
placeholder="my-alerts"
className="text-sm font-mono rounded-none"
/>
</Field>
<Field label={t("channels.ntfyToken")}>
<PasswordInput
value={draft.token}
onChange={(event) => set("token", event.target.value)}
placeholder="tk_..."
className="text-sm font-mono rounded-none"
/>
</Field>
</>
)}
{draft.type === "discord" && (
<>
<Field
label={t("channels.discordUrl")}
hint={t("channels.discordHint")}
>
<Input
value={draft.url}
onChange={(event) => set("url", event.target.value)}
placeholder="https://discord.com/api/webhooks/..."
className="text-sm font-mono rounded-none"
/>
</Field>
<Field label={t("channels.discordUsername")}>
<Input
value={draft.username}
onChange={(event) => set("username", event.target.value)}
placeholder="Termix"
className="text-sm rounded-none"
/>
</Field>
<Field label={t("channels.discordAvatar")}>
<Input
value={draft.avatarUrl}
onChange={(event) => set("avatarUrl", event.target.value)}
placeholder="https://example.com/avatar.png"
className="text-sm font-mono rounded-none"
/>
</Field>
</>
)}
{draft.type === "email" && (
<Field label={t("channels.emailTo")} hint={t("channels.emailHint")}>
<Input
value={draft.to}
onChange={(event) => set("to", event.target.value)}
placeholder="ops@example.com"
className="text-sm rounded-none"
/>
</Field>
)}
{canReachPrivate && (
<div className="flex items-start justify-between gap-3">
<div className="flex flex-col">
<span className="text-xs font-medium">
{t("channels.privateNetwork")}
</span>
<span className="text-[10px] text-muted-foreground">
{t("channels.privateNetworkHint")}
</span>
</div>
<Switch
checked={draft.allowPrivateNetwork}
onCheckedChange={(checked) =>
set("allowPrivateNetwork", checked)
}
/>
</div>
)}
</div>
<DialogFooter>
<Button
variant="ghost"
className="rounded-none"
onClick={() => onOpenChange(false)}
>
{t("actions.cancel")}
</Button>
<Button
variant="outline"
onClick={() => void save()}
disabled={saving}
className="rounded-none border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
>
{saving ? t("actions.saving") : t("actions.save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,204 @@
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import {
AlertTriangle,
Loader2,
Pencil,
Plus,
Send,
Trash2,
} from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { Badge, Button, Switch, useConfirmation } from "@termix/plugin-sdk/ui";
import type { ChannelSummary } from "../types";
import type { AlertsApi } from "./api";
import { ChannelDialog } from "./ChannelDialog";
export function ChannelsView({
api,
onChanged,
}: {
api: AlertsApi;
onChanged?: () => void;
}) {
const { t } = useTranslation();
const { confirmWithToast } = useConfirmation();
const [channels, setChannels] = useState<ChannelSummary[]>([]);
const [loading, setLoading] = useState(true);
const [emailAvailable, setEmailAvailable] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [testing, setTesting] = useState<number | null>(null);
const load = useCallback(async () => {
try {
const [list, meta] = await Promise.all([api.channels(), api.meta()]);
setChannels(list);
setEmailAvailable(meta.emailAvailable);
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
} finally {
setLoading(false);
}
}, [api]);
useEffect(() => {
void load();
}, [load]);
const test = async (channel: ChannelSummary) => {
setTesting(channel.id);
try {
await api.testChannel(channel.id);
toast.success(t("channels.testSent"));
} catch (error) {
toast.error(
t("channels.testFailed", {
error: error instanceof Error ? error.message : String(error),
}),
);
} finally {
setTesting(null);
}
};
const toggle = async (channel: ChannelSummary, enabled: boolean) => {
try {
await api.updateChannel(channel.id, { enabled });
setChannels((current) =>
current.map((entry) =>
entry.id === channel.id ? { ...entry, enabled } : entry,
),
);
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
}
};
const remove = (channel: ChannelSummary) => {
void confirmWithToast(
t("channels.deleteConfirm", { name: channel.name }),
async () => {
try {
await api.deleteChannel(channel.id);
toast.success(t("channels.deleted"));
await load();
onChanged?.();
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
}
},
t("actions.delete"),
t("actions.cancel"),
);
};
return (
<div className="flex flex-col gap-2 p-3">
<p className="text-xs text-muted-foreground">{t("channels.intro")}</p>
<Button
size="sm"
variant="outline"
className="rounded-none self-start border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={() => {
setEditingId(null);
setDialogOpen(true);
}}
>
<Plus className="size-3.5 mr-1" />
{t("channels.add")}
</Button>
{loading ? (
<Loader2 className="size-4 animate-spin text-muted-foreground m-4" />
) : channels.length === 0 ? (
<p className="text-xs text-muted-foreground py-4">
{t("channels.empty")}
</p>
) : (
<div className="flex flex-col border border-border">
{channels.map((channel) => (
<div
key={channel.id}
className="flex items-center gap-2 px-3 py-2 border-b border-border last:border-b-0"
>
<Switch
checked={channel.enabled}
onCheckedChange={(checked) => void toggle(channel, checked)}
aria-label={t("channels.enabled")}
/>
<span className="flex-1 min-w-0 text-sm truncate">
{channel.name}
</span>
{!channel.usable && (
<span
className="text-destructive"
title={t("channels.needsSave")}
>
<AlertTriangle className="size-3.5" />
</span>
)}
<Badge
variant="outline"
className="rounded-none text-[10px] uppercase"
>
{t(`channels.types.${channel.type}`)}
</Badge>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 rounded-none"
title={t("channels.test")}
aria-label={t("channels.test")}
disabled={testing === channel.id}
onClick={() => void test(channel)}
>
{testing === channel.id ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<Send className="size-3.5" />
)}
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 rounded-none"
title={t("channels.edit")}
aria-label={t("channels.edit")}
onClick={() => {
setEditingId(channel.id);
setDialogOpen(true);
}}
>
<Pencil className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 rounded-none"
title={t("actions.delete")}
aria-label={t("actions.delete")}
onClick={() => remove(channel)}
>
<Trash2 className="size-3.5" />
</Button>
</div>
))}
</div>
)}
<ChannelDialog
api={api}
open={dialogOpen}
channelId={editingId}
emailAvailable={emailAvailable}
onOpenChange={setDialogOpen}
onSaved={() => {
setDialogOpen(false);
void load();
onChanged?.();
}}
/>
</div>
);
}
+283
View File
@@ -0,0 +1,283 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { BellOff, CheckCheck, Loader2, Maximize2, Trash2 } from "lucide-react";
import {
usePluginApi,
useTabs,
useTranslation,
} from "@termix/plugin-sdk/frontend";
import {
Button,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
cn,
} from "@termix/plugin-sdk/ui";
import { SEVERITIES, type AlertItem, type Severity } from "../types";
import { createAlertsApi } from "./api";
import { AlertList } from "./AlertList";
import { sourceLabel } from "./format";
import { useAlertsVersion, type AlertsStore } from "./store";
const ALL = "__all";
export function InboxView({
store,
compact = false,
onOpenAll,
}: {
store: AlertsStore;
compact?: boolean;
onOpenAll?: () => void;
}) {
const { t } = useTranslation();
const client = usePluginApi();
const api = useMemo(() => createAlertsApi(client), [client]);
const tabs = useTabs();
const version = useAlertsVersion(store);
const [items, setItems] = useState<AlertItem[]>([]);
const [loading, setLoading] = useState(true);
const [hasMore, setHasMore] = useState(false);
const [unreadOnly, setUnreadOnly] = useState(false);
const [severity, setSeverity] = useState<Severity | "">("");
const [source, setSource] = useState("");
const [sources, setSources] = useState<string[]>([]);
const pageSize = compact ? 25 : 50;
const load = useCallback(async () => {
try {
const result = await api.items({
unread: unreadOnly,
severity: severity || undefined,
source: source || undefined,
limit: pageSize,
});
setItems(result.items);
setHasMore(result.items.length === pageSize);
store.setUnread(result.unread);
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
} finally {
setLoading(false);
}
}, [api, pageSize, severity, source, store, unreadOnly]);
useEffect(() => {
void load();
}, [load, version]);
useEffect(() => {
if (compact) return;
api
.categories()
.then((rows) =>
setSources([...new Set(rows.map((row) => row.source))].sort()),
)
.catch(() => {});
}, [api, compact, version]);
const loadMore = async () => {
const last = items[items.length - 1];
if (!last) return;
try {
const result = await api.items({
unread: unreadOnly,
severity: severity || undefined,
source: source || undefined,
limit: pageSize,
before: last.id,
});
setItems((current) => [...current, ...result.items]);
setHasMore(result.items.length === pageSize);
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
}
};
const setRead = async (ids: number[] | "all", read: boolean) => {
const { count } = await api.setRead(ids, read);
store.setUnread(count);
const now = new Date().toISOString();
setItems((current) =>
current
.map((item) =>
ids === "all" || ids.includes(item.id)
? { ...item, readAt: read ? (item.readAt ?? now) : null }
: item,
)
.filter((item) => !unreadOnly || !item.readAt),
);
};
const open = (item: AlertItem) => {
if (!item.readAt) void setRead([item.id], true).catch(() => {});
if (item.link?.tab) tabs.openSingletonTab(item.link.tab);
};
const remove = async (item: AlertItem) => {
try {
await api.remove(item.id);
setItems((current) => current.filter((entry) => entry.id !== item.id));
store.setUnread((await api.unread()).count);
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
}
};
const clearRead = async () => {
try {
const { removed } = await api.clear(true);
toast.success(t("inbox.cleared", { count: removed }));
await load();
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
}
};
const unread = items.some((item) => !item.readAt);
return (
<div className="flex flex-col flex-1 min-h-0">
<div
className={cn(
"flex flex-wrap items-center gap-1.5 border-b border-border shrink-0",
compact ? "px-2 py-1.5" : "px-3 py-2",
)}
>
<div className="flex border border-border">
{[false, true].map((value) => (
<button
key={String(value)}
type="button"
onClick={() => setUnreadOnly(value)}
className={cn(
"px-2 py-1 text-xs font-medium transition-colors",
unreadOnly === value
? "bg-accent-brand/10 text-accent-brand"
: "text-muted-foreground hover:text-foreground",
)}
>
{value ? t("inbox.filterUnread") : t("inbox.filterAll")}
</button>
))}
</div>
{!compact && (
<>
<Select
value={severity || ALL}
onValueChange={(value) =>
setSeverity(value === ALL ? "" : (value as Severity))
}
>
<SelectTrigger className="h-7 w-[140px] rounded-none text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>{t("inbox.anySeverity")}</SelectItem>
{SEVERITIES.map((value) => (
<SelectItem key={value} value={value}>
{t(`severity.${value}`)}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={source || ALL}
onValueChange={(value) => setSource(value === ALL ? "" : value)}
>
<SelectTrigger className="h-7 w-[160px] rounded-none text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>{t("inbox.anySource")}</SelectItem>
{sources.map((value) => (
<SelectItem key={value} value={value}>
{sourceLabel(value, t)}
</SelectItem>
))}
</SelectContent>
</Select>
</>
)}
<span className="flex-1" />
<Button
variant="ghost"
size="icon"
className="h-7 w-7 rounded-none"
disabled={!unread}
title={t("inbox.markAllRead")}
aria-label={t("inbox.markAllRead")}
onClick={() => void setRead("all", true).catch(() => {})}
>
<CheckCheck className="size-4" />
</Button>
{!compact && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 rounded-none"
title={t("inbox.clearRead")}
aria-label={t("inbox.clearRead")}
onClick={() => void clearRead()}
>
<Trash2 className="size-4" />
</Button>
)}
{onOpenAll && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 rounded-none"
title={t("inbox.openAll")}
aria-label={t("inbox.openAll")}
onClick={onOpenAll}
>
<Maximize2 className="size-4" />
</Button>
)}
</div>
<div className="flex-1 min-h-0 overflow-y-auto">
{loading ? (
<div className="flex justify-center p-6">
<Loader2 className="size-4 animate-spin text-muted-foreground" />
</div>
) : items.length === 0 ? (
<div className="flex flex-col items-center gap-2 p-8 text-center text-muted-foreground">
<BellOff className="size-6" />
<p className="text-sm">
{unreadOnly ? t("inbox.emptyUnread") : t("inbox.empty")}
</p>
</div>
) : (
<>
<AlertList
items={items}
compact={compact}
onOpen={open}
onToggleRead={(item) =>
void setRead([item.id], !item.readAt).catch(() => {})
}
onDelete={(item) => void remove(item)}
/>
{hasMore && (
<div className="p-2">
<Button
variant="ghost"
size="sm"
className="w-full rounded-none text-xs"
onClick={() => void loadMore()}
>
{t("inbox.loadMore")}
</Button>
</div>
)}
</>
)}
</div>
</div>
);
}
+337
View File
@@ -0,0 +1,337 @@
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { Loader2, Pencil, Plus, Trash2 } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import {
Button,
Checkbox,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
Input,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Switch,
useConfirmation,
} from "@termix/plugin-sdk/ui";
import {
SEVERITIES,
type AlertRule,
type ChannelSummary,
type Severity,
} from "../types";
import type { AlertsApi, RuleInput } from "./api";
const EMPTY: RuleInput = {
name: "",
match: "*",
minSeverity: "warning",
channelIds: [],
enabled: true,
};
export function RulesView({ api }: { api: AlertsApi }) {
const { t } = useTranslation();
const { confirmWithToast } = useConfirmation();
const [rules, setRules] = useState<AlertRule[]>([]);
const [channels, setChannels] = useState<ChannelSummary[]>([]);
const [categories, setCategories] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState<AlertRule | null>(null);
const [draft, setDraft] = useState<RuleInput>(EMPTY);
const [dialogOpen, setDialogOpen] = useState(false);
const [saving, setSaving] = useState(false);
const load = useCallback(async () => {
try {
const [ruleList, channelList, categoryList] = await Promise.all([
api.rules(),
api.channels(),
api.categories(),
]);
setRules(ruleList);
setChannels(channelList);
setCategories([...new Set(categoryList.map((entry) => entry.category))]);
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
} finally {
setLoading(false);
}
}, [api]);
useEffect(() => {
void load();
}, [load]);
const channelName = (id: number) =>
channels.find((channel) => channel.id === id)?.name ?? `#${id}`;
const openEditor = (rule: AlertRule | null) => {
setEditing(rule);
setDraft(
rule
? {
name: rule.name,
match: rule.match,
minSeverity: rule.minSeverity,
channelIds: rule.channelIds,
enabled: rule.enabled,
}
: EMPTY,
);
setDialogOpen(true);
};
const save = async () => {
if (!draft.name.trim()) {
toast.error(t("rules.nameRequired"));
return;
}
if (draft.channelIds.length === 0) {
toast.error(t("rules.channelRequired"));
return;
}
setSaving(true);
try {
if (editing) await api.updateRule(editing.id, draft);
else await api.createRule(draft);
setDialogOpen(false);
await load();
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
} finally {
setSaving(false);
}
};
const toggle = async (rule: AlertRule, enabled: boolean) => {
try {
await api.updateRule(rule.id, {
name: rule.name,
match: rule.match,
minSeverity: rule.minSeverity,
channelIds: rule.channelIds,
enabled,
});
setRules((current) =>
current.map((entry) =>
entry.id === rule.id ? { ...entry, enabled } : entry,
),
);
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
}
};
const remove = (rule: AlertRule) => {
void confirmWithToast(
t("rules.deleteConfirm", { name: rule.name }),
async () => {
try {
await api.deleteRule(rule.id);
await load();
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
}
},
t("actions.delete"),
t("actions.cancel"),
);
};
const toggleChannel = (id: number, checked: boolean) =>
setDraft((current) => ({
...current,
channelIds: checked
? [...new Set([...current.channelIds, id])]
: current.channelIds.filter((entry) => entry !== id),
}));
return (
<div className="flex flex-col gap-2 p-3">
<p className="text-xs text-muted-foreground">{t("rules.intro")}</p>
<Button
size="sm"
variant="outline"
className="rounded-none self-start border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
disabled={channels.length === 0}
title={channels.length === 0 ? t("rules.needChannel") : undefined}
onClick={() => openEditor(null)}
>
<Plus className="size-3.5 mr-1" />
{t("rules.add")}
</Button>
{loading ? (
<Loader2 className="size-4 animate-spin text-muted-foreground m-4" />
) : rules.length === 0 ? (
<p className="text-xs text-muted-foreground py-4">
{channels.length === 0 ? t("rules.needChannel") : t("rules.empty")}
</p>
) : (
<div className="flex flex-col border border-border">
{rules.map((rule) => (
<div
key={rule.id}
className="flex items-center gap-2 px-3 py-2 border-b border-border last:border-b-0"
>
<Switch
checked={rule.enabled}
onCheckedChange={(checked) => void toggle(rule, checked)}
aria-label={t("rules.enabled")}
/>
<div className="flex-1 min-w-0">
<div className="text-sm truncate">{rule.name}</div>
<div className="text-[11px] text-muted-foreground truncate">
{t("rules.summary", {
match:
rule.match === "*" ? t("rules.everything") : rule.match,
severity: t(`severity.${rule.minSeverity}`),
channels: rule.channelIds.map(channelName).join(", "),
})}
</div>
</div>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 rounded-none"
title={t("rules.edit")}
aria-label={t("rules.edit")}
onClick={() => openEditor(rule)}
>
<Pencil className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 rounded-none"
title={t("actions.delete")}
aria-label={t("actions.delete")}
onClick={() => remove(rule)}
>
<Trash2 className="size-3.5" />
</Button>
</div>
))}
</div>
)}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-w-md rounded-none">
<DialogHeader>
<DialogTitle>
{editing ? t("rules.edit") : t("rules.add")}
</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-3 py-2">
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("rules.name")}
</label>
<Input
value={draft.name}
onChange={(event) =>
setDraft({ ...draft, name: event.target.value })
}
className="text-sm rounded-none"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("rules.match")}
</label>
<Input
value={draft.match}
list="alerts-rule-categories"
onChange={(event) =>
setDraft({ ...draft, match: event.target.value })
}
placeholder="*"
className="text-sm font-mono rounded-none"
/>
<datalist id="alerts-rule-categories">
<option value="*" />
{categories.map((category) => (
<option key={category} value={category} />
))}
</datalist>
<span className="text-[10px] text-muted-foreground">
{t("rules.matchHint")}
</span>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("rules.minSeverity")}
</label>
<Select
value={draft.minSeverity}
onValueChange={(value) =>
setDraft({ ...draft, minSeverity: value as Severity })
}
>
<SelectTrigger className="h-8 rounded-none text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SEVERITIES.filter((value) => value !== "success").map(
(value) => (
<SelectItem key={value} value={value}>
{t(`severity.${value}`)}
</SelectItem>
),
)}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("rules.channels")}
</label>
<div className="flex flex-col gap-1.5 border border-border p-2 max-h-40 overflow-y-auto">
{channels.map((channel) => (
<label
key={channel.id}
className="flex items-center gap-2 text-sm cursor-pointer"
>
<Checkbox
checked={draft.channelIds.includes(channel.id)}
onCheckedChange={(checked) =>
toggleChannel(channel.id, checked === true)
}
/>
<span className="truncate">{channel.name}</span>
<span className="text-[10px] uppercase text-muted-foreground">
{t(`channels.types.${channel.type}`)}
</span>
</label>
))}
</div>
</div>
</div>
<DialogFooter>
<Button
variant="ghost"
className="rounded-none"
onClick={() => setDialogOpen(false)}
>
{t("actions.cancel")}
</Button>
<Button
variant="outline"
disabled={saving}
onClick={() => void save()}
className="rounded-none border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
>
{saving ? t("actions.saving") : t("actions.save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+123
View File
@@ -0,0 +1,123 @@
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
import type {
AlertItem,
AlertRule,
AlertsMeta,
ChannelDetail,
ChannelSummary,
ChannelType,
Severity,
} from "../types";
export interface ItemQuery {
unread?: boolean;
source?: string;
severity?: Severity;
limit?: number;
before?: number;
}
export interface ChannelInput {
name: string;
type: ChannelType;
config: Record<string, unknown>;
enabled?: boolean;
}
export type RuleInput = Omit<AlertRule, "id">;
function apiError(error: unknown, action: string): Error {
const data = (
error as { response?: { data?: { error?: string; message?: string } } }
)?.response?.data;
return new Error(
data?.error ||
data?.message ||
(error instanceof Error ? error.message : `Failed to ${action}`),
);
}
async function call<T>(
action: string,
request: () => Promise<{ data: T }>,
): Promise<T> {
try {
return (await request()).data;
} catch (error) {
throw apiError(error, action);
}
}
function query(params: ItemQuery): string {
const search = new URLSearchParams();
if (params.unread) search.set("unread", "true");
if (params.source) search.set("source", params.source);
if (params.severity) search.set("severity", params.severity);
if (params.limit) search.set("limit", String(params.limit));
if (params.before) search.set("before", String(params.before));
const text = search.toString();
return text ? `?${text}` : "";
}
export function createAlertsApi(api: PluginApiClient) {
return {
items: (params: ItemQuery = {}) =>
call<{ items: AlertItem[]; unread: number }>("load alerts", () =>
api.get(`/items${query(params)}`),
),
unread: () =>
call<{ count: number }>("count alerts", () => api.get("/unread")),
setRead: (ids: number[] | "all", read = true) =>
call<{ count: number }>("update alerts", () =>
api.post(
"/items/read",
ids === "all" ? { all: true, read } : { ids, read },
),
),
remove: (id: number) =>
call<{ success: boolean }>("delete alert", () =>
api.delete(`/items/${id}`),
),
clear: (onlyRead: boolean) =>
call<{ removed: number }>("clear alerts", () =>
api.delete(`/items${onlyRead ? "?read=true" : ""}`),
),
categories: () =>
call<Array<{ source: string; category: string }>>("load categories", () =>
api.get("/categories"),
),
meta: () => call<AlertsMeta>("load channel types", () => api.get("/meta")),
channels: () =>
call<ChannelSummary[]>("load channels", () => api.get("/channels")),
channel: (id: number) =>
call<ChannelDetail>("load channel", () => api.get(`/channels/${id}`)),
createChannel: (input: ChannelInput) =>
call<ChannelDetail>("create channel", () => api.post("/channels", input)),
updateChannel: (id: number, input: Partial<ChannelInput>) =>
call<ChannelDetail>("update channel", () =>
api.put(`/channels/${id}`, input),
),
deleteChannel: (id: number) =>
call<{ success: boolean }>("delete channel", () =>
api.delete(`/channels/${id}`),
),
testChannel: async (id: number) => {
const result = await call<{ success: boolean; error?: string }>(
"test channel",
() => api.post(`/channels/${id}/test`),
);
if (!result.success) throw new Error(result.error || "Test failed");
},
rules: () => call<AlertRule[]>("load rules", () => api.get("/rules")),
createRule: (input: RuleInput) =>
call<AlertRule>("create rule", () => api.post("/rules", input)),
updateRule: (id: number, input: RuleInput) =>
call<AlertRule>("update rule", () => api.put(`/rules/${id}`, input)),
deleteRule: (id: number) =>
call<{ success: boolean }>("delete rule", () =>
api.delete(`/rules/${id}`),
),
};
}
export type AlertsApi = ReturnType<typeof createAlertsApi>;
@@ -0,0 +1,101 @@
import type { ChannelDetail, ChannelType } from "../types";
export interface Draft {
name: string;
type: ChannelType;
url: string;
method: "POST" | "PUT";
headers: string;
topic: string;
token: string;
username: string;
avatarUrl: string;
to: string;
allowPrivateNetwork: boolean;
}
export const EMPTY: Draft = {
name: "",
type: "webhook",
url: "",
method: "POST",
headers: "",
topic: "",
token: "",
username: "",
avatarUrl: "",
to: "",
allowPrivateNetwork: false,
};
const str = (value: unknown) => (typeof value === "string" ? value : "");
export function draftFrom(channel: ChannelDetail): Draft {
const config = channel.config;
const headers =
config.headers && typeof config.headers === "object"
? Object.entries(config.headers as Record<string, unknown>)
.map(([key, value]) => `${key}: ${String(value)}`)
.join("\n")
: "";
return {
...EMPTY,
name: channel.name,
type: channel.type,
url: str(config.url) || (channel.type === "ntfy" ? "https://ntfy.sh" : ""),
method: config.method === "PUT" ? "PUT" : "POST",
headers,
topic: str(config.topic),
token: str(config.token),
username: str(config.username),
avatarUrl: str(config.avatar_url),
to: str(config.to),
allowPrivateNetwork: config.allowPrivateNetwork === true,
};
}
export function parseHeaders(text: string): Record<string, string> {
const headers: Record<string, string> = {};
for (const line of text.split(/\r?\n/)) {
const at = line.indexOf(":");
if (at <= 0) continue;
const key = line.slice(0, at).trim();
const value = line.slice(at + 1).trim();
if (key) headers[key] = value;
}
return headers;
}
export function configFrom(draft: Draft): Record<string, unknown> {
const privateNetwork = draft.allowPrivateNetwork
? { allowPrivateNetwork: true }
: {};
switch (draft.type) {
case "webhook": {
const headers = parseHeaders(draft.headers);
return {
url: draft.url.trim(),
method: draft.method,
...(Object.keys(headers).length ? { headers } : {}),
...privateNetwork,
};
}
case "ntfy":
return {
url: draft.url.trim(),
topic: draft.topic.trim(),
...(draft.token.trim() ? { token: draft.token.trim() } : {}),
...privateNetwork,
};
case "discord":
return {
url: draft.url.trim(),
...(draft.username.trim() ? { username: draft.username.trim() } : {}),
...(draft.avatarUrl.trim()
? { avatar_url: draft.avatarUrl.trim() }
: {}),
};
case "email":
return { to: draft.to.trim() };
}
}
+30
View File
@@ -0,0 +1,30 @@
import { ANNOUNCEMENT_SOURCE } from "../types";
export function timeAgo(iso: string, language: string): string {
// SQLite's CURRENT_TIMESTAMP has no zone; it is UTC.
const normalized = /[zZ]|[+-]\d\d:?\d\d$/.test(iso)
? iso
: `${iso.replace(" ", "T")}Z`;
const ms = Date.parse(normalized);
if (!Number.isFinite(ms)) return "";
const seconds = Math.round((ms - Date.now()) / 1000);
const format = new Intl.RelativeTimeFormat(language || "en", {
numeric: "auto",
});
const abs = Math.abs(seconds);
if (abs < 60) return format.format(seconds, "second");
if (abs < 3600) return format.format(Math.round(seconds / 60), "minute");
if (abs < 86400) return format.format(Math.round(seconds / 3600), "hour");
return format.format(Math.round(seconds / 86400), "day");
}
export function sourceLabel(
source: string,
t: (key: string, options?: Record<string, unknown>) => string,
): string {
if (source === ANNOUNCEMENT_SOURCE) return t("inbox.sourceTermix");
return source
.split("-")
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
+86
View File
@@ -0,0 +1,86 @@
import type { ComponentType } from "react";
import { Bell } from "lucide-react";
import type {
PanelProps,
TabProps,
TermixApp,
} from "@termix/plugin-sdk/frontend";
import { AlertToaster } from "./AlertToaster";
import { AlertsView } from "./AlertsView";
import { createSectionRequests } from "./sections";
import { InboxView } from "./InboxView";
import { createAlertsStore, useUnreadCount } from "./store";
const VIEW_ID = "alerts";
export function activate(app: TermixApp): void {
const store = createAlertsStore({
connect: (init) => app.fetch("/stream", init),
loadUnread: async () =>
(await app.api.get<{ count: number }>("/unread")).data.count,
});
const sections = createSectionRequests();
if (!app.guest) {
void app.hasPermission("use").then((allowed) => {
if (allowed) store.start();
});
}
app.onDispose(() => store.stop());
const openTab = () => app.tabs.openSingletonTab(VIEW_ID);
function Panel({ active }: PanelProps) {
if (!active) return null;
return <InboxView store={store} compact onOpenAll={openTab} />;
}
function AlertsTab() {
return <AlertsView store={store} sections={sections} />;
}
function Toaster() {
return <AlertToaster store={store} viewId={VIEW_ID} />;
}
app.registerRailItem({
id: VIEW_ID,
icon: Bell,
titleKey: "nav.alerts",
placement: "footer",
simplePreset: true,
promotable: true,
rightDockable: true,
separatorAfter: false,
permission: "use",
useBadge: () => useUnreadCount(store),
});
app.registerPanel(VIEW_ID, Panel);
app.registerTab(VIEW_ID, AlertsTab as ComponentType<TabProps>, {
icon: Bell,
titleKey: "nav.alerts",
singleton: true,
hostless: true,
panelFrame: true,
});
app.registerSlotContribution("shell.overlay", {
actionId: "alerts.toaster",
titleKey: "nav.alerts",
kind: "component",
component: Toaster as ComponentType<Record<string, unknown>>,
});
app.registerAction("alerts.open", () => openTab());
app.registerAction("alerts.openChannels", () => {
sections.request("channels");
openTab();
});
app.registerSlotContribution("onboarding.features", {
actionId: "alerts.feature",
titleKey: "onboarding.feature",
descriptionKey: "onboarding.featureDesc",
icon: Bell as ComponentType<{ className?: string }>,
});
}
+9
View File
@@ -0,0 +1,9 @@
import { severityRank, type AlertItem } from "../types";
export type PopupLevel = "all" | "warning" | "critical" | "off";
export function shouldPopUp(item: AlertItem, level: PopupLevel): boolean {
if (level === "off") return false;
if (level === "all") return true;
return severityRank(item.severity) >= severityRank(level);
}
+26
View File
@@ -0,0 +1,26 @@
export type Section = "inbox" | "channels" | "rules";
/** Lets an action open the tab on a given section. */
export function createSectionRequests() {
let pending: Section | null = null;
const listeners = new Set<(section: Section) => void>();
return {
request(section: Section) {
pending = section;
for (const listener of listeners) listener(section);
},
take(): Section | null {
const section = pending;
pending = null;
return section;
},
subscribe(listener: (section: Section) => void): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
};
}
export type SectionRequests = ReturnType<typeof createSectionRequests>;
+177
View File
@@ -0,0 +1,177 @@
import { useSyncExternalStore } from "react";
import type { AlertItem } from "../types";
interface ServerSentEvent {
event: string;
data: string;
}
export function parseServerSentEvents(
buffer: string,
onEvent: (event: ServerSentEvent) => void,
): string {
const blocks = buffer.split(/\r?\n\r?\n/);
const rest = blocks.pop() ?? "";
for (const block of blocks) {
let event = "message";
const data: string[] = [];
for (const line of block.split(/\r?\n/)) {
if (line.startsWith(":")) continue;
if (line.startsWith("event:")) event = line.slice(6).trim();
else if (line.startsWith("data:")) data.push(line.slice(5).trimStart());
}
if (data.length > 0) onEvent({ event, data: data.join("\n") });
}
return rest;
}
export interface AlertsStoreDeps {
/** Opens the event stream; app.fetch carries core's auth. */
connect: (init: RequestInit) => Promise<Response>;
/** The unread count, for when the stream is down. */
loadUnread: () => Promise<number>;
retryMs?: number;
pollMs?: number;
}
/**
* The live side of the inbox: the unread count every badge shows, and every
* new alert as it arrives, from one stream shared by the whole app.
*/
export function createAlertsStore(deps: AlertsStoreDeps) {
const retryMs = deps.retryMs ?? 5_000;
const pollMs = deps.pollMs ?? 60_000;
let unread = 0;
let version = 0;
const seen = new Set<number>();
const listeners = new Set<() => void>();
const itemListeners = new Set<(item: AlertItem, isNew: boolean) => void>();
let controller: AbortController | null = null;
const emit = () => {
for (const listener of listeners) listener();
};
const setUnread = (count: number) => {
if (count === unread) return;
unread = Math.max(0, count);
emit();
};
const onItem = (item: AlertItem) => {
const isNew = !seen.has(item.id);
seen.add(item.id);
version++;
emit();
for (const listener of itemListeners) listener(item, isNew);
};
const wait = (ms: number, signal: AbortSignal) =>
new Promise<void>((resolve) => {
const timer = setTimeout(resolve, ms);
signal.addEventListener(
"abort",
() => {
clearTimeout(timer);
resolve();
},
{ once: true },
);
});
async function run(signal: AbortSignal) {
while (!signal.aborted) {
try {
const response = await deps.connect({
headers: { Accept: "text/event-stream" },
signal,
});
if (!response.ok || !response.body) throw new Error("stream failed");
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (!signal.aborted) {
const { value, done } = await reader.read();
if (done) break;
buffer = parseServerSentEvents(
buffer + decoder.decode(value, { stream: true }),
(event) => {
try {
const data = JSON.parse(event.data);
if (event.event === "unread") setUnread(Number(data.count));
else if (event.event === "item") onItem(data as AlertItem);
} catch {
// A malformed event is skipped; the next one carries the count.
}
},
);
}
} catch {
// Falls through to a poll and a retry.
}
if (signal.aborted) return;
try {
setUnread(await deps.loadUnread());
} catch {
// Still offline.
}
await wait(retryMs, signal);
}
}
return {
start(): void {
if (controller) return;
controller = new AbortController();
const { signal } = controller;
void run(signal);
// A proxy that buffers the stream would hold the count back forever.
void (async () => {
while (!signal.aborted) {
await wait(pollMs, signal);
if (signal.aborted) return;
try {
setUnread(await deps.loadUnread());
} catch {
// Offline; the stream loop retries.
}
}
})();
},
stop(): void {
controller?.abort();
controller = null;
},
subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
/** Every alert that arrives while the app is open, and whether it is new. */
onItem(listener: (item: AlertItem, isNew: boolean) => void): () => void {
itemListeners.add(listener);
return () => {
itemListeners.delete(listener);
};
},
unread: () => unread,
/** Bumps whenever an alert arrives, so open lists know to reload. */
version: () => version,
setUnread,
};
}
export type AlertsStore = ReturnType<typeof createAlertsStore>;
export function useUnreadCount(store: AlertsStore): number {
return useSyncExternalStore(store.subscribe, store.unread, store.unread);
}
export function useAlertsVersion(store: AlertsStore): number {
return useSyncExternalStore(store.subscribe, store.version, store.version);
}
+91
View File
@@ -0,0 +1,91 @@
export const CHANNEL_TYPES = ["webhook", "ntfy", "discord", "email"] as const;
export type ChannelType = (typeof CHANNEL_TYPES)[number];
export const SEVERITIES = ["info", "success", "warning", "critical"] as const;
export type Severity = (typeof SEVERITIES)[number];
/** Where an announcement from the Termix docs comes from in the inbox. */
export const ANNOUNCEMENT_SOURCE = "termix";
export interface AlertLink {
tab?: string;
url?: string;
}
export interface DeliveryResult {
channelId: number;
name: string;
ok: boolean;
error?: string;
}
export interface AlertItem {
id: number;
source: string;
category: string;
severity: Severity;
title: string;
body: string | null;
link: AlertLink | null;
context: Record<string, unknown> | null;
deliveries: DeliveryResult[] | null;
readAt: string | null;
createdAt: string;
}
export interface ChannelSummary {
id: number;
name: string;
type: ChannelType;
enabled: boolean;
createdAt: string;
/** False when the config could not be read, e.g. still waiting on its upgrade. */
usable: boolean;
}
export interface ChannelDetail extends ChannelSummary {
config: Record<string, unknown>;
}
export interface AlertRule {
id: number;
name: string;
match: string;
minSeverity: Severity;
channelIds: number[];
enabled: boolean;
}
export interface AlertsMeta {
emailAvailable: boolean;
channelTypes: ChannelType[];
}
const RANK: Record<Severity, number> = {
info: 0,
success: 0,
warning: 1,
critical: 2,
};
export function isSeverity(value: unknown): value is Severity {
return (
typeof value === "string" &&
(SEVERITIES as readonly string[]).includes(value)
);
}
export function severityRank(severity: Severity): number {
return RANK[severity];
}
/** Whether a category pattern ("*", "automations.*", "acme-ssl.renewal_failed") covers a category. */
export function matchesCategory(pattern: string, category: string): boolean {
const trimmed = pattern.trim();
if (!trimmed || trimmed === "*") return true;
const escaped = trimmed
.split("*")
.map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&"))
.join(".*");
return new RegExp(`^${escaped}$`, "i").test(category);
}
+142
View File
@@ -0,0 +1,142 @@
import http from "node:http";
import type { AddressInfo } from "node:net";
import { fileURLToPath } from "node:url";
import express, { type Router } from "express";
import {
createMockCtx,
createTestDb,
type MockPluginContext,
type TestDb,
} from "@termix/plugin-sdk/testing";
import type {
PluginFetchInit,
PluginNotifyHub,
} from "@termix/plugin-sdk/backend";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import manifestJson from "../../manifest.json";
export const pluginDir = fileURLToPath(new URL("../..", import.meta.url));
export const manifest = manifestJson as unknown as PluginManifest;
export interface FetchCall {
url: string;
init?: PluginFetchInit;
}
export interface TestServer {
db: TestDb;
mock: MockPluginContext;
hub: PluginNotifyHub;
fetches: FetchCall[];
/** Where the router is served, for a raw fetch such as the stream. */
url: string;
request: (
method: string,
path: string,
options?: { user?: string; body?: unknown },
// Route bodies vary per test; asserting on them is the point.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) => Promise<{ status: number; body: any }>;
close: () => Promise<void>;
}
/**
* The plugin activated against a real in-memory database, its router mounted
* the way core mounts it, with every outbound request answered by `respond`.
*/
export async function startServer(
options: {
permissions?: string[];
settings?: Record<string, unknown>;
coreSettings?: Record<string, string>;
respond?: (url: string, init?: PluginFetchInit) => Response;
} = {},
): Promise<TestServer> {
const db = await createTestDb(pluginDir);
for (const user of ["alice", "bob"]) {
db.sqlite
.prepare("INSERT INTO users (id, username) VALUES (?, ?)")
.run(user, user);
}
const fetches: FetchCall[] = [];
let router: Router | null = null;
const mock = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: manifest.capabilities,
db: db.database,
router: () => (router = express.Router()),
permissions: options.permissions ?? ["alerts.use"],
settings: options.settings,
coreSettings: options.coreSettings,
fetch: async (url, init) => {
fetches.push({ url, init });
return options.respond?.(url, init) ?? new Response("ok");
},
});
const { activate } = await import("../../src/backend/index.js");
await activate(mock.ctx);
const hub = mock.notifyHub.current;
if (!hub) throw new Error("The plugin did not serve a hub");
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
mock.setActor(req.header("x-test-user") || undefined);
next();
});
app.use((req, res, next) => router!(req, res, next));
const server = http.createServer(app);
await new Promise<void>((resolve) => server.listen(0, resolve));
const { port } = server.address() as AddressInfo;
return {
db,
mock,
hub,
fetches,
url: `http://127.0.0.1:${port}`,
async request(method, path, { user = "alice", body: given } = {}) {
const body = method === "GET" ? undefined : given;
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
method,
headers: {
"x-test-user": user,
...(body !== undefined ? { "content-type": "application/json" } : {}),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await response.text();
return { status: response.status, body: text ? JSON.parse(text) : null };
},
async close() {
await new Promise<void>((resolve) => server.close(() => resolve()));
for (const dispose of [...mock.disposals].reverse()) {
await Promise.resolve(dispose()).catch(() => {});
}
db.close();
},
};
}
/** A channel for alice through the route, returning its id. */
export async function addChannel(
server: TestServer,
body: Record<string, unknown>,
user = "alice",
): Promise<number> {
const response = await server.request("POST", "/channels", { user, body });
if (response.status !== 201) {
throw new Error(`channel not created: ${JSON.stringify(response.body)}`);
}
return response.body.id;
}
export const WEBHOOK = {
name: "ops",
type: "webhook",
config: { url: "https://hooks.example/alert" },
};
+328
View File
@@ -0,0 +1,328 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { addChannel, startServer, WEBHOOK, type TestServer } from "./helpers";
const mail = vi.hoisted(() => ({ sent: [] as unknown[] }));
vi.mock("../../src/backend/mail.js", async () => {
const actual = await vi.importActual<
typeof import("../../src/backend/mail.js")
>("../../src/backend/mail.js");
return {
...actual,
sendMail: async (smtp: unknown, message: unknown) => {
mail.sent.push({ smtp, message });
},
};
});
let server: TestServer | null = null;
afterEach(async () => {
await server?.close();
server = null;
mail.sent.length = 0;
});
const inbox = async (s: TestServer, user = "alice") =>
(await s.request("GET", "/items", { user })).body;
describe("the alert hub", () => {
it("puts an alert in each recipient's inbox with safe defaults", async () => {
server = await startServer();
const result = await server.hub.deliver({
source: "docker",
recipients: ["alice", "bob", "alice"],
notification: {
title: "Container stopped",
body: "web exited",
link: { tab: "docker", url: "javascript:alert(1)" },
},
});
expect(result).toEqual({ recipients: 2, delivered: 0, failures: [] });
for (const user of ["alice", "bob"]) {
const { items, unread } = await inbox(server, user);
expect(unread).toBe(1);
expect(items).toEqual([
expect.objectContaining({
source: "docker",
category: "docker",
severity: "warning",
title: "Container stopped",
body: "web exited",
link: { tab: "docker" },
readAt: null,
}),
]);
}
});
it("drops a repeat while the first is still unread", async () => {
server = await startServer();
const send = () =>
server!.hub.deliver({
source: "acme-ssl",
recipients: ["alice"],
notification: { title: "Renewal failed", dedupeKey: "renew:a" },
});
expect((await send()).recipients).toBe(1);
expect((await send()).recipients).toBe(0);
expect((await inbox(server)).items).toHaveLength(1);
await server.request("POST", "/items/read", { body: { all: true } });
expect((await send()).recipients).toBe(1);
expect((await inbox(server)).items).toHaveLength(2);
});
it("sends to the channels a matching rule names, and records how it went", async () => {
server = await startServer({
respond: (url) =>
url.includes("broken")
? new Response("nope", { status: 500, statusText: "Server Error" })
: new Response("ok"),
});
const ops = await addChannel(server, WEBHOOK);
const broken = await addChannel(server, {
...WEBHOOK,
name: "broken",
config: { url: "https://broken.example/hook" },
});
const quiet = await addChannel(server, {
...WEBHOOK,
name: "quiet",
config: { url: "https://quiet.example/hook" },
});
await server.request("POST", "/rules", {
body: {
name: "Docker",
match: "docker.*",
minSeverity: "warning",
channelIds: [ops, broken],
},
});
await server.request("POST", "/rules", {
body: {
name: "Only critical",
match: "*",
minSeverity: "critical",
channelIds: [quiet],
},
});
const result = await server.hub.deliver({
source: "docker",
recipients: ["alice"],
notification: {
title: "Container stopped",
category: "docker.container_stopped",
severity: "warning",
},
});
expect(result.delivered).toBe(1);
expect(result.failures).toEqual([
{
channelId: broken,
name: "broken",
error: "HTTP 500 Server Error: nope",
},
]);
expect(server.fetches.map((call) => call.url)).toEqual([
"https://hooks.example/alert",
"https://broken.example/hook",
]);
const body = JSON.parse(server.fetches[0].init!.body as string);
expect(body).toMatchObject({
title: "Container stopped",
severity: "warning",
category: "docker.container_stopped",
source: "docker",
});
const [item] = (await inbox(server)).items;
expect(item.deliveries).toEqual([
{ channelId: ops, name: "ops", ok: true },
{
channelId: broken,
name: "broken",
ok: false,
error: "HTTP 500 Server Error: nope",
},
]);
});
it("adds the channels a sender names, but only the recipient's own", async () => {
server = await startServer();
const mine = await addChannel(server, WEBHOOK);
const theirs = await addChannel(
server,
{ ...WEBHOOK, config: { url: "https://bob.example/hook" } },
"bob",
);
const result = await server.hub.deliver({
source: "automations",
recipients: ["alice"],
notification: { title: "Ping", channelIds: [mine, theirs] },
});
expect(result.delivered).toBe(1);
expect(server.fetches.map((call) => call.url)).toEqual([
"https://hooks.example/alert",
]);
});
it("skips a disabled channel", async () => {
server = await startServer();
const id = await addChannel(server, WEBHOOK);
await server.request("PUT", `/channels/${id}`, {
body: { enabled: false },
});
const result = await server.hub.deliver({
source: "automations",
recipients: ["alice"],
notification: { title: "Ping", channelIds: [id] },
});
expect(result.delivered).toBe(0);
expect(server.fetches).toEqual([]);
});
it("reaches a private address only with the opt-in and the admin allowlist", async () => {
server = await startServer({
coreSettings: {
notification_private_endpoint_allowlist: JSON.stringify(["NTFY.LAN"]),
},
});
const open = await addChannel(server, {
name: "lan",
type: "ntfy",
config: {
url: "http://ntfy.lan",
topic: "alerts",
allowPrivateNetwork: true,
},
});
const closed = await addChannel(server, {
name: "lan2",
type: "ntfy",
config: { url: "http://ntfy.lan", topic: "other" },
});
await server.hub.deliver({
source: "automations",
recipients: ["alice"],
notification: { title: "Ping", channelIds: [open, closed] },
});
expect(server.fetches.map((call) => call.init?.allowPrivateHosts)).toEqual([
["ntfy.lan"],
[],
]);
});
it("emails through the admin's SMTP server", async () => {
server = await startServer({
settings: {
smtpHost: "smtp.example",
smtpPort: 465,
smtpSecure: true,
smtpUser: "termix",
smtpPassword: "pw",
smtpFrom: "Termix <alerts@example.com>",
},
});
const id = await addChannel(server, {
name: "mail",
type: "email",
config: { to: "ops@example.com, oncall@example.com" },
});
const result = await server.hub.deliver({
source: "automations",
recipients: ["alice"],
notification: {
title: "Disk full",
body: "/data at 99%",
channelIds: [id],
link: { url: "https://termix.example/hosts/1" },
},
});
expect(result.delivered).toBe(1);
expect(mail.sent).toEqual([
{
smtp: {
host: "smtp.example",
port: 465,
secure: true,
user: "termix",
password: "pw",
from: "Termix <alerts@example.com>",
},
message: {
to: ["ops@example.com", "oncall@example.com"],
subject: "[Termix] Disk full",
text: "/data at 99%\n\nhttps://termix.example/hosts/1",
},
},
]);
});
it("reports an email channel as failed while SMTP is not set up", async () => {
server = await startServer({
settings: {
smtpHost: "smtp.example",
smtpFrom: "alerts@example.com",
},
});
const id = await addChannel(server, {
name: "mail",
type: "email",
config: { to: "ops@example.com" },
});
await server.mock.ctx.settings.set("smtpHost", "");
const result = await server.hub.deliver({
source: "automations",
recipients: ["alice"],
notification: { title: "Disk full", channelIds: [id] },
});
expect(result.failures).toEqual([
{
channelId: id,
name: "mail",
error: "Email is not set up on this server",
},
]);
});
it("lists a user's channels without their config", async () => {
server = await startServer();
const id = await addChannel(server, WEBHOOK);
expect(await server.hub.channels("alice")).toEqual([
{ id, name: "ops", type: "webhook", enabled: true },
]);
expect(await server.hub.channels("bob")).toEqual([]);
});
it("wipes everything a user had when their data is wiped", async () => {
server = await startServer();
await addChannel(server, WEBHOOK);
await server.hub.deliver({
source: "docker",
recipients: ["alice", "bob"],
notification: { title: "x" },
});
server.mock.ctx.events.emit("user.data_wiped", { userId: "alice" });
await vi.waitFor(async () =>
expect((await inbox(server!)).items).toEqual([]),
);
expect(await server.hub.channels("alice")).toEqual([]);
expect((await inbox(server, "bob")).items).toHaveLength(1);
});
});
@@ -0,0 +1,87 @@
import { afterEach, describe, expect, it } from "vitest";
import { createTestDb, type TestDb } from "@termix/plugin-sdk/testing";
import { pluginDir } from "./helpers";
// dismissed_alerts as core's SQLite bootstrap created it before 2.9.0, with
// the index performance-indexes.ts added.
const LEGACY_DDL = `
CREATE TABLE dismissed_alerts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
alert_id TEXT NOT NULL,
dismissed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
CREATE INDEX idx_dismissed_alerts_user_id ON dismissed_alerts(user_id);
INSERT INTO users (id, username) VALUES ('alice', 'alice');
INSERT INTO dismissed_alerts (id, user_id, alert_id) VALUES (3, 'alice', 'old-news');
`;
const OWN = ["p_alerts_channels", "p_alerts_items", "p_alerts_rules"];
let db: TestDb | null = null;
afterEach(() => {
db?.close();
db = null;
});
function tables(target: TestDb): string[] {
return (
target.sqlite
.prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
.all() as Array<{ name: string }>
).map((row) => row.name);
}
function indexes(target: TestDb, table: string): string[] {
return (
target.sqlite
.prepare(
"SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = ? AND name NOT LIKE 'sqlite_%'",
)
.all(table) as Array<{ name: string }>
)
.map((row) => row.name)
.sort();
}
describe("the alerts tables", () => {
it("adopts dismissed_alerts with its rows and its one index", async () => {
db = await createTestDb(pluginDir, {
before: (sqlite) => sqlite.exec(LEGACY_DDL),
});
const names = tables(db);
expect(names).toContain("p_alerts_dismissed");
expect(names).not.toContain("dismissed_alerts");
for (const table of OWN) expect(names).toContain(table);
expect(
db.sqlite
.prepare("SELECT id, user_id, alert_id FROM p_alerts_dismissed")
.all(),
).toEqual([{ id: 3, user_id: "alice", alert_id: "old-news" }]);
expect(indexes(db, "p_alerts_dismissed")).toEqual([
"idx_dismissed_alerts_user_id",
]);
// Deleting the user still takes their rows with them.
db.sqlite.prepare("DELETE FROM users WHERE id = 'alice'").run();
expect(
db.sqlite.prepare("SELECT COUNT(*) AS n FROM p_alerts_dismissed").get(),
).toEqual({ n: 0 });
});
it("creates the same tables on a fresh install", async () => {
db = await createTestDb(pluginDir);
const names = tables(db);
for (const table of [...OWN, "p_alerts_dismissed"]) {
expect(names).toContain(table);
}
expect(indexes(db, "p_alerts_items")).toEqual([
"idx_alerts_items_dedupe",
"idx_alerts_items_unread",
"idx_alerts_items_user",
]);
});
});
+434
View File
@@ -0,0 +1,434 @@
import { afterEach, describe, expect, it } from "vitest";
import { FEED_URL } from "../../src/backend/announcements.js";
import { addChannel, startServer, WEBHOOK, type TestServer } from "./helpers";
let server: TestServer | null = null;
afterEach(async () => {
await server?.close();
server = null;
});
describe("permissions", () => {
it("refuses a user without alerts.use", async () => {
server = await startServer({ permissions: [] });
for (const [method, path] of [
["GET", "/items"],
["GET", "/channels"],
["POST", "/channels"],
["GET", "/rules"],
]) {
expect((await server.request(method, path, { body: {} })).status).toBe(
403,
);
}
});
});
describe("channels", () => {
it("creates, reads back, updates and deletes a channel", async () => {
server = await startServer();
const id = await addChannel(server, {
name: "pager",
type: "ntfy",
config: { url: "https://ntfy.sh", topic: "ops", token: "tk_1" },
});
const list = await server.request("GET", "/channels");
expect(list.body).toEqual([
expect.objectContaining({
id,
name: "pager",
type: "ntfy",
enabled: true,
usable: true,
}),
]);
expect(list.body[0].config).toBeUndefined();
const detail = await server.request("GET", `/channels/${id}`);
expect(detail.body.config).toEqual({
url: "https://ntfy.sh",
topic: "ops",
token: "tk_1",
});
// Stored sealed, never as plain JSON.
const stored = server.db.sqlite
.prepare("SELECT config FROM p_alerts_channels WHERE id = ?")
.get(id) as { config: string };
expect(stored.config).not.toContain("tk_1");
const renamed = await server.request("PUT", `/channels/${id}`, {
body: { name: "night pager" },
});
expect(renamed.body).toMatchObject({ name: "night pager", type: "ntfy" });
expect(renamed.body.config.token).toBe("tk_1");
expect((await server.request("DELETE", `/channels/${id}`)).status).toBe(
200,
);
expect((await server.request("GET", "/channels")).body).toEqual([]);
});
it("refuses a config that cannot send", async () => {
server = await startServer();
for (const body of [
{ name: "", type: "webhook", config: { url: "https://a.example" } },
{ name: "x", type: "sms", config: {} },
{ name: "x", type: "webhook", config: { url: "ftp://a.example" } },
{ name: "x", type: "ntfy", config: { url: "https://ntfy.sh" } },
{
name: "x",
type: "discord",
config: { url: "https://example.com/api/webhooks/1" },
},
{ name: "x", type: "email", config: { to: "not an address" } },
]) {
const response = await server.request("POST", "/channels", { body });
expect(response.status, JSON.stringify(body)).toBe(400);
}
});
it("keeps each user's channels to themselves", async () => {
server = await startServer();
const id = await addChannel(server, WEBHOOK);
for (const [method, path] of [
["GET", `/channels/${id}`],
["PUT", `/channels/${id}`],
["DELETE", `/channels/${id}`],
["POST", `/channels/${id}/test`],
]) {
const response = await server.request(method, path, {
user: "bob",
body: { name: "mine" },
});
expect(response.status, `${method} ${path}`).toBe(404);
}
expect(
(await server.request("GET", "/channels", { user: "bob" })).body,
).toEqual([]);
});
it("sends a test and reports why one failed", async () => {
server = await startServer({
respond: (url) =>
url.includes("down")
? new Response("", { status: 502, statusText: "Bad Gateway" })
: new Response("ok"),
});
const good = await addChannel(server, WEBHOOK);
const bad = await addChannel(server, {
...WEBHOOK,
config: { url: "https://down.example" },
});
expect(
(await server.request("POST", `/channels/${good}/test`)).body,
).toEqual({ success: true });
expect(
(await server.request("POST", `/channels/${bad}/test`)).body,
).toEqual({ success: false, error: "HTTP 502 Bad Gateway" });
});
it("says email is unavailable until SMTP is set up", async () => {
server = await startServer();
expect((await server.request("GET", "/meta")).body).toEqual({
emailAvailable: false,
channelTypes: ["webhook", "ntfy", "discord", "email"],
});
await server.mock.ctx.settings.set("smtpHost", "smtp.example");
await server.mock.ctx.settings.set("smtpFrom", "alerts@example.com");
expect((await server.request("GET", "/meta")).body.emailAvailable).toBe(
true,
);
});
});
describe("inbox", () => {
async function seed(s: TestServer) {
for (const title of ["one", "two", "three"]) {
await s.hub.deliver({
source: "docker",
recipients: ["alice"],
notification: {
title,
severity: title === "two" ? "critical" : "info",
},
});
}
await s.hub.deliver({
source: "docker",
recipients: ["bob"],
notification: { title: "bob only" },
});
}
it("lists newest first, filters and pages", async () => {
server = await startServer();
await seed(server);
const all = await server.request("GET", "/items");
expect(all.body.unread).toBe(3);
expect(all.body.items.map((item: { title: string }) => item.title)).toEqual(
["three", "two", "one"],
);
const critical = await server.request("GET", "/items?severity=critical");
expect(
critical.body.items.map((item: { title: string }) => item.title),
).toEqual(["two"]);
const page = await server.request(
"GET",
`/items?limit=1&before=${all.body.items[0].id}`,
);
expect(
page.body.items.map((item: { title: string }) => item.title),
).toEqual(["two"]);
});
it("marks read and unread, and counts what is left", async () => {
server = await startServer();
await seed(server);
const { items } = (await server.request("GET", "/items")).body;
const read = await server.request("POST", "/items/read", {
body: { ids: [items[0].id] },
});
expect(read.body).toEqual({ count: 2 });
const unread = await server.request("GET", "/items?unread=true");
expect(unread.body.items).toHaveLength(2);
await server.request("POST", "/items/read", {
body: { ids: [items[0].id], read: false },
});
expect((await server.request("GET", "/unread")).body).toEqual({
count: 3,
});
await server.request("POST", "/items/read", { body: { all: true } });
expect((await server.request("GET", "/unread")).body).toEqual({
count: 0,
});
// Bob's alert is his own.
expect(
(await server.request("GET", "/unread", { user: "bob" })).body,
).toEqual({ count: 1 });
});
it("deletes one alert, then every read one", async () => {
server = await startServer();
await seed(server);
const { items } = (await server.request("GET", "/items")).body;
expect(
(await server.request("DELETE", `/items/${items[0].id}`, { user: "bob" }))
.status,
).toBe(404);
await server.request("DELETE", `/items/${items[0].id}`);
await server.request("POST", "/items/read", {
body: { ids: [items[1].id] },
});
expect((await server.request("DELETE", "/items?read=true")).body).toEqual({
removed: 1,
});
expect(
(await server.request("GET", "/items")).body.items.map(
(item: { title: string }) => item.title,
),
).toEqual(["one"]);
});
it("lists the categories a user has alerts from", async () => {
server = await startServer();
await server.hub.deliver({
source: "acme-ssl",
recipients: ["alice"],
notification: { title: "x", category: "acme-ssl.renewal_failed" },
});
expect((await server.request("GET", "/categories")).body).toEqual([
{ source: "acme-ssl", category: "acme-ssl.renewal_failed" },
]);
});
});
describe("rules", () => {
it("creates, updates and deletes a rule over the user's own channels", async () => {
server = await startServer();
const mine = await addChannel(server, WEBHOOK);
const theirs = await addChannel(server, WEBHOOK, "bob");
expect(
(
await server.request("POST", "/rules", {
body: { name: "All", channelIds: [theirs] },
})
).status,
).toBe(400);
expect(
(
await server.request("POST", "/rules", {
body: { name: "All", channelIds: [] },
})
).status,
).toBe(400);
const created = await server.request("POST", "/rules", {
body: { name: "All", channelIds: [mine] },
});
expect(created.status).toBe(201);
expect(created.body).toMatchObject({
name: "All",
match: "*",
minSeverity: "warning",
channelIds: [mine],
enabled: true,
});
await server.request("PUT", `/rules/${created.body.id}`, {
body: {
name: "Docker",
match: "docker.*",
minSeverity: "critical",
channelIds: [mine],
enabled: false,
},
});
expect((await server.request("GET", "/rules")).body).toEqual([
{
id: created.body.id,
name: "Docker",
match: "docker.*",
minSeverity: "critical",
channelIds: [mine],
enabled: false,
},
]);
expect(
(
await server.request("DELETE", `/rules/${created.body.id}`, {
user: "bob",
})
).status,
).toBe(404);
await server.request("DELETE", `/rules/${created.body.id}`);
expect((await server.request("GET", "/rules")).body).toEqual([]);
});
it("drops a deleted channel from the rules that named it", async () => {
server = await startServer();
const a = await addChannel(server, WEBHOOK);
const b = await addChannel(server, { ...WEBHOOK, name: "b" });
await server.request("POST", "/rules", {
body: { name: "Both", channelIds: [a, b] },
});
await server.request("DELETE", `/channels/${a}`);
expect((await server.request("GET", "/rules")).body[0].channelIds).toEqual([
b,
]);
});
});
describe("announcements", () => {
const future = new Date(Date.now() + 86_400_000).toISOString();
const past = new Date(Date.now() - 86_400_000).toISOString();
const feed = [
{
id: "release-2-9",
title: "Termix 2.9 is out",
message: "Plugins are here.",
expiresAt: future,
type: "success",
actionUrl: "https://termix.site/blog",
actionText: "Read more",
},
{
id: "old",
title: "Gone",
message: "Expired",
expiresAt: past,
},
];
const respond = (url: string) =>
url === FEED_URL ? Response.json(feed) : new Response("ok");
it("brings each live announcement into the inbox once", async () => {
server = await startServer({ respond });
const first = await server.request("GET", "/items");
expect(first.body.items).toEqual([
expect.objectContaining({
source: "termix",
category: "termix.announcement",
severity: "success",
title: "Termix 2.9 is out",
body: "Plugins are here.",
link: { url: "https://termix.site/blog" },
context: { announcementId: "release-2-9", actionText: "Read more" },
}),
]);
await server.request("POST", "/items/read", { body: { all: true } });
const again = await server.request("GET", "/items");
expect(again.body.items).toHaveLength(1);
expect(again.body.unread).toBe(0);
});
it("never brings back one the user deleted", async () => {
server = await startServer({ respond });
const { items } = (await server.request("GET", "/items")).body;
await server.request("DELETE", `/items/${items[0].id}`);
expect((await server.request("GET", "/items")).body.items).toEqual([]);
expect(
server.db.sqlite.prepare("SELECT alert_id FROM p_alerts_dismissed").all(),
).toEqual([{ alert_id: "release-2-9" }]);
});
it("shows none while an admin has them turned off", async () => {
server = await startServer({ respond, settings: { announcements: false } });
expect((await server.request("GET", "/items")).body.items).toEqual([]);
expect(server.fetches).toEqual([]);
});
});
describe("the event stream", () => {
it("sends the unread count, then each new alert", async () => {
server = await startServer();
const controller = new AbortController();
const response = await fetch(`${server.url}/stream`, {
headers: { "x-test-user": "alice" },
signal: controller.signal,
});
expect(response.headers.get("content-type")).toContain("text/event-stream");
const reader = response.body!.getReader();
const decoder = new TextDecoder();
let text = "";
const readUntil = async (needle: string) => {
while (!text.includes(needle)) {
const { value, done } = await reader.read();
if (done) break;
text += decoder.decode(value, { stream: true });
}
};
await readUntil('"count":0');
expect(text).toContain("event: unread");
await server.hub.deliver({
source: "docker",
recipients: ["alice"],
notification: { title: "Live one" },
});
await readUntil("Live one");
await readUntil('"count":1');
expect(text).toContain("event: item");
controller.abort();
});
});
@@ -0,0 +1,213 @@
import { describe, expect, it, vi } from "vitest";
import type { PluginFetchInit } from "@termix/plugin-sdk/backend";
import {
parseRecipients,
sendToChannel,
validateChannelConfig,
type OutgoingAlert,
type SenderDeps,
} from "../../src/backend/senders.js";
import { matchesCategory, severityRank } from "../../src/types.js";
import { parseAllowlist } from "../../src/backend/index.js";
import { cleanLink } from "../../src/backend/hub.js";
const ALERT: OutgoingAlert = {
title: "Disk full",
body: "/data at 99%",
severity: "critical",
category: "automations.notify",
source: "automations",
link: { url: "https://termix.example/hosts/1" },
};
function deps(response = new Response("ok")) {
const calls: Array<{ url: string; init?: PluginFetchInit }> = [];
const value: SenderDeps = {
fetch: async (url, init) => {
calls.push({ url, init });
return response;
},
privateAllowlist: async () => ["ntfy.lan"],
smtp: async () => null,
sendMail: vi.fn(),
};
return { deps: value, calls };
}
describe("sendToChannel", () => {
it("posts ntfy with its priority, tag, click link and token", async () => {
const { deps: d, calls } = deps();
await sendToChannel(
{
type: "ntfy",
config: { url: "https://ntfy.sh/", topic: "ops", token: "tk_1" },
},
ALERT,
d,
);
expect(calls).toEqual([
{
url: "https://ntfy.sh/ops",
init: expect.objectContaining({
method: "POST",
body: "/data at 99%",
allowPrivateHosts: [],
headers: {
Title: "Disk full",
Priority: "5",
Tags: "rotating_light",
Click: "https://termix.example/hosts/1",
Authorization: "Bearer tk_1",
},
}),
},
]);
});
it("posts a Discord embed", async () => {
const { deps: d, calls } = deps();
await sendToChannel(
{
type: "discord",
config: {
url: "https://discord.com/api/webhooks/1/abc",
username: "Termix",
},
},
ALERT,
d,
);
const payload = JSON.parse(calls[0].init!.body!);
expect(payload).toMatchObject({
username: "Termix",
embeds: [
{
title: "Disk full",
description: "/data at 99%",
color: 15158332,
url: "https://termix.example/hosts/1",
},
],
});
});
it("puts on the webhook's own method and headers", async () => {
const { deps: d, calls } = deps();
await sendToChannel(
{
type: "webhook",
config: {
url: "https://hooks.example",
method: "PUT",
headers: { Authorization: "Bearer x" },
},
},
ALERT,
d,
);
expect(calls[0].init).toMatchObject({
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer x",
},
});
});
it("throws with the status when the receiver refuses", async () => {
const { deps: d } = deps(
new Response("slow down", { status: 429, statusText: "Too Many" }),
);
await expect(
sendToChannel(
{ type: "webhook", config: { url: "https://hooks.example" } },
ALERT,
d,
),
).rejects.toThrow("HTTP 429 Too Many: slow down");
});
it("never links to anything but http(s)", async () => {
const { deps: d, calls } = deps();
await sendToChannel(
{ type: "ntfy", config: { url: "https://ntfy.sh", topic: "t" } },
{ ...ALERT, link: { url: "javascript:alert(1)" } },
d,
);
expect(calls[0].init!.headers).not.toHaveProperty("Click");
});
});
describe("validateChannelConfig", () => {
it("accepts a working config of each type", () => {
expect(
validateChannelConfig("webhook", { url: "https://a.example" }),
).toBeNull();
expect(
validateChannelConfig("ntfy", { url: "https://ntfy.sh", topic: "t" }),
).toBeNull();
expect(
validateChannelConfig("discord", {
url: "https://discordapp.com/api/webhooks/1/x",
}),
).toBeNull();
expect(
validateChannelConfig("email", { to: "a@example.com; b@example.org" }),
).toBeNull();
});
it("names what is missing", () => {
expect(validateChannelConfig("ntfy", { url: "https://ntfy.sh" })).toMatch(
/topic/,
);
expect(validateChannelConfig("email", { to: "" })).toMatch(/address/);
});
});
describe("helpers", () => {
it("splits email recipients on commas, semicolons and spaces", () => {
expect(parseRecipients("a@x.io, b@x.io;c@x.io d@x.io")).toEqual([
"a@x.io",
"b@x.io",
"c@x.io",
"d@x.io",
]);
});
it("matches categories by pattern", () => {
expect(matchesCategory("*", "docker.stopped")).toBe(true);
expect(matchesCategory("", "docker.stopped")).toBe(true);
expect(matchesCategory("docker.*", "docker.stopped")).toBe(true);
expect(matchesCategory("docker.*", "dockerx.stopped")).toBe(false);
expect(
matchesCategory("acme-ssl.renewal_failed", "acme-ssl.renewal_failed"),
).toBe(true);
expect(matchesCategory("acme-ssl.renewal_failed", "acme-ssl.other")).toBe(
false,
);
});
it("orders severities with success alongside info", () => {
expect(severityRank("info")).toBe(severityRank("success"));
expect(severityRank("warning")).toBeGreaterThan(severityRank("info"));
expect(severityRank("critical")).toBeGreaterThan(severityRank("warning"));
});
it("reads the admin allowlist leniently", () => {
expect(parseAllowlist('["NTFY.lan", " mail.lan ", 3]')).toEqual([
"ntfy.lan",
"mail.lan",
]);
expect(parseAllowlist("not json")).toEqual([]);
expect(parseAllowlist(null)).toEqual([]);
});
it("keeps only a safe tab id and an http(s) url in a link", () => {
expect(cleanLink({ tab: "docker", url: "https://a.example" })).toEqual({
tab: "docker",
url: "https://a.example",
});
expect(cleanLink({ tab: "../../x", url: "file:///etc/passwd" })).toBeNull();
expect(cleanLink("docker")).toBeNull();
});
});
@@ -0,0 +1,150 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { fireEvent, waitFor } from "@testing-library/react";
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
import {
renderWithApp,
type RenderedPluginApp,
} from "@termix/plugin-sdk/testing";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import * as plugin from "../../src/frontend/index";
import manifestJson from "../../manifest.json";
import locales from "../../locales/en.json";
const manifest = manifestJson as unknown as PluginManifest;
let rendered: RenderedPluginApp | null = null;
afterEach(async () => {
await rendered?.deactivate();
rendered = null;
});
const ITEMS = [
{
id: 2,
source: "acme-ssl",
category: "acme-ssl.renewal_failed",
severity: "critical",
title: "Certificate renewal failed",
body: "rate limited",
link: null,
context: null,
deliveries: [{ channelId: 1, name: "pager", ok: false, error: "HTTP 500" }],
readAt: null,
createdAt: new Date().toISOString(),
},
{
id: 1,
source: "termix",
category: "termix.announcement",
severity: "info",
title: "Termix 2.9 is out",
body: null,
link: { url: "https://termix.site" },
context: { actionText: "Read more" },
deliveries: null,
readAt: "2026-01-01T00:00:00.000Z",
createdAt: "2026-01-01T00:00:00.000Z",
},
];
function stubApi() {
const calls: Array<[string, string, unknown?]> = [];
const api = {
get: vi.fn(async (url: string) => {
calls.push(["GET", url]);
if (url.startsWith("/items"))
return { data: { items: ITEMS, unread: 1 } };
if (url === "/unread") return { data: { count: 1 } };
return { data: [] };
}),
post: vi.fn(async (url: string, body?: unknown) => {
calls.push(["POST", url, body]);
return { data: { count: 0 } };
}),
delete: vi.fn(async (url: string) => {
calls.push(["DELETE", url]);
return { data: { success: true } };
}),
put: vi.fn(),
patch: vi.fn(),
} as unknown as PluginApiClient;
return { api, calls };
}
describe(`${manifest.id} activate`, () => {
it("registers a footer rail item, its panel and tab, the popups and its actions", async () => {
rendered = await renderWithApp(plugin, { manifest, locales });
expect(rendered.registered.railItems()).toEqual([
expect.objectContaining({ id: "alerts", permission: "alerts.use" }),
]);
expect(rendered.registered.panels()).toEqual(["alerts"]);
expect(rendered.registered.tabs()).toEqual(["alerts"]);
expect(rendered.registered.slot("shell.overlay")).toEqual([
"alerts.toaster",
]);
expect(rendered.registered.actions().sort()).toEqual([
"alerts.open",
"alerts.openChannels",
]);
});
it("opens the tab from its actions", async () => {
rendered = await renderWithApp(plugin, { manifest, locales });
await rendered.app.invokeAction("alerts.openChannels");
expect(rendered.shellCalls).toContainEqual({
method: "openSingletonTab",
args: ["alerts"],
});
});
it("removes everything it registered on deactivate", async () => {
const app = await renderWithApp(plugin, { manifest, locales });
await app.deactivate();
expect(app.registered.tabs()).toEqual([]);
expect(app.registered.panels()).toEqual([]);
expect(app.registered.railItems()).toEqual([]);
expect(app.registered.actions()).toEqual([]);
expect(app.registered.slot("shell.overlay")).toEqual([]);
});
});
describe("the alerts panel", () => {
it("lists alerts, flags a failed delivery and marks one read", async () => {
const { api, calls } = stubApi();
rendered = await renderWithApp(plugin, { manifest, locales, api });
const panel = rendered.renderPanel("alerts", { active: true });
await waitFor(() =>
expect(panel.textContent).toContain("Certificate renewal failed"),
);
expect(panel.textContent).toContain("Termix 2.9 is out");
expect(panel.textContent).toContain("1 channel failed");
expect(panel.textContent).toContain("Read more");
fireEvent.click(panel.querySelector('[aria-label="Mark as read"]')!);
await waitFor(() =>
expect(calls).toContainEqual([
"POST",
"/items/read",
{ ids: [2], read: true },
]),
);
});
it("deletes an alert", async () => {
const { api, calls } = stubApi();
rendered = await renderWithApp(plugin, { manifest, locales, api });
const panel = rendered.renderPanel("alerts", { active: true });
await waitFor(() =>
expect(panel.textContent).toContain("Certificate renewal failed"),
);
fireEvent.click(panel.querySelectorAll('[aria-label="Delete"]')[0]);
await waitFor(() => expect(calls).toContainEqual(["DELETE", "/items/2"]));
await waitFor(() =>
expect(panel.textContent).not.toContain("Certificate renewal failed"),
);
});
});
+140
View File
@@ -0,0 +1,140 @@
import { describe, expect, it, vi } from "vitest";
import {
createAlertsStore,
parseServerSentEvents,
} from "../../src/frontend/store";
import { shouldPopUp } from "../../src/frontend/popups";
import {
configFrom,
parseHeaders,
EMPTY,
} from "../../src/frontend/channel-config";
import type { AlertItem } from "../../src/types";
function item(id: number, severity: AlertItem["severity"]): AlertItem {
return {
id,
source: "docker",
category: "docker",
severity,
title: `alert ${id}`,
body: null,
link: null,
context: null,
deliveries: null,
readAt: null,
createdAt: new Date().toISOString(),
};
}
/** A response whose body streams the given chunks, then stays open. */
function streaming(chunks: string[]): Response {
const encoder = new TextEncoder();
const body = new ReadableStream<Uint8Array>({
start(controller) {
for (const chunk of chunks) controller.enqueue(encoder.encode(chunk));
},
});
return new Response(body, {
headers: { "Content-Type": "text/event-stream" },
});
}
describe("parseServerSentEvents", () => {
it("parses whole events and keeps the rest for later", () => {
const events: Array<{ event: string; data: string }> = [];
const rest = parseServerSentEvents(
'event: unread\ndata: {"count":2}\n\n: keepalive\n\nevent: item\ndata: {"id"',
(event) => events.push(event),
);
expect(events).toEqual([{ event: "unread", data: '{"count":2}' }]);
expect(rest).toBe('event: item\ndata: {"id"');
});
});
describe("createAlertsStore", () => {
it("follows the unread count and hands on each alert once as new", async () => {
const first = item(7, "critical");
const store = createAlertsStore({
connect: async () =>
streaming([
'event: unread\ndata: {"count":3}\n\n',
`event: item\ndata: ${JSON.stringify(first)}\n\n`,
`event: item\ndata: ${JSON.stringify({ ...first, deliveries: [] })}\n\n`,
]),
loadUnread: async () => 0,
});
const seen: Array<[number, boolean]> = [];
store.onItem((alert, isNew) => seen.push([alert.id, isNew]));
store.start();
await vi.waitFor(() => expect(seen).toHaveLength(2));
store.stop();
expect(store.unread()).toBe(3);
expect(seen).toEqual([
[7, true],
[7, false],
]);
expect(store.version()).toBe(2);
});
it("falls back to the count route while the stream is down", async () => {
const loadUnread = vi.fn(async () => 5);
const store = createAlertsStore({
connect: async () => new Response("", { status: 503 }),
loadUnread,
retryMs: 10_000,
});
store.start();
await vi.waitFor(() => expect(store.unread()).toBe(5));
store.stop();
});
});
describe("popups", () => {
it("follows the user's level", () => {
expect(shouldPopUp(item(1, "info"), "all")).toBe(true);
expect(shouldPopUp(item(1, "info"), "warning")).toBe(false);
expect(shouldPopUp(item(1, "warning"), "warning")).toBe(true);
expect(shouldPopUp(item(1, "warning"), "critical")).toBe(false);
expect(shouldPopUp(item(1, "critical"), "critical")).toBe(true);
expect(shouldPopUp(item(1, "critical"), "off")).toBe(false);
});
});
describe("channel config", () => {
it("reads headers one per line", () => {
expect(
parseHeaders("Authorization: Bearer x\nbad line\nX-Id: 7 "),
).toEqual({ Authorization: "Bearer x", "X-Id": "7" });
});
it("builds only the fields each type uses", () => {
expect(
configFrom({
...EMPTY,
type: "webhook",
url: " https://a.example ",
headers: "A: b",
allowPrivateNetwork: true,
}),
).toEqual({
url: "https://a.example",
method: "POST",
headers: { A: "b" },
allowPrivateNetwork: true,
});
expect(
configFrom({ ...EMPTY, type: "email", to: "ops@example.com" }),
).toEqual({ to: "ops@example.com" });
expect(
configFrom({
...EMPTY,
type: "discord",
url: "https://discord.com/api/webhooks/1",
allowPrivateNetwork: true,
}),
).toEqual({ url: "https://discord.com/api/webhooks/1" });
});
});
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "@termix/plugin-sdk/tsconfig.plugin.json",
"include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"]
}
+3
View File
@@ -0,0 +1,3 @@
import { pluginVitestConfig } from "@termix/plugin-sdk/vitest-preset";
export default pluginVitestConfig(import.meta.url);
+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