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

+4
View File
@@ -8,6 +8,10 @@
"id": "ai", "id": "ai",
"source": "workspace" "source": "workspace"
}, },
{
"id": "alerts",
"source": "workspace"
},
{ {
"id": "automations", "id": "automations",
"source": "workspace" "source": "workspace"
-18
View File
@@ -222,24 +222,6 @@ http {
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
} }
location ~ ^/alerts(/.*)?$ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
}
location ~ ^/notification-channels(/.*)?$ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
}
location ~ ^/rbac(/.*)?$ { location ~ ^/rbac(/.*)?$ {
proxy_pass http://127.0.0.1:30001; proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1; proxy_http_version 1.1;
-18
View File
@@ -191,24 +191,6 @@ http {
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
} }
location ~ ^/alerts(/.*)?$ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
}
location ~ ^/notification-channels(/.*)?$ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
}
location ~ ^/rbac(/.*)?$ { location ~ ^/rbac(/.*)?$ {
proxy_pass http://127.0.0.1:30001; proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1; proxy_http_version 1.1;
+6
View File
@@ -0,0 +1,6 @@
-- dismissed_alerts and notification_channels moved to the alerts plugin. It
-- renames the first into its own namespace and copies the second at
-- activation, both after this migration, so a real DROP TABLE here would
-- delete them first. notification_channels stays in place, unused, until
-- 3.0.0.
SELECT 1;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -407,6 +407,13 @@
"when": 1790304466729, "when": 1790304466729,
"tag": "0057_serious_shatterstar", "tag": "0057_serious_shatterstar",
"breakpoints": true "breakpoints": true
},
{
"idx": 58,
"version": "5",
"when": 1790370152568,
"tag": "0058_boring_avengers",
"breakpoints": true
} }
] ]
} }
+6
View File
@@ -0,0 +1,6 @@
-- dismissed_alerts and notification_channels moved to the alerts plugin. It
-- renames the first into its own namespace and copies the second at
-- activation, both after this migration, so a real DROP TABLE here would
-- delete them first. notification_channels stays in place, unused, until
-- 3.0.0.
SELECT 1;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -414,6 +414,13 @@
"when": 1790304465181, "when": 1790304465181,
"tag": "0058_goofy_ma_gnuci", "tag": "0058_goofy_ma_gnuci",
"breakpoints": true "breakpoints": true
},
{
"idx": 59,
"version": "7",
"when": 1790370151997,
"tag": "0059_square_korg",
"breakpoints": true
} }
] ]
} }
+6
View File
@@ -0,0 +1,6 @@
-- dismissed_alerts and notification_channels moved to the alerts plugin. It
-- renames the first into its own namespace and copies the second at
-- activation, both after this migration, so a real DROP TABLE here would
-- delete them first. notification_channels stays in place, unused, until
-- 3.0.0.
SELECT 1;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -379,6 +379,13 @@
"when": 1790304468233, "when": 1790304468233,
"tag": "0053_bent_absorbing_man", "tag": "0053_bent_absorbing_man",
"breakpoints": true "breakpoints": true
},
{
"idx": 54,
"version": "6",
"when": 1790370151385,
"tag": "0054_superb_guardian",
"breakpoints": true
} }
] ]
} }
+34
View File
@@ -7087,6 +7087,10 @@
"resolved": "plugins/ai", "resolved": "plugins/ai",
"link": true "link": true
}, },
"node_modules/@termix-plugin/alerts": {
"resolved": "plugins/alerts",
"link": true
},
"node_modules/@termix-plugin/automations": { "node_modules/@termix-plugin/automations": {
"resolved": "plugins/automations", "resolved": "plugins/automations",
"link": true "link": true
@@ -7578,6 +7582,16 @@
"undici-types": "~8.3.0" "undici-types": "~8.3.0"
} }
}, },
"node_modules/@types/nodemailer": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.2.tgz",
"integrity": "sha512-c7M5ox8p0nEOfbJ2E9Qcmt8/QCu/VzsiAhzBiZbvky2PhKZDHpKB3sQHBM5MEZNkCfBOaS6S9//AGPRTNB7IaA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/pg": { "node_modules/@types/pg": {
"version": "8.23.1", "version": "8.23.1",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz",
@@ -14859,6 +14873,15 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/nodemailer": {
"version": "10.0.10",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.0.10.tgz",
"integrity": "sha512-He9XskOFms62SyAKkLu8CcGcYHAo+BSHSsORI8s4PJH8qZgZY4L4lDfvyN0twvmbpyG+eGrxpyBlskj9d9rJ8A==",
"license": "MIT-0",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/nopt": { "node_modules/nopt": {
"version": "9.0.0", "version": "9.0.0",
"resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz", "resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz",
@@ -18930,6 +18953,17 @@
"@termix/plugin-sdk": "*" "@termix/plugin-sdk": "*"
} }
}, },
"plugins/alerts": {
"name": "@termix-plugin/alerts",
"version": "1.0.0",
"dependencies": {
"nodemailer": "^10.0.10"
},
"devDependencies": {
"@termix/plugin-sdk": "*",
"@types/nodemailer": "^8.0.2"
}
},
"plugins/automations": { "plugins/automations": {
"name": "@termix-plugin/automations", "name": "@termix-plugin/automations",
"version": "1.0.0", "version": "1.0.0",
+95 -47
View File
@@ -195,17 +195,17 @@ regenerated `schema.pg.ts`/`schema.mysql.ts` plus drizzle migrations.
**What stays in core.** After 2.9.0, `schema.ts`, the SQLite DDL in **What stays in core.** After 2.9.0, `schema.ts`, the SQLite DDL in
`db/index.ts`, `performance-indexes.ts`, the repository factory and the `db/index.ts`, `performance-indexes.ts`, the repository factory and the
drizzle migrations hold only these 38 tables. Anything else is a plugin's, drizzle migrations hold only these 36 tables. Anything else is a plugin's,
under `p_<id>_`. under `p_<id>_`.
| Area | Tables | | Area | Tables |
| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Accounts | `users`, `sessions`, `trusted_devices`, `api_keys`, `user_external_identities`, `user_second_factors` | | Accounts | `users`, `sessions`, `trusted_devices`, `api_keys`, `user_external_identities`, `user_second_factors` |
| Preferences | `settings`, `user_preferences`, `ui_preferences`, `host_sidebar_preferences`, `credential_sidebar_preferences`, `user_open_tabs` | | Preferences | `settings`, `user_preferences`, `ui_preferences`, `host_sidebar_preferences`, `credential_sidebar_preferences`, `user_open_tabs` |
| Hosts | `ssh_data`, `ssh_folders`, `recent_activity`, `dismissed_alerts` | | Hosts | `ssh_data`, `ssh_folders`, `recent_activity` |
| Credentials | `ssh_credentials`, `ssh_credential_usage` | | Credentials | `ssh_credentials`, `ssh_credential_usage` |
| Sharing and RBAC | `roles`, `user_roles`, `host_access`, `folder_access`, `credential_access`, `shared_host_secrets`, `shared_host_auth_overrides`, `shared_credential_secrets`, `rbac_known_permissions`, `rbac_applied_defaults` | | Sharing and RBAC | `roles`, `user_roles`, `host_access`, `folder_access`, `credential_access`, `shared_host_secrets`, `shared_host_auth_overrides`, `shared_credential_secrets`, `rbac_known_permissions`, `rbac_applied_defaults` |
| Audit and alerts | `audit_logs`, `notification_channels` | | Audit | `audit_logs` |
| Sync | `sync_tombstones` | | Sync | `sync_tombstones` |
| Plugin runtime | `plugins`, `plugin_permission_grants`, `plugin_registries`, `plugin_install_counts`, `plugin_storage`, `plugin_settings`, `plugin_migrations` | | Plugin runtime | `plugins`, `plugin_permission_grants`, `plugin_registries`, `plugin_install_counts`, `plugin_storage`, `plugin_settings`, `plugin_migrations` |
@@ -550,32 +550,32 @@ when the plugin deactivates. Every registered component is wrapped in the
plugin's scope (so the hooks know which plugin they belong to), an error plugin's scope (so the hooks know which plugin they belong to), an error
boundary and Suspense. boundary and Suspense.
| Member | What it does | | Member | What it does |
| --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | | --------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `registerRailItem` | A rail button, hidden from users without its `permission`. Hideable items show in Appearance > Sidebar > Navigation. The id must be a declared view | | `registerRailItem` | A rail button, hidden from users without its `permission`. Hideable items show in Appearance > Sidebar > Navigation. The id must be a declared view. `placement: "footer"` puts it above the profile, and `useBadge` is a hook whose number shows on the icon (**E1**) |
| `registerPanel` | A rail panel, id in `contributes.panels` or `contributes.tabs` | | `registerPanel` | A rail panel, id in `contributes.panels` or `contributes.tabs` |
| `registerTab` | A tab type, id in `contributes.tabs`. Options cover persistence, layouts, singletons and host needs | | `registerTab` | A tab type, id in `contributes.tabs`. Options cover persistence, layouts, singletons and host needs |
| `registerHostEditorSection` | A host editor tab in the top strip or the SSH group, with `form`, `setField` and `updateForm` | | `registerHostEditorSection` | A host editor tab in the top strip or the SSH group, with `form`, `setField` and `updateForm` |
| `registerHostAction` | A connect or open action on a host: sidebar row, palette, dashboard, default connect | | `registerHostAction` | A connect or open action on a host: sidebar row, palette, dashboard, default connect |
| `registerHostProtocol` | A connection protocol next to SSH (**B14**): General tab switch, Hosts panel filter and grouping, Quick Connect, the host's primary type and port | | `registerHostProtocol` | A connection protocol next to SSH (**B14**): General tab switch, Hosts panel filter and grouping, Quick Connect, the host's primary type and port |
| `registerHostBadge`, `registerHostContextMenuItem` | Host row badges and context menu entries | | `registerHostBadge`, `registerHostContextMenuItem` | Host row badges and context menu entries |
| `registerPaletteEntry` | A global or per-host command palette entry | | `registerPaletteEntry` | A global or per-host command palette entry |
| `registerDashboardCard` | A dashboard card, id in `contributes.dashboardCards` | | `registerDashboardCard` | A dashboard card, id in `contributes.dashboardCards` |
| `registerHomepageWidget` | A homepage widget, with an optional edit form | | `registerHomepageWidget` | A homepage widget, with an optional edit form |
| `registerSettingsComponent` | A component a `type: "custom"` settings field names | | `registerSettingsComponent` | A component a `type: "custom"` settings field names |
| `registerAction`, `declareActionSlot` | Frontend actions and the slots a plugin owns | | `registerAction`, `declareActionSlot` | Frontend actions and the slots a plugin owns |
| `registerSlotContribution`, `invokeAction` | Fill a slot with a `button` or a `component`, with an optional `when`; call an action and get its result | | `registerSlotContribution`, `invokeAction` | Fill a slot with a `button` or a `component`, with an optional `when`; call an action and get its result |
| `registerSshAuthEditor` | An SSH auth method's editor in the host editor | | `registerSshAuthEditor` | An SSH auth method's editor in the host editor |
| `registerLoginMethod`, `registerSecondFactorUI` | Login screen UI for a method (`placement: "inline"` beside the password form) and a second factor challenge, each with an optional `enrollment` | | `registerLoginMethod`, `registerSecondFactorUI` | Login screen UI for a method (`placement: "inline"` beside the password form) and a second factor challenge, each with an optional `enrollment` |
| `api`, `wsUrl(path)` | axios on `/plugin-api/<id>/` and the plugin's WebSocket URL | | `api`, `wsUrl(path)` | axios on `/plugin-api/<id>/` and the plugin's WebSocket URL |
| `apiFor(origin)` | The same client for a resolved connection origin: `"remote"` reaches the desktop app's connected server (**B14**) | | `apiFor(origin)` | The same client for a resolved connection origin: `"remote"` reaches the desktop app's connected server (**B14**) |
| `fetch(path, init)` | A raw `fetch` on `/plugin-api/<id>/<path>` with core's auth, for a streamed response (SSE) axios cannot read (**B18**) | | `fetch(path, init)` | A raw `fetch` on `/plugin-api/<id>/<path>` with core's auth, for a streamed response (SSE) axios cannot read (**B18**) |
| `t`, `hasPermission` | The plugin's strings and a permission check, for code outside a component (a toast from `activate`) | | `t`, `hasPermission` | The plugin's strings and a permission check, for code outside a component (a toast from `activate`) |
| `tabs.open`, `getLayout`, `applyLayout`, `onChange` | Tab control, used by workspaces; `tabs.openRailView` (**B12**) opens a rail view | | `tabs.open`, `getLayout`, `applyLayout`, `onChange` | Tab control, used by workspaces; `tabs.openRailView` (**B12**) opens a rail view |
| `guest`, `info`, `onDispose` | Guest mode flag, plugin info, extra cleanup | | `guest`, `info`, `onDispose` | Guest mode flag, plugin info, extra cleanup |
| `desktop` | `available`, `remoteServerUrl()` and `onRemoteServerChange`: whether the frontend runs in the desktop app and which remote server it syncs with (**C6**) | | `desktop` | `available`, `remoteServerUrl()` and `onRemoteServerChange`: whether the frontend runs in the desktop app and which remote server it syncs with (**C6**) |
| `registerComponent` | A component other plugins and core render by id with `PluginComponent` | | `registerComponent` | A component other plugins and core render by id with `PluginComponent` |
| `onSettingsChanged` | Called when one of the plugin's settings changes | | `onSettingsChanged` | Called when one of the plugin's settings changes |
A few contribution fields core reads generically: A few contribution fields core reads generically:
@@ -798,10 +798,10 @@ mode are still preset-seeded localStorage keys core writes, as in 2.8.
status for a host, from the shell's own polling, or null outside the shell. status for a host, from the shell's own polling, or null outside the shell.
**B14** added `buildOriginWsUrl`, `getBasePath`, `resolveRemoteHostId` and the **B14** added `buildOriginWsUrl`, `getBasePath`, `resolveRemoteHostId` and the
`ConnectionStage` type, for remote desktop's display socket and its remote `ConnectionStage` type, for remote desktop's display socket and its remote
host lookup. **B17** added `NotificationChannelDialog` and the channel API host lookup. **B17** added a channel dialog and API for core's notification
(`getNotificationChannels`, `deleteNotificationChannel`, channels, which **E1** removed again when channels moved to the alerts
`testNotificationChannel`, the `NotificationChannel` type): channels are core, plugin: a plugin that wants its user to manage channels invokes
so a plugin that manages them shows core's dialog. `useConnectionDefaults` now carries terminal defaults only; the `alerts.openChannels`. `useConnectionDefaults` now carries terminal defaults only; the
RDP half became remote desktop's user settings. Publishing its RDP half became remote desktop's user settings. Publishing its
`.d.ts` for plugins outside this repo is a follow-up for the repo split. `.d.ts` for plugins outside this repo is a follow-up for the repo split.
**B19** added `PluginViewPlaceholder`, for the homepage plugin's widget **B19** added `PluginViewPlaceholder`, for the homepage plugin's widget
@@ -976,8 +976,10 @@ sessions shared with a user from the `sessions.sharedWithMe` action.
(`snippets.access`, a hard dependency), fleets (`fleets.access`), tunnels (`snippets.access`, a hard dependency), fleets (`fleets.access`), tunnels
(`tunnels.access`), Docker (`docker.containers`, `docker.events`), host metrics (`tunnels.access`), Docker (`docker.containers`, `docker.events`), host metrics
(`host-metrics.viewers`) and wake-on-lan (`wake-on-lan.send` v1, (`host-metrics.viewers`) and wake-on-lan (`wake-on-lan.send` v1,
`wake(hostId)`, which **B20** provides) as services, notification channels through `wake(hostId)`, which **B20** provides) as services, alerts through
`ctx.notify`, the HTTP step through `ctx.fetch`, hosts through `ctx.hosts` and `ctx.notify` (a notify step goes to the owner's inbox plus the channels it
names, and a failed run sends the owner an `automations.run_failed` alert, E1),
the HTTP step through `ctx.fetch`, hosts through `ctx.hosts` and
`ctx.ssh.resolveHost`, and commands through `ctx.ssh.withConnection`. Every run `ctx.ssh.resolveHost`, and commands through `ctx.ssh.withConnection`. Every run
executes inside `ctx.asUser(<owner>)`, so each of those calls is the owner's. executes inside `ctx.asUser(<owner>)`, so each of those calls is the owner's.
The scheduler tick is `ctx.schedule.every`, and triggers listen through The scheduler tick is `ctx.schedule.every`, and triggers listen through
@@ -997,8 +999,9 @@ It provides `automations.access` v1 (`list`, `get`, `create`, `run`, gated
on `automations.view`, with `create` and `run` also checking their own on `automations.view`, with `create` and `run` also checking their own
permissions) for the AI assistant, which lists automations as an optional permissions) for the AI assistant, which lists automations as an optional
dependency. It adopts `automations` and the five `automation_*` tables; the dependency. It adopts `automations` and the five `automation_*` tables; the
migrations keep the legacy links between them and to `notification_channels` migrations keep the legacy links between them by hand. Its channel links
by hand. Core's `hosts/automation-events.ts` became `hosts/internal-events.ts` lost their foreign key to `notification_channels` in its 0002 migration (E1),
since channel ids now point into the alerts plugin's table. Core's `hosts/automation-events.ts` became `hosts/internal-events.ts`
(`emitInternalEvent`), since it never was specific to automations. Webhooks (`emitInternalEvent`), since it never was specific to automations. Webhooks
live at `/plugin-api/automations/webhook/<token>`; the 2.8 path live at `/plugin-api/automations/webhook/<token>`; the 2.8 path
`/automations/webhook/<token>` is rewritten to it in both nginx configs, `/automations/webhook/<token>` is rewritten to it in both nginx configs,
@@ -1139,6 +1142,52 @@ history retention and the new-host default are admin settings, the
temperature unit a user setting, and the rest of `stats_config` host temperature unit a user setting, and the rest of `stats_config` host
settings. settings.
**Alerts (E1)** is the one place every alert lands, from any plugin, official
or not. A plugin sends with `ctx.notify.send({ title, body?, severity?,
category?, audience?, channelIds?, link?, dedupeKey?, context? })` and never
needs to know the alerts plugin exists:
- Core resolves `audience` to user ids: nothing means the acting user,
`{ userId }` one user, `"admins"` every admin, `{ permission }` every user
who holds it. It then hands `{ source, recipients, notification }` to the
hub, the one plugin that called `ctx.notify.serve(hub)` with the
`notify:hub` capability (high risk: it sees every alert). A second hub is
refused, and a hub whose grant is withdrawn is ignored. With no hub running,
`send` reaches nobody and `channels()` answers `[]`, so a sender never
checks. `notify.hub` is core's word and names no plugin.
- `category` is `<pluginId>.<kind>` by convention (`acme-ssl.renewal_failed`,
`automations.run_failed`) and defaults to the sending plugin's id; users
route by it. `severity` is `info`, `success`, `warning` (the default) or
`critical`. `link.tab` names a tab type the inbox opens; `link.url` must be
http(s). An alert with a `dedupeKey` is dropped while an unread one with the
same key is in that user's inbox.
- The alerts plugin stores each alert once per recipient in
`p_alerts_items`, pushes it to the user's open `GET /stream` (server-sent
events), and sends it to that user's channels: the ones their delivery
rules match (`p_alerts_rules`: a category pattern such as `docker.*` and a
lowest severity) plus the `channelIds` the sender named, only ever the
recipient's own. Channels (`p_alerts_channels`: webhook, ntfy, Discord and
email over the admin's SMTP server) keep their config sealed with
`ctx.secrets.seal`, so an alert sent at night with no user signed in is still
delivered. Private addresses need the channel's opt-in and an exact host in
core's `notification_private_endpoint_allowlist`, which it reads with
`ctx.settings.readCore`.
- Termix announcements (`termix-alerts.json` in the Docs repo, which core
fetched in 2.8) come into each inbox the first time the user looks, and one
the user deletes is recorded in `p_alerts_dismissed` (adopted from core's
`dismissed_alerts`) and never comes back. They never go to channels.
- The upgrade copies core's `notification_channels` into `p_alerts_channels`
with the same ids and reseals each config once its owner's data key opens
(`upgrade/notification-channel-migration.ts`, at boot and at a password
login). The old table stays, emptied of configs, until 3.0.0.
- The frontend puts a bell at the bottom of the rail (a rail item with
`placement: "footer"` and a `useBadge` hook showing the unread count), an
inbox in its panel, and inbox, channels and delivery rules in its tab. New
alerts pop up through a `shell.overlay` component, as far as the user's
`popups` setting allows. Other plugins open it with the `alerts.open` and
`alerts.openChannels` actions. Automations sends its notify steps and
failed runs here, and acme-ssl a failed renewal to every admin.
### 10. Lifecycle ### 10. Lifecycle
`activate(ctx)` creates all state. Everything registered through `ctx` or `app` `activate(ctx)` creates all state. Everything registered through `ctx` or `app`
@@ -2074,7 +2123,8 @@ deciding, not the mechanism.
| `network:broadcast` | medium | Send network broadcast packets | | `network:broadcast` | medium | Send network broadcast packets |
| `users:read` | medium | See usernames and roles, never hashes or 2FA state | | `users:read` | medium | See usernames and roles, never hashes or 2FA state |
| `events:core` | medium | Subscribe to and emit core events carrying other users' data | | `events:core` | medium | Subscribe to and emit core events carrying other users' data |
| `notify:send` | medium | Send notifications through configured channels | | `notify:send` | medium | Send alerts to users and through their channels |
| `notify:hub` | high | Receive, store and deliver every alert all plugins send |
| `audit:read` | medium | Read the audit log | | `audit:read` | medium | Read the audit log |
| `desktop:window` | medium | Open desktop windows (Electron only) | | `desktop:window` | medium | Open desktop windows (Electron only) |
| `hosts:read` | low | See the host list it can already see | | `hosts:read` | low | See the host list it can already see |
@@ -2145,7 +2195,8 @@ authority for signatures.
| `desktop.openIsolatedWindow/launchNativeRdp/available` | `desktop:window` (not `available`) | Electron windows and the native RDP client | | `desktop.openIsolatedWindow/launchNativeRdp/available` | `desktop:window` (not `available`) | Electron windows and the native RDP client |
| `audit.record` | none | An audit line under the plugin's own action name | | `audit.record` | none | An audit line under the plugin's own action name |
| `schedule.every/after` | none | Timers that stop on deactivate and never overlap | | `schedule.every/after` | none | Timers that stop on deactivate and never overlap |
| `notify.channels/send` | `notify:send` | Core's notification channels, as the acting user | | `notify.channels/send` | `notify:send` | Alerts, handed to the hub plugin with the audience resolved in core |
| `notify.serve` | `notify:hub` | Makes the plugin the one hub that stores and delivers alerts |
| `fetch(url, init)` | `network:outbound` | Outbound HTTP through core's SSRF guard; `allowPrivateHosts` hosts also go through the configured proxy | | `fetch(url, init)` | `network:outbound` | Outbound HTTP through core's SSRF guard; `allowPrivateHosts` hosts also go through the configured proxy |
| `process.run/ensureBinary` | `process:spawn` (+ `network:outbound` to download) | Programs on the server | | `process.run/ensureBinary` | `process:spawn` (+ `network:outbound` to download) | Programs on the server |
| `system.tlsStatus/writeTlsCertificate/reloadTls/publishHttpChallenge/registerTlsRenewer` | `system:tls` | The server certificate | | `system.tlsStatus/writeTlsCertificate/reloadTls/publishHttpChallenge/registerTlsRenewer` | `system:tls` | The server certificate |
@@ -2414,14 +2465,11 @@ samples; the default still saves before returning.
**B17** added `ctx.notify` and `ctx.fetch`: **B17** added `ctx.notify` and `ctx.fetch`:
- `ctx.notify.channels()` lists the acting user's notification channels - `ctx.notify.channels()` lists the acting user's notification channels
(id, name, type, enabled, never the config), and `send(channelIds, (id, name, type, enabled, never the config), and `send` delivers an alert.
notification)` delivers to the ones among them that are theirs and Both need `notify:send` and are audited (`plugin_notify_channels`,
enabled, returning `{ delivered, failures }`. Both need `notify:send` and `plugin_notify_send`). **E1** moved channels, delivery and the inbox out of
an actor, and are audited (`plugin_notify_channels`, `plugin_notify_send`). core into the alerts plugin, gave `send` one notification argument with an
Channels stay core (`notification_channels`, its routes and audience, and added `serve`; see Alerts under "Cross-plugin use".
`NotificationChannelDialog`); delivery is `deliverNotification` in
`utils/notification-sender.ts`, which keeps the channel's private-network
opt-in plus the admin allowlist.
- `ctx.fetch(url, { method, headers, body, timeoutMs, allowPrivateHosts })` - `ctx.fetch(url, { method, headers, body, timeoutMs, allowPrivateHosts })`
goes through `safeOutboundFetch`: http and https only, no embedded goes through `safeOutboundFetch`: http and https only, no embedded
credentials, DNS pinned, no redirects, and private or loopback addresses credentials, DNS pinned, no redirects, and private or loopback addresses
+61 -14
View File
@@ -1492,19 +1492,46 @@ export interface PluginAudit {
}) => Promise<void>; }) => 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 { export interface PluginNotificationChannel {
id: number; id: number;
name: string; name: string;
/** "webhook", "ntfy" or "discord". */ /** "webhook", "ntfy", "discord", "email", or whatever the hub supports. */
type: string; type: string;
enabled: boolean; 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 { export interface PluginNotification {
title: string; title: string;
body: string; body?: string;
severity?: "info" | "warning" | "critical"; /** 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` * Extra fields a webhook receiver gets alongside the message. `sourceId`
* and `sourceName` name what sent it (an automation, a rule). * and `sourceName` name what sent it (an automation, a rule).
@@ -1517,30 +1544,50 @@ export interface PluginNotification {
triggerType?: string; triggerType?: string;
value?: unknown; value?: unknown;
threshold?: unknown; threshold?: unknown;
[key: string]: unknown;
}; };
} }
export interface PluginNotifyResult { export interface PluginNotifyResult {
/** Users the alert reached. */
recipients: number;
/** Channel deliveries that succeeded, across every recipient. */
delivered: number; delivered: number;
failures: Array<{ channelId: number; name: string; error: string }>; failures: Array<{ channelId: number; name: string; error: string }>;
} }
/** /**
* Core's notification channels, as the acting user. Needs notify:send, and * The plugin that stores alerts and delivers them to channels. Core resolves
* refuses a call with no actor: channels belong to a user. * 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 { 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[]>; channels: () => Promise<PluginNotificationChannel[]>;
/** /**
* Sends to the listed channels the acting user owns. Disabled channels and * Sends an alert. With no audience it goes to the acting user, so a call
* ids the user does not own are skipped. One channel failing does not stop * with neither is refused. One channel failing does not stop the others.
* the others.
*/ */
send: ( send: (notification: PluginNotification) => Promise<PluginNotifyResult>;
channelIds: number[], /**
notification: PluginNotification, * Makes this plugin the hub. Needs notify:hub. Only one hub runs at a time;
) => Promise<PluginNotifyResult>; * a second one is refused. Revoked on deactivate.
*/
serve: (hub: PluginNotifyHub) => () => void;
} }
export interface PluginFetchInit { export interface PluginFetchInit {
+1
View File
@@ -46,6 +46,7 @@ export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [
entry("users:write", "high"), entry("users:write", "high"),
entry("auth:provide", "high"), entry("auth:provide", "high"),
entry("device:serial", "high"), entry("device:serial", "high"),
entry("notify:hub", "high"),
entry("hosts:write", "medium"), entry("hosts:write", "medium"),
entry("credentials:use", "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", termix_identity_ca: "termix-identity",
webauthn_credentials: "webauthn", webauthn_credentials: "webauthn",
sso_providers: "sso", sso_providers: "sso",
dismissed_alerts: "alerts",
}; };
/** /**
+10
View File
@@ -168,6 +168,16 @@ export interface RailItemContribution {
after?: string; after?: string;
/** Lower sorts first among plugin items. */ /** Lower sorts first among plugin items. */
order?: number; 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 { export interface PanelProps {
+32 -8
View File
@@ -69,6 +69,7 @@ import type {
PluginHostStatusEntry, PluginHostStatusEntry,
PluginNotification, PluginNotification,
PluginNotificationChannel, PluginNotificationChannel,
PluginNotifyHub,
PluginFetchInit, PluginFetchInit,
PluginBinarySpec, PluginBinarySpec,
PluginTlsCertificateInfo, PluginTlsCertificateInfo,
@@ -303,9 +304,10 @@ export interface FakePluginContext {
/** Every ctx.notify.send call, with the actor it ran as. */ /** Every ctx.notify.send call, with the actor it ran as. */
notifications: Array<{ notifications: Array<{
actor: string | undefined; actor: string | undefined;
channelIds: number[];
notification: PluginNotification; 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>". */ /** Backing store behind ctx.secrets.get/set, keyed "<userId>:<key>". */
secretStore: Map<string, string>; secretStore: Map<string, string>;
/** Every ctx.fetch call, in order. */ /** Every ctx.fetch call, in order. */
@@ -434,6 +436,7 @@ export function createFakeContext(
const credentialReads: FakePluginContext["credentialReads"] = []; const credentialReads: FakePluginContext["credentialReads"] = [];
const createdSshKeys: FakePluginContext["createdSshKeys"] = []; const createdSshKeys: FakePluginContext["createdSshKeys"] = [];
const notifications: FakePluginContext["notifications"] = []; const notifications: FakePluginContext["notifications"] = [];
const notifyHub: FakePluginContext["notifyHub"] = { current: null };
const fetches: FakePluginContext["fetches"] = []; const fetches: FakePluginContext["fetches"] = [];
const processRuns: FakePluginContext["processRuns"] = []; const processRuns: FakePluginContext["processRuns"] = [];
const binaries: FakePluginContext["binaries"] = []; const binaries: FakePluginContext["binaries"] = [];
@@ -1105,13 +1108,29 @@ export function createFakeContext(
}, },
notify: { notify: {
channels: async () => options.notificationChannels ?? [], channels: async () => {
send: async (channelIds, notification) => { if (!actor) throw new Error("ctx.notify needs an acting user");
notifications.push({ actor, channelIds, notification }); 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( const selected = (options.notificationChannels ?? []).filter(
(channel) => channelIds.includes(channel.id) && channel.enabled, (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, credentialReads,
createdSshKeys, createdSshKeys,
notifications, notifications,
notifyHub,
fetches, fetches,
processRuns, processRuns,
binaries, 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 * 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 * topic outside the plugin's own namespace, settings:read-core on
* ctx.settings.readCore, ssh:connect plus credentials:use on ctx.ssh, * 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 * 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. * 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"); require("notify:send");
return ctx.notify.channels(); return ctx.notify.channels();
}, },
send: async (channelIds, notification) => { send: async (notification) => {
require("notify:send"); require("notify:send");
return ctx.notify.send(channelIds, notification); return ctx.notify.send(notification);
},
serve: (hub) => {
require("notify:hub");
return ctx.notify.serve(hub);
}, },
}, },
+1
View File
@@ -20,6 +20,7 @@
"secrets:own", "secrets:own",
"network:outbound", "network:outbound",
"network:serve", "network:serve",
"notify:send",
"ui:surface" "ui:surface"
], ],
"contributes": { "contributes": {
+12 -1
View File
@@ -134,10 +134,21 @@ export function createAcmeRunner(
await runner.issue(); await runner.issue();
return true; return true;
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error);
ctx.log.error( ctx.log.error(
"Certificate renewal failed", "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; return false;
} }
}, },
@@ -176,6 +176,23 @@ describe("scheduled renewal", () => {
await vi.waitFor(() => expect(mock.tls.renewers).toBe(0)); 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 () => { it("waits before retrying after a failed attempt", async () => {
const { mock, runner } = await setup({ settings: CONFIGURED }); const { mock, runner } = await setup({ settings: CONFIGURED });
issue.mockRejectedValueOnce(new Error("rate limited")); 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", "editTitle": "Edit Automation",
"empty": "No automations yet. Create one to react to a metric, a schedule, or an event.", "empty": "No automations yet. Create one to react to a metric, a schedule, or an event.",
"emptyRuns": "No runs yet.", "emptyRuns": "No runs yet.",
"emptyChannels": "No channels yet. Add one to send notifications somewhere.", "channelsHint": "Channels are set up in Alerts",
"noChannelsHint": "No channels set up yet. Add one in the Channels tab.", "noChannelsHint": "The alert always goes to your inbox. Add channels in Alerts to also send it somewhere else.",
"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.",
"nameLabel": "Name", "nameLabel": "Name",
"namePlaceholder": "e.g., Clean up /data when it fills", "namePlaceholder": "e.g., Clean up /data when it fills",
"nameRequired": "Name is required", "nameRequired": "Name is required",
@@ -101,7 +95,7 @@
"webhook": "Incoming webhook" "webhook": "Incoming webhook"
}, },
"stepTypes": { "stepTypes": {
"notify": "Send a notification", "notify": "Send an alert",
"http": "Call a URL", "http": "Call a URL",
"run_snippet": "Run a snippet", "run_snippet": "Run a snippet",
"run_command": "Run a command", "run_command": "Run a command",
@@ -140,7 +134,7 @@
"url": "URL", "url": "URL",
"method": "Method", "method": "Method",
"body": "Body", "body": "Body",
"channels": "Channels", "channels": "Also send to",
"title": "Title", "title": "Title",
"message": "Message", "message": "Message",
"snippet": "Snippet", "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 title = renderTemplate(step.title ?? "", context.template);
const body = renderTemplate(step.body ?? "", context.template); const body = renderTemplate(step.body ?? "", context.template);
if (step.channelIds.length === 0) { const channelIds = step.channelIds ?? [];
return fail("No notification channels selected");
}
if (context.dryRun) { if (context.dryRun) {
return ok( 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 ?? {}; const trigger = context.template.trigger ?? {};
let result; let result;
try { try {
result = await runtime.ctx.notify.send(step.channelIds, { result = await runtime.ctx.notify.send({
title: title || "Termix automation", title: title || "Termix automation",
body, body,
severity: step.severity ?? "warning", severity: step.severity ?? "warning",
category: "automations.notify",
audience: { userId: context.userId },
channelIds,
link: { tab: "automations" },
context: { context: {
hostId: hostId:
context.template.host?.id ?? context.template.host?.id ??
@@ -102,12 +106,15 @@ async function runNotify(
return fail(errorText(error)); 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}`); const errors = result.failures.map((f) => `${f.name}: ${f.error}`);
if (result.delivered === 0) { if (channelIds.length > 0 && result.delivered === 0 && errors.length > 0) {
return fail(errors.join("; ") || "No enabled channels to notify"); return fail(errors.join("; "));
} }
return ok( 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 // 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 // its own failure, so the event is not emitted for runs that this event
// already started. // 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") { if (request.triggerType !== "internal_event") {
this.ctx.events.emit(TOPIC_AUTOMATION_FAILED, { this.ctx.events.emit(TOPIC_AUTOMATION_FAILED, {
userId: automation.userId, 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. * channels the user owns, so a link can never point at another user's.
*/ */
export async function createAutomationRepository( 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 * Adopted from core's automation tables, so column and index names are the
* legacy ones and the rename carries every row across. * legacy ones and the rename carries every row across.
* *
* The links between these tables, and a channel link to core's * The links between these tables are plain integers here: the SDK only
* notification_channels, are plain integers here: the SDK only references * references users and ssh_data. The adoption migrations keep the legacy
* users and ssh_data. The adoption migrations keep the legacy FOREIGN KEY * FOREIGN KEY clauses by hand, so deleting an automation still cascades. A
* clauses by hand, so deleting an automation still cascades. * channel id points into the alerts plugin's table and has no foreign key.
*/ */
export const automations = adoptLegacyTable( 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( export const channels = adoptLegacyTable(
"automation_channels", "automation_channels",
defineTable( defineTable(
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from "react"; 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 { toast } from "sonner";
import { import {
ArrowLeft, ArrowLeft,
@@ -11,16 +11,7 @@ import {
Trash2, Trash2,
Workflow, Workflow,
} from "lucide-react"; } from "lucide-react";
import { import { Badge, Button, getBasePath } from "@termix/plugin-sdk/ui";
Badge,
Button,
NotificationChannelDialog,
deleteNotificationChannel,
getBasePath,
getNotificationChannels,
testNotificationChannel,
type NotificationChannel,
} from "@termix/plugin-sdk/ui";
import { import {
useAutomationsApi, useAutomationsApi,
type AutomationRow, type AutomationRow,
@@ -42,7 +33,7 @@ function getErrorMessage(error: unknown, fallback: string): string {
return error instanceof Error ? error.message : fallback; return error instanceof Error ? error.message : fallback;
} }
type PanelTab = "automations" | "runs" | "channels"; type PanelTab = "automations" | "runs";
const STATUS_CLASS: Record<string, string> = { const STATUS_CLASS: Record<string, string> = {
success: "text-green-500", success: "text-green-500",
@@ -96,11 +87,6 @@ export function AutomationsPanel({
const [expandedRun, setExpandedRun] = useState<number | null>(null); const [expandedRun, setExpandedRun] = useState<number | null>(null);
const [runSteps, setRunSteps] = useState<AutomationRunStepRow[]>([]); 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 () => { const loadAutomations = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
@@ -121,15 +107,10 @@ export function AutomationsPanel({
}, [api]); }, [api]);
const loadOptions = useCallback(async () => { const loadOptions = useCallback(async () => {
const [editorOptions, channelsResult] = await Promise.allSettled([ try {
api.editorOptions(), setOptions(await api.editorOptions());
getNotificationChannels(), } catch {
]); // The editor falls back to empty pickers.
if (channelsResult.status === "fulfilled") {
setChannels(channelsResult.value);
}
if (editorOptions.status === "fulfilled") {
setOptions(editorOptions.value);
} }
}, [api]); }, [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) { async function toggleRun(runId: number) {
if (expandedRun === runId) { if (expandedRun === runId) {
setExpandedRun(null); setExpandedRun(null);
@@ -372,7 +332,7 @@ export function AutomationsPanel({
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<div className="flex items-center gap-1 p-2 border-b border-border"> <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 <Button
key={key} key={key}
variant={tab === key ? "outline" : "ghost"} variant={tab === key ? "outline" : "ghost"}
@@ -386,11 +346,20 @@ export function AutomationsPanel({
> >
<span className="truncate"> <span className="truncate">
{t( {t(
`${base}.${key === "automations" ? "tabAutomations" : key === "runs" ? "tabRuns" : "tabChannels"}`, `${base}.${key === "automations" ? "tabAutomations" : "tabRuns"}`,
)} )}
</span> </span>
</Button> </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 <a
href="https://docs.termix.site/features/automations/overview" href="https://docs.termix.site/features/automations/overview"
target="_blank" target="_blank"
@@ -576,83 +545,7 @@ export function AutomationsPanel({
))} ))}
</div> </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> </div>
<NotificationChannelDialog
open={channelDialogOpen}
onOpenChange={setChannelDialogOpen}
channel={editingChannel}
onSaved={() => {
setChannelDialogOpen(false);
void loadOptions();
}}
/>
</div> </div>
); );
} }
@@ -62,10 +62,12 @@ describe("scheduled automations", () => {
expect(server.mock.notifications).toEqual([ expect(server.mock.notifications).toEqual([
{ {
actor: "alice", actor: "alice",
channelIds: [1],
notification: expect.objectContaining({ notification: expect.objectContaining({
title: "Ping schedule", title: "Ping schedule",
body: "hello", 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", "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 // The foreign keys followed the rename: deleting the automation still
// takes its state, schedule, runs, steps and channel links with it. // takes its state, schedule, runs, steps and channel links with it.
db.sqlite db.sqlite
+4 -1
View File
@@ -237,9 +237,12 @@ export async function runLogin(
await unlockUser(user, identity, deviceType); await unlockUser(user, identity, deviceType);
if ("password" in identity && identity.password) { if ("password" in identity && identity.password) {
// A 2.8 key still wrapped by the password only opens here, so this is the // A 2.8 key still wrapped by the password only opens here, so this is the
// first chance to move that user's TOTP secret into the plugin. // first chance to move that user's TOTP secret and channel configs.
const { runTotpMigration } = await import("../upgrade/totp-migration.js"); const { runTotpMigration } = await import("../upgrade/totp-migration.js");
await runTotpMigration(user.id); await runTotpMigration(user.id);
const { runNotificationChannelMigration } =
await import("../upgrade/notification-channel-migration.js");
await runNotificationChannelMigration(user.id);
} }
await syncSharedCredentialsForUserRoles( await syncSharedCredentialsForUserRoles(
user.id, user.id,
-4
View File
@@ -59,10 +59,6 @@ export async function runCoreBootMigrations(): Promise<void> {
await import("./utils/crypto-migration/private-shared-ssh-auth-migration.js"); await import("./utils/crypto-migration/private-shared-ssh-auth-migration.js");
await runPrivateSharedSshAuthMigration(); await runPrivateSharedSshAuthMigration();
const { runChannelConfigEncryptionMigration } =
await import("./utils/crypto-migration/channel-config-encryption.js");
await runChannelConfigEncryptionMigration();
const { runExternalIdentityMigration } = const { runExternalIdentityMigration } =
await import("./upgrade/external-identity-migration.js"); await import("./upgrade/external-identity-migration.js");
await runExternalIdentityMigration(); await runExternalIdentityMigration();
-59
View File
@@ -7,7 +7,6 @@ import multer from "multer";
import cookieParser from "cookie-parser"; import cookieParser from "cookie-parser";
import userRoutes from "./routes/users.js"; import userRoutes from "./routes/users.js";
import hostRoutes from "./routes/host.js"; import hostRoutes from "./routes/host.js";
import alertRoutes from "./routes/alerts.js";
import credentialsRoutes from "./routes/credentials.js"; import credentialsRoutes from "./routes/credentials.js";
import sshAuthRoutes from "./routes/ssh-auth-routes.js"; import sshAuthRoutes from "./routes/ssh-auth-routes.js";
import rbacRoutes from "./routes/rbac.js"; import rbacRoutes from "./routes/rbac.js";
@@ -17,7 +16,6 @@ import hostSidebarPreferencesRoutes from "./routes/host-sidebar-preferences.js";
import credentialSidebarPreferencesRoutes from "./routes/credential-sidebar-preferences.js"; import credentialSidebarPreferencesRoutes from "./routes/credential-sidebar-preferences.js";
import uiPreferencesRoutes from "./routes/ui-preferences.js"; import uiPreferencesRoutes from "./routes/ui-preferences.js";
import { registerAuditLogRoutes } from "./routes/audit-log-routes.js"; import { registerAuditLogRoutes } from "./routes/audit-log-routes.js";
import notificationChannelsRoutes from "./routes/notification-channels-routes.js";
import syncRoutes from "./routes/sync.js"; import syncRoutes from "./routes/sync.js";
import dashboardRoutes from "./routes/dashboard-routes.js"; import dashboardRoutes from "./routes/dashboard-routes.js";
import { import {
@@ -48,7 +46,6 @@ import { configureDirectHttps, getTlsConfig } from "../tls/tls-service.js";
import { acmeChallengeHandler } from "../tls/acme-challenges.js"; import { acmeChallengeHandler } from "../tls/acme-challenges.js";
import { import {
createCurrentCredentialRepository, createCurrentCredentialRepository,
createCurrentDismissedAlertRepository,
createCurrentHostRepository, createCurrentHostRepository,
createCurrentPluginSettingsRepository, createCurrentPluginSettingsRepository,
createCurrentSettingsRepository, createCurrentSettingsRepository,
@@ -866,13 +863,6 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
); );
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
);
CREATE TABLE ssh_credential_usage ( CREATE TABLE ssh_credential_usage (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
credential_id INTEGER NOT NULL, credential_id INTEGER NOT NULL,
@@ -1008,21 +998,6 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
// Rows the user owns in plugin tables travel with the export. // Rows the user owns in plugin tables travel with the export.
await writeUserPluginTables(exportDb, userId); await writeUserPluginTables(exportDb, userId);
const dismissedAlertRepository = createCurrentDismissedAlertRepository();
const alerts = await dismissedAlertRepository.listByUserId(userId);
const insertAlert = exportDb.prepare(`
INSERT INTO dismissed_alerts (id, user_id, alert_id, dismissed_at)
VALUES (?, ?, ?, ?)
`);
for (const alert of alerts) {
insertAlert.run(
alert.id,
alert.userId,
alert.alertId,
alert.dismissedAt,
);
}
const sshCredentialUsageRepository = const sshCredentialUsageRepository =
createCurrentSshCredentialUsageRepository(); createCurrentSshCredentialUsageRepository();
const usage = await sshCredentialUsageRepository.listByUserId(userId); const usage = await sshCredentialUsageRepository.listByUserId(userId);
@@ -1217,7 +1192,6 @@ app.post(
sshHostsImported: 0, sshHostsImported: 0,
sshCredentialsImported: 0, sshCredentialsImported: 0,
pluginItemsImported: 0, pluginItemsImported: 0,
dismissedAlertsImported: 0,
credentialUsageImported: 0, credentialUsageImported: 0,
settingsImported: 0, settingsImported: 0,
skippedItems: 0, skippedItems: 0,
@@ -1366,37 +1340,6 @@ app.post(
result.summary.skippedItems += pluginRows.skipped; result.summary.skippedItems += pluginRows.skipped;
result.summary.errors.push(...pluginRows.errors); result.summary.errors.push(...pluginRows.errors);
const dismissedAlertRepository =
createCurrentDismissedAlertRepository();
try {
const importedAlerts = importDb
.prepare("SELECT * FROM dismissed_alerts")
.all();
for (const alert of importedAlerts) {
try {
const created = await dismissedAlertRepository.createForImport(
userId,
alert.alert_id,
alert.dismissed_at,
);
if (created) {
result.summary.dismissedAlertsImported++;
} else {
result.summary.skippedItems++;
}
} catch (alertError) {
result.summary.errors.push(
`Dismissed alert import error: ${alertError.message}`,
);
}
}
} catch {
apiLogger.info(
"dismissed_alerts table not found in import file, skipping",
);
}
const targetUser = await userRepository.findById(userId); const targetUser = await userRepository.findById(userId);
if (targetUser?.isAdmin) { if (targetUser?.isAdmin) {
try { try {
@@ -1614,7 +1557,6 @@ app.post("/database/restore", requireAdmin, async (req, res) => {
app.use("/users", userRoutes); app.use("/users", userRoutes);
app.use("/host", hostRoutes); app.use("/host", hostRoutes);
app.use("/alerts", alertRoutes);
app.use("/credentials", credentialsRoutes); app.use("/credentials", credentialsRoutes);
app.use("/ssh-auth", sshAuthRoutes); app.use("/ssh-auth", sshAuthRoutes);
app.use("/rbac", rbacRoutes); app.use("/rbac", rbacRoutes);
@@ -1624,7 +1566,6 @@ app.use("/host-sidebar/preferences", hostSidebarPreferencesRoutes);
app.use("/credential-sidebar/preferences", credentialSidebarPreferencesRoutes); app.use("/credential-sidebar/preferences", credentialSidebarPreferencesRoutes);
app.use("/ui-preferences", uiPreferencesRoutes); app.use("/ui-preferences", uiPreferencesRoutes);
registerAuditLogRoutes(app, authenticateJWT); registerAuditLogRoutes(app, authenticateJWT);
app.use("/", notificationChannelsRoutes);
app.use("/sync", syncRoutes); app.use("/sync", syncRoutes);
app.use("/dashboard", dashboardRoutes); app.use("/dashboard", dashboardRoutes);
app.use("/plugins", pluginRoutes); app.use("/plugins", pluginRoutes);
-31
View File
@@ -322,14 +322,6 @@ async function initializeCompleteDatabase(): Promise<void> {
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
); );
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 TABLE IF NOT EXISTS ssh_credentials ( CREATE TABLE IF NOT EXISTS ssh_credentials (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL, user_id TEXT NOT NULL,
@@ -1549,29 +1541,6 @@ const migrateSchema = () => {
}); });
} }
} }
try {
sqlite.prepare("SELECT id FROM notification_channels LIMIT 1").get();
} catch {
try {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS notification_channels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
type TEXT NOT NULL,
config TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`);
} catch (createError) {
databaseLogger.warn("Failed to create notification_channels table", {
operation: "schema_migration",
error: createError,
});
}
}
// --- alerts end --- // --- alerts end ---
@@ -109,11 +109,6 @@ export const PERFORMANCE_INDEXES: PerformanceIndex[] = [
table: "user_open_tabs", table: "user_open_tabs",
columns: "user_id", columns: "user_id",
}, },
{
name: "idx_dismissed_alerts_user_id",
table: "dismissed_alerts",
columns: "user_id",
},
]; ];
interface IndexableDatabase { interface IndexableDatabase {
-30
View File
@@ -271,20 +271,6 @@ export const hosts = mysqlTable(
], ],
); );
export const dismissedAlerts = mysqlTable(
"dismissed_alerts",
{
id: int("id").autoincrement().primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
alertId: text("alert_id").notNull(),
dismissedAt: text("dismissed_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
},
(table) => [index("idx_dismissed_alerts_user_id").on(table.userId)],
);
export const sshCredentials = mysqlTable( export const sshCredentials = mysqlTable(
"ssh_credentials", "ssh_credentials",
@@ -720,22 +706,6 @@ export const uiPreferences = mysqlTable("ui_preferences", {
.default(sql`(CURRENT_TIMESTAMP)`), .default(sql`(CURRENT_TIMESTAMP)`),
}); });
// --- alerts begin ---
export const notificationChannels = mysqlTable("notification_channels", {
id: int("id").autoincrement().primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
name: text("name").notNull(),
type: text("type").notNull(),
config: text("config").notNull(),
enabled: boolean("enabled").notNull().default(true),
createdAt: text("created_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
});
// --- alerts end ---
// --- sync begin --- // --- sync begin ---
// Records a delete for a synced entity type so the other side of a sync // Records a delete for a synced entity type so the other side of a sync
// pair (embedded desktop backend <-> connected remote server) learns about // pair (embedded desktop backend <-> connected remote server) learns about
-30
View File
@@ -272,20 +272,6 @@ export const hosts = pgTable(
], ],
); );
export const dismissedAlerts = pgTable(
"dismissed_alerts",
{
id: serial("id").primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
alertId: text("alert_id").notNull(),
dismissedAt: text("dismissed_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [index("idx_dismissed_alerts_user_id").on(table.userId)],
);
export const sshCredentials = pgTable( export const sshCredentials = pgTable(
"ssh_credentials", "ssh_credentials",
@@ -721,22 +707,6 @@ export const uiPreferences = pgTable("ui_preferences", {
.default(sql`CURRENT_TIMESTAMP`), .default(sql`CURRENT_TIMESTAMP`),
}); });
// --- alerts begin ---
export const notificationChannels = pgTable("notification_channels", {
id: serial("id").primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
name: text("name").notNull(),
type: text("type").notNull(),
config: text("config").notNull(),
enabled: boolean("enabled").notNull().default(true),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
// --- alerts end ---
// --- sync begin --- // --- sync begin ---
// Records a delete for a synced entity type so the other side of a sync // Records a delete for a synced entity type so the other side of a sync
// pair (embedded desktop backend <-> connected remote server) learns about // pair (embedded desktop backend <-> connected remote server) learns about
-30
View File
@@ -264,20 +264,6 @@ export const hosts = sqliteTable(
], ],
); );
export const dismissedAlerts = sqliteTable(
"dismissed_alerts",
{
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
alertId: text("alert_id").notNull(),
dismissedAt: text("dismissed_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [index("idx_dismissed_alerts_user_id").on(table.userId)],
);
export const sshCredentials = sqliteTable( export const sshCredentials = sqliteTable(
"ssh_credentials", "ssh_credentials",
@@ -717,22 +703,6 @@ export const uiPreferences = sqliteTable("ui_preferences", {
.default(sql`CURRENT_TIMESTAMP`), .default(sql`CURRENT_TIMESTAMP`),
}); });
// --- alerts begin ---
export const notificationChannels = sqliteTable("notification_channels", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
name: text("name").notNull(),
type: text("type").notNull(),
config: text("config").notNull(),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
// --- alerts end ---
// --- sync begin --- // --- sync begin ---
// Records a delete for a synced entity type so the other side of a sync // Records a delete for a synced entity type so the other side of a sync
// pair (embedded desktop backend <-> connected remote server) learns about // pair (embedded desktop backend <-> connected remote server) learns about
@@ -1,107 +0,0 @@
import { and, eq } from "drizzle-orm";
import { dismissedAlerts } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
export type DismissedAlertRecord = typeof dismissedAlerts.$inferSelect;
export class DismissedAlertRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async listByUserId(userId: string): Promise<DismissedAlertRecord[]> {
return this.context.drizzle
.select()
.from(dismissedAlerts)
.where(eq(dismissedAlerts.userId, userId));
}
async listAlertIdsByUserId(userId: string): Promise<string[]> {
const rows = await this.context.drizzle
.select({ alertId: dismissedAlerts.alertId })
.from(dismissedAlerts)
.where(eq(dismissedAlerts.userId, userId));
return rows.map((row) => row.alertId);
}
async findForUser(
userId: string,
alertId: string,
): Promise<DismissedAlertRecord | null> {
const rows = await this.context.drizzle
.select()
.from(dismissedAlerts)
.where(
and(
eq(dismissedAlerts.userId, userId),
eq(dismissedAlerts.alertId, alertId),
),
)
.limit(1);
return rows[0] ?? null;
}
async create(userId: string, alertId: string): Promise<void> {
await this.context.drizzle.insert(dismissedAlerts).values({
userId,
alertId,
});
await this.afterWrite();
}
async createForImport(
userId: string,
alertId: string,
dismissedAt = new Date().toISOString(),
): Promise<boolean> {
const existing = await this.findForUser(userId, alertId);
if (existing) {
return false;
}
await this.context.drizzle.insert(dismissedAlerts).values({
userId,
alertId,
dismissedAt,
});
await this.afterWrite();
return true;
}
async deleteForUser(userId: string, alertId: string): Promise<boolean> {
const result = await this.context.drizzle
.delete(dismissedAlerts)
.where(
and(
eq(dismissedAlerts.userId, userId),
eq(dismissedAlerts.alertId, alertId),
),
);
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result) > 0;
}
async deleteByUserId(userId: string): Promise<number> {
const result = await this.context.drizzle
.delete(dismissedAlerts)
.where(eq(dismissedAlerts.userId, userId));
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result);
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}
@@ -3,11 +3,9 @@ import { getDb, getSqlite } from "../db/index.js";
import { needsExplicitPersist, resolveDatabaseDialect } from "../db/dialect.js"; import { needsExplicitPersist, resolveDatabaseDialect } from "../db/dialect.js";
import { primeSettingsCache, readCachedSetting } from "./settings-cache.js"; import { primeSettingsCache, readCachedSetting } from "./settings-cache.js";
import type { DatabaseContext } from "./database-context.js"; import type { DatabaseContext } from "./database-context.js";
import { NotificationChannelRepository } from "./notification-channel-repository.js";
import { ApiKeyRepository } from "./api-key-repository.js"; import { ApiKeyRepository } from "./api-key-repository.js";
import { AuditLogRepository } from "./audit-log-repository.js"; import { AuditLogRepository } from "./audit-log-repository.js";
import { CredentialRepository } from "./credential-repository.js"; import { CredentialRepository } from "./credential-repository.js";
import { DismissedAlertRepository } from "./dismissed-alert-repository.js";
import { HostFolderRepository } from "./host-folder-repository.js"; import { HostFolderRepository } from "./host-folder-repository.js";
import { HostRepository } from "./host-repository.js"; import { HostRepository } from "./host-repository.js";
import { HostResolutionRepository } from "./host-resolution-repository.js"; import { HostResolutionRepository } from "./host-resolution-repository.js";
@@ -119,13 +117,6 @@ export function getCurrentSettingValue(key: string): string | null {
return row?.value ?? null; return row?.value ?? null;
} }
export function createCurrentNotificationChannelRepository(): NotificationChannelRepository {
return new NotificationChannelRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("notification_channel_repository_write"),
);
}
export function createCurrentApiKeyRepository(): ApiKeyRepository { export function createCurrentApiKeyRepository(): ApiKeyRepository {
return new ApiKeyRepository( return new ApiKeyRepository(
createCurrentRepositoryContext(), createCurrentRepositoryContext(),
@@ -154,13 +145,6 @@ export function createCurrentSyncTombstoneRepository(): SyncTombstoneRepository
); );
} }
export function createCurrentDismissedAlertRepository(): DismissedAlertRepository {
return new DismissedAlertRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("dismissed_alert_repository_write"),
);
}
export function createCurrentHostFolderRepository(): HostFolderRepository { export function createCurrentHostFolderRepository(): HostFolderRepository {
return new HostFolderRepository( return new HostFolderRepository(
createCurrentRepositoryContext(), createCurrentRepositoryContext(),
@@ -1,231 +0,0 @@
import { and, eq } from "drizzle-orm";
import { notificationChannels } from "../db/schema.js";
import { DataCrypto } from "../../utils/data-crypto.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import { insertReturning, updateReturning } from "./returning.js";
type NotificationChannelRecord = typeof notificationChannels.$inferSelect;
export interface NotificationChannelRow {
id: number;
user_id: string;
name: string;
type: string;
config: string;
enabled: number;
created_at: string;
}
export class NotificationChannelRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
/**
* Channel configs carry ntfy tokens and webhook auth headers, so they are
* field-encrypted like any other secret. The key is derived from the row id,
* which does not exist until after the insert, so a new channel is written
* once and then re-encrypted in place with its real id.
*/
private userDataKey(userId: string): Buffer | null {
try {
return DataCrypto.getUserDataKey(userId);
} catch {
// Crypto is not initialized (tests, early boot); leave the value as is.
return null;
}
}
private encryptConfig(
config: string,
userId: string,
recordId: number | string,
): string {
const userDataKey = this.userDataKey(userId);
if (!userDataKey) return config;
return DataCrypto.encryptRecord(
"notification_channels",
{ id: recordId, config },
userId,
userDataKey,
).config;
}
private decryptConfig(
config: string,
userId: string,
recordId: number | string,
): string {
const userDataKey = this.userDataKey(userId);
if (!userDataKey) return config;
try {
return DataCrypto.decryptRecord(
"notification_channels",
{ id: recordId, config },
userId,
userDataKey,
).config;
} catch {
// Rows written before channel configs were encrypted are still plaintext.
return config;
}
}
async listNotificationChannels(
userId: string,
): Promise<NotificationChannelRow[]> {
const rows = await this.context.drizzle
.select()
.from(notificationChannels)
.where(eq(notificationChannels.userId, userId))
.orderBy(notificationChannels.id);
return rows.map((row) => {
const mapped = mapChannelRow(row);
mapped.config = this.decryptConfig(mapped.config, userId, mapped.id);
return mapped;
});
}
async findNotificationChannelForUser(
id: number,
userId: string,
): Promise<NotificationChannelRow | null> {
const rows = await this.context.drizzle
.select()
.from(notificationChannels)
.where(
and(
eq(notificationChannels.id, id),
eq(notificationChannels.userId, userId),
),
)
.limit(1);
if (!rows[0]) return null;
const mapped = mapChannelRow(rows[0]);
mapped.config = this.decryptConfig(mapped.config, userId, mapped.id);
return mapped;
}
async createNotificationChannel(input: {
userId: string;
name: string;
type: string;
config: string;
enabled: boolean;
}): Promise<NotificationChannelRow> {
const [created] = await insertReturning(
this.context,
notificationChannels,
{
userId: input.userId,
name: input.name,
type: input.type,
config: input.config,
enabled: input.enabled,
},
);
const encrypted = this.encryptConfig(
input.config,
input.userId,
created.id,
);
if (encrypted !== input.config) {
await this.context.drizzle
.update(notificationChannels)
.set({ config: encrypted })
.where(eq(notificationChannels.id, created.id));
}
await this.afterWrite();
const mapped = mapChannelRow(created);
mapped.config = input.config;
return mapped;
}
async updateNotificationChannel(
id: number,
userId: string,
input: {
name?: string;
type?: string;
config?: string;
enabled?: boolean;
},
): Promise<NotificationChannelRow | null> {
if (Object.keys(input).length === 0) {
return this.findNotificationChannelForUser(id, userId);
}
const values =
input.config !== undefined
? { ...input, config: this.encryptConfig(input.config, userId, id) }
: input;
const [updated] = await updateReturning(
this.context,
notificationChannels,
values,
and(
eq(notificationChannels.id, id),
eq(notificationChannels.userId, userId),
),
);
if (!updated) return null;
await this.afterWrite();
const mapped = mapChannelRow(updated);
mapped.config = this.decryptConfig(mapped.config, userId, mapped.id);
return mapped;
}
async deleteNotificationChannel(
id: number,
userId: string,
): Promise<boolean> {
const result = await this.context.drizzle
.delete(notificationChannels)
.where(
and(
eq(notificationChannels.id, id),
eq(notificationChannels.userId, userId),
),
);
if (rowsAffected(result) === 0) return false;
await this.afterWrite();
return true;
}
async deleteByUserId(userId: string): Promise<{ channelsDeleted: number }> {
const result = await this.context.drizzle
.delete(notificationChannels)
.where(eq(notificationChannels.userId, userId));
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return { channelsDeleted: rowsAffected(result) };
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}
function mapChannelRow(row: NotificationChannelRecord): NotificationChannelRow {
return {
id: row.id,
user_id: row.userId,
name: row.name,
type: row.type,
config: row.config,
enabled: row.enabled ? 1 : 0,
created_at: row.createdAt,
};
}
-282
View File
@@ -1,282 +0,0 @@
import { getErrorMessage } from "../../utils/error-message.js";
import type {
AuthenticatedRequest,
CacheEntry,
TermixAlert,
} from "../../../types/index.js";
import express from "express";
import { authLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { getProxyAgent } from "../../utils/proxy-agent.js";
import { createCurrentDismissedAlertRepository } from "../repositories/factory.js";
class AlertCache {
private cache: Map<string, CacheEntry> = new Map();
private readonly CACHE_DURATION = 5 * 60 * 1000;
set<T>(key: string, data: T): void {
const now = Date.now();
this.cache.set(key, {
data,
timestamp: now,
expiresAt: now + this.CACHE_DURATION,
});
}
get<T>(key: string): T | null {
const entry = this.cache.get(key);
if (!entry) {
return null;
}
if (Date.now() > entry.expiresAt) {
this.cache.delete(key);
return null;
}
return entry.data as T;
}
}
const alertCache = new AlertCache();
const GITHUB_RAW_BASE = "https://raw.githubusercontent.com";
const REPO_OWNER = "Termix-SSH";
const REPO_NAME = "Docs";
const ALERTS_FILE = "main/termix-alerts.json";
async function fetchAlertsFromGitHub(): Promise<TermixAlert[]> {
const cacheKey = "termix_alerts";
const cachedData = alertCache.get<TermixAlert[]>(cacheKey);
if (cachedData) {
return cachedData;
}
try {
const url = `${GITHUB_RAW_BASE}/${REPO_OWNER}/${REPO_NAME}/${ALERTS_FILE}`;
const response = await fetch(url, {
headers: {
Accept: "application/json",
"User-Agent": "TermixAlertChecker/1.0",
},
dispatcher: getProxyAgent(url),
});
if (!response.ok) {
authLogger.warn("GitHub API returned error status", {
operation: "alerts_fetch",
status: response.status,
statusText: response.statusText,
});
throw new Error(
`GitHub raw content error: ${response.status} ${response.statusText}`,
);
}
const alerts: TermixAlert[] = (await response.json()) as TermixAlert[];
const now = new Date();
const validAlerts = alerts.filter((alert) => {
const expiryDate = new Date(alert.expiresAt);
const isValid = expiryDate > now;
return isValid;
});
alertCache.set(cacheKey, validAlerts);
return validAlerts;
} catch (error) {
authLogger.error("Failed to fetch alerts from GitHub", {
operation: "alerts_fetch",
error: getErrorMessage(error),
});
return [];
}
}
const router = express.Router();
const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
/**
* @openapi
* /alerts:
* get:
* summary: Get active alerts
* description: Fetches active alerts for the authenticated user, excluding those that have been dismissed.
* tags:
* - Alerts
* responses:
* 200:
* description: A list of active alerts.
* 500:
* description: Failed to fetch alerts.
*/
router.get("/", authenticateJWT, async (req, res) => {
try {
const userId = (req as AuthenticatedRequest).userId;
const allAlerts = await fetchAlertsFromGitHub();
const dismissedAlertIds = new Set(
await createCurrentDismissedAlertRepository().listAlertIdsByUserId(
userId,
),
);
const activeAlertsForUser = allAlerts.filter(
(alert) => !dismissedAlertIds.has(alert.id),
);
res.json({
alerts: activeAlertsForUser,
cached: alertCache.get("termix_alerts") !== null,
total_count: activeAlertsForUser.length,
});
} catch (error) {
authLogger.error("Failed to get user alerts", error);
res.status(500).json({ error: "Failed to fetch alerts" });
}
});
/**
* @openapi
* /alerts/dismiss:
* post:
* summary: Dismiss an alert
* description: Marks an alert as dismissed for the authenticated user.
* tags:
* - Alerts
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* alertId:
* type: string
* responses:
* 200:
* description: Alert dismissed successfully.
* 400:
* description: Alert ID is required.
* 409:
* description: Alert already dismissed.
* 500:
* description: Failed to dismiss alert.
*/
router.post("/dismiss", authenticateJWT, async (req, res) => {
try {
const { alertId } = req.body;
const userId = (req as AuthenticatedRequest).userId;
if (!alertId) {
authLogger.warn("Missing alertId in dismiss request", { userId });
return res.status(400).json({ error: "Alert ID is required" });
}
const existingDismissal =
await createCurrentDismissedAlertRepository().findForUser(
userId,
alertId,
);
if (existingDismissal) {
authLogger.warn(`Alert ${alertId} already dismissed by user ${userId}`);
return res.status(409).json({ error: "Alert already dismissed" });
}
await createCurrentDismissedAlertRepository().create(userId, alertId);
res.json({ message: "Alert dismissed successfully" });
} catch (error) {
authLogger.error("Failed to dismiss alert", error);
res.status(500).json({ error: "Failed to dismiss alert" });
}
});
/**
* @openapi
* /alerts/dismissed:
* get:
* summary: Get dismissed alerts
* description: Fetches a list of alerts that have been dismissed by the authenticated user.
* tags:
* - Alerts
* responses:
* 200:
* description: A list of dismissed alerts.
* 500:
* description: Failed to fetch dismissed alerts.
*/
router.get("/dismissed", authenticateJWT, async (req, res) => {
try {
const userId = (req as AuthenticatedRequest).userId;
const dismissedAlertRecords =
await createCurrentDismissedAlertRepository().listByUserId(userId);
res.json({
dismissed_alerts: dismissedAlertRecords,
total_count: dismissedAlertRecords.length,
});
} catch (error) {
authLogger.error("Failed to get dismissed alerts", error);
res.status(500).json({ error: "Failed to fetch dismissed alerts" });
}
});
/**
* @openapi
* /alerts/dismiss:
* delete:
* summary: Undismiss an alert
* description: Removes an alert from the dismissed list for the authenticated user.
* tags:
* - Alerts
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* alertId:
* type: string
* responses:
* 200:
* description: Alert undismissed successfully.
* 400:
* description: Alert ID is required.
* 404:
* description: Dismissed alert not found.
* 500:
* description: Failed to undismiss alert.
*/
router.delete("/dismiss", authenticateJWT, async (req, res) => {
try {
const { alertId } = req.body;
const userId = (req as AuthenticatedRequest).userId;
if (!alertId) {
return res.status(400).json({ error: "Alert ID is required" });
}
const deleted = await createCurrentDismissedAlertRepository().deleteForUser(
userId,
alertId,
);
if (!deleted) {
return res.status(404).json({ error: "Dismissed alert not found" });
}
res.json({ message: "Alert undismissed successfully" });
} catch (error) {
authLogger.error("Failed to undismiss alert", error);
res.status(500).json({ error: "Failed to undismiss alert" });
}
});
export default router;
@@ -1,10 +1,8 @@
import { authLogger } from "../../utils/logger.js"; import { authLogger } from "../../utils/logger.js";
import { import {
createCurrentNotificationChannelRepository,
createCurrentApiKeyRepository, createCurrentApiKeyRepository,
createCurrentAuditLogRepository, createCurrentAuditLogRepository,
createCurrentCredentialRepository, createCurrentCredentialRepository,
createCurrentDismissedAlertRepository,
createCurrentHostFolderRepository, createCurrentHostFolderRepository,
createCurrentHostRepository, createCurrentHostRepository,
createCurrentHostSidebarPreferenceRepository, createCurrentHostSidebarPreferenceRepository,
@@ -62,13 +60,11 @@ export async function deleteUserAndRelatedData(
await createCurrentTrustedDeviceRepository().deleteByUserId(userId); await createCurrentTrustedDeviceRepository().deleteByUserId(userId);
await createCurrentRoleRepository().removeAllRolesFromUser(userId); await createCurrentRoleRepository().removeAllRolesFromUser(userId);
await createCurrentNotificationChannelRepository().deleteByUserId(userId);
await createCurrentAuditLogRepository().anonymizeByUserId(userId); await createCurrentAuditLogRepository().anonymizeByUserId(userId);
await createCurrentSshCredentialUsageRepository().deleteByUserId(userId); await createCurrentSshCredentialUsageRepository().deleteByUserId(userId);
await createCurrentRecentActivityRepository().deleteByUserId(userId); await createCurrentRecentActivityRepository().deleteByUserId(userId);
await createCurrentDismissedAlertRepository().deleteByUserId(userId);
await createCurrentHostFolderRepository().deleteByUserId(userId); await createCurrentHostFolderRepository().deleteByUserId(userId);
@@ -1,245 +0,0 @@
import express, { type Request, type Response } from "express";
import type { AuthenticatedRequest } from "../../../types/index.js";
import { createCurrentNotificationChannelRepository } from "../repositories/factory.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { databaseLogger } from "../../utils/logger.js";
import { deliverNotification } from "../../utils/notification-sender.js";
const router = express.Router();
const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
router.use(authenticateJWT);
/**
* @openapi
* /notification-channels:
* get:
* summary: List notification channels for the current user
* tags:
* - Notifications
* responses:
* 200:
* description: List of notification channels.
*/
router.get("/notification-channels", async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const rows =
await createCurrentNotificationChannelRepository().listNotificationChannels(
userId,
);
res.json(rows);
} catch (err) {
databaseLogger.error("Failed to list notification channels", {
operation: "list_channels",
error: err,
});
res.status(500).json({ error: "Failed to list channels" });
}
});
/**
* @openapi
* /notification-channels:
* post:
* summary: Create a notification channel
* tags:
* - Notifications
*/
router.post("/notification-channels", async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
const { name, type, config, enabled } = req.body as {
name: string;
type: string;
config: unknown;
enabled?: boolean;
};
if (!name || typeof name !== "string" || !name.trim()) {
return res.status(400).json({ error: "name is required" });
}
if (type !== "webhook" && type !== "ntfy" && type !== "discord") {
return res
.status(400)
.json({ error: "type must be 'webhook', 'ntfy' or 'discord'" });
}
if (!config || typeof config !== "object") {
return res.status(400).json({ error: "config is required" });
}
if (type === "ntfy") {
const c = config as Record<string, unknown>;
if (!c.url || typeof c.url !== "string")
return res.status(400).json({ error: "ntfy config requires url" });
if (!c.topic || typeof c.topic !== "string")
return res.status(400).json({ error: "ntfy config requires topic" });
}
if (type === "webhook") {
const c = config as Record<string, unknown>;
if (!c.url || typeof c.url !== "string")
return res.status(400).json({ error: "webhook config requires url" });
}
if (type === "discord") {
const c = config as Record<string, unknown>;
if (!c.url || typeof c.url !== "string")
return res.status(400).json({ error: "discord config requires url" });
if (
!/^https:\/\/(?:canary\.|ptb\.)?(?:discord\.com|discordapp\.com)\/api\/webhooks\/.+/i.test(
c.url,
)
) {
return res.status(400).json({
error:
"discord config requires a valid Discord webhook URL (https://discord.com/api/webhooks/...)",
});
}
}
try {
const row =
await createCurrentNotificationChannelRepository().createNotificationChannel(
{
userId,
name: name.trim(),
type,
config: JSON.stringify(config),
enabled: enabled !== false,
},
);
res.status(201).json(row);
} catch (err) {
databaseLogger.error("Failed to create notification channel", {
operation: "create_channel",
error: err,
});
res.status(500).json({ error: "Failed to create channel" });
}
});
/**
* @openapi
* /notification-channels/{id}:
* put:
* summary: Update a notification channel
* tags:
* - Notifications
*/
router.put(
"/notification-channels/:id",
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const channelId = Number(req.params.id);
const { name, type, config, enabled } = req.body as {
name?: string;
type?: string;
config?: unknown;
enabled?: boolean;
};
const repository = createCurrentNotificationChannelRepository();
const existing = await repository.findNotificationChannelForUser(
channelId,
userId,
);
if (!existing) return res.status(404).json({ error: "Channel not found" });
if (type && type !== "webhook" && type !== "ntfy" && type !== "discord") {
return res
.status(400)
.json({ error: "type must be 'webhook', 'ntfy' or 'discord'" });
}
if (
name === undefined &&
type === undefined &&
config === undefined &&
enabled === undefined
) {
return res.json({ success: true });
}
try {
const row = await repository.updateNotificationChannel(
channelId,
userId,
{
...(name !== undefined ? { name: name.trim() } : {}),
...(type !== undefined ? { type } : {}),
...(config !== undefined ? { config: JSON.stringify(config) } : {}),
...(enabled !== undefined ? { enabled } : {}),
},
);
if (!row) return res.status(404).json({ error: "Channel not found" });
res.json(row);
} catch (err) {
databaseLogger.error("Failed to update notification channel", {
operation: "update_channel",
error: err,
});
res.status(500).json({ error: "Failed to update channel" });
}
},
);
/**
* @openapi
* /notification-channels/{id}:
* delete:
* summary: Delete a notification channel
* tags:
* - Notifications
*/
router.delete(
"/notification-channels/:id",
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const channelId = Number(req.params.id);
const deleted =
await createCurrentNotificationChannelRepository().deleteNotificationChannel(
channelId,
userId,
);
if (!deleted) return res.status(404).json({ error: "Channel not found" });
res.json({ success: true });
},
);
/**
* @openapi
* /notification-channels/{id}/test:
* post:
* summary: Send a test notification
* tags:
* - Notifications
*/
router.post(
"/notification-channels/:id/test",
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const channelId = Number(req.params.id);
const row =
await createCurrentNotificationChannelRepository().findNotificationChannelForUser(
channelId,
userId,
);
if (!row) return res.status(404).json({ error: "Channel not found" });
try {
// The same path an automation's notify step takes, so a test proves
// the channel works for real, private-network opt-in included.
await deliverNotification(row, {
title: "Termix",
body: "This is a test notification from Termix",
severity: "info",
context: { sourceName: "Test", triggerType: "test" },
});
res.json({ success: true });
} catch (err) {
res.json({
success: false,
error: err instanceof Error ? err.message : String(err),
});
}
},
);
export default router;
@@ -7,7 +7,6 @@ import { authLogger } from "../../utils/logger.js";
import { loginRateLimiter } from "../../utils/login-rate-limiter.js"; import { loginRateLimiter } from "../../utils/login-rate-limiter.js";
import { import {
createCurrentCredentialRepository, createCurrentCredentialRepository,
createCurrentDismissedAlertRepository,
createCurrentHostRepository, createCurrentHostRepository,
createCurrentRecentActivityRepository, createCurrentRecentActivityRepository,
createCurrentSettingsRepository, createCurrentSettingsRepository,
@@ -70,7 +69,6 @@ export async function resetUserPassword(
await createCurrentSshCredentialUsageRepository().deleteByUserId(userId); await createCurrentSshCredentialUsageRepository().deleteByUserId(userId);
await createCurrentRecentActivityRepository().deleteByUserId(userId); await createCurrentRecentActivityRepository().deleteByUserId(userId);
await createCurrentDismissedAlertRepository().deleteByUserId(userId);
await createCurrentHostRepository().deleteByUserId(userId); await createCurrentHostRepository().deleteByUserId(userId);
await createCurrentCredentialRepository().deleteByUserId(userId); await createCurrentCredentialRepository().deleteByUserId(userId);
// A plugin holding this user's data (snippets and anything else keyed by // A plugin holding this user's data (snippets and anything else keyed by
+74 -43
View File
@@ -1,17 +1,20 @@
/** /**
* ctx.notify and ctx.fetch: core's notification channels and core's guarded * ctx.notify and ctx.fetch: alerts through whichever plugin serves as the
* outbound HTTP. Channels belong to a user, so notify always runs as the * hub, and core's guarded outbound HTTP. Core resolves who an alert is for,
* acting user and never sees a channel's config. * so a plugin names an audience and never a list of users it could not see.
*/ */
import type { import type {
PluginFetch, PluginFetch,
PluginNotification,
PluginNotificationAudience,
PluginNotify, PluginNotify,
PluginNotifyResult, PluginNotifyResult,
} from "@termix/plugin-sdk/backend"; } from "@termix/plugin-sdk/backend";
import type { PluginManifest } from "@termix/plugin-sdk/manifest"; import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import { assertCapability } from "./permissions.js"; import { assertCapability, capabilityRefused } from "./permissions.js";
import { getActor } from "./actor.js"; import { getActor } from "./actor.js";
import { activeNotifyHub, registerNotifyHub } from "./notify-hub.js";
type AuditFn = ( type AuditFn = (
action: string, action: string,
@@ -22,8 +25,16 @@ type AuditFn = (
interface Deps { interface Deps {
manifest: PluginManifest; manifest: PluginManifest;
audit: AuditFn; audit: AuditFn;
/** Where serve's revoke goes, so the hub goes away with the plugin. */
bag?: { add: (dispose: () => void, label: string) => void };
} }
const EMPTY_RESULT: PluginNotifyResult = {
recipients: 0,
delivered: 0,
failures: [],
};
function actingUser(): string { function actingUser(): string {
const actor = getActor(); const actor = getActor();
if (!actor) { if (!actor) {
@@ -60,14 +71,43 @@ async function audited<T>(
} }
} }
/** The user ids an audience names right now. */
export async function resolveAudience(
audience: PluginNotificationAudience | undefined,
): Promise<string[]> {
if (!audience) return [actingUser()];
const { createCurrentUserRepository } =
await import("../database/repositories/factory.js");
const users = createCurrentUserRepository();
if (audience === "admins") {
return (await users.listAll())
.filter((user) => user.isAdmin)
.map((user) => user.id);
}
if ("userId" in audience) {
return (await users.findById(audience.userId)) ? [audience.userId] : [];
}
const { PermissionManager } = await import("../utils/permission-manager.js");
const permissions = PermissionManager.getInstance();
const recipients: string[] = [];
for (const user of await users.listAll()) {
if (await permissions.hasPermission(user.id, audience.permission)) {
recipients.push(user.id);
}
}
return recipients;
}
function describeAudience(notification: PluginNotification): string {
const { audience } = notification;
if (!audience) return "acting user";
if (audience === "admins") return "admins";
if ("userId" in audience) return `user ${audience.userId}`;
return `permission ${audience.permission}`;
}
export function createPluginNotify(deps: Deps): PluginNotify { export function createPluginNotify(deps: Deps): PluginNotify {
const listOwn = async (userId: string) => { const pluginId = deps.manifest.id;
const { createCurrentNotificationChannelRepository } =
await import("../database/repositories/factory.js");
return createCurrentNotificationChannelRepository().listNotificationChannels(
userId,
);
};
return { return {
channels: () => channels: () =>
@@ -77,48 +117,39 @@ export function createPluginNotify(deps: Deps): PluginNotify {
"notify_channels", "notify_channels",
"listed channels", "listed channels",
async () => { async () => {
const rows = await listOwn(actingUser()); const userId = actingUser();
return rows.map((row) => ({ const hub = await activeNotifyHub();
id: row.id, return hub ? hub.channels(userId) : [];
name: row.name,
type: row.type,
enabled: !!row.enabled,
}));
}, },
), ),
send: (channelIds, notification) => send: (notification) =>
audited( audited(
deps, deps,
"notify:send", "notify:send",
"notify_send", "notify_send",
`${channelIds.length} channel(s)`, `${notification.category ?? pluginId} to ${describeAudience(notification)}`,
async () => { async () => {
const rows = await listOwn(actingUser()); const recipients = await resolveAudience(notification.audience);
const { deliverNotification } = const hub = await activeNotifyHub();
await import("../utils/notification-sender.js"); if (!hub || recipients.length === 0) return { ...EMPTY_RESULT };
const result: PluginNotifyResult = { delivered: 0, failures: [] }; return hub.deliver({ source: pluginId, recipients, notification });
for (const row of rows) {
if (!channelIds.includes(row.id) || !row.enabled) continue;
try {
await deliverNotification(row, {
title: notification.title,
body: notification.body,
severity: notification.severity ?? "warning",
context: notification.context,
});
result.delivered++;
} catch (error) {
result.failures.push({
channelId: row.id,
name: row.name,
error: error instanceof Error ? error.message : String(error),
});
}
}
return result;
}, },
), ),
serve: (hub) => {
if (!deps.manifest.capabilities.includes("notify:hub")) {
throw capabilityRefused(pluginId, "notify:hub", "notify_serve");
}
const revoke = registerNotifyHub({
pluginId,
declared: deps.manifest.capabilities,
hub,
});
deps.bag?.add(revoke, "alert hub");
void deps.audit("notify_serve", "serving alerts", { success: true });
return revoke;
},
}; };
} }
+5 -1
View File
@@ -858,7 +858,11 @@ export function createPluginContext(
), ),
), ),
ssh: createPluginSsh({ manifest, bag: handle.bag, audit: auditCall }), ssh: createPluginSsh({ manifest, bag: handle.bag, audit: auditCall }),
notify: createPluginNotify({ manifest, audit: auditCall }), notify: createPluginNotify({
manifest,
audit: auditCall,
bag: handle.bag,
}),
fetch: createPluginFetch({ manifest, audit: auditCall }), fetch: createPluginFetch({ manifest, audit: auditCall }),
process: createPluginProcess({ process: createPluginProcess({
manifest, manifest,
+45
View File
@@ -0,0 +1,45 @@
/**
* The one plugin that stores alerts and delivers them to channels. Core only
* resolves who an alert is for and hands it over; with no hub running an
* alert reaches nobody.
*/
import type { PluginNotifyHub } from "@termix/plugin-sdk/backend";
import { hasCapability } from "./permissions.js";
interface HubRegistration {
pluginId: string;
declared: readonly string[];
hub: PluginNotifyHub;
}
let current: HubRegistration | null = null;
export function registerNotifyHub(registration: HubRegistration): () => void {
if (current && current.pluginId !== registration.pluginId) {
throw new Error(
`Plugin ${registration.pluginId} cannot serve alerts: ${current.pluginId} already does`,
);
}
current = registration;
return () => {
if (current === registration) current = null;
};
}
/** The running hub, or null while none is registered or its grant is gone. */
export async function activeNotifyHub(): Promise<PluginNotifyHub | null> {
const registration = current;
if (!registration) return null;
const granted = await hasCapability(
registration.pluginId,
"notify:hub",
registration.declared,
);
return granted ? registration.hub : null;
}
/** Test seam. */
export function clearNotifyHub(): void {
current = null;
}
@@ -1,93 +0,0 @@
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import { DismissedAlertRepository } from "../../../database/repositories/dismissed-alert-repository.js";
describe("DismissedAlertRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(
onWrite?: () => void | Promise<void>,
): Promise<DismissedAlertRepository> {
adapter = new TestSqliteDatabase();
const context = await adapter.connect();
await adapter.exec(`
INSERT INTO users (id, username, password_hash)
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
`);
return new DismissedAlertRepository(context, onWrite);
}
it("creates, lists, and finds dismissed alerts by user", async () => {
const repo = await createRepository();
await repo.create("user-1", "alert-1");
await repo.create("user-1", "alert-2");
await repo.create("user-2", "alert-3");
expect(await repo.listAlertIdsByUserId("user-1")).toEqual([
"alert-1",
"alert-2",
]);
expect((await repo.findForUser("user-1", "alert-1"))?.alertId).toBe(
"alert-1",
);
expect(await repo.findForUser("user-1", "alert-3")).toBeNull();
expect(
(await repo.listByUserId("user-1")).map((row) => row.alertId),
).toEqual(["alert-1", "alert-2"]);
});
it("creates import alerts without duplicating user alert pairs", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
await expect(
repo.createForImport("user-1", "alert-1", "2026-01-01T00:00:00.000Z"),
).resolves.toBe(true);
await expect(
repo.createForImport("user-1", "alert-1", "2026-01-02T00:00:00.000Z"),
).resolves.toBe(false);
const alerts = await repo.listByUserId("user-1");
expect(alerts).toHaveLength(1);
expect(alerts[0]).toMatchObject({
alertId: "alert-1",
dismissedAt: "2026-01-01T00:00:00.000Z",
});
expect(writeCount).toBe(1);
});
it("deletes dismissed alerts and only triggers writes for changed rows", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
await repo.create("user-1", "alert-1");
await repo.create("user-1", "alert-2");
await repo.create("user-2", "alert-3");
expect(writeCount).toBe(3);
expect(await repo.deleteForUser("user-1", "missing")).toBe(false);
expect(writeCount).toBe(3);
expect(await repo.deleteForUser("user-1", "alert-1")).toBe(true);
expect(writeCount).toBe(4);
expect(await repo.listAlertIdsByUserId("user-1")).toEqual(["alert-2"]);
expect(await repo.deleteByUserId("user-1")).toBe(1);
expect(writeCount).toBe(5);
expect(await repo.deleteByUserId("user-1")).toBe(0);
expect(writeCount).toBe(5);
});
});
@@ -1,69 +0,0 @@
/**
* POST /notification-channels/:id/test sends through deliverNotification,
* the same path an automation's notify step takes.
*/
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const h = vi.hoisted(() => ({
deliver: vi.fn(),
channel: null as null | { id: number; type: string; config: string },
}));
vi.mock("../../../utils/auth-manager.js", () => ({
AuthManager: {
getInstance: () => ({
createAuthMiddleware:
() => (req: { userId?: string }, _res: unknown, next: () => void) => {
req.userId = "user-1";
next();
},
}),
},
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentNotificationChannelRepository: () => ({
findNotificationChannelForUser: async () => h.channel,
}),
}));
vi.mock("../../../utils/notification-sender.js", () => ({
deliverNotification: h.deliver,
}));
const { default: router } =
await import("../../../database/routes/notification-channels-routes.js");
const app = express().use(express.json()).use(router);
beforeEach(() => {
h.deliver.mockReset();
h.channel = { id: 3, type: "ntfy", config: '{"url":"https://ntfy.sh"}' };
});
describe("POST /notification-channels/:id/test", () => {
it("delivers a test notification to the channel", async () => {
h.deliver.mockResolvedValue(undefined);
const response = await request(app).post("/notification-channels/3/test");
expect(response.body).toEqual({ success: true });
expect(h.deliver).toHaveBeenCalledWith(
h.channel,
expect.objectContaining({ severity: "info" }),
);
});
it("reports the delivery error", async () => {
h.deliver.mockRejectedValue(new Error("ntfy channel is missing a topic"));
const response = await request(app).post("/notification-channels/3/test");
expect(response.body).toEqual({
success: false,
error: "ntfy channel is missing a topic",
});
});
it("answers 404 for a channel the user does not own", async () => {
h.channel = null;
const response = await request(app).post("/notification-channels/9/test");
expect(response.status).toBe(404);
});
});
@@ -29,7 +29,6 @@ vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentSettingsRepository: () => ({}), createCurrentSettingsRepository: () => ({}),
createCurrentSshCredentialUsageRepository: deletingRepo("usage"), createCurrentSshCredentialUsageRepository: deletingRepo("usage"),
createCurrentRecentActivityRepository: deletingRepo("activity"), createCurrentRecentActivityRepository: deletingRepo("activity"),
createCurrentDismissedAlertRepository: deletingRepo("alerts"),
createCurrentHostRepository: deletingRepo("hosts"), createCurrentHostRepository: deletingRepo("hosts"),
createCurrentCredentialRepository: deletingRepo("credentials"), createCurrentCredentialRepository: deletingRepo("credentials"),
})); }));
@@ -126,7 +125,6 @@ describe("resetUserPassword", () => {
expect(calls.deletedFor).toEqual([ expect(calls.deletedFor).toEqual([
"usage:user-1", "usage:user-1",
"activity:user-1", "activity:user-1",
"alerts:user-1",
"hosts:user-1", "hosts:user-1",
"credentials:user-1", "credentials:user-1",
]); ]);
Binary file not shown.
+8 -1
View File
@@ -412,6 +412,14 @@ export const ROWS: Record<string, Row[]> = {
enabled: 1, enabled: 1,
}, },
], ],
dismissed_alerts: [
{
id: 1,
user_id: USERS.admin,
alert_id: "d4-dismissed-announcement",
dismissed_at: T,
},
],
snippet_folders: [ snippet_folders: [
{ {
@@ -839,6 +847,5 @@ export const CORE_TABLES = new Set([
"ssh_data", "ssh_data",
"host_access", "host_access",
"user_preferences", "user_preferences",
"notification_channels",
"settings", "settings",
]); ]);
+81
View File
@@ -337,6 +337,14 @@ ALTER TABLE `user_preferences` MODIFY COLUMN `updated_at` text NOT NULL DEFAULT
-- would delete the data first. The columns stay in place, unused, until 3.0.0. -- would delete the data first. The columns stay in place, unused, until 3.0.0.
SELECT 1; SELECT 1;
-- ==== core 0058_boring_avengers ====
-- dismissed_alerts and notification_channels moved to the alerts plugin. It
-- renames the first into its own namespace and copies the second at
-- activation, both after this migration, so a real DROP TABLE here would
-- delete them first. notification_channels stays in place, unused, until
-- 3.0.0.
SELECT 1;
-- ==== plugin workspaces 0001_adopt_user_workspaces.sql ==== -- ==== plugin workspaces 0001_adopt_user_workspaces.sql ====
-- workspaces 0001: adopt_user_workspaces -- workspaces 0001: adopt_user_workspaces
-- Generated by termix-plugin migrations. Review before committing. -- Generated by termix-plugin migrations. Review before committing.
@@ -634,6 +642,23 @@ CREATE TABLE IF NOT EXISTS `automation_channels` (
); );
RENAME TABLE `automation_channels` TO `p_automations_channels`; RENAME TABLE `automation_channels` TO `p_automations_channels`;
-- ==== plugin automations 0002_unlink_channels.sql ====
-- 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`);
-- ==== plugin homepage 0001_adopt_homepage_tables.sql ==== -- ==== plugin homepage 0001_adopt_homepage_tables.sql ====
-- homepage 0001: adopt_homepage_tables -- homepage 0001: adopt_homepage_tables
-- Generated by termix-plugin migrations. Review before committing. -- Generated by termix-plugin migrations. Review before committing.
@@ -729,6 +754,62 @@ CREATE TABLE IF NOT EXISTS `ai_proposals` (
); );
RENAME TABLE `ai_proposals` TO `p_ai_proposals`; RENAME TABLE `ai_proposals` TO `p_ai_proposals`;
-- ==== plugin alerts 0001_create_alerts.sql ====
-- 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`;
-- ==== plugin file-manager 0001_adopt_file_manager_tables.sql ==== -- ==== plugin file-manager 0001_adopt_file_manager_tables.sql ====
-- file-manager 0001: adopt_file_manager_tables -- file-manager 0001: adopt_file_manager_tables
-- Generated by termix-plugin migrations. Review before committing. -- Generated by termix-plugin migrations. Review before committing.
+83
View File
@@ -363,6 +363,14 @@ ALTER TABLE "user_preferences" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIM
-- would delete the data first. The columns stay in place, unused, until 3.0.0. -- would delete the data first. The columns stay in place, unused, until 3.0.0.
SELECT 1; SELECT 1;
-- ==== core 0059_square_korg ====
-- dismissed_alerts and notification_channels moved to the alerts plugin. It
-- renames the first into its own namespace and copies the second at
-- activation, both after this migration, so a real DROP TABLE here would
-- delete them first. notification_channels stays in place, unused, until
-- 3.0.0.
SELECT 1;
-- ==== plugin workspaces 0001_adopt_user_workspaces.sql ==== -- ==== plugin workspaces 0001_adopt_user_workspaces.sql ====
-- workspaces 0001: adopt_user_workspaces -- workspaces 0001: adopt_user_workspaces
-- Generated by termix-plugin migrations. Review before committing. -- Generated by termix-plugin migrations. Review before committing.
@@ -682,6 +690,24 @@ CREATE TABLE IF NOT EXISTS "automation_channels" (
CREATE UNIQUE INDEX IF NOT EXISTS "idx_automation_channels_pair" ON "automation_channels" ("automation_id", "channel_id"); CREATE UNIQUE INDEX IF NOT EXISTS "idx_automation_channels_pair" ON "automation_channels" ("automation_id", "channel_id");
ALTER TABLE "automation_channels" RENAME TO "p_automations_channels"; ALTER TABLE "automation_channels" RENAME TO "p_automations_channels";
-- ==== plugin automations 0002_unlink_channels.sql ====
-- 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");
-- ==== plugin homepage 0001_adopt_homepage_tables.sql ==== -- ==== plugin homepage 0001_adopt_homepage_tables.sql ====
-- homepage 0001: adopt_homepage_tables -- homepage 0001: adopt_homepage_tables
-- Generated by termix-plugin migrations. Review before committing. -- Generated by termix-plugin migrations. Review before committing.
@@ -784,6 +810,63 @@ CREATE INDEX IF NOT EXISTS "idx_ai_proposals_user" ON "ai_proposals" ("user_id",
CREATE INDEX IF NOT EXISTS "idx_ai_proposals_conversation" ON "ai_proposals" ("conversation_id"); CREATE INDEX IF NOT EXISTS "idx_ai_proposals_conversation" ON "ai_proposals" ("conversation_id");
ALTER TABLE "ai_proposals" RENAME TO "p_ai_proposals"; ALTER TABLE "ai_proposals" RENAME TO "p_ai_proposals";
-- ==== plugin alerts 0001_create_alerts.sql ====
-- 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";
-- ==== plugin file-manager 0001_adopt_file_manager_tables.sql ==== -- ==== plugin file-manager 0001_adopt_file_manager_tables.sql ====
-- file-manager 0001: adopt_file_manager_tables -- file-manager 0001: adopt_file_manager_tables
-- Generated by termix-plugin migrations. Review before committing. -- Generated by termix-plugin migrations. Review before committing.
Loaded 100 of 129 files, more files were not shown because too many files have changed in this diff. Show more