mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
feat: unify alerts into one plugin, channels, rules and inbox
This commit is contained in:
1 parent
fbe736bc20
commit
7c02daccf4
129 files changed
+20957
-2687
No files matched your search
@@ -8,6 +8,10 @@
|
||||
"id": "ai",
|
||||
"source": "workspace"
|
||||
},
|
||||
{
|
||||
"id": "alerts",
|
||||
"source": "workspace"
|
||||
},
|
||||
{
|
||||
"id": "automations",
|
||||
"source": "workspace"
|
||||
|
||||
@@ -222,24 +222,6 @@ http {
|
||||
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(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
@@ -191,24 +191,6 @@ http {
|
||||
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(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
@@ -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
@@ -407,6 +407,13 @@
|
||||
"when": 1790304466729,
|
||||
"tag": "0057_serious_shatterstar",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 58,
|
||||
"version": "5",
|
||||
"when": 1790370152568,
|
||||
"tag": "0058_boring_avengers",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -414,6 +414,13 @@
|
||||
"when": 1790304465181,
|
||||
"tag": "0058_goofy_ma_gnuci",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 59,
|
||||
"version": "7",
|
||||
"when": 1790370151997,
|
||||
"tag": "0059_square_korg",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -379,6 +379,13 @@
|
||||
"when": 1790304468233,
|
||||
"tag": "0053_bent_absorbing_man",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 54,
|
||||
"version": "6",
|
||||
"when": 1790370151385,
|
||||
"tag": "0054_superb_guardian",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
Generated
+34
@@ -7087,6 +7087,10 @@
|
||||
"resolved": "plugins/ai",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@termix-plugin/alerts": {
|
||||
"resolved": "plugins/alerts",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@termix-plugin/automations": {
|
||||
"resolved": "plugins/automations",
|
||||
"link": true
|
||||
@@ -7578,6 +7582,16 @@
|
||||
"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": {
|
||||
"version": "8.23.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz",
|
||||
@@ -14859,6 +14873,15 @@
|
||||
"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": {
|
||||
"version": "9.0.0",
|
||||
"resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz",
|
||||
@@ -18930,6 +18953,17 @@
|
||||
"@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": {
|
||||
"name": "@termix-plugin/automations",
|
||||
"version": "1.0.0",
|
||||
|
||||
@@ -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
|
||||
`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>_`.
|
||||
|
||||
| Area | Tables |
|
||||
| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 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` |
|
||||
| Hosts | `ssh_data`, `ssh_folders`, `recent_activity`, `dismissed_alerts` |
|
||||
| Hosts | `ssh_data`, `ssh_folders`, `recent_activity` |
|
||||
| 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` |
|
||||
| Audit and alerts | `audit_logs`, `notification_channels` |
|
||||
| Audit | `audit_logs` |
|
||||
| Sync | `sync_tombstones` |
|
||||
| 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
|
||||
boundary and Suspense.
|
||||
|
||||
| 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 |
|
||||
| `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 |
|
||||
| `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 |
|
||||
| `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 |
|
||||
| `registerPaletteEntry` | A global or per-host command palette entry |
|
||||
| `registerDashboardCard` | A dashboard card, id in `contributes.dashboardCards` |
|
||||
| `registerHomepageWidget` | A homepage widget, with an optional edit form |
|
||||
| `registerSettingsComponent` | A component a `type: "custom"` settings field names |
|
||||
| `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 |
|
||||
| `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` |
|
||||
| `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**) |
|
||||
| `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`) |
|
||||
| `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 |
|
||||
| `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` |
|
||||
| `onSettingsChanged` | Called when one of the plugin's settings changes |
|
||||
| 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. `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` |
|
||||
| `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` |
|
||||
| `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 |
|
||||
| `registerHostBadge`, `registerHostContextMenuItem` | Host row badges and context menu entries |
|
||||
| `registerPaletteEntry` | A global or per-host command palette entry |
|
||||
| `registerDashboardCard` | A dashboard card, id in `contributes.dashboardCards` |
|
||||
| `registerHomepageWidget` | A homepage widget, with an optional edit form |
|
||||
| `registerSettingsComponent` | A component a `type: "custom"` settings field names |
|
||||
| `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 |
|
||||
| `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` |
|
||||
| `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**) |
|
||||
| `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`) |
|
||||
| `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 |
|
||||
| `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` |
|
||||
| `onSettingsChanged` | Called when one of the plugin's settings changes |
|
||||
|
||||
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.
|
||||
**B14** added `buildOriginWsUrl`, `getBasePath`, `resolveRemoteHostId` and the
|
||||
`ConnectionStage` type, for remote desktop's display socket and its remote
|
||||
host lookup. **B17** added `NotificationChannelDialog` and the channel API
|
||||
(`getNotificationChannels`, `deleteNotificationChannel`,
|
||||
`testNotificationChannel`, the `NotificationChannel` type): channels are core,
|
||||
so a plugin that manages them shows core's dialog. `useConnectionDefaults` now carries terminal defaults only; the
|
||||
host lookup. **B17** added a channel dialog and API for core's notification
|
||||
channels, which **E1** removed again when channels moved to the alerts
|
||||
plugin: a plugin that wants its user to manage channels invokes
|
||||
`alerts.openChannels`. `useConnectionDefaults` now carries terminal defaults only; the
|
||||
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.
|
||||
**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
|
||||
(`tunnels.access`), Docker (`docker.containers`, `docker.events`), host metrics
|
||||
(`host-metrics.viewers`) and wake-on-lan (`wake-on-lan.send` v1,
|
||||
`wake(hostId)`, which **B20** provides) as services, notification channels through
|
||||
`ctx.notify`, the HTTP step through `ctx.fetch`, hosts through `ctx.hosts` and
|
||||
`wake(hostId)`, which **B20** provides) as services, alerts through
|
||||
`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
|
||||
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
|
||||
@@ -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
|
||||
permissions) for the AI assistant, which lists automations as an optional
|
||||
dependency. It adopts `automations` and the five `automation_*` tables; the
|
||||
migrations keep the legacy links between them and to `notification_channels`
|
||||
by hand. Core's `hosts/automation-events.ts` became `hosts/internal-events.ts`
|
||||
migrations keep the legacy links between them by hand. Its channel links
|
||||
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
|
||||
live at `/plugin-api/automations/webhook/<token>`; the 2.8 path
|
||||
`/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
|
||||
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
|
||||
|
||||
`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 |
|
||||
| `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 |
|
||||
| `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 |
|
||||
| `desktop:window` | medium | Open desktop windows (Electron only) |
|
||||
| `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 |
|
||||
| `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 |
|
||||
| `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 |
|
||||
| `process.run/ensureBinary` | `process:spawn` (+ `network:outbound` to download) | Programs on the server |
|
||||
| `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`:
|
||||
|
||||
- `ctx.notify.channels()` lists the acting user's notification channels
|
||||
(id, name, type, enabled, never the config), and `send(channelIds,
|
||||
notification)` delivers to the ones among them that are theirs and
|
||||
enabled, returning `{ delivered, failures }`. Both need `notify:send` and
|
||||
an actor, and are audited (`plugin_notify_channels`, `plugin_notify_send`).
|
||||
Channels stay core (`notification_channels`, its routes and
|
||||
`NotificationChannelDialog`); delivery is `deliverNotification` in
|
||||
`utils/notification-sender.ts`, which keeps the channel's private-network
|
||||
opt-in plus the admin allowlist.
|
||||
(id, name, type, enabled, never the config), and `send` delivers an alert.
|
||||
Both need `notify:send` and are audited (`plugin_notify_channels`,
|
||||
`plugin_notify_send`). **E1** moved channels, delivery and the inbox out of
|
||||
core into the alerts plugin, gave `send` one notification argument with an
|
||||
audience, and added `serve`; see Alerts under "Cross-plugin use".
|
||||
- `ctx.fetch(url, { method, headers, body, timeoutMs, allowPrivateHosts })`
|
||||
goes through `safeOutboundFetch`: http and https only, no embedded
|
||||
credentials, DNS pinned, no redirects, and private or loopback addresses
|
||||
|
||||
@@ -1492,19 +1492,46 @@ export interface PluginAudit {
|
||||
}) => Promise<void>;
|
||||
}
|
||||
|
||||
/** A notification channel the acting user set up. Its config never leaves core. */
|
||||
/** A notification channel a user set up. Its config never leaves the hub. */
|
||||
export interface PluginNotificationChannel {
|
||||
id: number;
|
||||
name: string;
|
||||
/** "webhook", "ntfy" or "discord". */
|
||||
/** "webhook", "ntfy", "discord", "email", or whatever the hub supports. */
|
||||
type: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export type PluginNotificationSeverity =
|
||||
"info" | "success" | "warning" | "critical";
|
||||
|
||||
/**
|
||||
* Who receives an alert. Left out, it goes to the acting user. "admins" and
|
||||
* a permission reach every user who holds it when the alert is sent.
|
||||
*/
|
||||
export type PluginNotificationAudience =
|
||||
{ userId: string } | { permission: string } | "admins";
|
||||
|
||||
export interface PluginNotification {
|
||||
title: string;
|
||||
body: string;
|
||||
severity?: "info" | "warning" | "critical";
|
||||
body?: string;
|
||||
/** Defaults to "warning". */
|
||||
severity?: PluginNotificationSeverity;
|
||||
/**
|
||||
* What kind of alert this is, "<pluginId>.<kind>" by convention, e.g.
|
||||
* "acme-ssl.renewal_failed". Users route alerts to channels by it.
|
||||
* Defaults to the sending plugin's id.
|
||||
*/
|
||||
category?: string;
|
||||
audience?: PluginNotificationAudience;
|
||||
/**
|
||||
* Channels to deliver to on top of the recipient's own routing rules.
|
||||
* Only channels the recipient owns are used.
|
||||
*/
|
||||
channelIds?: number[];
|
||||
/** Where the alert points: a tab type to open, or an http(s) URL. */
|
||||
link?: { tab?: string; url?: string };
|
||||
/** While an unread alert with this key exists, repeats are dropped. */
|
||||
dedupeKey?: string;
|
||||
/**
|
||||
* Extra fields a webhook receiver gets alongside the message. `sourceId`
|
||||
* and `sourceName` name what sent it (an automation, a rule).
|
||||
@@ -1517,30 +1544,50 @@ export interface PluginNotification {
|
||||
triggerType?: string;
|
||||
value?: unknown;
|
||||
threshold?: unknown;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PluginNotifyResult {
|
||||
/** Users the alert reached. */
|
||||
recipients: number;
|
||||
/** Channel deliveries that succeeded, across every recipient. */
|
||||
delivered: number;
|
||||
failures: Array<{ channelId: number; name: string; error: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Core's notification channels, as the acting user. Needs notify:send, and
|
||||
* refuses a call with no actor: channels belong to a user.
|
||||
* The plugin that stores alerts and delivers them to channels. Core resolves
|
||||
* the audience and hands the hub a list of user ids; it never sees a channel.
|
||||
*/
|
||||
export interface PluginNotifyHub {
|
||||
deliver: (input: {
|
||||
/** The plugin that sent the alert. */
|
||||
source: string;
|
||||
recipients: string[];
|
||||
notification: PluginNotification;
|
||||
}) => Promise<PluginNotifyResult>;
|
||||
channels: (userId: string) => Promise<PluginNotificationChannel[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Alerts. Every plugin sends through here and the hub plugin decides where
|
||||
* they land. Needs notify:send. With no hub running, send reaches nobody and
|
||||
* channels answers an empty list, so callers never have to check.
|
||||
*/
|
||||
export interface PluginNotify {
|
||||
/** The acting user's channels, without their config. */
|
||||
/** The acting user's channels, without their config. Needs an actor. */
|
||||
channels: () => Promise<PluginNotificationChannel[]>;
|
||||
/**
|
||||
* Sends to the listed channels the acting user owns. Disabled channels and
|
||||
* ids the user does not own are skipped. One channel failing does not stop
|
||||
* the others.
|
||||
* Sends an alert. With no audience it goes to the acting user, so a call
|
||||
* with neither is refused. One channel failing does not stop the others.
|
||||
*/
|
||||
send: (
|
||||
channelIds: number[],
|
||||
notification: PluginNotification,
|
||||
) => Promise<PluginNotifyResult>;
|
||||
send: (notification: PluginNotification) => Promise<PluginNotifyResult>;
|
||||
/**
|
||||
* Makes this plugin the hub. Needs notify:hub. Only one hub runs at a time;
|
||||
* a second one is refused. Revoked on deactivate.
|
||||
*/
|
||||
serve: (hub: PluginNotifyHub) => () => void;
|
||||
}
|
||||
|
||||
export interface PluginFetchInit {
|
||||
|
||||
@@ -46,6 +46,7 @@ export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [
|
||||
entry("users:write", "high"),
|
||||
entry("auth:provide", "high"),
|
||||
entry("device:serial", "high"),
|
||||
entry("notify:hub", "high"),
|
||||
|
||||
entry("hosts:write", "medium"),
|
||||
entry("credentials:use", "medium"),
|
||||
|
||||
@@ -265,6 +265,7 @@ export const LEGACY_TABLE_OWNERS: Readonly<Record<string, string>> = {
|
||||
termix_identity_ca: "termix-identity",
|
||||
webauthn_credentials: "webauthn",
|
||||
sso_providers: "sso",
|
||||
dismissed_alerts: "alerts",
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -168,6 +168,16 @@ export interface RailItemContribution {
|
||||
after?: string;
|
||||
/** Lower sorts first among plugin items. */
|
||||
order?: number;
|
||||
/**
|
||||
* "footer" puts it at the bottom of the rail, above the user's profile,
|
||||
* for something always at hand like an inbox. Default "main".
|
||||
*/
|
||||
placement?: "main" | "footer";
|
||||
/**
|
||||
* A hook the rail calls to show a count on the icon, e.g. unread alerts.
|
||||
* Zero or nothing shows no badge.
|
||||
*/
|
||||
useBadge?: () => number | null | undefined;
|
||||
}
|
||||
|
||||
export interface PanelProps {
|
||||
|
||||
@@ -69,6 +69,7 @@ import type {
|
||||
PluginHostStatusEntry,
|
||||
PluginNotification,
|
||||
PluginNotificationChannel,
|
||||
PluginNotifyHub,
|
||||
PluginFetchInit,
|
||||
PluginBinarySpec,
|
||||
PluginTlsCertificateInfo,
|
||||
@@ -303,9 +304,10 @@ export interface FakePluginContext {
|
||||
/** Every ctx.notify.send call, with the actor it ran as. */
|
||||
notifications: Array<{
|
||||
actor: string | undefined;
|
||||
channelIds: number[];
|
||||
notification: PluginNotification;
|
||||
}>;
|
||||
/** The hub the plugin registered with ctx.notify.serve, if any. */
|
||||
notifyHub: { current: PluginNotifyHub | null };
|
||||
/** Backing store behind ctx.secrets.get/set, keyed "<userId>:<key>". */
|
||||
secretStore: Map<string, string>;
|
||||
/** Every ctx.fetch call, in order. */
|
||||
@@ -434,6 +436,7 @@ export function createFakeContext(
|
||||
const credentialReads: FakePluginContext["credentialReads"] = [];
|
||||
const createdSshKeys: FakePluginContext["createdSshKeys"] = [];
|
||||
const notifications: FakePluginContext["notifications"] = [];
|
||||
const notifyHub: FakePluginContext["notifyHub"] = { current: null };
|
||||
const fetches: FakePluginContext["fetches"] = [];
|
||||
const processRuns: FakePluginContext["processRuns"] = [];
|
||||
const binaries: FakePluginContext["binaries"] = [];
|
||||
@@ -1105,13 +1108,29 @@ export function createFakeContext(
|
||||
},
|
||||
|
||||
notify: {
|
||||
channels: async () => options.notificationChannels ?? [],
|
||||
send: async (channelIds, notification) => {
|
||||
notifications.push({ actor, channelIds, notification });
|
||||
channels: async () => {
|
||||
if (!actor) throw new Error("ctx.notify needs an acting user");
|
||||
return options.notificationChannels ?? [];
|
||||
},
|
||||
send: async (notification) => {
|
||||
if (!notification.audience && !actor) {
|
||||
throw new Error("ctx.notify needs an acting user or an audience");
|
||||
}
|
||||
notifications.push({ actor, notification });
|
||||
const channelIds = notification.channelIds ?? [];
|
||||
const selected = (options.notificationChannels ?? []).filter(
|
||||
(channel) => channelIds.includes(channel.id) && channel.enabled,
|
||||
);
|
||||
return { delivered: selected.length, failures: [] };
|
||||
return { recipients: 1, delivered: selected.length, failures: [] };
|
||||
},
|
||||
serve: (hub) => {
|
||||
if (notifyHub.current) throw new Error("A hub is already running");
|
||||
notifyHub.current = hub;
|
||||
const revoke = () => {
|
||||
if (notifyHub.current === hub) notifyHub.current = null;
|
||||
};
|
||||
disposals.push(revoke);
|
||||
return revoke;
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1235,6 +1254,7 @@ export function createFakeContext(
|
||||
credentialReads,
|
||||
createdSshKeys,
|
||||
notifications,
|
||||
notifyHub,
|
||||
fetches,
|
||||
processRuns,
|
||||
binaries,
|
||||
@@ -1333,7 +1353,7 @@ export interface MockPluginContext extends FakePluginContext {
|
||||
* ctx.db, network:serve on ctx.http and ctx.ws, events:core on emitting a
|
||||
* topic outside the plugin's own namespace, settings:read-core on
|
||||
* ctx.settings.readCore, ssh:connect plus credentials:use on ctx.ssh,
|
||||
* notify:send on ctx.notify, network:outbound on ctx.fetch, process:spawn on
|
||||
* notify:send on ctx.notify (notify:hub on serve), network:outbound on ctx.fetch, process:spawn on
|
||||
* ctx.process, system:tls on ctx.system, and auth:provide on ctx.auth. Reading a plugin's own settings is deliberately
|
||||
* ungated. As the SDK grows a member, add its gate here in the same shape.
|
||||
*/
|
||||
@@ -1692,9 +1712,13 @@ export function createMockCtx(
|
||||
require("notify:send");
|
||||
return ctx.notify.channels();
|
||||
},
|
||||
send: async (channelIds, notification) => {
|
||||
send: async (notification) => {
|
||||
require("notify:send");
|
||||
return ctx.notify.send(channelIds, notification);
|
||||
return ctx.notify.send(notification);
|
||||
},
|
||||
serve: (hub) => {
|
||||
require("notify:hub");
|
||||
return ctx.notify.serve(hub);
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"secrets:own",
|
||||
"network:outbound",
|
||||
"network:serve",
|
||||
"notify:send",
|
||||
"ui:surface"
|
||||
],
|
||||
"contributes": {
|
||||
|
||||
@@ -134,10 +134,21 @@ export function createAcmeRunner(
|
||||
await runner.issue();
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
ctx.log.error(
|
||||
"Certificate renewal failed",
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
error instanceof Error ? error : new Error(message),
|
||||
);
|
||||
await ctx.notify
|
||||
.send({
|
||||
title: `Certificate renewal failed for ${settings.domain}`,
|
||||
body: message,
|
||||
severity: "critical",
|
||||
category: "acme-ssl.renewal_failed",
|
||||
audience: "admins",
|
||||
dedupeKey: `acme-ssl.renewal_failed:${settings.domain}`,
|
||||
})
|
||||
.catch(() => {});
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -176,6 +176,23 @@ describe("scheduled renewal", () => {
|
||||
await vi.waitFor(() => expect(mock.tls.renewers).toBe(0));
|
||||
});
|
||||
|
||||
it("alerts every admin when a renewal fails", async () => {
|
||||
const { mock, runner } = await setup({ settings: CONFIGURED });
|
||||
issue.mockRejectedValueOnce(new Error("rate limited"));
|
||||
expect(await runner.check()).toBe(false);
|
||||
expect(mock.notifications).toEqual([
|
||||
{
|
||||
actor: undefined,
|
||||
notification: expect.objectContaining({
|
||||
body: "rate limited",
|
||||
severity: "critical",
|
||||
category: "acme-ssl.renewal_failed",
|
||||
audience: "admins",
|
||||
}),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("waits before retrying after a failed attempt", async () => {
|
||||
const { mock, runner } = await setup({ settings: CONFIGURED });
|
||||
issue.mockRejectedValueOnce(new Error("rate limited"));
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## 1.0.0
|
||||
|
||||
Bundled with Termix 2.9.0.
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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,
|
||||
}));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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() {}
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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];
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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() };
|
||||
}
|
||||
}
|
||||
@@ -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(" ");
|
||||
}
|
||||
@@ -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 }>,
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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" },
|
||||
};
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "@termix/plugin-sdk/tsconfig.plugin.json",
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { pluginVitestConfig } from "@termix/plugin-sdk/vitest-preset";
|
||||
|
||||
export default pluginVitestConfig(import.meta.url);
|
||||
@@ -34,14 +34,8 @@
|
||||
"editTitle": "Edit Automation",
|
||||
"empty": "No automations yet. Create one to react to a metric, a schedule, or an event.",
|
||||
"emptyRuns": "No runs yet.",
|
||||
"emptyChannels": "No channels yet. Add one to send notifications somewhere.",
|
||||
"noChannelsHint": "No channels set up yet. Add one in the Channels tab.",
|
||||
"addChannel": "Add Channel",
|
||||
"testChannel": "Test",
|
||||
"channelTestSent": "Test notification sent",
|
||||
"channelTestFailed": "Test notification failed",
|
||||
"channelDeleted": "Channel deleted",
|
||||
"deleteChannelConfirm": "Delete this channel? Automations using it will stop notifying.",
|
||||
"channelsHint": "Channels are set up in Alerts",
|
||||
"noChannelsHint": "The alert always goes to your inbox. Add channels in Alerts to also send it somewhere else.",
|
||||
"nameLabel": "Name",
|
||||
"namePlaceholder": "e.g., Clean up /data when it fills",
|
||||
"nameRequired": "Name is required",
|
||||
@@ -101,7 +95,7 @@
|
||||
"webhook": "Incoming webhook"
|
||||
},
|
||||
"stepTypes": {
|
||||
"notify": "Send a notification",
|
||||
"notify": "Send an alert",
|
||||
"http": "Call a URL",
|
||||
"run_snippet": "Run a snippet",
|
||||
"run_command": "Run a command",
|
||||
@@ -140,7 +134,7 @@
|
||||
"url": "URL",
|
||||
"method": "Method",
|
||||
"body": "Body",
|
||||
"channels": "Channels",
|
||||
"channels": "Also send to",
|
||||
"title": "Title",
|
||||
"message": "Message",
|
||||
"snippet": "Snippet",
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
-- automations 0002: unlink_channels
|
||||
-- Hand-written: channels moved from core's notification_channels to the
|
||||
-- alerts plugin, so the link table drops its foreign key to the old table.
|
||||
-- The constraint's name differs between installs, so the table is rebuilt.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `p_automations_channels_v2` (
|
||||
`id` int AUTO_INCREMENT PRIMARY KEY,
|
||||
`automation_id` int NOT NULL,
|
||||
`channel_id` int NOT NULL,
|
||||
FOREIGN KEY (`automation_id`) REFERENCES `p_automations_automations` (`id`) ON DELETE CASCADE
|
||||
);
|
||||
INSERT INTO `p_automations_channels_v2` (`id`, `automation_id`, `channel_id`) SELECT `id`, `automation_id`, `channel_id` FROM `p_automations_channels`;
|
||||
DROP TABLE `p_automations_channels`;
|
||||
RENAME TABLE `p_automations_channels_v2` TO `p_automations_channels`;
|
||||
CREATE UNIQUE INDEX `idx_automation_channels_pair` ON `p_automations_channels` (`automation_id`, `channel_id`);
|
||||
@@ -0,0 +1,16 @@
|
||||
-- automations 0002: unlink_channels
|
||||
-- Hand-written: channels moved from core's notification_channels to the
|
||||
-- alerts plugin, so the link table drops its foreign key to the old table.
|
||||
-- The constraint's name differs between installs, so the table is rebuilt.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "p_automations_channels_v2" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"automation_id" integer NOT NULL,
|
||||
"channel_id" integer NOT NULL,
|
||||
FOREIGN KEY ("automation_id") REFERENCES "p_automations_automations" ("id") ON DELETE CASCADE
|
||||
);
|
||||
INSERT INTO "p_automations_channels_v2" ("id", "automation_id", "channel_id") SELECT "id", "automation_id", "channel_id" FROM "p_automations_channels";
|
||||
DROP TABLE "p_automations_channels";
|
||||
ALTER TABLE "p_automations_channels_v2" RENAME TO "p_automations_channels";
|
||||
SELECT setval(pg_get_serial_sequence('p_automations_channels', 'id'), COALESCE((SELECT MAX("id") FROM "p_automations_channels"), 1));
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "idx_automation_channels_pair" ON "p_automations_channels" ("automation_id", "channel_id");
|
||||
@@ -0,0 +1,15 @@
|
||||
-- automations 0002: unlink_channels
|
||||
-- Hand-written: channels moved from core's notification_channels to the
|
||||
-- alerts plugin, so the link table drops its foreign key to the old table.
|
||||
-- SQLite cannot drop a constraint, so the table is rebuilt.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "p_automations_channels_v2" (
|
||||
"id" integer PRIMARY KEY AUTOINCREMENT,
|
||||
"automation_id" integer NOT NULL,
|
||||
"channel_id" integer NOT NULL,
|
||||
FOREIGN KEY ("automation_id") REFERENCES "p_automations_automations" ("id") ON DELETE CASCADE
|
||||
);
|
||||
INSERT INTO "p_automations_channels_v2" ("id", "automation_id", "channel_id") SELECT "id", "automation_id", "channel_id" FROM "p_automations_channels";
|
||||
DROP TABLE "p_automations_channels";
|
||||
ALTER TABLE "p_automations_channels_v2" RENAME TO "p_automations_channels";
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "idx_automation_channels_pair" ON "p_automations_channels" ("automation_id", "channel_id");
|
||||
@@ -67,22 +67,26 @@ async function runNotify(
|
||||
const title = renderTemplate(step.title ?? "", context.template);
|
||||
const body = renderTemplate(step.body ?? "", context.template);
|
||||
|
||||
if (step.channelIds.length === 0) {
|
||||
return fail("No notification channels selected");
|
||||
}
|
||||
const channelIds = step.channelIds ?? [];
|
||||
if (context.dryRun) {
|
||||
return ok(
|
||||
`Would notify ${step.channelIds.length} channel(s): ${title || body}`,
|
||||
channelIds.length > 0
|
||||
? `Would send an alert to the inbox and ${channelIds.length} channel(s): ${title || body}`
|
||||
: `Would send an alert to the inbox: ${title || body}`,
|
||||
);
|
||||
}
|
||||
|
||||
const trigger = context.template.trigger ?? {};
|
||||
let result;
|
||||
try {
|
||||
result = await runtime.ctx.notify.send(step.channelIds, {
|
||||
result = await runtime.ctx.notify.send({
|
||||
title: title || "Termix automation",
|
||||
body,
|
||||
severity: step.severity ?? "warning",
|
||||
category: "automations.notify",
|
||||
audience: { userId: context.userId },
|
||||
channelIds,
|
||||
link: { tab: "automations" },
|
||||
context: {
|
||||
hostId:
|
||||
context.template.host?.id ??
|
||||
@@ -102,12 +106,15 @@ async function runNotify(
|
||||
return fail(errorText(error));
|
||||
}
|
||||
|
||||
if (result.recipients === 0) {
|
||||
return fail("Alerts are turned off on this server");
|
||||
}
|
||||
const errors = result.failures.map((f) => `${f.name}: ${f.error}`);
|
||||
if (result.delivered === 0) {
|
||||
return fail(errors.join("; ") || "No enabled channels to notify");
|
||||
if (channelIds.length > 0 && result.delivered === 0 && errors.length > 0) {
|
||||
return fail(errors.join("; "));
|
||||
}
|
||||
return ok(
|
||||
`Notified ${result.delivered} channel(s)${errors.length ? `; ${errors.join("; ")}` : ""}`,
|
||||
`Sent an alert${result.delivered ? ` and notified ${result.delivered} channel(s)` : ""}${errors.length ? `; ${errors.join("; ")}` : ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -302,6 +302,27 @@ export class AutomationEngine {
|
||||
// An automation_failed handler that itself fails must not re-announce
|
||||
// its own failure, so the event is not emitted for runs that this event
|
||||
// already started.
|
||||
await this.ctx.notify
|
||||
.send({
|
||||
title: `Automation "${automation.name}" failed`,
|
||||
body: error ?? undefined,
|
||||
severity: "warning",
|
||||
category: "automations.run_failed",
|
||||
audience: { userId: automation.userId },
|
||||
link: { tab: "automations" },
|
||||
dedupeKey: `automations.run_failed:${automation.id}`,
|
||||
context: {
|
||||
sourceId: automation.id,
|
||||
sourceName: automation.name,
|
||||
triggerType: request.triggerType,
|
||||
},
|
||||
})
|
||||
.catch((notifyError: unknown) =>
|
||||
this.ctx.log.warn(
|
||||
`Could not send the failure alert for automation ${automation.id}: ${errorText(notifyError)}`,
|
||||
),
|
||||
);
|
||||
|
||||
if (request.triggerType !== "internal_event") {
|
||||
this.ctx.events.emit(TOPIC_AUTOMATION_FAILED, {
|
||||
userId: automation.userId,
|
||||
|
||||
@@ -99,7 +99,7 @@ export type AutomationRepository = Awaited<
|
||||
>;
|
||||
|
||||
/**
|
||||
* Automation storage. `ownedChannelIds` answers which of core's notification
|
||||
* Automation storage. `ownedChannelIds` answers which alert
|
||||
* channels the user owns, so a link can never point at another user's.
|
||||
*/
|
||||
export async function createAutomationRepository(
|
||||
|
||||
@@ -15,10 +15,10 @@ import {
|
||||
* Adopted from core's automation tables, so column and index names are the
|
||||
* legacy ones and the rename carries every row across.
|
||||
*
|
||||
* The links between these tables, and a channel link to core's
|
||||
* notification_channels, are plain integers here: the SDK only references
|
||||
* users and ssh_data. The adoption migrations keep the legacy FOREIGN KEY
|
||||
* clauses by hand, so deleting an automation still cascades.
|
||||
* The links between these tables are plain integers here: the SDK only
|
||||
* references users and ssh_data. The adoption migrations keep the legacy
|
||||
* FOREIGN KEY clauses by hand, so deleting an automation still cascades. A
|
||||
* channel id points into the alerts plugin's table and has no foreign key.
|
||||
*/
|
||||
|
||||
export const automations = adoptLegacyTable(
|
||||
@@ -166,7 +166,7 @@ export const runSteps = adoptLegacyTable(
|
||||
),
|
||||
);
|
||||
|
||||
/** Which of core's notification channels an automation is linked to. */
|
||||
/** Which alert channels an automation is linked to. */
|
||||
export const channels = adoptLegacyTable(
|
||||
"automation_channels",
|
||||
defineTable(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { invokeAction, useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
ArrowLeft,
|
||||
@@ -11,16 +11,7 @@ import {
|
||||
Trash2,
|
||||
Workflow,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
NotificationChannelDialog,
|
||||
deleteNotificationChannel,
|
||||
getBasePath,
|
||||
getNotificationChannels,
|
||||
testNotificationChannel,
|
||||
type NotificationChannel,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import { Badge, Button, getBasePath } from "@termix/plugin-sdk/ui";
|
||||
import {
|
||||
useAutomationsApi,
|
||||
type AutomationRow,
|
||||
@@ -42,7 +33,7 @@ function getErrorMessage(error: unknown, fallback: string): string {
|
||||
return error instanceof Error ? error.message : fallback;
|
||||
}
|
||||
|
||||
type PanelTab = "automations" | "runs" | "channels";
|
||||
type PanelTab = "automations" | "runs";
|
||||
|
||||
const STATUS_CLASS: Record<string, string> = {
|
||||
success: "text-green-500",
|
||||
@@ -96,11 +87,6 @@ export function AutomationsPanel({
|
||||
const [expandedRun, setExpandedRun] = useState<number | null>(null);
|
||||
const [runSteps, setRunSteps] = useState<AutomationRunStepRow[]>([]);
|
||||
|
||||
const [channels, setChannels] = useState<NotificationChannel[]>([]);
|
||||
const [channelDialogOpen, setChannelDialogOpen] = useState(false);
|
||||
const [editingChannel, setEditingChannel] =
|
||||
useState<NotificationChannel | null>(null);
|
||||
|
||||
const loadAutomations = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -121,15 +107,10 @@ export function AutomationsPanel({
|
||||
}, [api]);
|
||||
|
||||
const loadOptions = useCallback(async () => {
|
||||
const [editorOptions, channelsResult] = await Promise.allSettled([
|
||||
api.editorOptions(),
|
||||
getNotificationChannels(),
|
||||
]);
|
||||
if (channelsResult.status === "fulfilled") {
|
||||
setChannels(channelsResult.value);
|
||||
}
|
||||
if (editorOptions.status === "fulfilled") {
|
||||
setOptions(editorOptions.value);
|
||||
try {
|
||||
setOptions(await api.editorOptions());
|
||||
} catch {
|
||||
// The editor falls back to empty pickers.
|
||||
}
|
||||
}, [api]);
|
||||
|
||||
@@ -241,27 +222,6 @@ export function AutomationsPanel({
|
||||
}
|
||||
}
|
||||
|
||||
async function testChannel(channel: NotificationChannel) {
|
||||
try {
|
||||
// Throws with the reason when delivery fails.
|
||||
await testNotificationChannel(channel.id);
|
||||
toast.success(t(`${base}.channelTestSent`));
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, String(error)));
|
||||
}
|
||||
}
|
||||
|
||||
async function removeChannel(channel: NotificationChannel) {
|
||||
if (!confirm(t(`${base}.deleteChannelConfirm`))) return;
|
||||
try {
|
||||
await deleteNotificationChannel(channel.id);
|
||||
toast.success(t(`${base}.channelDeleted`));
|
||||
await loadOptions();
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, String(error)));
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleRun(runId: number) {
|
||||
if (expandedRun === runId) {
|
||||
setExpandedRun(null);
|
||||
@@ -372,7 +332,7 @@ export function AutomationsPanel({
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center gap-1 p-2 border-b border-border">
|
||||
{(["automations", "runs", "channels"] as PanelTab[]).map((key) => (
|
||||
{(["automations", "runs"] as PanelTab[]).map((key) => (
|
||||
<Button
|
||||
key={key}
|
||||
variant={tab === key ? "outline" : "ghost"}
|
||||
@@ -386,11 +346,20 @@ export function AutomationsPanel({
|
||||
>
|
||||
<span className="truncate">
|
||||
{t(
|
||||
`${base}.${key === "automations" ? "tabAutomations" : key === "runs" ? "tabRuns" : "tabChannels"}`,
|
||||
`${base}.${key === "automations" ? "tabAutomations" : "tabRuns"}`,
|
||||
)}
|
||||
</span>
|
||||
</Button>
|
||||
))}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="min-w-0 flex-shrink rounded-none h-7 px-2 text-xs"
|
||||
title={t(`${base}.channelsHint`)}
|
||||
onClick={() => void invokeAction("alerts.openChannels")}
|
||||
>
|
||||
<span className="truncate">{t(`${base}.tabChannels`)}</span>
|
||||
</Button>
|
||||
<a
|
||||
href="https://docs.termix.site/features/automations/overview"
|
||||
target="_blank"
|
||||
@@ -576,83 +545,7 @@ export function AutomationsPanel({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "channels" && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="rounded-none w-full mb-2 border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
onClick={() => {
|
||||
setEditingChannel(null);
|
||||
setChannelDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus size={14} className="mr-1" />
|
||||
{t(`${base}.addChannel`)}
|
||||
</Button>
|
||||
|
||||
{channels.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground p-2">
|
||||
{t(`${base}.emptyChannels`)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-1">
|
||||
{channels.map((channel) => (
|
||||
<div
|
||||
key={channel.id}
|
||||
className="border border-border p-2.5 hover:bg-muted/40"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
className="flex-1 text-left text-sm truncate"
|
||||
onClick={() => {
|
||||
setEditingChannel(channel);
|
||||
setChannelDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
{channel.name}
|
||||
</button>
|
||||
<Badge variant="outline" className="text-[10px] uppercase">
|
||||
{channel.type}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<span className="flex-1" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 rounded-none text-[11px]"
|
||||
onClick={() => testChannel(channel)}
|
||||
>
|
||||
{t(`${base}.testChannel`)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 rounded-none"
|
||||
onClick={() => removeChannel(channel)}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<NotificationChannelDialog
|
||||
open={channelDialogOpen}
|
||||
onOpenChange={setChannelDialogOpen}
|
||||
channel={editingChannel}
|
||||
onSaved={() => {
|
||||
setChannelDialogOpen(false);
|
||||
void loadOptions();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -62,10 +62,12 @@ describe("scheduled automations", () => {
|
||||
expect(server.mock.notifications).toEqual([
|
||||
{
|
||||
actor: "alice",
|
||||
channelIds: [1],
|
||||
notification: expect.objectContaining({
|
||||
title: "Ping schedule",
|
||||
body: "hello",
|
||||
category: "automations.notify",
|
||||
audience: { userId: "alice" },
|
||||
channelIds: [1],
|
||||
}),
|
||||
},
|
||||
]);
|
||||
@@ -381,3 +383,76 @@ describe("watchers", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("alerts", () => {
|
||||
const schedule = { kind: "schedule", intervalSeconds: 300 };
|
||||
|
||||
it("sends a notify step to the owner's inbox when no channel is picked", async () => {
|
||||
server = await startServer();
|
||||
const id = await create(
|
||||
server,
|
||||
"Inbox only",
|
||||
definition(schedule, [
|
||||
{ id: "n", type: "notify", channelIds: [], title: "Heads up" },
|
||||
]),
|
||||
);
|
||||
|
||||
const outcome = await server.request("POST", `/${id}/run`, { body: {} });
|
||||
|
||||
expect(outcome.body.status).toBe("success");
|
||||
expect(server.mock.notifications).toEqual([
|
||||
{
|
||||
actor: "alice",
|
||||
notification: expect.objectContaining({
|
||||
title: "Heads up",
|
||||
audience: { userId: "alice" },
|
||||
channelIds: [],
|
||||
link: { tab: "automations" },
|
||||
}),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("only describes the alert on a dry run", async () => {
|
||||
server = await startServer();
|
||||
const id = await create(
|
||||
server,
|
||||
"Dry",
|
||||
definition(schedule, [
|
||||
{ id: "n", type: "notify", channelIds: [1], title: "Heads up" },
|
||||
]),
|
||||
);
|
||||
|
||||
const outcome = await server.request("POST", `/${id}/run`, {
|
||||
body: { dryRun: true },
|
||||
});
|
||||
|
||||
expect(outcome.body.status).toBe("success");
|
||||
expect(server.mock.notifications).toEqual([]);
|
||||
});
|
||||
|
||||
it("alerts the owner when a run fails", async () => {
|
||||
server = await startServer();
|
||||
const id = await create(
|
||||
server,
|
||||
"Breaks",
|
||||
definition(schedule, [{ id: "s", type: "stop", status: "failed" }]),
|
||||
);
|
||||
|
||||
const outcome = await server.request("POST", `/${id}/run`, { body: {} });
|
||||
|
||||
expect(outcome.body.status).toBe("failed");
|
||||
expect(server.mock.notifications).toEqual([
|
||||
{
|
||||
actor: "alice",
|
||||
notification: expect.objectContaining({
|
||||
title: 'Automation "Breaks" failed',
|
||||
severity: "warning",
|
||||
category: "automations.run_failed",
|
||||
audience: { userId: "alice" },
|
||||
dedupeKey: `automations.run_failed:${id}`,
|
||||
}),
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -175,6 +175,23 @@ describe("adopting the automation tables", () => {
|
||||
"idx_automation_runs_user",
|
||||
]);
|
||||
|
||||
// Channels moved to the alerts plugin, so a link may name a channel the
|
||||
// old table never had, and the old one's link is still there.
|
||||
expect(indexes(db, "p_automations_channels")).toEqual([
|
||||
"idx_automation_channels_pair",
|
||||
]);
|
||||
expect(
|
||||
db.sqlite.prepare("SELECT channel_id FROM p_automations_channels").all(),
|
||||
).toEqual([{ channel_id: 4 }]);
|
||||
db.sqlite
|
||||
.prepare(
|
||||
"INSERT INTO p_automations_channels (automation_id, channel_id) VALUES (7, 99)",
|
||||
)
|
||||
.run();
|
||||
db.sqlite
|
||||
.prepare("DELETE FROM p_automations_channels WHERE channel_id = 99")
|
||||
.run();
|
||||
|
||||
// The foreign keys followed the rename: deleting the automation still
|
||||
// takes its state, schedule, runs, steps and channel links with it.
|
||||
db.sqlite
|
||||
|
||||
@@ -237,9 +237,12 @@ export async function runLogin(
|
||||
await unlockUser(user, identity, deviceType);
|
||||
if ("password" in identity && identity.password) {
|
||||
// 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");
|
||||
await runTotpMigration(user.id);
|
||||
const { runNotificationChannelMigration } =
|
||||
await import("../upgrade/notification-channel-migration.js");
|
||||
await runNotificationChannelMigration(user.id);
|
||||
}
|
||||
await syncSharedCredentialsForUserRoles(
|
||||
user.id,
|
||||
|
||||
@@ -59,10 +59,6 @@ export async function runCoreBootMigrations(): Promise<void> {
|
||||
await import("./utils/crypto-migration/private-shared-ssh-auth-migration.js");
|
||||
await runPrivateSharedSshAuthMigration();
|
||||
|
||||
const { runChannelConfigEncryptionMigration } =
|
||||
await import("./utils/crypto-migration/channel-config-encryption.js");
|
||||
await runChannelConfigEncryptionMigration();
|
||||
|
||||
const { runExternalIdentityMigration } =
|
||||
await import("./upgrade/external-identity-migration.js");
|
||||
await runExternalIdentityMigration();
|
||||
|
||||
@@ -7,7 +7,6 @@ import multer from "multer";
|
||||
import cookieParser from "cookie-parser";
|
||||
import userRoutes from "./routes/users.js";
|
||||
import hostRoutes from "./routes/host.js";
|
||||
import alertRoutes from "./routes/alerts.js";
|
||||
import credentialsRoutes from "./routes/credentials.js";
|
||||
import sshAuthRoutes from "./routes/ssh-auth-routes.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 uiPreferencesRoutes from "./routes/ui-preferences.js";
|
||||
import { registerAuditLogRoutes } from "./routes/audit-log-routes.js";
|
||||
import notificationChannelsRoutes from "./routes/notification-channels-routes.js";
|
||||
import syncRoutes from "./routes/sync.js";
|
||||
import dashboardRoutes from "./routes/dashboard-routes.js";
|
||||
import {
|
||||
@@ -48,7 +46,6 @@ import { configureDirectHttps, getTlsConfig } from "../tls/tls-service.js";
|
||||
import { acmeChallengeHandler } from "../tls/acme-challenges.js";
|
||||
import {
|
||||
createCurrentCredentialRepository,
|
||||
createCurrentDismissedAlertRepository,
|
||||
createCurrentHostRepository,
|
||||
createCurrentPluginSettingsRepository,
|
||||
createCurrentSettingsRepository,
|
||||
@@ -866,13 +863,6 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
|
||||
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 (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
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.
|
||||
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 =
|
||||
createCurrentSshCredentialUsageRepository();
|
||||
const usage = await sshCredentialUsageRepository.listByUserId(userId);
|
||||
@@ -1217,7 +1192,6 @@ app.post(
|
||||
sshHostsImported: 0,
|
||||
sshCredentialsImported: 0,
|
||||
pluginItemsImported: 0,
|
||||
dismissedAlertsImported: 0,
|
||||
credentialUsageImported: 0,
|
||||
settingsImported: 0,
|
||||
skippedItems: 0,
|
||||
@@ -1366,37 +1340,6 @@ app.post(
|
||||
result.summary.skippedItems += pluginRows.skipped;
|
||||
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);
|
||||
if (targetUser?.isAdmin) {
|
||||
try {
|
||||
@@ -1614,7 +1557,6 @@ app.post("/database/restore", requireAdmin, async (req, res) => {
|
||||
|
||||
app.use("/users", userRoutes);
|
||||
app.use("/host", hostRoutes);
|
||||
app.use("/alerts", alertRoutes);
|
||||
app.use("/credentials", credentialsRoutes);
|
||||
app.use("/ssh-auth", sshAuthRoutes);
|
||||
app.use("/rbac", rbacRoutes);
|
||||
@@ -1624,7 +1566,6 @@ app.use("/host-sidebar/preferences", hostSidebarPreferencesRoutes);
|
||||
app.use("/credential-sidebar/preferences", credentialSidebarPreferencesRoutes);
|
||||
app.use("/ui-preferences", uiPreferencesRoutes);
|
||||
registerAuditLogRoutes(app, authenticateJWT);
|
||||
app.use("/", notificationChannelsRoutes);
|
||||
app.use("/sync", syncRoutes);
|
||||
app.use("/dashboard", dashboardRoutes);
|
||||
app.use("/plugins", pluginRoutes);
|
||||
|
||||
@@ -322,14 +322,6 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
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 (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
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 ---
|
||||
|
||||
|
||||
|
||||
@@ -109,11 +109,6 @@ export const PERFORMANCE_INDEXES: PerformanceIndex[] = [
|
||||
table: "user_open_tabs",
|
||||
columns: "user_id",
|
||||
},
|
||||
{
|
||||
name: "idx_dismissed_alerts_user_id",
|
||||
table: "dismissed_alerts",
|
||||
columns: "user_id",
|
||||
},
|
||||
];
|
||||
|
||||
interface IndexableDatabase {
|
||||
|
||||
@@ -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(
|
||||
"ssh_credentials",
|
||||
@@ -720,22 +706,6 @@ export const uiPreferences = mysqlTable("ui_preferences", {
|
||||
.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 ---
|
||||
// Records a delete for a synced entity type so the other side of a sync
|
||||
// pair (embedded desktop backend <-> connected remote server) learns about
|
||||
|
||||
@@ -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(
|
||||
"ssh_credentials",
|
||||
@@ -721,22 +707,6 @@ export const uiPreferences = pgTable("ui_preferences", {
|
||||
.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 ---
|
||||
// Records a delete for a synced entity type so the other side of a sync
|
||||
// pair (embedded desktop backend <-> connected remote server) learns about
|
||||
|
||||
@@ -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(
|
||||
"ssh_credentials",
|
||||
@@ -717,22 +703,6 @@ export const uiPreferences = sqliteTable("ui_preferences", {
|
||||
.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 ---
|
||||
// Records a delete for a synced entity type so the other side of a sync
|
||||
// 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 { primeSettingsCache, readCachedSetting } from "./settings-cache.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { NotificationChannelRepository } from "./notification-channel-repository.js";
|
||||
import { ApiKeyRepository } from "./api-key-repository.js";
|
||||
import { AuditLogRepository } from "./audit-log-repository.js";
|
||||
import { CredentialRepository } from "./credential-repository.js";
|
||||
import { DismissedAlertRepository } from "./dismissed-alert-repository.js";
|
||||
import { HostFolderRepository } from "./host-folder-repository.js";
|
||||
import { HostRepository } from "./host-repository.js";
|
||||
import { HostResolutionRepository } from "./host-resolution-repository.js";
|
||||
@@ -119,13 +117,6 @@ export function getCurrentSettingValue(key: string): string | null {
|
||||
return row?.value ?? null;
|
||||
}
|
||||
|
||||
export function createCurrentNotificationChannelRepository(): NotificationChannelRepository {
|
||||
return new NotificationChannelRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("notification_channel_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentApiKeyRepository(): ApiKeyRepository {
|
||||
return new ApiKeyRepository(
|
||||
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 {
|
||||
return new HostFolderRepository(
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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 {
|
||||
createCurrentNotificationChannelRepository,
|
||||
createCurrentApiKeyRepository,
|
||||
createCurrentAuditLogRepository,
|
||||
createCurrentCredentialRepository,
|
||||
createCurrentDismissedAlertRepository,
|
||||
createCurrentHostFolderRepository,
|
||||
createCurrentHostRepository,
|
||||
createCurrentHostSidebarPreferenceRepository,
|
||||
@@ -62,13 +60,11 @@ export async function deleteUserAndRelatedData(
|
||||
await createCurrentTrustedDeviceRepository().deleteByUserId(userId);
|
||||
|
||||
await createCurrentRoleRepository().removeAllRolesFromUser(userId);
|
||||
await createCurrentNotificationChannelRepository().deleteByUserId(userId);
|
||||
await createCurrentAuditLogRepository().anonymizeByUserId(userId);
|
||||
|
||||
await createCurrentSshCredentialUsageRepository().deleteByUserId(userId);
|
||||
|
||||
await createCurrentRecentActivityRepository().deleteByUserId(userId);
|
||||
await createCurrentDismissedAlertRepository().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 {
|
||||
createCurrentCredentialRepository,
|
||||
createCurrentDismissedAlertRepository,
|
||||
createCurrentHostRepository,
|
||||
createCurrentRecentActivityRepository,
|
||||
createCurrentSettingsRepository,
|
||||
@@ -70,7 +69,6 @@ export async function resetUserPassword(
|
||||
|
||||
await createCurrentSshCredentialUsageRepository().deleteByUserId(userId);
|
||||
await createCurrentRecentActivityRepository().deleteByUserId(userId);
|
||||
await createCurrentDismissedAlertRepository().deleteByUserId(userId);
|
||||
await createCurrentHostRepository().deleteByUserId(userId);
|
||||
await createCurrentCredentialRepository().deleteByUserId(userId);
|
||||
// A plugin holding this user's data (snippets and anything else keyed by
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
/**
|
||||
* ctx.notify and ctx.fetch: core's notification channels and core's guarded
|
||||
* outbound HTTP. Channels belong to a user, so notify always runs as the
|
||||
* acting user and never sees a channel's config.
|
||||
* ctx.notify and ctx.fetch: alerts through whichever plugin serves as the
|
||||
* hub, and core's guarded outbound HTTP. Core resolves who an alert is for,
|
||||
* so a plugin names an audience and never a list of users it could not see.
|
||||
*/
|
||||
|
||||
import type {
|
||||
PluginFetch,
|
||||
PluginNotification,
|
||||
PluginNotificationAudience,
|
||||
PluginNotify,
|
||||
PluginNotifyResult,
|
||||
} from "@termix/plugin-sdk/backend";
|
||||
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 { activeNotifyHub, registerNotifyHub } from "./notify-hub.js";
|
||||
|
||||
type AuditFn = (
|
||||
action: string,
|
||||
@@ -22,8 +25,16 @@ type AuditFn = (
|
||||
interface Deps {
|
||||
manifest: PluginManifest;
|
||||
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 {
|
||||
const actor = getActor();
|
||||
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 {
|
||||
const listOwn = async (userId: string) => {
|
||||
const { createCurrentNotificationChannelRepository } =
|
||||
await import("../database/repositories/factory.js");
|
||||
return createCurrentNotificationChannelRepository().listNotificationChannels(
|
||||
userId,
|
||||
);
|
||||
};
|
||||
const pluginId = deps.manifest.id;
|
||||
|
||||
return {
|
||||
channels: () =>
|
||||
@@ -77,48 +117,39 @@ export function createPluginNotify(deps: Deps): PluginNotify {
|
||||
"notify_channels",
|
||||
"listed channels",
|
||||
async () => {
|
||||
const rows = await listOwn(actingUser());
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
type: row.type,
|
||||
enabled: !!row.enabled,
|
||||
}));
|
||||
const userId = actingUser();
|
||||
const hub = await activeNotifyHub();
|
||||
return hub ? hub.channels(userId) : [];
|
||||
},
|
||||
),
|
||||
|
||||
send: (channelIds, notification) =>
|
||||
send: (notification) =>
|
||||
audited(
|
||||
deps,
|
||||
"notify:send",
|
||||
"notify_send",
|
||||
`${channelIds.length} channel(s)`,
|
||||
`${notification.category ?? pluginId} to ${describeAudience(notification)}`,
|
||||
async () => {
|
||||
const rows = await listOwn(actingUser());
|
||||
const { deliverNotification } =
|
||||
await import("../utils/notification-sender.js");
|
||||
const result: PluginNotifyResult = { delivered: 0, failures: [] };
|
||||
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;
|
||||
const recipients = await resolveAudience(notification.audience);
|
||||
const hub = await activeNotifyHub();
|
||||
if (!hub || recipients.length === 0) return { ...EMPTY_RESULT };
|
||||
return hub.deliver({ source: pluginId, recipients, notification });
|
||||
},
|
||||
),
|
||||
|
||||
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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -858,7 +858,11 @@ export function createPluginContext(
|
||||
),
|
||||
),
|
||||
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 }),
|
||||
process: createPluginProcess({
|
||||
manifest,
|
||||
|
||||
@@ -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: () => ({}),
|
||||
createCurrentSshCredentialUsageRepository: deletingRepo("usage"),
|
||||
createCurrentRecentActivityRepository: deletingRepo("activity"),
|
||||
createCurrentDismissedAlertRepository: deletingRepo("alerts"),
|
||||
createCurrentHostRepository: deletingRepo("hosts"),
|
||||
createCurrentCredentialRepository: deletingRepo("credentials"),
|
||||
}));
|
||||
@@ -126,7 +125,6 @@ describe("resetUserPassword", () => {
|
||||
expect(calls.deletedFor).toEqual([
|
||||
"usage:user-1",
|
||||
"activity:user-1",
|
||||
"alerts:user-1",
|
||||
"hosts:user-1",
|
||||
"credentials:user-1",
|
||||
]);
|
||||
|
||||
BIN
Binary file not shown.
+8
-1
@@ -412,6 +412,14 @@ export const ROWS: Record<string, Row[]> = {
|
||||
enabled: 1,
|
||||
},
|
||||
],
|
||||
dismissed_alerts: [
|
||||
{
|
||||
id: 1,
|
||||
user_id: USERS.admin,
|
||||
alert_id: "d4-dismissed-announcement",
|
||||
dismissed_at: T,
|
||||
},
|
||||
],
|
||||
|
||||
snippet_folders: [
|
||||
{
|
||||
@@ -839,6 +847,5 @@ export const CORE_TABLES = new Set([
|
||||
"ssh_data",
|
||||
"host_access",
|
||||
"user_preferences",
|
||||
"notification_channels",
|
||||
"settings",
|
||||
]);
|
||||
@@ -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.
|
||||
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 ====
|
||||
-- workspaces 0001: adopt_user_workspaces
|
||||
-- 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`;
|
||||
|
||||
-- ==== 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 ====
|
||||
-- homepage 0001: adopt_homepage_tables
|
||||
-- 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`;
|
||||
|
||||
-- ==== 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 ====
|
||||
-- file-manager 0001: adopt_file_manager_tables
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
|
||||
@@ -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.
|
||||
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 ====
|
||||
-- workspaces 0001: adopt_user_workspaces
|
||||
-- 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");
|
||||
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 ====
|
||||
-- homepage 0001: adopt_homepage_tables
|
||||
-- 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");
|
||||
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 ====
|
||||
-- file-manager 0001: adopt_file_manager_tables
|
||||
-- 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
Reference in new issue
Block a user