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

+61 -14
View File
@@ -1492,19 +1492,46 @@ export interface PluginAudit {
}) => Promise<void>;
}
/** A notification channel the acting user set up. Its config never leaves core. */
/** A notification channel a user set up. Its config never leaves the hub. */
export interface PluginNotificationChannel {
id: number;
name: string;
/** "webhook", "ntfy" or "discord". */
/** "webhook", "ntfy", "discord", "email", or whatever the hub supports. */
type: string;
enabled: boolean;
}
export type PluginNotificationSeverity =
"info" | "success" | "warning" | "critical";
/**
* Who receives an alert. Left out, it goes to the acting user. "admins" and
* a permission reach every user who holds it when the alert is sent.
*/
export type PluginNotificationAudience =
{ userId: string } | { permission: string } | "admins";
export interface PluginNotification {
title: string;
body: string;
severity?: "info" | "warning" | "critical";
body?: string;
/** Defaults to "warning". */
severity?: PluginNotificationSeverity;
/**
* What kind of alert this is, "<pluginId>.<kind>" by convention, e.g.
* "acme-ssl.renewal_failed". Users route alerts to channels by it.
* Defaults to the sending plugin's id.
*/
category?: string;
audience?: PluginNotificationAudience;
/**
* Channels to deliver to on top of the recipient's own routing rules.
* Only channels the recipient owns are used.
*/
channelIds?: number[];
/** Where the alert points: a tab type to open, or an http(s) URL. */
link?: { tab?: string; url?: string };
/** While an unread alert with this key exists, repeats are dropped. */
dedupeKey?: string;
/**
* Extra fields a webhook receiver gets alongside the message. `sourceId`
* and `sourceName` name what sent it (an automation, a rule).
@@ -1517,30 +1544,50 @@ export interface PluginNotification {
triggerType?: string;
value?: unknown;
threshold?: unknown;
[key: string]: unknown;
};
}
export interface PluginNotifyResult {
/** Users the alert reached. */
recipients: number;
/** Channel deliveries that succeeded, across every recipient. */
delivered: number;
failures: Array<{ channelId: number; name: string; error: string }>;
}
/**
* Core's notification channels, as the acting user. Needs notify:send, and
* refuses a call with no actor: channels belong to a user.
* The plugin that stores alerts and delivers them to channels. Core resolves
* the audience and hands the hub a list of user ids; it never sees a channel.
*/
export interface PluginNotifyHub {
deliver: (input: {
/** The plugin that sent the alert. */
source: string;
recipients: string[];
notification: PluginNotification;
}) => Promise<PluginNotifyResult>;
channels: (userId: string) => Promise<PluginNotificationChannel[]>;
}
/**
* Alerts. Every plugin sends through here and the hub plugin decides where
* they land. Needs notify:send. With no hub running, send reaches nobody and
* channels answers an empty list, so callers never have to check.
*/
export interface PluginNotify {
/** The acting user's channels, without their config. */
/** The acting user's channels, without their config. Needs an actor. */
channels: () => Promise<PluginNotificationChannel[]>;
/**
* Sends to the listed channels the acting user owns. Disabled channels and
* ids the user does not own are skipped. One channel failing does not stop
* the others.
* Sends an alert. With no audience it goes to the acting user, so a call
* with neither is refused. One channel failing does not stop the others.
*/
send: (
channelIds: number[],
notification: PluginNotification,
) => Promise<PluginNotifyResult>;
send: (notification: PluginNotification) => Promise<PluginNotifyResult>;
/**
* Makes this plugin the hub. Needs notify:hub. Only one hub runs at a time;
* a second one is refused. Revoked on deactivate.
*/
serve: (hub: PluginNotifyHub) => () => void;
}
export interface PluginFetchInit {
+1
View File
@@ -46,6 +46,7 @@ export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [
entry("users:write", "high"),
entry("auth:provide", "high"),
entry("device:serial", "high"),
entry("notify:hub", "high"),
entry("hosts:write", "medium"),
entry("credentials:use", "medium"),
+1
View File
@@ -265,6 +265,7 @@ export const LEGACY_TABLE_OWNERS: Readonly<Record<string, string>> = {
termix_identity_ca: "termix-identity",
webauthn_credentials: "webauthn",
sso_providers: "sso",
dismissed_alerts: "alerts",
};
/**
+10
View File
@@ -168,6 +168,16 @@ export interface RailItemContribution {
after?: string;
/** Lower sorts first among plugin items. */
order?: number;
/**
* "footer" puts it at the bottom of the rail, above the user's profile,
* for something always at hand like an inbox. Default "main".
*/
placement?: "main" | "footer";
/**
* A hook the rail calls to show a count on the icon, e.g. unread alerts.
* Zero or nothing shows no badge.
*/
useBadge?: () => number | null | undefined;
}
export interface PanelProps {
+32 -8
View File
@@ -69,6 +69,7 @@ import type {
PluginHostStatusEntry,
PluginNotification,
PluginNotificationChannel,
PluginNotifyHub,
PluginFetchInit,
PluginBinarySpec,
PluginTlsCertificateInfo,
@@ -303,9 +304,10 @@ export interface FakePluginContext {
/** Every ctx.notify.send call, with the actor it ran as. */
notifications: Array<{
actor: string | undefined;
channelIds: number[];
notification: PluginNotification;
}>;
/** The hub the plugin registered with ctx.notify.serve, if any. */
notifyHub: { current: PluginNotifyHub | null };
/** Backing store behind ctx.secrets.get/set, keyed "<userId>:<key>". */
secretStore: Map<string, string>;
/** Every ctx.fetch call, in order. */
@@ -434,6 +436,7 @@ export function createFakeContext(
const credentialReads: FakePluginContext["credentialReads"] = [];
const createdSshKeys: FakePluginContext["createdSshKeys"] = [];
const notifications: FakePluginContext["notifications"] = [];
const notifyHub: FakePluginContext["notifyHub"] = { current: null };
const fetches: FakePluginContext["fetches"] = [];
const processRuns: FakePluginContext["processRuns"] = [];
const binaries: FakePluginContext["binaries"] = [];
@@ -1105,13 +1108,29 @@ export function createFakeContext(
},
notify: {
channels: async () => options.notificationChannels ?? [],
send: async (channelIds, notification) => {
notifications.push({ actor, channelIds, notification });
channels: async () => {
if (!actor) throw new Error("ctx.notify needs an acting user");
return options.notificationChannels ?? [];
},
send: async (notification) => {
if (!notification.audience && !actor) {
throw new Error("ctx.notify needs an acting user or an audience");
}
notifications.push({ actor, notification });
const channelIds = notification.channelIds ?? [];
const selected = (options.notificationChannels ?? []).filter(
(channel) => channelIds.includes(channel.id) && channel.enabled,
);
return { delivered: selected.length, failures: [] };
return { recipients: 1, delivered: selected.length, failures: [] };
},
serve: (hub) => {
if (notifyHub.current) throw new Error("A hub is already running");
notifyHub.current = hub;
const revoke = () => {
if (notifyHub.current === hub) notifyHub.current = null;
};
disposals.push(revoke);
return revoke;
},
},
@@ -1235,6 +1254,7 @@ export function createFakeContext(
credentialReads,
createdSshKeys,
notifications,
notifyHub,
fetches,
processRuns,
binaries,
@@ -1333,7 +1353,7 @@ export interface MockPluginContext extends FakePluginContext {
* ctx.db, network:serve on ctx.http and ctx.ws, events:core on emitting a
* topic outside the plugin's own namespace, settings:read-core on
* ctx.settings.readCore, ssh:connect plus credentials:use on ctx.ssh,
* notify:send on ctx.notify, network:outbound on ctx.fetch, process:spawn on
* notify:send on ctx.notify (notify:hub on serve), network:outbound on ctx.fetch, process:spawn on
* ctx.process, system:tls on ctx.system, and auth:provide on ctx.auth. Reading a plugin's own settings is deliberately
* ungated. As the SDK grows a member, add its gate here in the same shape.
*/
@@ -1692,9 +1712,13 @@ export function createMockCtx(
require("notify:send");
return ctx.notify.channels();
},
send: async (channelIds, notification) => {
send: async (notification) => {
require("notify:send");
return ctx.notify.send(channelIds, notification);
return ctx.notify.send(notification);
},
serve: (hub) => {
require("notify:hub");
return ctx.notify.serve(hub);
},
},