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