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:
authored and GitHub committed 2026-10-01 16:20:16 -05:00
1 parent 49fa488093
commit 3643e7af97
4405 files changed
+1341026 -289722

No files matched your search

@@ -0,0 +1,85 @@
import type { PluginSshHost } from "@termix/plugin-sdk/backend";
import type { HostSelector } from "../../types.js";
import type { StepExecutionContext, StepRuntime } from "./types.js";
/** A host the owner may act on, resolved through core's connect pipeline. */
export interface ResolvedTarget {
id: number;
name: string;
host: PluginSshHost;
}
/**
* Turns a selector into the hosts a step may actually act on.
*
* Access is checked at execution time rather than when the automation was
* saved, so a permission revoked after the fact takes effect on the next run.
* resolveHost runs as the automation's owner and answers null when they can
* no longer reach the host.
*/
export async function resolveTargets(
selector: HostSelector,
context: StepExecutionContext,
runtime: StepRuntime,
): Promise<{ targets: ResolvedTarget[]; skipped: number[] }> {
const ids = await selectorHostIds(selector, context, runtime);
const targets: ResolvedTarget[] = [];
const skipped: number[] = [];
for (const id of ids) {
let host: PluginSshHost | null = null;
try {
host = await runtime.ctx.ssh.resolveHost(id);
} catch {
host = null;
}
if (!host) {
skipped.push(id);
continue;
}
const name =
typeof host.name === "string" && host.name ? host.name : host.ip;
targets.push({ id, name, host });
}
return { targets, skipped };
}
async function selectorHostIds(
selector: HostSelector,
context: StepExecutionContext,
runtime: StepRuntime,
): Promise<number[]> {
switch (selector.kind) {
case "host":
return [selector.hostId];
case "hosts":
return selector.hostIds;
case "trigger":
return context.triggerHostId ? [context.triggerHostId] : [];
case "fleet": {
const fleets = runtime.deps.fleets();
if (typeof fleets.members !== "function") return [];
try {
const members = await fleets.members(selector.fleetId);
return members.map((member) => member.id);
} catch {
return [];
}
}
case "all": {
try {
const hosts = await runtime.ctx.hosts.list();
// The owner's own hosts, as before: "all" never fans out onto hosts
// someone else shared with them.
return hosts
.filter((host) => host.userId === context.userId)
.map((host) => host.id);
} catch {
return [];
}
}
default:
return [];
}
}
@@ -0,0 +1,442 @@
import type { Client } from "ssh2";
import { DEFAULT_STEP_TIMEOUT_MS, type Step } from "../../types.js";
import { execCommand, execElevated } from "@termix/plugin-sdk/host-commands";
import { renderRecord, renderTemplate } from "../template.js";
import { resolveTargets, type ResolvedTarget } from "./host-targets.js";
import {
fail,
ok,
stepTimeout,
type StepExecutionContext,
type StepResult,
type StepRuntime,
} from "./types.js";
/** The admin allowlist of private hosts an HTTP step may reach. */
const PRIVATE_ALLOWLIST_KEY = "notification_private_endpoint_allowlist";
const errorText = (error: unknown) =>
error instanceof Error ? error.message : String(error);
/**
* One executor per step type.
*
* Anything that leaves Termix checks context.dryRun first and reports what it
* would have done instead of doing it, so an automation can be exercised
* safely while it is being built.
*/
export async function executeStep(
step: Step,
context: StepExecutionContext,
runtime: StepRuntime,
): Promise<StepResult> {
switch (step.type) {
case "notify":
return runNotify(step, context, runtime);
case "http":
return runHttp(step, context, runtime);
case "run_command":
return runCommand(step, context, runtime);
case "run_snippet":
return runSnippet(step, context, runtime);
case "docker":
return runDocker(step, context, runtime);
case "tunnel":
return runTunnel(step, context, runtime);
case "wol":
return runWol(step, context, runtime);
case "wait":
return runWait(step, context);
case "set_var":
return runSetVar(step, context);
case "stop":
return {
success: true,
halt: { status: step.status ?? "success" },
output: `Stopped with status ${step.status ?? "success"}`,
};
default:
return fail(`Unsupported step type: ${(step as Step).type}`);
}
}
async function runNotify(
step: Extract<Step, { type: "notify" }>,
context: StepExecutionContext,
runtime: StepRuntime,
): Promise<StepResult> {
const title = renderTemplate(step.title ?? "", context.template);
const body = renderTemplate(step.body ?? "", context.template);
const channelIds = step.channelIds ?? [];
if (context.dryRun) {
return ok(
channelIds.length > 0
? `Would send an alert to the inbox and ${channelIds.length} channel(s): ${title || body}`
: `Would send an alert to the inbox: ${title || body}`,
);
}
const trigger = context.template.trigger ?? {};
let result;
try {
result = await runtime.ctx.notify.send({
title: title || "Termix automation",
body,
severity: step.severity ?? "warning",
category: "automations.notify",
audience: { userId: context.userId },
channelIds,
link: { tab: "automations" },
context: {
hostId:
context.template.host?.id ??
(typeof trigger.hostId === "number" ? trigger.hostId : undefined),
hostName:
context.template.host?.name ??
(typeof trigger.hostName === "string" ? trigger.hostName : undefined),
sourceId: context.automationId,
sourceName: title || undefined,
triggerType:
typeof trigger.type === "string" ? trigger.type : undefined,
value: trigger.value,
threshold: trigger.threshold,
},
});
} catch (error) {
return fail(errorText(error));
}
if (result.recipients === 0) {
return fail("Alerts are turned off on this server");
}
const errors = result.failures.map((f) => `${f.name}: ${f.error}`);
if (channelIds.length > 0 && result.delivered === 0 && errors.length > 0) {
return fail(errors.join("; "));
}
return ok(
`Sent an alert${result.delivered ? ` and notified ${result.delivered} channel(s)` : ""}${errors.length ? `; ${errors.join("; ")}` : ""}`,
);
}
async function runHttp(
step: Extract<Step, { type: "http" }>,
context: StepExecutionContext,
runtime: StepRuntime,
): Promise<StepResult> {
const url = renderTemplate(step.url, context.template);
const headers = renderRecord(step.headers, context.template);
const body = step.body
? renderTemplate(step.body, context.template)
: undefined;
if (context.dryRun) {
return ok(`Would ${step.method} ${url}`);
}
try {
// Private delivery needs both the step's opt-in and an exact host in
// the admin allowlist.
const allowPrivateHosts = step.allowPrivateNetwork
? parseAllowlist(
await runtime.ctx.settings.readCore(PRIVATE_ALLOWLIST_KEY),
)
: [];
const response = await runtime.ctx.fetch(url, {
method: step.method,
headers: body
? { "Content-Type": "application/json", ...(headers ?? {}) }
: headers,
body,
allowPrivateHosts,
timeoutMs: stepTimeout(context, step.timeoutMs, DEFAULT_STEP_TIMEOUT_MS),
});
const text = await response.text();
const summary = `HTTP ${response.status} ${response.statusText}\n${text}`;
return response.ok ? ok(summary) : fail(`HTTP ${response.status}`, summary);
} catch (error) {
return fail(errorText(error));
}
}
function parseAllowlist(raw: unknown): string[] {
if (typeof raw !== "string" || !raw) return [];
try {
const value = JSON.parse(raw);
if (!Array.isArray(value)) return [];
return value
.filter((entry): entry is string => typeof entry === "string")
.map((entry) => entry.trim().toLowerCase())
.filter(Boolean);
} catch {
return [];
}
}
async function runCommand(
step: Extract<Step, { type: "run_command" }>,
context: StepExecutionContext,
runtime: StepRuntime,
): Promise<StepResult> {
const command = renderTemplate(step.command, context.template);
return runOnTargets(step.hostSelector, context, runtime, async (target) => {
if (context.dryRun) {
return { output: `Would run on ${target.name}: ${command}` };
}
return execOnHost(
target.host,
command,
step.elevated,
context,
runtime,
step.timeoutMs,
);
});
}
async function runSnippet(
step: Extract<Step, { type: "run_snippet" }>,
context: StepExecutionContext,
runtime: StepRuntime,
): Promise<StepResult> {
const snippets = runtime.deps.snippets();
const getSnippet = snippets.get;
const resolveCommand = snippets.resolveCommand;
if (
typeof getSnippet !== "function" ||
typeof resolveCommand !== "function"
) {
return fail("The snippets plugin is not available");
}
const snippet = await getSnippet(step.snippetId).catch(() => null);
if (!snippet) return fail("Snippet not found");
if (snippet.isNote) return fail("Notes cannot be executed on a host");
// Template values only ever reach the snippet through inputValues, never by
// rewriting the snippet body, so automation variables cannot inject snippet
// syntax of their own.
const inputValues = renderRecord(step.inputValues, context.template) ?? {};
return runOnTargets(step.hostSelector, context, runtime, async (target) => {
const command = await resolveCommand(
step.snippetId,
{
ip: target.host.ip,
username: target.host.username,
port: target.host.port,
name: target.name,
},
inputValues,
).catch(() => null);
if (!command) {
return { output: "", error: "Snippet could not be resolved" };
}
if (context.dryRun) {
return { output: `Would run snippet on ${target.name}: ${command}` };
}
return execOnHost(
target.host,
command,
step.elevated,
context,
runtime,
step.timeoutMs,
);
});
}
async function runDocker(
step: Extract<Step, { type: "docker" }>,
context: StepExecutionContext,
runtime: StepRuntime,
): Promise<StepResult> {
const container = renderTemplate(step.container, context.template);
if (!container) return fail("Container name is required");
const action = runtime.deps.docker().action;
if (typeof action !== "function") {
return fail("The docker plugin is not available");
}
return runOnTargets(step.hostSelector, context, runtime, async (target) => {
if (context.dryRun) {
return {
output: `Would ${step.action} container ${container} on ${target.name}`,
};
}
try {
await action(target.id, container, step.action);
return { output: `Container ${container}: ${step.action} done` };
} catch (error) {
return { output: "", error: errorText(error) };
}
});
}
async function runTunnel(
step: Extract<Step, { type: "tunnel" }>,
context: StepExecutionContext,
runtime: StepRuntime,
): Promise<StepResult> {
const name = renderTemplate(step.tunnelName, context.template);
if (context.dryRun) return ok(`Would ${step.action} tunnel ${name}`);
const tunnels = runtime.deps.tunnels();
const act = step.action === "connect" ? tunnels.start : tunnels.stop;
if (typeof act !== "function") {
return fail("The tunnels plugin is not available");
}
// A disconnect goes through the tunnels plugin's manual stop, which also
// holds off its retries, so the tunnel stays down.
try {
await act(name);
} catch (error) {
return fail(errorText(error));
}
return ok(
step.action === "connect"
? `Tunnel ${name} connected`
: `Tunnel ${name} disconnected`,
);
}
async function runWol(
step: Extract<Step, { type: "wol" }>,
context: StepExecutionContext,
runtime: StepRuntime,
): Promise<StepResult> {
const host = await runtime.ctx.hosts.get(step.hostId).catch(() => null);
if (!host) return fail("Host not found or not accessible");
const name = host.name || host.ip;
const wake = runtime.deps.wakeOnLan().wake;
if (typeof wake !== "function") {
return fail("The wake-on-lan plugin is not available");
}
if (context.dryRun) return ok(`Would wake ${name}`);
try {
await wake(step.hostId);
return ok(`Sent magic packet to ${name}`);
} catch (error) {
return fail(errorText(error));
}
}
async function runWait(
step: Extract<Step, { type: "wait" }>,
context: StepExecutionContext,
): Promise<StepResult> {
const requested = Math.max(step.seconds, 0) * 1000;
const waitMs = Math.min(
requested,
stepTimeout(context, undefined, requested),
);
if (context.dryRun) return ok(`Would wait ${step.seconds}s`);
await new Promise((resolve) => setTimeout(resolve, waitMs));
return ok(`Waited ${Math.round(waitMs / 1000)}s`);
}
async function runSetVar(
step: Extract<Step, { type: "set_var" }>,
context: StepExecutionContext,
): Promise<StepResult> {
const value = renderTemplate(step.value, context.template);
return ok(`${step.name} = ${value}`, { [step.name]: value });
}
/**
* Runs a per-host action across a selector's targets. One host failing does
* not stop the others, matching how fleet execution behaves.
*/
async function runOnTargets(
selector: Extract<Step, { type: "run_command" }>["hostSelector"],
context: StepExecutionContext,
runtime: StepRuntime,
run: (target: ResolvedTarget) => Promise<{ output?: string; error?: string }>,
): Promise<StepResult> {
const { targets, skipped } = await resolveTargets(selector, context, runtime);
if (targets.length === 0) {
return fail(
skipped.length > 0
? `No accessible hosts (${skipped.length} skipped)`
: "No hosts matched the selector",
);
}
const results = await Promise.allSettled(
targets.map(async (target) => ({
target,
result: await run(target),
})),
);
const lines: string[] = [];
let failures = 0;
for (const [index, settled] of results.entries()) {
const name = targets[index].name;
if (settled.status === "rejected") {
failures++;
lines.push(`${name}: ${String(settled.reason)}`);
continue;
}
const { result } = settled.value;
if (result.error) {
failures++;
lines.push(`${name}: ${result.error}`);
} else {
lines.push(`${name}: ${result.output ?? "ok"}`);
}
}
if (skipped.length > 0) {
lines.push(`${skipped.length} host(s) skipped: no access`);
}
const output = lines.join("\n");
return failures > 0 && failures === targets.length
? fail(`All ${failures} host(s) failed`, output)
: ok(output);
}
async function execOnHost(
host: ResolvedTarget["host"],
command: string,
elevated: boolean | undefined,
context: StepExecutionContext,
runtime: StepRuntime,
timeoutMs: number | undefined,
): Promise<{ output?: string; error?: string }> {
const timeout = stepTimeout(context, timeoutMs, DEFAULT_STEP_TIMEOUT_MS);
if (timeout <= 0) return { error: "Run deadline exceeded" };
try {
const result = await runtime.ctx.ssh.withConnection<
{ stdout: string; stderr: string; code: number | null },
Client
>(host, { pool: "automations", purpose: "fleet" }, async (client) => {
if (elevated) {
return execElevated(
client,
command,
host.sudoPassword as string | undefined,
{ timeoutMs: timeout },
);
}
return execCommand(client, command, timeout);
});
const output = [result.stdout, result.stderr].filter(Boolean).join("\n");
if (result.code === 0 || result.code === null) {
return { output: output || "(no output)" };
}
return { error: `Exited with code ${result.code}`, output };
} catch (error) {
return { error: errorText(error) };
}
}
@@ -0,0 +1,66 @@
import type { PluginContext } from "@termix/plugin-sdk/backend";
import type { Step } from "../../types.js";
import type { Deps } from "../deps.js";
import type { TemplateContext } from "../template.js";
/** What every executor returns. `output` is what later steps can read. */
export interface StepResult {
success: boolean;
output?: string;
error?: string;
/** Merged into the run's variables, for set_var and friends. */
vars?: Record<string, string>;
/** Set by the stop step to end the run early. */
halt?: { status: "success" | "failed" };
}
/** What the executors reach the rest of Termix through. */
export interface StepRuntime {
ctx: PluginContext;
deps: Deps;
}
export interface StepExecutionContext {
userId: string;
automationId: number;
runId: number;
/** Nothing that leaves Termix may actually happen when this is set. */
dryRun: boolean;
template: TemplateContext;
/** Host the trigger fired for, when there was one. */
triggerHostId?: number;
/** Automations already on the stack, to refuse recursion. */
ancestry: number[];
depth: number;
/** Wall-clock deadline for the whole run. */
deadlineAt: number;
signal?: AbortSignal;
}
export type StepExecutor<T extends Step = Step> = (
step: T,
context: StepExecutionContext,
) => Promise<StepResult>;
export function ok(output?: string, vars?: Record<string, string>): StepResult {
return { success: true, output, vars };
}
export function fail(error: string, output?: string): StepResult {
return { success: false, error, output };
}
/** Milliseconds left before the run's overall deadline. */
export function remainingMs(context: StepExecutionContext): number {
return Math.max(context.deadlineAt - Date.now(), 0);
}
/** A step's timeout, clamped so it can never outlive the run. */
export function stepTimeout(
context: StepExecutionContext,
requested: number | undefined,
fallback: number,
): number {
const wanted = requested && requested > 0 ? requested : fallback;
return Math.max(Math.min(wanted, remainingMs(context)), 0);
}
@@ -0,0 +1,203 @@
import type { MetricPath, Operator } from "../types.js";
/**
* Operator evaluation and metric extraction.
*
* Pure: no database, no SSH, no clock. Everything here is driven by values the
* caller already has, which keeps the comparison rules testable on their own.
*/
/** Shape of the metrics snapshot this module reads. Mirrors collectMetrics(). */
export interface MetricsSnapshot {
cpu?: {
percent?: number | null;
load?: [number, number, number] | null;
} | null;
memory?: { percent?: number | null; usedGiB?: number | null } | null;
disk?: {
percent?: number | null;
filesystems?: Array<{
mount?: string;
percent?: number | null;
availableBytes?: number | null;
}> | null;
} | null;
network?: {
interfaces?: Array<{
name?: string;
rxBytes?: string | number | null;
txBytes?: string | number | null;
rxRateBps?: number | null;
txRateBps?: number | null;
}> | null;
} | null;
temperature?: { highestCelsius?: number | null } | null;
uptime?: { seconds?: number | null } | null;
processes?: { total?: number | null } | null;
}
function toNumber(value: unknown): number | null {
if (value === null || value === undefined || value === "") return null;
const parsed = typeof value === "number" ? value : Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
/**
* Pulls the value a trigger watches out of a metrics snapshot.
*
* The mount and iface selectors are what let a rule watch one filesystem or
* one interface. Without a selector the aggregate is used, which for disk is
* the primary (root) mount, matching what the metrics UI shows.
*/
export function extractMetricValue(
metrics: MetricsSnapshot | null | undefined,
metric: MetricPath,
): number | null {
if (!metrics) return null;
switch (metric.path) {
case "cpu.percent":
return toNumber(metrics.cpu?.percent);
case "cpu.load1":
return toNumber(metrics.cpu?.load?.[0]);
case "cpu.load5":
return toNumber(metrics.cpu?.load?.[1]);
case "cpu.load15":
return toNumber(metrics.cpu?.load?.[2]);
case "memory.percent":
return toNumber(metrics.memory?.percent);
case "memory.usedGiB":
return toNumber(metrics.memory?.usedGiB);
case "disk.percent": {
if (!metric.mount) return toNumber(metrics.disk?.percent);
const fs = findMount(metrics, metric.mount);
return fs ? toNumber(fs.percent) : null;
}
case "disk.availableBytes": {
const fs = metric.mount ? findMount(metrics, metric.mount) : null;
if (metric.mount) return fs ? toNumber(fs.availableBytes) : null;
const first = metrics.disk?.filesystems?.[0];
return first ? toNumber(first.availableBytes) : null;
}
case "temperature.highestCelsius":
return toNumber(metrics.temperature?.highestCelsius);
case "uptime.seconds":
return toNumber(metrics.uptime?.seconds);
case "processes.total":
return toNumber(metrics.processes?.total);
case "network.rxBytes":
return toNumber(findInterface(metrics, metric.iface)?.rxBytes);
case "network.txBytes":
return toNumber(findInterface(metrics, metric.iface)?.txBytes);
case "network.rxRateBps":
return toNumber(findInterface(metrics, metric.iface)?.rxRateBps);
case "network.txRateBps":
return toNumber(findInterface(metrics, metric.iface)?.txRateBps);
default:
return null;
}
}
function findMount(metrics: MetricsSnapshot, mount: string) {
return (
metrics.disk?.filesystems?.find((entry) => entry.mount === mount) ?? null
);
}
function findInterface(metrics: MetricsSnapshot, iface?: string) {
const interfaces = metrics.network?.interfaces;
if (!interfaces || interfaces.length === 0) return null;
if (!iface) return interfaces[0];
return interfaces.find((entry) => entry.name === iface) ?? null;
}
/**
* The key a trigger's durable state is stored under. Including the mount or
* container is what allows a sustained-breach window per filesystem rather
* than per host.
*/
export function metricStateKey(hostId: number, metric: MetricPath): string {
if ("mount" in metric && metric.mount) return `${hostId}:${metric.mount}`;
if ("iface" in metric && metric.iface) return `${hostId}:${metric.iface}`;
return String(hostId);
}
/**
* Compares two values. Numeric when both sides look numeric, so "90" and 90
* behave the same; string comparison otherwise. `changed` is handled by the
* caller, which is the only place that knows the previous value.
*/
export function compare(
left: unknown,
operator: Operator,
right: unknown,
): boolean {
if (operator === "contains" || operator === "not_contains") {
const haystack = String(left ?? "");
const needle = String(right ?? "");
const found = haystack.includes(needle);
return operator === "contains" ? found : !found;
}
const leftNumber = toNumber(left);
const rightNumber = toNumber(right);
const numeric = leftNumber !== null && rightNumber !== null;
switch (operator) {
case ">":
return numeric && leftNumber > rightNumber;
case "<":
return numeric && leftNumber < rightNumber;
case ">=":
return numeric && leftNumber >= rightNumber;
case "<=":
return numeric && leftNumber <= rightNumber;
case "==":
return numeric
? leftNumber === rightNumber
: String(left ?? "") === String(right ?? "");
case "!=":
return numeric
? leftNumber !== rightNumber
: String(left ?? "") !== String(right ?? "");
case "changed":
return String(left ?? "") !== String(right ?? "");
default:
return false;
}
}
/** Whether a cooldown window is still open. */
export function isCoolingDown(
lastFiredAt: string | null | undefined,
cooldownMinutes: number,
now: number = Date.now(),
): boolean {
if (!lastFiredAt) return false;
const last = Date.parse(lastFiredAt);
if (Number.isNaN(last)) return false;
return now - last < Math.max(cooldownMinutes, 0) * 60_000;
}
/** Whether a sustained breach has been held long enough to fire. */
export function hasDwelled(
breachStartedAt: string | null | undefined,
forSeconds: number | undefined,
now: number = Date.now(),
): boolean {
if (!forSeconds) return true;
if (!breachStartedAt) return false;
const started = Date.parse(breachStartedAt);
if (Number.isNaN(started)) return false;
return now - started >= forSeconds * 1000;
}
/** Severity for a threshold breach, matching the old engine's behaviour. */
export function severityForValue(
value: number | null,
explicit?: "info" | "warning" | "critical",
): "info" | "warning" | "critical" {
if (explicit) return explicit;
if (value !== null && value >= 95) return "critical";
return "warning";
}
+291
View File
@@ -0,0 +1,291 @@
/**
* A small five field cron parser, used only to work out when a schedule is
* next due.
*
* Deliberately not a dependency: the engine needs "when is this next due?" and
* nothing else, and a pure function is far easier to test than a scheduler
* library. Fields are the standard minute, hour, day-of-month, month and
* day-of-week, supporting *, lists (1,2), ranges (1-5) and steps (a slash).
*
* Day-of-month and day-of-week follow cron's union rule: when both are
* restricted, a date matching either one matches.
*/
interface CronFields {
minutes: Set<number>;
hours: Set<number>;
daysOfMonth: Set<number>;
months: Set<number>;
daysOfWeek: Set<number>;
domRestricted: boolean;
dowRestricted: boolean;
}
const RANGES: Record<string, [number, number]> = {
minute: [0, 59],
hour: [0, 23],
dayOfMonth: [1, 31],
month: [1, 12],
// 7 is accepted as an alias for Sunday and folded to 0 once parsed.
dayOfWeek: [0, 7],
};
const NAMED_MONTHS: Record<string, number> = {
jan: 1,
feb: 2,
mar: 3,
apr: 4,
may: 5,
jun: 6,
jul: 7,
aug: 8,
sep: 9,
oct: 10,
nov: 11,
dec: 12,
};
const NAMED_DAYS: Record<string, number> = {
sun: 0,
mon: 1,
tue: 2,
wed: 3,
thu: 4,
fri: 5,
sat: 6,
};
function normalize(token: string, kind: string): string {
const lower = token.toLowerCase();
if (kind === "month" && lower in NAMED_MONTHS) {
return String(NAMED_MONTHS[lower]);
}
if (kind === "dayOfWeek" && lower in NAMED_DAYS) {
return String(NAMED_DAYS[lower]);
}
return token;
}
function parseField(field: string, kind: keyof typeof RANGES): Set<number> {
const [min, max] = RANGES[kind];
const values = new Set<number>();
for (const part of field.split(",")) {
const trimmed = part.trim();
if (!trimmed) throw new Error(`Empty value in ${kind} field`);
const [rangePart, stepPart] = trimmed.split("/");
const step = stepPart === undefined ? 1 : Number(stepPart);
if (!Number.isInteger(step) || step < 1) {
throw new Error(`Invalid step in ${kind} field`);
}
let start: number;
let end: number;
if (rangePart === "*" || rangePart === "") {
start = min;
end = max;
} else if (rangePart.includes("-")) {
const [from, to] = rangePart.split("-");
start = Number(normalize(from, kind));
end = Number(normalize(to, kind));
} else {
start = Number(normalize(rangePart, kind));
end = stepPart === undefined ? start : max;
}
if (!Number.isInteger(start) || !Number.isInteger(end)) {
throw new Error(`Invalid value in ${kind} field`);
}
if (start < min || end > max || start > end) {
throw new Error(`Value out of range in ${kind} field`);
}
for (let value = start; value <= end; value += step) {
// Sunday can be written as 7; cron treats it as 0.
values.add(kind === "dayOfWeek" && value === 7 ? 0 : value);
}
}
return values;
}
export function parseCron(expression: string): CronFields {
const fields = expression.trim().split(/\s+/);
if (fields.length !== 5) {
throw new Error("A cron expression needs five fields");
}
const [minute, hour, dayOfMonth, month, dayOfWeek] = fields;
return {
minutes: parseField(minute, "minute"),
hours: parseField(hour, "hour"),
daysOfMonth: parseField(dayOfMonth, "dayOfMonth"),
months: parseField(month, "month"),
daysOfWeek: parseField(dayOfWeek, "dayOfWeek"),
domRestricted: dayOfMonth.trim() !== "*",
dowRestricted: dayOfWeek.trim() !== "*",
};
}
export function isValidCron(expression: string): boolean {
try {
parseCron(expression);
return true;
} catch {
return false;
}
}
/** Wall-clock fields for a moment, in a given zone or server local time. */
interface WallClock {
minute: number;
hour: number;
dayOfMonth: number;
month: number;
dayOfWeek: number;
}
const WEEKDAYS: Record<string, number> = {
Sun: 0,
Mon: 1,
Tue: 2,
Wed: 3,
Thu: 4,
Fri: 5,
Sat: 6,
};
const formatterCache = new Map<string, Intl.DateTimeFormat>();
/**
* Whether a zone name is one this runtime actually knows. An unknown zone
* falls back to server local time rather than throwing, so a bad value saved
* against a schedule cannot stop it from ever running.
*/
export function isValidTimezone(timezone: string): boolean {
try {
new Intl.DateTimeFormat("en-US", { timeZone: timezone });
return true;
} catch {
return false;
}
}
function formatterFor(timezone: string): Intl.DateTimeFormat | null {
const cached = formatterCache.get(timezone);
if (cached) return cached;
try {
const formatter = new Intl.DateTimeFormat("en-US", {
timeZone: timezone,
hour12: false,
weekday: "short",
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "numeric",
});
formatterCache.set(timezone, formatter);
return formatter;
} catch {
return null;
}
}
function wallClock(date: Date, timezone?: string | null): WallClock {
const formatter = timezone ? formatterFor(timezone) : null;
if (!formatter) {
return {
minute: date.getMinutes(),
hour: date.getHours(),
dayOfMonth: date.getDate(),
month: date.getMonth() + 1,
dayOfWeek: date.getDay(),
};
}
const parts: Record<string, string> = {};
for (const part of formatter.formatToParts(date)) {
parts[part.type] = part.value;
}
return {
// Midnight formats as 24 in some locales' hour-cycle handling.
minute: Number(parts.minute),
hour: Number(parts.hour) % 24,
dayOfMonth: Number(parts.day),
month: Number(parts.month),
dayOfWeek: WEEKDAYS[parts.weekday] ?? date.getDay(),
};
}
function matches(
fields: CronFields,
date: Date,
timezone?: string | null,
): boolean {
const clock = wallClock(date, timezone);
if (!fields.months.has(clock.month)) return false;
if (!fields.minutes.has(clock.minute)) return false;
if (!fields.hours.has(clock.hour)) return false;
const domMatch = fields.daysOfMonth.has(clock.dayOfMonth);
const dowMatch = fields.daysOfWeek.has(clock.dayOfWeek);
// Both restricted means either may match, which is how cron behaves.
if (fields.domRestricted && fields.dowRestricted) return domMatch || dowMatch;
if (fields.domRestricted) return domMatch;
if (fields.dowRestricted) return dowMatch;
return true;
}
/**
* The next time on or after `from` that the expression matches, or null when
* nothing matches within a four year window (e.g. Feb 30).
*/
export function nextCronRun(
expression: string,
from: Date = new Date(),
timezone?: string | null,
): Date | null {
const fields = parseCron(expression);
const candidate = new Date(from.getTime());
candidate.setSeconds(0, 0);
candidate.setMinutes(candidate.getMinutes() + 1);
// Four years covers every leap year cycle, so a date that never matches
// gives up rather than looping.
const limit = 366 * 4 * 24 * 60;
for (let i = 0; i < limit; i++) {
if (matches(fields, candidate, timezone)) return candidate;
candidate.setMinutes(candidate.getMinutes() + 1);
}
return null;
}
/**
* Next due time for a schedule trigger, as an ISO string. Interval wins over
* cron when both are set, matching the editor which offers one or the other.
*/
export function computeNextDueAt(
schedule: {
cron?: string | null;
intervalSeconds?: number | null;
timezone?: string | null;
},
from: Date = new Date(),
): string | null {
if (schedule.intervalSeconds && schedule.intervalSeconds > 0) {
return new Date(
from.getTime() + schedule.intervalSeconds * 1000,
).toISOString();
}
if (schedule.cron) {
const next = nextCronRun(schedule.cron, from, schedule.timezone);
return next ? next.toISOString() : null;
}
return null;
}
+121
View File
@@ -0,0 +1,121 @@
import type { PluginServices } from "@termix/plugin-sdk/backend";
import type { DockerEventKind } from "../types.js";
/*
* Other plugins, as automations reaches them. Every call runs as the current
* actor (the automation's owner, set by the engine with ctx.asUser). A
* missing provider answers an empty handle, so each method is probed before
* it is called.
*/
export interface SnippetsAccessV1 {
list?: () => Promise<Array<{ id: number; name: string; isNote?: boolean }>>;
get: (id: number) => Promise<{
id: number;
name: string;
content: string;
isNote: boolean;
} | null>;
resolveCommand: (
id: number,
vars: { ip?: string; username?: string; port?: number; name?: string },
inputValues?: Record<string, string>,
) => Promise<string | null>;
}
export interface FleetsAccessV1 {
list: () => Promise<Array<{ id: number; name: string }>>;
members: (
fleetId: number,
) => Promise<Array<{ id: number; name: string | null; ip: string }>>;
}
export interface TunnelsAccessV1 {
start: (name: string) => Promise<void>;
stop: (name: string) => Promise<void>;
}
export interface DockerContainersV1 {
action: (
hostId: number,
container: string,
action: "start" | "stop" | "restart",
) => Promise<void>;
}
export interface DockerEvent {
hostId: number;
container: string;
event: DockerEventKind;
}
export interface DockerEventsV1 {
subscribe: (
hostId: number,
listener: (event: DockerEvent) => void,
) => Promise<() => void>;
}
export interface MetricsViewersV1 {
register: (
hostId: number,
) => Promise<{ viewerSessionId: string } | { skipped: true; reason: string }>;
heartbeat: (viewerSessionId: string) => boolean;
unregister: (hostId: number, viewerSessionId: string) => void;
}
/** Provided by the wake-on-lan plugin. It resolves the host's MAC itself. */
export interface WakeOnLanV1 {
wake: (hostId: number) => Promise<void>;
}
/** The plugin id behind each service, for "needs <plugin>" messages. */
export const PROVIDERS = {
snippets: "snippets.access",
fleets: "fleets.access",
tunnels: "tunnels.access",
docker: "docker.containers",
"docker-events": "docker.events",
"host-metrics": "host-metrics.viewers",
"wake-on-lan": "wake-on-lan.send",
} as const;
export type ProviderKey = keyof typeof PROVIDERS;
/** The plugin a provider key belongs to. */
export function pluginOf(key: ProviderKey): string {
return key === "docker-events" ? "docker" : key;
}
export type Deps = ReturnType<typeof createDeps>;
export function createDeps(services: PluginServices) {
function handle<T extends object>(name: string): Partial<T> {
try {
return services.get<T>(name) as Partial<T>;
} catch {
return {};
}
}
return {
/** Which providers are running now. */
available(key: ProviderKey): boolean {
return services.providers(PROVIDERS[key]).length > 0;
},
providers(): Record<ProviderKey, boolean> {
const result = {} as Record<ProviderKey, boolean>;
for (const key of Object.keys(PROVIDERS) as ProviderKey[]) {
result[key] = services.providers(PROVIDERS[key]).length > 0;
}
return result;
},
snippets: () => handle<SnippetsAccessV1>(PROVIDERS.snippets),
fleets: () => handle<FleetsAccessV1>(PROVIDERS.fleets),
tunnels: () => handle<TunnelsAccessV1>(PROVIDERS.tunnels),
docker: () => handle<DockerContainersV1>(PROVIDERS.docker),
dockerEvents: () => handle<DockerEventsV1>(PROVIDERS["docker-events"]),
viewers: () => handle<MetricsViewersV1>(PROVIDERS["host-metrics"]),
wakeOnLan: () => handle<WakeOnLanV1>(PROVIDERS["wake-on-lan"]),
};
}
@@ -0,0 +1,90 @@
import type { PluginContext } from "@termix/plugin-sdk/backend";
import type { Deps, DockerEvent } from "./deps.js";
import type { DockerEvent as TriggerDockerEvent } from "./triggers.js";
/**
* Keeps a docker.events subscription for every host a docker_event trigger
* names, as the automation's owner. The docker plugin does the polling; this
* only follows which hosts are watched.
*
* Subscribing is idempotent on the docker side, so every watched host is
* subscribed again once a minute. That is what picks the subscriptions back
* up after the docker plugin is disabled and enabled again.
*/
const RESUBSCRIBE_MS = 60_000;
interface Watch {
listener: (event: DockerEvent) => void;
unsubscribe: (() => void) | null;
subscribedAt: number;
}
export function createDockerWatcher(
ctx: Pick<PluginContext, "asUser">,
deps: Pick<Deps, "dockerEvents">,
watchedHosts: () => Promise<Map<number, string>>,
onEvent: (event: TriggerDockerEvent) => Promise<void>,
) {
const watches = new Map<string, Watch>();
const keyOf = (hostId: number, userId: string) => `${userId}:${hostId}`;
return {
/** Brings the subscriptions in line with the watched hosts. */
async reconcile(now: number = Date.now()): Promise<void> {
const wanted = await watchedHosts();
const wantedKeys = new Set(
[...wanted].map(([hostId, userId]) => keyOf(hostId, userId)),
);
for (const [key, watch] of watches) {
if (wantedKeys.has(key)) continue;
watch.unsubscribe?.();
watches.delete(key);
}
for (const [hostId, userId] of wanted) {
const key = keyOf(hostId, userId);
let watch = watches.get(key);
if (!watch) {
watch = {
listener: (event) => {
void onEvent({
hostId: event.hostId,
ownerUserId: userId,
container: event.container,
event: event.event,
}).catch(() => undefined);
},
unsubscribe: null,
subscribedAt: 0,
};
watches.set(key, watch);
}
if (watch.subscribedAt && now - watch.subscribedAt < RESUBSCRIBE_MS) {
continue;
}
const subscribe = deps.dockerEvents().subscribe;
if (typeof subscribe !== "function") {
watch.subscribedAt = 0;
continue;
}
try {
const current = watch;
watch.unsubscribe = await ctx.asUser(userId, () =>
subscribe(hostId, current.listener),
);
watch.subscribedAt = now;
} catch {
watch.subscribedAt = 0;
}
}
},
/** Drops every subscription, on deactivate. */
reset(): void {
for (const watch of watches.values()) watch.unsubscribe?.();
watches.clear();
},
};
}
+501
View File
@@ -0,0 +1,501 @@
import type { PluginContext } from "@termix/plugin-sdk/backend";
import type { AutomationDefinition, RunStatus, Step } from "../types.js";
import {
DEFAULT_MAX_RUN_SECONDS,
MAX_AUTOMATION_DEPTH,
MAX_STEP_OUTPUT_BYTES,
} from "../types.js";
import { executeStep } from "./actions/index.js";
import type { StepExecutionContext, StepResult } from "./actions/types.js";
import { compare } from "./conditions.js";
import type { Deps } from "./deps.js";
import type {
AutomationEngineRow,
AutomationRepository,
} from "./repository.js";
import { missingPlugins } from "./requirements.js";
import { renderTemplate, type TemplateContext } from "./template.js";
/** Emitted when a run fails, so an automation_failed trigger can react. */
export const TOPIC_AUTOMATION_FAILED = "plugin.automations.automation_failed";
export interface RunRequest {
automationId: number;
triggerType: string;
triggerContext?: Record<string, unknown>;
triggerHostId?: number;
/** Overrides the automation's own dry-run flag, for "test run". */
dryRun?: boolean;
parentRunId?: number;
ancestry?: number[];
depth?: number;
}
export interface RunOutcome {
runId: number | null;
status: RunStatus;
error?: string;
}
/**
* Executes automations. One per activation. State that has to survive a
* restart (cooldowns, dwell windows) lives in the database; the only thing
* held in memory is the set of runs in flight, which is meaningless after a
* restart anyway.
*/
export class AutomationEngine {
private readonly running = new Set<number>();
private readonly queued = new Map<number, number>();
constructor(
private readonly ctx: PluginContext,
private readonly repository: AutomationRepository,
private readonly deps: Deps,
) {}
isRunning(automationId: number): boolean {
return this.running.has(automationId);
}
/**
* Runs an automation as its owner. Never throws: a failure is the outcome.
*/
async run(request: RunRequest): Promise<RunOutcome> {
let automation: AutomationEngineRow | null;
try {
automation = await this.repository.findById(request.automationId);
} catch (err) {
return { runId: null, status: "failed", error: errorText(err) };
}
if (!automation) {
return { runId: null, status: "failed", error: "Automation not found" };
}
const owner = automation;
try {
return await this.ctx.asUser(owner.userId, () =>
this.runAsOwner(owner, request),
);
} catch (err) {
return { runId: null, status: "failed", error: errorText(err) };
}
}
private async runAsOwner(
automation: AutomationEngineRow,
request: RunRequest,
): Promise<RunOutcome> {
const repository = this.repository;
const depth = request.depth ?? 0;
const ancestry = request.ancestry ?? [];
// Refuse recursion before anything is recorded, so a cycle cannot spin.
if (depth > MAX_AUTOMATION_DEPTH) {
return {
runId: null,
status: "failed",
error: `Maximum automation depth of ${MAX_AUTOMATION_DEPTH} exceeded`,
};
}
if (ancestry.includes(automation.id)) {
return {
runId: null,
status: "failed",
error: `Automation ${automation.id} is already running in this chain`,
};
}
let definition: AutomationDefinition;
try {
definition = JSON.parse(automation.definition) as AutomationDefinition;
} catch {
return {
runId: null,
status: "failed",
error: "Automation definition is not valid JSON",
};
}
// A trigger or step whose plugin is off skips the whole run with the
// reason on record, rather than running half of it.
const missing = missingPlugins(definition, this.deps);
if (missing.length > 0) {
const reason = `Needs the ${missing.join(", ")} plugin${missing.length > 1 ? "s" : ""}`;
this.ctx.log.info(
`Skipping automation ${automation.id} (${request.triggerType}): ${reason}`,
);
const run = await repository.createRun({
automationId: automation.id,
userId: automation.userId,
triggerType: request.triggerType,
triggerContext: JSON.stringify(request.triggerContext ?? {}),
status: "skipped",
parentRunId: request.parentRunId ?? null,
});
await repository.finishRun(run.id, {
status: "skipped",
error: reason,
durationMs: 0,
});
return { runId: run.id, status: "skipped", error: reason };
}
// A second trigger while a run is in flight is recorded as skipped rather
// than dropped silently, so the history explains what happened.
//
// The slot has to be claimed in the same tick as the check. It used to be
// claimed several awaits later, so two triggers arriving together both
// passed this test and both ran.
let claimed = false;
if (this.running.has(automation.id)) {
const policy = automation.concurrencyPolicy;
if (policy === "skip") {
const run = await repository.createRun({
automationId: automation.id,
userId: automation.userId,
triggerType: request.triggerType,
triggerContext: JSON.stringify(request.triggerContext ?? {}),
status: "skipped",
});
await repository.finishRun(run.id, {
status: "skipped",
error: "A previous run was still in progress",
durationMs: 0,
});
return { runId: run.id, status: "skipped" };
}
if (policy === "queue") {
const depthNow = this.queued.get(automation.id) ?? 0;
if (depthNow >= 5) {
return { runId: null, status: "skipped", error: "Queue is full" };
}
this.queued.set(automation.id, depthNow + 1);
try {
await this.waitUntilFree(automation.id);
} finally {
this.queued.set(
automation.id,
(this.queued.get(automation.id) ?? 1) - 1,
);
}
// waitUntilFree gives up on its own deadline, so the slot may still be
// taken. Only claim it when it is genuinely free.
if (!this.running.has(automation.id)) {
this.running.add(automation.id);
claimed = true;
}
}
} else {
this.running.add(automation.id);
claimed = true;
}
const dryRun = request.dryRun ?? automation.dryRun;
const maxRunSeconds = automation.maxRunSeconds || DEFAULT_MAX_RUN_SECONDS;
const startedAt = Date.now();
let run: { id: number };
try {
run = await repository.createRun({
automationId: automation.id,
userId: automation.userId,
triggerType: request.triggerType,
triggerContext: JSON.stringify(request.triggerContext ?? {}),
status: "running",
dryRun,
parentRunId: request.parentRunId ?? null,
});
} catch (err) {
// The slot is already claimed at this point, so it has to be given back
// here; the finally below is only reached once a run row exists.
if (claimed) this.running.delete(automation.id);
return {
runId: null,
status: "failed",
error: err instanceof Error ? err.message : String(err),
};
}
if (!claimed) this.running.add(automation.id);
const trigger = { ...(request.triggerContext ?? {}) };
trigger.type ??= request.triggerType;
let host: TemplateContext["host"];
if (request.triggerHostId) {
try {
const resolved = await this.ctx.hosts.get(request.triggerHostId);
if (resolved) {
host = {
id: request.triggerHostId,
name: resolved.name || resolved.ip,
ip: resolved.ip,
username: resolved.username,
port: resolved.port,
};
trigger.hostName ??= host.name;
}
} catch {
// A notification should still run with its numeric host id.
}
}
const template: TemplateContext = {
host,
trigger,
steps: {},
vars: {},
run: {
id: run.id,
automationId: automation.id,
startedAt: new Date(startedAt).toISOString(),
},
};
const context: StepExecutionContext = {
userId: automation.userId,
automationId: automation.id,
runId: run.id,
dryRun,
template,
triggerHostId: request.triggerHostId,
ancestry: [...ancestry, automation.id],
depth,
deadlineAt: startedAt + maxRunSeconds * 1000,
};
let status: RunStatus = "success";
let error: string | undefined;
try {
const result = await this.runSteps(definition.steps ?? [], context, {
index: 0,
});
if (result.halted?.status === "failed") {
status = "failed";
error = "Stopped by a stop step";
} else if (result.failed) {
status = "failed";
error = result.error;
}
if (Date.now() >= context.deadlineAt) {
status = "timeout";
error = `Run exceeded ${maxRunSeconds}s`;
}
} catch (err) {
status = "failed";
error = errorText(err);
} finally {
this.running.delete(automation.id);
}
await repository.finishRun(run.id, {
status,
error: error ?? null,
durationMs: Date.now() - startedAt,
});
if (status === "failed") {
this.ctx.log.warn(
`Automation "${automation.name}" (${automation.id}) failed in run ${run.id}: ${error ?? "unknown error"}`,
);
// An automation_failed handler that itself fails must not re-announce
// its own failure, so the event is not emitted for runs that this event
// already started.
await this.ctx.notify
.send({
title: `Automation "${automation.name}" failed`,
body: error ?? undefined,
severity: "warning",
category: "automations.run_failed",
audience: { userId: automation.userId },
link: { tab: "automations" },
dedupeKey: `automations.run_failed:${automation.id}`,
context: {
sourceId: automation.id,
sourceName: automation.name,
triggerType: request.triggerType,
},
})
.catch((notifyError: unknown) =>
this.ctx.log.warn(
`Could not send the failure alert for automation ${automation.id}: ${errorText(notifyError)}`,
),
);
if (request.triggerType !== "internal_event") {
this.ctx.events.emit(TOPIC_AUTOMATION_FAILED, {
userId: automation.userId,
automationId: automation.id,
automationName: automation.name,
runId: run.id,
error: error ?? null,
});
}
}
return { runId: run.id, status, error };
}
/**
* Runs a list of steps in order, descending into if/else. Returns as soon as
* a stop step halts the run or a failing step's policy says to stop.
*/
private async runSteps(
steps: Step[],
context: StepExecutionContext,
cursor: { index: number },
): Promise<{
failed: boolean;
error?: string;
halted?: { status: "success" | "failed" };
}> {
const repository = this.repository;
for (const step of steps) {
if (step.enabled === false) continue;
if (Date.now() >= context.deadlineAt) {
return { failed: true, error: "Run deadline exceeded" };
}
const stepIndex = cursor.index++;
if (step.type === "if") {
const left = renderTemplate(step.condition.left, context.template);
const right = renderTemplate(
step.condition.right ?? "",
context.template,
);
const matched = compare(left, step.condition.operator, right);
const rowId = await repository.createRunStep({
runId: context.runId,
stepIndex,
stepId: step.id,
stepType: "if",
status: "running",
});
await repository.finishRunStep(rowId, {
status: "success",
output: `Condition ${matched ? "matched" : "did not match"}: ${left} ${step.condition.operator} ${right}`,
});
const branch = matched ? step.then : (step.else ?? []);
const result = await this.runSteps(branch, context, cursor);
if (result.halted) return result;
if (result.failed) return result;
continue;
}
if (step.type === "run_automation") {
const rowId = await repository.createRunStep({
runId: context.runId,
stepIndex,
stepId: step.id,
stepType: step.type,
status: "running",
});
const nested = await this.run({
automationId: step.automationId,
triggerType: "run_automation",
triggerContext: { parentAutomationId: context.automationId },
triggerHostId: context.triggerHostId,
dryRun: context.dryRun,
parentRunId: context.runId,
ancestry: context.ancestry,
depth: context.depth + 1,
});
const nestedOk = nested.status === "success";
await repository.finishRunStep(rowId, {
status: nestedOk ? "success" : "failed",
output: `Nested run ${nested.runId ?? "not started"}: ${nested.status}`,
error: nested.error ?? null,
});
if (!nestedOk && (step.onError ?? "stop") === "stop") {
return { failed: true, error: nested.error ?? "Nested run failed" };
}
continue;
}
const rowId = await repository.createRunStep({
runId: context.runId,
stepIndex,
stepId: step.id,
stepType: step.type,
status: "running",
});
let result: StepResult;
try {
result = await executeStep(step, context, {
ctx: this.ctx,
deps: this.deps,
});
} catch (err) {
result = { success: false, error: errorText(err) };
}
const { text, truncated } = truncate(result.output);
await repository.finishRunStep(rowId, {
status: result.success ? "success" : "failed",
output: text,
error: result.error ?? null,
truncated,
});
// Later steps read earlier output through {{steps.<id>.stdout}}.
context.template.steps = {
...context.template.steps,
[step.id]: {
stdout: result.output ?? "",
code: result.success ? 0 : 1,
},
};
if (result.vars) {
context.template.vars = { ...context.template.vars, ...result.vars };
}
if (result.halt) return { failed: false, halted: result.halt };
if (!result.success) {
const policy = step.onError ?? "stop";
if (policy === "stop") {
return { failed: true, error: result.error };
}
}
}
return { failed: false };
}
private async waitUntilFree(automationId: number): Promise<void> {
const started = Date.now();
while (this.running.has(automationId)) {
if (Date.now() - started > 60_000) return;
await new Promise((resolve) => setTimeout(resolve, 250));
}
}
}
function errorText(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
/** Keeps a single step's output from bloating the database. */
function truncate(output: string | undefined): {
text: string | null;
truncated: boolean;
} {
if (!output) return { text: null, truncated: false };
if (Buffer.byteLength(output, "utf8") <= MAX_STEP_OUTPUT_BYTES) {
return { text: output, truncated: false };
}
return {
text: output.slice(0, MAX_STEP_OUTPUT_BYTES) + "\n... (truncated)",
truncated: true,
};
}
@@ -0,0 +1,101 @@
import type { PluginContext } from "@termix/plugin-sdk/backend";
import type { Deps } from "./deps.js";
/**
* Keeps metric collection running for hosts an automation watches.
*
* Host metrics only polls hosts somebody is looking at. Automations register
* as a viewer through the host-metrics.viewers service, as the automation's
* owner, and heartbeat every tick so the viewer is not reaped. A heartbeat
* that comes back false means host-metrics restarted and forgot the viewer,
* so the next tick registers it again.
*/
interface Registered {
userId: string;
viewerSessionId: string;
}
export function createHeadlessViewers(
ctx: Pick<PluginContext, "asUser" | "log">,
deps: Pick<Deps, "viewers">,
watchedHosts: () => Promise<Map<number, string>>,
) {
const registered = new Map<number, Registered>();
async function release(hostId: number, entry: Registered): Promise<void> {
registered.delete(hostId);
const unregister = deps.viewers().unregister;
if (typeof unregister !== "function") return;
try {
await ctx.asUser(entry.userId, () =>
unregister(hostId, entry.viewerSessionId),
);
} catch {
// Already gone; drop it either way.
}
}
return {
/** Brings the viewers in line with what the automations watch. */
async reconcile(): Promise<{ added: number; removed: number }> {
const viewers = deps.viewers();
if (typeof viewers.register !== "function") {
// host-metrics is off: its viewers went with it.
registered.clear();
return { added: 0, removed: 0 };
}
const watched = await watchedHosts();
let added = 0;
let removed = 0;
for (const [hostId, entry] of [...registered]) {
if (watched.get(hostId) === entry.userId) continue;
await release(hostId, entry);
removed++;
}
for (const [hostId, userId] of watched) {
const existing = registered.get(hostId);
if (existing) {
const alive = await ctx
.asUser(userId, () => viewers.heartbeat!(existing.viewerSessionId))
.catch(() => false);
if (alive) continue;
registered.delete(hostId);
}
try {
const result = await ctx.asUser(userId, () =>
viewers.register!(hostId),
);
if ("viewerSessionId" in result) {
registered.set(hostId, {
userId,
viewerSessionId: result.viewerSessionId,
});
added++;
}
} catch (error) {
ctx.log.warn(
`Could not start headless metrics for host ${hostId}: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
}
return { added, removed };
},
/** Drops every viewer, on deactivate. */
async releaseAll(): Promise<void> {
for (const [hostId, entry] of [...registered]) {
await release(hostId, entry);
}
},
registeredHosts: () => [...registered.keys()],
};
}
+92
View File
@@ -0,0 +1,92 @@
import type { Router } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { createDeps } from "./deps.js";
import { createDockerWatcher } from "./docker-watcher.js";
import { AutomationEngine } from "./engine.js";
import { createHeadlessViewers } from "./headless-viewer.js";
import { createAutomationRepository } from "./repository.js";
import { registerRoutes } from "./routes.js";
import { createScheduler, STARTUP_DELAY_MS, TICK_MS } from "./scheduler.js";
import { createAutomationsService } from "./service.js";
import { createTriggers } from "./triggers.js";
import { createMaintenanceRepository } from "./maintenance-repository.js";
import { createMaintenanceService } from "./maintenance-service.js";
import { registerMaintenanceRoutes } from "./maintenance-routes.js";
export type { AutomationsAccessV1, AutomationSummary } from "./service.js";
export async function activate(ctx: PluginContext) {
const repository = await createAutomationRepository(ctx.db, async () => {
const channels = await ctx.notify.channels();
return channels.map((channel) => channel.id);
});
const maintenance = createMaintenanceService(
ctx,
await createMaintenanceRepository(ctx.db),
async (userId, hostId) => {
for (const state of await repository.listOpenBreaches()) {
if (Number(state.stateKey.split(":")[0]) !== hostId) continue;
const automation = await repository.findById(state.automationId);
if (automation?.userId === userId)
await repository.clearBreach(state.automationId, state.stateKey);
}
},
);
ctx.events.on("user.data_wiped", (payload) => {
const userId = (payload as { userId?: string } | undefined)?.userId;
if (userId) return maintenance.wipeUser(userId);
});
const deps = createDeps(ctx.services);
const engine = new AutomationEngine(ctx, repository, deps);
const triggers = createTriggers(
repository,
engine,
ctx.log,
maintenance.isMaintaining,
);
triggers.subscribe(ctx.events);
const viewers = createHeadlessViewers(ctx, deps, () =>
triggers.watchedHosts("metric_threshold"),
);
const docker = createDockerWatcher(
ctx,
deps,
() => triggers.watchedHosts("docker_event"),
triggers.onDockerEvent,
);
ctx.disposables.add(() => {
docker.reset();
void viewers.releaseAll();
});
const scheduler = createScheduler({
repository,
engine,
log: ctx.log,
reconcile: [
() => maintenance.tick(),
() => viewers.reconcile(),
(now) => docker.reconcile(now),
],
isMaintaining: maintenance.isMaintaining,
});
ctx.schedule.after(STARTUP_DELAY_MS, () => scheduler.tick());
ctx.schedule.every(TICK_MS, () => scheduler.tick());
const router = ctx.http.router<Router>({ public: ["/webhook/:token"] });
registerMaintenanceRoutes(router, ctx, maintenance);
registerRoutes(
// The webhook route authenticates on its own per-automation token rather
// than a session, which is the point of an inbound webhook.
router,
{ ctx, repository, engine, deps },
);
ctx.services.provide(
"automations.access",
createAutomationsService(ctx, repository, engine, deps),
);
}
export async function deactivate() {}
@@ -0,0 +1,66 @@
import { and, eq } from "drizzle-orm";
import type { PluginDatabase } from "@termix/plugin-sdk/backend";
import { maintenanceTable } from "./tables.js";
import { emptyMaintenance, type HostMaintenance } from "../maintenance.js";
export interface MaintenanceRow {
userId: string;
hostId: number;
state: HostMaintenance;
}
export type MaintenanceRepository = Awaited<
ReturnType<typeof createMaintenanceRepository>
>;
export async function createMaintenanceRepository(db: PluginDatabase) {
// The SDK constructs dialect-specific tables at runtime.
/* eslint-disable @typescript-eslint/no-explicit-any */
const table: any = await db.define(maintenanceTable);
const client = () => db.client<any>();
/* eslint-enable @typescript-eslint/no-explicit-any */
const where = (userId: string, hostId: number) =>
and(eq(table.userId, userId), eq(table.hostId, hostId));
return {
async read(userId: string, hostId: number): Promise<HostMaintenance> {
const rows = await (
await client()
)
.select()
.from(table)
.where(where(userId, hostId));
return rows.length ? JSON.parse(rows[0].state) : emptyMaintenance();
},
async write(
userId: string,
hostId: number,
state: HostMaintenance,
): Promise<void> {
const connection = await client();
const rows = await connection
.select({ id: table.id })
.from(table)
.where(where(userId, hostId));
const values = { state: JSON.stringify(state) };
if (rows.length)
await connection.update(table).set(values).where(where(userId, hostId));
else await connection.insert(table).values({ userId, hostId, ...values });
await db.persist();
},
async list(userId?: string): Promise<MaintenanceRow[]> {
const query = (await client()).select().from(table);
const rows = await (userId
? query.where(eq(table.userId, userId))
: query);
return rows.map(
(row: { userId: string; hostId: number; state: string }) => ({
...row,
state: JSON.parse(row.state),
}),
);
},
async wipeUser(userId: string): Promise<void> {
await (await client()).delete(table).where(eq(table.userId, userId));
await db.persist();
},
};
}
@@ -0,0 +1,155 @@
import type { Router, Request, Response } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { MaintenanceInputError } from "./maintenance-time.js";
import type { MaintenanceService } from "./maintenance-service.js";
export function registerMaintenanceRoutes(
router: Router,
ctx: PluginContext,
service: MaintenanceService,
) {
/**
* @openapi
* /plugin-api/automations/maintenance:
* get:
* summary: List maintenance windows on the caller's hosts
* description: Covers hosts the caller owns and hosts shared with them. A shared host carries its owner's maintenance with owned set to false.
* tags: [Automations]
* responses:
* 200: { description: Maintenance state per host. }
* 401: { description: Not signed in. }
*/
router.get(
"/maintenance",
ctx.rbac.require("view") as never,
async (_req: Request, res: Response) => {
try {
const user = ctx.currentActor();
if (!user)
return res.status(401).json({ error: "Authentication required" });
// Maintenance belongs to the host's owner, so a shared host shows
// the owner's windows, read only.
const hostsByOwner = new Map<string, Set<number>>();
for (const host of await ctx.hosts.list()) {
const ids = hostsByOwner.get(host.userId) ?? new Set<number>();
ids.add(host.id);
hostsByOwner.set(host.userId, ids);
}
const rows = [];
for (const [owner, ids] of hostsByOwner) {
for (const row of await service.list(owner)) {
if (!ids.has(row.hostId)) continue;
rows.push({
hostId: row.hostId,
state: { ...row.state, owned: owner === user },
});
}
}
res.json(rows);
} catch (error) {
ctx.log.error("Failed to list maintenance", error as Error);
res.status(500).json({ error: "Failed to list maintenance" });
}
},
);
const handle = (mutate: boolean) => async (req: Request, res: Response) => {
const hostId = Number(req.params.hostId);
if (!Number.isSafeInteger(hostId) || hostId < 1)
return res.status(400).json({ error: "Invalid host id" });
const user = ctx.currentActor();
if (!user)
return res.status(401).json({ error: "Authentication required" });
try {
const host = await ctx.hosts.get(hostId);
if (!host) return res.status(404).json({ error: "Host not found" });
const owned = host.userId === user;
if (!mutate)
return res.json({
...(await service.read(host.userId, hostId)),
owned,
});
if (!owned)
return res
.status(403)
.json({ error: "Only the host's owner can change maintenance" });
const state = await service.edit(
user,
hostId,
req.body?.action,
req.body,
);
await ctx.audit.record({
action: `maintenance_${req.body.action}`,
resourceType: "host",
resourceId: String(hostId),
resourceName: host.name || host.ip,
success: true,
});
res.json({ ...state, owned: true });
} catch (error) {
// Validation failures have no side effects; storage failures remain errors.
if (error instanceof MaintenanceInputError) {
return res.status(400).json({ error: error.message });
}
ctx.log.error("Failed to update maintenance", error as Error);
res.status(500).json({ error: "Failed to update maintenance" });
}
};
/**
* @openapi
* /plugin-api/automations/maintenance/{hostId}:
* get:
* summary: Get a host's maintenance state and schedule
* tags: [Automations]
* parameters:
* - in: path
* name: hostId
* required: true
* schema: { type: integer }
* responses:
* 200: { description: The active window, if any, and the planned ones. owned is false on a host shared with the caller. }
* 404: { description: A host the caller cannot see. }
*/
router.get(
"/maintenance/:hostId",
ctx.rbac.require("view") as never,
handle(false),
);
/**
* @openapi
* /plugin-api/automations/maintenance/{hostId}:
* post:
* summary: Start, schedule, end or remove maintenance on a host
* description: While a host is in maintenance, automations with host status, health check, metric, Docker or tunnel triggers do not fire for it. Manual, scheduled, webhook and login triggers still run. Past the estimated end plus the grace period, an optional overdue alert is sent.
* tags: [Automations]
* parameters:
* - in: path
* name: hostId
* required: true
* schema: { type: integer }
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [action]
* properties:
* action: { type: string, enum: [start, schedule, end, remove] }
* reason: { type: string }
* start: { type: string, format: date-time, description: When a scheduled window begins. }
* durationMinutes: { type: integer, description: Estimated length. }
* graceMinutes: { type: integer }
* recurrence: { type: string, description: Repeat a scheduled window, e.g. weekly or monthly. }
* responses:
* 200: { description: The new maintenance state. }
* 400: { description: Invalid action or plan. }
* 403: { description: The host was shared with the caller; only its owner can change maintenance. }
* 404: { description: A host the caller cannot see. }
*/
router.post(
"/maintenance/:hostId",
ctx.rbac.require("edit") as never,
handle(true),
);
}
@@ -0,0 +1,152 @@
import { randomUUID } from "node:crypto";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import type { HostMaintenance } from "../maintenance.js";
import type { MaintenanceRepository } from "./maintenance-repository.js";
import {
MaintenanceInputError,
advanceMaintenance,
beginMaintenance,
parsePlan,
plansOverlap,
} from "./maintenance-time.js";
export type MaintenanceService = ReturnType<typeof createMaintenanceService>;
export function createMaintenanceService(
ctx: PluginContext,
repository: MaintenanceRepository,
resetBreaches: (
userId: string,
hostId: number,
) => Promise<void> = async () => {},
) {
// Serialize reads that advance schedules with edits and the reminder tick.
let tail: Promise<unknown> = Promise.resolve();
const exclusive = <T>(work: () => Promise<T>): Promise<T> => {
const result = tail.then(work);
tail = result.catch(() => undefined);
return result;
};
async function update(
userId: string,
hostId: number,
edit?: (state: HostMaintenance) => void,
) {
const state = await repository.read(userId, hostId);
const previous = JSON.stringify(state);
const wasActive = !!state.active;
advanceMaintenance(state, Date.now());
const activated = !wasActive && !!state.active;
edit?.(state);
if (activated || wasActive !== !!state.active)
await resetBreaches(userId, hostId);
if (JSON.stringify(state) !== previous)
await repository.write(userId, hostId, state);
return state;
}
const service = {
read: (userId: string, hostId: number) =>
exclusive(() => update(userId, hostId)),
async isMaintaining(userId: string, hostId: number): Promise<boolean> {
return !!(await service.read(userId, hostId)).active;
},
edit: (userId: string, hostId: number, action: string, input: unknown) =>
exclusive(() =>
update(userId, hostId, (state) => {
if (action === "end") {
state.active = null;
return;
}
if (action === "remove") {
const id = (input as { id?: unknown } | null)?.id;
if (
typeof id !== "string" ||
!state.plans.some((plan) => plan.id === id)
)
throw new MaintenanceInputError("Schedule not found");
state.plans = state.plans.filter((plan) => plan.id !== id);
return;
}
if (action !== "start" && action !== "schedule")
throw new MaintenanceInputError("Invalid maintenance action");
if (action === "start" && state.active)
throw new MaintenanceInputError("Maintenance is already active");
const plan = parsePlan(input, Date.now(), action === "start");
plan.id = randomUUID();
if (action === "start") {
beginMaintenance(state, plan, Date.now());
return;
}
if (state.plans.length >= 20)
throw new MaintenanceInputError(
"At most 20 maintenance schedules are allowed per host",
);
if (
state.plans.some(
(other) =>
other.nextStart &&
plansOverlap(plan, { ...other, start: other.nextStart }),
)
) {
throw new MaintenanceInputError(
"Planned maintenance times overlap",
);
}
state.plans.push(plan);
}),
),
async list(userId: string) {
const rows = await repository.list(userId);
return Promise.all(
rows.map(async (row) => ({
hostId: row.hostId,
state: await service.read(userId, row.hostId),
})),
);
},
wipeUser: (userId: string) => exclusive(() => repository.wipeUser(userId)),
async tick() {
for (const row of await repository.list()) {
try {
await exclusive(async () => {
const state = await update(row.userId, row.hostId);
const active = state.active;
if (!active?.notifyOverdue || active.notified) return;
const deadline =
Date.parse(active.estimatedEnd) + active.graceMinutes * 60_000;
if (Date.now() < deadline) return;
await ctx.asUser(row.userId, async () => {
const host = await ctx.hosts.get(row.hostId);
if (!host || host.userId !== row.userId) return;
const status = await ctx.hosts.status.get(row.hostId);
// Unknown or stale data is not evidence of an overdue outage.
if (
status?.status !== "offline" ||
Date.parse(status.lastChecked) < deadline ||
!Number.isFinite(Date.parse(status.lastChecked))
)
return;
const result = await ctx.notify.send({
title: `Maintenance overdue: ${host.name || host.ip}`,
body: `${active.reasons.join("; ")}. Estimated completion: ${active.estimatedEnd}. The host is still unavailable after the grace period. Maintenance remains active until you end it.`,
severity: "warning",
category: "automations.maintenance_overdue",
dedupeKey: `maintenance:${row.hostId}:${active.startedAt}:${active.estimatedEnd}`,
context: { hostId: row.hostId },
});
if (result.recipients > 0) {
active.notified = true;
await repository.write(row.userId, row.hostId, state);
}
});
});
} catch (error) {
ctx.log.warn(
`Maintenance check for host ${row.hostId} failed: ${String(error)}`,
);
}
}
},
};
return service;
}
@@ -0,0 +1,155 @@
import type { MaintenancePlan, HostMaintenance } from "../maintenance.js";
export class MaintenanceInputError extends Error {}
const DAY = 86_400_000;
const WEEK = 7 * DAY;
/** UTC wall-clock recurrence; monthly dates missing from a month are skipped. */
export function nextOccurrence(
plan: MaintenancePlan,
after: number,
): number | null {
const first = Date.parse(plan.start);
if (first > after) return first;
if (plan.recurrence === "once") return null;
if (plan.recurrence === "weekly") {
return first + (Math.floor((after - first) / WEEK) + 1) * WEEK;
}
const start = new Date(first);
const from = new Date(after);
let month = from.getUTCFullYear() * 12 + from.getUTCMonth();
for (let i = 0; i < 14; i++, month++) {
const candidate = Date.UTC(
Math.floor(month / 12),
month % 12,
start.getUTCDate(),
start.getUTCHours(),
start.getUTCMinutes(),
);
if (new Date(candidate).getUTCMonth() === month % 12 && candidate > after)
return candidate;
}
throw new MaintenanceInputError("Cannot calculate maintenance recurrence");
}
/** Exhaust a Gregorian cycle for recurring pairs, not an arbitrary lookahead. */
export function plansOverlap(a: MaintenancePlan, b: MaintenancePlan): boolean {
const start = Math.max(Date.parse(a.start), Date.parse(b.start));
const end =
a.recurrence === "once" || b.recurrence === "once"
? start + WEEK
: start + 146097 * DAY + WEEK;
let left = nextOccurrence(a, start - WEEK - 1);
let right = nextOccurrence(b, start - WEEK - 1);
while (left !== null && right !== null && left <= end && right <= end) {
const leftEnd = left + a.durationMinutes * 60_000;
const rightEnd = right + b.durationMinutes * 60_000;
if (left < rightEnd && right < leftEnd) return true;
if (leftEnd <= right) left = nextOccurrence(a, left);
else right = nextOccurrence(b, right);
}
return false;
}
export function beginMaintenance(
state: HostMaintenance,
plan: MaintenancePlan,
start: number,
): void {
const end = new Date(start + plan.durationMinutes * 60_000).toISOString();
const current = state.active;
state.active = {
startedAt: new Date(
Math.min(start, current ? Date.parse(current.startedAt) : start),
).toISOString(),
reasons: [...new Set([...(current?.reasons ?? []), plan.reason])].slice(
-20,
),
estimatedEnd:
current && current.estimatedEnd > end ? current.estimatedEnd : end,
graceMinutes: Math.max(current?.graceMinutes ?? 0, plan.graceMinutes),
notifyOverdue: !!current?.notifyOverdue || plan.notifyOverdue,
notified: current && current.estimatedEnd >= end ? current.notified : false,
};
}
/** Catch up after downtime, preserving maintenance until explicitly ended. */
export function advanceMaintenance(state: HostMaintenance, now: number): void {
for (const plan of state.plans) {
if (!plan.nextStart || Date.parse(plan.nextStart) > now) continue;
let due = Date.parse(plan.nextStart);
let next = nextOccurrence(plan, due);
while (next !== null && next <= now) {
due = next;
next = nextOccurrence(plan, due);
}
beginMaintenance(state, plan, due);
plan.nextStart = next === null ? null : new Date(next).toISOString();
}
}
export function parsePlan(
input: unknown,
now: number,
immediate = false,
): MaintenancePlan {
if (!input || typeof input !== "object")
throw new MaintenanceInputError("Invalid maintenance plan");
const value = input as Record<string, unknown>;
const reason = typeof value.reason === "string" ? value.reason.trim() : "";
const start = immediate
? now
: typeof value.start === "string"
? Date.parse(value.start)
: NaN;
const duration = value.durationMinutes;
const grace = value.graceMinutes;
const recurrence = immediate ? "once" : value.recurrence;
if (!reason || reason.length > 200)
throw new MaintenanceInputError("Reason must contain 1 to 200 characters");
if (
!Number.isFinite(start) ||
(!immediate && start <= now) ||
start > now + 366 * 10 * DAY
) {
throw new MaintenanceInputError(
"Start must be a future time within ten years",
);
}
if (
typeof duration !== "number" ||
!Number.isInteger(duration) ||
duration < 1 ||
duration > 10080
) {
throw new MaintenanceInputError(
"Estimated duration must be 1 to 10080 minutes",
);
}
if (
typeof grace !== "number" ||
!Number.isInteger(grace) ||
grace < 0 ||
grace > 1440
) {
throw new MaintenanceInputError("Grace period must be 0 to 1440 minutes");
}
if (!["once", "weekly", "monthly"].includes(String(recurrence)))
throw new MaintenanceInputError("Invalid recurrence");
if (typeof value.notifyOverdue !== "boolean")
throw new MaintenanceInputError("Invalid overdue notification setting");
const iso = new Date(start).toISOString();
if (!immediate && start % 60_000 !== 0)
throw new MaintenanceInputError("Start must use whole minutes");
return {
id: "",
reason,
start: iso,
durationMinutes: duration,
graceMinutes: grace,
recurrence: recurrence as MaintenancePlan["recurrence"],
notifyOverdue: value.notifyOverdue,
nextStart: iso,
};
}
@@ -0,0 +1,738 @@
import { and, asc, desc, eq, inArray, lt, lte, sql } from "drizzle-orm";
import type { PluginDatabase } from "@termix/plugin-sdk/backend";
import {
automations as automationsDef,
channels as channelsDef,
runSteps as runStepsDef,
runs as runsDef,
schedules as schedulesDef,
triggerState as triggerStateDef,
} 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 AutomationRow {
id: number;
user_id: string;
name: string;
description: string | null;
enabled: number;
definition: string;
definition_version: number;
concurrency_policy: string;
max_run_seconds: number;
dry_run: number;
last_run_at: string | null;
last_run_status: string | null;
created_at: string;
updated_at: string;
channels: number[];
}
export interface AutomationRunRow {
id: number;
automation_id: number;
user_id: string;
trigger_type: string;
trigger_context: string | null;
status: string;
started_at: string;
finished_at: string | null;
duration_ms: number | null;
error: string | null;
dry_run: number;
parent_run_id: number | null;
automation_name?: string | null;
}
export interface AutomationRunStepRow {
id: number;
run_id: number;
step_index: number;
step_id: string;
step_type: string;
status: string;
started_at: string;
finished_at: string | null;
output: string | null;
error: string | null;
truncated: number;
}
/** The shape the engine loads. */
export interface AutomationEngineRow {
id: number;
userId: string;
name: string;
enabled: boolean;
definition: string;
concurrencyPolicy: string;
maxRunSeconds: number;
dryRun: boolean;
}
export interface TriggerStateRow {
automationId: number;
stateKey: string;
breachStartedAt: string | null;
lastFiredAt: string | null;
lastValue: number | null;
lastObservedState: string | null;
}
export interface DueScheduleRow {
automationId: number;
cron: string | null;
intervalSeconds: number | null;
timezone: string | null;
nextDueAt: string | null;
}
export type AutomationRepository = Awaited<
ReturnType<typeof createAutomationRepository>
>;
/**
* Automation storage. `ownedChannelIds` answers which alert
* channels the user owns, so a link can never point at another user's.
*/
export async function createAutomationRepository(
db: PluginDatabase,
ownedChannelIds: (userId: string) => Promise<number[]>,
) {
const automations: Table = await db.define(automationsDef);
const triggerState: Table = await db.define(triggerStateDef);
const schedules: Table = await db.define(schedulesDef);
const runs: Table = await db.define(runsDef);
const runSteps: Table = await db.define(runStepsDef);
const channels: Table = await db.define(channelsDef);
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 rows[0].id as number;
}
async function listChannelIds(automationId: number): Promise<number[]> {
const drizzle = await client();
const rows: Row[] = await drizzle
.select({ channelId: channels.channelId })
.from(channels)
.where(eq(channels.automationId, automationId));
return rows.map((row) => row.channelId as number);
}
async function replaceChannels(
automationId: number,
userId: string,
channelIds: number[],
): Promise<void> {
const drizzle = await client();
await drizzle
.delete(channels)
.where(eq(channels.automationId, automationId));
if (channelIds.length === 0) return;
const owned = new Set(await ownedChannelIds(userId));
const linked = [...new Set(channelIds)].filter((id) => owned.has(id));
if (linked.length > 0) {
await drizzle
.insert(channels)
.values(linked.map((channelId) => ({ automationId, channelId })));
}
}
async function findForUser(
id: number,
userId: string,
): Promise<AutomationRow | null> {
const drizzle = await client();
const rows: Row[] = await drizzle
.select()
.from(automations)
.where(and(eq(automations.id, id), eq(automations.userId, userId)))
.limit(1);
if (!rows[0]) return null;
return mapAutomationRow(rows[0], await listChannelIds(id));
}
async function getTriggerState(
automationId: number,
stateKey: string,
): Promise<TriggerStateRow | null> {
const drizzle = await client();
const rows: Row[] = await drizzle
.select()
.from(triggerState)
.where(
and(
eq(triggerState.automationId, automationId),
eq(triggerState.stateKey, stateKey),
),
)
.limit(1);
return rows[0] ? mapTriggerStateRow(rows[0]) : null;
}
return {
async list(userId: string): Promise<AutomationRow[]> {
const drizzle = await client();
const rows: Row[] = await drizzle
.select()
.from(automations)
.where(eq(automations.userId, userId))
.orderBy(asc(automations.name));
if (rows.length === 0) return [];
// One query for every automation's channels rather than one per row.
const links: Row[] = await drizzle
.select({
automationId: channels.automationId,
channelId: channels.channelId,
})
.from(channels)
.where(
inArray(
channels.automationId,
rows.map((row) => row.id),
),
);
const byAutomation = new Map<number, number[]>();
for (const link of links) {
const list = byAutomation.get(link.automationId) ?? [];
list.push(link.channelId);
byAutomation.set(link.automationId, list);
}
return rows.map((row) => mapAutomationRow(row, byAutomation.get(row.id)));
},
findForUser,
async findById(id: number): Promise<AutomationEngineRow | null> {
const drizzle = await client();
const rows: Row[] = await drizzle
.select()
.from(automations)
.where(eq(automations.id, id))
.limit(1);
return rows[0] ? mapEngineRow(rows[0]) : null;
},
async create(input: {
userId: string;
name: string;
description?: string | null;
enabled?: boolean;
definition: string;
concurrencyPolicy?: string;
maxRunSeconds?: number;
dryRun?: boolean;
channels?: number[];
}): Promise<AutomationRow> {
const now = new Date().toISOString();
const id = await insertId(automations, {
userId: input.userId,
name: input.name,
description: input.description ?? null,
enabled: input.enabled ?? true,
definition: input.definition,
concurrencyPolicy: input.concurrencyPolicy ?? "skip",
maxRunSeconds: input.maxRunSeconds ?? 300,
dryRun: input.dryRun ?? false,
createdAt: now,
updatedAt: now,
});
await replaceChannels(id, input.userId, input.channels ?? []);
await db.persist();
const created = await findForUser(id, input.userId);
if (!created) throw new Error("Automation could not be read back");
return created;
},
async update(
id: number,
userId: string,
input: {
name?: string;
description?: string | null;
enabled?: boolean;
definition?: string;
concurrencyPolicy?: string;
maxRunSeconds?: number;
dryRun?: boolean;
channels?: number[];
},
): Promise<AutomationRow | null> {
const existing = await findForUser(id, userId);
if (!existing) return null;
const { channels: channelIds, ...fields } = input;
const drizzle = await client();
if (Object.keys(fields).length > 0) {
await drizzle
.update(automations)
.set({ ...fields, updatedAt: new Date().toISOString() })
.where(and(eq(automations.id, id), eq(automations.userId, userId)));
}
if (channelIds) await replaceChannels(id, userId, channelIds);
await db.persist();
return findForUser(id, userId);
},
async delete(id: number, userId: string): Promise<boolean> {
const existing = await findForUser(id, userId);
if (!existing) return false;
const drizzle = await client();
await drizzle
.delete(automations)
.where(and(eq(automations.id, id), eq(automations.userId, userId)));
await db.persist();
return true;
},
/**
* Enabled automations owned by one user. Wildcard targets must never
* reach across users.
*/
async listEnabledForUser(userId: string): Promise<AutomationEngineRow[]> {
const drizzle = await client();
const rows: Row[] = await drizzle
.select()
.from(automations)
.where(
and(eq(automations.enabled, true), eq(automations.userId, userId)),
);
return rows.map(mapEngineRow);
},
async listAllEnabled(): Promise<AutomationEngineRow[]> {
const drizzle = await client();
const rows: Row[] = await drizzle
.select()
.from(automations)
.where(eq(automations.enabled, true));
return rows.map(mapEngineRow);
},
getTriggerState,
async upsertTriggerState(input: {
automationId: number;
stateKey: string;
breachStartedAt?: string | null;
lastFiredAt?: string | null;
lastValue?: number | null;
lastObservedState?: string | null;
}): Promise<void> {
const existing = await getTriggerState(
input.automationId,
input.stateKey,
);
const updatedAt = new Date().toISOString();
const drizzle = await client();
if (existing) {
const values: Record<string, unknown> = { updatedAt };
if (input.breachStartedAt !== undefined)
values.breachStartedAt = input.breachStartedAt;
if (input.lastFiredAt !== undefined)
values.lastFiredAt = input.lastFiredAt;
if (input.lastValue !== undefined) values.lastValue = input.lastValue;
if (input.lastObservedState !== undefined)
values.lastObservedState = input.lastObservedState;
await drizzle
.update(triggerState)
.set(values)
.where(
and(
eq(triggerState.automationId, input.automationId),
eq(triggerState.stateKey, input.stateKey),
),
);
} else {
await drizzle.insert(triggerState).values({
automationId: input.automationId,
stateKey: input.stateKey,
breachStartedAt: input.breachStartedAt ?? null,
lastFiredAt: input.lastFiredAt ?? null,
lastValue: input.lastValue ?? null,
lastObservedState: input.lastObservedState ?? null,
updatedAt,
});
}
await db.persist({ lazy: true });
},
async clearBreach(automationId: number, stateKey: string): Promise<void> {
const drizzle = await client();
await drizzle
.update(triggerState)
.set({ breachStartedAt: null, updatedAt: new Date().toISOString() })
.where(
and(
eq(triggerState.automationId, automationId),
eq(triggerState.stateKey, stateKey),
),
);
await db.persist({ lazy: true });
},
/** Dwell windows the scheduler has to re-check without a fresh sample. */
async listOpenBreaches(): Promise<TriggerStateRow[]> {
const drizzle = await client();
const rows: Row[] = await drizzle
.select()
.from(triggerState)
.where(sql`${triggerState.breachStartedAt} IS NOT NULL`);
return rows.map(mapTriggerStateRow);
},
async upsertSchedule(input: {
automationId: number;
cron: string | null;
intervalSeconds: number | null;
timezone: string | null;
nextDueAt: string | null;
}): Promise<void> {
const drizzle = await client();
const existing: Row[] = await drizzle
.select({ id: schedules.id })
.from(schedules)
.where(eq(schedules.automationId, input.automationId))
.limit(1);
if (existing[0]) {
await drizzle
.update(schedules)
.set({
cron: input.cron,
intervalSeconds: input.intervalSeconds,
timezone: input.timezone,
nextDueAt: input.nextDueAt,
})
.where(eq(schedules.automationId, input.automationId));
} else {
await drizzle.insert(schedules).values(input);
}
await db.persist();
},
async deleteSchedule(automationId: number): Promise<void> {
const drizzle = await client();
await drizzle
.delete(schedules)
.where(eq(schedules.automationId, automationId));
await db.persist();
},
/** Schedules due at or before `now`, joined to their enabled automation. */
async listDueSchedules(now: string): Promise<DueScheduleRow[]> {
const drizzle = await client();
return drizzle
.select({
automationId: schedules.automationId,
cron: schedules.cron,
intervalSeconds: schedules.intervalSeconds,
timezone: schedules.timezone,
nextDueAt: schedules.nextDueAt,
})
.from(schedules)
.innerJoin(automations, eq(automations.id, schedules.automationId))
.where(
and(eq(automations.enabled, true), lte(schedules.nextDueAt, now)),
);
},
async markScheduleTicked(
automationId: number,
nextDueAt: string | null,
lastTickAt: string,
): Promise<void> {
const drizzle = await client();
await drizzle
.update(schedules)
.set({ nextDueAt, lastTickAt })
.where(eq(schedules.automationId, automationId));
await db.persist({ lazy: true });
},
async createRun(input: {
automationId: number;
userId: string;
triggerType: string;
triggerContext?: string | null;
status: string;
dryRun?: boolean;
parentRunId?: number | null;
}): Promise<{ id: number }> {
const id = await insertId(runs, {
automationId: input.automationId,
userId: input.userId,
triggerType: input.triggerType,
triggerContext: input.triggerContext ?? null,
status: input.status,
startedAt: new Date().toISOString(),
dryRun: input.dryRun ?? false,
parentRunId: input.parentRunId ?? null,
});
await db.persist({ lazy: true });
return { id };
},
async finishRun(
runId: number,
input: {
status: string;
error?: string | null;
durationMs?: number | null;
},
): Promise<void> {
const drizzle = await client();
await drizzle
.update(runs)
.set({
status: input.status,
error: input.error ?? null,
finishedAt: new Date().toISOString(),
durationMs: input.durationMs ?? null,
})
.where(eq(runs.id, runId));
const run: Row[] = await drizzle
.select({ automationId: runs.automationId, startedAt: runs.startedAt })
.from(runs)
.where(eq(runs.id, runId))
.limit(1);
if (run[0]) {
await drizzle
.update(automations)
.set({ lastRunAt: run[0].startedAt, lastRunStatus: input.status })
.where(eq(automations.id, run[0].automationId));
}
await db.persist();
},
async listRuns(
userId: string,
options: { automationId?: number; limit?: number; offset?: number } = {},
): Promise<AutomationRunRow[]> {
const limit = Math.min(Math.max(options.limit ?? 50, 1), 200);
const offset = Math.max(options.offset ?? 0, 0);
const where = options.automationId
? and(
eq(runs.userId, userId),
eq(runs.automationId, options.automationId),
)
: eq(runs.userId, userId);
const drizzle = await client();
const rows: Row[] = await drizzle
.select({ run: runs, automationName: automations.name })
.from(runs)
.leftJoin(automations, eq(automations.id, runs.automationId))
.where(where)
.orderBy(desc(runs.startedAt), desc(runs.id))
.limit(limit)
.offset(offset);
return rows.map((row) => ({
...mapRunRow(row.run),
automation_name: row.automationName ?? null,
}));
},
async findRunForUser(
runId: number,
userId: string,
): Promise<AutomationRunRow | null> {
const drizzle = await client();
const rows: Row[] = await drizzle
.select()
.from(runs)
.where(and(eq(runs.id, runId), eq(runs.userId, userId)))
.limit(1);
return rows[0] ? mapRunRow(rows[0]) : null;
},
async createRunStep(input: {
runId: number;
stepIndex: number;
stepId: string;
stepType: string;
status: string;
}): Promise<number> {
const id = await insertId(runSteps, {
...input,
startedAt: new Date().toISOString(),
});
await db.persist({ lazy: true });
return id;
},
async finishRunStep(
stepRowId: number,
input: {
status: string;
output?: string | null;
error?: string | null;
truncated?: boolean;
},
): Promise<void> {
const drizzle = await client();
await drizzle
.update(runSteps)
.set({
status: input.status,
output: input.output ?? null,
error: input.error ?? null,
truncated: input.truncated ?? false,
finishedAt: new Date().toISOString(),
})
.where(eq(runSteps.id, stepRowId));
await db.persist({ lazy: true });
},
async listRunSteps(runId: number): Promise<AutomationRunStepRow[]> {
const drizzle = await client();
const rows: Row[] = await drizzle
.select()
.from(runSteps)
.where(eq(runSteps.runId, runId))
.orderBy(asc(runSteps.stepIndex));
return rows.map(mapRunStepRow);
},
/** Trims run history. Called from the scheduler's daily sweep. */
async pruneRunsOlderThan(days: number): Promise<number> {
const cutoff = new Date(Date.now() - days * 86400000).toISOString();
const drizzle = await client();
const old: Row[] = await drizzle
.select({ id: runs.id })
.from(runs)
.where(lt(runs.startedAt, cutoff));
if (old.length === 0) return 0;
await drizzle.delete(runs).where(lt(runs.startedAt, cutoff));
await db.persist();
return old.length;
},
/** Marks runs left behind by a crash so they do not block concurrency. */
async failStaleRunningRuns(olderThanIso: string): Promise<number> {
const where = and(
eq(runs.status, "running"),
lt(runs.startedAt, olderThanIso),
);
const drizzle = await client();
const stale: Row[] = await drizzle
.select({ id: runs.id })
.from(runs)
.where(where);
if (stale.length === 0) return 0;
await drizzle
.update(runs)
.set({
status: "failed",
error: "Interrupted by a server restart",
finishedAt: new Date().toISOString(),
})
.where(where);
await db.persist();
return stale.length;
},
};
}
function mapAutomationRow(row: Row, channelIds: number[] = []): AutomationRow {
return {
id: row.id,
user_id: row.userId,
name: row.name,
description: row.description ?? null,
enabled: row.enabled ? 1 : 0,
definition: row.definition,
definition_version: row.definitionVersion,
concurrency_policy: row.concurrencyPolicy,
max_run_seconds: row.maxRunSeconds,
dry_run: row.dryRun ? 1 : 0,
last_run_at: row.lastRunAt ?? null,
last_run_status: row.lastRunStatus ?? null,
created_at: row.createdAt,
updated_at: row.updatedAt,
channels: channelIds,
};
}
function mapEngineRow(row: Row): AutomationEngineRow {
return {
id: row.id,
userId: row.userId,
name: row.name,
enabled: !!row.enabled,
definition: row.definition,
concurrencyPolicy: row.concurrencyPolicy,
maxRunSeconds: row.maxRunSeconds,
dryRun: !!row.dryRun,
};
}
function mapTriggerStateRow(row: Row): TriggerStateRow {
return {
automationId: row.automationId,
stateKey: row.stateKey,
breachStartedAt: row.breachStartedAt ?? null,
lastFiredAt: row.lastFiredAt ?? null,
lastValue: row.lastValue ?? null,
lastObservedState: row.lastObservedState ?? null,
};
}
function mapRunRow(row: Row): AutomationRunRow {
return {
id: row.id,
automation_id: row.automationId,
user_id: row.userId,
trigger_type: row.triggerType,
trigger_context: row.triggerContext ?? null,
status: row.status,
started_at: row.startedAt,
finished_at: row.finishedAt ?? null,
duration_ms: row.durationMs ?? null,
error: row.error ?? null,
dry_run: row.dryRun ? 1 : 0,
parent_run_id: row.parentRunId ?? null,
};
}
function mapRunStepRow(row: Row): AutomationRunStepRow {
return {
id: row.id,
run_id: row.runId,
step_index: row.stepIndex,
step_id: row.stepId,
step_type: row.stepType,
status: row.status,
started_at: row.startedAt,
finished_at: row.finishedAt ?? null,
output: row.output ?? null,
error: row.error ?? null,
truncated: row.truncated ? 1 : 0,
};
}
@@ -0,0 +1,93 @@
import type { AutomationDefinition, HostSelector, Step } from "../types.js";
import { pluginOf, type Deps, type ProviderKey } from "./deps.js";
/**
* The providers a definition cannot run without. A trigger or step whose
* plugin is off makes the whole automation wait for it, rather than half of
* it running.
*/
export function requiredProviders(
definition: AutomationDefinition | null | undefined,
): ProviderKey[] {
const needed = new Set<ProviderKey>();
if (!definition) return [];
const selector = (value: HostSelector | undefined) => {
if (value?.kind === "fleet") needed.add("fleets");
};
const trigger = definition.trigger;
switch (trigger?.kind) {
case "metric_threshold":
case "health_check":
needed.add("host-metrics");
selector(trigger.hostSelector);
break;
case "docker_event":
needed.add("docker-events");
selector(trigger.hostSelector);
break;
case "host_status":
selector(trigger.hostSelector);
break;
case "internal_event":
if (trigger.event === "tunnel_disconnected") needed.add("tunnels");
selector(trigger.hostSelector);
break;
default:
break;
}
const walk = (steps: Step[] | undefined) => {
for (const step of steps ?? []) {
if (step.enabled === false) continue;
switch (step.type) {
case "run_snippet":
needed.add("snippets");
selector(step.hostSelector);
break;
case "run_command":
selector(step.hostSelector);
break;
case "docker":
needed.add("docker");
selector(step.hostSelector);
break;
case "tunnel":
needed.add("tunnels");
break;
case "wol":
needed.add("wake-on-lan");
break;
case "if":
walk(step.then);
walk(step.else);
break;
default:
break;
}
}
};
walk(definition.steps);
return [...needed];
}
/** Plugin ids the definition needs that are not running, deduplicated. */
export function missingPlugins(
definition: AutomationDefinition | null | undefined,
deps: Pick<Deps, "available">,
): string[] {
const missing = requiredProviders(definition)
.filter((key) => !deps.available(key))
.map(pluginOf);
return [...new Set(missing)];
}
export function parseDefinition(raw: string): AutomationDefinition | null {
try {
return JSON.parse(raw) as AutomationDefinition;
} catch {
return null;
}
}
+618
View File
@@ -0,0 +1,618 @@
import crypto from "node:crypto";
import type { Request, Response, Router } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import type { AutomationDefinition } from "../types.js";
import { computeNextDueAt } from "./cron.js";
import type { Deps } from "./deps.js";
import type { AutomationEngine } from "./engine.js";
import type { AutomationRepository, AutomationRow } from "./repository.js";
import { missingPlugins, parseDefinition } from "./requirements.js";
import { isNonEmptyString, validateDefinition } from "./validate.js";
export interface RouteDeps {
ctx: PluginContext;
repository: AutomationRepository;
engine: Pick<AutomationEngine, "run">;
deps: Deps;
}
function parseId(raw: unknown): number | null {
const id = typeof raw === "string" ? parseInt(raw, 10) : NaN;
return Number.isInteger(id) && id > 0 ? id : null;
}
export function hashToken(token: string): string {
return crypto.createHash("sha256").update(token).digest("hex");
}
function timingSafeEqual(a: string, b: string): boolean {
const left = Buffer.from(a || "", "utf8");
const right = Buffer.from(b || "", "utf8");
if (left.length !== right.length) return false;
return crypto.timingSafeEqual(left, right);
}
/**
* The wire shape of an automation: the definition parsed, the webhook token
* hash blanked, and the plugins it needs that are not running.
*/
export function serializeAutomation(row: AutomationRow, deps: Deps) {
let definition: AutomationDefinition | null = parseDefinition(row.definition);
if (definition?.trigger?.kind === "webhook") {
definition = {
...definition,
trigger: { ...definition.trigger, tokenHash: "" },
};
}
return {
...row,
definition,
missingPlugins: missingPlugins(definition, deps),
};
}
export async function syncSchedule(
repository: AutomationRepository,
automationId: number,
definition: AutomationDefinition,
): Promise<void> {
if (definition.trigger?.kind !== "schedule") {
await repository.deleteSchedule(automationId);
return;
}
const trigger = definition.trigger;
await repository.upsertSchedule({
automationId,
cron: trigger.cron ?? null,
intervalSeconds: trigger.intervalSeconds ?? null,
timezone: trigger.timezone ?? null,
nextDueAt: computeNextDueAt({
cron: trigger.cron,
intervalSeconds: trigger.intervalSeconds,
timezone: trigger.timezone,
}),
});
}
export function registerRoutes(
router: Router,
{ ctx, repository, engine, deps }: RouteDeps,
): void {
const actor = () => ctx.currentActor() as string;
const fail = (res: Response, message: string, error: unknown) => {
ctx.log.error(
message,
error instanceof Error ? error : new Error(String(error)),
);
res.status(500).json({ error: message });
};
const audit = (
action: string,
id: number,
name: string | undefined,
success = true,
details?: string,
) =>
ctx.audit.record({
action,
resourceType: "automation",
resourceId: String(id),
resourceName: name,
details,
success,
});
/**
* @openapi
* /plugin-api/automations/webhook/{token}:
* post:
* summary: Trigger an automation from an external system
* description: Unauthenticated by design; the 32-byte token in the path is the credential and is compared against a stored hash in constant time.
* tags:
* - Automations
* parameters:
* - in: path
* name: token
* required: true
* schema:
* type: string
* responses:
* 202:
* description: The run was accepted.
* 404:
* description: No automation matches that token.
*/
router.post("/webhook/:token", async (req: Request, res: Response) => {
const token = req.params.token;
if (!isNonEmptyString(token) || token.length < 32) {
return res.status(404).json({ error: "Not found" });
}
try {
const wanted = hashToken(token);
const match = (await repository.listAllEnabled()).find((row) => {
const definition = parseDefinition(row.definition);
if (definition?.trigger?.kind !== "webhook") return false;
return timingSafeEqual(definition.trigger.tokenHash, wanted);
});
if (!match) return res.status(404).json({ error: "Not found" });
const outcome = await engine.run({
automationId: match.id,
triggerType: "webhook",
triggerContext: {
body: req.body ?? {},
receivedAt: new Date().toISOString(),
},
});
res.status(202).json({ runId: outcome.runId, status: outcome.status });
} catch (error) {
fail(res, "Failed to run automation", error);
}
});
/**
* @openapi
* /plugin-api/automations:
* get:
* summary: List the current user's automations
* description: Returns every automation the caller owns, with its parsed definition, linked notification channels and the plugins it needs that are not running.
* tags:
* - Automations
* responses:
* 200:
* description: List of automations.
* 403:
* description: Missing the automations.view permission.
*/
router.get(
"/",
ctx.rbac.require("view") as never,
async (_req: Request, res: Response) => {
try {
const rows = await repository.list(actor());
res.json(rows.map((row) => serializeAutomation(row, deps)));
} catch (error) {
fail(res, "Failed to list automations", error);
}
},
);
/**
* @openapi
* /plugin-api/automations/editor-options:
* get:
* summary: What the automation editor can offer
* description: The caller's hosts, snippets, fleets and notification channels, and which optional plugins are running, so the editor only offers triggers and steps it can run.
* tags:
* - Automations
* responses:
* 200:
* description: Lookup lists and the running providers.
* 403:
* description: Missing the automations.view permission.
*/
router.get(
"/editor-options",
ctx.rbac.require("view") as never,
async (_req: Request, res: Response) => {
const userId = actor();
const settle = async <T>(fn: () => Promise<T>, fallback: T) => {
try {
return await fn();
} catch {
return fallback;
}
};
const snippets = deps.snippets();
const fleets = deps.fleets();
const [hosts, snippetList, fleetList, channels] = await Promise.all([
settle(() => ctx.hosts.list(), []),
settle(
async () =>
typeof snippets.list === "function" ? snippets.list() : [],
[],
),
settle(
async () => (typeof fleets.list === "function" ? fleets.list() : []),
[],
),
settle(() => ctx.notify.channels(), []),
]);
res.json({
hosts: hosts
.filter((host) => host.userId === userId)
.map((host) => ({ id: host.id, name: host.name || host.ip })),
snippets: snippetList
.filter((snippet) => !snippet.isNote)
.map((snippet) => ({ id: snippet.id, name: snippet.name })),
fleets: fleetList.map((fleet) => ({ id: fleet.id, name: fleet.name })),
channels: channels.map((channel) => ({
id: channel.id,
name: channel.name,
})),
providers: deps.providers(),
});
},
);
/**
* @openapi
* /plugin-api/automations/runs/history:
* get:
* summary: List automation runs
* tags:
* - Automations
* parameters:
* - in: query
* name: automationId
* schema:
* type: integer
* - in: query
* name: limit
* schema:
* type: integer
* - in: query
* name: offset
* schema:
* type: integer
* responses:
* 200:
* description: Recent runs, newest first.
*/
router.get(
"/runs/history",
ctx.rbac.require("view") as never,
async (req: Request, res: Response) => {
try {
res.json(
await repository.listRuns(actor(), {
automationId: parseId(req.query.automationId) ?? undefined,
limit: Number(req.query.limit) || 50,
offset: Number(req.query.offset) || 0,
}),
);
} catch (error) {
fail(res, "Failed to list runs", error);
}
},
);
/**
* @openapi
* /plugin-api/automations/runs/{runId}/steps:
* get:
* summary: Step-by-step results for a run
* tags:
* - Automations
* parameters:
* - in: path
* name: runId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: The run's steps in order.
* 404:
* description: Run not found.
*/
router.get(
"/runs/:runId/steps",
ctx.rbac.require("view") as never,
async (req: Request, res: Response) => {
const runId = parseId(req.params.runId);
if (runId === null) return res.status(400).json({ error: "Invalid id" });
try {
const run = await repository.findRunForUser(runId, actor());
if (!run) return res.status(404).json({ error: "Run not found" });
res.json(await repository.listRunSteps(runId));
} catch (error) {
fail(res, "Failed to list run steps", error);
}
},
);
/**
* @openapi
* /plugin-api/automations/{id}:
* get:
* summary: Fetch a single automation
* tags:
* - Automations
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: The automation.
* 404:
* description: Automation not found.
*/
router.get(
"/:id",
ctx.rbac.require("view") as never,
async (req: Request, res: Response) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: "Invalid id" });
try {
const row = await repository.findForUser(id, actor());
if (!row)
return res.status(404).json({ error: "Automation not found" });
res.json(serializeAutomation(row, deps));
} catch (error) {
fail(res, "Failed to fetch automation", error);
}
},
);
/**
* @openapi
* /plugin-api/automations:
* post:
* summary: Create an automation
* description: Validates the trigger and every step before storing the definition. A schedule trigger also registers its next due time, and a webhook trigger returns its token once.
* tags:
* - Automations
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* name:
* type: string
* definition:
* type: object
* responses:
* 201:
* description: The created automation.
* 400:
* description: Validation failed.
*/
router.post(
"/",
ctx.rbac.require("create") as never,
async (req: Request, res: Response) => {
const userId = actor();
const { name, description, enabled, definition, concurrencyPolicy } =
req.body ?? {};
if (!isNonEmptyString(name)) {
return res.status(400).json({ error: "Name is required" });
}
const validated = validateDefinition(definition);
if (!validated.ok || !validated.definition) {
return res.status(400).json({ error: validated.error });
}
// A webhook trigger's token is shown once here and only stored hashed.
let webhookToken: string | undefined;
if (validated.definition.trigger.kind === "webhook") {
webhookToken = crypto.randomBytes(32).toString("hex");
validated.definition = {
...validated.definition,
trigger: { kind: "webhook", tokenHash: hashToken(webhookToken) },
};
}
try {
const created = await repository.create({
userId,
name: name.trim(),
description: description ?? null,
enabled: enabled !== false,
definition: JSON.stringify(validated.definition),
concurrencyPolicy,
channels: Array.isArray(req.body?.channels) ? req.body.channels : [],
});
await syncSchedule(repository, created.id, validated.definition);
await audit("create_automation", created.id, created.name);
res
.status(201)
.json({ ...serializeAutomation(created, deps), webhookToken });
} catch (error) {
fail(res, "Failed to create automation", error);
}
},
);
/**
* @openapi
* /plugin-api/automations/{id}:
* put:
* summary: Update an automation
* tags:
* - Automations
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: The updated automation.
* 400:
* description: Validation failed.
* 404:
* description: Automation not found.
*/
router.put(
"/:id",
ctx.rbac.require("edit") as never,
async (req: Request, res: Response) => {
const userId = actor();
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: "Invalid id" });
try {
const existing = await repository.findForUser(id, userId);
if (!existing) {
return res.status(404).json({ error: "Automation not found" });
}
const update: Parameters<AutomationRepository["update"]>[2] = {};
if (req.body?.name !== undefined) {
if (!isNonEmptyString(req.body.name)) {
return res.status(400).json({ error: "Name cannot be empty" });
}
update.name = req.body.name.trim();
}
if (req.body?.description !== undefined) {
update.description = req.body.description;
}
if (req.body?.enabled !== undefined)
update.enabled = !!req.body.enabled;
if (req.body?.concurrencyPolicy !== undefined) {
update.concurrencyPolicy = req.body.concurrencyPolicy;
}
if (Array.isArray(req.body?.channels)) {
update.channels = req.body.channels;
}
let parsedDefinition: AutomationDefinition | null = null;
if (req.body?.definition !== undefined) {
const validated = validateDefinition(req.body.definition);
if (!validated.ok || !validated.definition) {
return res.status(400).json({ error: validated.error });
}
// Keep the stored token hash: the raw token is only shown once.
if (validated.definition.trigger.kind === "webhook") {
const previous = parseDefinition(existing.definition);
validated.definition = {
...validated.definition,
trigger: {
kind: "webhook",
tokenHash:
previous?.trigger?.kind === "webhook"
? previous.trigger.tokenHash
: "",
},
};
}
parsedDefinition = validated.definition;
update.definition = JSON.stringify(validated.definition);
}
const updated = await repository.update(id, userId, update);
if (!updated) {
return res.status(404).json({ error: "Automation not found" });
}
if (parsedDefinition) {
await syncSchedule(repository, id, parsedDefinition);
}
await audit("update_automation", id, updated.name);
res.json(serializeAutomation(updated, deps));
} catch (error) {
fail(res, "Failed to update automation", error);
}
},
);
/**
* @openapi
* /plugin-api/automations/{id}:
* delete:
* summary: Delete an automation
* tags:
* - Automations
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Deleted.
* 404:
* description: Automation not found.
*/
router.delete(
"/:id",
ctx.rbac.require("delete") as never,
async (req: Request, res: Response) => {
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: "Invalid id" });
try {
const deleted = await repository.delete(id, actor());
if (!deleted) {
return res.status(404).json({ error: "Automation not found" });
}
await audit("delete_automation", id, undefined);
res.json({ success: true });
} catch (error) {
fail(res, "Failed to delete automation", error);
}
},
);
/**
* @openapi
* /plugin-api/automations/{id}/run:
* post:
* summary: Run an automation now
* description: Runs immediately, as the automation's owner. Pass dryRun to record what each step would do without touching anything outside Termix. An automation that needs a plugin that is off is recorded as skipped.
* tags:
* - Automations
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* requestBody:
* content:
* application/json:
* schema:
* type: object
* properties:
* dryRun:
* type: boolean
* responses:
* 200:
* description: The run outcome.
* 404:
* description: Automation not found.
*/
router.post(
"/:id/run",
ctx.rbac.require("run") as never,
async (req: Request, res: Response) => {
const userId = actor();
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: "Invalid id" });
try {
const existing = await repository.findForUser(id, userId);
if (!existing) {
return res.status(404).json({ error: "Automation not found" });
}
const dryRun = req.body?.dryRun === true;
const outcome = await engine.run({
automationId: id,
triggerType: "manual",
triggerContext: { manual: true, requestedBy: userId },
dryRun,
});
await audit(
"run_automation",
id,
existing.name,
outcome.status === "success",
JSON.stringify({ status: outcome.status, dryRun }),
);
res.json(outcome);
} catch (error) {
fail(res, "Failed to run automation", error);
}
},
);
}
@@ -0,0 +1,166 @@
import type { PluginLogger } from "@termix/plugin-sdk/backend";
import type { AutomationDefinition } from "../types.js";
import { computeNextDueAt } from "./cron.js";
import { hasDwelled, isCoolingDown } from "./conditions.js";
import type { AutomationEngine } from "./engine.js";
import type { AutomationRepository } from "./repository.js";
/**
* The one tick the automations plugin runs. It handles due schedules, dwell
* windows that need re-checking without a fresh sample, the watchers that
* keep metrics and Docker events flowing for watched hosts, and history
* pruning. activate() drives it through ctx.schedule.
*/
export const TICK_MS = 15_000;
export const STARTUP_DELAY_MS = 30_000;
const PRUNE_INTERVAL_MS = 24 * 60 * 60 * 1000;
const RUN_RETENTION_DAYS = 30;
/** A "running" row older than this belongs to a process that is gone. */
const STALE_RUN_MS = 6 * 60 * 60 * 1000;
export interface SchedulerDeps {
repository: AutomationRepository;
engine: Pick<AutomationEngine, "run">;
log: PluginLogger;
isMaintaining?: (userId: string, hostId: number) => Promise<boolean>;
/** Watchers brought in line with the enabled automations every tick. */
reconcile: Array<(now: number) => Promise<unknown>>;
}
export function createScheduler(deps: SchedulerDeps) {
const { repository, engine, log } = deps;
let lastPruneAt = 0;
let ticking = false;
const errorText = (error: unknown) =>
error instanceof Error ? error.message : String(error);
async function runDueSchedules(now: Date): Promise<void> {
const due = await repository.listDueSchedules(now.toISOString());
for (const schedule of due) {
const nextDueAt = computeNextDueAt(
{
cron: schedule.cron,
intervalSeconds: schedule.intervalSeconds,
timezone: schedule.timezone,
},
now,
);
await repository.markScheduleTicked(
schedule.automationId,
nextDueAt,
now.toISOString(),
);
void engine
.run({
automationId: schedule.automationId,
triggerType: "schedule",
triggerContext: { scheduledFor: now.toISOString() },
})
.catch((error: unknown) =>
log.warn(
`Scheduled automation ${schedule.automationId} failed to start: ${errorText(error)}`,
),
);
}
}
/**
* Fires sustained breaches whose window has elapsed. Without this a dwell
* window only completes when another sample happens to arrive.
*/
async function recheckOpenBreaches(now: Date): Promise<void> {
const open = await repository.listOpenBreaches();
const nowMs = now.getTime();
for (const state of open) {
const automation = await repository.findById(state.automationId);
if (!automation || !automation.enabled) continue;
const hostId = Number(state.stateKey.split(":")[0]);
if (
Number.isFinite(hostId) &&
(await deps.isMaintaining?.(automation.userId, hostId))
) {
await repository.clearBreach(automation.id, state.stateKey);
continue;
}
let definition: AutomationDefinition;
try {
definition = JSON.parse(automation.definition) as AutomationDefinition;
} catch {
continue;
}
const trigger = definition.trigger;
if (trigger?.kind !== "metric_threshold") continue;
if (!trigger.forSeconds) continue;
if (!hasDwelled(state.breachStartedAt, trigger.forSeconds, nowMs)) {
continue;
}
if (isCoolingDown(state.lastFiredAt, trigger.cooldownMinutes, nowMs)) {
continue;
}
await repository.upsertTriggerState({
automationId: automation.id,
stateKey: state.stateKey,
lastFiredAt: now.toISOString(),
});
void engine
.run({
automationId: automation.id,
triggerType: "metric_threshold",
triggerContext: {
hostId,
value: state.lastValue,
threshold: trigger.value,
metric: trigger.metric.path,
sustained: true,
},
triggerHostId: Number.isFinite(hostId) ? hostId : undefined,
})
.catch(() => undefined);
}
}
async function pruneIfDue(now: Date): Promise<void> {
if (now.getTime() - lastPruneAt < PRUNE_INTERVAL_MS) return;
lastPruneAt = now.getTime();
try {
await repository.failStaleRunningRuns(
new Date(now.getTime() - STALE_RUN_MS).toISOString(),
);
const deleted = await repository.pruneRunsOlderThan(RUN_RETENTION_DAYS);
if (deleted > 0) log.info(`Pruned ${deleted} old automation run(s)`);
} catch (error) {
log.warn(`Automation run pruning failed: ${errorText(error)}`);
}
}
return {
async tick(now: Date = new Date()): Promise<void> {
// A slow tick must not overlap the next one.
if (ticking) return;
ticking = true;
try {
for (const reconcile of deps.reconcile) {
await reconcile(now.getTime()).catch((error: unknown) =>
log.warn(`Automation watcher failed: ${errorText(error)}`),
);
}
await runDueSchedules(now);
await recheckOpenBreaches(now);
await pruneIfDue(now);
} catch (error) {
log.warn(`Automation scheduler tick failed: ${errorText(error)}`);
} finally {
ticking = false;
}
},
};
}
+139
View File
@@ -0,0 +1,139 @@
import type { PluginContext } from "@termix/plugin-sdk/backend";
import type { AutomationDefinition, RunStatus } from "../types.js";
import type { Deps } from "./deps.js";
import type { AutomationEngine } from "./engine.js";
import type { AutomationRepository } from "./repository.js";
import { missingPlugins, parseDefinition } from "./requirements.js";
import { syncSchedule } from "./routes.js";
import { validateDefinition } from "./validate.js";
export interface AutomationSummary {
id: number;
name: string;
description: string | null;
enabled: boolean;
triggerKind: string | null;
lastRunAt: string | null;
lastRunStatus: string | null;
missingPlugins: string[];
}
/**
* The "automations.access" service, version 1. Runs as the caller's user,
* gated on automations.view; create and run also need automations.create and
* automations.run.
*/
export interface AutomationsAccessV1 {
list: () => Promise<AutomationSummary[]>;
get: (
id: number,
) => Promise<
(AutomationSummary & { definition: AutomationDefinition | null }) | null
>;
/**
* Validates the definition the same way the create route does. Starts
* disabled unless `enabled` is set, so an automation nobody watched run
* does not start firing on its own.
*/
create: (input: {
name: string;
description?: string | null;
definition: unknown;
enabled?: boolean;
}) => Promise<{ id: number; name: string }>;
run: (
id: number,
options?: { dryRun?: boolean },
) => Promise<{ runId: number | null; status: RunStatus; error?: string }>;
}
export function createAutomationsService(
ctx: PluginContext,
repository: AutomationRepository,
engine: Pick<AutomationEngine, "run">,
deps: Deps,
): AutomationsAccessV1 {
const actor = (method: string) => {
const userId = ctx.currentActor();
if (!userId) throw new Error(`automations.access.${method} needs an actor`);
return userId;
};
const summarize = (
row: Awaited<ReturnType<AutomationRepository["list"]>>[number],
) => {
const definition = parseDefinition(row.definition);
if (definition?.trigger?.kind === "webhook") {
definition.trigger = { ...definition.trigger, tokenHash: "" };
}
return {
summary: {
id: row.id,
name: row.name,
description: row.description,
enabled: !!row.enabled,
triggerKind: definition?.trigger?.kind ?? null,
lastRunAt: row.last_run_at,
lastRunStatus: row.last_run_status,
missingPlugins: missingPlugins(definition, deps),
},
definition,
};
};
return {
list: async () => {
const rows = await repository.list(actor("list"));
return rows.map((row) => summarize(row).summary);
},
get: async (id) => {
const row = await repository.findForUser(id, actor("get"));
if (!row) return null;
const { summary, definition } = summarize(row);
return { ...summary, definition };
},
create: async (input) => {
const userId = actor("create");
if (!(await ctx.rbac.has("create"))) {
throw new Error('Creating automations needs "automations.create"');
}
const name = typeof input.name === "string" ? input.name.trim() : "";
if (!name) throw new Error("Name is required");
const validated = validateDefinition(input.definition);
if (!validated.ok || !validated.definition) {
throw new Error(
validated.error ?? "The automation definition is invalid",
);
}
if (validated.definition.trigger.kind === "webhook") {
// A webhook's token is only ever shown by the create route.
throw new Error("Webhook automations are created from the editor");
}
const created = await repository.create({
userId,
name,
description: input.description ?? null,
definition: JSON.stringify(validated.definition),
enabled: input.enabled ?? false,
});
await syncSchedule(repository, created.id, validated.definition);
return { id: created.id, name: created.name };
},
run: async (id, options = {}) => {
const userId = actor("run");
if (!(await ctx.rbac.has("run"))) {
throw new Error('Running automations needs "automations.run"');
}
const existing = await repository.findForUser(id, userId);
if (!existing) throw new Error("Automation not found");
return engine.run({
automationId: id,
triggerType: "manual",
triggerContext: { manual: true, requestedBy: userId },
dryRun: options.dryRun === true,
});
},
};
}
+217
View File
@@ -0,0 +1,217 @@
import {
adoptLegacyTable,
boolean,
defineTable,
id,
integer,
real,
refUser,
refHost,
text,
timestamp,
varchar,
} from "@termix/plugin-sdk/db";
/*
* Adopted from core's automation tables, so column and index names are the
* legacy ones and the rename carries every row across.
*
* The links between these tables are plain integers here: the SDK only
* references users and ssh_data. The adoption migrations keep the legacy
* FOREIGN KEY clauses by hand, so deleting an automation still cascades. A
* channel id points into the alerts plugin's table and has no foreign key.
*/
export const automations = adoptLegacyTable(
"automations",
defineTable(
"automations",
{
id: id(),
userId: refUser(),
name: text().notNull(),
description: text(),
enabled: boolean().notNull().default(true),
// The whole trigger and steps graph, read and written as a unit.
definition: text().notNull(),
definitionVersion: integer().notNull().default(1),
concurrencyPolicy: text().notNull().default("skip"),
maxRunSeconds: integer().notNull().default(300),
dryRun: boolean().notNull().default(false),
lastRunAt: text(),
lastRunStatus: text(),
createdAt: timestamp().notNull().defaultNow(),
updatedAt: timestamp().notNull().defaultNow(),
},
{
indexes: [
{ name: "idx_automations_user", columns: ["userId", "enabled"] },
],
},
),
);
/**
* Durable per-target trigger state, so cooldowns and dwell windows survive a
* restart. state_key scopes it to what the trigger watches ("<hostId>",
* "<hostId>:/data", "<hostId>:<container>").
*/
export const triggerState = adoptLegacyTable(
"automation_trigger_state",
defineTable(
"trigger_state",
{
id: id(),
automationId: integer().notNull(),
stateKey: varchar().notNull(),
breachStartedAt: text(),
lastFiredAt: text(),
lastValue: real(),
lastObservedState: text(),
updatedAt: timestamp().notNull().defaultNow(),
},
{
uniques: [
{
name: "idx_automation_trigger_state_key",
columns: ["automationId", "stateKey"],
},
],
},
),
);
export const schedules = adoptLegacyTable(
"automation_schedules",
defineTable(
"schedules",
{
id: id(),
automationId: integer().notNull(),
cron: text(),
intervalSeconds: integer(),
timezone: text(),
nextDueAt: timestamp(),
lastTickAt: text(),
},
{
uniques: [
{
name: "idx_automation_schedules_automation",
columns: ["automationId"],
},
],
indexes: [
{ name: "idx_automation_schedules_due", columns: ["nextDueAt"] },
],
},
),
);
export const runs = adoptLegacyTable(
"automation_runs",
defineTable(
"runs",
{
id: id(),
automationId: integer().notNull(),
userId: refUser(),
triggerType: text().notNull(),
triggerContext: text(),
status: text().notNull(),
startedAt: timestamp().notNull().defaultNow(),
finishedAt: text(),
durationMs: integer(),
error: text(),
dryRun: boolean().notNull().default(false),
// Set when one automation invoked another, so a chain can be traced.
parentRunId: integer(),
},
{
indexes: [
{
name: "idx_automation_runs_automation",
columns: ["automationId", "startedAt"],
},
{ name: "idx_automation_runs_user", columns: ["userId", "startedAt"] },
],
},
),
);
export const runSteps = adoptLegacyTable(
"automation_run_steps",
defineTable(
"run_steps",
{
id: id(),
runId: integer().notNull(),
stepIndex: integer().notNull(),
stepId: text().notNull(),
stepType: text().notNull(),
status: text().notNull(),
startedAt: timestamp().notNull().defaultNow(),
finishedAt: text(),
output: text(),
error: text(),
truncated: boolean().notNull().default(false),
},
{
indexes: [
{
name: "idx_automation_run_steps_run",
columns: ["runId", "stepIndex"],
},
],
},
),
);
/** Which alert channels an automation is linked to. */
export const channels = adoptLegacyTable(
"automation_channels",
defineTable(
"channels",
{
id: id(),
automationId: integer().notNull(),
channelId: integer().notNull(),
},
{
uniques: [
{
name: "idx_automation_channels_pair",
columns: ["automationId", "channelId"],
},
],
},
),
);
export const maintenanceTable = defineTable(
"host_maintenance",
{
id: id(),
userId: refUser(),
hostId: refHost(),
state: text().notNull(),
},
{
uniques: [
{
name: "idx_automation_maintenance_host_owner",
columns: ["userId", "hostId"],
},
],
},
);
export const tables = [
maintenanceTable,
automations,
triggerState,
schedules,
runs,
runSteps,
channels,
];
+101
View File
@@ -0,0 +1,101 @@
/**
* Variable substitution for automation steps.
*
* Templates read from the run context: {{host.name}}, {{trigger.value}},
* {{steps.<stepId>.stdout}}, {{vars.myVar}}. Resolution always produces a
* plain string and never shell syntax; callers that build a command are
* responsible for quoting the result (see shellSingleQuote in
* hosts/metrics/managers/exec-elevated.ts). Nothing here escapes anything,
* precisely so there is one obvious place where quoting happens.
*/
export interface TemplateContext {
host?: {
id?: number;
name?: string;
ip?: string;
username?: string;
port?: number;
};
trigger?: Record<string, unknown>;
steps?: Record<string, { stdout?: string; stderr?: string; code?: number }>;
vars?: Record<string, string>;
run?: { id?: number; automationId?: number; startedAt?: string };
}
const TOKEN = /\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g;
function readPath(context: TemplateContext, path: string): unknown {
const parts = path.split(".");
let current: unknown = context;
for (const part of parts) {
if (current === null || current === undefined) return undefined;
if (typeof current !== "object") return undefined;
current = (current as Record<string, unknown>)[part];
}
return current;
}
function stringify(value: unknown): string {
if (value === null || value === undefined) return "";
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
return JSON.stringify(value);
}
/**
* Replaces every {{token}} it can resolve. An unresolvable token is left
* as-is so a typo shows up in the run output rather than silently becoming an
* empty string, which is the difference between a visible mistake and a
* command that quietly does the wrong thing.
*/
export function renderTemplate(
input: string,
context: TemplateContext,
): string {
if (!input || !input.includes("{{")) return input;
return input.replace(TOKEN, (match, path: string) => {
const value = readPath(context, path);
return value === undefined ? match : stringify(value);
});
}
/** Renders every string in a flat record, leaving keys untouched. */
export function renderRecord(
input: Record<string, string> | undefined,
context: TemplateContext,
): Record<string, string> | undefined {
if (!input) return undefined;
const output: Record<string, string> = {};
for (const [key, value] of Object.entries(input)) {
output[key] = renderTemplate(value, context);
}
return output;
}
/** True when a template still has unresolved tokens after rendering. */
export function hasUnresolvedTokens(rendered: string): boolean {
TOKEN.lastIndex = 0;
return TOKEN.test(rendered);
}
const SECRET_KEY = /(authorization|token|password|secret|api[-_]?key|cookie)/i;
/**
* Masks values whose key looks like a credential, for anything written to run
* history or returned by the API.
*/
export function redactSecrets(
input: Record<string, string> | undefined,
): Record<string, string> | undefined {
if (!input) return undefined;
const output: Record<string, string> = {};
for (const [key, value] of Object.entries(input)) {
output[key] = SECRET_KEY.test(key) ? "***" : value;
}
return output;
}
+485
View File
@@ -0,0 +1,485 @@
import type { PluginEvents, PluginLogger } from "@termix/plugin-sdk/backend";
import type { AutomationDefinition, HostSelector, Trigger } from "../types.js";
import type {
AutomationEngineRow,
AutomationRepository,
} from "./repository.js";
import {
compare,
extractMetricValue,
hasDwelled,
isCoolingDown,
metricStateKey,
severityForValue,
type MetricsSnapshot,
} from "./conditions.js";
import { TOPIC_AUTOMATION_FAILED, type AutomationEngine } from "./engine.js";
/**
* Matches events against automation triggers and decides what fires.
*
* Dwell windows and cooldowns live in automation_trigger_state rather than in
* memory, so a restart mid-breach neither loses the window nor re-fires an
* alert that already went out.
*/
export interface MetricEvent {
hostId: number;
ownerUserId: string;
metrics: MetricsSnapshot;
}
export interface StatusEvent {
hostId: number;
ownerUserId: string;
online: boolean;
}
export interface HealthEvent {
hostId: number;
userId: string;
checkId: string;
ok: boolean;
detail?: string;
}
export interface DockerEvent {
hostId: number;
ownerUserId: string;
container: string;
event: "exited" | "started" | "unhealthy" | "restarting";
}
export interface InternalEvent {
event: string;
userId: string;
hostId?: number;
details?: Record<string, unknown>;
}
interface LoadedAutomation {
row: AutomationEngineRow;
definition: AutomationDefinition;
}
export type Triggers = ReturnType<typeof createTriggers>;
/** Built once per activation. */
export function createTriggers(
repository: AutomationRepository,
engine: Pick<AutomationEngine, "run">,
log: PluginLogger,
isMaintaining: (
userId: string,
hostId: number,
) => Promise<boolean> = async () => false,
) {
async function loadEnabledFor(userId: string): Promise<LoadedAutomation[]> {
try {
const rows = await repository.listEnabledForUser(userId);
const loaded: LoadedAutomation[] = [];
for (const row of rows) {
try {
loaded.push({
row,
definition: JSON.parse(row.definition) as AutomationDefinition,
});
} catch {
// A malformed definition should not stop the others from evaluating.
}
}
return loaded;
} catch {
return [];
}
}
/** Whether a selector covers a host. Ownership is checked by the caller. */
function selectorCoversHost(
selector: HostSelector | undefined,
hostId: number,
): boolean {
if (!selector) return true;
switch (selector.kind) {
case "all":
case "trigger":
return true;
case "host":
return selector.hostId === hostId;
case "hosts":
return selector.hostIds.includes(hostId);
case "fleet":
// Fleet membership is resolved at execution time; evaluate optimistically
// so a fleet-scoped trigger still reaches the engine.
return true;
default:
return false;
}
}
async function fire(
automation: AutomationEngineRow,
stateKey: string,
triggerType: string,
triggerContext: Record<string, unknown>,
hostId?: number,
): Promise<void> {
if (
hostId !== undefined &&
triggerType !== "internal_event" &&
(await isMaintaining(automation.userId, hostId))
)
return;
await repository.upsertTriggerState({
automationId: automation.id,
stateKey,
lastFiredAt: new Date().toISOString(),
});
engine
.run({
automationId: automation.id,
triggerType,
triggerContext,
triggerHostId: hostId,
})
.catch((error: unknown) => {
log.warn(
`Automation ${automation.id} failed to start: ${
error instanceof Error ? error.message : String(error)
}`,
);
});
}
/**
* Metric samples. Called for every polled host, including hosts polled only
* because an automation asked for them.
*/
async function onMetrics(event: MetricEvent): Promise<void> {
const automations = await loadEnabledFor(event.ownerUserId);
const now = Date.now();
const maintaining = await isMaintaining(event.ownerUserId, event.hostId);
for (const { row, definition } of automations) {
const trigger = definition.trigger;
if (trigger?.kind !== "metric_threshold") continue;
if (!selectorCoversHost(trigger.hostSelector, event.hostId)) continue;
const value = extractMetricValue(event.metrics, trigger.metric);
if (value === null) continue;
const stateKey = metricStateKey(event.hostId, trigger.metric);
const state = await repository.getTriggerState(row.id, stateKey);
const breaching = compare(value, trigger.operator, trigger.value);
if (maintaining || !breaching) {
if (state?.breachStartedAt) {
await repository.clearBreach(row.id, stateKey);
}
continue;
}
// Open the dwell window on the first breaching sample.
if (!state?.breachStartedAt) {
await repository.upsertTriggerState({
automationId: row.id,
stateKey,
breachStartedAt: new Date(now).toISOString(),
lastValue: value,
});
if (trigger.forSeconds) continue;
}
const breachStartedAt =
state?.breachStartedAt ?? new Date(now).toISOString();
if (!hasDwelled(breachStartedAt, trigger.forSeconds, now)) continue;
if (isCoolingDown(state?.lastFiredAt, trigger.cooldownMinutes, now))
continue;
await fire(
row,
stateKey,
"metric_threshold",
{
hostId: event.hostId,
value,
threshold: trigger.value,
operator: trigger.operator,
metric: trigger.metric.path,
mount: "mount" in trigger.metric ? trigger.metric.mount : undefined,
severity: severityForValue(value, trigger.severity),
},
event.hostId,
);
}
}
/** Host reachability transitions. Only edges fire, never steady state. */
async function onStatus(event: StatusEvent): Promise<void> {
const automations = await loadEnabledFor(event.ownerUserId);
const now = Date.now();
const observed = event.online ? "online" : "offline";
for (const { row, definition } of automations) {
const trigger = definition.trigger;
if (trigger?.kind !== "host_status") continue;
if (!selectorCoversHost(trigger.hostSelector, event.hostId)) continue;
const stateKey = String(event.hostId);
const state = await repository.getTriggerState(row.id, stateKey);
if (state?.lastObservedState === observed) continue;
await repository.upsertTriggerState({
automationId: row.id,
stateKey,
lastObservedState: observed,
});
// The first observation establishes a baseline rather than firing, so a
// restart does not announce every host as though it just changed.
if (await isMaintaining(row.userId, event.hostId)) continue;
if (!state?.lastObservedState) continue;
if (trigger.to !== observed) continue;
if (isCoolingDown(state?.lastFiredAt, trigger.cooldownMinutes, now))
continue;
await fire(
row,
stateKey,
"host_status",
{ hostId: event.hostId, status: observed },
event.hostId,
);
}
}
async function onHealthCheck(event: HealthEvent): Promise<void> {
const automations = await loadEnabledFor(event.userId);
const now = Date.now();
const observed = event.ok ? "recovered" : "failing";
for (const { row, definition } of automations) {
const trigger = definition.trigger;
if (trigger?.kind !== "health_check") continue;
if (!selectorCoversHost(trigger.hostSelector, event.hostId)) continue;
if (trigger.checkId && trigger.checkId !== event.checkId) continue;
const stateKey = `${event.hostId}:${event.checkId}`;
const state = await repository.getTriggerState(row.id, stateKey);
if (state?.lastObservedState === observed) continue;
await repository.upsertTriggerState({
automationId: row.id,
stateKey,
lastObservedState: observed,
});
if (await isMaintaining(row.userId, event.hostId)) continue;
if (!state?.lastObservedState) continue;
if (trigger.to !== observed) continue;
if (isCoolingDown(state?.lastFiredAt, trigger.cooldownMinutes, now))
continue;
await fire(
row,
stateKey,
"health_check",
{
hostId: event.hostId,
checkId: event.checkId,
state: observed,
detail: event.detail,
},
event.hostId,
);
}
}
async function onDockerEvent(event: DockerEvent): Promise<void> {
if (await isMaintaining(event.ownerUserId, event.hostId)) return;
const automations = await loadEnabledFor(event.ownerUserId);
const now = Date.now();
for (const { row, definition } of automations) {
const trigger = definition.trigger;
if (trigger?.kind !== "docker_event") continue;
if (!selectorCoversHost(trigger.hostSelector, event.hostId)) continue;
if (trigger.container && trigger.container !== event.container) continue;
if (trigger.event !== event.event) continue;
const stateKey = `${event.hostId}:${event.container}`;
const state = await repository.getTriggerState(row.id, stateKey);
if (isCoolingDown(state?.lastFiredAt, trigger.cooldownMinutes, now))
continue;
await fire(
row,
stateKey,
"docker_event",
{
hostId: event.hostId,
container: event.container,
event: event.event,
},
event.hostId,
);
}
}
async function onInternalEvent(event: InternalEvent): Promise<void> {
if (
event.event === "tunnel_disconnected" &&
event.hostId !== undefined &&
(await isMaintaining(event.userId, event.hostId))
)
return;
const automations = await loadEnabledFor(event.userId);
const now = Date.now();
for (const { row, definition } of automations) {
const trigger = definition.trigger;
if (trigger?.kind !== "internal_event") continue;
if (trigger.event !== event.event) continue;
if (
event.hostId !== undefined &&
!selectorCoversHost(trigger.hostSelector, event.hostId)
) {
continue;
}
const stateKey = event.hostId ? String(event.hostId) : "global";
const state = await repository.getTriggerState(row.id, stateKey);
if (isCoolingDown(state?.lastFiredAt, trigger.cooldownMinutes, now))
continue;
await fire(
row,
stateKey,
"internal_event",
{ event: event.event, hostId: event.hostId, ...(event.details ?? {}) },
event.hostId,
);
}
}
/**
* Hosts an enabled automation watches with the given trigger kind, with
* the owning user. Fleet and "all" selectors are not expanded: collecting
* for every host a user owns is far too costly.
*/
async function watchedHosts(
kind: "metric_threshold" | "docker_event",
): Promise<Map<number, string>> {
const watched = new Map<number, string>();
try {
for (const row of await repository.listAllEnabled()) {
let definition: AutomationDefinition;
try {
definition = JSON.parse(row.definition) as AutomationDefinition;
} catch {
continue;
}
const trigger: Trigger | undefined = definition.trigger;
if (trigger?.kind !== kind) continue;
const selector = trigger.hostSelector;
if (selector?.kind === "host") {
watched.set(selector.hostId, row.userId);
} else if (selector?.kind === "hosts") {
for (const hostId of selector.hostIds)
watched.set(hostId, row.userId);
}
}
} catch {
return watched;
}
return watched;
}
/**
* Subscribes to the topics the triggers read. Every listener is disposed
* with the plugin. Handlers are async and the bus is fire-and-forget, so a
* rejection is logged here rather than reaching whoever emitted.
*/
function subscribe(events: PluginEvents): void {
const on = <T>(
topic: string,
handler: (payload: T) => Promise<void> | undefined,
) =>
events.on(topic, (payload) => {
void Promise.resolve(handler(payload as T)).catch((error: unknown) =>
log.warn(
`Automation trigger for ${topic} failed: ${
error instanceof Error ? error.message : String(error)
}`,
),
);
});
on<MetricEvent>("plugin.host-metrics.snapshot", onMetrics);
on<HealthEvent>("plugin.host-metrics.health-check", onHealthCheck);
on<StatusEvent>("host.status", onStatus);
on<InternalEvent>("internal.event", (event) =>
event?.userId ? onInternalEvent(event) : undefined,
);
// An SSH login seen by the terminal.
on<{ hostId: number; userId: string; sshUser?: string; fromIp?: string }>(
"host.login",
(login) =>
login?.userId
? onInternalEvent({
event: "user_login",
userId: login.userId,
hostId: login.hostId,
details: { sshUser: login.sshUser, fromIp: login.fromIp },
})
: undefined,
);
// Nothing arrives here while the tunnels plugin is off, which is the
// whole of that optional dependency.
on<{ userId?: string; hostId?: number; tunnelName?: string }>(
"plugin.tunnels.tunnel_disconnected",
(event) =>
event?.userId
? onInternalEvent({
event: "tunnel_disconnected",
userId: event.userId,
hostId: event.hostId,
details: { tunnelName: event.tunnelName },
})
: undefined,
);
on<{
userId?: string;
automationId?: number;
automationName?: string;
runId?: number;
error?: string | null;
}>(TOPIC_AUTOMATION_FAILED, (event) =>
event?.userId
? onInternalEvent({
event: "automation_failed",
userId: event.userId,
details: {
automationId: event.automationId,
automationName: event.automationName,
runId: event.runId,
error: event.error ?? null,
},
})
: undefined,
);
}
return {
onMetrics,
onStatus,
onHealthCheck,
onDockerEvent,
onInternalEvent,
watchedHosts,
subscribe,
};
}
+153
View File
@@ -0,0 +1,153 @@
import type { AutomationDefinition, Step, Trigger } from "../types.js";
import { AUTOMATION_DEFINITION_VERSION } from "../types.js";
import { isValidCron, isValidTimezone } from "./cron.js";
const TRIGGER_KINDS = new Set([
"metric_threshold",
"host_status",
"health_check",
"schedule",
"docker_event",
"internal_event",
"webhook",
]);
const STEP_TYPES = new Set([
"notify",
"http",
"run_snippet",
"run_command",
"docker",
"tunnel",
"wol",
"wait",
"set_var",
"if",
"run_automation",
"stop",
]);
const OPERATORS = new Set([
">",
"<",
">=",
"<=",
"==",
"!=",
"contains",
"not_contains",
"changed",
]);
export function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
/**
* Validates a definition before it is stored. The engine treats the stored
* blob as trusted, so everything it relies on is checked once here.
*/
export function validateDefinition(value: unknown): {
ok: boolean;
error?: string;
definition?: AutomationDefinition;
} {
if (typeof value !== "object" || value === null) {
return { ok: false, error: "Definition must be an object" };
}
const candidate = value as Partial<AutomationDefinition>;
const trigger = candidate.trigger as Trigger | undefined;
if (!trigger || !TRIGGER_KINDS.has(trigger.kind)) {
return { ok: false, error: "Unknown or missing trigger kind" };
}
if (trigger.kind === "metric_threshold") {
if (!OPERATORS.has(trigger.operator)) {
return { ok: false, error: "Unknown comparison operator" };
}
if (typeof trigger.value !== "number" || !Number.isFinite(trigger.value)) {
return { ok: false, error: "Threshold value must be a number" };
}
if (!trigger.metric?.path) {
return { ok: false, error: "Trigger is missing a metric" };
}
}
if (trigger.kind === "schedule") {
const hasInterval =
typeof trigger.intervalSeconds === "number" &&
trigger.intervalSeconds > 0;
const hasCron = isNonEmptyString(trigger.cron);
if (!hasInterval && !hasCron) {
return { ok: false, error: "Schedule needs an interval or a cron" };
}
if (hasCron && !isValidCron(trigger.cron as string)) {
return { ok: false, error: "Cron expression is not valid" };
}
if (hasInterval && (trigger.intervalSeconds as number) < 60) {
return { ok: false, error: "Interval must be at least 60 seconds" };
}
if (
isNonEmptyString(trigger.timezone) &&
!isValidTimezone(trigger.timezone)
) {
return { ok: false, error: "Time zone is not valid" };
}
}
const steps = candidate.steps;
if (!Array.isArray(steps)) {
return { ok: false, error: "Definition must include a steps array" };
}
const seen = new Set<string>();
const stepError = validateSteps(steps as Step[], seen);
if (stepError) return { ok: false, error: stepError };
return {
ok: true,
definition: {
version: candidate.version ?? AUTOMATION_DEFINITION_VERSION,
trigger,
steps: steps as Step[],
},
};
}
function validateSteps(steps: Step[], seen: Set<string>): string | null {
for (const step of steps) {
if (!step || typeof step !== "object") return "Step must be an object";
if (!isNonEmptyString(step.id)) return "Every step needs an id";
if (seen.has(step.id)) return `Duplicate step id: ${step.id}`;
seen.add(step.id);
if (!STEP_TYPES.has(step.type)) {
return `Unknown step type: ${step.type}`;
}
if (step.type === "if") {
if (!step.condition || !OPERATORS.has(step.condition.operator)) {
return "Condition needs a valid operator";
}
const thenError = validateSteps(step.then ?? [], seen);
if (thenError) return thenError;
const elseError = validateSteps(step.else ?? [], seen);
if (elseError) return elseError;
}
if (step.type === "http" && !isNonEmptyString(step.url)) {
return "HTTP steps need a URL";
}
if (step.type === "run_command" && !isNonEmptyString(step.command)) {
return "Command steps need a command";
}
if (step.type === "wait" && typeof step.seconds !== "number") {
return "Wait steps need a number of seconds";
}
if (step.type === "set_var" && !isNonEmptyString(step.name)) {
return "Variable steps need a name";
}
}
return null;
}
@@ -0,0 +1,557 @@
import { useCallback, useEffect, useState } from "react";
import { invokeAction, useTranslation } from "@termix/plugin-sdk/frontend";
import { toast } from "sonner";
import {
ArrowLeft,
Copy,
FlaskConical,
Loader2,
Play,
Plus,
Trash2,
Workflow,
} from "lucide-react";
import { Badge, Button, getBasePath } from "@termix/plugin-sdk/ui";
import {
useAutomationsApi,
type AutomationRow,
type AutomationRunRow,
type AutomationRunStepRow,
} from "./automations-api";
import {
AutomationEditor,
emptyDraft,
type AutomationDraft,
} from "./automations/AutomationEditor";
import {
EMPTY_EDITOR_OPTIONS,
type AutomationEditorOptions,
} from "./automations/editor-types";
function getErrorMessage(error: unknown, fallback: string): string {
return error instanceof Error ? error.message : fallback;
}
type PanelTab = "automations" | "runs";
const STATUS_CLASS: Record<string, string> = {
success: "text-green-500",
failed: "text-destructive",
timeout: "text-destructive",
running: "text-foreground",
skipped: "text-muted-foreground",
cancelled: "text-muted-foreground",
};
type Translate = (key: string, options?: Record<string, unknown>) => string;
function timeAgo(iso: string | null, t: Translate): string {
if (!iso) return "";
const ms = Date.now() - new Date(iso).getTime();
if (!Number.isFinite(ms)) return "";
const base = "newUi.sidebar.automations.ago";
const sec = Math.floor(ms / 1000);
if (sec < 60) return t(`${base}.seconds`, { count: sec });
const min = Math.floor(sec / 60);
if (min < 60) return t(`${base}.minutes`, { count: min });
const hr = Math.floor(min / 60);
if (hr < 24) return t(`${base}.hours`, { count: hr });
return t(`${base}.days`, { count: Math.floor(hr / 24) });
}
export function AutomationsPanel({
active = true,
onEditingChange,
}: {
active?: boolean;
onEditingChange?: (editing: boolean) => void;
}) {
const { t } = useTranslation();
const api = useAutomationsApi();
const base = "newUi.sidebar.automations";
const [tab, setTab] = useState<PanelTab>("automations");
const [automations, setAutomations] = useState<AutomationRow[]>([]);
const [runs, setRuns] = useState<AutomationRunRow[]>([]);
const [loading, setLoading] = useState(false);
const [options, setOptions] =
useState<AutomationEditorOptions>(EMPTY_EDITOR_OPTIONS);
const [editorOpen, setEditorOpen] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [draft, setDraft] = useState<AutomationDraft>(emptyDraft());
const [saving, setSaving] = useState(false);
const [webhookToken, setWebhookToken] = useState<string | null>(null);
const [expandedRun, setExpandedRun] = useState<number | null>(null);
const [runSteps, setRunSteps] = useState<AutomationRunStepRow[]>([]);
const loadAutomations = useCallback(async () => {
setLoading(true);
try {
setAutomations(await api.list());
} catch {
// A failed load leaves the previous list on screen.
} finally {
setLoading(false);
}
}, [api]);
const loadRuns = useCallback(async () => {
try {
setRuns(await api.runs({ limit: 100 }));
} catch {
// Non-fatal.
}
}, [api]);
const loadOptions = useCallback(async () => {
try {
setOptions(await api.editorOptions());
} catch {
// The editor falls back to empty pickers.
}
}, [api]);
useEffect(() => {
if (!active) return;
void loadAutomations();
void loadOptions();
}, [active, loadAutomations, loadOptions]);
useEffect(() => {
if (active && tab === "runs") void loadRuns();
}, [active, tab, loadRuns]);
// Widens the sidebar while the editor is open, and releases it again on the
// way out or when the user switches to another rail destination.
useEffect(() => {
if (active) onEditingChange?.(editorOpen);
}, [active, editorOpen, onEditingChange]);
useEffect(() => {
return () => onEditingChange?.(false);
}, [onEditingChange]);
function closeEditor() {
setWebhookToken(null);
setEditorOpen(false);
}
function openCreate() {
setEditingId(null);
setDraft(emptyDraft());
setWebhookToken(null);
setEditorOpen(true);
}
function openEdit(row: AutomationRow) {
setEditingId(row.id);
setDraft({
name: row.name,
description: row.description ?? "",
enabled: !!row.enabled,
concurrencyPolicy: row.concurrency_policy ?? "skip",
definition: row.definition ?? emptyDraft().definition,
});
setWebhookToken(null);
setEditorOpen(true);
}
async function save() {
if (!draft.name.trim()) {
toast.error(t(`${base}.nameRequired`));
return;
}
setSaving(true);
try {
if (editingId === null) {
const created = await api.create({
name: draft.name.trim(),
description: draft.description || null,
enabled: draft.enabled,
concurrencyPolicy: draft.concurrencyPolicy,
definition: draft.definition,
});
toast.success(t(`${base}.created`));
if (created.webhookToken) {
setWebhookToken(created.webhookToken);
} else {
setEditorOpen(false);
}
} else {
await api.update(editingId, {
name: draft.name.trim(),
description: draft.description || null,
enabled: draft.enabled,
concurrencyPolicy: draft.concurrencyPolicy,
definition: draft.definition,
});
toast.success(t(`${base}.updated`));
setEditorOpen(false);
}
await loadAutomations();
} catch (error) {
toast.error(getErrorMessage(error, String(error)));
} finally {
setSaving(false);
}
}
async function remove(row: AutomationRow) {
if (!confirm(t(`${base}.deleteConfirm`))) return;
try {
await api.remove(row.id);
toast.success(t(`${base}.deleted`));
await loadAutomations();
} catch (error) {
toast.error(getErrorMessage(error, String(error)));
}
}
async function run(row: AutomationRow, dryRun: boolean) {
try {
const outcome = await api.run(row.id, { dryRun });
toast.success(t(`${base}.runFinished`, { status: outcome.status }));
await loadAutomations();
if (tab === "runs") await loadRuns();
} catch (error) {
toast.error(getErrorMessage(error, String(error)));
}
}
async function toggleRun(runId: number) {
if (expandedRun === runId) {
setExpandedRun(null);
return;
}
setExpandedRun(runId);
try {
setRunSteps(await api.runSteps(runId));
} catch {
setRunSteps([]);
}
}
// Editing takes over the whole panel and widens the sidebar, the same way the
// host manager does, rather than opening a dialog over the app.
if (editorOpen) {
const webhookUrl = webhookToken
? `${window.location.origin}${getBasePath()}/plugin-api/automations/webhook/${webhookToken}`
: "";
return (
<div className="flex flex-col h-full min-h-0">
<button
onClick={closeEditor}
className="flex items-center gap-2 px-3 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/40 transition-colors border-b border-border/50 shrink-0"
>
<ArrowLeft className="size-3.5 shrink-0" />
<span>{t(`${base}.backToAutomations`)}</span>
{editingId !== null && (
<span
className="ml-auto font-semibold text-foreground truncate max-w-[200px]"
title={draft.name}
>
{draft.name}
</span>
)}
</button>
<div className="flex-1 min-h-0 overflow-y-auto p-3">
{webhookToken ? (
<div className="space-y-2">
<p className="text-sm">{t(`${base}.webhookTokenTitle`)}</p>
<p className="text-xs text-muted-foreground">
{t(`${base}.webhookTokenDescription`)}
</p>
<div className="flex gap-2">
<code className="flex-1 p-2 bg-muted text-xs break-all">
{webhookUrl}
</code>
<Button
size="sm"
variant="outline"
className="rounded-none border-border shrink-0"
onClick={() => {
navigator.clipboard?.writeText(webhookUrl);
toast.success(t(`${base}.copied`));
}}
>
<Copy size={14} />
</Button>
</div>
</div>
) : (
<AutomationEditor
draft={draft}
options={options}
onChange={setDraft}
/>
)}
</div>
<div className="flex items-center gap-2 p-3 border-t border-border shrink-0">
{webhookToken ? (
<Button
variant="outline"
className="rounded-none ml-auto h-9 px-6 border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={closeEditor}
>
{t("common.close")}
</Button>
) : (
<>
<Button
variant="outline"
className="rounded-none border-border h-9 px-5"
onClick={closeEditor}
>
{t("common.cancel")}
</Button>
<Button
variant="outline"
className="rounded-none ml-auto h-9 px-6 border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
disabled={saving}
onClick={save}
>
{saving && (
<Loader2 size={14} className="mr-1.5 animate-spin" />
)}
{t(`${base}.save`)}
</Button>
</>
)}
</div>
</div>
);
}
return (
<div className="flex flex-col h-full">
<div className="flex items-center gap-1 p-2 border-b border-border">
{(["automations", "runs"] as PanelTab[]).map((key) => (
<Button
key={key}
variant={tab === key ? "outline" : "ghost"}
size="sm"
className={`min-w-0 flex-shrink rounded-none h-7 px-2 text-xs ${
tab === key
? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand hover:bg-accent-brand/20 hover:text-accent-brand"
: ""
}`}
onClick={() => setTab(key)}
>
<span className="truncate">
{t(
`${base}.${key === "automations" ? "tabAutomations" : "tabRuns"}`,
)}
</span>
</Button>
))}
<Button
variant="ghost"
size="sm"
className="min-w-0 flex-shrink rounded-none h-7 px-2 text-xs"
title={t(`${base}.channelsHint`)}
onClick={() => void invokeAction("alerts.openChannels")}
>
<span className="truncate">{t(`${base}.tabChannels`)}</span>
</Button>
<a
href="https://docs.termix.site/features/automations/overview"
target="_blank"
rel="noreferrer"
className="ml-auto shrink-0 text-[10px] text-accent-brand hover:underline"
>
{t(`${base}.docsLink`)}
</a>
</div>
<div className="flex-1 overflow-y-auto p-2">
{tab === "automations" && (
<>
<Button
size="sm"
variant="outline"
className="rounded-none w-full mb-2 border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={openCreate}
>
<Plus size={14} className="mr-1" />
{t(`${base}.createAutomation`)}
</Button>
{loading && automations.length === 0 && (
<div className="flex justify-center p-4">
<Loader2 size={16} className="animate-spin" />
</div>
)}
{!loading && automations.length === 0 && (
<p className="text-xs text-muted-foreground p-2">
{t(`${base}.empty`)}
</p>
)}
<div className="space-y-1">
{automations.map((row) => (
<div
key={row.id}
className="border border-border p-2.5 hover:bg-muted/40"
>
<div className="flex items-center gap-2">
<Workflow size={14} className="shrink-0" />
<button
className="flex-1 text-left text-sm truncate"
onClick={() => openEdit(row)}
>
{row.name}
</button>
{!!row.missingPlugins?.length && (
<Badge
variant="outline"
className="text-[10px] border-destructive/50 text-destructive"
title={t(`${base}.needsPluginHint`)}
>
{t(`${base}.needsPlugin`, {
plugin: row.missingPlugins.join(", "),
})}
</Badge>
)}
{!row.enabled && (
<Badge variant="outline" className="text-[10px]">
{t(`${base}.offBadge`)}
</Badge>
)}
</div>
<div className="flex items-center gap-1 mt-1">
<span className="text-[11px] text-muted-foreground flex-1 flex gap-2.5 min-w-0 overflow-hidden whitespace-nowrap">
{row.definition && (
<span className="truncate">
{t(
`${base}.triggerKinds.${row.definition.trigger.kind}`,
)}
</span>
)}
{row.last_run_status && (
<span className="shrink-0">
{timeAgo(row.last_run_at, t)}
</span>
)}
</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 rounded-none"
title={t(`${base}.testRun`)}
onClick={() => run(row, true)}
>
<FlaskConical size={12} />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 rounded-none"
title={t(`${base}.runNow`)}
onClick={() => run(row, false)}
>
<Play size={12} />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 rounded-none"
onClick={() => remove(row)}
>
<Trash2 size={12} />
</Button>
</div>
</div>
))}
</div>
</>
)}
{tab === "runs" && (
<div className="space-y-1">
{runs.length === 0 && (
<p className="text-xs text-muted-foreground p-2">
{t(`${base}.emptyRuns`)}
</p>
)}
{runs.map((entry) => (
<div key={entry.id} className="border border-border">
<button
className="w-full text-left p-2.5 hover:bg-muted/40"
onClick={() => toggleRun(entry.id)}
>
<div className="flex items-center gap-2">
<span className="text-sm flex-1 truncate">
{entry.automation_name ?? `#${entry.automation_id}`}
</span>
{entry.dry_run ? (
<Badge variant="outline" className="text-[10px]">
{t(`${base}.dryRunBadge`)}
</Badge>
) : null}
<span
className={`text-[11px] ${STATUS_CLASS[entry.status] ?? ""}`}
>
{t(`${base}.statuses.${entry.status}`)}
</span>
</div>
<div className="flex gap-2.5 text-[11px] text-muted-foreground">
<span>{timeAgo(entry.started_at, t)}</span>
{entry.duration_ms !== null && (
<span>{Math.round(entry.duration_ms / 100) / 10}s</span>
)}
</div>
{entry.error && (
<div className="text-[11px] text-destructive truncate">
{entry.error}
</div>
)}
</button>
{expandedRun === entry.id && (
<div className="border-t border-border p-2 space-y-1">
{runSteps.map((step) => (
<div key={step.id} className="text-[11px]">
<div className="flex gap-2">
<span className="text-muted-foreground">
{step.step_index + 1}.
</span>
<span className="flex-1">
{t(`${base}.stepTypes.${step.step_type}`, {
defaultValue: step.step_type,
})}
</span>
<span className={STATUS_CLASS[step.status] ?? ""}>
{step.status}
</span>
</div>
{step.output && (
<pre className="mt-1 p-1 bg-muted overflow-x-auto whitespace-pre-wrap break-all text-[10px]">
{step.output}
</pre>
)}
{step.error && (
<div className="text-destructive">{step.error}</div>
)}
</div>
))}
</div>
)}
</div>
))}
</div>
)}
</div>
</div>
);
}
export default AutomationsPanel;
@@ -0,0 +1,343 @@
import {
useEffect,
useState,
useSyncExternalStore,
type FormEvent,
} from "react";
import {
usePermission,
useTranslation,
type PluginHostRecord,
} from "@termix/plugin-sdk/frontend";
import { Badge, Button, Input, Label } from "@termix/plugin-sdk/ui";
import type { MaintenanceStore } from "./maintenance-store";
import type { Recurrence } from "../maintenance";
export function MaintenanceBadge({
host,
store,
}: {
host: PluginHostRecord;
store: MaintenanceStore;
}) {
const allowed = usePermission("view");
const { t } = useTranslation();
return allowed ? (
<VisibleBadge host={host} store={store} title={t("maintenance.title")} />
) : null;
}
function VisibleBadge({
host,
store,
title,
}: {
host: PluginHostRecord;
store: MaintenanceStore;
title: string;
}) {
const states = useSyncExternalStore(store.subscribe, store.snapshot);
const active = states[Number(host.id)]?.active;
if (!active) return null;
return (
<Badge
variant="outline"
title={`${active.reasons.join("; ")}\n${new Date(active.estimatedEnd).toLocaleString()}`}
>
{title}
</Badge>
);
}
export function HostMaintenance({
host,
store,
visible,
}: {
host: PluginHostRecord;
store: MaintenanceStore;
visible: boolean;
}) {
const canView = usePermission("view");
const { t } = useTranslation();
if (!canView) return <p className="p-4">{t("maintenance.denied")}</p>;
if (!visible) return null;
return <MaintenanceEditor key={host.id} host={host} store={store} />;
}
function MaintenanceEditor({
host,
store,
}: {
host: PluginHostRecord;
store: MaintenanceStore;
}) {
const { t } = useTranslation();
const canEditPermission = usePermission("edit");
const states = useSyncExternalStore(store.subscribe, store.snapshot);
const state = states[Number(host.id)];
// A shared host shows its owner's maintenance, read only.
const canEdit = canEditPermission && state?.owned !== false;
const [mode, setMode] = useState<"start" | "schedule">("start");
const [reason, setReason] = useState("");
const [start, setStart] = useState("");
const [duration, setDuration] = useState(60);
const [grace, setGrace] = useState(20);
const [recurrence, setRecurrence] = useState<Recurrence>("once");
const [notify, setNotify] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [ending, setEnding] = useState(false);
useEffect(() => {
void store.refresh();
}, [store]);
async function mutate(input: Record<string, unknown>) {
setBusy(true);
setError("");
try {
await store.edit(Number(host.id), input);
setEnding(false);
} catch (error) {
const body = (error as { response?: { data?: { error?: string } } })
.response?.data?.error;
setError(
body ||
(error instanceof Error ? error.message : t("maintenance.failed")),
);
} finally {
setBusy(false);
}
}
function submit(event: FormEvent) {
event.preventDefault();
void mutate({
action: mode,
reason,
start: start ? `${start}:00.000Z` : undefined,
durationMinutes: duration,
graceMinutes: grace,
recurrence,
notifyOverdue: notify,
});
}
const fieldClass = "h-9 border bg-background px-3 text-sm";
return (
<div className="mx-auto w-full max-w-3xl space-y-6 overflow-auto p-6">
<div>
<h2 className="text-lg font-semibold">{t("maintenance.title")}</h2>
<p className="text-sm font-medium">{host.name || host.ip}</p>
<p className="text-sm text-muted-foreground">
{t("maintenance.description")}
</p>
</div>
{store.error() && (
<p role="alert" className="text-sm text-destructive">
{t(store.error())}
</p>
)}
{!store.loaded() && <p>{t("maintenance.loading")}</p>}
{error && (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
)}
{state?.active && (
<section className="space-y-3 border p-4">
<Badge>{t("maintenance.active")}</Badge>
<p className="break-words">{state.active.reasons.join("; ")}</p>
<p>
{t("maintenance.estimated")}:{" "}
{new Date(state.active.estimatedEnd).toLocaleString()}
</p>
<p className="text-sm text-muted-foreground">
{t("maintenance.noAutoEnd")}
</p>
{canEdit &&
(ending ? (
<div className="space-y-2">
<p>{t("maintenance.endConfirm")}</p>
<Button
disabled={busy || !store.loaded() || !!store.error()}
onClick={() => void mutate({ action: "end" })}
>
{t("maintenance.confirmEnd")}
</Button>{" "}
<Button variant="outline" onClick={() => setEnding(false)}>
{t("maintenance.cancel")}
</Button>
</div>
) : (
<Button
variant="outline"
disabled={busy || !store.loaded() || !!store.error()}
onClick={() => setEnding(true)}
>
{t("maintenance.end")}
</Button>
))}
</section>
)}
{state?.owned === false && (
<p className="text-sm text-muted-foreground">
{t("maintenance.sharedReadOnly")}
</p>
)}
{canEdit && (
<form onSubmit={submit} className="space-y-4 border p-4">
<div className="flex flex-wrap gap-3">
<Label htmlFor="maintenance-mode">{t("maintenance.action")}</Label>
<select
id="maintenance-mode"
className={fieldClass}
value={mode}
onChange={(event) => setMode(event.target.value as typeof mode)}
>
<option value="start">{t("maintenance.startNow")}</option>
<option value="schedule">{t("maintenance.schedule")}</option>
</select>
</div>
<div className="space-y-2">
<Label htmlFor="maintenance-reason">
{t("maintenance.reason")}
</Label>
<Input
id="maintenance-reason"
required
maxLength={200}
value={reason}
onChange={(event) => setReason(event.target.value)}
/>
</div>
{mode === "schedule" && (
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="maintenance-start">
{t("maintenance.startUtc")}
</Label>
<Input
id="maintenance-start"
type="datetime-local"
required
value={start}
onChange={(event) => setStart(event.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="maintenance-repeat">
{t("maintenance.repeat")}
</Label>
<select
id="maintenance-repeat"
className={`${fieldClass} w-full`}
value={recurrence}
onChange={(event) =>
setRecurrence(event.target.value as Recurrence)
}
>
{(["once", "weekly", "monthly"] as const).map((value) => (
<option key={value} value={value}>
{t(`maintenance.${value}`)}
</option>
))}
</select>
</div>
<p className="text-sm text-muted-foreground sm:col-span-2">
{t("maintenance.utcHelp")}
</p>
</div>
)}
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="maintenance-duration">
{t("maintenance.duration")}
</Label>
<Input
id="maintenance-duration"
type="number"
min={1}
max={10080}
required
value={duration}
onChange={(event) => setDuration(Number(event.target.value))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="maintenance-grace">
{t("maintenance.grace")}
</Label>
<Input
id="maintenance-grace"
type="number"
min={0}
max={1440}
required
value={grace}
onChange={(event) => setGrace(Number(event.target.value))}
/>
</div>
</div>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={notify}
onChange={(event) => setNotify(event.target.checked)}
/>
{t("maintenance.notify")}
</label>
<Button
type="submit"
disabled={
busy ||
!store.loaded() ||
!!store.error() ||
(mode === "start" && !!state?.active)
}
>
{t(
mode === "start"
? "maintenance.startNow"
: "maintenance.addSchedule",
)}
</Button>
</form>
)}
<section className="space-y-3">
<h3 className="font-medium">{t("maintenance.schedules")}</h3>
{!state?.plans.length && (
<p className="text-sm text-muted-foreground">
{t("maintenance.noSchedules")}
</p>
)}
{state?.plans.map((plan) => (
<div
key={plan.id}
className="flex flex-wrap items-center justify-between gap-3 border p-3"
>
<div className="min-w-0 space-y-1">
<p className="break-words">{plan.reason}</p>
<p className="flex flex-wrap gap-x-3 text-sm text-muted-foreground">
<span>{t(`maintenance.${plan.recurrence}`)}</span>
<span>
{plan.nextStart
? `${plan.nextStart.replace("T", " ").slice(0, 16)} UTC`
: t("maintenance.completed")}
</span>
<span>
{t("maintenance.minutes", { count: plan.durationMinutes })}
</span>
</p>
</div>
{canEdit && (
<Button
variant="outline"
disabled={busy || !store.loaded() || !!store.error()}
onClick={() => void mutate({ action: "remove", id: plan.id })}
>
{t("maintenance.remove")}
</Button>
)}
</div>
))}
</section>
</div>
);
}
@@ -0,0 +1,160 @@
import { useMemo } from "react";
import {
usePluginApi,
type PluginApiClient,
} from "@termix/plugin-sdk/frontend";
import type {
AutomationDefinition,
ConcurrencyPolicy,
RunStatus,
StepStatus,
StepType,
TriggerKind,
} from "../types";
export interface AutomationRow {
id: number;
user_id: string;
name: string;
description: string | null;
enabled: number;
definition: AutomationDefinition | null;
definition_version: number;
concurrency_policy: ConcurrencyPolicy;
max_run_seconds: number;
dry_run: number;
last_run_at: string | null;
last_run_status: RunStatus | null;
created_at: string;
updated_at: string;
channels: number[];
/** Plugins this automation needs that are not running. */
missingPlugins?: string[];
/** Returned once, only when a webhook automation is created. */
webhookToken?: string;
}
export interface AutomationRunRow {
id: number;
automation_id: number;
user_id: string;
trigger_type: TriggerKind | "manual";
trigger_context: string | null;
status: RunStatus;
started_at: string;
finished_at: string | null;
duration_ms: number | null;
error: string | null;
dry_run: number;
parent_run_id: number | null;
automation_name?: string | null;
}
export interface AutomationRunStepRow {
id: number;
run_id: number;
step_index: number;
step_id: string;
step_type: StepType;
status: StepStatus;
started_at: string;
finished_at: string | null;
output: string | null;
error: string | null;
truncated: number;
}
export interface AutomationRunOutcome {
runId: number | null;
status: RunStatus;
error?: string;
}
export interface AutomationInput {
name: string;
description?: string | null;
enabled?: boolean;
definition: AutomationDefinition;
concurrencyPolicy?: ConcurrencyPolicy;
channels?: number[];
}
/** Which optional plugins are running, keyed like the backend's deps. */
export interface AutomationProviders {
snippets: boolean;
fleets: boolean;
tunnels: boolean;
docker: boolean;
"docker-events": boolean;
"host-metrics": boolean;
"wake-on-lan": boolean;
}
export interface AutomationEditorOptionsResponse {
hosts: Array<{ id: number; name: string }>;
snippets: Array<{ id: number; name: string }>;
fleets: Array<{ id: number; name: string }>;
channels: Array<{ id: number; name: string }>;
providers: AutomationProviders;
}
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);
}
}
export function createAutomationsApi(api: PluginApiClient) {
return {
list: () => call<AutomationRow[]>("fetch automations", () => api.get("/")),
editorOptions: () =>
call<AutomationEditorOptionsResponse>("fetch editor options", () =>
api.get("/editor-options"),
),
create: (input: AutomationInput) =>
call<AutomationRow>("create automation", () => api.post("/", input)),
update: (id: number, input: Partial<AutomationInput>) =>
call<AutomationRow>("update automation", () => api.put(`/${id}`, input)),
remove: (id: number) =>
call<{ success: boolean }>("delete automation", () =>
api.delete(`/${id}`),
),
run: (id: number, options: { dryRun?: boolean } = {}) =>
call<AutomationRunOutcome>("run automation", () =>
api.post(`/${id}/run`, options),
),
runs: (
options: { automationId?: number; limit?: number; offset?: number } = {},
) =>
call<AutomationRunRow[]>("fetch automation runs", () =>
api.get("/runs/history", { params: options }),
),
runSteps: (runId: number) =>
call<AutomationRunStepRow[]>("fetch run steps", () =>
api.get(`/runs/${runId}/steps`),
),
};
}
export type AutomationsApi = ReturnType<typeof createAutomationsApi>;
export function useAutomationsApi(): AutomationsApi {
const api = usePluginApi();
return useMemo(() => createAutomationsApi(api), [api]);
}
@@ -0,0 +1,259 @@
import { useState } from "react";
import {
Button,
Input,
Label,
Switch,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@termix/plugin-sdk/ui";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { Plus } from "lucide-react";
import {
AUTOMATION_DEFINITION_VERSION,
type AutomationDefinition,
type ConcurrencyPolicy,
type Step,
} from "../../types";
import { StepBlock } from "./StepBlock";
import { TriggerCard, defaultTrigger } from "./TriggerCard";
import { newStepId, type AutomationEditorOptions } from "./editor-types";
export interface AutomationDraft {
name: string;
description: string;
enabled: boolean;
concurrencyPolicy: ConcurrencyPolicy;
definition: AutomationDefinition;
}
export function emptyDraft(): AutomationDraft {
return {
name: "",
description: "",
enabled: true,
concurrencyPolicy: "skip",
definition: {
version: AUTOMATION_DEFINITION_VERSION,
trigger: defaultTrigger("metric_threshold"),
steps: [],
},
};
}
/**
* The automation builder: one trigger, then an ordered list of steps that can
* nest through if/otherwise. A JSON view is available for anything the form
* does not cover yet.
*/
export function AutomationEditor({
draft,
options,
onChange,
}: {
draft: AutomationDraft;
options: AutomationEditorOptions;
onChange: (next: AutomationDraft) => void;
}) {
const { t } = useTranslation();
const base = "newUi.sidebar.automations";
const [view, setView] = useState<"builder" | "json">("builder");
const [jsonText, setJsonText] = useState("");
const [jsonError, setJsonError] = useState<string | null>(null);
function setDefinition(definition: AutomationDefinition) {
onChange({ ...draft, definition });
}
function setSteps(steps: Step[]) {
setDefinition({ ...draft.definition, steps });
}
function renderSteps(
steps: Step[],
onStepsChange: (next: Step[]) => void,
depth: number,
): React.ReactNode {
return (
<div className="space-y-2">
{steps.map((step, index) => (
<StepBlock
key={step.id}
step={step}
index={index}
total={steps.length}
depth={depth}
options={options}
onChange={(next) => {
const copy = [...steps];
copy[index] = next;
onStepsChange(copy);
}}
onRemove={() => onStepsChange(steps.filter((_, i) => i !== index))}
onMove={(direction) => {
const target = index + direction;
if (target < 0 || target >= steps.length) return;
const copy = [...steps];
[copy[index], copy[target]] = [copy[target], copy[index]];
onStepsChange(copy);
}}
renderNested={renderSteps}
/>
))}
<Button
variant="outline"
size="sm"
className="rounded-none w-full border-border"
onClick={() =>
onStepsChange([
...steps,
{ id: newStepId(), type: "notify", channelIds: [] } as Step,
])
}
>
<Plus size={14} className="mr-1" />
{t(`${base}.addStep`)}
</Button>
</div>
);
}
return (
<div className="space-y-4">
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">
{t(`${base}.nameLabel`)}
</Label>
<Input
className="h-8 rounded-none"
placeholder={t(`${base}.namePlaceholder`)}
value={draft.name}
onChange={(e) => onChange({ ...draft, name: e.target.value })}
/>
</div>
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<Switch
checked={draft.enabled}
onCheckedChange={(enabled) => onChange({ ...draft, enabled })}
/>
{t(`${base}.enabledLabel`)}
</label>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">
{t(`${base}.concurrencyLabel`)}
</Label>
<Select
value={draft.concurrencyPolicy}
onValueChange={(value) =>
onChange({
...draft,
concurrencyPolicy: value as ConcurrencyPolicy,
})
}
>
<SelectTrigger
size="sm"
className="w-full rounded-none border-border"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="skip">
{t(`${base}.concurrency.skip`)}
</SelectItem>
<SelectItem value="queue">
{t(`${base}.concurrency.queue`)}
</SelectItem>
<SelectItem value="allow">
{t(`${base}.concurrency.allow`)}
</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{t(`${base}.concurrencyHint`)}
</p>
</div>
<div className="flex gap-1">
<Button
variant="outline"
size="sm"
className={`rounded-none ${
view === "builder"
? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand hover:bg-accent-brand/20 hover:text-accent-brand"
: "border-border"
}`}
onClick={() => setView("builder")}
>
{t(`${base}.viewBuilder`)}
</Button>
<Button
variant="outline"
size="sm"
className={`rounded-none ${
view === "json"
? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand hover:bg-accent-brand/20 hover:text-accent-brand"
: "border-border"
}`}
onClick={() => {
setJsonText(JSON.stringify(draft.definition, null, 2));
setJsonError(null);
setView("json");
}}
>
{t(`${base}.viewJson`)}
</Button>
</div>
{view === "builder" ? (
<>
<TriggerCard
trigger={draft.definition.trigger}
options={options}
onChange={(trigger) =>
setDefinition({ ...draft.definition, trigger })
}
/>
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">
{t(`${base}.steps`)}
</Label>
{draft.definition.steps.length === 0 && (
<p className="text-xs text-muted-foreground">
{t(`${base}.noSteps`)}
</p>
)}
{renderSteps(draft.definition.steps, setSteps, 0)}
</div>
</>
) : (
<div className="space-y-2">
<textarea
className="w-full min-h-64 bg-background border border-border p-2 font-mono text-xs"
value={jsonText}
onChange={(e) => {
setJsonText(e.target.value);
try {
const parsed = JSON.parse(e.target.value);
setDefinition(parsed as AutomationDefinition);
setJsonError(null);
} catch (error) {
setJsonError(
error instanceof Error ? error.message : String(error),
);
}
}}
/>
{jsonError && <p className="text-xs text-destructive">{jsonError}</p>}
</div>
)}
</div>
);
}
@@ -0,0 +1,137 @@
import { useTranslation } from "@termix/plugin-sdk/frontend";
import {
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@termix/plugin-sdk/ui";
import type { HostSelector } from "../../types";
import type { AutomationEditorOptions } from "./editor-types";
/**
* Picks which hosts a step acts on. "The host that triggered this" is the
* common case, so it is offered first and used as the default.
*/
export function HostSelectorField({
value,
options,
onChange,
allowTrigger = true,
}: {
value: HostSelector | undefined;
options: AutomationEditorOptions;
onChange: (next: HostSelector) => void;
allowTrigger?: boolean;
}) {
const { t } = useTranslation();
const base = "newUi.sidebar.automations";
const kind = value?.kind ?? (allowTrigger ? "trigger" : "host");
return (
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">
{t(`${base}.fields.host`)}
</Label>
<div className="flex gap-2">
<Select
value={kind}
onValueChange={(next) =>
onChange(defaultFor(next as HostSelector["kind"]))
}
>
<SelectTrigger
size="sm"
className="min-w-0 flex-1 rounded-none border-border"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{allowTrigger && (
<SelectItem value="trigger">
{t(`${base}.hostSelector.trigger`)}
</SelectItem>
)}
<SelectItem value="host">
{t(`${base}.hostSelector.host`)}
</SelectItem>
{(options.providers.fleets || kind === "fleet") && (
<SelectItem value="fleet">
{t(`${base}.hostSelector.fleet`)}
</SelectItem>
)}
<SelectItem value="all">{t(`${base}.hostSelector.all`)}</SelectItem>
</SelectContent>
</Select>
{kind === "host" && (
<Select
value={
value?.kind === "host" && value.hostId ? String(value.hostId) : ""
}
onValueChange={(next) =>
onChange({ kind: "host", hostId: Number(next) })
}
>
<SelectTrigger
size="sm"
className="min-w-0 flex-1 rounded-none border-border"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{options.hosts.map((host) => (
<SelectItem key={host.id} value={String(host.id)}>
{host.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{kind === "fleet" && (
<Select
value={
value?.kind === "fleet" && value.fleetId
? String(value.fleetId)
: ""
}
onValueChange={(next) =>
onChange({ kind: "fleet", fleetId: Number(next) })
}
>
<SelectTrigger
size="sm"
className="min-w-0 flex-1 rounded-none border-border"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{options.fleets.map((fleet) => (
<SelectItem key={fleet.id} value={String(fleet.id)}>
{fleet.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
</div>
);
}
function defaultFor(kind: HostSelector["kind"]): HostSelector {
switch (kind) {
case "host":
return { kind: "host", hostId: 0 };
case "fleet":
return { kind: "fleet", fleetId: 0 };
case "hosts":
return { kind: "hosts", hostIds: [] };
case "all":
return { kind: "all" };
default:
return { kind: "trigger" };
}
}
@@ -0,0 +1,597 @@
import { useTranslation } from "@termix/plugin-sdk/frontend";
import {
Button,
Input,
Label,
Textarea,
Switch,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@termix/plugin-sdk/ui";
import { ChevronDown, ChevronUp, Trash2 } from "lucide-react";
import type { Step, StepType } from "../../types";
import { HostSelectorField } from "./HostSelectorField";
import {
missingStepProvider,
type AutomationEditorOptions,
} from "./editor-types";
const STEP_TYPES: StepType[] = [
"notify",
"http",
"run_command",
"run_snippet",
"docker",
"tunnel",
"wol",
"wait",
"set_var",
"if",
"stop",
];
interface StepBlockProps {
step: Step;
index: number;
total: number;
depth: number;
options: AutomationEditorOptions;
onChange: (next: Step) => void;
onRemove: () => void;
onMove: (direction: -1 | 1) => void;
renderNested: (
steps: Step[],
onChange: (next: Step[]) => void,
depth: number,
) => React.ReactNode;
}
/**
* One block in the automation's step list. Each step type renders only the
* fields it actually uses, so the form stays readable as more types are added.
*/
export function StepBlock({
step,
index,
total,
depth,
options,
onChange,
onRemove,
onMove,
renderNested,
}: StepBlockProps) {
const { t } = useTranslation();
const base = "newUi.sidebar.automations";
function patch(changes: Partial<Step>) {
onChange({ ...step, ...changes } as Step);
}
return (
<div
className={`border border-border p-3 ${depth > 0 ? "border-l-2 border-l-primary/40" : ""}`}
>
<div className="flex items-center gap-2 mb-3">
<Select
value={step.type}
onValueChange={(value) =>
onChange(changeStepType(step, value as StepType))
}
>
<SelectTrigger
size="sm"
className="flex-1 rounded-none border-border"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{STEP_TYPES.map((type) => {
const needs = missingStepProvider(type, options.providers);
// A type whose plugin is off is only listed when this step
// already uses it, marked as needing that plugin.
if (needs && type !== step.type) return null;
return (
<SelectItem key={type} value={type}>
{t(`${base}.stepTypes.${type}`)}
{needs
? ` (${t(`${base}.needsPlugin`, { plugin: needs })})`
: ""}
</SelectItem>
);
})}
</SelectContent>
</Select>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-none"
disabled={index === 0}
title={t(`${base}.moveUp`)}
onClick={() => onMove(-1)}
>
<ChevronUp size={14} />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-none"
disabled={index === total - 1}
title={t(`${base}.moveDown`)}
onClick={() => onMove(1)}
>
<ChevronDown size={14} />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-none"
title={t(`${base}.removeStep`)}
onClick={onRemove}
>
<Trash2 size={14} />
</Button>
</div>
<div className="space-y-3">
{step.type === "notify" && (
<>
<Field label={t(`${base}.fields.title`)}>
<Input
className="h-8 rounded-none border-border"
value={step.title ?? ""}
onChange={(e) =>
patch({ title: e.target.value } as Partial<Step>)
}
/>
</Field>
<Field label={t(`${base}.fields.message`)}>
<Textarea
className="rounded-none border-border min-h-16"
value={step.body ?? ""}
onChange={(e) =>
patch({ body: e.target.value } as Partial<Step>)
}
/>
</Field>
<Field label={t(`${base}.fields.channels`)}>
<div className="flex flex-wrap gap-2">
{options.channels.length === 0 && (
<span className="text-xs text-muted-foreground">
{t(`${base}.noChannelsHint`)}
</span>
)}
{options.channels.map((channel) => {
const checked = step.channelIds?.includes(channel.id);
return (
<button
key={channel.id}
type="button"
onClick={() =>
patch({
channelIds: checked
? step.channelIds.filter((id) => id !== channel.id)
: [...(step.channelIds ?? []), channel.id],
} as Partial<Step>)
}
className={`px-2 py-1 text-xs border ${
checked
? "border-primary text-foreground"
: "border-border text-muted-foreground"
}`}
>
{channel.name}
</button>
);
})}
</div>
</Field>
</>
)}
{step.type === "http" && (
<>
<div className="flex gap-2">
<Select
value={step.method}
onValueChange={(value) =>
patch({ method: value } as Partial<Step>)
}
>
<SelectTrigger
size="sm"
className="w-28 rounded-none border-border"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{["GET", "POST", "PUT", "PATCH", "DELETE"].map((method) => (
<SelectItem key={method} value={method}>
{method}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
className="h-8 flex-1 rounded-none border-border"
placeholder="https://"
value={step.url ?? ""}
onChange={(e) =>
patch({ url: e.target.value } as Partial<Step>)
}
/>
</div>
<Field label={t(`${base}.fields.body`)}>
<Textarea
className="rounded-none border-border min-h-16 font-mono text-xs"
value={step.body ?? ""}
onChange={(e) =>
patch({ body: e.target.value } as Partial<Step>)
}
/>
</Field>
<label className="flex items-start gap-2 text-xs text-muted-foreground">
<Switch
checked={!!step.allowPrivateNetwork}
onCheckedChange={(checked) =>
patch({ allowPrivateNetwork: checked } as Partial<Step>)
}
/>
<span>
{t(`${base}.fields.allowPrivateNetwork`)}
<span className="block">
{t(`${base}.fields.allowPrivateNetworkHint`)}
</span>
</span>
</label>
</>
)}
{step.type === "run_command" && (
<>
<HostSelectorField
value={step.hostSelector}
options={options}
onChange={(hostSelector) =>
patch({ hostSelector } as Partial<Step>)
}
/>
<Field label={t(`${base}.fields.command`)}>
<Textarea
className="rounded-none border-border min-h-16 font-mono text-xs"
value={step.command ?? ""}
onChange={(e) =>
patch({ command: e.target.value } as Partial<Step>)
}
/>
</Field>
<ElevatedToggle
checked={!!step.elevated}
onChange={(elevated) => patch({ elevated } as Partial<Step>)}
/>
</>
)}
{step.type === "run_snippet" && (
<>
<HostSelectorField
value={step.hostSelector}
options={options}
onChange={(hostSelector) =>
patch({ hostSelector } as Partial<Step>)
}
/>
<Field label={t(`${base}.fields.snippet`)}>
<Select
value={step.snippetId ? String(step.snippetId) : ""}
onValueChange={(value) =>
patch({ snippetId: Number(value) } as Partial<Step>)
}
>
<SelectTrigger size="sm" className="rounded-none border-border">
<SelectValue />
</SelectTrigger>
<SelectContent>
{options.snippets.map((snippet) => (
<SelectItem key={snippet.id} value={String(snippet.id)}>
{snippet.name}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<ElevatedToggle
checked={!!step.elevated}
onChange={(elevated) => patch({ elevated } as Partial<Step>)}
/>
</>
)}
{step.type === "docker" && (
<>
<HostSelectorField
value={step.hostSelector}
options={options}
onChange={(hostSelector) =>
patch({ hostSelector } as Partial<Step>)
}
/>
<div className="flex gap-2">
<Select
value={step.action}
onValueChange={(value) =>
patch({ action: value } as Partial<Step>)
}
>
<SelectTrigger
size="sm"
className="min-w-0 flex-1 rounded-none border-border"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{["start", "stop", "restart"].map((action) => (
<SelectItem key={action} value={action}>
{action}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
className="h-8 flex-1 rounded-none border-border"
placeholder={t(`${base}.fields.container`)}
value={step.container ?? ""}
onChange={(e) =>
patch({ container: e.target.value } as Partial<Step>)
}
/>
</div>
</>
)}
{step.type === "tunnel" && (
<div className="flex gap-2">
<Select
value={step.action}
onValueChange={(value) =>
patch({ action: value } as Partial<Step>)
}
>
<SelectTrigger
size="sm"
className="min-w-0 flex-1 rounded-none border-border"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="connect">connect</SelectItem>
<SelectItem value="disconnect">disconnect</SelectItem>
</SelectContent>
</Select>
<Input
className="h-8 flex-1 rounded-none border-border"
value={step.tunnelName ?? ""}
onChange={(e) =>
patch({ tunnelName: e.target.value } as Partial<Step>)
}
/>
</div>
)}
{step.type === "wol" && (
<Field label={t(`${base}.fields.host`)}>
<Select
value={step.hostId ? String(step.hostId) : ""}
onValueChange={(value) =>
patch({ hostId: Number(value) } as Partial<Step>)
}
>
<SelectTrigger size="sm" className="rounded-none border-border">
<SelectValue />
</SelectTrigger>
<SelectContent>
{options.hosts.map((host) => (
<SelectItem key={host.id} value={String(host.id)}>
{host.name}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
)}
{step.type === "wait" && (
<Field label={t(`${base}.fields.seconds`)}>
<Input
type="number"
min={1}
className="h-8 w-32 rounded-none border-border"
value={step.seconds ?? 0}
onChange={(e) =>
patch({ seconds: Number(e.target.value) } as Partial<Step>)
}
/>
</Field>
)}
{step.type === "set_var" && (
<div className="flex gap-2">
<Input
className="h-8 w-40 rounded-none border-border"
placeholder={t(`${base}.fields.variableName`)}
value={step.name ?? ""}
onChange={(e) => patch({ name: e.target.value } as Partial<Step>)}
/>
<Input
className="h-8 flex-1 rounded-none border-border"
placeholder={t(`${base}.fields.variableValue`)}
value={step.value ?? ""}
onChange={(e) =>
patch({ value: e.target.value } as Partial<Step>)
}
/>
</div>
)}
{step.type === "if" && (
<div className="space-y-3">
<div className="flex gap-2">
<Input
className="h-8 flex-1 rounded-none border-border font-mono text-xs"
placeholder={t(`${base}.fields.conditionLeftPlaceholder`)}
value={step.condition?.left ?? ""}
onChange={(e) =>
patch({
condition: { ...step.condition, left: e.target.value },
} as Partial<Step>)
}
/>
<Select
value={step.condition?.operator ?? "=="}
onValueChange={(value) =>
patch({
condition: { ...step.condition, operator: value },
} as Partial<Step>)
}
>
<SelectTrigger
size="sm"
className="w-28 rounded-none border-border"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{[">", "<", ">=", "<=", "==", "!=", "contains"].map((op) => (
<SelectItem key={op} value={op}>
{op}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
className="h-8 flex-1 rounded-none border-border font-mono text-xs"
placeholder={t(`${base}.fields.conditionRightPlaceholder`)}
value={step.condition?.right ?? ""}
onChange={(e) =>
patch({
condition: { ...step.condition, right: e.target.value },
} as Partial<Step>)
}
/>
</div>
{renderNested(
step.then ?? [],
(next) => patch({ then: next } as Partial<Step>),
depth + 1,
)}
</div>
)}
{step.type !== "stop" && (
<Field label={t(`${base}.fields.onError`)}>
<Select
value={step.onError ?? "stop"}
onValueChange={(value) =>
patch({ onError: value } as Partial<Step>)
}
>
<SelectTrigger
size="sm"
className="min-w-0 w-full rounded-none border-border"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="stop">
{t(`${base}.onError.stop`)}
</SelectItem>
<SelectItem value="continue">
{t(`${base}.onError.continue`)}
</SelectItem>
</SelectContent>
</Select>
</Field>
)}
</div>
</div>
);
}
function Field({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">{label}</Label>
{children}
</div>
);
}
function ElevatedToggle({
checked,
onChange,
}: {
checked: boolean;
onChange: (value: boolean) => void;
}) {
const { t } = useTranslation();
return (
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<Switch checked={checked} onCheckedChange={onChange} />
{t("newUi.sidebar.automations.fields.elevated")}
</label>
);
}
/** Keeps the step id when the type changes, so run history stays linked. */
function changeStepType(step: Step, type: StepType): Step {
const next: Record<string, unknown> = { id: step.id, type };
switch (type) {
case "notify":
next.channelIds = [];
break;
case "http":
next.method = "POST";
next.url = "";
break;
case "run_command":
next.command = "";
next.hostSelector = { kind: "trigger" };
break;
case "run_snippet":
next.hostSelector = { kind: "trigger" };
break;
case "docker":
next.action = "restart";
next.container = "";
next.hostSelector = { kind: "trigger" };
break;
case "tunnel":
next.action = "connect";
next.tunnelName = "";
break;
case "wait":
next.seconds = 30;
break;
case "set_var":
next.name = "";
next.value = "";
break;
case "if":
next.condition = { left: "", operator: "==", right: "" };
next.then = [];
break;
default:
break;
}
return next as unknown as Step;
}
@@ -0,0 +1,437 @@
import { useTranslation } from "@termix/plugin-sdk/frontend";
import {
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@termix/plugin-sdk/ui";
import type { Trigger, TriggerKind } from "../../types";
import { HostSelectorField } from "./HostSelectorField";
import {
missingTriggerProvider,
type AutomationEditorOptions,
} from "./editor-types";
const TRIGGER_KINDS: TriggerKind[] = [
"metric_threshold",
"host_status",
"health_check",
"schedule",
"docker_event",
"webhook",
];
/** Sentinel for "no zone set", which means the server's own time. */
const SERVER_TIMEZONE = "__server__";
/**
* Zones offered for a cron schedule. Uses the runtime's own list where it is
* available so the picker is complete, with a small fallback for older
* browsers that do not expose supportedValuesOf.
*/
const TIMEZONES: string[] = (() => {
try {
const supported = (
Intl as unknown as { supportedValuesOf?: (key: string) => string[] }
).supportedValuesOf?.("timeZone");
if (supported?.length) return supported;
} catch {
// Fall through to the short list.
}
return [
"UTC",
"America/New_York",
"America/Chicago",
"America/Denver",
"America/Los_Angeles",
"Europe/London",
"Europe/Berlin",
"Europe/Moscow",
"Asia/Kolkata",
"Asia/Shanghai",
"Asia/Tokyo",
"Australia/Sydney",
];
})();
const METRIC_PATHS = [
"cpu.percent",
"memory.percent",
"disk.percent",
"temperature.highestCelsius",
"cpu.load1",
"processes.total",
"network.rxRateBps",
"network.txRateBps",
];
/** The single trigger at the top of an automation. */
export function TriggerCard({
trigger,
options,
onChange,
}: {
trigger: Trigger;
options: AutomationEditorOptions;
onChange: (next: Trigger) => void;
}) {
const { t } = useTranslation();
const base = "newUi.sidebar.automations";
function patch(changes: Record<string, unknown>) {
onChange({ ...trigger, ...changes } as Trigger);
}
return (
<div className="border border-border p-3 space-y-3">
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">
{t(`${base}.trigger`)}
</Label>
<Select
value={trigger.kind}
onValueChange={(value) =>
onChange(defaultTrigger(value as TriggerKind))
}
>
<SelectTrigger size="sm" className="rounded-none border-border">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TRIGGER_KINDS.map((kind) => {
const needs = missingTriggerProvider(kind, options.providers);
// A kind whose plugin is off is only listed when this trigger
// already uses it, marked as needing that plugin.
if (needs && kind !== trigger.kind) return null;
return (
<SelectItem key={kind} value={kind}>
{t(`${base}.triggerKinds.${kind}`)}
{needs
? ` (${t(`${base}.needsPlugin`, { plugin: needs })})`
: ""}
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
{trigger.kind === "metric_threshold" && (
<>
<HostSelectorField
value={trigger.hostSelector}
options={options}
allowTrigger={false}
onChange={(hostSelector) => patch({ hostSelector })}
/>
<div className="flex gap-2">
<Select
value={trigger.metric?.path ?? "cpu.percent"}
onValueChange={(value) =>
patch({ metric: { ...trigger.metric, path: value } })
}
>
<SelectTrigger
size="sm"
className="flex-1 rounded-none border-border"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{METRIC_PATHS.map((path) => (
<SelectItem key={path} value={path}>
{path}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={trigger.operator}
onValueChange={(value) => patch({ operator: value })}
>
<SelectTrigger
size="sm"
className="w-20 rounded-none border-border"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{[">", ">=", "<", "<=", "==", "!="].map((op) => (
<SelectItem key={op} value={op}>
{op}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
type="number"
className="h-8 w-24 rounded-none border-border"
value={trigger.value ?? 0}
onChange={(e) => patch({ value: Number(e.target.value) })}
/>
</div>
{trigger.metric?.path === "disk.percent" && (
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">
{t(`${base}.fields.mount`)}
</Label>
<Input
className="h-8 rounded-none border-border font-mono text-xs"
placeholder="/data"
value={trigger.metric?.mount ?? ""}
onChange={(e) =>
patch({
metric: {
...trigger.metric,
mount: e.target.value || undefined,
},
})
}
/>
</div>
)}
{trigger.metric?.path.startsWith("network.") &&
"iface" in trigger.metric && (
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">
{t(`${base}.fields.interface`)}
</Label>
<Input
className="h-8 rounded-none border-border font-mono text-xs"
placeholder="eth0"
value={trigger.metric.iface ?? ""}
onChange={(e) =>
patch({
metric: {
...trigger.metric,
iface: e.target.value || undefined,
},
})
}
/>
</div>
)}
<div className="flex gap-2">
<NumberField
label={t(`${base}.fields.forSeconds`)}
value={trigger.forSeconds ?? 0}
onChange={(value) => patch({ forSeconds: value })}
/>
<NumberField
label={t(`${base}.fields.cooldown`)}
value={trigger.cooldownMinutes ?? 15}
onChange={(value) => patch({ cooldownMinutes: value })}
/>
</div>
</>
)}
{(trigger.kind === "host_status" || trigger.kind === "health_check") && (
<>
<HostSelectorField
value={trigger.hostSelector}
options={options}
allowTrigger={false}
onChange={(hostSelector) => patch({ hostSelector })}
/>
<Select
value={trigger.to}
onValueChange={(value) => patch({ to: value })}
>
<SelectTrigger size="sm" className="rounded-none border-border">
<SelectValue />
</SelectTrigger>
<SelectContent>
{trigger.kind === "host_status" ? (
<>
<SelectItem value="offline">offline</SelectItem>
<SelectItem value="online">online</SelectItem>
</>
) : (
<>
<SelectItem value="failing">failing</SelectItem>
<SelectItem value="recovered">recovered</SelectItem>
</>
)}
</SelectContent>
</Select>
<NumberField
label={t(`${base}.fields.cooldown`)}
value={trigger.cooldownMinutes ?? 15}
onChange={(value) => patch({ cooldownMinutes: value })}
/>
</>
)}
{trigger.kind === "schedule" && (
<div className="space-y-3">
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">
{t(`${base}.fields.cron`)}
</Label>
<Input
className="h-8 rounded-none border-border font-mono text-xs"
placeholder="0 2 * * *"
value={trigger.cron ?? ""}
onChange={(e) =>
patch({ cron: e.target.value, intervalSeconds: undefined })
}
/>
</div>
<NumberField
label={t(`${base}.fields.intervalSeconds`)}
value={trigger.intervalSeconds ?? 0}
onChange={(value) =>
patch({ intervalSeconds: value || undefined, cron: undefined })
}
/>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">
{t(`${base}.fields.timezone`)}
</Label>
<Select
value={trigger.timezone ?? SERVER_TIMEZONE}
onValueChange={(value) =>
patch({
timezone: value === SERVER_TIMEZONE ? undefined : value,
})
}
>
<SelectTrigger
size="sm"
className="w-full rounded-none border-border"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={SERVER_TIMEZONE}>
{t(`${base}.fields.timezoneServer`)}
</SelectItem>
{TIMEZONES.map((zone) => (
<SelectItem key={zone} value={zone}>
{zone}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{t(`${base}.fields.timezoneHint`)}
</p>
</div>
</div>
)}
{trigger.kind === "docker_event" && (
<>
<HostSelectorField
value={trigger.hostSelector}
options={options}
allowTrigger={false}
onChange={(hostSelector) => patch({ hostSelector })}
/>
<div className="flex gap-2">
<Input
className="h-8 flex-1 rounded-none border-border"
placeholder={t(`${base}.fields.container`)}
value={trigger.container ?? ""}
onChange={(e) => patch({ container: e.target.value })}
/>
<Select
value={trigger.event}
onValueChange={(value) => patch({ event: value })}
>
<SelectTrigger
size="sm"
className="min-w-0 flex-1 rounded-none border-border"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{["exited", "started", "unhealthy", "restarting"].map((e) => (
<SelectItem key={e} value={e}>
{e}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</>
)}
{trigger.kind === "webhook" && (
<p className="text-xs text-muted-foreground">
{t(`${base}.webhookTokenDescription`)}
</p>
)}
</div>
);
}
function NumberField({
label,
value,
onChange,
}: {
label: string;
value: number;
onChange: (value: number) => void;
}) {
return (
<div className="space-y-1 flex-1">
<Label className="text-xs text-muted-foreground">{label}</Label>
<Input
type="number"
min={0}
className="h-8 rounded-none border-border"
value={value}
onChange={(e) => onChange(Number(e.target.value))}
/>
</div>
);
}
export function defaultTrigger(kind: TriggerKind): Trigger {
switch (kind) {
case "host_status":
return {
kind: "host_status",
hostSelector: { kind: "all" },
to: "offline",
cooldownMinutes: 15,
};
case "health_check":
return {
kind: "health_check",
hostSelector: { kind: "all" },
to: "failing",
cooldownMinutes: 15,
};
case "schedule":
return { kind: "schedule", cron: "0 2 * * *" };
case "docker_event":
return {
kind: "docker_event",
hostSelector: { kind: "all" },
event: "exited",
cooldownMinutes: 15,
};
case "webhook":
return { kind: "webhook", tokenHash: "" };
default:
return {
kind: "metric_threshold",
hostSelector: { kind: "all" },
metric: { path: "cpu.percent" },
operator: ">",
value: 90,
forSeconds: 300,
cooldownMinutes: 15,
};
}
}
@@ -0,0 +1,72 @@
import type { StepType, TriggerKind } from "../../types";
import type { AutomationProviders } from "../automations-api";
/** Lookup data the editor needs to turn ids into names. */
export interface AutomationEditorOptions {
hosts: Array<{ id: number; name: string }>;
snippets: Array<{ id: number; name: string }>;
channels: Array<{ id: number; name: string }>;
fleets: Array<{ id: number; name: string }>;
/** Which optional plugins are running. */
providers: AutomationProviders;
}
export const EMPTY_EDITOR_OPTIONS: AutomationEditorOptions = {
hosts: [],
snippets: [],
channels: [],
fleets: [],
providers: {
snippets: false,
fleets: false,
tunnels: false,
docker: false,
"docker-events": false,
"host-metrics": false,
"wake-on-lan": false,
},
};
/** Short unique id for a new step, stable for the life of the automation. */
export function newStepId(): string {
return `s${Math.random().toString(36).slice(2, 8)}`;
}
/** The plugin a trigger kind needs, when it is not core. */
const TRIGGER_PROVIDERS: Partial<
Record<TriggerKind, keyof AutomationProviders>
> = {
metric_threshold: "host-metrics",
health_check: "host-metrics",
docker_event: "docker-events",
};
/** The plugin a step type needs, when it is not core. */
const STEP_PROVIDERS: Partial<Record<StepType, keyof AutomationProviders>> = {
run_snippet: "snippets",
docker: "docker",
tunnel: "tunnels",
wol: "wake-on-lan",
};
/** The plugin id shown in "needs <plugin>", for a provider key. */
export function providerPlugin(key: keyof AutomationProviders): string {
return key === "docker-events" ? "docker" : key;
}
/** The provider a kind needs that is not running, or null when it can run. */
export function missingTriggerProvider(
kind: TriggerKind,
providers: AutomationProviders,
): string | null {
const key = TRIGGER_PROVIDERS[kind];
return key && !providers[key] ? providerPlugin(key) : null;
}
export function missingStepProvider(
type: StepType,
providers: AutomationProviders,
): string | null {
const key = STEP_PROVIDERS[type];
return key && !providers[key] ? providerPlugin(key) : null;
}
@@ -0,0 +1,84 @@
import { Workflow, Wrench } from "lucide-react";
import type {
PanelProps,
TabProps,
TermixApp,
} from "@termix/plugin-sdk/frontend";
import { AutomationsPanel } from "./AutomationsPanel";
import { createAutomationsApi } from "./automations-api";
import { createMaintenanceStore } from "./maintenance-store";
import { HostMaintenance, MaintenanceBadge } from "./HostMaintenance";
const VIEW_ID = "automations";
function Panel({ active, setEditing }: PanelProps) {
return (
<div className="flex-1 min-h-0 overflow-y-auto">
<AutomationsPanel active={active} onEditingChange={setEditing} />
</div>
);
}
function AutomationsTab({ isVisible }: TabProps) {
return <AutomationsPanel active={isVisible} />;
}
export function activate(app: TermixApp): void {
const maintenance = createMaintenanceStore(app.api);
app.onDispose(() => maintenance.dispose());
app.registerTab(
"host_maintenance",
({ host, isVisible }) =>
host ? (
<HostMaintenance host={host} store={maintenance} visible={isVisible} />
) : null,
{
icon: Wrench,
titleKey: "maintenance.title",
},
);
app.registerHostAction({
id: "maintenance",
titleKey: "maintenance.title",
icon: Wrench,
kind: "open",
tabType: "host_maintenance",
tray: false,
when: (host) => Number(host.id) > 0,
});
app.registerHostBadge({
id: "maintenance",
when: (host) => Number(host.id) > 0,
component: ({ host }) => (
<MaintenanceBadge host={host} store={maintenance} />
),
});
app.registerRailItem({
id: VIEW_ID,
icon: Workflow,
titleKey: "nav.automations",
promotable: true,
after: "macros",
order: 30,
});
app.registerPanel(VIEW_ID, Panel);
app.registerTab(VIEW_ID, AutomationsTab, {
icon: Workflow,
titleKey: "nav.automations",
singleton: true,
hostless: true,
panelFrame: true,
});
// The assistant's @-mentions list automations through this.
const api = createAutomationsApi(app.api);
app.registerAction("automations.list", () => api.list());
app.registerSlotContribution("onboarding.features", {
actionId: "automations.feature",
titleKey: "onboarding.feature_automations",
descriptionKey: "onboarding.feature_automations_desc",
icon: Workflow,
});
}
@@ -0,0 +1,85 @@
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
import type { HostMaintenance } from "../maintenance";
export function createMaintenanceStore(api: PluginApiClient) {
let snapshot: Record<number, HostMaintenance> = {};
const listeners = new Set<() => void>();
let timer: ReturnType<typeof setInterval> | undefined;
let loading = false;
let disposed = false;
let revision = 0;
let error = "";
let loaded = false;
const emit = () => listeners.forEach((listener) => listener());
async function refresh() {
if (loading || disposed || document.hidden) return;
loading = true;
const version = revision;
try {
const { data } =
await api.get<Array<{ hostId: number; state: HostMaintenance }>>(
"/maintenance",
);
if (!disposed && version === revision) {
const next = Object.fromEntries(
data.map(({ hostId, state }) => [hostId, state]),
);
// Most polls change nothing; skip the emit so badges do not re-render.
const unchanged =
loaded && !error && JSON.stringify(next) === JSON.stringify(snapshot);
error = "";
loaded = true;
if (unchanged) return;
snapshot = next;
emit();
}
} catch {
if (!disposed && version === revision) {
error = "maintenance.loadFailed";
snapshot = { ...snapshot };
emit();
}
} finally {
loading = false;
}
}
return {
snapshot: () => snapshot,
error: () => error,
loaded: () => loaded,
subscribe(listener: () => void) {
listeners.add(listener);
if (!timer) {
void refresh();
timer = setInterval(() => void refresh(), 15_000);
document.addEventListener("visibilitychange", refresh);
}
return () => {
listeners.delete(listener);
if (!listeners.size) {
clearInterval(timer);
timer = undefined;
document.removeEventListener("visibilitychange", refresh);
}
};
},
async edit(hostId: number, input: Record<string, unknown>) {
const { data } = await api.post<HostMaintenance>(
`/maintenance/${hostId}`,
input,
);
revision++;
snapshot = { ...snapshot, [hostId]: data };
emit();
return data;
},
refresh,
dispose() {
disposed = true;
clearInterval(timer);
document.removeEventListener("visibilitychange", refresh);
listeners.clear();
},
};
}
export type MaintenanceStore = ReturnType<typeof createMaintenanceStore>;
+33
View File
@@ -0,0 +1,33 @@
export type Recurrence = "once" | "weekly" | "monthly";
export interface MaintenancePlan {
id: string;
reason: string;
start: string;
durationMinutes: number;
recurrence: Recurrence;
graceMinutes: number;
notifyOverdue: boolean;
nextStart: string | null;
}
export interface ActiveMaintenance {
startedAt: string;
reasons: string[];
estimatedEnd: string;
graceMinutes: number;
notifyOverdue: boolean;
notified: boolean;
}
export interface HostMaintenance {
active: ActiveMaintenance | null;
plans: MaintenancePlan[];
/** Set on API responses: false when the host was shared with the caller. */
owned?: boolean;
}
export const emptyMaintenance = (): HostMaintenance => ({
active: null,
plans: [],
});
+286
View File
@@ -0,0 +1,286 @@
/**
* Shared automation model used by both the backend engine and the editor UI.
* An automation is one trigger plus an ordered list of steps. The whole
* definition is stored as JSON on the automations row, so this file is the
* only contract describing that blob.
*/
export const AUTOMATION_DEFINITION_VERSION = 1;
/** Which hosts a trigger watches or a step acts on. */
export type HostSelector =
| { kind: "host"; hostId: number }
| { kind: "hosts"; hostIds: number[] }
| { kind: "fleet"; fleetId: number }
| { kind: "all" }
/** The host that produced the event. Only valid for host-scoped triggers. */
| { kind: "trigger" };
/**
* A metric to compare against. Paths mirror the shape returned by
* collectMetrics(). mount/iface pick one entry out of a per-instance list so a
* rule can watch a single filesystem rather than the aggregate.
*/
export type MetricPath =
| { path: "cpu.percent" }
| { path: "cpu.load1" }
| { path: "cpu.load5" }
| { path: "cpu.load15" }
| { path: "memory.percent" }
| { path: "memory.usedGiB" }
| { path: "disk.percent"; mount?: string }
| { path: "disk.availableBytes"; mount?: string }
| { path: "temperature.highestCelsius" }
| { path: "uptime.seconds" }
| { path: "processes.total" }
| { path: "network.rxBytes"; iface?: string }
| { path: "network.txBytes"; iface?: string }
| { path: "network.rxRateBps"; iface?: string }
| { path: "network.txRateBps"; iface?: string };
export type Operator =
| ">"
| "<"
| ">="
| "<="
| "=="
| "!="
| "contains"
| "not_contains"
| "changed";
export type Severity = "info" | "warning" | "critical";
export type HostStatusState = "online" | "offline";
export type HealthCheckState = "failing" | "recovered";
export type DockerEventKind = "exited" | "started" | "unhealthy" | "restarting";
export type InternalEventKind =
| "user_login"
| "host_added"
| "host_deleted"
| "tunnel_disconnected"
| "automation_failed";
export type Trigger =
| {
kind: "metric_threshold";
hostSelector: HostSelector;
metric: MetricPath;
operator: Operator;
value: number;
/** Sustained breach window before firing. 0 fires immediately. */
forSeconds?: number;
cooldownMinutes: number;
severity?: Severity;
}
| {
kind: "host_status";
hostSelector: HostSelector;
to: HostStatusState;
cooldownMinutes: number;
}
| {
kind: "health_check";
hostSelector: HostSelector;
checkId?: string;
to: HealthCheckState;
cooldownMinutes: number;
}
| {
kind: "schedule";
/** Five field cron. Ignored when intervalSeconds is set. */
cron?: string;
intervalSeconds?: number;
timezone?: string;
}
| {
kind: "docker_event";
hostSelector: HostSelector;
container?: string;
event: DockerEventKind;
cooldownMinutes: number;
}
| {
kind: "internal_event";
event: InternalEventKind;
hostSelector?: HostSelector;
cooldownMinutes: number;
}
| {
kind: "webhook";
/** Only the hash is persisted. The raw token is shown once on creation. */
tokenHash: string;
};
export type TriggerKind = Trigger["kind"];
/** A comparison used by `if` steps, evaluated against the run context. */
export interface Condition {
/** Template string, e.g. "{{steps.check.stdout}}" or "{{trigger.value}}". */
left: string;
operator: Operator;
/** Template string. Compared numerically when both sides parse as numbers. */
right?: string;
}
export type StepErrorPolicy = "stop" | "continue" | "branch";
interface StepBase {
/** Stable across edits so run history survives reordering. */
id: string;
name?: string;
enabled?: boolean;
onError?: StepErrorPolicy;
timeoutMs?: number;
}
export type Step =
| (StepBase & {
type: "notify";
channelIds: number[];
title?: string;
body?: string;
severity?: Severity;
})
| (StepBase & {
type: "http";
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
url: string;
headers?: Record<string, string>;
body?: string;
/** Opt in to LAN/private addresses. Off by default, see safe-outbound-fetch. */
allowPrivateNetwork?: boolean;
})
| (StepBase & {
type: "run_snippet";
snippetId: number;
hostSelector: HostSelector;
inputValues?: Record<string, string>;
elevated?: boolean;
})
| (StepBase & {
type: "run_command";
command: string;
hostSelector: HostSelector;
elevated?: boolean;
})
| (StepBase & {
type: "docker";
action: "start" | "stop" | "restart";
container: string;
hostSelector: HostSelector;
})
| (StepBase & {
type: "tunnel";
action: "connect" | "disconnect";
tunnelName: string;
})
| (StepBase & { type: "wol"; hostId: number })
| (StepBase & { type: "wait"; seconds: number })
| (StepBase & { type: "set_var"; name: string; value: string })
| (StepBase & {
type: "if";
condition: Condition;
then: Step[];
else?: Step[];
})
| (StepBase & { type: "run_automation"; automationId: number })
| (StepBase & { type: "stop"; status?: "success" | "failed" });
export type StepType = Step["type"];
export interface AutomationDefinition {
version: number;
trigger: Trigger;
steps: Step[];
}
export type ConcurrencyPolicy = "skip" | "queue" | "allow";
export type RunStatus =
"running" | "success" | "failed" | "timeout" | "skipped" | "cancelled";
export type StepStatus =
"pending" | "running" | "success" | "failed" | "skipped";
export interface Automation {
id: number;
userId: string;
name: string;
description: string | null;
enabled: boolean;
definition: AutomationDefinition;
concurrencyPolicy: ConcurrencyPolicy;
maxRunSeconds: number;
dryRun: boolean;
lastRunAt: string | null;
lastRunStatus: RunStatus | null;
createdAt: string;
updatedAt: string;
}
export interface AutomationRun {
id: number;
automationId: number;
userId: string;
triggerType: TriggerKind | "manual";
triggerContext: Record<string, unknown> | null;
status: RunStatus;
startedAt: string;
finishedAt: string | null;
durationMs: number | null;
error: string | null;
dryRun: boolean;
parentRunId: number | null;
}
export interface AutomationRunStep {
id: number;
runId: number;
stepIndex: number;
stepId: string;
stepType: StepType;
status: StepStatus;
startedAt: string;
finishedAt: string | null;
output: string | null;
error: string | null;
truncated: boolean;
}
/** Defaults applied when an automation does not override them. */
export const DEFAULT_MAX_RUN_SECONDS = 300;
export const DEFAULT_STEP_TIMEOUT_MS = 60_000;
export const DEFAULT_COOLDOWN_MINUTES = 15;
/** Guards run_automation against direct and mutual recursion. */
export const MAX_AUTOMATION_DEPTH = 5;
/** Step output beyond this is truncated before it reaches the database. */
export const MAX_STEP_OUTPUT_BYTES = 32_768;
export const OPERATOR_LABELS: Record<Operator, string> = {
">": "greater than",
"<": "less than",
">=": "greater than or equal to",
"<=": "less than or equal to",
"==": "equals",
"!=": "does not equal",
contains: "contains",
not_contains: "does not contain",
changed: "changed",
};
/** Steps that reach outside Termix and are therefore stubbed in a dry run. */
export const SIDE_EFFECTING_STEP_TYPES: readonly StepType[] = [
"notify",
"http",
"run_snippet",
"run_command",
"docker",
"tunnel",
"wol",
];
export function isSideEffectingStep(type: StepType): boolean {
return SIDE_EFFECTING_STEP_TYPES.includes(type);
}