mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 22:55:24 +00:00
release-2.9.0 (#1532)
* fix(macos): skip single-instance lock in Mac App Store builds (#1454)
fix(macos): skip single-instance lock in Mac App Store builds
* Fix/backend optional sharp (#1455)
Fix/backend optional sharp
* feat: issue #1218 (#1218)
https://github.com/Termix-SSH/Support/issues/1218
* feat: Add option to use saved global custom theme under Connection Defaults (#1209)
https://github.com/Termix-SSH/Support/issues/1209
* feat: Add Fleet Sharing functionality to the UI (#1228)
https://github.com/Termix-SSH/Support/issues/1228
* feat: Add a list view to the Docker management page (#1237)
https://github.com/Termix-SSH/Support/issues/1237
* feat: issue #1264 (#1264)
https://github.com/Termix-SSH/Support/issues/1264
* feat: 2FA With FortiToken (#1288)
https://github.com/Termix-SSH/Support/issues/1288
* feat: Allow to disable showing paths to folders (#1274)
https://github.com/Termix-SSH/Support/issues/1274
* feat: Sync Network Graph between Desktop and Remote Server (#1245)
https://github.com/Termix-SSH/Support/issues/1245
* feat: Version Number on offline servers (#1291)
https://github.com/Termix-SSH/Support/issues/1291
* feat: Support OrbStack Docker socket path on macOS / Fix "Docker is not installed" on mac... (#1302)
https://github.com/Termix-SSH/Support/issues/1302
* feat: Sidebar host click should focus existing tab instead of opening a new connection (#1289)
https://github.com/Termix-SSH/Support/issues/1289
* fix: Search in side bar shows overlapping hosts when grouped by tags (#1303)
https://github.com/Termix-SSH/Support/issues/1303
* fix: I can't send a file on its own. (#1304)
https://github.com/Termix-SSH/Support/issues/1304
* fix: sudo password not autofilling (#1248)
https://github.com/Termix-SSH/Support/issues/1248
* fix: node-MainThread (#1300)
https://github.com/Termix-SSH/Support/issues/1300
* fix: Tunnel authentication with shared credentials (#1295)
https://github.com/Termix-SSH/Support/issues/1295
* fix: host key updates not syncing, metrics 404 race on first connect
Host key writes never bumped updatedAt so sync never picked them up.
Also retry the first metrics fetch briefly instead of failing right away.
* chore: increment ver
* feat: add category and icon fields to plugin manifest schema
* ci(deps): bump the github-actions group with 2 updates (#1458)
Bumps the github-actions group with 2 updates: [crowdin/github-action](https://github.com/crowdin/github-action) and [actions/github-script](https://github.com/actions/github-script).
Updates `crowdin/github-action` from 2 to 3
- [Release notes](https://github.com/crowdin/github-action/releases)
- [Commits](https://github.com/crowdin/github-action/compare/v2...v3)
Updates `actions/github-script` from 7 to 9
- [Release notes](https://github.com/actions/github-script/releases)
- [Commits](https://github.com/actions/github-script/compare/v7...v9)
---
updated-dependencies:
- dependency-name: crowdin/github-action
dependency-version: '3'
dependency-type: direct:production
update-type: version-update:semver-major
dependency-group: github-actions
- dependency-name: actions/github-script
dependency-version: '9'
dependency-type: direct:production
update-type: version-update:semver-major
dependency-group: github-actions
...
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* chore(deps-dev): bump the dev-patch-updates group with 13 updates (#1459)
Bumps the dev-patch-updates group with 13 updates:
| Package | From | To |
| --- | --- | --- |
| [@codemirror/commands](https://github.com/codemirror/commands) | `6.11.0` | `6.11.1` |
| [@codemirror/search](https://github.com/codemirror/search) | `6.7.1` | `6.7.2` |
| [@codemirror/view](https://github.com/codemirror/view) | `6.43.9` | `6.43.12` |
| [@testing-library/dom](https://github.com/testing-library/dom-testing-library) | `10.4.1` | `10.4.2` |
| [@testing-library/react](https://github.com/testing-library/react-testing-library) | `16.3.2` | `16.3.3` |
| [@testing-library/user-event](https://github.com/testing-library/user-event) | `14.6.5` | `14.6.7` |
| [@types/ssh2](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/ssh2) | `1.15.5` | `1.15.6` |
| [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) | `6.1.0` | `6.1.1` |
| [cytoscape](https://github.com/cytoscape/cytoscape.js) | `3.34.1` | `3.34.3` |
| [eslint-plugin-react-refresh](https://github.com/ArnaudBarre/eslint-plugin-react-refresh) | `0.5.4` | `0.5.7` |
| [i18next](https://github.com/i18next/i18next) | `26.4.0` | `26.4.2` |
| [prettier](https://github.com/prettier/prettier) | `3.9.6` | `3.9.8` |
| [react-i18next](https://github.com/i18next/react-i18next) | `17.0.12` | `17.0.14` |
Updates `@codemirror/commands` from 6.11.0 to 6.11.1
- [Changelog](https://github.com/codemirror/commands/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/commands/commits)
Updates `@codemirror/search` from 6.7.1 to 6.7.2
- [Changelog](https://github.com/codemirror/search/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/search/commits)
Updates `@codemirror/view` from 6.43.9 to 6.43.12
- [Changelog](https://github.com/codemirror/view/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/view/commits)
Updates `@testing-library/dom` from 10.4.1 to 10.4.2
- [Release notes](https://github.com/testing-library/dom-testing-library/releases)
- [Changelog](https://github.com/testing-library/dom-testing-library/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/dom-testing-library/compare/v10.4.1...v10.4.2)
Updates `@testing-library/react` from 16.3.2 to 16.3.3
- [Release notes](https://github.com/testing-library/react-testing-library/releases)
- [Changelog](https://github.com/testing-library/react-testing-library/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/react-testing-library/compare/v16.3.2...v16.3.3)
Updates `@testing-library/user-event` from 14.6.5 to 14.6.7
- [Release notes](https://github.com/testing-library/user-event/releases)
- [Changelog](https://github.com/testing-library/user-event/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/user-event/compare/v14.6.5...v14.6.7)
Updates `@types/ssh2` from 1.15.5 to 1.15.6
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/ssh2)
Updates `@vitejs/plugin-react` from 6.1.0 to 6.1.1
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@6.1.1/packages/plugin-react)
Updates `cytoscape` from 3.34.1 to 3.34.3
- [Release notes](https://github.com/cytoscape/cytoscape.js/releases)
- [Commits](https://github.com/cytoscape/cytoscape.js/compare/v3.34.1...v3.34.3)
Updates `eslint-plugin-react-refresh` from 0.5.4 to 0.5.7
- [Release notes](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/releases)
- [Changelog](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/compare/v0.5.4...v0.5.7)
Updates `i18next` from 26.4.0 to 26.4.2
- [Release notes](https://github.com/i18next/i18next/releases)
- [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next/compare/v26.4.0...v26.4.2)
Updates `prettier` from 3.9.6 to 3.9.8
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.9.6...3.9.8)
Updates `react-i18next` from 17.0.12 to 17.0.14
- [Changelog](https://github.com/i18next/react-i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/react-i18next/compare/v17.0.12...v17.0.14)
---
updated-dependencies:
- dependency-name: "@codemirror/commands"
dependency-version: 6.11.1
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@codemirror/search"
dependency-version: 6.7.2
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@codemirror/view"
dependency-version: 6.43.12
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@testing-library/dom"
dependency-version: 10.4.2
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@testing-library/react"
dependency-version: 16.3.3
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@testing-library/user-event"
dependency-version: 14.6.7
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@types/ssh2"
dependency-version: 1.15.6
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@vitejs/plugin-react"
dependency-version: 6.1.1
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: cytoscape
dependency-version: 3.34.3
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: eslint-plugin-react-refresh
dependency-version: 0.5.7
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: i18next
dependency-version: 26.4.2
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: prettier
dependency-version: 3.9.8
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: react-i18next
dependency-version: 17.0.14
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
...
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* chore(deps): bump the prod-patch-updates group with 6 updates (#1461)
Bumps the prod-patch-updates group with 6 updates:
| Package | From | To |
| --- | --- | --- |
| [@tanstack/react-virtual](https://github.com/TanStack/virtual/tree/HEAD/packages/react-virtual) | `3.14.10` | `3.14.13` |
| [compression](https://github.com/expressjs/compression) | `1.8.1` | `1.8.2` |
| [jose](https://github.com/panva/jose) | `6.2.9` | `6.2.12` |
| [jszip](https://github.com/Stuk/jszip) | `3.10.1` | `3.10.2` |
| [socks](https://github.com/JoshGlazebrook/socks) | `2.8.9` | `2.8.10` |
| [undici](https://github.com/nodejs/undici) | `8.10.0` | `8.10.2` |
Updates `@tanstack/react-virtual` from 3.14.10 to 3.14.13
- [Release notes](https://github.com/TanStack/virtual/releases)
- [Changelog](https://github.com/TanStack/virtual/blob/main/packages/react-virtual/CHANGELOG.md)
- [Commits](https://github.com/TanStack/virtual/commits/@tanstack/react-virtual@3.14.13/packages/react-virtual)
Updates `compression` from 1.8.1 to 1.8.2
- [Release notes](https://github.com/expressjs/compression/releases)
- [Changelog](https://github.com/expressjs/compression/blob/master/HISTORY.md)
- [Commits](https://github.com/expressjs/compression/compare/v1.8.1...v1.8.2)
Updates `jose` from 6.2.9 to 6.2.12
- [Release notes](https://github.com/panva/jose/releases)
- [Changelog](https://github.com/panva/jose/blob/main/CHANGELOG.md)
- [Commits](https://github.com/panva/jose/compare/v6.2.9...v6.2.12)
Updates `jszip` from 3.10.1 to 3.10.2
- [Changelog](https://github.com/Stuk/jszip/blob/main/CHANGES.md)
- [Commits](https://github.com/Stuk/jszip/compare/v3.10.1...v3.10.2)
Updates `socks` from 2.8.9 to 2.8.10
- [Release notes](https://github.com/JoshGlazebrook/socks/releases)
- [Commits](https://github.com/JoshGlazebrook/socks/compare/2.8.9...2.8.10)
Updates `undici` from 8.10.0 to 8.10.2
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v8.10.0...v8.10.2)
---
updated-dependencies:
- dependency-name: "@tanstack/react-virtual"
dependency-version: 3.14.13
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: prod-patch-updates
- dependency-name: compression
dependency-version: 1.8.2
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: prod-patch-updates
- dependency-name: jose
dependency-version: 6.2.12
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: prod-patch-updates
- dependency-name: jszip
dependency-version: 3.10.2
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: prod-patch-updates
- dependency-name: socks
dependency-version: 2.8.10
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: prod-patch-updates
- dependency-name: undici
dependency-version: 8.10.2
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: prod-patch-updates
...
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* feat: add plugin worker protocol, bootstrap and plugin_storage table
Worker-side ctx proxy plus the message envelope it talks over, and the
per-plugin key/value table behind ctx.storage with migrations for all
three dialects. Rows cascade with the plugin so uninstalling leaves
nothing behind.
* feat: add allowlisted host view for plugins
An allowlist rather than a list of fields to strip. A blocklist rots:
the moment a new secret-bearing column lands on SSHHost it starts
leaking silently. With an allowlist a new field stays invisible to
plugins until someone adds it here on purpose.
* feat: add plugin loader with manifest validation and crash restart
Validates an unpacked plugin, spawns a worker_threads worker and manages
activate/deactivate/crash-restart with backoff, disabling after three
tries.
The crash counter clears only after a plugin stays up past a stability
window, not on activation. Clearing it on activation let a plugin that
activates cleanly and then dies restart forever, because every attempt
looked like the first.
loader.ts documents what the worker boundary does and does not buy you.
Node's permission model is process-wide, not per-worker, so it cannot be
enabled for plugin workers alone and is not claimed here.
* feat: route internal events through a shared plugin event bus
Formalises three ad-hoc publish paths that already existed:
automation-events.ts, metrics/automation-bridge.ts and the
hostSessionStatus singleton, now the host.session.status topic.
Both invariants the old code relied on are preserved. Publishing stays
fire-and-forget, so a failing subscriber cannot disturb a metrics poll or
a host delete. The automations engine subscribes to the bus at scheduler
start instead of being imported ad hoc, which keeps the edge
one-directional: automations may import repositories, hosts modules must
not import automations.
* feat: add plugin ctx broker with permission gate and audit
Every ctx call a plugin makes is checked against its granted
capabilities, performed by the main thread, then audited. A capability
must be both declared in the manifest and granted in the database, so
widening a plugin's reach always needs a new manifest the user can see,
never just a database row.
Audit attribution is set by the broker, not the plugin. The plugin
supplies only the details, so it cannot forge the actor.
fix: stop plugin workers seeing plaintext credentials in error messages
A failing ctx.ssh.connect handed the worker the raw error from deep in
the SSH stack, which embeds the connect config. The worker received, in
full:
Authentication failed for root using password <SECRET>
where <SECRET> was the host's real plaintext password. Any plugin with
ssh.exec could read it by catching the error, defeating the whole point
of the handle-based ssh API.
Errors crossing the boundary are now sanitised. Only PluginFacingError
and PluginPermissionError, both authored here, reach a plugin verbatim;
anything else becomes a generic message and the real one goes to the
log. This uses a marker class rather than matching on message text,
because matching would start leaking again the moment an upstream error
happened to contain a familiar phrase.
Covered by credential-isolation.test.ts, which plants a sentinel in
every secret-bearing field and asserts it appears in no message the
worker ever receives.
* feat: dispatch plugin HTTP routes and wire the runtime into startup
ctx.http.route now registers a real router on the /plugin-api/:pluginId
dispatcher, replacing its stub 404. A plugin never touches the Express
req/res: it gets a plain summary and returns a plain object, so sockets
and session cookies stay on this side of the boundary. Authorization and
cookie headers are not forwarded.
Plugins load last in the start-up sequence, after the server is fully
wired, and are terminated before the database on shutdown so none can
outlive it. A plugin that fails to load cannot stop the backend.
fix: correct the nginx plugin-api location regex
The pattern was ^/plugin-api/(/.*)?$, which needs a double slash and so
never matched /plugin-api/<id>/<route>. Every other block in the file
uses ^/prefix(/.*)?$. Left alone, plugin routes would work in dev and
404 only behind Docker.
* fix: update stale SFTP transfer test for single file destination paths
The test still asserted transferToHost was called with the destination
directory "/srv". Commit 7727fa09 (#1304) deliberately changed a single
file transfer to send the full destination path including the filename,
so the correct expectation is "/srv/remote-1.txt". The code was right
and the test was left behind.
* feat: ship the ssh terminal as a first-party in-process plugin
Adds a hardcoded process:transport-owner tier so the terminal can own its
WebSocket server and ssh2 clients, which cannot cross the worker boundary.
* feat: ship docker as a first-party in-process plugin
Moves docker backend and frontend into plugins/docker/, alongside
ssh-terminal. Adds a host editor tab seam, a dashboard card registry,
and a build step to compile plugin backends into dist.
* feat: ship host metrics as a first-party in-process plugin
* fix: harden plugin auth and add per-request identity for ctx calls
Require auth on /plugin-api, gate plugin enable/grant/revoke routes
behind admin.plugins.manage, and thread the calling user through
worker ctx.hosts/ctx.ssh calls instead of always using the install
owner. Also adds the plugin permissions grant/revoke UI.
* feat: ship the AI assistant as a first-party in-process plugin
Moves src/backend/ai and its routes into plugins/ai, mounted through a
small /ai dispatcher instead of its own port. Moves the ai-enabled and
ai-private-endpoints settings routes out of user-settings-routes.ts
into the plugin too, so it has no leftover core dependents.
* fix: remove compiled plugin JS left behind in plugins/ after build
copy-bundled-plugins.cjs compiled and rewrote plugin backend TS in
place, copied it to dist/plugins, but never deleted the .js it had
just emitted next to the .ts sources. Those files shadowed the real
source for vitest/tsx and had import paths rewritten for the dist
layout, so tests failed with "Cannot find module" once a local build
had run. Affects docker, host-metrics and ai, not just this one.
* feat: add a permission-gated backend service registry for plugins
* feat: add a permission-gated UI action registry and action slots for plugins
Moves the terminal toolbar's hardwired AI button into a slot the ssh-terminal
plugin offers and the ai plugin fills, gated on ai.services.use.
* feat: add cross-plugin secret references gated by the provider's RBAC permission
* feat: move proxmox discovery/import/sync into a first-party plugin
* feat: move remote desktop (RDP/VNC/Telnet) into a first-party plugin
Relocates guacamole backend/frontend source and tests into
plugins/remote-desktop, following the ssh-terminal/docker pattern.
* feat: wire remote-desktop plugin into core and rename i18n keys
Repoints core consumers (dispatcher, collab, session-sharing, tab
utils) at the plugin, adds isTabTypeAvailable gating for rdp/vnc/
telnet, widens hostCapability to accept an array, and renames the
guacamole i18n namespace to remoteDesktop.
* feat: move fleets into a first-party plugin
* feat: move automations into a first-party plugin
* feat: remove legacy alert rules, superseded by automations
Alert rules already auto-migrate into automations on boot and the old
engine stands down after. Deletes the rule/firing tables, routes, UI,
and AI tools; keeps notification channels since automations still use
them. Also fixes automations' notify step, which still called the
deleted repository.
* feat: move network topology into a first-party plugin
* feat: move tailscale device discovery and settings into a plugin
Adds plugins/tailscale/ with a devices sidebar tab, admin settings
section, and re-auth dialog. Host Metrics tailscale manager card and
SSH re-auth banner parsing stay in core.
* feat: move workspaces into a first-party plugin
* feat: move web endpoint into a first-party plugin
* feat: collapse plugin runtime to one tier behind a plugin SDK
* feat: make each plugin an npm workspace built by a termix-plugin CLI
* feat: give plugins their own tables, migrations and sync entities
* feat: serve plugin http and websockets from one mount instead of ports
* feat: namespace plugin permissions and keep them valid when a plugin is off
* feat: give plugins their own settings and one settings screen
* feat: load plugin frontends at runtime and remove plugin ids from the shell
* fix: mock the ai status fetch in its activate test
* feat: add auth extension points and one ssh connect pipeline
* feat: rebuild workspaces as the reference plugin
* fix: gate second factors after SSO/LDAP behind an admin setting
* feat: convert network-topology to a full plugin (B1)
* feat: move snippets backend and tables into a plugin (B2)
* feat: point core frontend snippets pickers at the plugin (B2)
* feat: add the snippets plugin (B2)
* feat: wire ai, automations, fleets and ssh-terminal to snippets (B2)
* feat: finish the tailscale plugin, including SSH auth (B3)
* feat: finish the fleets plugin, add ctx.hosts and host-commands SDK (B4)
* chore: update proxmox and host-metrics locale files for B5
* chore: regenerate drizzle migration journals for B5
* feat: add ctx.hosts create/update to the SDK, host-metrics drops proxmox
* feat: move proxmox node stats and collectors into the proxmox plugin
* feat: drop proxmox host columns, use plugin settings instead
* feat: finish the fleets plugin, add ctx.hosts and host-commands SDK (B4)
* feat: convert file manager into a plugin
* feat: convert ssh tunnels into a plugin
* feat: finish the web-endpoint plugin, add ctx.desktop (B8)
* feat: move the terminal fully into the ssh-terminal plugin (B9)
* feat: convert tmux monitoring into a plugin (B10)
* feat: convert serial console into the serial plugin
* feat: move session sharing and collab rooms into a plugin (B12)
* feat: turn session recording into a plugin (B13)
* feat: finish the remote-desktop plugin (B14)
* feat: finish the host-metrics plugin, move host status into core (B16)
* feat: finish the docker plugin (B15)
* feat: finish the automations plugin (B17)
* feat: finish the ai plugin (B18)
* feat: finish the homepage plugin, move service links and canvas out of core (B19)
* feat: convert wake-on-lan into a plugin (B20)
* feat: convert secret-sources into a plugin (B20)
* feat: move totp and passkeys into plugins (C1)
* feat: move sso and ldap logins into plugins (C2)
* feat: move opkssh and warpgate into plugins (C3)
* feat: move step-ca into a plugin (C4)
* feat: move vault ssh signing into a plugin (C5)
* feat: move termix identity into a plugin (C6)
* feat: move acme certificate automation into a plugin (C7)
* fix: finish 2.9.0 plugin moves and get every check green
* chore: lock the plugin boundary and remove 2.9.0 leftovers
* fix: harden the plugin runtime after the 2.9.0 security review
* feat: add plugin packing, signing, pinned trust and tmxplug bundling
* test: prove 2.8 to 2.9.0 upgrade is lossless and fix what it caught
* fix: give bundled plugin backends a real require for CJS deps
* feat: improve UI loading time and jitter
* feat: show credential sharing inline in the sidebar like host sharing
* feat: tailscale add host and copy ip, fix outbound fetch hang and plugin ESM warning
* feat: unify alerts into one plugin, channels, rules and inbox
* feat: rebuild desktop sync as linked server accounts, fix packaged plugin bundles and UI scaling
* fix: set blacksmith builder cache-key to stop broken gha cache fallback
* fix: link plugin-sdk workspace before npm ci in docker deps stage
* fix: link every plugin workspace before npm ci in docker deps stage
* fix(host-metrics): exclude image and firmware filesystems (#1469)
* fix(host-metrics): exclude image and firmware filesystems
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(hosts): normalize imported jump host references (#1470)
* fix(hosts): normalize imported jump host references
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(ci): build plugin SDK and repair MySQL settings migration (#1471)
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(file-manager): keep inline rename consistent (#1476)
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(file-manager): keep inline rename consistent
* fix(dashboard): reuse translated host status labels (#1475)
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(dashboard): reuse translated host status labels
* docs(api): clarify local and remote version fields (#1474)
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* docs(api): clarify local and remote version fields
* fix(file-manager): preserve selected backgrounds on hover (#1473)
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(file-manager): preserve selection feedback on hover
* test(ui): await dialog focus cleanup before teardown
* fix(file-manager): restore Ctrl/Cmd+F file search (#1472)
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(file-manager): focus file search with Ctrl or Cmd F
* fix: keep rounded-full circular in production builds
* fix(file-manager): cancel uploads and stop remote writes (#1477)
* fix(file-manager): cancel uploads and stop remote writes
* test(css): exercise the configured PostCSS plugin chain
* fix(file-manager): apply compact density to directory tree (#1478)
* fix(file-manager): apply compact density to directory tree
* test(css): exercise the configured PostCSS plugin chain
* fix(file-manager): preserve font sizes in compact mode (#1480)
* fix(file-manager): preserve font sizes in compact mode
* test(css): exercise the configured PostCSS plugin chain
* fix(file-manager): prioritize file names in narrow lists (#1483)
* fix(file-manager): prioritize file names in narrow lists
* test(css): exercise the configured PostCSS plugin chain
* test(identity): preserve full Ed25519 public keys in fixtures
* test(remote-desktop): always mutate ciphertext in tamper test
* fix(remote-desktop): probe the saved host guacd endpoint (#1481)
* fix(remote-desktop): probe the saved host guacd endpoint
* test(css): exercise the configured PostCSS plugin chain
* test(identity): preserve full Ed25519 public keys in fixtures
* fix(homepage): check allowlisted private service reachability (#1479)
* fix(homepage): check allowlisted private service reachability
* test(css): exercise the configured PostCSS plugin chain
* fix(remote-desktop): stop unsolicited clipboard read prompts (#1482)
* fix(remote-desktop): read clipboard only on explicit paste
* test(css): exercise the configured PostCSS plugin chain
* test(identity): preserve full Ed25519 public keys in fixtures
* test(remote-desktop): always mutate ciphertext in tamper test
* chore: simplify plugin readmes, changelogs and manifest descriptions
* fix: keep plugins loaded after signing in from the login screen
* fix(file-manager): accept OS file drops over existing rows (#1484)
* fix(file-manager): accept OS file drops over existing rows
* test(file-manager): target the file label in drop tests
* test(css): exercise the configured PostCSS plugin chain
* test(identity): preserve full Ed25519 public keys in fixtures
* test(remote-desktop): always mutate ciphertext in tamper test
* fix(file-manager): pipeline SFTP download reads (#1485)
* feat: add plugin sdk build to beta release
* fix: make link-store secret check immune to random base64 matches
* fix(ldap): preserve the directory-provided bind DN (#1486)
* fix: compute beta changes across dev branches and move the beta tag
* chore: untrack plugin sdk ARCHITECTURE.md
* chore: remove references to plugin sdk ARCHITECTURE.md
* feat: merge host editor terminal tabs into one owned by ssh-terminal
* feat: add descriptions to plugin host enable toggles
* fix(file-manager): show download progress in file viewers (#1487)
* fix(electron): signal backend readiness over IPC (#1488)
* fix(split-screen): allow layout creation without randomUUID (#1490)
* fix(split-screen): allow layout creation without randomUUID
* test(credentials): avoid flaky Ed25519 key generation
* fix(sidebar): remeasure visible rows after density changes (#1489)
* fix(sidebar): remeasure visible rows after density changes
* test(credentials): avoid flaky Ed25519 key generation
* feat(split-screen): redesign split screen with free-form panes and pane picker
* feat(sidebar): add host click behavior setting, default to always opening a new tab
* feat(snippets): redesign panel to match the host list
* fix(session-recording): stop session logs panel from refetching in a loop
* feat(snippets): bring back sharing, target hosts and drag reorder
* feat(ssh-terminal): add toolbar position, start state, display and fade host settings
* feat(ssh-terminal): add host setting to turn off password prompt auto-fill
* fix(ai): stop streams getting cut off and keep tool steps across messages
* feat: improve quick connect system using plugin api
* fix: proxmox guest sync, startup snippet sync, drop dead cloudflare ssh transport
* feat: move terminal settings, look and ssh tools into ssh-terminal plugin
* feat: move snippets, keybinding actions and macros out of core into plugins
* feat: store rdp, vnc and telnet logins in a generic per-protocol table
* feat: type the plugin host record, move plugin strings out of core and add lint checks
* feat: replace core homepage widget registry with generic extension points
* feat: plugins only see their own host settings in the browser
* feat: harden plugin api for separate repos, signed updates and generic external login
* fix: keep plugin css below core utilities and version plugin assets by their css
* fix: load and save plugin host settings in the editor and tighten settings row spacing
* feat: redesign user host defaults system
* fix(remote-desktop): release held keys on focus loss (#1498)
* fix(session-sharing): replace local presentation after takeover (#1500)
* fix(file-manager): refresh disk usage with directory listing (#1503)
* fix(ssh-terminal): preserve input order during local echo reconciliation (#1505)
* feat(remote-desktop): expose display zoom controls for RDP (#1514)
* fix(file-manager): restore text selection and copying in Markdown previews (#1518)
* fix(sidebar): keep compact host addresses visible (#1524)
* fix(hosts): preserve plugin settings when reopening the editor (#1499)
* fix(ssh-terminal): close owner sockets when presentations unmount (#1501)
* fix(session-sharing): synchronize shared SSH terminal dimensions (#1502)
* fix(session-sharing): show participant presence in member terminals (#1504)
* fix(ai): sync provider configuration and keys across linked devices (#1506)
* feat(ssh-terminal): add a duplicate tab action (#1510)
* feat(file-manager): allow disabling move-to-trash confirmation (#1511)
* feat(file-manager): default to the remote login directory (#1512)
* feat(homepage): synchronize canvas layouts across devices (#1515)
* fix(shortcuts): prevent accidental double-Shift palette activation (#1519)
* feat(tmux-monitor): add a unified multi-host view (#1520)
* fix(electron): allow explicit quit with active connections (#1522)
* feat: reconnect all disconnected terminal tabs (#1523)
* fix(hosts): preserve recording and sharing settings in the dev editor (#1509)
* fix(database): preserve RDP domain during startup backfill (#1497)
* feat(sidebar): add keyboard type-ahead search for hosts (#1516)
* feat(automations): add per-host maintenance mode and recurring schedules (#1521)
* feat(tmux): add a per-host mouse toggle and preserve remote configuration (#1513)
* fix(session-sharing): use the linked server for desktop meetings and links (#1507)
* fix(tunnels): support TOTP for client tunnels and jump hosts (#1508)
* fix: type errors and lint failures from merged plugin prs
* feat(installer): make the Windows desktop shortcut optional (#1517)
* fix: ship the plugin sdk and serialport in desktop builds
* fix: prefer ipv4 when resolving ssh host names (#1365)
* fix: readable terminal selection with the light theme (#1319)
* fix: build desktop session share links on the linked server (#1350)
* fix: show meeting guests to members and hide the roster from guests (#1353)
* fix: reliable file manager saves with sudo prompt and no base64 guessing (#1335)
* fix: show disk usage for the mount being browsed (#1347)
* fix: load the linux tray icon reliably and log tray failures (#1332)
* fix: cover the gnome close path in the desktop quit test (#1332)
* fix: prompt for jump host TOTP in terminal, file manager and metrics (#1312)
* feat: optional auto reconnect for dropped ssh sessions (#1321)
* feat: tighter compact host rows with inline tags (#1325)
* feat: bring back the alert feed homepage widget
* feat: pick target terminals for snippets again
* feat: share button on connection tabs again
* feat: recent servers list when linking the desktop app
* feat: list the fixed app shortcuts in keyboard shortcuts
* fix: translate hardcoded toasts and drop em dashes from ui text
* fix: translate leftover hardcoded ui text
* fix: toolbar files button opens at the shell directory again
* fix: show the remote desktop note under connection origin again
* chore: openapi docs for host metrics manager routes
* chore: openapi docs for undocumented routes and fix two broken doc blocks
* fix: declare terminal.listSessions use for snippet targets
* revert: drop the alert feed widget, the notification center replaces it
* feat: restore the alert feed homepage widget
* fix: quick connect fills the host address from plugin auth editors like tailscale
* fix: docker container logs use the base background color
* chore: fix dependabot alerts and apply safe npm upgrades
* fix: proxmox stats tab reads its switch from plugin host settings
* fix: count plugin ssh logins toward host status and stop showing reachable as a warning
* fix: hide the built-in source pill in the host editor
* fix: host status shows online when the port answers and only warns on a failed login
* fix: drop the top border on the split layout menu
* fix: host defaults tabs sit where the host editor puts them and skip plugins with nothing to default
* fix: remote desktop auth method labels and notes for vnc and telnet
* fix: add description to the web endpoint enable switch
* fix: crowdin sync and pretranslate for plugin locales, run plugin tests on release
* feat: move telemetry out of core into a usage statistics plugin
* fix: recent activity card no longer cuts off host names early
* fix: remove duplicate top border on split screen menu
* fix: redesign session log player controls, fix tooltip overlap and drop dot separators
* fix: file manager and sftp text showing raw i18n keys
* fix: onboarding rerunning for existing users after upgrading
* fix: fleet detail buttons overflowing the sidebar
* fix: session log player scrolling off screen horizontally
* fix: network graph lag when adding hosts and on every render
* fix: maintenance index name, shared host read only view and badge polling
* fix: meeting tabs, workspace tab merging, rail panels as tabs and general ui lag
* chore: update release notes
* fix: remote desktop tabs reconnecting on every tab switch
* fix: build plugin sdk before linting in release workflow
* chore: lint, format, and bump version to 2.9.0
* chore: skip crowdin pre-translate in release for now
* chore: sync Crowdin translations for 2.9.0
---------
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Koi <38512047+datlt4@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
This commit is contained in:
4405 files changed
+1341026
-289722
No files matched your search
@@ -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,197 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
Bell,
|
||||
Check,
|
||||
CheckCircle,
|
||||
Info,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { Input } from "@termix/plugin-sdk/ui";
|
||||
import type { AlertItem } from "../types";
|
||||
import type { AlertsApi } from "./api";
|
||||
import { timeAgo } from "./format";
|
||||
import { useAlertsVersion, type AlertsStore } from "./store";
|
||||
|
||||
export interface AlertFeedConfig {
|
||||
maxItems: number;
|
||||
showRead: boolean;
|
||||
/** 2.8 name for showRead, still found in saved homepages. */
|
||||
showAcknowledged?: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_ALERT_FEED_CONFIG: AlertFeedConfig = {
|
||||
maxItems: 10,
|
||||
showRead: false,
|
||||
};
|
||||
|
||||
function SeverityIcon({ severity }: { severity: AlertItem["severity"] }) {
|
||||
if (severity === "critical")
|
||||
return <AlertCircle size={11} className="shrink-0 text-red-500" />;
|
||||
if (severity === "warning")
|
||||
return <AlertTriangle size={11} className="shrink-0 text-amber-500" />;
|
||||
if (severity === "success")
|
||||
return <CheckCircle size={11} className="shrink-0 text-green-500" />;
|
||||
return <Info size={11} className="shrink-0 text-blue-400" />;
|
||||
}
|
||||
|
||||
export function showsRead(config: Partial<AlertFeedConfig>): boolean {
|
||||
return config.showRead ?? config.showAcknowledged ?? false;
|
||||
}
|
||||
|
||||
export function createAlertFeedWidget(api: AlertsApi, store: AlertsStore) {
|
||||
function AlertFeedWidget({
|
||||
widget,
|
||||
config,
|
||||
}: {
|
||||
widget: { title?: string | null };
|
||||
config: AlertFeedConfig;
|
||||
}) {
|
||||
const { t, language } = useTranslation();
|
||||
const version = useAlertsVersion(store);
|
||||
const maxItems = Math.max(1, config.maxItems || 10);
|
||||
const showRead = showsRead(config);
|
||||
const [items, setItems] = useState<AlertItem[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api
|
||||
.items({ unread: showRead ? undefined : true, limit: maxItems })
|
||||
.then((result) => {
|
||||
if (!cancelled) setItems(result.items.slice(0, maxItems));
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setItems([]);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [maxItems, showRead, version]);
|
||||
|
||||
const markRead = async (id: number) => {
|
||||
try {
|
||||
await api.setRead([id]);
|
||||
setItems((prev) =>
|
||||
(prev ?? []).flatMap((item) =>
|
||||
item.id !== id
|
||||
? [item]
|
||||
: showRead
|
||||
? [{ ...item, readAt: new Date().toISOString() }]
|
||||
: [],
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
// the next reload shows the real state
|
||||
}
|
||||
};
|
||||
|
||||
if (items === null) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
|
||||
{t("widget.loading")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
<div className="flex items-center gap-1.5 px-2 py-1.5 border-b border-border/50 text-[11px] font-semibold text-muted-foreground">
|
||||
<Bell size={11} />
|
||||
<span className="truncate">{widget.title || t("widget.name")}</span>
|
||||
</div>
|
||||
{items.length === 0 ? (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-2">
|
||||
<CheckCircle size={20} className="text-green-500" />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("widget.allClear")}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 overflow-auto">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`flex items-start gap-2 px-2 py-1.5 border-b border-border/30 ${item.readAt ? "opacity-50" : ""}`}
|
||||
>
|
||||
<SeverityIcon severity={item.severity} />
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
<span className="text-[10px] font-semibold text-foreground truncate">
|
||||
{item.title}
|
||||
</span>
|
||||
<span className="text-[9px] text-muted-foreground truncate">
|
||||
{timeAgo(item.createdAt, language)}
|
||||
</span>
|
||||
</div>
|
||||
{!item.readAt && (
|
||||
<button
|
||||
type="button"
|
||||
title={t("inbox.markRead")}
|
||||
onClick={() => void markRead(item.id)}
|
||||
className="shrink-0 p-0.5 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Check size={11} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return AlertFeedWidget;
|
||||
}
|
||||
|
||||
export function AlertFeedEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: {
|
||||
config: AlertFeedConfig;
|
||||
onChange: (config: AlertFeedConfig) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("widget.maxItems")}
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={50}
|
||||
value={config.maxItems}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...config,
|
||||
maxItems: Math.min(50, Math.max(1, Number(e.target.value))),
|
||||
})
|
||||
}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showsRead(config)}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...config,
|
||||
showRead: e.target.checked,
|
||||
showAcknowledged: undefined,
|
||||
})
|
||||
}
|
||||
className="accent-accent-brand"
|
||||
/>
|
||||
{t("widget.showRead")}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The widget's name in the homepage add menu, in this plugin's locale. */
|
||||
export function AlertFeedLabel({ part }: { part: "name" | "description" }) {
|
||||
const { t } = useTranslation();
|
||||
return <>{t(part === "name" ? "widget.name" : "widget.description")}</>;
|
||||
}
|
||||
@@ -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,112 @@
|
||||
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";
|
||||
import { createAlertsApi } from "./api";
|
||||
import {
|
||||
AlertFeedEditForm,
|
||||
AlertFeedLabel,
|
||||
DEFAULT_ALERT_FEED_CONFIG,
|
||||
createAlertFeedWidget,
|
||||
} from "./AlertFeedWidget";
|
||||
|
||||
// The homepage grid step, in pixels.
|
||||
const GRID_SIZE = 30;
|
||||
|
||||
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.registerExtension("homepage.widgets", {
|
||||
id: "alert_feed",
|
||||
name: "Alert Feed",
|
||||
description: "Your latest alerts",
|
||||
category: "monitoring",
|
||||
icon: <Bell size={14} />,
|
||||
defaultConfig: DEFAULT_ALERT_FEED_CONFIG,
|
||||
defaultSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 10 },
|
||||
minSize: { w: GRID_SIZE * 6, h: GRID_SIZE * 5 },
|
||||
components: {
|
||||
view: createAlertFeedWidget(createAlertsApi(app.api), store) as never,
|
||||
editForm: AlertFeedEditForm as never,
|
||||
label: AlertFeedLabel as never,
|
||||
},
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
Reference in new issue
Block a user