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

+7
View File
@@ -0,0 +1,7 @@
# Changelog
## 1.0.0
First release, bundled with Termix 2.9.0.
Runs actions on your hosts on a schedule or when something happens, like a host going down, a metric crossing a limit, a container event or an incoming webhook. Each automation is a list of steps, such as running a command, sending an alert or calling a URL.
+51
View File
@@ -0,0 +1,51 @@
# Automations
Run actions on your hosts on a schedule or when something happens, like a host going down or a metric crossing a limit.
## Features
- Triggers: a schedule, a host going up or down, a metric crossing a limit, a health check change, a container event, a Termix event or an incoming webhook.
- Steps: send an alert, call a URL, run a snippet or command, control a container or tunnel, wake a host, wait, set a variable, branch with if / otherwise, run another automation or stop.
- Run an automation on the host that triggered it or on any host or fleet you pick.
- See the output and errors of each step for every run.
## Permissions
- `automations.view`: See automations and their runs. Admins and users have it by default.
- `automations.create`: Create automations. Admins and users have it by default.
- `automations.edit`: Change automations. Admins and users have it by default.
- `automations.delete`: Delete automations. Admins and users have it by default.
- `automations.run`: Run automations by hand. Admins and users have it by default.
## Services
Provides to other plugins:
- `automations.access`: list and run automations.
Uses from other plugins:
- `snippets.access` for the run snippet step. Required.
- `fleets.access`, `tunnels.access`, `docker.containers`, `docker.events`, `host-metrics.viewers` and `wake-on-lan.send` for their steps and triggers. Each one is optional.
## Development
```bash
npm run build # build into dist/
npm run test # run this plugin's tests
npm run typecheck # type-check this plugin
```
## Host maintenance
Open **Maintenance** from a saved host's actions. Its owner can start maintenance now, record a reason and estimated duration, or add a one-time, weekly or monthly schedule. Viewing requires `automations.view`; changes require `automations.edit`. Active maintenance appears as a host badge. The Automations plugin must remain enabled for maintenance scheduling and suppression to operate.
Schedules use UTC, including through daylight-saving changes. A monthly schedule for a day missing from a month skips that month. Up to 20 plans per host are stored; remove completed one-time plans when no longer needed. Planned windows cannot overlap (touching endpoints are allowed), and recurring conflicts are checked over the Gregorian calendar cycle. Removing a plan cancels future occurrences, not maintenance already in progress.
The estimated completion time is informational. Maintenance **only ends when its owner explicitly ends it**. If work overruns into another scheduled window, the host stays in maintenance and the estimate extends to the later planned end. After server downtime, due plans are caught up without briefly exposing an outage between maintenance windows. Each plan's next occurrence and the active state survive restarts.
While active, maintenance suppresses new host-status, health-check, metric-threshold, container-event and tunnel-disconnection automation runs for that host. This pauses the entire triggered automation, including any remediation steps. Monitoring continues to report the real status; observed status/health baselines still advance and metric dwell windows are reset. Manual runs, schedules, webhooks, security login events and runs already in progress continue normally. Maintenance does not globally silence alerts from unrelated plugins.
An optional overdue reminder uses the estimate plus a configurable grace period (20 minutes by default). It only sends after core has observed the host offline at or after that deadline; an unknown, online or older cached status does not qualify. Enable host status checks to supply those observations. Reminders use the Alerts hub and category `automations.maintenance_overdue`, which can be routed through the user's notification rules. A successful reminder is remembered across restarts; an extended estimate permits another reminder for the new deadline. No reminder automatically ends maintenance.
Maintenance rows reference their owner and host with cascading deletion. SQLite, PostgreSQL and MySQL migrations add only the new maintenance table.
+211
View File
@@ -0,0 +1,211 @@
{
"permissions": {
"view": {
"title": "View automations",
"description": "See the automations and their history."
},
"create": {
"title": "Create automations",
"description": "Add new automations."
},
"edit": {
"title": "Edit automations",
"description": "Change existing automations."
},
"delete": {
"title": "Delete automations",
"description": "Remove automations."
},
"run": {
"title": "Run automations",
"description": "Trigger an automation by hand."
}
},
"newUi": {
"sidebar": {
"automations": {
"title": "Automations",
"tabAutomations": "Automations",
"tabRuns": "Runs",
"tabChannels": "Channels",
"createAutomation": "New Automation",
"backToAutomations": "Back to Automations",
"createTitle": "New Automation",
"editTitle": "Edit Automation",
"empty": "No automations yet. Create one to react to a metric, a schedule, or an event.",
"emptyRuns": "No runs yet.",
"channelsHint": "Channels are set up in Alerts",
"noChannelsHint": "The alert always goes to your inbox. Add channels in Alerts to also send it somewhere else.",
"nameLabel": "Name",
"namePlaceholder": "e.g., Clean up /data when it fills",
"nameRequired": "Name is required",
"descriptionLabel": "Description",
"descriptionPlaceholder": "Optional description",
"enabledLabel": "Enabled",
"concurrencyLabel": "If it is already running",
"concurrencyHint": "What to do when this is triggered again before the last run has finished.",
"concurrency": {
"skip": "Skip the new run",
"queue": "Wait and run after",
"allow": "Run both at once"
},
"save": "Save",
"created": "Automation created",
"updated": "Automation saved",
"deleted": "Automation deleted",
"deleteConfirm": "Delete this automation? Its run history is deleted too.",
"runNow": "Run now",
"testRun": "Test run",
"testRunHint": "Runs every step but does not touch anything outside Termix.",
"runStarted": "Run started",
"runFinished": "Run finished: {{status}}",
"trigger": "Trigger",
"steps": "Steps",
"addStep": "Add step",
"removeStep": "Remove step",
"moveUp": "Move up",
"moveDown": "Move down",
"noSteps": "No steps yet. Add one to say what should happen.",
"webhookTokenTitle": "Webhook URL",
"webhookTokenDescription": "This is shown once. Save it now, it cannot be retrieved later.",
"copy": "Copy",
"copied": "Copied",
"viewJson": "JSON",
"viewBuilder": "Builder",
"viewGraph": "Graph",
"lastRun": "Last run",
"never": "Never",
"duration": "{{seconds}}s",
"stepOutput": "Output",
"stepError": "Error",
"dryRunBadge": "Test run",
"hostSelector": {
"trigger": "Triggering host",
"host": "Specific host",
"fleet": "Fleet",
"all": "All hosts"
},
"triggerKinds": {
"metric_threshold": "Metric threshold",
"host_status": "Host goes up or down",
"health_check": "Health check changes",
"schedule": "On a schedule",
"docker_event": "Container event",
"internal_event": "Termix event",
"webhook": "Incoming webhook"
},
"stepTypes": {
"notify": "Send an alert",
"http": "Call a URL",
"run_snippet": "Run a snippet",
"run_command": "Run a command",
"docker": "Control a container",
"tunnel": "Control a tunnel",
"wol": "Wake a host",
"wait": "Wait",
"set_var": "Set a variable",
"if": "If / otherwise",
"run_automation": "Run another automation",
"stop": "Stop"
},
"statuses": {
"running": "Running",
"success": "Success",
"failed": "Failed",
"timeout": "Timed out",
"skipped": "Skipped",
"cancelled": "Cancelled"
},
"fields": {
"host": "Host",
"metric": "Metric",
"mount": "Mount point",
"operator": "Is",
"value": "Value",
"forSeconds": "Sustained for (seconds)",
"cooldown": "Cooldown (minutes)",
"cron": "Cron expression",
"intervalSeconds": "Every (seconds)",
"timezone": "Time zone",
"timezoneServer": "Server time",
"timezoneHint": "Cron times are read in this zone. Does not apply to interval schedules.",
"container": "Container",
"command": "Command",
"url": "URL",
"method": "Method",
"body": "Body",
"channels": "Also send to",
"title": "Title",
"message": "Message",
"snippet": "Snippet",
"seconds": "Seconds",
"conditionLeftPlaceholder": "Value to check",
"conditionRightPlaceholder": "Compare against",
"variableName": "Variable name",
"variableValue": "Value",
"elevated": "Run with sudo",
"onError": "If this fails",
"allowPrivateNetwork": "Allow local network addresses",
"allowPrivateNetworkHint": "Needed for a self-hosted service on your LAN. Off by default to prevent requests to internal addresses."
},
"onError": {
"stop": "Stop the run",
"continue": "Carry on"
},
"needsPlugin": "Needs {{plugin}}",
"needsPluginHint": "This automation is skipped until the feature it needs is available again.",
"offBadge": "Off",
"docsLink": "View docs",
"ago": {
"seconds": "{{count}}s ago",
"minutes": "{{count}}m ago",
"hours": "{{count}}h ago",
"days": "{{count}}d ago"
}
}
}
},
"nav": {
"automations": "Automations"
},
"onboarding": {
"feature_automations": "Automations",
"feature_automations_desc": "Run steps automatically when something happens, and get notified."
},
"maintenance": {
"title": "Maintenance",
"description": "Pause host status, health, metric, container and tunnel-disconnection automations while work is in progress. Monitoring, manual runs and scheduled automations continue.",
"denied": "You do not have permission to view maintenance.",
"sharedReadOnly": "This host was shared with you. Only its owner can change maintenance.",
"failed": "Maintenance could not be updated.",
"active": "Maintenance active",
"estimated": "Estimated completion",
"noAutoEnd": "The estimate does not end maintenance. End it explicitly when the work is complete.",
"endConfirm": "End maintenance and resume event-triggered automations for this host?",
"confirmEnd": "Confirm end",
"cancel": "Cancel",
"end": "End maintenance",
"action": "Action",
"startNow": "Start now",
"schedule": "Schedule for later",
"reason": "Reason",
"startUtc": "First start (UTC)",
"repeat": "Repeat",
"once": "Once",
"weekly": "Weekly",
"monthly": "Monthly",
"utcHelp": "Schedules use UTC throughout the year. Monthly schedules skip months without the selected day. Planned windows cannot overlap; overrunning maintenance continues across later windows.",
"duration": "Estimated duration (minutes)",
"grace": "Grace period (minutes)",
"notify": "Notify once if still offline after the estimate plus grace period",
"addSchedule": "Add schedule",
"schedules": "Schedules",
"noSchedules": "No maintenance schedules.",
"completed": "Already started",
"minutes_one": "{{count}} minute",
"minutes_other": "{{count}} minutes",
"remove": "Remove schedule",
"loadFailed": "Could not refresh maintenance status. Changes are disabled until it can be loaded.",
"loading": "Loading maintenance status…"
}
}
@@ -0,0 +1,211 @@
{
"permissions": {
"view": {
"title": "Bekyk outomatisasies",
"description": "Sien die outomatisasies en hul geskiedenis."
},
"create": {
"title": "Skep outomatisasies",
"description": "Voeg nuwe outomatisasies by."
},
"edit": {
"title": "Wysig outomatisasies",
"description": "Verander bestaande outomatisasies."
},
"delete": {
"title": "Vee outomatisasies uit",
"description": "Verwyder outomatisasies."
},
"run": {
"title": "Voer outomatisasies uit",
"description": "Aktiveer 'n outomatisering met die hand."
}
},
"newUi": {
"sidebar": {
"automations": {
"title": "Outomatisasies",
"tabAutomations": "Outomatisasies",
"tabRuns": "Loopbane",
"tabChannels": "Kanale",
"createAutomation": "Nuwe Outomatisering",
"backToAutomations": "Terug na Outomatisasies",
"createTitle": "Nuwe Outomatisering",
"editTitle": "Wysig Outomatisering",
"empty": "Geen outomatisasies nog nie. Skep een om op 'n maatstaf, 'n skedule of 'n gebeurtenis te reageer.",
"emptyRuns": "Geen lopies nog nie.",
"channelsHint": "Kanale word in Waarskuwings opgestel",
"noChannelsHint": "Die waarskuwing gaan altyd na jou inboks. Voeg kanale by Waarskuwings om dit ook elders te stuur.",
"nameLabel": "Naam",
"namePlaceholder": "bv. Maak /data skoon wanneer dit vol is",
"nameRequired": "Naam word vereis",
"descriptionLabel": "Beskrywing",
"descriptionPlaceholder": "Opsionele beskrywing",
"enabledLabel": "Geaktiveer",
"concurrencyLabel": "As dit reeds aan die gang is",
"concurrencyHint": "Wat om te doen wanneer dit weer geaktiveer word voordat die laaste lopie klaar is.",
"concurrency": {
"skip": "Slaan die nuwe lopie oor",
"queue": "Wag en hardloop agterna",
"allow": "Begin albei gelyktydig"
},
"save": "Stoor",
"created": "Outomatisering geskep",
"updated": "Outomatisering gestoor",
"deleted": "Outomatisering uitgevee",
"deleteConfirm": "Vee hierdie outomatisering uit? Die loopgeskiedenis daarvan word ook uitgevee.",
"runNow": "Hardloop nou",
"testRun": "Toetslopie",
"testRunHint": "Loop elke stap, maar raak niks buite Termix aan nie.",
"runStarted": "Loop het begin",
"runFinished": "Loop voltooi: {{status}}",
"trigger": "Sneller",
"steps": "Stappe",
"addStep": "Voeg stap by",
"removeStep": "Verwyder stap",
"moveUp": "Beweeg op",
"moveDown": "Beweeg af",
"noSteps": "Geen stappe nog nie. Voeg een by om te sê wat moet gebeur.",
"webhookTokenTitle": "Webhook-URL",
"webhookTokenDescription": "Dit word een keer gewys. Stoor dit nou, dit kan nie later herwin word nie.",
"copy": "Kopieer",
"copied": "Gekopieer",
"viewJson": "JSON",
"viewBuilder": "Bouer",
"viewGraph": "Grafiek",
"lastRun": "Laaste lopie",
"never": "Nooit",
"duration": "{{seconds}}s",
"stepOutput": "Uitset",
"stepError": "Fout",
"dryRunBadge": "Toetslopie",
"hostSelector": {
"trigger": "Aanvallende gasheer",
"host": "Spesifieke gasheer",
"fleet": "Vloot",
"all": "Alle gashere"
},
"triggerKinds": {
"metric_threshold": "Metrieke drempel",
"host_status": "Gasheer gaan op of af",
"health_check": "Veranderinge in gesondheidstoetse",
"schedule": "Op 'n skedule",
"docker_event": "Houergebeurtenis",
"internal_event": "Termix-geleentheid",
"webhook": "Inkomende webhook"
},
"stepTypes": {
"notify": "Stuur 'n waarskuwing",
"http": "Roep 'n URL op",
"run_snippet": "Voer 'n brokkie uit",
"run_command": "Voer 'n opdrag uit",
"docker": "Beheer 'n houer",
"tunnel": "Beheer 'n tonnel",
"wol": "Maak 'n gasheer wakker",
"wait": "Wag",
"set_var": "Stel 'n veranderlike",
"if": "Indien / andersins",
"run_automation": "Begin nog 'n outomatisering",
"stop": "Stop"
},
"statuses": {
"running": "Hardloop",
"success": "Sukses",
"failed": "Misluk",
"timeout": "Tyd verstryk",
"skipped": "Oorgeslaan",
"cancelled": "Gekanselleer"
},
"fields": {
"host": "Gasheer",
"metric": "Metrieke",
"mount": "Monteerpunt",
"operator": "Is",
"value": "Waarde",
"forSeconds": "Aangehou vir (sekondes)",
"cooldown": "Afkoeltyd (minute)",
"cron": "Cron-uitdrukking",
"intervalSeconds": "Elke (sekondes)",
"timezone": "Tydsone",
"timezoneServer": "Bedienertyd",
"timezoneHint": "Cron-tye word in hierdie sone gelees. Geld nie vir intervalskedules nie.",
"container": "Houer",
"command": "Bevel",
"url": "URL",
"method": "Metode",
"body": "Liggaam",
"channels": "Stuur ook na",
"title": "Titel",
"message": "Boodskap",
"snippet": "Brokkie",
"seconds": "Sekondes",
"conditionLeftPlaceholder": "Waarde om na te gaan",
"conditionRightPlaceholder": "Vergelyk teen",
"variableName": "Veranderlike naam",
"variableValue": "Waarde",
"elevated": "Begin met sudo",
"onError": "As dit misluk",
"allowPrivateNetwork": "Laat plaaslike netwerkadresse toe",
"allowPrivateNetworkHint": "Nodig vir 'n selfgehoste diens op jou LAN. Standaard afgeskakel om versoeke na interne adresse te voorkom."
},
"onError": {
"stop": "Stop die hardloop",
"continue": "Gaan voort"
},
"needsPlugin": "Benodig {{plugin}}",
"needsPluginHint": "Hierdie outomatisering word oorgeslaan totdat die funksie wat dit benodig weer beskikbaar is.",
"offBadge": "Af",
"docsLink": "Bekyk dokumente",
"ago": {
"seconds": "{{count}}s gelede",
"minutes": "{{count}}m gelede",
"hours": "{{count}}uur gelede",
"days": "{{count}}dae gelede"
}
}
}
},
"nav": {
"automations": "Outomatisasies"
},
"onboarding": {
"feature_automations": "Outomatisasies",
"feature_automations_desc": "Voer stappe outomaties uit wanneer iets gebeur, en word in kennis gestel."
},
"maintenance": {
"title": "Onderhoud",
"description": "Onderbreek gasheerstatus, gesondheid, metrieke, houer- en tonnel-ontkoppelingsoutomatisasies terwyl werk aan die gang is. Monitering, handmatige lopies en geskeduleerde outomatisasies gaan voort.",
"denied": "Jy het nie toestemming om onderhoud te sien nie.",
"sharedReadOnly": "Hierdie gasheer is met jou gedeel. Slegs die eienaar kan die onderhoud verander.",
"failed": "Onderhoud kon nie opgedateer word nie.",
"active": "Onderhoud aktief",
"estimated": "Geraamde voltooiing",
"noAutoEnd": "Die skatting beëindig nie onderhoud nie. Beëindig dit eksplisiet wanneer die werk voltooi is.",
"endConfirm": "Beëindig onderhoud en hervat gebeurtenis-geaktiveerde outomatisasies vir hierdie gasheer?",
"confirmEnd": "Bevestig einde",
"cancel": "Kanselleer",
"end": "Einde van onderhoud",
"action": "Aksie",
"startNow": "Begin nou",
"schedule": "Skeduleer vir later",
"reason": "Rede",
"startUtc": "Eerste begin (UTC)",
"repeat": "Herhaal",
"once": "Eens",
"weekly": "Weekliks",
"monthly": "Maandeliks",
"utcHelp": "Skedules gebruik UTC dwarsdeur die jaar. Maandelikse skedules slaan maande oor sonder die gekose dag. Beplande vensters kan nie oorvleuel nie; oorskrydende onderhoud gaan voort oor latere vensters.",
"duration": "Geraamde duur (minute)",
"grace": "Genadetydperk (minute)",
"notify": "Stel een keer in kennis indien steeds vanlyn na die skatting plus grasietydperk",
"addSchedule": "Voeg skedule by",
"schedules": "Skedules",
"noSchedules": "Geen onderhoudskedules nie.",
"completed": "Reeds begin",
"minutes_one": "{{count}} minuut",
"minutes_other": "{{count}} minute",
"remove": "Verwyder skedule",
"loadFailed": "Kon nie onderhoudstatus verfris nie. Veranderinge is gedeaktiveer totdat dit gelaai kan word.",
"loading": "Laai onderhoudstatus…"
}
}
@@ -0,0 +1,211 @@
{
"permissions": {
"view": {
"title": "عرض عمليات التشغيل الآلي",
"description": "اطلع على أنظمة التشغيل الآلي وتاريخها."
},
"create": {
"title": "إنشاء عمليات أتمتة",
"description": "أضف عمليات أتمتة جديدة."
},
"edit": {
"title": "تحرير عمليات التشغيل الآلي",
"description": "قم بتغيير أنظمة التشغيل الآلي الحالية."
},
"delete": {
"title": "حذف عمليات التشغيل الآلي",
"description": "إزالة العمليات الآلية."
},
"run": {
"title": "تشغيل عمليات التشغيل الآلي",
"description": "قم بتشغيل عملية أتمتة يدوياً."
}
},
"newUi": {
"sidebar": {
"automations": {
"title": "الأتمتة",
"tabAutomations": "الأتمتة",
"tabRuns": "يركض",
"tabChannels": "القنوات",
"createAutomation": "الأتمتة الجديدة",
"backToAutomations": "العودة إلى الأتمتة",
"createTitle": "الأتمتة الجديدة",
"editTitle": "أتمتة التحرير",
"empty": "لا توجد عمليات أتمتة حتى الآن. أنشئ واحدة للتفاعل مع مقياس أو جدول زمني أو حدث.",
"emptyRuns": "لم يتم تسجيل أي نقاط حتى الآن.",
"channelsHint": "يتم إعداد القنوات في قسم التنبيهات",
"noChannelsHint": "تصل التنبيهات دائمًا إلى بريدك الوارد. أضف قنوات في قسم التنبيهات لإرسالها إلى مكان آخر أيضًا.",
"nameLabel": "الاسم",
"namePlaceholder": "مثال: تنظيف /data عند امتلائها",
"nameRequired": "الاسم مطلوب",
"descriptionLabel": "الوصف",
"descriptionPlaceholder": "وصف اختياري",
"enabledLabel": "مُمكّن",
"concurrencyLabel": "إذا كان قيد التشغيل بالفعل",
"concurrencyHint": "ماذا نفعل عندما يتم تشغيل هذا مرة أخرى قبل انتهاء التشغيل الأخير؟",
"concurrency": {
"skip": "تخطَّ الجولة الجديدة",
"queue": "انتظر واركض خلفه",
"allow": "قم بتشغيل كليهما في وقت واحد"
},
"save": "حفظ",
"created": "تم إنشاء الأتمتة",
"updated": "تم حفظ الأتمتة",
"deleted": "تم حذف الأتمتة",
"deleteConfirm": "هل تريد حذف هذه العملية الآلية؟ سيتم حذف سجل تشغيلها أيضاً.",
"runNow": "انطلق الآن",
"testRun": "تشغيل تجريبي",
"testRunHint": "يقوم بتنفيذ كل خطوة ولكنه لا يلمس أي شيء خارج Termix.",
"runStarted": "بدأ الجري",
"runFinished": "انتهى التشغيل: {{status}}",
"trigger": "المشغّل",
"steps": "خطوات",
"addStep": "خطوات إضافية",
"removeStep": "خطوة الإزالة",
"moveUp": "انتقل إلى مستوى أعلى",
"moveDown": "انزل لأسفل",
"noSteps": "لم تُتخذ أي خطوات بعد. أضف خطوة واحدة لتوضيح ما ينبغي أن يحدث.",
"webhookTokenTitle": "رابط الويب هوك",
"webhookTokenDescription": "هذا يظهر مرة واحدة فقط. احفظه الآن، فلن تتمكن من استعادته لاحقاً.",
"copy": "نسخ",
"copied": "تم النسخ",
"viewJson": "JSON",
"viewBuilder": "باني",
"viewGraph": "الرسم البياني",
"lastRun": "الجولة الأخيرة",
"never": "أبدًا",
"duration": "{{seconds}}s",
"stepOutput": "المخرجات",
"stepError": "خطأ",
"dryRunBadge": "تشغيل تجريبي",
"hostSelector": {
"trigger": "تشغيل المضيف",
"host": "مضيف محدد",
"fleet": "أسطول",
"all": "جميع المضيفين"
},
"triggerKinds": {
"metric_threshold": "العتبة المترية",
"host_status": "يرتفع أو ينخفض مستوى المضيف",
"health_check": "تغييرات في الفحص الصحي",
"schedule": "وفقًا لجدول زمني",
"docker_event": "حدث الحاوية",
"internal_event": "حدث تيرميكس",
"webhook": "رابط الويب الوارد"
},
"stepTypes": {
"notify": "إرسال تنبيه",
"http": "استدعاء عنوان URL",
"run_snippet": "قم بتشغيل جزء من التعليمات البرمجية",
"run_command": "نفّذ أمرًا",
"docker": "التحكم في حاوية",
"tunnel": "التحكم في نفق",
"wol": "أيقظ مضيفًا",
"wait": "انتظر",
"set_var": "قم بتعيين متغير",
"if": "إذا / خلاف ذلك",
"run_automation": "قم بتشغيل عملية أتمتة أخرى",
"stop": "التوقف"
},
"statuses": {
"running": "قيد التشغيل",
"success": "نجاح",
"failed": "فشل",
"timeout": "انتهت مهلة الاتصال",
"skipped": "تم التخطي",
"cancelled": "تم الإلغاء"
},
"fields": {
"host": "المضيف",
"metric": "المقياس",
"mount": "نقطة التثبيت",
"operator": "يكون",
"value": "قيمة",
"forSeconds": "استمر لمدة (ثوانٍ)",
"cooldown": "فترة التهدئة (بالدقائق)",
"cron": "تعبير كرون",
"intervalSeconds": "كل (ثانية)",
"timezone": "المنطقة الزمنية",
"timezoneServer": "وقت الخادم",
"timezoneHint": "تُقرأ أوقات Cron في هذه المنطقة. لا ينطبق ذلك على الجداول الزمنية الفاصلة.",
"container": "حاوية",
"command": "الأمر",
"url": "URL",
"method": "طريقة",
"body": "جسم",
"channels": "أرسل أيضًا إلى",
"title": "العنوان",
"message": "رسالة",
"snippet": "مقتطف",
"seconds": "ثوانٍ",
"conditionLeftPlaceholder": "قيمة يجب التحقق منها",
"conditionRightPlaceholder": "قارن بـ",
"variableName": "اسم المتغير",
"variableValue": "قيمة",
"elevated": "قم بتشغيل الأمر sudo",
"onError": "إذا فشل هذا",
"allowPrivateNetwork": "السماح بعناوين الشبكة المحلية",
"allowPrivateNetworkHint": "هذا الخيار ضروري لخدمة مُستضافة ذاتيًا على شبكتك المحلية. وهو مُعطّل افتراضيًا لمنع الطلبات إلى العناوين الداخلية."
},
"onError": {
"stop": "أوقف الجري",
"continue": "حمل"
},
"needsPlugin": "الاحتياجات {{plugin}}",
"needsPluginHint": "يتم تخطي هذه العملية الآلية حتى تصبح الميزة التي تحتاجها متاحة مرة أخرى.",
"offBadge": "غير مفعلة",
"docsLink": "عرض المستندات",
"ago": {
"seconds": "{{count}}منذ ثانية",
"minutes": "قبل {{count}}m",
"hours": "قبل {{count}}h",
"days": "قبل {{count}}d"
}
}
}
},
"nav": {
"automations": "الأتمتة"
},
"onboarding": {
"feature_automations": "الأتمتة",
"feature_automations_desc": "قم بتشغيل الخطوات تلقائيًا عند حدوث شيء ما، واحصل على إشعار بذلك."
},
"maintenance": {
"title": "صيانة",
"description": "أوقف عمليات أتمتة حالة المضيف، وصحته، ومقاييسه، والحاويات، وفصل الأنفاق مؤقتًا أثناء سير العمل. وتستمر عمليات المراقبة، والتشغيل اليدوي، والأتمتة المجدولة.",
"denied": "ليس لديك إذن للاطلاع على أعمال الصيانة.",
"sharedReadOnly": "تمت مشاركة هذا المضيف معك. مالكه فقط هو من يستطيع تغيير إعداداته.",
"failed": "تعذر تحديث الصيانة.",
"active": "الصيانة نشطة",
"estimated": "تاريخ الإنجاز المتوقع",
"noAutoEnd": "لا يُنهي التقدير أعمال الصيانة. يجب إنهاؤه صراحةً عند اكتمال العمل.",
"endConfirm": "هل تريد إنهاء الصيانة واستئناف عمليات التشغيل الآلي التي يتم تشغيلها بواسطة الأحداث لهذا المضيف؟",
"confirmEnd": "تأكيد الإنهاء",
"cancel": "إلغاء",
"end": "إنهاء الصيانة",
"action": "الإجراء",
"startNow": "ابدأ الآن",
"schedule": "جدولة لاحقاً",
"reason": "سبب",
"startUtc": "بداية التشغيل الأولى (UTC)",
"repeat": "يكرر",
"once": "مرة واحدة",
"weekly": "أسبوعي",
"monthly": "شهريا",
"utcHelp": "تستخدم الجداول الزمنية التوقيت العالمي المنسق (UTC) على مدار العام. وتتخطى الجداول الشهرية الأشهر التي لا تتضمن اليوم المحدد. ولا يمكن أن تتداخل فترات التخطيط؛ إذ تستمر أعمال الصيانة المتأخرة خلال الفترات اللاحقة.",
"duration": "المدة المقدرة (بالدقائق)",
"grace": "فترة السماح (بالدقائق)",
"notify": "قم بالإخطار مرة واحدة إذا كنت لا تزال غير متصل بالإنترنت بعد الفترة التقديرية بالإضافة إلى فترة السماح",
"addSchedule": "إضافة جدول",
"schedules": "الجداول الزمنية",
"noSchedules": "لا توجد جداول صيانة.",
"completed": "ابدأ بالفعل",
"minutes_one": "{{count}} minute",
"minutes_other": "{{count}} دقائق",
"remove": "إزالة الجدول الزمني",
"loadFailed": "تعذر تحديث حالة الصيانة. التغييرات معطلة حتى يتم تحميلها.",
"loading": "جارٍ تحميل حالة الصيانة…"
}
}
@@ -0,0 +1,211 @@
{
"permissions": {
"view": {
"title": "Преглед на автоматизациите",
"description": "Вижте автоматизациите и тяхната история."
},
"create": {
"title": "Създаване на автоматизации",
"description": "Добавете нови автоматизации."
},
"edit": {
"title": "Редактиране на автоматизации",
"description": "Променете съществуващите автоматизации."
},
"delete": {
"title": "Изтриване на автоматизации",
"description": "Премахнете автоматизациите."
},
"run": {
"title": "Изпълнявайте автоматизации",
"description": "Задействайте автоматизация ръчно."
}
},
"newUi": {
"sidebar": {
"automations": {
"title": "Автоматизации",
"tabAutomations": "Автоматизации",
"tabRuns": "Бягания",
"tabChannels": "Канали",
"createAutomation": "Нова автоматизация",
"backToAutomations": "Обратно към Автоматизации",
"createTitle": "Нова автоматизация",
"editTitle": "Редактиране на автоматизация",
"empty": "Все още няма автоматизации. Създайте такава, която да реагира на показател, график или събитие.",
"emptyRuns": "Все още няма бягания.",
"channelsHint": "Каналите се настройват в „Известия“",
"noChannelsHint": "Сигналът винаги отива във входящата ви поща. Добавете канали в „Сигнали“, за да го изпращате и някъде другаде.",
"nameLabel": "Име",
"namePlaceholder": "напр. Почистване на /данни, когато се запълни",
"nameRequired": "Името е задължително",
"descriptionLabel": "Описание",
"descriptionPlaceholder": "Незадължително описание",
"enabledLabel": "Активирано",
"concurrencyLabel": "Ако вече работи",
"concurrencyHint": "Какво да направите, когато това се задейства отново, преди да е завършило последното изпълнение.",
"concurrency": {
"skip": "Пропуснете новото бягане",
"queue": "Изчакай и бягай след това",
"allow": "Изпълнете и двете едновременно"
},
"save": "Запазване",
"created": "Автоматизация, създадена",
"updated": "Автоматизацията е запазена",
"deleted": "Автоматизацията е изтрита",
"deleteConfirm": "Да се изтрие ли тази автоматизация? Историята на нейните изпълнения също ще бъде изтрита.",
"runNow": "Бягай сега",
"testRun": "Тестов пробег",
"testRunHint": "Изпълнява всяка стъпка, но не докосва нищо извън Termix.",
"runStarted": "Бягането започна",
"runFinished": "Изпълнението е завършено: {{status}}",
"trigger": "Спусък",
"steps": "Стъпки",
"addStep": "Добавяне на стъпка",
"removeStep": "Премахване на стъпка",
"moveUp": "Премести нагоре",
"moveDown": "Премести надолу",
"noSteps": "Все още няма стъпки. Добавете една, за да кажете какво трябва да се случи.",
"webhookTokenTitle": "URL адрес на уебкукичката",
"webhookTokenDescription": "Това се показва веднъж. Запазете го сега, не може да бъде извлечено по-късно.",
"copy": "Копиране",
"copied": "Копирано",
"viewJson": "JSON",
"viewBuilder": "Строител",
"viewGraph": "Графика",
"lastRun": "Последно бягане",
"never": "Никога",
"duration": "{{seconds}}с",
"stepOutput": "Изход",
"stepError": "Грешка",
"dryRunBadge": "Тестов пробег",
"hostSelector": {
"trigger": "Задействащ хост",
"host": "Специфичен хост",
"fleet": "Флот",
"all": "Всички хостове"
},
"triggerKinds": {
"metric_threshold": "Праг на показателя",
"host_status": "Домакинът се изкачва или слиза",
"health_check": "Промени в проверката на състоянието",
"schedule": "По график",
"docker_event": "Събитие в контейнера",
"internal_event": "Събитие Termix",
"webhook": "Входящ уеб кукичка"
},
"stepTypes": {
"notify": "Изпрати сигнал",
"http": "Извикване на URL адрес",
"run_snippet": "Изпълнете фрагмент",
"run_command": "Изпълнете команда",
"docker": "Контрол на контейнер",
"tunnel": "Контрол на тунел",
"wol": "Събудете домакин",
"wait": "Чакай",
"set_var": "Задаване на променлива",
"if": "Ако / в противен случай",
"run_automation": "Изпълнете друга автоматизация",
"stop": "Спри"
},
"statuses": {
"running": "Бягане",
"success": "Успех",
"failed": "Неуспешно",
"timeout": "Времето за изчакване изтече",
"skipped": "Пропуснато",
"cancelled": "Отменено"
},
"fields": {
"host": "Домакин",
"metric": "Метричен",
"mount": "Точка на монтиране",
"operator": "Е",
"value": "Стойност",
"forSeconds": "Задържа се за (секунди)",
"cooldown": "Охлаждане (минути)",
"cron": "Cron израз",
"intervalSeconds": "Всеки (секунди)",
"timezone": "Часова зона",
"timezoneServer": "Време на сървъра",
"timezoneHint": "Cron времената се отчитат в тази зона. Не се отнася за интервални графици.",
"container": "Контейнер",
"command": "Команда",
"url": "URL адрес",
"method": "Метод",
"body": "Тяло",
"channels": "Изпрати също до",
"title": "Заглавие",
"message": "Съобщение",
"snippet": "Фрагмент",
"seconds": "Секунди",
"conditionLeftPlaceholder": "Стойност за проверка",
"conditionRightPlaceholder": "Сравнете с",
"variableName": "Име на променлива",
"variableValue": "Стойност",
"elevated": "Стартирай със sudo",
"onError": "Ако това не успее",
"allowPrivateNetwork": "Разрешаване на локални мрежови адреси",
"allowPrivateNetworkHint": "Необходимо за самостоятелно хоствана услуга във вашата локална мрежа. Изключено по подразбиране, за да се предотвратят заявки към вътрешни адреси."
},
"onError": {
"stop": "Спрете бягането",
"continue": "Продължавай"
},
"needsPlugin": "Изисква {{plugin}}",
"needsPluginHint": "Тази автоматизация се пропуска, докато функцията, от която се нуждае, не стане отново налична.",
"offBadge": "Изключено",
"docsLink": "Преглед на документи",
"ago": {
"seconds": "преди {{count}}сек",
"minutes": "преди {{count}}мин.",
"hours": "преди {{count}}ч",
"days": "преди {{count}}дни"
}
}
}
},
"nav": {
"automations": "Автоматизации"
},
"onboarding": {
"feature_automations": "Автоматизации",
"feature_automations_desc": "Изпълнявайте стъпките автоматично, когато нещо се случи, и получавайте известия."
},
"maintenance": {
"title": "Поддръжка",
"description": "Паузирайте автоматизациите за състоянието на хоста, здравето, метриките, контейнерите и прекъсванията на тунелите, докато работата е в ход. Мониторингът, ръчните изпълнения и планираните автоматизации продължават.",
"denied": "Нямате разрешение да преглеждате поддръжката.",
"sharedReadOnly": "Този хост беше споделен с вас. Само собственикът му може да променя поддръжката.",
"failed": "Поддръжката не можа да бъде актуализирана.",
"active": "Поддръжката е активна",
"estimated": "Очаквано завършване",
"noAutoEnd": "Оценката не прекратява поддръжката. Прекратете я изрично, когато работата е завършена.",
"endConfirm": "Да се прекрати ли поддръжката и да се възобновят автоматизациите, задействани от събития, за този хост?",
"confirmEnd": "Потвърждаване на края",
"cancel": "Отказ",
"end": "Край на поддръжката",
"action": "Действие",
"startNow": "Започнете сега",
"schedule": "Планирайте за по-късно",
"reason": "Причина",
"startUtc": "Първо стартиране (UTC)",
"repeat": "Повторете",
"once": "Веднъж",
"weekly": "Седмично",
"monthly": "Месечно",
"utcHelp": "Графиците използват UTC през цялата година. Месечните графици пропускат месеците без избрания ден. Планираните прозорци не могат да се припокриват; превишаването на поддръжката продължава и през по-късни прозорци.",
"duration": "Очаквана продължителност (минути)",
"grace": "Гратисен период (минути)",
"notify": "Уведомявай веднъж, ако все още си офлайн след прогнозната цена плюс гратисен период",
"addSchedule": "Добавяне на график",
"schedules": "Разписания",
"noSchedules": "Няма графици за поддръжка.",
"completed": "Вече започна",
"minutes_one": "{{count}} минута",
"minutes_other": "{{count}} минути",
"remove": "Премахване на графика",
"loadFailed": "Не можа да се обнови състоянието на поддръжката. Промените са деактивирани, докато не може да бъде заредено.",
"loading": "Зареждане на състоянието на поддръжката…"
}
}
@@ -0,0 +1,211 @@
{
"permissions": {
"view": {
"title": "View automations",
"description": "See the automations and their history."
},
"create": {
"title": "Create automations",
"description": "Add new automations."
},
"edit": {
"title": "Edit automations",
"description": "Change existing automations."
},
"delete": {
"title": "Delete automations",
"description": "Remove automations."
},
"run": {
"title": "Run automations",
"description": "Trigger an automation by hand."
}
},
"newUi": {
"sidebar": {
"automations": {
"title": "Automations",
"tabAutomations": "Automations",
"tabRuns": "Runs",
"tabChannels": "Channels",
"createAutomation": "New Automation",
"backToAutomations": "Back to Automations",
"createTitle": "New Automation",
"editTitle": "Edit Automation",
"empty": "No automations yet. Create one to react to a metric, a schedule, or an event.",
"emptyRuns": "No runs yet.",
"channelsHint": "Channels are set up in Alerts",
"noChannelsHint": "The alert always goes to your inbox. Add channels in Alerts to also send it somewhere else.",
"nameLabel": "Name",
"namePlaceholder": "e.g., Clean up /data when it fills",
"nameRequired": "Name is required",
"descriptionLabel": "Description",
"descriptionPlaceholder": "Optional description",
"enabledLabel": "Enabled",
"concurrencyLabel": "If it is already running",
"concurrencyHint": "What to do when this is triggered again before the last run has finished.",
"concurrency": {
"skip": "Skip the new run",
"queue": "Wait and run after",
"allow": "Run both at once"
},
"save": "Save",
"created": "Automation created",
"updated": "Automation saved",
"deleted": "Automation deleted",
"deleteConfirm": "Delete this automation? Its run history is deleted too.",
"runNow": "Run now",
"testRun": "Test run",
"testRunHint": "Runs every step but does not touch anything outside Termix.",
"runStarted": "Run started",
"runFinished": "Run finished: {{status}}",
"trigger": "Trigger",
"steps": "Steps",
"addStep": "Add step",
"removeStep": "Remove step",
"moveUp": "Move up",
"moveDown": "Move down",
"noSteps": "No steps yet. Add one to say what should happen.",
"webhookTokenTitle": "Webhook URL",
"webhookTokenDescription": "This is shown once. Save it now, it cannot be retrieved later.",
"copy": "Copy",
"copied": "Copied",
"viewJson": "JSON",
"viewBuilder": "Builder",
"viewGraph": "Graph",
"lastRun": "Last run",
"never": "Never",
"duration": "{{seconds}}s",
"stepOutput": "Output",
"stepError": "Error",
"dryRunBadge": "Test run",
"hostSelector": {
"trigger": "Triggering host",
"host": "Specific host",
"fleet": "Fleet",
"all": "All hosts"
},
"triggerKinds": {
"metric_threshold": "Metric threshold",
"host_status": "Host goes up or down",
"health_check": "Health check changes",
"schedule": "On a schedule",
"docker_event": "Container event",
"internal_event": "Termix event",
"webhook": "Incoming webhook"
},
"stepTypes": {
"notify": "Send an alert",
"http": "Call a URL",
"run_snippet": "Run a snippet",
"run_command": "Run a command",
"docker": "Control a container",
"tunnel": "Control a tunnel",
"wol": "Wake a host",
"wait": "Wait",
"set_var": "Set a variable",
"if": "If / otherwise",
"run_automation": "Run another automation",
"stop": "Stop"
},
"statuses": {
"running": "Running",
"success": "Success",
"failed": "Failed",
"timeout": "Timed out",
"skipped": "Skipped",
"cancelled": "Cancelled"
},
"fields": {
"host": "Host",
"metric": "Metric",
"mount": "Mount point",
"operator": "Is",
"value": "Value",
"forSeconds": "Sustained for (seconds)",
"cooldown": "Cooldown (minutes)",
"cron": "Cron expression",
"intervalSeconds": "Every (seconds)",
"timezone": "Time zone",
"timezoneServer": "Server time",
"timezoneHint": "Cron times are read in this zone. Does not apply to interval schedules.",
"container": "Container",
"command": "Command",
"url": "URL",
"method": "Method",
"body": "Body",
"channels": "Also send to",
"title": "Title",
"message": "Message",
"snippet": "Snippet",
"seconds": "Seconds",
"conditionLeftPlaceholder": "Value to check",
"conditionRightPlaceholder": "Compare against",
"variableName": "Variable name",
"variableValue": "Value",
"elevated": "Run with sudo",
"onError": "If this fails",
"allowPrivateNetwork": "Allow local network addresses",
"allowPrivateNetworkHint": "Needed for a self-hosted service on your LAN. Off by default to prevent requests to internal addresses."
},
"onError": {
"stop": "Stop the run",
"continue": "Carry on"
},
"needsPlugin": "Needs {{plugin}}",
"needsPluginHint": "This automation is skipped until the feature it needs is available again.",
"offBadge": "Off",
"docsLink": "View docs",
"ago": {
"seconds": "{{count}}s ago",
"minutes": "{{count}}m ago",
"hours": "{{count}}h ago",
"days": "{{count}}d ago"
}
}
}
},
"nav": {
"automations": "Automations"
},
"onboarding": {
"feature_automations": "Automations",
"feature_automations_desc": "Run steps automatically when something happens, and get notified."
},
"maintenance": {
"title": "Maintenance",
"description": "Pause host status, health, metric, container and tunnel-disconnection automations while work is in progress. Monitoring, manual runs and scheduled automations continue.",
"denied": "You do not have permission to view maintenance.",
"sharedReadOnly": "This host was shared with you. Only its owner can change maintenance.",
"failed": "Maintenance could not be updated.",
"active": "Maintenance active",
"estimated": "Estimated completion",
"noAutoEnd": "The estimate does not end maintenance. End it explicitly when the work is complete.",
"endConfirm": "End maintenance and resume event-triggered automations for this host?",
"confirmEnd": "Confirm end",
"cancel": "Cancel",
"end": "End maintenance",
"action": "Action",
"startNow": "Start now",
"schedule": "Schedule for later",
"reason": "Reason",
"startUtc": "First start (UTC)",
"repeat": "Repeat",
"once": "Once",
"weekly": "Weekly",
"monthly": "Monthly",
"utcHelp": "Schedules use UTC throughout the year. Monthly schedules skip months without the selected day. Planned windows cannot overlap; overrunning maintenance continues across later windows.",
"duration": "Estimated duration (minutes)",
"grace": "Grace period (minutes)",
"notify": "Notify once if still offline after the estimate plus grace period",
"addSchedule": "Add schedule",
"schedules": "Schedules",
"noSchedules": "No maintenance schedules.",
"completed": "Already started",
"minutes_one": "{{count}} minute",
"minutes_other": "{{count}} minutes",
"remove": "Remove schedule",
"loadFailed": "Could not refresh maintenance status. Changes are disabled until it can be loaded.",
"loading": "Loading maintenance status…"
}
}
@@ -0,0 +1,211 @@
{
"permissions": {
"view": {
"title": "Veure automatitzacions",
"description": "Vegeu les automatitzacions i el seu historial."
},
"create": {
"title": "Crea automatitzacions",
"description": "Afegiu noves automatitzacions."
},
"edit": {
"title": "Edita automatitzacions",
"description": "Canviar les automatitzacions existents."
},
"delete": {
"title": "Suprimeix automatitzacions",
"description": "Eliminar automatitzacions."
},
"run": {
"title": "Executar automatitzacions",
"description": "Activa una automatització manualment."
}
},
"newUi": {
"sidebar": {
"automations": {
"title": "Automatitzacions",
"tabAutomations": "Automatitzacions",
"tabRuns": "Carreres",
"tabChannels": "Canals",
"createAutomation": "Nova automatització",
"backToAutomations": "Torna a Automatitzacions",
"createTitle": "Nova automatització",
"editTitle": "Edita l'automatització",
"empty": "Encara no hi ha automatitzacions. Crea'n una per reaccionar a una mètrica, una programació o un esdeveniment.",
"emptyRuns": "Encara no hi ha curses.",
"channelsHint": "Els canals es configuren a Alertes",
"noChannelsHint": "L'alerta sempre va a la teva safata d'entrada. Afegeix canals a Alertes per enviar-la també a un altre lloc.",
"nameLabel": "Nom",
"namePlaceholder": "p. ex., Neteja /dades quan s'ompli",
"nameRequired": "El nom és obligatori",
"descriptionLabel": "Descripció",
"descriptionPlaceholder": "Descripció opcional",
"enabledLabel": "Activat",
"concurrencyLabel": "Si ja està en funcionament",
"concurrencyHint": "Què cal fer quan això es torna a activar abans que s'hagi acabat l'última execució.",
"concurrency": {
"skip": "Omet la nova cursa",
"queue": "Espera i corre darrere",
"allow": "Executa tots dos alhora"
},
"save": "Desa",
"created": "Automatització creada",
"updated": "Automatització desada",
"deleted": "Automatització suprimida",
"deleteConfirm": "Voleu suprimir aquesta automatització? També se n'esborra l'historial d'execucions.",
"runNow": "Executa ara",
"testRun": "Prova de funcionament",
"testRunHint": "Corre cada pas però no toca res fora de Termix.",
"runStarted": "Execució iniciada",
"runFinished": "Execució acabada: {{status}}",
"trigger": "Disparador",
"steps": "Passos",
"addStep": "Afegeix un pas",
"removeStep": "Elimina el pas",
"moveUp": "Puja",
"moveDown": "Moure cap avall",
"noSteps": "Encara no hi ha cap pas. Afegeix-ne un per indicar què hauria de passar.",
"webhookTokenTitle": "URL del webhook",
"webhookTokenDescription": "Això es mostra una vegada. Desa-ho ara, no es podrà recuperar més tard.",
"copy": "Còpia",
"copied": "Copiat",
"viewJson": "JSON",
"viewBuilder": "Constructor",
"viewGraph": "Gràfic",
"lastRun": "Última cursa",
"never": "Mai",
"duration": "{{seconds}}s",
"stepOutput": "Sortida",
"stepError": "Error",
"dryRunBadge": "Prova de funcionament",
"hostSelector": {
"trigger": "Host activador",
"host": "Amfitrió específic",
"fleet": "Flota",
"all": "Tots els amfitrions"
},
"triggerKinds": {
"metric_threshold": "Llindar mètric",
"host_status": "L'amfitrió puja o baixa",
"health_check": "Canvis en la revisió de salut",
"schedule": "En un horari",
"docker_event": "Esdeveniment de contenidor",
"internal_event": "Esdeveniment de Termix",
"webhook": "Webhook entrant"
},
"stepTypes": {
"notify": "Enviar una alerta",
"http": "Crida a una URL",
"run_snippet": "Executa un fragment",
"run_command": "Executa una ordre",
"docker": "Controlar un contenidor",
"tunnel": "Controlar un túnel",
"wol": "Despertar un amfitrió",
"wait": "Espera",
"set_var": "Defineix una variable",
"if": "Si / altrament",
"run_automation": "Executa una altra automatització",
"stop": "Atura"
},
"statuses": {
"running": "Córrer",
"success": "Èxit",
"failed": "Fallat",
"timeout": "Temps d'espera esgotat",
"skipped": "Omès",
"cancelled": "Cancel·lat"
},
"fields": {
"host": "Amfitrió",
"metric": "Mètrica",
"mount": "Punt de muntatge",
"operator": "És",
"value": "Valor",
"forSeconds": "Sostingut durant (segons)",
"cooldown": "Refredament (minuts)",
"cron": "Expressió cron",
"intervalSeconds": "Cada (segons)",
"timezone": "Zona horària",
"timezoneServer": "Hora del servidor",
"timezoneHint": "Els temps cron es llegeixen en aquesta zona. No s'aplica a les programacions per intervals.",
"container": "Contenidor",
"command": "Comandament",
"url": "URL",
"method": "Mètode",
"body": "Cos",
"channels": "També envia a",
"title": "Títol",
"message": "Missatge",
"snippet": "Fragment",
"seconds": "Segons",
"conditionLeftPlaceholder": "Valor a comprovar",
"conditionRightPlaceholder": "Comparar amb",
"variableName": "Nom de la variable",
"variableValue": "Valor",
"elevated": "Executar amb sudo",
"onError": "Si això falla",
"allowPrivateNetwork": "Permetre adreces de xarxa locals",
"allowPrivateNetworkHint": "Necessari per a un servei autoallotjat a la vostra LAN. Desactivat per defecte per evitar sol·licituds a adreces internes."
},
"onError": {
"stop": "Atura la cursa",
"continue": "Continua"
},
"needsPlugin": "Necessita {{plugin}}",
"needsPluginHint": "Aquesta automatització s'omet fins que la funció que necessita torni a estar disponible.",
"offBadge": "Desactivat",
"docsLink": "Veure documents",
"ago": {
"seconds": "Fa {{count}}segons",
"minutes": "fa {{count}}minuts",
"hours": "fa {{count}}h",
"days": "fa {{count}}dies"
}
}
}
},
"nav": {
"automations": "Automatitzacions"
},
"onboarding": {
"feature_automations": "Automatitzacions",
"feature_automations_desc": "Executa els passos automàticament quan passi alguna cosa i rep notificacions."
},
"maintenance": {
"title": "Manteniment",
"description": "Pausa les automatitzacions d'estat, salut, mètriques, contenidors i desconnexió de túnels de l'amfitrió mentre la feina està en curs. La supervisió, les execucions manuals i les automatitzacions programades continuen.",
"denied": "No teniu permís per veure el manteniment.",
"sharedReadOnly": "Aquest amfitrió s'ha compartit amb tu. Només el seu propietari pot canviar el manteniment.",
"failed": "No s'ha pogut actualitzar el manteniment.",
"active": "Manteniment actiu",
"estimated": "Finalització prevista",
"noAutoEnd": "El pressupost no finalitza el manteniment. Finalitzeu-lo explícitament quan la feina s'hagi completat.",
"endConfirm": "Voleu finalitzar el manteniment i reprendre les automatitzacions activades per esdeveniments per a aquest amfitrió?",
"confirmEnd": "Confirma la fi",
"cancel": "Cancel·la",
"end": "Finalitzar el manteniment",
"action": "Acció",
"startNow": "Comença ara",
"schedule": "Programa per a més tard",
"reason": "Raó",
"startUtc": "Primer inici (UTC)",
"repeat": "Repetir",
"once": "Un cop",
"weekly": "Setmanal",
"monthly": "Mensual",
"utcHelp": "Les programacions utilitzen UTC durant tot l'any. Les programacions mensuals salten mesos sense el dia seleccionat. Les finestres planificades no es poden superposar; el manteniment que s'executa de manera excessiva continua a les finestres posteriors.",
"duration": "Durada estimada (minuts)",
"grace": "Període de gràcia (minuts)",
"notify": "Notifica una vegada si encara estàs fora de línia després de l'estimació més el període de gràcia",
"addSchedule": "Afegeix una programació",
"schedules": "Horaris",
"noSchedules": "Sense programes de manteniment.",
"completed": "Ja ha començat",
"minutes_one": "{{count}} minut",
"minutes_other": "{{count}} minuts",
"remove": "Elimina la programació",
"loadFailed": "No s'ha pogut actualitzar l'estat del manteniment. Els canvis estan desactivats fins que es pugui carregar.",
"loading": "S'està carregant l'estat del manteniment…"
}
}
@@ -0,0 +1,211 @@
{
"permissions": {
"view": {
"title": "Zobrazit automatizace",
"description": "Podívejte se na automatizace a jejich historii."
},
"create": {
"title": "Vytvářejte automatizace",
"description": "Přidejte nové automatizace."
},
"edit": {
"title": "Upravit automatizace",
"description": "Změňte stávající automatizace."
},
"delete": {
"title": "Smazat automatizace",
"description": "Odstraňte automatizace."
},
"run": {
"title": "Spouštění automatizací",
"description": "Ručně spusťte automatizaci."
}
},
"newUi": {
"sidebar": {
"automations": {
"title": "Automatizace",
"tabAutomations": "Automatizace",
"tabRuns": "Běhy",
"tabChannels": "Kanály",
"createAutomation": "Nová automatizace",
"backToAutomations": "Zpět k automatizaci",
"createTitle": "Nová automatizace",
"editTitle": "Upravit automatizaci",
"empty": "Zatím žádné automatizace. Vytvořte si automatizaci, která bude reagovat na metriku, plán nebo událost.",
"emptyRuns": "Zatím žádné běhy.",
"channelsHint": "Kanály se nastavují v části Upozornění",
"noChannelsHint": "Upozornění se vždy dostane do vaší schránky. Přidejte si do Upozornění kanály, aby se odesílalo i někam jinam.",
"nameLabel": "Jméno",
"namePlaceholder": "např. Vyčistit /data, když se zaplní",
"nameRequired": "Jméno je povinné",
"descriptionLabel": "Popis",
"descriptionPlaceholder": "Volitelný popis",
"enabledLabel": "Povoleno",
"concurrencyLabel": "Pokud již běží",
"concurrencyHint": "Co dělat, když se to spustí znovu před dokončením posledního běhu.",
"concurrency": {
"skip": "Přeskočit nový běh",
"queue": "Čekej a běž za ním",
"allow": "Spustit obojí najednou"
},
"save": "Uložit",
"created": "Automatizace vytvořena",
"updated": "Automatizace uložena",
"deleted": "Automatizace smazána",
"deleteConfirm": "Smazat tuto automatizaci? Její historie spuštění bude také smazána.",
"runNow": "Běhej hned",
"testRun": "Zkušební provoz",
"testRunHint": "Provádí každý krok, ale nedotýká se ničeho mimo Termix.",
"runStarted": "Běh byl zahájen",
"runFinished": "Běh dokončen: {{status}}",
"trigger": "Spoušť",
"steps": "Kroky",
"addStep": "Přidat krok",
"removeStep": "Odebrat krok",
"moveUp": "Posunout nahoru",
"moveDown": "Přesunout dolů",
"noSteps": "Zatím žádné kroky. Přidejte jeden, který popisuje, co by se mělo stát.",
"webhookTokenTitle": "URL webhooku",
"webhookTokenDescription": "Toto se zobrazí jednou. Uložte si to nyní, později to nelze načíst.",
"copy": "Kopie",
"copied": "Zkopírováno",
"viewJson": "JSON",
"viewBuilder": "Stavitel",
"viewGraph": "Graf",
"lastRun": "Poslední běh",
"never": "Nikdy",
"duration": "{{seconds}}s",
"stepOutput": "Výstup",
"stepError": "Chyba",
"dryRunBadge": "Zkušební provoz",
"hostSelector": {
"trigger": "Spouštěcí hostitel",
"host": "Specifický hostitel",
"fleet": "Loďstvo",
"all": "Všichni hostitelé"
},
"triggerKinds": {
"metric_threshold": "Prahová hodnota metriky",
"host_status": "Hostitel jde nahoru nebo dolů",
"health_check": "Změny v kontrolách stavu",
"schedule": "Podle plánu",
"docker_event": "Událost kontejneru",
"internal_event": "Událost Termix",
"webhook": "Příchozí webhook"
},
"stepTypes": {
"notify": "Odeslat upozornění",
"http": "Zavolat URL",
"run_snippet": "Spustit úryvek",
"run_command": "Spusťte příkaz",
"docker": "Ovládání kontejneru",
"tunnel": "Ovládejte tunel",
"wol": "Probuďte hostitele",
"wait": "Počkejte",
"set_var": "Nastavení proměnné",
"if": "Pokud / jinak",
"run_automation": "Spusťte další automatizaci",
"stop": "Zastávka"
},
"statuses": {
"running": "Běh",
"success": "Úspěch",
"failed": "Selhalo",
"timeout": "Časový limit vypršel",
"skipped": "Přeskočeno",
"cancelled": "Zrušeno"
},
"fields": {
"host": "Hostitel",
"metric": "Metrický",
"mount": "Bod připojení",
"operator": "Je",
"value": "Hodnota",
"forSeconds": "Trvalé po dobu (sekund)",
"cooldown": "Doba ochlazování (minuty)",
"cron": "Cron výraz",
"intervalSeconds": "Každých (sekund)",
"timezone": "Časové pásmo",
"timezoneServer": "Čas serveru",
"timezoneHint": "V této zóně se odečítají časy Cron. Nevztahuje se na intervalové plány.",
"container": "Kontejner",
"command": "Příkaz",
"url": "URL",
"method": "Metoda",
"body": "Tělo",
"channels": "Také poslat na",
"title": "Titul",
"message": "Zpráva",
"snippet": "Úryvek",
"seconds": "Sekundy",
"conditionLeftPlaceholder": "Hodnota ke kontrole",
"conditionRightPlaceholder": "Porovnat s",
"variableName": "Název proměnné",
"variableValue": "Hodnota",
"elevated": "Spustit se sudo",
"onError": "Pokud se to nepodaří",
"allowPrivateNetwork": "Povolit lokální síťové adresy",
"allowPrivateNetworkHint": "Vyžadováno pro službu hostovanou na vašem lokálním serveru (LAN). Ve výchozím nastavení vypnuto, aby se zabránilo požadavkům na interní adresy."
},
"onError": {
"stop": "Zastav běh",
"continue": "Pokračovat"
},
"needsPlugin": "Potřeby {{plugin}}",
"needsPluginHint": "Tato automatizace se přeskočí, dokud nebude znovu k dispozici funkce, kterou potřebuje.",
"offBadge": "Vypnuto",
"docsLink": "Zobrazit dokumenty",
"ago": {
"seconds": "Před {{count}}s",
"minutes": "{{count}}před m",
"hours": "{{count}}před hodinami",
"days": "Před {{count}}dny"
}
}
}
},
"nav": {
"automations": "Automatizace"
},
"onboarding": {
"feature_automations": "Automatizace",
"feature_automations_desc": "Automaticky spouštět kroky, když se něco stane, a dostávat upozornění."
},
"maintenance": {
"title": "Údržba",
"description": "Během probíhající práce pozastavte automatizaci stavu hostitele, jeho kondice, metrik, kontejnerů a odpojení tunelů. Monitorování, ruční spuštění a plánované automatizace pokračují.",
"denied": "Nemáte oprávnění k zobrazení údržby.",
"sharedReadOnly": "Tento hostitel byl s vámi sdílen. Pouze jeho majitel může změnit údržbu.",
"failed": "Údržbu se nepodařilo aktualizovat.",
"active": "Údržba aktivní",
"estimated": "Předpokládané dokončení",
"noAutoEnd": "Odhad údržby neukončuje. Ukončete ji explicitně po dokončení prací.",
"endConfirm": "Ukončit údržbu a obnovit automatizace spouštěné událostmi pro tohoto hostitele?",
"confirmEnd": "Potvrdit konec",
"cancel": "Zrušit",
"end": "Ukončení údržby",
"action": "Akce",
"startNow": "Začněte hned",
"schedule": "Naplánovat na později",
"reason": "Důvod",
"startUtc": "První spuštění (UTC)",
"repeat": "Opakovat",
"once": "Jednou",
"weekly": "Týdně",
"monthly": "Měsíční",
"utcHelp": "Harmonogramy používají po celý rok UTC. Měsíční harmonogramy přeskakují měsíce bez vybraného dne. Plánovaná okna se nemohou překrývat; údržba překročením se provádí i v pozdějších oknech.",
"duration": "Odhadovaná doba trvání (minuty)",
"grace": "Dodatečná lhůta (minuty)",
"notify": "Upozornit jednou, pokud je stále offline po uplynutí odhadované doby plus lhůty.",
"addSchedule": "Přidat rozvrh",
"schedules": "Rozvrhy",
"noSchedules": "Žádné plány údržby.",
"completed": "Již začalo",
"minutes_one": "{{count}} minuta",
"minutes_other": "{{count}} minut",
"remove": "Odebrat plán",
"loadFailed": "Nepodařilo se obnovit stav údržby. Změny jsou zakázány, dokud nebude možné jej načíst.",
"loading": "Načítání stavu údržby…"
}
}
@@ -0,0 +1,211 @@
{
"permissions": {
"view": {
"title": "Se automatiseringer",
"description": "Se automatiseringerne og deres historik."
},
"create": {
"title": "Opret automatiseringer",
"description": "Tilføj nye automatiseringer."
},
"edit": {
"title": "Rediger automatiseringer",
"description": "Ændr eksisterende automatiseringer."
},
"delete": {
"title": "Slet automatiseringer",
"description": "Fjern automatiseringer."
},
"run": {
"title": "Kør automatiseringer",
"description": "Udløs en automatisering manuelt."
}
},
"newUi": {
"sidebar": {
"automations": {
"title": "Automatiseringer",
"tabAutomations": "Automatiseringer",
"tabRuns": "Løber",
"tabChannels": "Kanaler",
"createAutomation": "Ny automatisering",
"backToAutomations": "Tilbage til Automatiseringer",
"createTitle": "Ny automatisering",
"editTitle": "Rediger automatisering",
"empty": "Ingen automatiseringer endnu. Opret en for at reagere på en metric, en tidsplan eller en begivenhed.",
"emptyRuns": "Ingen løb endnu.",
"channelsHint": "Kanaler er konfigureret i advarsler",
"noChannelsHint": "Beskeden sendes altid til din indbakke. Tilføj kanaler i Beskeder for også at sende den et andet sted hen.",
"nameLabel": "Navn",
"namePlaceholder": "f.eks. Ryd op i /data når den fyldes op",
"nameRequired": "Navn er påkrævet",
"descriptionLabel": "Beskrivelse",
"descriptionPlaceholder": "Valgfri beskrivelse",
"enabledLabel": "Aktiveret",
"concurrencyLabel": "Hvis den allerede kører",
"concurrencyHint": "Hvad skal man gøre, når dette udløses igen, før den sidste kørsel er færdig.",
"concurrency": {
"skip": "Spring den nye kørsel over",
"queue": "Vent og løb efter",
"allow": "Kør begge på én gang"
},
"save": "Spare",
"created": "Automatisering skabt",
"updated": "Automatisering gemt",
"deleted": "Automatisering slettet",
"deleteConfirm": "Vil du slette denne automatisering? Dens kørselshistorik slettes også.",
"runNow": "Løb nu",
"testRun": "Testkørsel",
"testRunHint": "Kører hvert trin, men rører ikke noget uden for Termix.",
"runStarted": "Løbet startede",
"runFinished": "Kørsel færdig: {{status}}",
"trigger": "Udløser",
"steps": "Trin",
"addStep": "Tilføj trin",
"removeStep": "Fjern trin",
"moveUp": "Flyt op",
"moveDown": "Flyt ned",
"noSteps": "Ingen trin endnu. Tilføj et trin for at angive, hvad der skal ske.",
"webhookTokenTitle": "Webhook-URL",
"webhookTokenDescription": "Dette vises én gang. Gem det nu, det kan ikke hentes senere.",
"copy": "Kopi",
"copied": "Kopieret",
"viewJson": "JSON",
"viewBuilder": "Byggefirma",
"viewGraph": "Kurve",
"lastRun": "Sidste løb",
"never": "Aldrig",
"duration": "{{seconds}}s",
"stepOutput": "Produktion",
"stepError": "Fejl",
"dryRunBadge": "Testkørsel",
"hostSelector": {
"trigger": "Udløsende vært",
"host": "Specifik vært",
"fleet": "Flåde",
"all": "Alle værter"
},
"triggerKinds": {
"metric_threshold": "Metrisk tærskel",
"host_status": "Værten går op eller ned",
"health_check": "Ændringer i sundhedstjek",
"schedule": "På en tidsplan",
"docker_event": "Containerhændelse",
"internal_event": "Termix-begivenhed",
"webhook": "Indgående webhook"
},
"stepTypes": {
"notify": "Send en alarm",
"http": "Kald en URL",
"run_snippet": "Kør et uddrag",
"run_command": "Kør en kommando",
"docker": "Styr en container",
"tunnel": "Kontroller en tunnel",
"wol": "Væk en vært",
"wait": "Vente",
"set_var": "Angiv en variabel",
"if": "Hvis / ellers",
"run_automation": "Kør en anden automatisering",
"stop": "Stop"
},
"statuses": {
"running": "Løb",
"success": "Succes",
"failed": "Mislykkedes",
"timeout": "Timeout",
"skipped": "Springet over",
"cancelled": "Annulleret"
},
"fields": {
"host": "Vært",
"metric": "Metrisk",
"mount": "Monteringspunkt",
"operator": "Er",
"value": "Værdi",
"forSeconds": "Vedvarende i (sekunder)",
"cooldown": "Nedkølingstid (minutter)",
"cron": "Cron-udtryk",
"intervalSeconds": "Hvert (sekund)",
"timezone": "Tidszone",
"timezoneServer": "Servertid",
"timezoneHint": "Cron-tider aflæses i denne zone. Gælder ikke for intervalplaner.",
"container": "Beholder",
"command": "Kommando",
"url": "URL",
"method": "Metode",
"body": "Legeme",
"channels": "Send også til",
"title": "Titel",
"message": "Besked",
"snippet": "Uddrag",
"seconds": "Sekunder",
"conditionLeftPlaceholder": "Værdi at kontrollere",
"conditionRightPlaceholder": "Sammenlign med",
"variableName": "Variabelnavn",
"variableValue": "Værdi",
"elevated": "Kør med sudo",
"onError": "Hvis dette mislykkes",
"allowPrivateNetwork": "Tillad lokale netværksadresser",
"allowPrivateNetworkHint": "Nødvendig for en selvhostet tjeneste på dit LAN. Deaktiveret som standard for at forhindre anmodninger til interne adresser."
},
"onError": {
"stop": "Stop løbet",
"continue": "Fortsæt"
},
"needsPlugin": "Behøver {{plugin}}",
"needsPluginHint": "Denne automatisering springes over, indtil den nødvendige funktion er tilgængelig igen.",
"offBadge": "Slukket",
"docsLink": "Se dokumenter",
"ago": {
"seconds": "For {{count}}sekunder siden",
"minutes": "For {{count}}minutter siden",
"hours": "For {{count}}timer siden",
"days": "for {{count}}dage siden"
}
}
}
},
"nav": {
"automations": "Automatiseringer"
},
"onboarding": {
"feature_automations": "Automatiseringer",
"feature_automations_desc": "Kør trin automatisk, når der sker noget, og få besked."
},
"maintenance": {
"title": "Opretholdelse",
"description": "Sæt automatiseringer for værtsstatus, tilstand, metrikker, containere og tunnelfrakobling på pause, mens arbejdet er i gang. Overvågning, manuelle kørsel og planlagte automatiseringer fortsætter.",
"denied": "Du har ikke tilladelse til at se vedligeholdelsen.",
"sharedReadOnly": "Denne vært blev delt med dig. Kun dens ejer kan ændre vedligeholdelsen.",
"failed": "Vedligeholdelsen kunne ikke opdateres.",
"active": "Vedligeholdelse aktiv",
"estimated": "Forventet færdiggørelse",
"noAutoEnd": "Estimatet afslutter ikke vedligeholdelsen. Afslut den eksplicit, når arbejdet er færdigt.",
"endConfirm": "Vil du afslutte vedligeholdelsen og genoptage hændelsesudløste automatiseringer for denne vært?",
"confirmEnd": "Bekræft slut",
"cancel": "Ophæve",
"end": "Afslut vedligeholdelse",
"action": "Handling",
"startNow": "Start nu",
"schedule": "Planlæg til senere",
"reason": "Årsag",
"startUtc": "Første start (UTC)",
"repeat": "Gentage",
"once": "Engang",
"weekly": "Ugentlig",
"monthly": "Månedlig",
"utcHelp": "Tidsplaner bruger UTC hele året. Månedlige tidsplaner springer måneder over uden den valgte dag. Planlagte vinduer kan ikke overlappe hinanden; overstyring af vedligeholdelse fortsætter på tværs af senere vinduer.",
"duration": "Estimeret varighed (minutter)",
"grace": "Frist (minutter)",
"notify": "Giv én besked, hvis du stadig er offline efter estimatet plus den henstandsperiode",
"addSchedule": "Tilføj tidsplan",
"schedules": "Tidsplaner",
"noSchedules": "Ingen vedligeholdelsesplaner.",
"completed": "Allerede startet",
"minutes_one": "{{count}} minut",
"minutes_other": "{{count}} minutter",
"remove": "Fjern tidsplan",
"loadFailed": "Vedligeholdelsesstatus kunne ikke opdateres. Ændringer er deaktiveret, indtil den kan indlæses.",
"loading": "Indlæser vedligeholdelsesstatus…"
}
}
@@ -0,0 +1,211 @@
{
"permissions": {
"view": {
"title": "Automatisierungen anzeigen",
"description": "Sehen Sie sich die Automatisierungen und deren Verlauf an."
},
"create": {
"title": "Automatisierungen erstellen",
"description": "Neue Automatisierungen hinzufügen."
},
"edit": {
"title": "Automatisierungen bearbeiten",
"description": "Bestehende Automatisierungen ändern."
},
"delete": {
"title": "Automatisierungen löschen",
"description": "Automatisierungen entfernen."
},
"run": {
"title": "Automatisierungen ausführen",
"description": "Eine Automatisierung manuell auslösen."
}
},
"newUi": {
"sidebar": {
"automations": {
"title": "Automatisierungen",
"tabAutomations": "Automatisierungen",
"tabRuns": "Läufe",
"tabChannels": "Kanäle",
"createAutomation": "Neue Automatisierung",
"backToAutomations": "Zurück zu Automatisierungen",
"createTitle": "Neue Automatisierung",
"editTitle": "Automatisierung bearbeiten",
"empty": "Noch keine Automatisierungen. Erstellen Sie eine, um auf eine Kennzahl, einen Zeitplan oder ein Ereignis zu reagieren.",
"emptyRuns": "Bisher keine Läufe.",
"channelsHint": "Kanäle werden in den Benachrichtigungen eingerichtet.",
"noChannelsHint": "Die Benachrichtigung wird immer in Ihrem Posteingang angezeigt. Fügen Sie in den Benachrichtigungen Kanäle hinzu, um sie auch an andere Orte zu senden.",
"nameLabel": "Name",
"namePlaceholder": "z.B. das Verzeichnis /data leeren, wenn es voll ist",
"nameRequired": "Name ist erforderlich",
"descriptionLabel": "Beschreibung",
"descriptionPlaceholder": "Optionale Beschreibung",
"enabledLabel": "Aktiviert",
"concurrencyLabel": "Wenn es bereits läuft",
"concurrencyHint": "Was ist zu tun, wenn dies erneut ausgelöst wird, bevor der letzte Durchlauf abgeschlossen ist?",
"concurrency": {
"skip": "Den neuen Lauf überspringen.",
"queue": "Warten und dann hinterherlaufen",
"allow": "Führe beide gleichzeitig aus"
},
"save": "Speichern",
"created": "Automatisierung erstellt",
"updated": "Automatisierung gerettet",
"deleted": "Automatisierung gelöscht",
"deleteConfirm": "Wenn Sie diese Automatisierung löschen, wird auch ihr Ausführungsverlauf gelöscht.",
"runNow": "Jetzt laufen",
"testRun": "Probelauf",
"testRunHint": "Führt jeden Schritt aus, greift aber auf nichts außerhalb von Termix ein.",
"runStarted": "Lauf gestartet",
"runFinished": "Lauf beendet: {{status}}",
"trigger": "Auslöser",
"steps": "Schritte",
"addStep": "Schritt hinzufügen",
"removeStep": "Schritt entfernen",
"moveUp": "Aufsteigen",
"moveDown": "Nach unten schieben",
"noSteps": "Noch keine Schritte. Füge einen hinzu, um zu beschreiben, was passieren soll.",
"webhookTokenTitle": "Webhook-URL",
"webhookTokenDescription": "Dies wird nur einmal angezeigt. Speichern Sie es jetzt, es kann später nicht mehr abgerufen werden.",
"copy": "Kopieren",
"copied": "Kopiert",
"viewJson": "JSON",
"viewBuilder": "Bauherr",
"viewGraph": "Graph",
"lastRun": "Letzter Lauf",
"never": "Nie",
"duration": "{{seconds}}s",
"stepOutput": "Ausgabe",
"stepError": "Fehler",
"dryRunBadge": "Probelauf",
"hostSelector": {
"trigger": "Auslösender Host",
"host": "Spezifischer Wirt",
"fleet": "Flotte",
"all": "Alle Hosts"
},
"triggerKinds": {
"metric_threshold": "Metrischer Schwellenwert",
"host_status": "Der Host steigt oder fällt ab",
"health_check": "Änderungen bei Gesundheitschecks",
"schedule": "Nach einem Zeitplan",
"docker_event": "Container-Ereignis",
"internal_event": "Termix-Ereignis",
"webhook": "Eingehender Webhook"
},
"stepTypes": {
"notify": "Benachrichtigung senden",
"http": "URL aufrufen",
"run_snippet": "Führe einen Codeausschnitt aus",
"run_command": "Führe einen Befehl aus",
"docker": "Einen Container steuern",
"tunnel": "Einen Tunnel steuern",
"wol": "Wecken Sie einen Gastgeber",
"wait": "Warten",
"set_var": "Eine Variable setzen",
"if": "Falls / andernfalls",
"run_automation": "Führe eine weitere Automatisierung aus",
"stop": "Stoppen"
},
"statuses": {
"running": "Läuft",
"success": "Erfolgreich",
"failed": "Fehlgeschlagen",
"timeout": "Zeitüberschreitung",
"skipped": "Übersprungen",
"cancelled": "Abgesagt"
},
"fields": {
"host": "Host",
"metric": "Metrik",
"mount": "Montagepunkt",
"operator": "Ist",
"value": "Wert",
"forSeconds": "Anhaltend für (Sekunden)",
"cooldown": "Abklingzeit (Minuten)",
"cron": "Cron-Ausdruck",
"intervalSeconds": "Alle (Sekunden)",
"timezone": "Zeitzone",
"timezoneServer": "Serverzeit",
"timezoneHint": "In dieser Zone werden Cron-Zeiten gelesen. Gilt nicht für Intervallpläne.",
"container": "Container",
"command": "Befehl",
"url": "URL",
"method": "Verfahren",
"body": "Körper",
"channels": "Bitte auch senden an",
"title": "Titel",
"message": "Nachricht",
"snippet": "Ausschnitt",
"seconds": "Sekunden",
"conditionLeftPlaceholder": "Zu prüfender Wert",
"conditionRightPlaceholder": "Vergleiche mit",
"variableName": "Variablenname",
"variableValue": "Wert",
"elevated": "Mit sudo ausführen",
"onError": "Wenn das fehlschlägt",
"allowPrivateNetwork": "Lokale Netzwerkadressen zulassen",
"allowPrivateNetworkHint": "Wird für einen selbstgehosteten Dienst in Ihrem LAN benötigt. Standardmäßig deaktiviert, um Anfragen an interne Adressen zu verhindern."
},
"onError": {
"stop": "Stoppt die Flucht!",
"continue": "Weiter geht's"
},
"needsPlugin": "Benötigt {{plugin}}",
"needsPluginHint": "Diese Automatisierung wird übersprungen, bis die benötigte Funktion wieder verfügbar ist.",
"offBadge": "Aus",
"docsLink": "Dokumentation ansehen",
"ago": {
"seconds": "{{count}}s ago",
"minutes": "vor {{count}} Min.",
"hours": "vor {{count}} Std.",
"days": "vor {{count}} Tagen"
}
}
}
},
"nav": {
"automations": "Automatisierungen"
},
"onboarding": {
"feature_automations": "Automatisierungen",
"feature_automations_desc": "Schritte automatisch ausführen, wenn etwas passiert, und Benachrichtigungen erhalten."
},
"maintenance": {
"title": "Wartung",
"description": "Die Automatisierungen für Hoststatus, Integrität, Metriken, Container- und Tunnel-Trennung werden während der laufenden Arbeiten pausiert. Überwachung, manuelle Ausführungen und geplante Automatisierungen werden fortgesetzt.",
"denied": "Sie haben keine Berechtigung, Wartungsarbeiten anzuzeigen.",
"sharedReadOnly": "Dieser Host wurde Ihnen zur Verfügung gestellt. Nur der Eigentümer kann Wartungsänderungen vornehmen.",
"failed": "Die Wartungsarbeiten konnten nicht aktualisiert werden.",
"active": "Wartung aktiv",
"estimated": "Voraussichtlicher Abschluss",
"noAutoEnd": "Die Kostenschätzung beendet die Wartungsarbeiten nicht. Beenden Sie sie explizit, sobald die Arbeiten abgeschlossen sind.",
"endConfirm": "Wartungsarbeiten beenden und ereignisgesteuerte Automatisierungen für diesen Host wiederaufnehmen?",
"confirmEnd": "Ende bestätigen",
"cancel": "Abbrechen",
"end": "Wartungsarbeiten beenden",
"action": "Aktion",
"startNow": "Jetzt starten",
"schedule": "Termin für später",
"reason": "Grund",
"startUtc": "Erster Start (UTC)",
"repeat": "Wiederholen",
"once": "Einmal",
"weekly": "Wöchentlich",
"monthly": "Monatlich",
"utcHelp": "Die Zeitpläne verwenden ganzjährig UTC. Monatliche Zeitpläne überspringen Monate ohne den ausgewählten Tag. Geplante Zeiträume dürfen sich nicht überschneiden; Wartungsarbeiten, die über die vereinbarten Zeiträume hinausgehen, werden fortgesetzt.",
"duration": "Geschätzte Dauer (Minuten)",
"grace": "Kulanzfrist (Minuten)",
"notify": "Benachrichtigen Sie uns einmalig, falls Sie nach Ablauf der Schätzung und der Kulanzfrist immer noch offline sind.",
"addSchedule": "Zeitplan hinzufügen",
"schedules": "Zeitpläne",
"noSchedules": "Keine Wartungspläne.",
"completed": "Bereits begonnen",
"minutes_one": "{{count}} Minute",
"minutes_other": "{{count}} Minuten",
"remove": "Zeitplan entfernen",
"loadFailed": "Der Wartungsstatus konnte nicht aktualisiert werden. Änderungen sind deaktiviert, bis die Daten geladen werden können.",
"loading": "Lade Wartungsstatus…"
}
}
@@ -0,0 +1,211 @@
{
"permissions": {
"view": {
"title": "Προβολή αυτοματισμών",
"description": "Δείτε τους αυτοματισμούς και το ιστορικό τους."
},
"create": {
"title": "Δημιουργήστε αυτοματισμούς",
"description": "Προσθήκη νέων αυτοματισμών."
},
"edit": {
"title": "Επεξεργασία αυτοματισμών",
"description": "Αλλαγή υπαρχόντων αυτοματισμών."
},
"delete": {
"title": "Διαγραφή αυτοματισμών",
"description": "Αφαίρεση αυτοματισμών."
},
"run": {
"title": "Εκτέλεση αυτοματισμών",
"description": "Ενεργοποιήστε έναν αυτοματισμό χειροκίνητα."
}
},
"newUi": {
"sidebar": {
"automations": {
"title": "Αυτοματισμοί",
"tabAutomations": "Αυτοματισμοί",
"tabRuns": "Τρεξίματα",
"tabChannels": "Κανάλια",
"createAutomation": "Νέος Αυτοματισμός",
"backToAutomations": "Επιστροφή στους Αυτοματισμούς",
"createTitle": "Νέος Αυτοματισμός",
"editTitle": "Επεξεργασία αυτοματισμού",
"empty": "Δεν υπάρχουν ακόμη αυτοματοποιήσεις. Δημιουργήστε μία για να αντιδράτε σε μια μέτρηση, ένα πρόγραμμα ή ένα συμβάν.",
"emptyRuns": "Δεν υπάρχουν τρεξίματα ακόμα.",
"channelsHint": "Τα κανάλια έχουν ρυθμιστεί στις Ειδοποιήσεις",
"noChannelsHint": "Η ειδοποίηση αποστέλλεται πάντα στα εισερχόμενά σας. Προσθέστε κανάλια στις Ειδοποιήσεις για να την στείλετε και κάπου αλλού.",
"nameLabel": "Ονομα",
"namePlaceholder": "π.χ., Καθαρισμός /data όταν γεμίσει",
"nameRequired": "Απαιτείται όνομα",
"descriptionLabel": "Περιγραφή",
"descriptionPlaceholder": "Προαιρετική περιγραφή",
"enabledLabel": "Ενεργοποιημένο",
"concurrencyLabel": "Εάν ήδη εκτελείται",
"concurrencyHint": "Τι πρέπει να κάνετε όταν αυτό ενεργοποιηθεί ξανά πριν ολοκληρωθεί η τελευταία εκτέλεση.",
"concurrency": {
"skip": "Παράλειψη της νέας διαδρομής",
"queue": "Περίμενε και τρέχα πίσω",
"allow": "Εκτελέστε και τα δύο ταυτόχρονα"
},
"save": "Εκτός",
"created": "Αυτοματοποίηση που δημιουργήθηκε",
"updated": "Ο αυτοματισμός αποθηκεύτηκε",
"deleted": "Ο αυτοματισμός διαγράφηκε.",
"deleteConfirm": "Διαγραφή αυτού του αυτοματισμού; Το ιστορικό εκτέλεσής του διαγράφεται επίσης.",
"runNow": "Εκτέλεση τώρα",
"testRun": "Δοκιμαστική εκτέλεση",
"testRunHint": "Εκτελεί κάθε βήμα αλλά δεν αγγίζει τίποτα εκτός του Termix.",
"runStarted": "Η εκτέλεση ξεκίνησε",
"runFinished": "Η εκτέλεση ολοκληρώθηκε: {{status}}",
"trigger": "Σκανδάλη",
"steps": "Βήματα",
"addStep": "Προσθήκη βήματος",
"removeStep": "Αφαίρεση βήματος",
"moveUp": "Μετακίνηση προς τα πάνω",
"moveDown": "Μετακίνηση προς τα κάτω",
"noSteps": "Δεν υπάρχουν ακόμη βήματα. Προσθέστε ένα για να πείτε τι πρέπει να συμβεί.",
"webhookTokenTitle": "URL ιστού",
"webhookTokenDescription": "Αυτό εμφανίζεται μία φορά. Αποθηκεύστε το τώρα, δεν μπορεί να ανακτηθεί αργότερα.",
"copy": "Αντίγραφο",
"copied": "Αντιγράφηκε",
"viewJson": "JSON",
"viewBuilder": "Οικοδόμος",
"viewGraph": "Γραφική παράσταση",
"lastRun": "Τελευταία εκτέλεση",
"never": "Ποτέ",
"duration": "{{seconds}}s",
"stepOutput": "Παραγωγή",
"stepError": "Σφάλμα",
"dryRunBadge": "Δοκιμαστική εκτέλεση",
"hostSelector": {
"trigger": "Ενεργοποίηση κεντρικού υπολογιστή",
"host": "Συγκεκριμένος κεντρικός υπολογιστής",
"fleet": "Στόλος",
"all": "Όλοι οι οικοδεσπότες"
},
"triggerKinds": {
"metric_threshold": "Μετρικό όριο",
"host_status": "Ο κεντρικός υπολογιστής ανεβαίνει ή κατεβαίνει",
"health_check": "Αλλαγές στον έλεγχο υγείας",
"schedule": "Σε πρόγραμμα",
"docker_event": "Συμβάν κοντέινερ",
"internal_event": "Συμβάν Termix",
"webhook": "Εισερχόμενο webhook"
},
"stepTypes": {
"notify": "Αποστολή ειδοποίησης",
"http": "Κλήση μιας διεύθυνσης URL",
"run_snippet": "Εκτέλεση ενός αποσπάσματος",
"run_command": "Εκτέλεση εντολής",
"docker": "Έλεγχος ενός κοντέινερ",
"tunnel": "Έλεγχος μιας σήραγγας",
"wol": "Ξυπνήστε έναν οικοδεσπότη",
"wait": "Περιμένετε",
"set_var": "Ορισμός μεταβλητής",
"if": "Εάν / διαφορετικά",
"run_automation": "Εκτελέστε έναν άλλο αυτοματισμό",
"stop": "Στάση"
},
"statuses": {
"running": "Τρέξιμο",
"success": "Επιτυχία",
"failed": "Αποτυχημένος",
"timeout": "Λήξη χρονικού ορίου",
"skipped": "Παραλήφθηκε",
"cancelled": "Ακυρώθηκε"
},
"fields": {
"host": "Πλήθος",
"metric": "Μετρικός",
"mount": "Σημείο προσάρτησης",
"operator": "Είναι",
"value": "Αξία",
"forSeconds": "Διαρκεί για (δευτερόλεπτα)",
"cooldown": "Χρόνος αναμονής (λεπτά)",
"cron": "Έκφραση Cron",
"intervalSeconds": "Κάθε (δευτερόλεπτα)",
"timezone": "Ζώνη ώρας",
"timezoneServer": "Ώρα διακομιστή",
"timezoneHint": "Οι χρόνοι Cron διαβάζονται σε αυτήν τη ζώνη. Δεν ισχύει για τα χρονοδιαγράμματα διαστημάτων.",
"container": "Δοχείο",
"command": "Εντολή",
"url": "URL",
"method": "Μέθοδος",
"body": "Σώμα",
"channels": "Αποστολή επίσης σε",
"title": "Τίτλος",
"message": "Μήνυμα",
"snippet": "Απόσπασμα",
"seconds": "Δευτερόλεπτα",
"conditionLeftPlaceholder": "Τιμή προς έλεγχο",
"conditionRightPlaceholder": "Σύγκριση με",
"variableName": "Όνομα μεταβλητής",
"variableValue": "Αξία",
"elevated": "Εκτέλεση με sudo",
"onError": "Εάν αυτό αποτύχει",
"allowPrivateNetwork": "Επιτρέψτε τις τοπικές διευθύνσεις δικτύου",
"allowPrivateNetworkHint": "Απαιτείται για μια αυτο-φιλοξενούμενη υπηρεσία στο LAN σας. Απενεργοποιημένο από προεπιλογή για την αποτροπή αιτημάτων σε εσωτερικές διευθύνσεις."
},
"onError": {
"stop": "Σταμάτα το τρέξιμο",
"continue": "Συνεχίζω"
},
"needsPlugin": "Ανάγκες {{plugin}}",
"needsPluginHint": "Αυτή η αυτοματοποίηση παραλείπεται μέχρι να γίνει ξανά διαθέσιμη η λειτουργία που χρειάζεται.",
"offBadge": "Μακριά από",
"docsLink": "Προβολή εγγράφων",
"ago": {
"seconds": "{{count}}πριν από s",
"minutes": "{{count}}πριν από λεπτά",
"hours": "{{count}}πριν από ώρες",
"days": "{{count}}πριν από ημέρες"
}
}
}
},
"nav": {
"automations": "Αυτοματισμοί"
},
"onboarding": {
"feature_automations": "Αυτοματισμοί",
"feature_automations_desc": "Εκτελέστε αυτόματα βήματα όταν συμβεί κάτι και λάβετε ειδοποίηση."
},
"maintenance": {
"title": "Συντήρηση",
"description": "Παύση των αυτοματισμών κατάστασης κεντρικού υπολογιστή, εύρυθμης λειτουργίας, μετρήσεων, κοντέινερ και αποσύνδεσης σήραγγας ενώ οι εργασίες βρίσκονται σε εξέλιξη. Η παρακολούθηση, οι χειροκίνητες εκτελέσεις και οι προγραμματισμένοι αυτοματισμοί συνεχίζονται.",
"denied": "Δεν έχετε άδεια προβολής συντήρησης.",
"sharedReadOnly": "Αυτός ο κεντρικός υπολογιστής κοινοποιήθηκε σε εσάς. Μόνο ο κάτοχός του μπορεί να αλλάξει τη συντήρηση.",
"failed": "Δεν ήταν δυνατή η ενημέρωση της συντήρησης.",
"active": "Ενεργή συντήρηση",
"estimated": "Εκτιμώμενη ολοκλήρωση",
"noAutoEnd": "Η εκτίμηση κόστους δεν τερματίζει τη συντήρηση. Τερματίζει ρητά όταν ολοκληρωθεί η εργασία.",
"endConfirm": "Να τερματιστεί η συντήρηση και να συνεχιστούν οι αυτοματοποιήσεις που ενεργοποιούνται από συμβάντα για αυτόν τον κεντρικό υπολογιστή;",
"confirmEnd": "Επιβεβαίωση τέλους",
"cancel": "Ματαίωση",
"end": "Τέλος συντήρησης",
"action": "Δράση",
"startNow": "Ξεκινήστε τώρα",
"schedule": "Προγραμματισμός για αργότερα",
"reason": "Λόγος",
"startUtc": "Πρώτη εκκίνηση (UTC)",
"repeat": "Επαναλαμβάνω",
"once": "Μια φορά",
"weekly": "Εβδομαδιαίος",
"monthly": "Μηνιαίος",
"utcHelp": "Τα χρονοδιαγράμματα χρησιμοποιούν UTC καθ' όλη τη διάρκεια του έτους. Τα μηνιαία χρονοδιαγράμματα παραλείπουν μήνες χωρίς την επιλεγμένη ημέρα. Τα προγραμματισμένα παράθυρα δεν μπορούν να επικαλύπτονται. Η συντήρηση που εκτελείται εκτός προγράμματος συνεχίζεται και σε μεταγενέστερα παράθυρα.",
"duration": "Εκτιμώμενη διάρκεια (λεπτά)",
"grace": "Περίοδος χάριτος (λεπτά)",
"notify": "Ειδοποιήστε μία φορά εάν εξακολουθείτε να είστε εκτός σύνδεσης μετά την εκτίμηση συν την περίοδο χάριτος",
"addSchedule": "Προσθήκη χρονοδιαγράμματος",
"schedules": "Δρομολόγια",
"noSchedules": "Δεν υπάρχουν χρονοδιαγράμματα συντήρησης.",
"completed": "Ήδη ξεκίνησε",
"minutes_one": "{{count}} λεπτό",
"minutes_other": "{{count}} λεπτά",
"remove": "Κατάργηση χρονοδιαγράμματος",
"loadFailed": "Δεν ήταν δυνατή η ανανέωση της κατάστασης συντήρησης. Οι αλλαγές είναι απενεργοποιημένες μέχρι να είναι δυνατή η φόρτωσή τους.",
"loading": "Φόρτωση κατάστασης συντήρησης…"
}
}
@@ -0,0 +1,211 @@
{
"permissions": {
"view": {
"title": "Ver automatizaciones",
"description": "Consulta las automatizaciones y su historial."
},
"create": {
"title": "Crear automatizaciones",
"description": "Agregar nuevas automatizaciones."
},
"edit": {
"title": "Editar automatizaciones",
"description": "Modificar las automatizaciones existentes."
},
"delete": {
"title": "Eliminar automatizaciones",
"description": "Eliminar automatizaciones."
},
"run": {
"title": "Ejecutar automatizaciones",
"description": "Activar una automatización manualmente."
}
},
"newUi": {
"sidebar": {
"automations": {
"title": "Automatizaciones",
"tabAutomations": "Automatizaciones",
"tabRuns": "Corre",
"tabChannels": "Canales",
"createAutomation": "Nueva automatización",
"backToAutomations": "Volver a Automatizaciones",
"createTitle": "Nueva automatización",
"editTitle": "Automatización de edición",
"empty": "Aún no hay automatizaciones. Crea una para que reaccione a una métrica, una programación o un evento.",
"emptyRuns": "Aún no hay carreras.",
"channelsHint": "Los canales se configuran en Alertas.",
"noChannelsHint": "La alerta siempre llega a tu bandeja de entrada. Agrega canales en Alertas para enviarla también a otros lugares.",
"nameLabel": "Nombre",
"namePlaceholder": "Por ejemplo, limpiar /data cuando se llena",
"nameRequired": "El nombre es obligatorio",
"descriptionLabel": "Descripción",
"descriptionPlaceholder": "Descripción opcional",
"enabledLabel": "Activado",
"concurrencyLabel": "Si ya está en funcionamiento",
"concurrencyHint": "Qué hacer si esto se activa de nuevo antes de que finalice la última ejecución.",
"concurrency": {
"skip": "Saltarse la nueva carrera",
"queue": "Espera y corre tras",
"allow": "Ejecutar ambos a la vez"
},
"save": "Guardar",
"created": "Automatización creada",
"updated": "Automatización guardada",
"deleted": "Automatización eliminada",
"deleteConfirm": "¿Eliminar esta automatización? Su historial de ejecución también se eliminará.",
"runNow": "Corre ahora",
"testRun": "Ejecución de prueba",
"testRunHint": "Ejecuta todos los pasos, pero no toca nada fuera de Termix.",
"runStarted": "La carrera ha comenzado.",
"runFinished": "Ejecución finalizada: {{status}}",
"trigger": "Disparador",
"steps": "Pasos",
"addStep": "Agregar paso",
"removeStep": "Eliminar paso",
"moveUp": "Subir",
"moveDown": "Baja",
"noSteps": "Aún no hay pasos a seguir. Añade uno para indicar qué debería suceder.",
"webhookTokenTitle": "URL del webhook",
"webhookTokenDescription": "Esto se muestra una sola vez. Guárdalo ahora, no se podrá recuperar más tarde.",
"copy": "Copiar",
"copied": "Copiado",
"viewJson": "JSON",
"viewBuilder": "Constructor",
"viewGraph": "Gráfico",
"lastRun": "Última carrera",
"never": "Nunca",
"duration": "{{seconds}}s",
"stepOutput": "Salida",
"stepError": "Error",
"dryRunBadge": "Ejecución de prueba",
"hostSelector": {
"trigger": "Host desencadenante",
"host": "Anfitrión específico",
"fleet": "Flota",
"all": "Todos los hosts"
},
"triggerKinds": {
"metric_threshold": "Umbral métrico",
"host_status": "El host sube o baja",
"health_check": "Cambios en los chequeos médicos",
"schedule": "Según un horario",
"docker_event": "Evento de contenedor",
"internal_event": "Evento Termix",
"webhook": "Webhook entrante"
},
"stepTypes": {
"notify": "Enviar alerta",
"http": "Llamar a una URL",
"run_snippet": "Ejecutar un fragmento",
"run_command": "Ejecutar un comando",
"docker": "Controlar un contenedor",
"tunnel": "Controlar un túnel",
"wol": "Despierta a un anfitrión",
"wait": "Esperar",
"set_var": "Establecer una variable",
"if": "Si / de lo contrario",
"run_automation": "Ejecuta otra automatización",
"stop": "Parada"
},
"statuses": {
"running": "En ejecución",
"success": "Éxito",
"failed": "Fallido",
"timeout": "Tiempo de espera agotado",
"skipped": "Saltado",
"cancelled": "Cancelado"
},
"fields": {
"host": "Host",
"metric": "Métrica",
"mount": "Punto de montaje",
"operator": "Es",
"value": "Valor",
"forSeconds": "Mantenido durante (segundos)",
"cooldown": "Tiempo de espera (minutos)",
"cron": "Expresión cron",
"intervalSeconds": "Cada (segundos)",
"timezone": "Huso horario",
"timezoneServer": "Hora del servidor",
"timezoneHint": "En esta zona se leen las horas cron. No se aplica a los horarios de intervalo.",
"container": "Recipiente",
"command": "Comando",
"url": "URL",
"method": "Método",
"body": "Cuerpo",
"channels": "También enviar a",
"title": "Título",
"message": "Mensaje",
"snippet": "Retazo",
"seconds": "Artículos de segunda clase",
"conditionLeftPlaceholder": "Valor a comprobar",
"conditionRightPlaceholder": "Comparar con",
"variableName": "nombre de la variable",
"variableValue": "Valor",
"elevated": "Ejecutar con sudo",
"onError": "Si esto falla",
"allowPrivateNetwork": "Permitir direcciones de red local",
"allowPrivateNetworkHint": "Necesario para un servicio autohospedado en su LAN. Desactivado por defecto para evitar solicitudes a direcciones internas."
},
"onError": {
"stop": "Detengan la carrera",
"continue": "Continuar"
},
"needsPlugin": "Necesidades {{plugin}}",
"needsPluginHint": "Esta automatización se omite hasta que la función que necesita vuelva a estar disponible.",
"offBadge": "Desactivado",
"docsLink": "Ver documentación",
"ago": {
"seconds": "{{count}}hace s",
"minutes": "hace {{count}}m",
"hours": "hace {{count}}h",
"days": "hace {{count}}d"
}
}
}
},
"nav": {
"automations": "Automatizaciones"
},
"onboarding": {
"feature_automations": "Automatizaciones",
"feature_automations_desc": "Ejecuta pasos automáticamente cuando ocurra algo y recibe notificaciones."
},
"maintenance": {
"title": "Mantenimiento",
"description": "Se pausan las automatizaciones de estado del host, salud, métricas, contenedores y desconexión de túneles mientras se realiza el trabajo. La monitorización, las ejecuciones manuales y las automatizaciones programadas continúan.",
"denied": "No tienes permiso para ver el mantenimiento.",
"sharedReadOnly": "Este servidor se ha compartido contigo. Solo su propietario puede modificar el mantenimiento.",
"failed": "No se pudo actualizar el mantenimiento.",
"active": "Mantenimiento activo",
"estimated": "Finalización estimada",
"noAutoEnd": "El presupuesto no pone fin al mantenimiento. Finalícelo explícitamente cuando el trabajo esté terminado.",
"endConfirm": "¿Finalizar el mantenimiento y reanudar las automatizaciones activadas por eventos para este host?",
"confirmEnd": "Confirmar fin",
"cancel": "Cancelar",
"end": "Fin del mantenimiento",
"action": "Acción",
"startNow": "Empieza ahora",
"schedule": "Programar para más tarde",
"reason": "Razón",
"startUtc": "Primer inicio (UTC)",
"repeat": "Repetir",
"once": "Una vez",
"weekly": "Semanalmente",
"monthly": "Mensual",
"utcHelp": "Los horarios utilizan UTC durante todo el año. Los horarios mensuales omiten los meses que no incluyen el día seleccionado. Los periodos de mantenimiento planificados no pueden superponerse; el mantenimiento prolongado continúa en los periodos posteriores.",
"duration": "Duración estimada (minutos)",
"grace": "Período de gracia (minutos)",
"notify": "Notificar una vez si sigue sin conexión después del período estimado más el período de gracia.",
"addSchedule": "Agregar horario",
"schedules": "Horarios",
"noSchedules": "Sin programas de mantenimiento.",
"completed": "Ya ha comenzado",
"minutes_one": "{{count}} minuto",
"minutes_other": "{{count}} minutos",
"remove": "Eliminar horario",
"loadFailed": "No se pudo actualizar el estado de mantenimiento. Los cambios están deshabilitados hasta que se puedan cargar.",
"loading": "Cargando estado de mantenimiento…"
}
}
@@ -0,0 +1,211 @@
{
"permissions": {
"view": {
"title": "Näytä automaatiot",
"description": "Katso automaatiot ja niiden historia."
},
"create": {
"title": "Luo automaatioita",
"description": "Lisää uusia automaatioita."
},
"edit": {
"title": "Muokkaa automaatioita",
"description": "Muuta olemassa olevia automaatioita."
},
"delete": {
"title": "Poista automaatiot",
"description": "Poista automaatiot."
},
"run": {
"title": "Suorita automaatioita",
"description": "Käynnistä automaatio manuaalisesti."
}
},
"newUi": {
"sidebar": {
"automations": {
"title": "Automaatiot",
"tabAutomations": "Automaatiot",
"tabRuns": "Juoksut",
"tabChannels": "Kanavat",
"createAutomation": "Uusi automaatio",
"backToAutomations": "Takaisin automaatioihin",
"createTitle": "Uusi automaatio",
"editTitle": "Muokkaa automatisointia",
"empty": "Ei vielä automaatioita. Luo sellainen reagoimaan mittariin, aikatauluun tai tapahtumaan.",
"emptyRuns": "Ei vielä juoksuja.",
"channelsHint": "Kanavat määritetään Hälytykset-kohdassa",
"noChannelsHint": "Ilmoitus menee aina postilaatikkoosi. Lisää kanavia Ilmoitukset-kohtaan lähettääksesi sen myös muualle.",
"nameLabel": "Nimi",
"namePlaceholder": "esim. Siivoa /data, kun se täyttyy",
"nameRequired": "Nimi on pakollinen",
"descriptionLabel": "Kuvaus",
"descriptionPlaceholder": "Valinnainen kuvaus",
"enabledLabel": "Käytössä",
"concurrencyLabel": "Jos se on jo käynnissä",
"concurrencyHint": "Mitä tehdä, kun tämä laukeaa uudelleen ennen viimeisen ajon päättymistä.",
"concurrency": {
"skip": "Ohita uusi juoksu",
"queue": "Odota ja juokse perässä",
"allow": "Suorita molemmat yhtä aikaa"
},
"save": "Tallentaa",
"created": "Automaatio luotu",
"updated": "Automaatio tallennettu",
"deleted": "Automaatio poistettu",
"deleteConfirm": "Poistetaanko tämä automaatio? Myös sen suoritushistoria poistetaan.",
"runNow": "Juokse nyt",
"testRun": "Koeajo",
"testRunHint": "Juoksee jokaisen askeleen, mutta ei koske mihinkään Termixin ulkopuolella.",
"runStarted": "Juoksu aloitettu",
"runFinished": "Suoritus päättynyt: {{status}}",
"trigger": "Laukaista",
"steps": "Askeleet",
"addStep": "Lisää vaihe",
"removeStep": "Poista vaihe",
"moveUp": "Siirry ylös",
"moveDown": "Siirrä alas",
"noSteps": "Ei vielä vaiheita. Lisää yksi, joka kertoo, mitä pitäisi tapahtua.",
"webhookTokenTitle": "Webhookin URL-osoite",
"webhookTokenDescription": "Tämä näytetään kerran. Tallenna se nyt, sitä ei voi palauttaa myöhemmin.",
"copy": "Kopioida",
"copied": "Kopioitu",
"viewJson": "JSON",
"viewBuilder": "Rakentaja",
"viewGraph": "Kaavio",
"lastRun": "Viimeisin juoksu",
"never": "Ei koskaan",
"duration": "{{seconds}}s",
"stepOutput": "Lähtö",
"stepError": "Virhe",
"dryRunBadge": "Koeajo",
"hostSelector": {
"trigger": "Käynnistävä isäntä",
"host": "Tietty isäntä",
"fleet": "Laivasto",
"all": "Kaikki isännät"
},
"triggerKinds": {
"metric_threshold": "Metrinen kynnysarvo",
"host_status": "Isäntä nousee tai laskee",
"health_check": "Terveystarkastusten muutokset",
"schedule": "Aikataulussa",
"docker_event": "Säiliötapahtuma",
"internal_event": "Termix-tapahtuma",
"webhook": "Saapuva webhook"
},
"stepTypes": {
"notify": "Lähetä hälytys",
"http": "Soita URL-osoitteeseen",
"run_snippet": "Suorita koodinpätkä",
"run_command": "Suorita komento",
"docker": "Säiliön hallinta",
"tunnel": "Hallitse tunnelia",
"wol": "Herätä isäntä",
"wait": "Odota",
"set_var": "Aseta muuttuja",
"if": "Jos / muuten",
"run_automation": "Suorita toinen automaatio",
"stop": "Stop"
},
"statuses": {
"running": "Juoksu",
"success": "Menestys",
"failed": "Epäonnistui",
"timeout": "Aikakatkaisu",
"skipped": "Ohitettu",
"cancelled": "Peruutettu"
},
"fields": {
"host": "Isäntä",
"metric": "Metrinen",
"mount": "Kiinnityskohta",
"operator": "On",
"value": "Arvo",
"forSeconds": "Jatkuu (sekuntia)",
"cooldown": "Jäähdyttelyaika (minuuttia)",
"cron": "Cron-lauseke",
"intervalSeconds": "Joka (sekunti)",
"timezone": "Aikavyöhyke",
"timezoneServer": "Palvelimen aika",
"timezoneHint": "Cron-ajat luetaan tällä alueella. Ei koske intervalliaikatauluja.",
"container": "Kontti",
"command": "Komento",
"url": "URL-osoite",
"method": "Menetelmä",
"body": "Keho",
"channels": "Lähetä myös osoitteeseen",
"title": "Otsikko",
"message": "Viesti",
"snippet": "Katkelma",
"seconds": "Sekuntia",
"conditionLeftPlaceholder": "Tarkistettava arvo",
"conditionRightPlaceholder": "Vertaa",
"variableName": "Muuttujan nimi",
"variableValue": "Arvo",
"elevated": "Suorita sudon avulla",
"onError": "Jos tämä epäonnistuu",
"allowPrivateNetwork": "Salli paikallisverkko-osoitteet",
"allowPrivateNetworkHint": "Tarvitaan lähiverkossasi itse isännöidylle palvelulle. Oletusarvoisesti pois päältä estääkseen pyynnöt sisäisiin osoitteisiin."
},
"onError": {
"stop": "Pysäytä juoksu",
"continue": "Jatkaa"
},
"needsPlugin": "Tarpeet {{plugin}}",
"needsPluginHint": "Tämä automaatio ohitetaan, kunnes sen tarvitsema ominaisuus on taas käytettävissä.",
"offBadge": "Pois",
"docsLink": "Näytä dokumentit",
"ago": {
"seconds": "{{count}}s sitten",
"minutes": "{{count}}minuuttia sitten",
"hours": "{{count}}tuntia sitten",
"days": "{{count}}d sitten"
}
}
}
},
"nav": {
"automations": "Automaatiot"
},
"onboarding": {
"feature_automations": "Automaatiot",
"feature_automations_desc": "Suorita vaiheet automaattisesti, kun jotain tapahtuu, ja saat ilmoituksen."
},
"maintenance": {
"title": "Huolto",
"description": "Keskeytä isännän tilan, kunnon, mittareiden, säilön ja tunnelin katkaisun automatisoinnit työn ollessa käynnissä. Valvonta, manuaaliset suoritukset ja ajoitetut automatisoinnit jatkuvat.",
"denied": "Sinulla ei ole oikeutta tarkastella ylläpitoa.",
"sharedReadOnly": "Tämä isäntä jaettiin kanssasi. Vain sen omistaja voi muuttaa ylläpitoa.",
"failed": "Ylläpitoa ei voitu päivittää.",
"active": "Huolto aktiivinen",
"estimated": "Arvioitu valmistuminen",
"noAutoEnd": "Arvio ei lopeta huoltoa. Lopeta se nimenomaisesti, kun työ on valmis.",
"endConfirm": "Lopetetaanko ylläpito ja jatketaanko tapahtumien laukaisemia automaatioita tälle isännälle?",
"confirmEnd": "Vahvista loppu",
"cancel": "Peruuttaa",
"end": "Huoltopäätös",
"action": "Toiminta",
"startNow": "Aloita nyt",
"schedule": "Aikatauluta myöhemmin",
"reason": "Syy",
"startUtc": "Ensimmäinen lähtö (UTC)",
"repeat": "Toistaa",
"once": "Kerran",
"weekly": "Viikoittain",
"monthly": "Kuukausittain",
"utcHelp": "Aikataulut käyttävät UTC-aikaa ympäri vuoden. Kuukausiaikataulut ohittavat kuukaudet, joilla ei ole valittua päivää. Suunnitellut ikkunat eivät voi olla päällekkäisiä; ylikuormittava huolto jatkuu myöhemmissä ikkuna-ajoissa.",
"duration": "Arvioitu kesto (minuutteina)",
"grace": "Lisäaika (minuutteina)",
"notify": "Ilmoita kerran, jos olet edelleen offline-tilassa arvion ja lisäajan jälkeen",
"addSchedule": "Lisää aikataulu",
"schedules": "Aikataulut",
"noSchedules": "Ei huoltoaikatauluja.",
"completed": "Jo aloitettu",
"minutes_one": "{{count}} minuutti",
"minutes_other": "{{count}} minuuttia",
"remove": "Poista aikataulu",
"loadFailed": "Ylläpidon tilaa ei voitu päivittää. Muutokset on poistettu käytöstä, kunnes ne voidaan ladata.",
"loading": "Ladataan ylläpitotilaa…"
}
}
@@ -0,0 +1,211 @@
{
"permissions": {
"view": {
"title": "Afficher les automatisations",
"description": "Consultez les automatisations et leur historique."
},
"create": {
"title": "Créer des automatisations",
"description": "Ajouter de nouvelles automatisations."
},
"edit": {
"title": "Modifier les automatisations",
"description": "Modifier les automatisations existantes."
},
"delete": {
"title": "Supprimer les automatisations",
"description": "Supprimer les automatisations."
},
"run": {
"title": "Exécuter des automatisations",
"description": "Déclencher une automatisation manuellement."
}
},
"newUi": {
"sidebar": {
"automations": {
"title": "Automatisations",
"tabAutomations": "Automatisations",
"tabRuns": "Courses",
"tabChannels": "Canaux",
"createAutomation": "Nouvelle automatisation",
"backToAutomations": "Retour aux automatisations",
"createTitle": "Nouvelle automatisation",
"editTitle": "Automatisation de la modification",
"empty": "Aucune automatisation pour le moment. Créez-en une pour qu'elle réagisse à une mesure, une planification ou un événement.",
"emptyRuns": "Pas encore de runs.",
"channelsHint": "Les canaux sont configurés dans les alertes",
"noChannelsHint": "L'alerte est toujours envoyée dans votre boîte de réception. Ajoutez des canaux dans les alertes pour la diffuser également ailleurs.",
"nameLabel": "Nom",
"namePlaceholder": "Par exemple, nettoyer le répertoire /data lorsqu'il est plein.",
"nameRequired": "Le nom est obligatoire",
"descriptionLabel": "Description",
"descriptionPlaceholder": "Description facultative",
"enabledLabel": "Activé",
"concurrencyLabel": "S'il est déjà en cours d'exécution",
"concurrencyHint": "Que faire si ce problème se reproduit avant la fin de la dernière exécution ?",
"concurrency": {
"skip": "Évitez la nouvelle course",
"queue": "Attendez et courez après",
"allow": "Exécutez les deux simultanément."
},
"save": "Enregistrer",
"created": "L'automatisation a créé",
"updated": "L'automatisation a permis de sauver",
"deleted": "Automatisation supprimée",
"deleteConfirm": "Supprimer cette automatisation ? Son historique d'exécution est également supprimé.",
"runNow": "Courez maintenant",
"testRun": "Essai",
"testRunHint": "Exécute chaque étape mais ne touche à rien en dehors de Termix.",
"runStarted": "La course a commencé",
"runFinished": "Course terminée : {{status}}",
"trigger": "Déclencheur",
"steps": "Mesures",
"addStep": "Ajouter une étape",
"removeStep": "Supprimer l'étape",
"moveUp": "Monter en gamme",
"moveDown": "Descendre",
"noSteps": "Aucune étape pour l'instant. Ajoutez-en une pour décrire ce qui doit se passer.",
"webhookTokenTitle": "URL du webhook",
"webhookTokenDescription": "Ceci ne s'affiche qu'une seule fois. Enregistrez-le maintenant, il ne pourra pas être récupéré ultérieurement.",
"copy": "Copier",
"copied": "Copié",
"viewJson": "JSON",
"viewBuilder": "Constructeur",
"viewGraph": "Graphique",
"lastRun": "Dernière course",
"never": "Jamais",
"duration": "{{seconds}}s",
"stepOutput": "Sortie",
"stepError": "Erreur",
"dryRunBadge": "Essai",
"hostSelector": {
"trigger": "Hôte déclencheur",
"host": "hôte spécifique",
"fleet": "Flotte",
"all": "Tous les hôtes"
},
"triggerKinds": {
"metric_threshold": "Seuil métrique",
"host_status": "L'hôte monte ou descend",
"health_check": "changements au bilan de santé",
"schedule": "Selon un horaire",
"docker_event": "événement conteneur",
"internal_event": "Événement Termix",
"webhook": "Webhook entrant"
},
"stepTypes": {
"notify": "Envoyer une alerte",
"http": "Appeler une URL",
"run_snippet": "Exécuter un extrait de code",
"run_command": "Exécuter une commande",
"docker": "Contrôler un conteneur",
"tunnel": "Contrôler un tunnel",
"wol": "Réveillez un hôte",
"wait": "Attendez",
"set_var": "Définir une variable",
"if": "Si / sinon",
"run_automation": "Exécutez une autre automatisation",
"stop": "Stop"
},
"statuses": {
"running": "En cours d’exécution",
"success": "Succès",
"failed": "Échec",
"timeout": "Délai d'attente dépassé",
"skipped": "Omission",
"cancelled": "Annulé"
},
"fields": {
"host": "Hôte",
"metric": "Métrique",
"mount": "Point de montage",
"operator": "Est",
"value": "Valeur",
"forSeconds": "Maintenu pendant (secondes)",
"cooldown": "Temporisation (minutes)",
"cron": "Expression de Cron",
"intervalSeconds": "Toutes les (secondes)",
"timezone": "Fuseau horaire",
"timezoneServer": "Heure du serveur",
"timezoneHint": "Les heures des tâches cron sont lues dans cette zone. Ceci ne s'applique pas aux planifications par intervalles.",
"container": "Récipient",
"command": "Commande",
"url": "URL",
"method": "Méthode",
"body": "Corps",
"channels": "Envoyer également à",
"title": "Titre",
"message": "Message",
"snippet": "Fragment",
"seconds": "Secondes",
"conditionLeftPlaceholder": "Valeur à vérifier",
"conditionRightPlaceholder": "Comparer à",
"variableName": "Nom de la variable",
"variableValue": "Valeur",
"elevated": "Exécuter avec sudo",
"onError": "Si cela échoue",
"allowPrivateNetwork": "Autoriser les adresses réseau locales",
"allowPrivateNetworkHint": "Nécessaire pour un service auto-hébergé sur votre réseau local. Désactivé par défaut pour empêcher les requêtes vers des adresses internes."
},
"onError": {
"stop": "Arrêtez la course",
"continue": "Continuer"
},
"needsPlugin": "Besoins {{plugin}}",
"needsPluginHint": "Cette automatisation est suspendue jusqu'à ce que la fonctionnalité nécessaire soit de nouveau disponible.",
"offBadge": "Désactivée",
"docsLink": "Voir la documentation",
"ago": {
"seconds": "{{count}}il y a s",
"minutes": "il y a {{count}} min",
"hours": "il y a {{count}} h",
"days": "il y a {{count}} j"
}
}
}
},
"nav": {
"automations": "Automatisations"
},
"onboarding": {
"feature_automations": "Automatisations",
"feature_automations_desc": "Exécutez des actions automatiquement lorsqu'un événement se produit et recevez une notification."
},
"maintenance": {
"title": "Maintenance",
"description": "Suspendez les automatisations relatives à l'état des hôtes, à leur santé, aux métriques, aux conteneurs et à la déconnexion des tunnels pendant la durée de l'opération. La surveillance, les exécutions manuelles et les automatisations planifiées se poursuivent.",
"denied": "Vous n'êtes pas autorisé à consulter les informations de maintenance.",
"sharedReadOnly": "Cet hébergement vous a été proposé à la location. Seul son propriétaire peut en modifier la maintenance.",
"failed": "La maintenance n'a pas pu être mise à jour.",
"active": "Maintenance active",
"estimated": "Achèvement estimé",
"noAutoEnd": "Le devis ne met pas fin à la maintenance. Il faut y mettre fin explicitement une fois les travaux terminés.",
"endConfirm": "Mettre fin à la maintenance et reprendre les automatisations déclenchées par des événements pour cet hôte ?",
"confirmEnd": "Confirmer la fin",
"cancel": "Annuler",
"end": "Maintenance finale",
"action": "Action",
"startNow": "Commencez maintenant",
"schedule": "Planifiez plus tard",
"reason": "Raison",
"startUtc": "Premier démarrage (UTC)",
"repeat": "Répéter",
"once": "Une fois",
"weekly": "Hebdomadaire",
"monthly": "Mensuel",
"utcHelp": "Les planifications utilisent le temps universel coordonné (UTC) tout au long de l'année. Les planifications mensuelles ignorent les mois sans le jour sélectionné. Les fenêtres de planification ne peuvent pas se chevaucher ; la maintenance en cours se poursuit sur les fenêtres suivantes.",
"duration": "Durée estimée (minutes)",
"grace": "Délai de grâce (minutes)",
"notify": "Veuillez nous informer une seule fois si nous sommes toujours hors ligne après la période estimée et le délai de grâce. Veuillez nous avertir une fois si nous sommes toujours hors ligne après la période estimée et le délai de grâce.",
"addSchedule": "Ajouter un horaire",
"schedules": "Horaires",
"noSchedules": "Aucun programme de maintenance.",
"completed": "Déjà commencé",
"minutes_one": "{{count}} minute",
"minutes_other": "{{count}} minutes",
"remove": "Supprimer le planning",
"loadFailed": "Impossible d'actualiser l'état de maintenance. Les modifications sont désactivées jusqu'à ce que le chargement soit possible.",
"loading": "État de la maintenance en cours de chargement…"
}
}
@@ -0,0 +1,211 @@
{
"permissions": {
"view": {
"title": "הצג אוטומציות",
"description": "ראה את האוטומציות ואת ההיסטוריה שלהן."
},
"create": {
"title": "צור אוטומציות",
"description": "הוסף אוטומציות חדשות."
},
"edit": {
"title": "עריכת אוטומציות",
"description": "שינוי אוטומציות קיימות."
},
"delete": {
"title": "מחיקת אוטומציות",
"description": "הסר אוטומציות."
},
"run": {
"title": "הפעלת אוטומציות",
"description": "הפעל אוטומציה באופן ידני."
}
},
"newUi": {
"sidebar": {
"automations": {
"title": "אוטומציות",
"tabAutomations": "אוטומציות",
"tabRuns": "ריצות",
"tabChannels": "ערוצים",
"createAutomation": "אוטומציה חדשה",
"backToAutomations": "חזרה לאוטומציות",
"createTitle": "אוטומציה חדשה",
"editTitle": "עריכה אוטומציה",
"empty": "אין עדיין אוטומציות. צור אחת שתגיב למדד, לוח זמנים או אירוע.",
"emptyRuns": "עדיין אין ריצות.",
"channelsHint": "ערוצים מוגדרים בהתראות",
"noChannelsHint": "ההתראה תמיד מגיעה לתיבת הדואר הנכנס שלך. הוסף ערוצים בהתראות כדי לשלוח אותה גם למקום אחר.",
"nameLabel": "שֵׁם",
"namePlaceholder": "לדוגמה, ניקוי /נתונים כאשר הוא מתמלא",
"nameRequired": "שם נדרש",
"descriptionLabel": "תֵאוּר",
"descriptionPlaceholder": "תיאור אופציונלי",
"enabledLabel": "מופעל",
"concurrencyLabel": "אם זה כבר פועל",
"concurrencyHint": "מה לעשות כאשר זה מופעל שוב לפני שהריצה האחרונה הסתיימה.",
"concurrency": {
"skip": "דלג על הריצה החדשה",
"queue": "חכו ורוץ אחרי",
"allow": "הפעל את שניהם בו זמנית"
},
"save": "לְהַצִיל",
"created": "אוטומציה נוצרה",
"updated": "אוטומציה נשמרה",
"deleted": "אוטומציה נמחקה",
"deleteConfirm": "למחוק את האוטומציה הזו? גם היסטוריית הריצות שלה נמחקת.",
"runNow": "רוץ עכשיו",
"testRun": "ריצת מבחן",
"testRunHint": "רץ כל שלב אבל לא נוגע בשום דבר מחוץ לטרמיקס.",
"runStarted": "הריצה התחילה",
"runFinished": "ריצה הסתיימה: {{status}}",
"trigger": "לְהַפְעִיל",
"steps": "צעדים",
"addStep": "הוסף שלב",
"removeStep": "הסר שלב",
"moveUp": "הזז למעלה",
"moveDown": "הזז למטה",
"noSteps": "אין עדיין שלבים. הוסף שלבים כדי לומר מה אמור לקרות.",
"webhookTokenTitle": "כתובת אתר של Webhook",
"webhookTokenDescription": "זה מוצג פעם אחת. שמור את זה עכשיו, לא ניתן לאחזר את זה מאוחר יותר.",
"copy": "לְהַעְתִיק",
"copied": "מוּעֲתָק",
"viewJson": "JSON",
"viewBuilder": "בּוֹנֶה",
"viewGraph": "גרָף",
"lastRun": "ריצה אחרונה",
"never": "לְעוֹלָם לֹא",
"duration": "{{seconds}}ים",
"stepOutput": "תְפוּקָה",
"stepError": "שְׁגִיאָה",
"dryRunBadge": "ריצת מבחן",
"hostSelector": {
"trigger": "מארח מפעיל",
"host": "מארח ספציפי",
"fleet": "צִי",
"all": "כל המארחים"
},
"triggerKinds": {
"metric_threshold": "סף מטרי",
"host_status": "המארח עולה או יורד",
"health_check": "שינויים בבדיקת הבריאות",
"schedule": "על פי לוח זמנים",
"docker_event": "אירוע מיכל",
"internal_event": "אירוע טרמיקס",
"webhook": "וובהוק נכנס"
},
"stepTypes": {
"notify": "שלח התראה",
"http": "קריאה לכתובת URL",
"run_snippet": "הפעלת קטע קוד",
"run_command": "הפעל פקודה",
"docker": "שליטה על מכולה",
"tunnel": "שליטה במנהרה",
"wol": "להעיר מארח",
"wait": "לַחֲכוֹת",
"set_var": "הגדר משתנה",
"if": "אם / אחרת",
"run_automation": "הפעל אוטומציה נוספת",
"stop": "לְהַפְסִיק"
},
"statuses": {
"running": "רִיצָה",
"success": "הַצלָחָה",
"failed": "נִכשָׁל",
"timeout": "תם הזמן",
"skipped": "דילג",
"cancelled": "בּוּטלָה"
},
"fields": {
"host": "מְאָרֵחַ",
"metric": "מֶטרִי",
"mount": "נקודת הרכבה",
"operator": "האם",
"value": "עֵרֶך",
"forSeconds": "נמשך (שניות)",
"cooldown": "זמן קירור (דקות)",
"cron": "ביטוי קרון",
"intervalSeconds": "כל (שניות)",
"timezone": "אזור זמן",
"timezoneServer": "זמן שרת",
"timezoneHint": "זמני Cron נקראים באזור זה. לא חל על לוחות זמנים של מרווחים.",
"container": "מְכוֹלָה",
"command": "פְּקוּדָה",
"url": "כתובת אתר",
"method": "שִׁיטָה",
"body": "גוּף",
"channels": "שלחו גם אל",
"title": "כּוֹתֶרֶת",
"message": "הוֹדָעָה",
"snippet": "קטע",
"seconds": "שניות",
"conditionLeftPlaceholder": "ערך לבדיקה",
"conditionRightPlaceholder": "השווה מול",
"variableName": "שם משתנה",
"variableValue": "עֵרֶך",
"elevated": "רוץ עם סודו",
"onError": "אם זה ייכשל",
"allowPrivateNetwork": "אפשר כתובות רשת מקומיות",
"allowPrivateNetworkHint": "נדרש עבור שירות אירוח עצמי ברשת הרשת המקומית שלך. כבוי כברירת מחדל כדי למנוע בקשות לכתובות פנימיות."
},
"onError": {
"stop": "עצור את הריצה",
"continue": "לְהַמשִׁיך"
},
"needsPlugin": "צרכים {{plugin}}",
"needsPluginHint": "אוטומציה זו מדלגת עד שהתכונה הדרושה לה תהיה זמינה שוב.",
"offBadge": "כבוי",
"docsLink": "הצג מסמכים",
"ago": {
"seconds": "לפני {{count}}שניות",
"minutes": "לפני {{count}}דקות",
"hours": "{{count}}h ago",
"days": "{{count}}d ago"
}
}
}
},
"nav": {
"automations": "אוטומציות"
},
"onboarding": {
"feature_automations": "אוטומציות",
"feature_automations_desc": "הפעל שלבים באופן אוטומטי כאשר משהו קורה, וקבל הודעה."
},
"maintenance": {
"title": "תַחזוּקָה",
"description": "השהה אוטומציות של סטטוס המארח, תקינות, מדדים, ניתוק מנהרות ומכולות בזמן שהעבודה מתבצעת. ניטור, הרצות ידניות ואוטומציות מתוזמנות ממשיכות.",
"denied": "אין לך הרשאה לצפות בתחזוקה.",
"sharedReadOnly": "מארח זה שותף איתך. רק בעליו יכול לשנות את התחזוקה.",
"failed": "לא ניתן היה לעדכן את תוכנית התחזוקה.",
"active": "תחזוקה פעילה",
"estimated": "השלמה משוערת",
"noAutoEnd": "ההערכה אינה מסיימת את התחזוקה. יש לסיים אותה במפורש כאשר העבודה הושלמה.",
"endConfirm": "לסיים את התחזוקה ולחדש אוטומציות המופעלות על ידי אירועים עבור מארח זה?",
"confirmEnd": "אישור סיום",
"cancel": "לְבַטֵל",
"end": "סיום תחזוקה",
"action": "פְּעוּלָה",
"startNow": "התחל עכשיו",
"schedule": "קבע למועד מאוחר יותר",
"reason": "לְנַמֵק",
"startUtc": "התחלה ראשונה (UTC)",
"repeat": "לַחֲזוֹר עַל",
"once": "פַּעַם",
"weekly": "שְׁבוּעִי",
"monthly": "יַרחוֹן",
"utcHelp": "לוחות זמנים משתמשים ב-UTC לאורך כל השנה. לוחות זמנים חודשיים מדלגים על חודשים ללא היום שנבחר. חלונות מתוכננים אינם יכולים לחפוף; תחזוקה מדורגת ממשיכה בחלונות מאוחרים יותר.",
"duration": "משך זמן משוער (בדקות)",
"grace": "תקופת חסד (בדקות)",
"notify": "הודעה אחת אם עדיין לא מחובר לאחר תקופת ההערכה בתוספת חסד",
"addSchedule": "הוסף לוח זמנים",
"schedules": "לוחות זמנים",
"noSchedules": "אין לוחות זמנים לתחזוקה.",
"completed": "כבר התחיל",
"minutes_one": "דקה {{count}}",
"minutes_other": "{{count}} דקות",
"remove": "הסר את לוח הזמנים",
"loadFailed": "לא ניתן היה לרענן את סטטוס התחזוקה. השינויים מושבתים עד שניתן יהיה לטעון אותו.",
"loading": "טוען סטטוס תחזוקה…"
}
}
@@ -0,0 +1,211 @@
{
"permissions": {
"view": {
"title": "View automations",
"description": "See the automations and their history."
},
"create": {
"title": "Create automations",
"description": "Add new automations."
},
"edit": {
"title": "Edit automations",
"description": "Change existing automations."
},
"delete": {
"title": "Delete automations",
"description": "Remove automations."
},
"run": {
"title": "Run automations",
"description": "Trigger an automation by hand."
}
},
"newUi": {
"sidebar": {
"automations": {
"title": "Automations",
"tabAutomations": "Automations",
"tabRuns": "Runs",
"tabChannels": "चैनल",
"createAutomation": "New Automation",
"backToAutomations": "Back to Automations",
"createTitle": "New Automation",
"editTitle": "Edit Automation",
"empty": "No automations yet. Create one to react to a metric, a schedule, or an event.",
"emptyRuns": "No runs yet.",
"channelsHint": "Channels are set up in Alerts",
"noChannelsHint": "The alert always goes to your inbox. Add channels in Alerts to also send it somewhere else.",
"nameLabel": "नाम",
"namePlaceholder": "e.g., Clean up /data when it fills",
"nameRequired": "नाम आवश्यक है",
"descriptionLabel": "विवरण",
"descriptionPlaceholder": "वैकल्पिक विवरण",
"enabledLabel": "सक्षम",
"concurrencyLabel": "If it is already running",
"concurrencyHint": "What to do when this is triggered again before the last run has finished.",
"concurrency": {
"skip": "Skip the new run",
"queue": "Wait and run after",
"allow": "Run both at once"
},
"save": "सहेजें",
"created": "Automation created",
"updated": "Automation saved",
"deleted": "Automation deleted",
"deleteConfirm": "Delete this automation? Its run history is deleted too.",
"runNow": "Run now",
"testRun": "Test run",
"testRunHint": "Runs every step but does not touch anything outside Termix.",
"runStarted": "Run started",
"runFinished": "Run finished: {{status}}",
"trigger": "ट्रिगर",
"steps": "Steps",
"addStep": "Add step",
"removeStep": "Remove step",
"moveUp": "Move up",
"moveDown": "Move down",
"noSteps": "No steps yet. Add one to say what should happen.",
"webhookTokenTitle": "Webhook URL",
"webhookTokenDescription": "This is shown once. Save it now, it cannot be retrieved later.",
"copy": "कॉपी",
"copied": "कॉपी किया गया",
"viewJson": "JSON",
"viewBuilder": "Builder",
"viewGraph": "Graph",
"lastRun": "Last run",
"never": "कभी नहीं",
"duration": "{{seconds}}s",
"stepOutput": "आउटपुट",
"stepError": "त्रुटि",
"dryRunBadge": "Test run",
"hostSelector": {
"trigger": "Triggering host",
"host": "Specific host",
"fleet": "Fleet",
"all": "सभी होस्ट"
},
"triggerKinds": {
"metric_threshold": "Metric threshold",
"host_status": "Host goes up or down",
"health_check": "Health check changes",
"schedule": "On a schedule",
"docker_event": "Container event",
"internal_event": "Termix event",
"webhook": "Incoming webhook"
},
"stepTypes": {
"notify": "Send an alert",
"http": "Call a URL",
"run_snippet": "Run a snippet",
"run_command": "Run a command",
"docker": "Control a container",
"tunnel": "Control a tunnel",
"wol": "Wake a host",
"wait": "Wait",
"set_var": "Set a variable",
"if": "If / otherwise",
"run_automation": "Run another automation",
"stop": "स्टॉप"
},
"statuses": {
"running": "चालू",
"success": "सफल",
"failed": "असफल",
"timeout": "Timed out",
"skipped": "Skipped",
"cancelled": "Cancelled"
},
"fields": {
"host": "होस्ट",
"metric": "मीट्रिक",
"mount": "Mount point",
"operator": "Is",
"value": "Value",
"forSeconds": "Sustained for (seconds)",
"cooldown": "कूलडाउन (मिनट)",
"cron": "Cron expression",
"intervalSeconds": "Every (seconds)",
"timezone": "Time zone",
"timezoneServer": "Server time",
"timezoneHint": "Cron times are read in this zone. Does not apply to interval schedules.",
"container": "Container",
"command": "कमांड",
"url": "URL",
"method": "Method",
"body": "Body",
"channels": "Also send to",
"title": "शीर्षक",
"message": "Message",
"snippet": "Snippet",
"seconds": "Seconds",
"conditionLeftPlaceholder": "Value to check",
"conditionRightPlaceholder": "Compare against",
"variableName": "Variable name",
"variableValue": "Value",
"elevated": "Run with sudo",
"onError": "If this fails",
"allowPrivateNetwork": "Allow local network addresses",
"allowPrivateNetworkHint": "Needed for a self-hosted service on your LAN. Off by default to prevent requests to internal addresses."
},
"onError": {
"stop": "Stop the run",
"continue": "Carry on"
},
"needsPlugin": "Needs {{plugin}}",
"needsPluginHint": "This automation is skipped until the feature it needs is available again.",
"offBadge": "बंद",
"docsLink": "दस्तावेज़ देखें",
"ago": {
"seconds": "{{count}}s ago",
"minutes": "{{count}}m पहले",
"hours": "{{count}}h पहले",
"days": "{{count}}d पहले"
}
}
}
},
"nav": {
"automations": "Automations"
},
"onboarding": {
"feature_automations": "Automations",
"feature_automations_desc": "Run steps automatically when something happens, and get notified."
},
"maintenance": {
"title": "रखरखाव",
"description": "Pause host status, health, metric, container and tunnel-disconnection automations while work is in progress. Monitoring, manual runs and scheduled automations continue.",
"denied": "You do not have permission to view maintenance.",
"sharedReadOnly": "This host was shared with you. Only its owner can change maintenance.",
"failed": "Maintenance could not be updated.",
"active": "Maintenance active",
"estimated": "Estimated completion",
"noAutoEnd": "The estimate does not end maintenance. End it explicitly when the work is complete.",
"endConfirm": "End maintenance and resume event-triggered automations for this host?",
"confirmEnd": "Confirm end",
"cancel": "रद्द करें",
"end": "End maintenance",
"action": "क्रिया",
"startNow": "Start now",
"schedule": "Schedule for later",
"reason": "Reason",
"startUtc": "First start (UTC)",
"repeat": "Repeat",
"once": "Once",
"weekly": "Weekly",
"monthly": "Monthly",
"utcHelp": "Schedules use UTC throughout the year. Monthly schedules skip months without the selected day. Planned windows cannot overlap; overrunning maintenance continues across later windows.",
"duration": "Estimated duration (minutes)",
"grace": "Grace period (minutes)",
"notify": "Notify once if still offline after the estimate plus grace period",
"addSchedule": "Add schedule",
"schedules": "Schedules",
"noSchedules": "No maintenance schedules.",
"completed": "Already started",
"minutes_one": "{{count}} minute",
"minutes_other": "{{count}} minutes",
"remove": "Remove schedule",
"loadFailed": "Could not refresh maintenance status. Changes are disabled until it can be loaded.",
"loading": "Loading maintenance status…"
}
}
@@ -0,0 +1,211 @@
{
"permissions": {
"view": {
"title": "Automatizálások megtekintése",
"description": "Tekintse meg az automatizálásokat és azok előzményeit."
},
"create": {
"title": "Automatizálások létrehozása",
"description": "Új automatizálások hozzáadása."
},
"edit": {
"title": "Automatizálások szerkesztése",
"description": "Módosítsa a meglévő automatizálásokat."
},
"delete": {
"title": "Automatizálások törlése",
"description": "Automatizálások eltávolítása."
},
"run": {
"title": "Automatizálások futtatása",
"description": "Automatizálás manuális indítása."
}
},
"newUi": {
"sidebar": {
"automations": {
"title": "Automatizálások",
"tabAutomations": "Automatizálások",
"tabRuns": "Futások",
"tabChannels": "Csatornák",
"createAutomation": "Új automatizálás",
"backToAutomations": "Vissza az automatizálásokhoz",
"createTitle": "Új automatizálás",
"editTitle": "Szerkesztési automatizálás",
"empty": "Még nincsenek automatizálások. Hozz létre egyet, amely egy mérőszámra, ütemezésre vagy eseményre reagál.",
"emptyRuns": "Még nincsenek futások.",
"channelsHint": "A csatornák a Riasztások menüpontban vannak beállítva.",
"noChannelsHint": "Az értesítés mindig a beérkező levelek mappájába érkezik. Adj hozzá csatornákat az Értesítések részben, hogy máshová is elküldhesd.",
"nameLabel": "Név",
"namePlaceholder": "pl. /data törlése, amikor megtelik",
"nameRequired": "Név megadása kötelező",
"descriptionLabel": "Leírás",
"descriptionPlaceholder": "Opcionális leírás",
"enabledLabel": "Engedélyezve",
"concurrencyLabel": "Ha már fut",
"concurrencyHint": "Mi a teendő, ha ez ismét aktiválódik, mielőtt az utolsó futtatás befejeződött?",
"concurrency": {
"skip": "Kihagyja az új futást",
"queue": "Várj és fuss utána",
"allow": "Futtassa mindkettőt egyszerre"
},
"save": "Megtakarítás",
"created": "Automatizálás létrehozva",
"updated": "Automatizálás mentve",
"deleted": "Automatizálás törölve",
"deleteConfirm": "Törli ezt az automatizálást? A futási előzményei is törlődnek.",
"runNow": "Futtatás most",
"testRun": "Próbaüzem",
"testRunHint": "Minden lépést lefuttat, de a Termixen kívül semmihez sem ér hozzá.",
"runStarted": "Futtatás elkezdve",
"runFinished": "Futtatás befejezve: {{status}}",
"trigger": "Trigger",
"steps": "Lépések",
"addStep": "Lépés hozzáadása",
"removeStep": "Lépés eltávolítása",
"moveUp": "Feljebb mozgatás",
"moveDown": "Lejjebb mozgatás",
"noSteps": "Még nincsenek lépések. Írj be egyet, ami leírja, hogy minek kellene történnie.",
"webhookTokenTitle": "Webhook URL-címe",
"webhookTokenDescription": "Ez egyszer jelenik meg. Mentsd el most, később nem lesz elérhető.",
"copy": "Másolat",
"copied": "Másolva",
"viewJson": "JSON",
"viewBuilder": "Építész",
"viewGraph": "Grafikon",
"lastRun": "Utolsó futás",
"never": "Soha",
"duration": "{{seconds}}s",
"stepOutput": "Kimenet",
"stepError": "Hiba",
"dryRunBadge": "Próbaüzem",
"hostSelector": {
"trigger": "Kiváltó gazdagép",
"host": "Adott gazdagép",
"fleet": "Flotta",
"all": "Minden házigazda"
},
"triggerKinds": {
"metric_threshold": "Metrikus küszöbérték",
"host_status": "A host fel- vagy lekapcsolódik",
"health_check": "Egészségügyi ellenőrzés változásai",
"schedule": "Ütemterv szerint",
"docker_event": "Konténeresemény",
"internal_event": "Termix esemény",
"webhook": "Bejövő webhook"
},
"stepTypes": {
"notify": "Értesítés küldése",
"http": "URL hívása",
"run_snippet": "Kódrészlet futtatása",
"run_command": "Parancs futtatása",
"docker": "Egy konténer vezérlése",
"tunnel": "Irányíts egy alagutat",
"wol": "Ébresszen fel egy házigazdát",
"wait": "Várjon",
"set_var": "Változó beállítása",
"if": "Ha / egyébként",
"run_automation": "Futtasson egy másik automatizálást",
"stop": "Stop"
},
"statuses": {
"running": "Futás",
"success": "Siker",
"failed": "Sikertelen",
"timeout": "Időtúllépés történt",
"skipped": "Kihagyva",
"cancelled": "Lemondva"
},
"fields": {
"host": "Házigazda",
"metric": "Metrika",
"mount": "Csatlakozási pont",
"operator": "Van",
"value": "Érték",
"forSeconds": "Fenntartott (másodperc)",
"cooldown": "Lehűlési idő (perc)",
"cron": "Cron kifejezés",
"intervalSeconds": "Minden (másodperc)",
"timezone": "Időzóna",
"timezoneServer": "Szerveridő",
"timezoneHint": "A Cron idők leolvasása ebben a zónában történik. Nem vonatkozik az intervallumos ütemtervekre.",
"container": "Tartály",
"command": "Parancs",
"url": "URL-cím",
"method": "Módszer",
"body": "Test",
"channels": "Küldés ide is",
"title": "Cím",
"message": "Üzenet",
"snippet": "Töredék",
"seconds": "Másodperc",
"conditionLeftPlaceholder": "Ellenőrizendő érték",
"conditionRightPlaceholder": "Összevet",
"variableName": "Változó neve",
"variableValue": "Érték",
"elevated": "Futtassa a sudo paranccsal",
"onError": "Ha ez nem sikerül",
"allowPrivateNetwork": "Helyi hálózati címek engedélyezése",
"allowPrivateNetworkHint": "Szükséges egy saját tárhelyen futó szolgáltatáshoz a helyi hálózaton. Alapértelmezés szerint ki van kapcsolva, hogy megakadályozza a belső címekre irányuló kéréseket."
},
"onError": {
"stop": "Állítsd meg a futást",
"continue": "Folytat"
},
"needsPlugin": "Szükségletek {{plugin}}",
"needsPluginHint": "Ez az automatizálás kimarad, amíg a szükséges funkció újra elérhetővé nem válik.",
"offBadge": "Le",
"docsLink": "Dokumentumok megtekintése",
"ago": {
"seconds": "{{count}}másodperccel ezelőtt",
"minutes": "{{count}}perccel ezelőtt",
"hours": "{{count}}órával ezelőtt",
"days": "{{count}}nappal ezelőtt"
}
}
}
},
"nav": {
"automations": "Automatizálások"
},
"onboarding": {
"feature_automations": "Automatizálások",
"feature_automations_desc": "Automatikusan futtassa a lépéseket, amikor valami történik, és kapjon értesítést."
},
"maintenance": {
"title": "Karbantartás",
"description": "A folyamatban lévő munka alatt szüneteltesse a hoszt állapot-, egészség-, metrika-, konténer- és alagút-leválasztási automatizálásokat. A monitorozás, a manuális futtatások és az ütemezett automatizálások továbbra is működnek.",
"denied": "Nincs jogosultsága a karbantartás megtekintésére.",
"sharedReadOnly": "Ezt a gazdagépet megosztották veled. Csak a tulajdonosa módosíthatja a karbantartási beállításokat.",
"failed": "A karbantartás frissítése nem sikerült.",
"active": "Karbantartás aktív",
"estimated": "Várható befejezés",
"noAutoEnd": "A becslés nem fejezi be a karbantartást. A munka befejezésekor explicit módon fejezze be.",
"endConfirm": "Befejezi a karbantartást, és folytatja az eseményvezérelt automatizálásokat ennél a gazdagépnél?",
"confirmEnd": "Vég megerősítése",
"cancel": "Mégsem",
"end": "Karbantartás vége",
"action": "Akció",
"startNow": "Kezdés most",
"schedule": "Későbbi időpontra ütemezhető",
"reason": "Ok",
"startUtc": "Első indítás (UTC)",
"repeat": "Ismétlés",
"once": "Egyszer",
"weekly": "Heti",
"monthly": "Havi",
"utcHelp": "Az ütemtervek az év során UTC-t használják. A havi ütemtervek kihagyják a kiválasztott nap nélküli hónapokat. A tervezett ablakok nem fedhetik át egymást; a túlterheléses karbantartás a későbbi ablakokban is folytatódik.",
"duration": "Becsült időtartam (perc)",
"grace": "Türelmi időszak (perc)",
"notify": "Értesítsen egyszer, ha a becslés és a türelmi időszak után is offline állapotban van",
"addSchedule": "Ütemezés hozzáadása",
"schedules": "Ütemtervek",
"noSchedules": "Nincsenek karbantartási ütemtervek.",
"completed": "Már elkezdődött",
"minutes_one": "{{count}} perc",
"minutes_other": "{{count}} perc",
"remove": "Ütemezés eltávolítása",
"loadFailed": "Nem sikerült frissíteni a karbantartási állapotot. A változtatások le vannak tiltva, amíg be nem töltődik.",
"loading": "Karbantartási állapot betöltése…"
}
}
@@ -0,0 +1,211 @@
{
"permissions": {
"view": {
"title": "View automations",
"description": "See the automations and their history."
},
"create": {
"title": "Create automations",
"description": "Add new automations."
},
"edit": {
"title": "Edit automations",
"description": "Change existing automations."
},
"delete": {
"title": "Delete automations",
"description": "Remove automations."
},
"run": {
"title": "Run automations",
"description": "Trigger an automation by hand."
}
},
"newUi": {
"sidebar": {
"automations": {
"title": "Automations",
"tabAutomations": "Automations",
"tabRuns": "Runs",
"tabChannels": "Channels",
"createAutomation": "New Automation",
"backToAutomations": "Back to Automations",
"createTitle": "New Automation",
"editTitle": "Edit Automation",
"empty": "No automations yet. Create one to react to a metric, a schedule, or an event.",
"emptyRuns": "No runs yet.",
"channelsHint": "Channels are set up in Alerts",
"noChannelsHint": "The alert always goes to your inbox. Add channels in Alerts to also send it somewhere else.",
"nameLabel": "Name",
"namePlaceholder": "e.g., Clean up /data when it fills",
"nameRequired": "Name is required",
"descriptionLabel": "Description",
"descriptionPlaceholder": "Optional description",
"enabledLabel": "Enabled",
"concurrencyLabel": "If it is already running",
"concurrencyHint": "What to do when this is triggered again before the last run has finished.",
"concurrency": {
"skip": "Skip the new run",
"queue": "Wait and run after",
"allow": "Run both at once"
},
"save": "Save",
"created": "Automation created",
"updated": "Automation saved",
"deleted": "Automation deleted",
"deleteConfirm": "Delete this automation? Its run history is deleted too.",
"runNow": "Run now",
"testRun": "Test run",
"testRunHint": "Runs every step but does not touch anything outside Termix.",
"runStarted": "Run started",
"runFinished": "Run finished: {{status}}",
"trigger": "Trigger",
"steps": "Steps",
"addStep": "Add step",
"removeStep": "Remove step",
"moveUp": "Move up",
"moveDown": "Move down",
"noSteps": "No steps yet. Add one to say what should happen.",
"webhookTokenTitle": "Webhook URL",
"webhookTokenDescription": "This is shown once. Save it now, it cannot be retrieved later.",
"copy": "Copy",
"copied": "Copied",
"viewJson": "JSON",
"viewBuilder": "Builder",
"viewGraph": "Graph",
"lastRun": "Last run",
"never": "Never",
"duration": "{{seconds}}s",
"stepOutput": "Output",
"stepError": "Error",
"dryRunBadge": "Test run",
"hostSelector": {
"trigger": "Triggering host",
"host": "Specific host",
"fleet": "Fleet",
"all": "All hosts"
},
"triggerKinds": {
"metric_threshold": "Metric threshold",
"host_status": "Host goes up or down",
"health_check": "Health check changes",
"schedule": "On a schedule",
"docker_event": "Container event",
"internal_event": "Termix event",
"webhook": "Incoming webhook"
},
"stepTypes": {
"notify": "Send an alert",
"http": "Call a URL",
"run_snippet": "Run a snippet",
"run_command": "Run a command",
"docker": "Control a container",
"tunnel": "Control a tunnel",
"wol": "Wake a host",
"wait": "Wait",
"set_var": "Set a variable",
"if": "If / otherwise",
"run_automation": "Run another automation",
"stop": "Stop"
},
"statuses": {
"running": "Running",
"success": "Success",
"failed": "Failed",
"timeout": "Timed out",
"skipped": "Skipped",
"cancelled": "Cancelled"
},
"fields": {
"host": "Host",
"metric": "Metric",
"mount": "Mount point",
"operator": "Is",
"value": "Value",
"forSeconds": "Sustained for (seconds)",
"cooldown": "Cooldown (minutes)",
"cron": "Cron expression",
"intervalSeconds": "Every (seconds)",
"timezone": "Time zone",
"timezoneServer": "Server time",
"timezoneHint": "Cron times are read in this zone. Does not apply to interval schedules.",
"container": "Container",
"command": "Command",
"url": "URL",
"method": "Method",
"body": "Body",
"channels": "Also send to",
"title": "Title",
"message": "Message",
"snippet": "Snippet",
"seconds": "Seconds",
"conditionLeftPlaceholder": "Value to check",
"conditionRightPlaceholder": "Compare against",
"variableName": "Variable name",
"variableValue": "Value",
"elevated": "Run with sudo",
"onError": "If this fails",
"allowPrivateNetwork": "Allow local network addresses",
"allowPrivateNetworkHint": "Needed for a self-hosted service on your LAN. Off by default to prevent requests to internal addresses."
},
"onError": {
"stop": "Stop the run",
"continue": "Carry on"
},
"needsPlugin": "Needs {{plugin}}",
"needsPluginHint": "This automation is skipped until the feature it needs is available again.",
"offBadge": "Off",
"docsLink": "View docs",
"ago": {
"seconds": "{{count}}s ago",
"minutes": "{{count}} menit lalu",
"hours": "{{count}} jam lalu",
"days": "{{count}} detik lalu"
}
}
}
},
"nav": {
"automations": "Automations"
},
"onboarding": {
"feature_automations": "Automations",
"feature_automations_desc": "Run steps automatically when something happens, and get notified."
},
"maintenance": {
"title": "Maintenance",
"description": "Pause host status, health, metric, container and tunnel-disconnection automations while work is in progress. Monitoring, manual runs and scheduled automations continue.",
"denied": "You do not have permission to view maintenance.",
"sharedReadOnly": "This host was shared with you. Only its owner can change maintenance.",
"failed": "Maintenance could not be updated.",
"active": "Maintenance active",
"estimated": "Estimated completion",
"noAutoEnd": "The estimate does not end maintenance. End it explicitly when the work is complete.",
"endConfirm": "End maintenance and resume event-triggered automations for this host?",
"confirmEnd": "Confirm end",
"cancel": "Cancel",
"end": "End maintenance",
"action": "Action",
"startNow": "Start now",
"schedule": "Schedule for later",
"reason": "Reason",
"startUtc": "First start (UTC)",
"repeat": "Repeat",
"once": "Once",
"weekly": "Weekly",
"monthly": "Monthly",
"utcHelp": "Schedules use UTC throughout the year. Monthly schedules skip months without the selected day. Planned windows cannot overlap; overrunning maintenance continues across later windows.",
"duration": "Estimated duration (minutes)",
"grace": "Grace period (minutes)",
"notify": "Notify once if still offline after the estimate plus grace period",
"addSchedule": "Add schedule",
"schedules": "Schedules",
"noSchedules": "No maintenance schedules.",
"completed": "Already started",
"minutes_one": "{{count}} minute",
"minutes_other": "{{count}} minutes",
"remove": "Remove schedule",
"loadFailed": "Could not refresh maintenance status. Changes are disabled until it can be loaded.",
"loading": "Loading maintenance status…"
}
}
@@ -0,0 +1,211 @@
{
"permissions": {
"view": {
"title": "Visualizza le automazioni",
"description": "Visualizza le automazioni e la loro cronologia."
},
"create": {
"title": "Creare automazioni",
"description": "Aggiungi nuove automazioni."
},
"edit": {
"title": "Automazioni di modifica",
"description": "Modifica le automazioni esistenti."
},
"delete": {
"title": "Eliminazione delle automazioni",
"description": "Rimuovi le automazioni."
},
"run": {
"title": "Eseguire automazioni",
"description": "Attivare un'automazione manualmente."
}
},
"newUi": {
"sidebar": {
"automations": {
"title": "Automazioni",
"tabAutomations": "Automazioni",
"tabRuns": "Corse",
"tabChannels": "Canali",
"createAutomation": "Nuova automazione",
"backToAutomations": "Torna alle automazioni",
"createTitle": "Nuova automazione",
"editTitle": "Automazione di modifica",
"empty": "Nessuna automazione ancora disponibile. Creane una per reagire a una metrica, a una pianificazione o a un evento.",
"emptyRuns": "Nessuna corsa ancora.",
"channelsHint": "I canali sono configurati negli avvisi",
"noChannelsHint": "L'avviso viene sempre inviato alla tua casella di posta. Aggiungi i canali agli avvisi per inviarlo anche ad altri destinatari.",
"nameLabel": "Nome",
"namePlaceholder": "Ad esempio, pulire /data quando è pieno",
"nameRequired": "Nome obbligatorio",
"descriptionLabel": "Descrizione",
"descriptionPlaceholder": "Descrizione opzionale",
"enabledLabel": "Abilitato",
"concurrencyLabel": "Se è già in esecuzione",
"concurrencyHint": "Cosa fare se questo problema si ripresenta prima che l'ultima esecuzione sia terminata.",
"concurrency": {
"skip": "Salta la nuova corsa",
"queue": "Aspetta e corri dopo",
"allow": "Eseguili entrambi contemporaneamente"
},
"save": "Salva",
"created": "Automazione creata",
"updated": "Automazione salvata",
"deleted": "Automazione eliminata",
"deleteConfirm": "Eliminare questa automazione? Anche la sua cronologia di esecuzione verrà eliminata.",
"runNow": "Corri adesso",
"testRun": "Esecuzione del test",
"testRunHint": "Esegue ogni passaggio ma non tocca nulla al di fuori di Termix.",
"runStarted": "Corsa iniziata",
"runFinished": "Corsa terminata: {{status}}",
"trigger": "Trigger",
"steps": "Passaggi",
"addStep": "Aggiungi passaggio",
"removeStep": "Rimuovere il passaggio",
"moveUp": "Spostarsi verso l'alto",
"moveDown": "Scendendo",
"noSteps": "Nessun passaggio ancora. Aggiungine uno per specificare cosa dovrebbe succedere.",
"webhookTokenTitle": "URL del webhook",
"webhookTokenDescription": "Questa immagine viene visualizzata una sola volta. Salvala ora, non sarà possibile recuperarla in seguito.",
"copy": "Copia",
"copied": "Copiato",
"viewJson": "JSON",
"viewBuilder": "Costruttore",
"viewGraph": "Grafico",
"lastRun": "Prima corsa",
"never": "Mai",
"duration": "{{seconds}}s",
"stepOutput": "Output",
"stepError": "Errore",
"dryRunBadge": "Esecuzione del test",
"hostSelector": {
"trigger": "Host che ha attivato l'evento",
"host": "Ospite specifico",
"fleet": "Flotta",
"all": "Tutti gli host"
},
"triggerKinds": {
"metric_threshold": "Soglia metrica",
"host_status": "L'host sale o scende",
"health_check": "modifiche al controllo sanitario",
"schedule": "Secondo un programma",
"docker_event": "evento contenitore",
"internal_event": "Evento Termix",
"webhook": "Webhook in entrata"
},
"stepTypes": {
"notify": "Invia un avviso",
"http": "Chiamare un medico",
"run_snippet": "Esegui uno snippet",
"run_command": "Eseguire un comando",
"docker": "Controllare un contenitore",
"tunnel": "Controllare un tunnel",
"wol": "Svegliare un ospite",
"wait": "Aspettare",
"set_var": "Imposta una variabile",
"if": "Se / altrimenti",
"run_automation": "Esegui un'altra automazione",
"stop": "Stop"
},
"statuses": {
"running": "In esecuzione",
"success": "Successo",
"failed": "Fallito",
"timeout": "Tempo scaduto",
"skipped": "Saltato",
"cancelled": "Annullato"
},
"fields": {
"host": "Host",
"metric": "Metrica",
"mount": "Punto di appoggio",
"operator": "È",
"value": "Valore",
"forSeconds": "Sostenuto per (secondi)",
"cooldown": "Cooldown (minuti)",
"cron": "espressione Cron",
"intervalSeconds": "Ogni (secondi)",
"timezone": "Fuso orario",
"timezoneServer": "Ora del server",
"timezoneHint": "In questa zona vengono letti i tempi di Cron. Non si applica alle programmazioni a intervalli.",
"container": "Contenitore",
"command": "Comando",
"url": "URL",
"method": "Metodo",
"body": "Corpo",
"channels": "Invia anche a",
"title": "Titolo",
"message": "Messaggio",
"snippet": "Frammento",
"seconds": "secondi",
"conditionLeftPlaceholder": "Valore da controllare",
"conditionRightPlaceholder": "Confronta con",
"variableName": "Nome variabile",
"variableValue": "Valore",
"elevated": "Esegui con sudo",
"onError": "Se questo non funziona",
"allowPrivateNetwork": "Consenti indirizzi di rete locale",
"allowPrivateNetworkHint": "Necessario per un servizio self-hosted sulla tua LAN. Disattivato per impostazione predefinita per impedire richieste a indirizzi interni."
},
"onError": {
"stop": "Ferma la corsa",
"continue": "Proseguire"
},
"needsPlugin": "Bisogno {{plugin}}",
"needsPluginHint": "Questa automazione viene saltata finché la funzionalità necessaria non sarà nuovamente disponibile.",
"offBadge": "Disattivo",
"docsLink": "Vedi documenti",
"ago": {
"seconds": "{{count}}secondi fa",
"minutes": "{{count}}m fa",
"hours": "{{count}}h fa",
"days": "{{count}}d fa"
}
}
}
},
"nav": {
"automations": "Automazioni"
},
"onboarding": {
"feature_automations": "Automazioni",
"feature_automations_desc": "Esegui automaticamente i passaggi quando si verifica un evento e ricevi una notifica."
},
"maintenance": {
"title": "Manutenzione",
"description": "Metti in pausa le automazioni relative allo stato dell'host, all'integrità, alle metriche, ai container e alla disconnessione del tunnel durante l'esecuzione dei lavori. Il monitoraggio, le esecuzioni manuali e le automazioni pianificate continuano.",
"denied": "Non hai l'autorizzazione per visualizzare gli interventi di manutenzione.",
"sharedReadOnly": "Questo host è stato condiviso con te. Solo il proprietario può modificarne la manutenzione.",
"failed": "Impossibile aggiornare la manutenzione.",
"active": "Manutenzione attiva",
"estimated": "Completamento previsto",
"noAutoEnd": "Il preventivo non conclude la manutenzione. La sua conclusione va esplicitamente al termine dei lavori.",
"endConfirm": "Terminare la manutenzione e riprendere le automazioni attivate da eventi per questo host?",
"confirmEnd": "Conferma fine",
"cancel": "Annulla",
"end": "Manutenzione finale",
"action": "Azione",
"startNow": "Inizia ora",
"schedule": "Programma per dopo",
"reason": "Motivo",
"startUtc": "Primo inizio (UTC)",
"repeat": "Ripetere",
"once": "Una volta",
"weekly": "Settimanale",
"monthly": "Mensile",
"utcHelp": "Le pianificazioni utilizzano il fuso orario UTC durante tutto l'anno. Le pianificazioni mensili saltano i mesi in cui non è selezionato il giorno. Le finestre temporali pianificate non possono sovrapporsi; la manutenzione straordinaria si estende anche alle finestre successive.",
"duration": "Durata stimata (minuti)",
"grace": "Periodo di grazia (minuti)",
"notify": "Avvisami una volta se rimani ancora offline dopo il periodo stimato più il periodo di tolleranza.",
"addSchedule": "Aggiungi programma",
"schedules": "Orari",
"noSchedules": "Nessun programma di manutenzione.",
"completed": "iniziato",
"minutes_one": "{{count}} minuto",
"minutes_other": "{{count}} minuti",
"remove": "Rimuovere la pianificazione",
"loadFailed": "Impossibile aggiornare lo stato della manutenzione. Le modifiche sono disabilitate fino al caricamento.",
"loading": "Caricamento dello stato di manutenzione…"
}
}
@@ -0,0 +1,211 @@
{
"permissions": {
"view": {
"title": "自動化を表示する",
"description": "自動化処理とその履歴をご覧ください。"
},
"create": {
"title": "自動化を作成する",
"description": "新しい自動化機能を追加します。"
},
"edit": {
"title": "自動化を編集する",
"description": "既存の自動化プロセスを変更します。"
},
"delete": {
"title": "自動化を削除する",
"description": "自動化機能を削除してください。"
},
"run": {
"title": "自動化を実行する",
"description": "自動化処理を手動で起動する。"
}
},
"newUi": {
"sidebar": {
"automations": {
"title": "自動化",
"tabAutomations": "自動化",
"tabRuns": "走る",
"tabChannels": "チャンネル",
"createAutomation": "新しい自動化",
"backToAutomations": "自動化に戻る",
"createTitle": "新しい自動化",
"editTitle": "編集自動化",
"empty": "自動化はまだ設定されていません。指標、スケジュール、またはイベントに応じて自動化を設定してください。",
"emptyRuns": "まだ得点はありません。",
"channelsHint": "アラートでチャンネルを設定します",
"noChannelsHint": "アラートは常に受信トレイに届きます。アラート設定でチャネルを追加すれば、他の場所にも送信できます。",
"nameLabel": "名前",
"namePlaceholder": "例:/data がいっぱいになったらクリーンアップする",
"nameRequired": "名前は必須です",
"descriptionLabel": "説明",
"descriptionPlaceholder": "任意の説明",
"enabledLabel": "有効",
"concurrencyLabel": "既に実行中の場合",
"concurrencyHint": "前回の実行が終了する前に、これが再び発生した場合の対処法。",
"concurrency": {
"skip": "新しいランをスキップ",
"queue": "待ってから追いかける",
"allow": "両方を同時に実行する"
},
"save": "保存",
"created": "自動化によって作成されました",
"updated": "自動化が保存されました",
"deleted": "自動化が削除されました",
"deleteConfirm": "この自動化を削除すると、実行履歴も削除されます。",
"runNow": "今すぐ実行",
"testRun": "試運転",
"testRunHint": "すべてのステップを実行しますが、Termix以外のものには一切触れません。",
"runStarted": "ランニング開始",
"runFinished": "実行完了: {{status}}",
"trigger": "トリガー",
"steps": "手順",
"addStep": "ステップを追加する",
"removeStep": "削除手順",
"moveUp": "上へ進む",
"moveDown": "下へ移動",
"noSteps": "まだ手順がありません。何が起こるべきかを記述する手順を追加してください。",
"webhookTokenTitle": "Webhook URL",
"webhookTokenDescription": "これは一度しか表示されません。今すぐ保存してください。後で取り出すことはできません。",
"copy": "コピー",
"copied": "コピーしました",
"viewJson": "JSON",
"viewBuilder": "ビルダー",
"viewGraph": "グラフ",
"lastRun": "最後の走行",
"never": "なし",
"duration": "{{seconds}}s",
"stepOutput": "出力",
"stepError": "エラー",
"dryRunBadge": "試運転",
"hostSelector": {
"trigger": "トリガーホスト",
"host": "特定の宿主",
"fleet": "艦隊",
"all": "すべてのホスト"
},
"triggerKinds": {
"metric_threshold": "メトリックしきい値",
"host_status": "ホストが起動または停止する",
"health_check": "健康チェックの変更点",
"schedule": "スケジュール通りに",
"docker_event": "コンテナイベント",
"internal_event": "ターミックスイベント",
"webhook": "受信ウェブフック"
},
"stepTypes": {
"notify": "アラートを送信する",
"http": "URLを呼び出す",
"run_snippet": "スニペットを実行する",
"run_command": "コマンドを実行する",
"docker": "コンテナを制御する",
"tunnel": "トンネルを制御する",
"wol": "ホストを起こす",
"wait": "待って",
"set_var": "変数を設定する",
"if": "もし/そうでなければ",
"run_automation": "別の自動化を実行する",
"stop": "ストップ"
},
"statuses": {
"running": "実行中",
"success": "成功",
"failed": "失敗",
"timeout": "タイムアウト",
"skipped": "スキップしました",
"cancelled": "キャンセル"
},
"fields": {
"host": "ホスト",
"metric": "メトリック",
"mount": "取り付けポイント",
"operator": "は",
"value": "価値",
"forSeconds": "(秒)の間持続",
"cooldown": "クールダウン(分)",
"cron": "Cron発現",
"intervalSeconds": "(秒)ごとに",
"timezone": "タイムゾーン",
"timezoneServer": "サーバー時間",
"timezoneHint": "このゾーンでは、cronの実行時間が読み取られます。インターバルスケジュールには適用されません。",
"container": "容器",
"command": "コマンド",
"url": "URL",
"method": "方法",
"body": "体",
"channels": "また、以下へも送信してください。",
"title": "タイトル",
"message": "メッセージ",
"snippet": "抜粋",
"seconds": "秒",
"conditionLeftPlaceholder": "チェックすべき値",
"conditionRightPlaceholder": "比較対象",
"variableName": "変数名",
"variableValue": "価値",
"elevated": "sudo を付けて実行",
"onError": "これが失敗した場合",
"allowPrivateNetwork": "ローカルネットワークアドレスを許可する",
"allowPrivateNetworkHint": "LAN上でセルフホスト型サービスを実行する場合に必要です。内部アドレスへのリクエストを防ぐため、デフォルトでは無効になっています。"
},
"onError": {
"stop": "走るのをやめろ",
"continue": "続ける"
},
"needsPlugin": "必要数 {{plugin}}",
"needsPluginHint": "この自動化処理は、必要な機能が再び利用可能になるまでスキップされます。",
"offBadge": "オフ",
"docsLink": "ドキュメントを見る",
"ago": {
"seconds": "{{count}}秒前",
"minutes": "{{count}}分前",
"hours": "{{count}}時間前",
"days": "{{count}}日前"
}
}
}
},
"nav": {
"automations": "自動化"
},
"onboarding": {
"feature_automations": "自動化",
"feature_automations_desc": "何かが発生した際に自動的に手順を実行し、通知を受け取ります。"
},
"maintenance": {
"title": "メンテナンス",
"description": "作業中は、ホストの状態、健全性、メトリック、コンテナ、トンネル切断に関する自動化処理を一時停止します。監視、手動実行、およびスケジュールされた自動化処理は継続されます。",
"denied": "メンテナンス情報を閲覧する権限がありません。",
"sharedReadOnly": "このホストはあなたと共有されています。メンテナンス設定を変更できるのは所有者のみです。",
"failed": "メンテナンスの更新ができませんでした。",
"active": "メンテナンスが有効です",
"estimated": "完成予定",
"noAutoEnd": "見積もりはメンテナンスの終了を意味するものではありません。作業が完了した時点で、明示的に終了してください。",
"endConfirm": "このホストのメンテナンスを終了し、イベントトリガーによる自動化を再開しますか?",
"confirmEnd": "終了を確認する",
"cancel": "キャンセル",
"end": "最終メンテナンス",
"action": "アクション",
"startNow": "今すぐ始めましょう",
"schedule": "後でスケジュールする",
"reason": "理由",
"startUtc": "最初の開始時刻(UTC)",
"repeat": "繰り返す",
"once": "一度",
"weekly": "毎週",
"monthly": "月刊",
"utcHelp": "年間を通してスケジュールはUTCを使用します。月間スケジュールでは、選択した日がない月はスキップされます。計画された作業期間は重複できません。作業期間が超過した場合、その超過作業は後の作業期間にも引き継がれます。",
"duration": "所要時間の目安(分)",
"grace": "猶予期間(分)",
"notify": "推定期間と猶予期間を過ぎてもオフライン状態が続く場合は、一度通知してください。",
"addSchedule": "スケジュールを追加",
"schedules": "スケジュール",
"noSchedules": "メンテナンススケジュールはありません。",
"completed": "既に開始済み",
"minutes_one": "{{count}} 分",
"minutes_other": "{{count}} 分",
"remove": "スケジュールを削除",
"loadFailed": "メンテナンス状況を更新できませんでした。読み込みが完了するまで、変更は無効になっています。",
"loading": "メンテナンスステータスをロード中…"
}
}
@@ -0,0 +1,211 @@
{
"permissions": {
"view": {
"title": "자동화 보기",
"description": "자동화 기능과 그 이력을 확인하세요."
},
"create": {
"title": "자동화를 생성하세요",
"description": "새로운 자동화를 추가하세요."
},
"edit": {
"title": "자동화 편집",
"description": "기존 자동화 설정을 변경하세요."
},
"delete": {
"title": "자동화 삭제",
"description": "자동화 기능을 제거하세요."
},
"run": {
"title": "자동화 실행",
"description": "자동화 기능을 수동으로 실행합니다."
}
},
"newUi": {
"sidebar": {
"automations": {
"title": "자동화",
"tabAutomations": "자동화",
"tabRuns": "실행",
"tabChannels": "채널",
"createAutomation": "새로운 자동화",
"backToAutomations": "자동화 목록으로 돌아가기",
"createTitle": "새로운 자동화",
"editTitle": "편집 자동화",
"empty": "아직 자동화가 설정되어 있지 않습니다. 지표, 일정 또는 이벤트에 반응하는 자동화를 생성하세요.",
"emptyRuns": "아직 실행된 적이 없습니다.",
"channelsHint": "알림에 채널이 설정됩니다.",
"noChannelsHint": "알림은 항상 받은 편지함으로 전송됩니다. 알림 설정에서 채널을 추가하면 다른 곳으로도 알림을 보낼 수 있습니다.",
"nameLabel": "이름",
"namePlaceholder": "예: /data 디렉토리가 가득 차면 정리합니다.",
"nameRequired": "이름은 필수입니다",
"descriptionLabel": "설명",
"descriptionPlaceholder": "선택 사항 설명",
"enabledLabel": "사용",
"concurrencyLabel": "이미 실행 중인 경우",
"concurrencyHint": "마지막 실행이 완료되기 전에 이 문제가 다시 발생할 경우 어떻게 해야 할까요?",
"concurrency": {
"skip": "새로운 실행을 건너뛰세요",
"queue": "기다렸다가 실행하세요",
"allow": "두 가지를 동시에 실행하세요"
},
"save": "저장",
"created": "자동화 생성됨",
"updated": "자동화 저장됨",
"deleted": "자동화 삭제됨",
"deleteConfirm": "이 자동화를 삭제하시겠습니까? 실행 기록도 함께 삭제됩니다.",
"runNow": "지금 실행하세요",
"testRun": "테스트 실행",
"testRunHint": "모든 단계를 실행하지만 Termix 외부의 어떤 것도 건드리지 않습니다.",
"runStarted": "실행 시작됨",
"runFinished": "실행 완료: {{status}}",
"trigger": "트리거",
"steps": "단계",
"addStep": "단계 추가",
"removeStep": "제거 단계",
"moveUp": "위로 이동",
"moveDown": "아래로 이동",
"noSteps": "아직 단계가 없습니다. 어떤 일이 일어나야 하는지 설명하는 단계를 추가하세요.",
"webhookTokenTitle": "웹훅 URL",
"webhookTokenDescription": "이 내용은 한 번만 표시됩니다. 지금 저장하세요. 나중에 다시 불러올 수 없습니다.",
"copy": "복사",
"copied": "복사됨",
"viewJson": "JSON",
"viewBuilder": "건축업자",
"viewGraph": "그래프",
"lastRun": "마지막 실행",
"never": "없음",
"duration": "{{seconds}}s",
"stepOutput": "출력",
"stepError": "오류",
"dryRunBadge": "테스트 실행",
"hostSelector": {
"trigger": "트리거링 호스트",
"host": "특정 호스트",
"fleet": "함대",
"all": "모든 호스트"
},
"triggerKinds": {
"metric_threshold": "측정 임계값",
"host_status": "호스트가 올라가거나 내려갑니다.",
"health_check": "건강 검진 변경 사항",
"schedule": "정해진 일정에 따라",
"docker_event": "컨테이너 이벤트",
"internal_event": "터믹스 이벤트",
"webhook": "수신 웹훅"
},
"stepTypes": {
"notify": "알림을 보내세요",
"http": "URL을 호출합니다",
"run_snippet": "코드 조각을 실행하세요",
"run_command": "명령어를 실행하세요",
"docker": "컨테이너를 제어하세요",
"tunnel": "터널을 제어하세요",
"wol": "호스트를 깨우세요",
"wait": "기다리다",
"set_var": "변수를 설정하세요",
"if": "만약 / 그렇지 않다면",
"run_automation": "다른 자동화를 실행합니다",
"stop": "스톱"
},
"statuses": {
"running": "실행 중",
"success": "성공",
"failed": "실패",
"timeout": "시간 초과됨",
"skipped": "건너뛰었습니다",
"cancelled": "취소"
},
"fields": {
"host": "호스트",
"metric": "메트릭",
"mount": "마운트 포인트",
"operator": "~이다",
"value": "값",
"forSeconds": "(초) 동안 지속됨",
"cooldown": "쿨다운 (분)",
"cron": "크론 발현",
"intervalSeconds": "매 (초)",
"timezone": "시간대",
"timezoneServer": "서버 시간",
"timezoneHint": "크론 시간은 이 영역에서 읽습니다. 간격 스케줄에는 적용되지 않습니다.",
"container": "컨테이너",
"command": "명령",
"url": "URL",
"method": "방법",
"body": "몸",
"channels": "또한 다음에도 보내주세요",
"title": "제목",
"message": "메시지",
"snippet": "단편",
"seconds": "초",
"conditionLeftPlaceholder": "확인할 값",
"conditionRightPlaceholder": "~와 비교하세요",
"variableName": "변수 이름",
"variableValue": "값",
"elevated": "sudo를 사용하여 실행하세요",
"onError": "이것이 실패하면",
"allowPrivateNetwork": "로컬 네트워크 주소를 허용합니다",
"allowPrivateNetworkHint": "LAN에서 자체 호스팅 서비스를 운영하는 데 필요합니다. 내부 주소로의 요청을 방지하기 위해 기본적으로 비활성화되어 있습니다."
},
"onError": {
"stop": "달리기를 멈춰라",
"continue": "계속하세요"
},
"needsPlugin": "{{plugin}}가 필요합니다",
"needsPluginHint": "이 자동화 작업은 필요한 기능이 다시 사용 가능해질 때까지 건너뛰어집니다.",
"offBadge": "꺼짐",
"docsLink": "문서 보기",
"ago": {
"seconds": "{{count}}초 전",
"minutes": "{{count}}분 전",
"hours": "{{count}}시간 전",
"days": "{{count}}일 전"
}
}
}
},
"nav": {
"automations": "자동화"
},
"onboarding": {
"feature_automations": "자동화",
"feature_automations_desc": "어떤 상황이 발생하면 자동으로 단계를 실행하고 알림을 받으세요."
},
"maintenance": {
"title": "유지 관리",
"description": "작업이 진행되는 동안 호스트 상태, 상태 모니터링, 메트릭 모니터링, 컨테이너 모니터링 및 터널 연결 해제 자동화가 일시 중지됩니다. 모니터링, 수동 실행 및 예약된 자동화는 계속 실행됩니다.",
"denied": "유지보수 화면을 볼 권한이 없습니다.",
"sharedReadOnly": "이 호스트는 귀하와 공유되었습니다. 유지 관리 변경은 호스트 소유자만 할 수 있습니다.",
"failed": "유지보수 업데이트를 할 수 없습니다.",
"active": "유지보수 중",
"estimated": "예상 완료일",
"noAutoEnd": "견적은 유지보수를 종료하는 것이 아닙니다. 작업이 완료되면 명시적으로 유지보수를 종료하십시오.",
"endConfirm": "이 호스트에 대한 유지 관리를 종료하고 이벤트 기반 자동화를 다시 시작하시겠습니까?",
"confirmEnd": "종료 확인",
"cancel": "취소",
"end": "유지보수 종료",
"action": "작업",
"startNow": "지금 시작하세요",
"schedule": "나중에 일정 잡기",
"reason": "이유",
"startUtc": "첫 시작 (UTC)",
"repeat": "반복하다",
"once": "한 번",
"weekly": "주간",
"monthly": "월간 간행물",
"utcHelp": "일정은 연중 UTC를 기준으로 합니다. 월별 일정은 선택한 날짜가 없는 달은 건너뜁니다. 계획된 기간은 겹칠 수 없으며, 연장된 유지 보수 작업은 이후 기간에도 계속됩니다.",
"duration": "예상 소요 시간(분)",
"grace": "유예 기간(분)",
"notify": "예상 시간 및 유예 기간이 지난 후에도 오프라인 상태인 경우 한 번만 알림을 보내주세요.",
"addSchedule": "일정을 추가하세요",
"schedules": "일정",
"noSchedules": "정기 유지보수 일정이 없습니다.",
"completed": "이미 시작했습니다",
"minutes_one": "{{count}} 분",
"minutes_other": "{{count}} 분",
"remove": "일정을 삭제합니다",
"loadFailed": "점검 상태를 새로 고칠 수 없습니다. 점검 상태가 로드될 때까지 변경 사항이 적용되지 않습니다.",
"loading": "유지보수 상태 로딩 중…"
}
}
@@ -0,0 +1,211 @@
{
"permissions": {
"view": {
"title": "Automatiseringen bekijken",
"description": "Bekijk de automatiseringen en hun geschiedenis."
},
"create": {
"title": "Automatiseringen maken",
"description": "Voeg nieuwe automatiseringen toe."
},
"edit": {
"title": "Automatiseringen bewerken",
"description": "Wijzig bestaande automatiseringsprocessen."
},
"delete": {
"title": "Automatiseringen verwijderen",
"description": "Automatiseringen uitschakelen."
},
"run": {
"title": "Automatiseringen uitvoeren",
"description": "Activeer een automatisering handmatig."
}
},
"newUi": {
"sidebar": {
"automations": {
"title": "Automatiseringen",
"tabAutomations": "Automatiseringen",
"tabRuns": "Loopt",
"tabChannels": "Kanalen",
"createAutomation": "Nieuwe automatisering",
"backToAutomations": "Terug naar automatiseringen",
"createTitle": "Nieuwe automatisering",
"editTitle": "Automatisering bewerken",
"empty": "Nog geen automatiseringen. Maak er een aan die reageert op een meetwaarde, een planning of een gebeurtenis.",
"emptyRuns": "Nog geen runs.",
"channelsHint": "Kanalen worden ingesteld in waarschuwingen.",
"noChannelsHint": "De melding komt altijd in je inbox terecht. Voeg kanalen toe in Meldingen om de melding ook naar een andere locatie te verzenden.",
"nameLabel": "Naam",
"namePlaceholder": "Bijvoorbeeld: maak /data leeg wanneer het vol is.",
"nameRequired": "Naam is verplicht",
"descriptionLabel": "Beschrijving",
"descriptionPlaceholder": "Optionele beschrijving",
"enabledLabel": "Ingeschakeld",
"concurrencyLabel": "Als het al draait",
"concurrencyHint": "Wat te doen als dit opnieuw wordt geactiveerd voordat de laatste run is voltooid?",
"concurrency": {
"skip": "Sla de nieuwe run over",
"queue": "Wacht en ren erachteraan.",
"allow": "Voer ze allebei tegelijk uit"
},
"save": "Redden",
"created": "Automatisering gecreëerd",
"updated": "Automatisering opgeslagen",
"deleted": "Automatisering verwijderd",
"deleteConfirm": "Als je deze automatisering verwijdert, wordt ook de uitvoeringsgeschiedenis ervan verwijderd.",
"runNow": "Ren nu",
"testRun": "Testrun",
"testRunHint": "Het voert elke stap uit, maar raakt niets buiten Termix aan.",
"runStarted": "De run is gestart.",
"runFinished": "Run voltooid: {{status}}",
"trigger": "Trekker",
"steps": "Stappen",
"addStep": "Voeg stap toe",
"removeStep": "Verwijder stap",
"moveUp": "Omhoog gaan",
"moveDown": "Ga naar beneden",
"noSteps": "Nog geen stappen. Voeg er een toe om aan te geven wat er moet gebeuren.",
"webhookTokenTitle": "Webhook-URL",
"webhookTokenDescription": "Dit wordt eenmalig weergegeven. Sla het nu op, het kan later niet meer worden opgevraagd.",
"copy": "Kopiëren",
"copied": "Gekopieerd",
"viewJson": "JSON",
"viewBuilder": "Bouwer",
"viewGraph": "Grafiek",
"lastRun": "Laatste run",
"never": "Nooit",
"duration": "{{seconds}}s",
"stepOutput": "Uitvoer",
"stepError": "Fout",
"dryRunBadge": "Testrun",
"hostSelector": {
"trigger": "Activerende host",
"host": "Specifieke gastheer",
"fleet": "Vloot",
"all": "Alle hosts"
},
"triggerKinds": {
"metric_threshold": "Metrische drempelwaarde",
"host_status": "De host gaat omhoog of omlaag.",
"health_check": "Wijzigingen in de gezondheidscontrole",
"schedule": "Volgens een schema",
"docker_event": "Container-gebeurtenis",
"internal_event": "Termix-evenement",
"webhook": "Inkomende webhook"
},
"stepTypes": {
"notify": "Verzend een melding",
"http": "Roep een URL aan",
"run_snippet": "Voer een codefragment uit",
"run_command": "Voer een opdracht uit",
"docker": "Een container besturen",
"tunnel": "Beheer een tunnel",
"wol": "Maak een gastheer wakker",
"wait": "Wachten",
"set_var": "Een variabele instellen",
"if": "Indien / anders",
"run_automation": "Voer nog een automatisering uit",
"stop": "Stop"
},
"statuses": {
"running": "Rennen",
"success": "Succes",
"failed": "Mislukt",
"timeout": "De tijd is verlopen.",
"skipped": "Overgeslagen",
"cancelled": "Geannuleerd"
},
"fields": {
"host": "Gastheer",
"metric": "Metrisch",
"mount": "Bergpunt",
"operator": "Is",
"value": "Waarde",
"forSeconds": "Aangehouden gedurende (seconden)",
"cooldown": "Afkoeltijd (minuten)",
"cron": "Cron-expressie",
"intervalSeconds": "Elke (seconden)",
"timezone": "Tijdzone",
"timezoneServer": "Servertijd",
"timezoneHint": "In deze zone worden de cron-tijden gelezen. Dit geldt niet voor intervalschema's.",
"container": "Container",
"command": "Commando",
"url": "URL",
"method": "Methode",
"body": "Lichaam",
"channels": "Ook verzenden naar",
"title": "Titel",
"message": "Bericht",
"snippet": "Fragment",
"seconds": "Seconden",
"conditionLeftPlaceholder": "Waarde om te controleren",
"conditionRightPlaceholder": "Vergelijk met",
"variableName": "Variabelenaam",
"variableValue": "Waarde",
"elevated": "Uitvoeren met sudo",
"onError": "Als dit mislukt",
"allowPrivateNetwork": "Sta lokale netwerkadressen toe.",
"allowPrivateNetworkHint": "Nodig voor een zelfgehoste service op uw LAN. Standaard uitgeschakeld om verzoeken naar interne adressen te voorkomen."
},
"onError": {
"stop": "Stop de loop",
"continue": "Ga zo door"
},
"needsPlugin": "Vereist {{plugin}}",
"needsPluginHint": "Deze automatisering wordt overgeslagen totdat de benodigde functie weer beschikbaar is.",
"offBadge": "Uit",
"docsLink": "Documenten bekijken",
"ago": {
"seconds": "{{count}}s geleden",
"minutes": "{{count}}m geleden",
"hours": "{{count}}uur geleden",
"days": "{{count}}dagen geleden"
}
}
}
},
"nav": {
"automations": "Automatiseringen"
},
"onboarding": {
"feature_automations": "Automatiseringen",
"feature_automations_desc": "Voer stappen automatisch uit wanneer er iets gebeurt en ontvang meldingen."
},
"maintenance": {
"title": "Onderhoud",
"description": "Pauzeer de automatiseringsprocessen voor hoststatus, gezondheid, metrics, containers en tunnelontkoppeling terwijl de werkzaamheden worden uitgevoerd. Monitoring, handmatige uitvoeringen en geplande automatiseringsprocessen worden wel voortgezet.",
"denied": "U hebt geen toestemming om onderhoudswerkzaamheden te bekijken.",
"sharedReadOnly": "Deze host is met u gedeeld. Alleen de eigenaar kan het onderhoud wijzigen.",
"failed": "Het onderhoud kon niet worden bijgewerkt.",
"active": "Onderhoud actief",
"estimated": "Geschatte voltooiing",
"noAutoEnd": "De begroting beëindigt het onderhoud niet. Beëindig het expliciet wanneer de werkzaamheden zijn voltooid.",
"endConfirm": "Onderhoud beëindigen en gebeurtenisgestuurde automatiseringen voor deze host hervatten?",
"confirmEnd": "Bevestig einde",
"cancel": "Annuleren",
"end": "Einde onderhoud",
"action": "Actie",
"startNow": "Begin nu",
"schedule": "Plan voor later",
"reason": "Reden",
"startUtc": "Eerste start (UTC)",
"repeat": "Herhalen",
"once": "Eenmaal",
"weekly": "Wekelijks",
"monthly": "Maandelijks",
"utcHelp": "De planning gebruikt het hele jaar door UTC. Maandelijkse planningen slaan maanden over waarin de geselecteerde dag niet voorkomt. Geplande onderhoudsvensters mogen elkaar niet overlappen; uitlopend onderhoud wordt voortgezet in latere vensters.",
"duration": "Geschatte duur (minuten)",
"grace": "Respijtperiode (minuten)",
"notify": "Eenmalige melding verzenden indien de verbinding na de geschatte tijd plus de respijtperiode nog steeds verbroken is.",
"addSchedule": "Voeg een schema toe",
"schedules": "Roosters",
"noSchedules": "Geen onderhoudsschema's.",
"completed": "Reeds begonnen",
"minutes_one": "{{count}} minuut",
"minutes_other": "{{count}} minuten",
"remove": "Schema verwijderen",
"loadFailed": "De onderhoudsstatus kon niet worden vernieuwd. Wijzigingen zijn uitgeschakeld totdat de status kan worden geladen.",
"loading": "Onderhoudsstatus laden…"
}
}
@@ -0,0 +1,211 @@
{
"permissions": {
"view": {
"title": "View automations",
"description": "See the automations and their history."
},
"create": {
"title": "Create automations",
"description": "Add new automations."
},
"edit": {
"title": "Edit automations",
"description": "Change existing automations."
},
"delete": {
"title": "Delete automations",
"description": "Remove automations."
},
"run": {
"title": "Run automations",
"description": "Trigger an automation by hand."
}
},
"newUi": {
"sidebar": {
"automations": {
"title": "Automations",
"tabAutomations": "Automations",
"tabRuns": "Runs",
"tabChannels": "Channels",
"createAutomation": "New Automation",
"backToAutomations": "Back to Automations",
"createTitle": "New Automation",
"editTitle": "Edit Automation",
"empty": "No automations yet. Create one to react to a metric, a schedule, or an event.",
"emptyRuns": "No runs yet.",
"channelsHint": "Channels are set up in Alerts",
"noChannelsHint": "The alert always goes to your inbox. Add channels in Alerts to also send it somewhere else.",
"nameLabel": "Navn",
"namePlaceholder": "e.g., Clean up /data when it fills",
"nameRequired": "Navn er påkrevd",
"descriptionLabel": "Beskrivelse",
"descriptionPlaceholder": "Valgfri beskrivelse",
"enabledLabel": "Aktivert",
"concurrencyLabel": "If it is already running",
"concurrencyHint": "What to do when this is triggered again before the last run has finished.",
"concurrency": {
"skip": "Skip the new run",
"queue": "Wait and run after",
"allow": "Run both at once"
},
"save": "Lagre",
"created": "Automation created",
"updated": "Automation saved",
"deleted": "Automation deleted",
"deleteConfirm": "Delete this automation? Its run history is deleted too.",
"runNow": "Run now",
"testRun": "Test run",
"testRunHint": "Runs every step but does not touch anything outside Termix.",
"runStarted": "Run started",
"runFinished": "Run finished: {{status}}",
"trigger": "Trigger",
"steps": "Steps",
"addStep": "Add step",
"removeStep": "Remove step",
"moveUp": "Move up",
"moveDown": "Move down",
"noSteps": "No steps yet. Add one to say what should happen.",
"webhookTokenTitle": "Webhook URL",
"webhookTokenDescription": "This is shown once. Save it now, it cannot be retrieved later.",
"copy": "Kopier",
"copied": "Copied",
"viewJson": "JSON",
"viewBuilder": "Builder",
"viewGraph": "Graph",
"lastRun": "Last run",
"never": "Never",
"duration": "{{seconds}}s",
"stepOutput": "Output",
"stepError": "Feil",
"dryRunBadge": "Test run",
"hostSelector": {
"trigger": "Triggering host",
"host": "Specific host",
"fleet": "Fleet",
"all": "All hosts"
},
"triggerKinds": {
"metric_threshold": "Metric threshold",
"host_status": "Host goes up or down",
"health_check": "Health check changes",
"schedule": "On a schedule",
"docker_event": "Container event",
"internal_event": "Termix event",
"webhook": "Incoming webhook"
},
"stepTypes": {
"notify": "Send an alert",
"http": "Call a URL",
"run_snippet": "Run a snippet",
"run_command": "Run a command",
"docker": "Control a container",
"tunnel": "Control a tunnel",
"wol": "Wake a host",
"wait": "Wait",
"set_var": "Set a variable",
"if": "If / otherwise",
"run_automation": "Run another automation",
"stop": "Stopp"
},
"statuses": {
"running": "Kjører",
"success": "Suksess",
"failed": "Mislyktes",
"timeout": "Timed out",
"skipped": "Skipped",
"cancelled": "Cancelled"
},
"fields": {
"host": "Host",
"metric": "Metric",
"mount": "Mount point",
"operator": "Is",
"value": "Value",
"forSeconds": "Sustained for (seconds)",
"cooldown": "Cooldown (minutes)",
"cron": "Cron expression",
"intervalSeconds": "Every (seconds)",
"timezone": "Time zone",
"timezoneServer": "Server time",
"timezoneHint": "Cron times are read in this zone. Does not apply to interval schedules.",
"container": "Container",
"command": "Kommando",
"url": "URL",
"method": "Method",
"body": "Body",
"channels": "Also send to",
"title": "Title",
"message": "Message",
"snippet": "Snippet",
"seconds": "Seconds",
"conditionLeftPlaceholder": "Value to check",
"conditionRightPlaceholder": "Compare against",
"variableName": "Variable name",
"variableValue": "Value",
"elevated": "Run with sudo",
"onError": "If this fails",
"allowPrivateNetwork": "Allow local network addresses",
"allowPrivateNetworkHint": "Needed for a self-hosted service on your LAN. Off by default to prevent requests to internal addresses."
},
"onError": {
"stop": "Stop the run",
"continue": "Carry on"
},
"needsPlugin": "Needs {{plugin}}",
"needsPluginHint": "This automation is skipped until the feature it needs is available again.",
"offBadge": "Off",
"docsLink": "View docs",
"ago": {
"seconds": "{{count}}s ago",
"minutes": "{{count}}m ago",
"hours": "{{count}}h ago",
"days": "{{count}}d ago"
}
}
}
},
"nav": {
"automations": "Automations"
},
"onboarding": {
"feature_automations": "Automations",
"feature_automations_desc": "Run steps automatically when something happens, and get notified."
},
"maintenance": {
"title": "Vedlikehold",
"description": "Pause host status, health, metric, container and tunnel-disconnection automations while work is in progress. Monitoring, manual runs and scheduled automations continue.",
"denied": "You do not have permission to view maintenance.",
"sharedReadOnly": "This host was shared with you. Only its owner can change maintenance.",
"failed": "Maintenance could not be updated.",
"active": "Maintenance active",
"estimated": "Estimated completion",
"noAutoEnd": "The estimate does not end maintenance. End it explicitly when the work is complete.",
"endConfirm": "End maintenance and resume event-triggered automations for this host?",
"confirmEnd": "Confirm end",
"cancel": "Avbryt",
"end": "End maintenance",
"action": "Action",
"startNow": "Start now",
"schedule": "Schedule for later",
"reason": "Reason",
"startUtc": "First start (UTC)",
"repeat": "Repeat",
"once": "Once",
"weekly": "Weekly",
"monthly": "Monthly",
"utcHelp": "Schedules use UTC throughout the year. Monthly schedules skip months without the selected day. Planned windows cannot overlap; overrunning maintenance continues across later windows.",
"duration": "Estimated duration (minutes)",
"grace": "Grace period (minutes)",
"notify": "Notify once if still offline after the estimate plus grace period",
"addSchedule": "Add schedule",
"schedules": "Schedules",
"noSchedules": "No maintenance schedules.",
"completed": "Already started",
"minutes_one": "{{count}} minute",
"minutes_other": "{{count}} minutes",
"remove": "Remove schedule",
"loadFailed": "Could not refresh maintenance status. Changes are disabled until it can be loaded.",
"loading": "Loading maintenance status…"
}
}
@@ -0,0 +1,211 @@
{
"permissions": {
"view": {
"title": "View automations",
"description": "See the automations and their history."
},
"create": {
"title": "Create automations",
"description": "Add new automations."
},
"edit": {
"title": "Edit automations",
"description": "Change existing automations."
},
"delete": {
"title": "Delete automations",
"description": "Remove automations."
},
"run": {
"title": "Run automations",
"description": "Trigger an automation by hand."
}
},
"newUi": {
"sidebar": {
"automations": {
"title": "Automatyzacje",
"tabAutomations": "Automatyzacje",
"tabRuns": "Uruchomienia",
"tabChannels": "Kanały",
"createAutomation": "Nowa automatyzacja",
"backToAutomations": "Powrót do automatyzacji",
"createTitle": "Nowa automatyzacja",
"editTitle": "Edytuj automatyzację",
"empty": "Brak jeszcze automatyzacji. Utwórz automatyzację, która będzie reagować na metrykę, harmonogram lub zdarzenie.",
"emptyRuns": "Brak uruchomień.",
"channelsHint": "Channels are set up in Alerts",
"noChannelsHint": "The alert always goes to your inbox. Add channels in Alerts to also send it somewhere else.",
"nameLabel": "Nazwa",
"namePlaceholder": "np. Wyczyść /data, gdy się zapełni",
"nameRequired": "Nazwa jest wymagana",
"descriptionLabel": "Opis",
"descriptionPlaceholder": "Opis opcjonalny",
"enabledLabel": "Włączona",
"concurrencyLabel": "Jeśli już działa",
"concurrencyHint": "Co zrobić, gdy zostanie wyzwolona ponownie przed zakończeniem poprzedniego uruchomienia.",
"concurrency": {
"skip": "Pomiń nowe uruchomienie",
"queue": "Poczekaj i uruchom po zakończeniu",
"allow": "Uruchom oba naraz"
},
"save": "Zapisz",
"created": "Utworzono automatyzację",
"updated": "Zapisano automatyzację",
"deleted": "Usunięto automatyzację",
"deleteConfirm": "Usunąć tę automatyzację? Jej historia uruchomień również zostanie usunięta.",
"runNow": "Uruchom teraz",
"testRun": "Uruchomienie próbne",
"testRunHint": "Wykonuje każdy krok, ale nie zmienia niczego poza Termixem.",
"runStarted": "Uruchomiono",
"runFinished": "Zakończono: {{status}}",
"trigger": "Wyzwalacz",
"steps": "Kroki",
"addStep": "Dodaj krok",
"removeStep": "Usuń krok",
"moveUp": "W górę",
"moveDown": "W dół",
"noSteps": "Brak kroków. Dodaj jeden, aby określić, co powinno się stać.",
"webhookTokenTitle": "Adres URL webhooka",
"webhookTokenDescription": "Pokazywany tylko raz. Zapisz go teraz, później nie da się go odzyskać.",
"copy": "Kopiuj",
"copied": "Skopiowano",
"viewJson": "JSON",
"viewBuilder": "Kreator",
"viewGraph": "Graf",
"lastRun": "Ostatnie uruchomienie",
"never": "Nigdy",
"duration": "{{seconds}}s",
"stepOutput": "Wyjście",
"stepError": "Błąd",
"dryRunBadge": "Uruchomienie próbne",
"hostSelector": {
"trigger": "Host wyzwalający",
"host": "Konkretny host",
"fleet": "Flota",
"all": "Wszystkie hosty"
},
"triggerKinds": {
"metric_threshold": "Próg metryki",
"host_status": "Host wstaje lub pada",
"health_check": "Zmiana kontroli stanu",
"schedule": "Zgodnie z harmonogramem",
"docker_event": "Zdarzenie kontenera",
"internal_event": "Zdarzenie Termixa",
"webhook": "Przychodzący webhook"
},
"stepTypes": {
"notify": "Send an alert",
"http": "Wywołaj adres URL",
"run_snippet": "Uruchom snippet",
"run_command": "Uruchom polecenie",
"docker": "Kontroluj kontener",
"tunnel": "Kontroluj tunel",
"wol": "Obudź host",
"wait": "Czekaj",
"set_var": "Ustaw zmienną",
"if": "Jeśli / w przeciwnym razie",
"run_automation": "Uruchom inną automatyzację",
"stop": "Zatrzymaj"
},
"statuses": {
"running": "Działa",
"success": "Powodzenie",
"failed": "Niepowodzenie",
"timeout": "Przekroczono limit czasu",
"skipped": "Pominięto",
"cancelled": "Anulowano"
},
"fields": {
"host": "Host",
"metric": "Metryka",
"mount": "Punkt montowania",
"operator": "Jest",
"value": "Wartość",
"forSeconds": "Utrzymywany przez (sekundy)",
"cooldown": "Czas wyciszenia (minuty)",
"cron": "Wyrażenie cron",
"intervalSeconds": "Co (sekundy)",
"timezone": "Strefa czasowa",
"timezoneServer": "Czas serwera",
"timezoneHint": "Czasy cron są odczytywane w tej strefie. Nie dotyczy harmonogramów interwałowych.",
"container": "Kontener",
"command": "Polecenie",
"url": "Adres URL",
"method": "Metoda",
"body": "Treść",
"channels": "Also send to",
"title": "Tytuł",
"message": "Wiadomość",
"snippet": "Snippet",
"seconds": "Sekundy",
"conditionLeftPlaceholder": "Wartość do sprawdzenia",
"conditionRightPlaceholder": "Porównaj z",
"variableName": "Nazwa zmiennej",
"variableValue": "Wartość",
"elevated": "Uruchom z sudo",
"onError": "Jeśli to się nie powiedzie",
"allowPrivateNetwork": "Zezwalaj na lokalne adresy sieciowe",
"allowPrivateNetworkHint": "Wymagane dla własnej usługi w sieci LAN. Domyślnie wyłączone, aby zapobiec żądaniom do adresów wewnętrznych."
},
"onError": {
"stop": "Zatrzymaj uruchomienie",
"continue": "Kontynuuj"
},
"needsPlugin": "Needs {{plugin}}",
"needsPluginHint": "This automation is skipped until the feature it needs is available again.",
"offBadge": "Wył.",
"docsLink": "Zobacz dokumentację",
"ago": {
"seconds": "{{count}}s ago",
"minutes": "{{count}} min temu",
"hours": "{{count}} godz. temu",
"days": "{{count}} dni temu"
}
}
}
},
"nav": {
"automations": "Automatyzacje"
},
"onboarding": {
"feature_automations": "Automatyzacje",
"feature_automations_desc": "Uruchamiaj kroki automatycznie, gdy coś się wydarzy, i otrzymuj powiadomienia."
},
"maintenance": {
"title": "Konserwacja",
"description": "Pause host status, health, metric, container and tunnel-disconnection automations while work is in progress. Monitoring, manual runs and scheduled automations continue.",
"denied": "You do not have permission to view maintenance.",
"sharedReadOnly": "This host was shared with you. Only its owner can change maintenance.",
"failed": "Maintenance could not be updated.",
"active": "Maintenance active",
"estimated": "Estimated completion",
"noAutoEnd": "The estimate does not end maintenance. End it explicitly when the work is complete.",
"endConfirm": "End maintenance and resume event-triggered automations for this host?",
"confirmEnd": "Confirm end",
"cancel": "Anuluj",
"end": "End maintenance",
"action": "Akcja",
"startNow": "Start now",
"schedule": "Schedule for later",
"reason": "Reason",
"startUtc": "First start (UTC)",
"repeat": "Powtórz",
"once": "Once",
"weekly": "Weekly",
"monthly": "Monthly",
"utcHelp": "Schedules use UTC throughout the year. Monthly schedules skip months without the selected day. Planned windows cannot overlap; overrunning maintenance continues across later windows.",
"duration": "Estimated duration (minutes)",
"grace": "Grace period (minutes)",
"notify": "Notify once if still offline after the estimate plus grace period",
"addSchedule": "Add schedule",
"schedules": "Schedules",
"noSchedules": "No maintenance schedules.",
"completed": "Already started",
"minutes_one": "{{count}} minute",
"minutes_other": "{{count}} minutes",
"remove": "Remove schedule",
"loadFailed": "Could not refresh maintenance status. Changes are disabled until it can be loaded.",
"loading": "Loading maintenance status…"
}
}
@@ -0,0 +1,211 @@
{
"permissions": {
"view": {
"title": "View automations",
"description": "See the automations and their history."
},
"create": {
"title": "Create automations",
"description": "Add new automations."
},
"edit": {
"title": "Edit automations",
"description": "Change existing automations."
},
"delete": {
"title": "Delete automations",
"description": "Remove automations."
},
"run": {
"title": "Run automations",
"description": "Trigger an automation by hand."
}
},
"newUi": {
"sidebar": {
"automations": {
"title": "Automations",
"tabAutomations": "Automations",
"tabRuns": "Runs",
"tabChannels": "Channels",
"createAutomation": "New Automation",
"backToAutomations": "Back to Automations",
"createTitle": "New Automation",
"editTitle": "Edit Automation",
"empty": "No automations yet. Create one to react to a metric, a schedule, or an event.",
"emptyRuns": "No runs yet.",
"channelsHint": "Channels are set up in Alerts",
"noChannelsHint": "The alert always goes to your inbox. Add channels in Alerts to also send it somewhere else.",
"nameLabel": "Name",
"namePlaceholder": "e.g., Clean up /data when it fills",
"nameRequired": "Name is required",
"descriptionLabel": "Description",
"descriptionPlaceholder": "Optional description",
"enabledLabel": "Enabled",
"concurrencyLabel": "If it is already running",
"concurrencyHint": "What to do when this is triggered again before the last run has finished.",
"concurrency": {
"skip": "Skip the new run",
"queue": "Wait and run after",
"allow": "Run both at once"
},
"save": "Salvar",
"created": "Automation created",
"updated": "Automation saved",
"deleted": "Automation deleted",
"deleteConfirm": "Delete this automation? Its run history is deleted too.",
"runNow": "Run now",
"testRun": "Test run",
"testRunHint": "Runs every step but does not touch anything outside Termix.",
"runStarted": "Run started",
"runFinished": "Run finished: {{status}}",
"trigger": "Trigger",
"steps": "Steps",
"addStep": "Add step",
"removeStep": "Remove step",
"moveUp": "Move up",
"moveDown": "Move down",
"noSteps": "No steps yet. Add one to say what should happen.",
"webhookTokenTitle": "Webhook URL",
"webhookTokenDescription": "This is shown once. Save it now, it cannot be retrieved later.",
"copy": "Copiar",
"copied": "Copiado",
"viewJson": "JSON",
"viewBuilder": "Builder",
"viewGraph": "Graph",
"lastRun": "Last run",
"never": "Never",
"duration": "{{seconds}}s",
"stepOutput": "Output",
"stepError": "Erro",
"dryRunBadge": "Test run",
"hostSelector": {
"trigger": "Triggering host",
"host": "Specific host",
"fleet": "Fleet",
"all": "All hosts"
},
"triggerKinds": {
"metric_threshold": "Metric threshold",
"host_status": "Host goes up or down",
"health_check": "Health check changes",
"schedule": "On a schedule",
"docker_event": "Container event",
"internal_event": "Termix event",
"webhook": "Incoming webhook"
},
"stepTypes": {
"notify": "Send an alert",
"http": "Call a URL",
"run_snippet": "Run a snippet",
"run_command": "Run a command",
"docker": "Control a container",
"tunnel": "Control a tunnel",
"wol": "Wake a host",
"wait": "Wait",
"set_var": "Set a variable",
"if": "If / otherwise",
"run_automation": "Run another automation",
"stop": "Stop"
},
"statuses": {
"running": "Running",
"success": "Success",
"failed": "Failed",
"timeout": "Timed out",
"skipped": "Skipped",
"cancelled": "Cancelled"
},
"fields": {
"host": "Host",
"metric": "Metric",
"mount": "Mount point",
"operator": "Is",
"value": "Value",
"forSeconds": "Sustained for (seconds)",
"cooldown": "Cooldown (minutes)",
"cron": "Cron expression",
"intervalSeconds": "Every (seconds)",
"timezone": "Time zone",
"timezoneServer": "Server time",
"timezoneHint": "Cron times are read in this zone. Does not apply to interval schedules.",
"container": "Container",
"command": "Command",
"url": "URL",
"method": "Method",
"body": "Body",
"channels": "Also send to",
"title": "Title",
"message": "Message",
"snippet": "Snippet",
"seconds": "Seconds",
"conditionLeftPlaceholder": "Value to check",
"conditionRightPlaceholder": "Compare against",
"variableName": "Variable name",
"variableValue": "Value",
"elevated": "Run with sudo",
"onError": "If this fails",
"allowPrivateNetwork": "Allow local network addresses",
"allowPrivateNetworkHint": "Needed for a self-hosted service on your LAN. Off by default to prevent requests to internal addresses."
},
"onError": {
"stop": "Stop the run",
"continue": "Carry on"
},
"needsPlugin": "Needs {{plugin}}",
"needsPluginHint": "This automation is skipped until the feature it needs is available again.",
"offBadge": "Off",
"docsLink": "Documentação",
"ago": {
"seconds": "{{count}}s ago",
"minutes": "{{count}}m ago",
"hours": "{{count}}h ago",
"days": "{{count}}d ago"
}
}
}
},
"nav": {
"automations": "Automations"
},
"onboarding": {
"feature_automations": "Automations",
"feature_automations_desc": "Run steps automatically when something happens, and get notified."
},
"maintenance": {
"title": "Manutenção",
"description": "Pause host status, health, metric, container and tunnel-disconnection automations while work is in progress. Monitoring, manual runs and scheduled automations continue.",
"denied": "You do not have permission to view maintenance.",
"sharedReadOnly": "This host was shared with you. Only its owner can change maintenance.",
"failed": "Maintenance could not be updated.",
"active": "Maintenance active",
"estimated": "Estimated completion",
"noAutoEnd": "The estimate does not end maintenance. End it explicitly when the work is complete.",
"endConfirm": "End maintenance and resume event-triggered automations for this host?",
"confirmEnd": "Confirm end",
"cancel": "Cancelar",
"end": "End maintenance",
"action": "Action",
"startNow": "Start now",
"schedule": "Schedule for later",
"reason": "Reason",
"startUtc": "First start (UTC)",
"repeat": "Repeat",
"once": "Once",
"weekly": "Weekly",
"monthly": "Monthly",
"utcHelp": "Schedules use UTC throughout the year. Monthly schedules skip months without the selected day. Planned windows cannot overlap; overrunning maintenance continues across later windows.",
"duration": "Estimated duration (minutes)",
"grace": "Grace period (minutes)",
"notify": "Notify once if still offline after the estimate plus grace period",
"addSchedule": "Add schedule",
"schedules": "Schedules",
"noSchedules": "No maintenance schedules.",
"completed": "Already started",
"minutes_one": "{{count}} minute",
"minutes_other": "{{count}} minutes",
"remove": "Remove schedule",
"loadFailed": "Could not refresh maintenance status. Changes are disabled until it can be loaded.",
"loading": "Loading maintenance status…"
}
}
@@ -0,0 +1,211 @@
{
"permissions": {
"view": {
"title": "View automations",
"description": "See the automations and their history."
},
"create": {
"title": "Create automations",
"description": "Add new automations."
},
"edit": {
"title": "Edit automations",
"description": "Change existing automations."
},
"delete": {
"title": "Delete automations",
"description": "Remove automations."
},
"run": {
"title": "Run automations",
"description": "Trigger an automation by hand."
}
},
"newUi": {
"sidebar": {
"automations": {
"title": "Automations",
"tabAutomations": "Automations",
"tabRuns": "Runs",
"tabChannels": "Canais",
"createAutomation": "New Automation",
"backToAutomations": "Back to Automations",
"createTitle": "New Automation",
"editTitle": "Edit Automation",
"empty": "No automations yet. Create one to react to a metric, a schedule, or an event.",
"emptyRuns": "No runs yet.",
"channelsHint": "Channels are set up in Alerts",
"noChannelsHint": "The alert always goes to your inbox. Add channels in Alerts to also send it somewhere else.",
"nameLabel": "Nome",
"namePlaceholder": "e.g., Clean up /data when it fills",
"nameRequired": "O nome é obrigatório",
"descriptionLabel": "Descrição",
"descriptionPlaceholder": "Descrição opcional",
"enabledLabel": "Ativado",
"concurrencyLabel": "If it is already running",
"concurrencyHint": "What to do when this is triggered again before the last run has finished.",
"concurrency": {
"skip": "Skip the new run",
"queue": "Wait and run after",
"allow": "Run both at once"
},
"save": "Guardar",
"created": "Automation created",
"updated": "Automation saved",
"deleted": "Automation deleted",
"deleteConfirm": "Delete this automation? Its run history is deleted too.",
"runNow": "Run now",
"testRun": "Test run",
"testRunHint": "Runs every step but does not touch anything outside Termix.",
"runStarted": "Run started",
"runFinished": "Run finished: {{status}}",
"trigger": "Gatilho",
"steps": "Steps",
"addStep": "Add step",
"removeStep": "Remove step",
"moveUp": "Move up",
"moveDown": "Move down",
"noSteps": "No steps yet. Add one to say what should happen.",
"webhookTokenTitle": "Webhook URL",
"webhookTokenDescription": "This is shown once. Save it now, it cannot be retrieved later.",
"copy": "Copiar",
"copied": "Copiado",
"viewJson": "JSON",
"viewBuilder": "Builder",
"viewGraph": "Graph",
"lastRun": "Last run",
"never": "Nunca",
"duration": "{{seconds}}s",
"stepOutput": "Saída",
"stepError": "Erro",
"dryRunBadge": "Test run",
"hostSelector": {
"trigger": "Triggering host",
"host": "Specific host",
"fleet": "Fleet",
"all": "Todos os hosts"
},
"triggerKinds": {
"metric_threshold": "Metric threshold",
"host_status": "Host goes up or down",
"health_check": "Health check changes",
"schedule": "On a schedule",
"docker_event": "Container event",
"internal_event": "Termix event",
"webhook": "Incoming webhook"
},
"stepTypes": {
"notify": "Send an alert",
"http": "Call a URL",
"run_snippet": "Run a snippet",
"run_command": "Run a command",
"docker": "Control a container",
"tunnel": "Control a tunnel",
"wol": "Wake a host",
"wait": "Wait",
"set_var": "Set a variable",
"if": "If / otherwise",
"run_automation": "Run another automation",
"stop": "Stop"
},
"statuses": {
"running": "Em execução",
"success": "Sucesso",
"failed": "Falhou",
"timeout": "Timed out",
"skipped": "Skipped",
"cancelled": "Cancelled"
},
"fields": {
"host": "Servidor",
"metric": "Métrica",
"mount": "Mount point",
"operator": "Is",
"value": "Value",
"forSeconds": "Sustained for (seconds)",
"cooldown": "Período de arrefecimento (minutos)",
"cron": "Cron expression",
"intervalSeconds": "Every (seconds)",
"timezone": "Time zone",
"timezoneServer": "Server time",
"timezoneHint": "Cron times are read in this zone. Does not apply to interval schedules.",
"container": "Container",
"command": "Comando",
"url": "URL",
"method": "Method",
"body": "Body",
"channels": "Also send to",
"title": "Título",
"message": "Message",
"snippet": "Snippet",
"seconds": "Seconds",
"conditionLeftPlaceholder": "Value to check",
"conditionRightPlaceholder": "Compare against",
"variableName": "Variable name",
"variableValue": "Value",
"elevated": "Run with sudo",
"onError": "If this fails",
"allowPrivateNetwork": "Allow local network addresses",
"allowPrivateNetworkHint": "Needed for a self-hosted service on your LAN. Off by default to prevent requests to internal addresses."
},
"onError": {
"stop": "Stop the run",
"continue": "Carry on"
},
"needsPlugin": "Needs {{plugin}}",
"needsPluginHint": "This automation is skipped until the feature it needs is available again.",
"offBadge": "Desativado",
"docsLink": "Ver docs",
"ago": {
"seconds": "{{count}}s ago",
"minutes": "há {{count}} min",
"hours": "há {{count}} h",
"days": "há {{count}} d"
}
}
}
},
"nav": {
"automations": "Automations"
},
"onboarding": {
"feature_automations": "Automations",
"feature_automations_desc": "Run steps automatically when something happens, and get notified."
},
"maintenance": {
"title": "Manutenção",
"description": "Pause host status, health, metric, container and tunnel-disconnection automations while work is in progress. Monitoring, manual runs and scheduled automations continue.",
"denied": "You do not have permission to view maintenance.",
"sharedReadOnly": "This host was shared with you. Only its owner can change maintenance.",
"failed": "Maintenance could not be updated.",
"active": "Maintenance active",
"estimated": "Estimated completion",
"noAutoEnd": "The estimate does not end maintenance. End it explicitly when the work is complete.",
"endConfirm": "End maintenance and resume event-triggered automations for this host?",
"confirmEnd": "Confirm end",
"cancel": "Cancelar",
"end": "End maintenance",
"action": "Ação",
"startNow": "Start now",
"schedule": "Schedule for later",
"reason": "Reason",
"startUtc": "First start (UTC)",
"repeat": "Repeat",
"once": "Once",
"weekly": "Weekly",
"monthly": "Monthly",
"utcHelp": "Schedules use UTC throughout the year. Monthly schedules skip months without the selected day. Planned windows cannot overlap; overrunning maintenance continues across later windows.",
"duration": "Estimated duration (minutes)",
"grace": "Grace period (minutes)",
"notify": "Notify once if still offline after the estimate plus grace period",
"addSchedule": "Add schedule",
"schedules": "Schedules",
"noSchedules": "No maintenance schedules.",
"completed": "Already started",
"minutes_one": "{{count}} minute",
"minutes_other": "{{count}} minutes",
"remove": "Remove schedule",
"loadFailed": "Could not refresh maintenance status. Changes are disabled until it can be loaded.",
"loading": "Loading maintenance status…"
}
}
@@ -0,0 +1,211 @@
{
"permissions": {
"view": {
"title": "Vizualizați automatizările",
"description": "Vedeți automatizările și istoricul lor."
},
"create": {
"title": "Creați automatizări",
"description": "Adăugați noi automatizări."
},
"edit": {
"title": "Editați automatizările",
"description": "Modificați automatizările existente."
},
"delete": {
"title": "Șterge automatizările",
"description": "Eliminați automatizările."
},
"run": {
"title": "Rulați automatizări",
"description": "Declanșează o automatizare manual."
}
},
"newUi": {
"sidebar": {
"automations": {
"title": "Automatizări",
"tabAutomations": "Automatizări",
"tabRuns": "Aleargă",
"tabChannels": "Canale",
"createAutomation": "Automatizare nouă",
"backToAutomations": "Înapoi la Automatizări",
"createTitle": "Automatizare nouă",
"editTitle": "Editare automatizare",
"empty": "Încă nu există automatizări. Creați una pentru a reacționa la o metrică, un program sau un eveniment.",
"emptyRuns": "Nicio alergare încă.",
"channelsHint": "Canalele sunt configurate în Alerte",
"noChannelsHint": "Alerta ajunge întotdeauna în căsuța ta poștală. Adaugă canale în Alerte pentru a o trimite și în altă parte.",
"nameLabel": "Nume",
"namePlaceholder": "De exemplu, curățați /data când se umple",
"nameRequired": "Numele este obligatoriu",
"descriptionLabel": "Descriere",
"descriptionPlaceholder": "Descriere opțională",
"enabledLabel": "Activat",
"concurrencyLabel": "Dacă deja rulează",
"concurrencyHint": "Ce trebuie făcut când acest lucru este declanșat din nou înainte de finalizarea ultimei rulări.",
"concurrency": {
"skip": "Sari peste noua cursă",
"queue": "Așteaptă și aleargă după",
"allow": "Rulați ambele simultan"
},
"save": "Salva",
"created": "Automatizare creată",
"updated": "Automatizare salvată",
"deleted": "Automatizare ștearsă",
"deleteConfirm": "Ștergeți această automatizare? Și istoricul execuțiilor sale este șters.",
"runNow": "Aleargă acum",
"testRun": "Test de funcționare",
"testRunHint": "Aleargă la fiecare pas, dar nu atinge nimic din afara Termixului.",
"runStarted": "Rularea a început",
"runFinished": "Execuția s-a încheiat: {{status}}",
"trigger": "Declanșator",
"steps": "Pași",
"addStep": "Adăugați un pas",
"removeStep": "Eliminați pasul",
"moveUp": "Mută în sus",
"moveDown": "Mută în jos",
"noSteps": "Încă nu există pași. Adăugați unul pentru a specifica ce ar trebui să se întâmple.",
"webhookTokenTitle": "URL-ul webhook-ului",
"webhookTokenDescription": "Aceasta se afișează o singură dată. Salvați-o acum, nu poate fi recuperată ulterior.",
"copy": "Copie",
"copied": "Copiat",
"viewJson": "JSON",
"viewBuilder": "Constructor",
"viewGraph": "Grafic",
"lastRun": "Ultima alergare",
"never": "Nu",
"duration": "{{seconds}}s",
"stepOutput": "Ieșire",
"stepError": "Eroare",
"dryRunBadge": "Test de funcționare",
"hostSelector": {
"trigger": "Gazdă declanșatoare",
"host": "Gazdă specifică",
"fleet": "Flotă",
"all": "Toate gazdele"
},
"triggerKinds": {
"metric_threshold": "Pragul metric",
"host_status": "Gazda se activează sau se deconectează",
"health_check": "Modificări la verificarea stării de sănătate",
"schedule": "Pe un program",
"docker_event": "Eveniment container",
"internal_event": "Evenimentul Termix",
"webhook": "Webhook de intrare"
},
"stepTypes": {
"notify": "Trimiteți o alertă",
"http": "Apelați o adresă URL",
"run_snippet": "Executați un fragment",
"run_command": "Executați o comandă",
"docker": "Controlează un container",
"tunnel": "Controlează un tunel",
"wol": "Trezește o gazdă",
"wait": "Așteaptă",
"set_var": "Setați o variabilă",
"if": "Dacă / altfel",
"run_automation": "Rulați o altă automatizare",
"stop": "Stop"
},
"statuses": {
"running": "Funcţionare",
"success": "Succes",
"failed": "Eșuat",
"timeout": "Expirat",
"skipped": "Omis",
"cancelled": "Anulat"
},
"fields": {
"host": "Gazdă",
"metric": "Metric",
"mount": "Punct de montare",
"operator": "Este",
"value": "Valoare",
"forSeconds": "Menținut timp de (secunde)",
"cooldown": "Timp de răcire (minute)",
"cron": "Expresie Cron",
"intervalSeconds": "La fiecare (secunde)",
"timezone": "Fus orar",
"timezoneServer": "Ora serverului",
"timezoneHint": "Timpii cron sunt citiți în această zonă. Nu se aplică programărilor cu intervale.",
"container": "Container",
"command": "Comanda",
"url": "URL-ul",
"method": "Metodă",
"body": "Corp",
"channels": "Trimiteți și către",
"title": "Titlu",
"message": "Mesaj",
"snippet": "Fragment",
"seconds": "Secunde",
"conditionLeftPlaceholder": "Valoare de verificat",
"conditionRightPlaceholder": "Comparați cu",
"variableName": "Nume variabilă",
"variableValue": "Valoare",
"elevated": "Execută cu sudo",
"onError": "Dacă acest lucru eșuează",
"allowPrivateNetwork": "Permiteți adrese de rețea locală",
"allowPrivateNetworkHint": "Necesar pentru un serviciu auto-găzduit în rețeaua locală. Dezactivat în mod implicit pentru a preveni solicitările către adrese interne."
},
"onError": {
"stop": "Oprește alergarea",
"continue": "Continuă"
},
"needsPlugin": "Necesită {{plugin}}",
"needsPluginHint": "Această automatizare este omisă până când funcția de care are nevoie este din nou disponibilă.",
"offBadge": "Dezactivat",
"docsLink": "Vizualizați documentele",
"ago": {
"seconds": "Acum {{count}}secunde",
"minutes": "{{count}}acum câteva minute",
"hours": "{{count}}acum o oră",
"days": "{{count}}zile în urmă"
}
}
}
},
"nav": {
"automations": "Automatizări"
},
"onboarding": {
"feature_automations": "Automatizări",
"feature_automations_desc": "Execută pașii automat când se întâmplă ceva și primește notificări."
},
"maintenance": {
"title": "Întreţinere",
"description": "Întrerupeți automatizările privind starea gazdei, sănătatea, metrica, containerul și deconectarea tunelului în timp ce lucrul este în desfășurare. Monitorizarea, rulările manuale și automatizările programate continuă.",
"denied": "Nu aveți permisiunea de a vizualiza întreținerea.",
"sharedReadOnly": "Această gazdă a fost partajată cu dvs. Numai proprietarul acesteia poate modifica întreținerea.",
"failed": "Întreținerea nu a putut fi actualizată.",
"active": "Mentenanță activă",
"estimated": "Finalizare estimată",
"noAutoEnd": "Estimarea nu încheie întreținerea. Încheiați-o explicit când lucrarea este finalizată.",
"endConfirm": "Încheiați întreținerea și reluați automatizările declanșate de evenimente pentru această gazdă?",
"confirmEnd": "Confirmați sfârșitul",
"cancel": "Anula",
"end": "Sfârșitul întreținerii",
"action": "Acţiune",
"startNow": "Începeți acum",
"schedule": "Programați pentru mai târziu",
"reason": "Motiv",
"startUtc": "Prima pornire (UTC)",
"repeat": "Repeta",
"once": "Dată",
"weekly": "Săptămânal",
"monthly": "Lunar",
"utcHelp": "Programările utilizează UTC pe tot parcursul anului. Programările lunare sar peste luni fără ziua selectată. Ferestrele planificate nu se pot suprapune; întreținerea depășită continuă în ferestrele ulterioare.",
"duration": "Durată estimată (minute)",
"grace": "Perioadă de grație (minute)",
"notify": "Notificați o dată dacă sunteți încă offline după estimare plus perioada de grație",
"addSchedule": "Adăugați un program",
"schedules": "Programe",
"noSchedules": "Fără programe de întreținere.",
"completed": "Deja început",
"minutes_one": "{{count}} minut",
"minutes_other": "{{count}} minute",
"remove": "Eliminați programul",
"loadFailed": "Nu s-a putut actualiza starea de întreținere. Modificările sunt dezactivate până când pot fi încărcate.",
"loading": "Se încarcă starea de întreținere…"
}
}
@@ -0,0 +1,211 @@
{
"permissions": {
"view": {
"title": "View automations",
"description": "See the automations and their history."
},
"create": {
"title": "Create automations",
"description": "Add new automations."
},
"edit": {
"title": "Edit automations",
"description": "Change existing automations."
},
"delete": {
"title": "Delete automations",
"description": "Remove automations."
},
"run": {
"title": "Run automations",
"description": "Trigger an automation by hand."
}
},
"newUi": {
"sidebar": {
"automations": {
"title": "Automations",
"tabAutomations": "Automations",
"tabRuns": "Runs",
"tabChannels": "Каналы",
"createAutomation": "New Automation",
"backToAutomations": "Back to Automations",
"createTitle": "New Automation",
"editTitle": "Edit Automation",
"empty": "No automations yet. Create one to react to a metric, a schedule, or an event.",
"emptyRuns": "No runs yet.",
"channelsHint": "Channels are set up in Alerts",
"noChannelsHint": "The alert always goes to your inbox. Add channels in Alerts to also send it somewhere else.",
"nameLabel": "Название",
"namePlaceholder": "e.g., Clean up /data when it fills",
"nameRequired": "Имя обязательно",
"descriptionLabel": "Описание",
"descriptionPlaceholder": "Необязательное описание",
"enabledLabel": "Включено",
"concurrencyLabel": "If it is already running",
"concurrencyHint": "What to do when this is triggered again before the last run has finished.",
"concurrency": {
"skip": "Skip the new run",
"queue": "Wait and run after",
"allow": "Run both at once"
},
"save": "Сохранить",
"created": "Automation created",
"updated": "Automation saved",
"deleted": "Automation deleted",
"deleteConfirm": "Delete this automation? Its run history is deleted too.",
"runNow": "Run now",
"testRun": "Test run",
"testRunHint": "Runs every step but does not touch anything outside Termix.",
"runStarted": "Run started",
"runFinished": "Run finished: {{status}}",
"trigger": "Триггер",
"steps": "Steps",
"addStep": "Add step",
"removeStep": "Remove step",
"moveUp": "Move up",
"moveDown": "Move down",
"noSteps": "No steps yet. Add one to say what should happen.",
"webhookTokenTitle": "Webhook URL",
"webhookTokenDescription": "This is shown once. Save it now, it cannot be retrieved later.",
"copy": "Копировать",
"copied": "Скопировано",
"viewJson": "JSON",
"viewBuilder": "Builder",
"viewGraph": "Graph",
"lastRun": "Last run",
"never": "Никогда",
"duration": "{{seconds}}s",
"stepOutput": "Вывод",
"stepError": "Ошибка",
"dryRunBadge": "Test run",
"hostSelector": {
"trigger": "Triggering host",
"host": "Specific host",
"fleet": "Fleet",
"all": "Все хосты"
},
"triggerKinds": {
"metric_threshold": "Metric threshold",
"host_status": "Host goes up or down",
"health_check": "Health check changes",
"schedule": "On a schedule",
"docker_event": "Container event",
"internal_event": "Termix event",
"webhook": "Incoming webhook"
},
"stepTypes": {
"notify": "Send an alert",
"http": "Call a URL",
"run_snippet": "Run a snippet",
"run_command": "Run a command",
"docker": "Control a container",
"tunnel": "Control a tunnel",
"wol": "Wake a host",
"wait": "Wait",
"set_var": "Set a variable",
"if": "If / otherwise",
"run_automation": "Run another automation",
"stop": "Остановить"
},
"statuses": {
"running": "Запущен",
"success": "Успех",
"failed": "Ошибка",
"timeout": "Timed out",
"skipped": "Skipped",
"cancelled": "Cancelled"
},
"fields": {
"host": "Хост",
"metric": "Метрика",
"mount": "Mount point",
"operator": "Is",
"value": "Value",
"forSeconds": "Sustained for (seconds)",
"cooldown": "Задержка повторения (минут)",
"cron": "Cron expression",
"intervalSeconds": "Every (seconds)",
"timezone": "Time zone",
"timezoneServer": "Server time",
"timezoneHint": "Cron times are read in this zone. Does not apply to interval schedules.",
"container": "Container",
"command": "Команда",
"url": "URL",
"method": "Method",
"body": "Body",
"channels": "Also send to",
"title": "Название",
"message": "Message",
"snippet": "Snippet",
"seconds": "Seconds",
"conditionLeftPlaceholder": "Value to check",
"conditionRightPlaceholder": "Compare against",
"variableName": "Variable name",
"variableValue": "Value",
"elevated": "Run with sudo",
"onError": "If this fails",
"allowPrivateNetwork": "Allow local network addresses",
"allowPrivateNetworkHint": "Needed for a self-hosted service on your LAN. Off by default to prevent requests to internal addresses."
},
"onError": {
"stop": "Stop the run",
"continue": "Carry on"
},
"needsPlugin": "Needs {{plugin}}",
"needsPluginHint": "This automation is skipped until the feature it needs is available again.",
"offBadge": "Отключено",
"docsLink": "Документация",
"ago": {
"seconds": "{{count}}s ago",
"minutes": "{{count}}м назад",
"hours": "{{count}}ч назад",
"days": "{{count}}д назад"
}
}
}
},
"nav": {
"automations": "Automations"
},
"onboarding": {
"feature_automations": "Automations",
"feature_automations_desc": "Run steps automatically when something happens, and get notified."
},
"maintenance": {
"title": "Обслуживание",
"description": "Pause host status, health, metric, container and tunnel-disconnection automations while work is in progress. Monitoring, manual runs and scheduled automations continue.",
"denied": "You do not have permission to view maintenance.",
"sharedReadOnly": "This host was shared with you. Only its owner can change maintenance.",
"failed": "Maintenance could not be updated.",
"active": "Maintenance active",
"estimated": "Estimated completion",
"noAutoEnd": "The estimate does not end maintenance. End it explicitly when the work is complete.",
"endConfirm": "End maintenance and resume event-triggered automations for this host?",
"confirmEnd": "Confirm end",
"cancel": "Отмена",
"end": "End maintenance",
"action": "Действие",
"startNow": "Start now",
"schedule": "Schedule for later",
"reason": "Reason",
"startUtc": "First start (UTC)",
"repeat": "Repeat",
"once": "Once",
"weekly": "Weekly",
"monthly": "Monthly",
"utcHelp": "Schedules use UTC throughout the year. Monthly schedules skip months without the selected day. Planned windows cannot overlap; overrunning maintenance continues across later windows.",
"duration": "Estimated duration (minutes)",
"grace": "Grace period (minutes)",
"notify": "Notify once if still offline after the estimate plus grace period",
"addSchedule": "Add schedule",
"schedules": "Schedules",
"noSchedules": "No maintenance schedules.",
"completed": "Already started",
"minutes_one": "{{count}} minute",
"minutes_other": "{{count}} minutes",
"remove": "Remove schedule",
"loadFailed": "Could not refresh maintenance status. Changes are disabled until it can be loaded.",
"loading": "Loading maintenance status…"
}
}
@@ -0,0 +1,211 @@
{
"permissions": {
"view": {
"title": "View automations",
"description": "See the automations and their history."
},
"create": {
"title": "Create automations",
"description": "Add new automations."
},
"edit": {
"title": "Edit automations",
"description": "Change existing automations."
},
"delete": {
"title": "Delete automations",
"description": "Remove automations."
},
"run": {
"title": "Run automations",
"description": "Trigger an automation by hand."
}
},
"newUi": {
"sidebar": {
"automations": {
"title": "Automations",
"tabAutomations": "Automations",
"tabRuns": "Runs",
"tabChannels": "Channels",
"createAutomation": "New Automation",
"backToAutomations": "Back to Automations",
"createTitle": "New Automation",
"editTitle": "Edit Automation",
"empty": "No automations yet. Create one to react to a metric, a schedule, or an event.",
"emptyRuns": "No runs yet.",
"channelsHint": "Channels are set up in Alerts",
"noChannelsHint": "The alert always goes to your inbox. Add channels in Alerts to also send it somewhere else.",
"nameLabel": "Name",
"namePlaceholder": "e.g., Clean up /data when it fills",
"nameRequired": "Name is required",
"descriptionLabel": "Description",
"descriptionPlaceholder": "Optional description",
"enabledLabel": "Enabled",
"concurrencyLabel": "If it is already running",
"concurrencyHint": "What to do when this is triggered again before the last run has finished.",
"concurrency": {
"skip": "Skip the new run",
"queue": "Wait and run after",
"allow": "Run both at once"
},
"save": "Save",
"created": "Automation created",
"updated": "Automation saved",
"deleted": "Automation deleted",
"deleteConfirm": "Delete this automation? Its run history is deleted too.",
"runNow": "Run now",
"testRun": "Test run",
"testRunHint": "Runs every step but does not touch anything outside Termix.",
"runStarted": "Run started",
"runFinished": "Run finished: {{status}}",
"trigger": "Trigger",
"steps": "Steps",
"addStep": "Add step",
"removeStep": "Remove step",
"moveUp": "Move up",
"moveDown": "Move down",
"noSteps": "No steps yet. Add one to say what should happen.",
"webhookTokenTitle": "Webhook URL",
"webhookTokenDescription": "This is shown once. Save it now, it cannot be retrieved later.",
"copy": "Copy",
"copied": "Copied",
"viewJson": "JSON",
"viewBuilder": "Builder",
"viewGraph": "Graph",
"lastRun": "Last run",
"never": "Never",
"duration": "{{seconds}}s",
"stepOutput": "Output",
"stepError": "Error",
"dryRunBadge": "Test run",
"hostSelector": {
"trigger": "Triggering host",
"host": "Specific host",
"fleet": "Fleet",
"all": "All hosts"
},
"triggerKinds": {
"metric_threshold": "Metric threshold",
"host_status": "Host goes up or down",
"health_check": "Health check changes",
"schedule": "On a schedule",
"docker_event": "Container event",
"internal_event": "Termix event",
"webhook": "Incoming webhook"
},
"stepTypes": {
"notify": "Send an alert",
"http": "Call a URL",
"run_snippet": "Run a snippet",
"run_command": "Run a command",
"docker": "Control a container",
"tunnel": "Control a tunnel",
"wol": "Wake a host",
"wait": "Wait",
"set_var": "Set a variable",
"if": "If / otherwise",
"run_automation": "Run another automation",
"stop": "Stop"
},
"statuses": {
"running": "Running",
"success": "Success",
"failed": "Failed",
"timeout": "Timed out",
"skipped": "Skipped",
"cancelled": "Cancelled"
},
"fields": {
"host": "Host",
"metric": "Metric",
"mount": "Mount point",
"operator": "Is",
"value": "Value",
"forSeconds": "Sustained for (seconds)",
"cooldown": "Cooldown (minutes)",
"cron": "Cron expression",
"intervalSeconds": "Every (seconds)",
"timezone": "Time zone",
"timezoneServer": "Server time",
"timezoneHint": "Cron times are read in this zone. Does not apply to interval schedules.",
"container": "Container",
"command": "Command",
"url": "URL",
"method": "Method",
"body": "Body",
"channels": "Also send to",
"title": "Title",
"message": "Message",
"snippet": "Snippet",
"seconds": "Seconds",
"conditionLeftPlaceholder": "Value to check",
"conditionRightPlaceholder": "Compare against",
"variableName": "Variable name",
"variableValue": "Value",
"elevated": "Run with sudo",
"onError": "If this fails",
"allowPrivateNetwork": "Allow local network addresses",
"allowPrivateNetworkHint": "Needed for a self-hosted service on your LAN. Off by default to prevent requests to internal addresses."
},
"onError": {
"stop": "Stop the run",
"continue": "Carry on"
},
"needsPlugin": "Needs {{plugin}}",
"needsPluginHint": "This automation is skipped until the feature it needs is available again.",
"offBadge": "Off",
"docsLink": "View docs",
"ago": {
"seconds": "{{count}}s ago",
"minutes": "{{count}}m ago",
"hours": "{{count}}h ago",
"days": "{{count}}d ago"
}
}
}
},
"nav": {
"automations": "Automations"
},
"onboarding": {
"feature_automations": "Automations",
"feature_automations_desc": "Run steps automatically when something happens, and get notified."
},
"maintenance": {
"title": "Maintenance",
"description": "Pause host status, health, metric, container and tunnel-disconnection automations while work is in progress. Monitoring, manual runs and scheduled automations continue.",
"denied": "You do not have permission to view maintenance.",
"sharedReadOnly": "This host was shared with you. Only its owner can change maintenance.",
"failed": "Maintenance could not be updated.",
"active": "Maintenance active",
"estimated": "Estimated completion",
"noAutoEnd": "The estimate does not end maintenance. End it explicitly when the work is complete.",
"endConfirm": "End maintenance and resume event-triggered automations for this host?",
"confirmEnd": "Confirm end",
"cancel": "Cancel",
"end": "End maintenance",
"action": "Action",
"startNow": "Start now",
"schedule": "Schedule for later",
"reason": "Reason",
"startUtc": "First start (UTC)",
"repeat": "Repeat",
"once": "Once",
"weekly": "Weekly",
"monthly": "Monthly",
"utcHelp": "Schedules use UTC throughout the year. Monthly schedules skip months without the selected day. Planned windows cannot overlap; overrunning maintenance continues across later windows.",
"duration": "Estimated duration (minutes)",
"grace": "Grace period (minutes)",
"notify": "Notify once if still offline after the estimate plus grace period",
"addSchedule": "Add schedule",
"schedules": "Schedules",
"noSchedules": "No maintenance schedules.",
"completed": "Already started",
"minutes_one": "{{count}} minute",
"minutes_other": "{{count}} minutes",
"remove": "Remove schedule",
"loadFailed": "Could not refresh maintenance status. Changes are disabled until it can be loaded.",
"loading": "Loading maintenance status…"
}
}
@@ -0,0 +1,211 @@
{
"permissions": {
"view": {
"title": "View automations",
"description": "See the automations and their history."
},
"create": {
"title": "Create automations",
"description": "Add new automations."
},
"edit": {
"title": "Edit automations",
"description": "Change existing automations."
},
"delete": {
"title": "Delete automations",
"description": "Remove automations."
},
"run": {
"title": "Run automations",
"description": "Trigger an automation by hand."
}
},
"newUi": {
"sidebar": {
"automations": {
"title": "Automations",
"tabAutomations": "Automations",
"tabRuns": "Runs",
"tabChannels": "Channels",
"createAutomation": "New Automation",
"backToAutomations": "Back to Automations",
"createTitle": "New Automation",
"editTitle": "Edit Automation",
"empty": "No automations yet. Create one to react to a metric, a schedule, or an event.",
"emptyRuns": "No runs yet.",
"channelsHint": "Channels are set up in Alerts",
"noChannelsHint": "The alert always goes to your inbox. Add channels in Alerts to also send it somewhere else.",
"nameLabel": "Name",
"namePlaceholder": "e.g., Clean up /data when it fills",
"nameRequired": "Name is required",
"descriptionLabel": "Description",
"descriptionPlaceholder": "Optional description",
"enabledLabel": "Enabled",
"concurrencyLabel": "If it is already running",
"concurrencyHint": "What to do when this is triggered again before the last run has finished.",
"concurrency": {
"skip": "Skip the new run",
"queue": "Wait and run after",
"allow": "Run both at once"
},
"save": "Save",
"created": "Automation created",
"updated": "Automation saved",
"deleted": "Automation deleted",
"deleteConfirm": "Delete this automation? Its run history is deleted too.",
"runNow": "Run now",
"testRun": "Test run",
"testRunHint": "Runs every step but does not touch anything outside Termix.",
"runStarted": "Run started",
"runFinished": "Run finished: {{status}}",
"trigger": "Trigger",
"steps": "Steps",
"addStep": "Add step",
"removeStep": "Remove step",
"moveUp": "Move up",
"moveDown": "Move down",
"noSteps": "No steps yet. Add one to say what should happen.",
"webhookTokenTitle": "Webhook URL",
"webhookTokenDescription": "This is shown once. Save it now, it cannot be retrieved later.",
"copy": "Copy",
"copied": "Copied",
"viewJson": "JSON",
"viewBuilder": "Builder",
"viewGraph": "Graph",
"lastRun": "Last run",
"never": "Never",
"duration": "{{seconds}}s",
"stepOutput": "Output",
"stepError": "Error",
"dryRunBadge": "Test run",
"hostSelector": {
"trigger": "Triggering host",
"host": "Specific host",
"fleet": "Fleet",
"all": "All hosts"
},
"triggerKinds": {
"metric_threshold": "Metric threshold",
"host_status": "Host goes up or down",
"health_check": "Health check changes",
"schedule": "On a schedule",
"docker_event": "Container event",
"internal_event": "Termix event",
"webhook": "Incoming webhook"
},
"stepTypes": {
"notify": "Send an alert",
"http": "Call a URL",
"run_snippet": "Run a snippet",
"run_command": "Run a command",
"docker": "Control a container",
"tunnel": "Control a tunnel",
"wol": "Wake a host",
"wait": "Wait",
"set_var": "Set a variable",
"if": "If / otherwise",
"run_automation": "Run another automation",
"stop": "Stop"
},
"statuses": {
"running": "Running",
"success": "Success",
"failed": "Failed",
"timeout": "Timed out",
"skipped": "Skipped",
"cancelled": "Cancelled"
},
"fields": {
"host": "Host",
"metric": "Metric",
"mount": "Mount point",
"operator": "Is",
"value": "Value",
"forSeconds": "Sustained for (seconds)",
"cooldown": "Cooldown (minutes)",
"cron": "Cron expression",
"intervalSeconds": "Every (seconds)",
"timezone": "Time zone",
"timezoneServer": "Server time",
"timezoneHint": "Cron times are read in this zone. Does not apply to interval schedules.",
"container": "Container",
"command": "Command",
"url": "URL",
"method": "Method",
"body": "Body",
"channels": "Also send to",
"title": "Title",
"message": "Message",
"snippet": "Snippet",
"seconds": "Seconds",
"conditionLeftPlaceholder": "Value to check",
"conditionRightPlaceholder": "Compare against",
"variableName": "Variable name",
"variableValue": "Value",
"elevated": "Run with sudo",
"onError": "If this fails",
"allowPrivateNetwork": "Allow local network addresses",
"allowPrivateNetworkHint": "Needed for a self-hosted service on your LAN. Off by default to prevent requests to internal addresses."
},
"onError": {
"stop": "Stop the run",
"continue": "Carry on"
},
"needsPlugin": "Needs {{plugin}}",
"needsPluginHint": "This automation is skipped until the feature it needs is available again.",
"offBadge": "Off",
"docsLink": "View docs",
"ago": {
"seconds": "{{count}}s ago",
"minutes": "{{count}}m ago",
"hours": "{{count}}h ago",
"days": "{{count}}d ago"
}
}
}
},
"nav": {
"automations": "Automations"
},
"onboarding": {
"feature_automations": "Automations",
"feature_automations_desc": "Run steps automatically when something happens, and get notified."
},
"maintenance": {
"title": "Maintenance",
"description": "Pause host status, health, metric, container and tunnel-disconnection automations while work is in progress. Monitoring, manual runs and scheduled automations continue.",
"denied": "You do not have permission to view maintenance.",
"sharedReadOnly": "This host was shared with you. Only its owner can change maintenance.",
"failed": "Maintenance could not be updated.",
"active": "Maintenance active",
"estimated": "Estimated completion",
"noAutoEnd": "The estimate does not end maintenance. End it explicitly when the work is complete.",
"endConfirm": "End maintenance and resume event-triggered automations for this host?",
"confirmEnd": "Confirm end",
"cancel": "Cancel",
"end": "End maintenance",
"action": "Action",
"startNow": "Start now",
"schedule": "Schedule for later",
"reason": "Reason",
"startUtc": "First start (UTC)",
"repeat": "Repeat",
"once": "Once",
"weekly": "Weekly",
"monthly": "Monthly",
"utcHelp": "Schedules use UTC throughout the year. Monthly schedules skip months without the selected day. Planned windows cannot overlap; overrunning maintenance continues across later windows.",
"duration": "Estimated duration (minutes)",
"grace": "Grace period (minutes)",
"notify": "Notify once if still offline after the estimate plus grace period",
"addSchedule": "Add schedule",
"schedules": "Schedules",
"noSchedules": "No maintenance schedules.",
"completed": "Already started",
"minutes_one": "{{count}} minute",
"minutes_other": "{{count}} minutes",
"remove": "Remove schedule",
"loadFailed": "Could not refresh maintenance status. Changes are disabled until it can be loaded.",
"loading": "Loading maintenance status…"
}
}
@@ -0,0 +1,211 @@
{
"permissions": {
"view": {
"title": "View automations",
"description": "See the automations and their history."
},
"create": {
"title": "Create automations",
"description": "Add new automations."
},
"edit": {
"title": "Edit automations",
"description": "Change existing automations."
},
"delete": {
"title": "Delete automations",
"description": "Remove automations."
},
"run": {
"title": "Run automations",
"description": "Trigger an automation by hand."
}
},
"newUi": {
"sidebar": {
"automations": {
"title": "Automations",
"tabAutomations": "Automations",
"tabRuns": "Runs",
"tabChannels": "Channels",
"createAutomation": "New Automation",
"backToAutomations": "Back to Automations",
"createTitle": "New Automation",
"editTitle": "Edit Automation",
"empty": "No automations yet. Create one to react to a metric, a schedule, or an event.",
"emptyRuns": "No runs yet.",
"channelsHint": "Channels are set up in Alerts",
"noChannelsHint": "The alert always goes to your inbox. Add channels in Alerts to also send it somewhere else.",
"nameLabel": "Name",
"namePlaceholder": "e.g., Clean up /data when it fills",
"nameRequired": "Name is required",
"descriptionLabel": "Description",
"descriptionPlaceholder": "Optional description",
"enabledLabel": "Enabled",
"concurrencyLabel": "If it is already running",
"concurrencyHint": "What to do when this is triggered again before the last run has finished.",
"concurrency": {
"skip": "Skip the new run",
"queue": "Wait and run after",
"allow": "Run both at once"
},
"save": "Save",
"created": "Automation created",
"updated": "Automation saved",
"deleted": "Automation deleted",
"deleteConfirm": "Delete this automation? Its run history is deleted too.",
"runNow": "Run now",
"testRun": "Test run",
"testRunHint": "Runs every step but does not touch anything outside Termix.",
"runStarted": "Run started",
"runFinished": "Run finished: {{status}}",
"trigger": "Trigger",
"steps": "Steps",
"addStep": "Add step",
"removeStep": "Remove step",
"moveUp": "Move up",
"moveDown": "Move down",
"noSteps": "No steps yet. Add one to say what should happen.",
"webhookTokenTitle": "Webhook URL",
"webhookTokenDescription": "This is shown once. Save it now, it cannot be retrieved later.",
"copy": "Copy",
"copied": "Copied",
"viewJson": "JSON",
"viewBuilder": "Builder",
"viewGraph": "Graph",
"lastRun": "Last run",
"never": "Never",
"duration": "{{seconds}}s",
"stepOutput": "Output",
"stepError": "Error",
"dryRunBadge": "Test run",
"hostSelector": {
"trigger": "Triggering host",
"host": "Specific host",
"fleet": "Fleet",
"all": "All hosts"
},
"triggerKinds": {
"metric_threshold": "Metric threshold",
"host_status": "Host goes up or down",
"health_check": "Health check changes",
"schedule": "On a schedule",
"docker_event": "Container event",
"internal_event": "Termix event",
"webhook": "Incoming webhook"
},
"stepTypes": {
"notify": "Send an alert",
"http": "Call a URL",
"run_snippet": "Run a snippet",
"run_command": "Run a command",
"docker": "Control a container",
"tunnel": "Control a tunnel",
"wol": "Wake a host",
"wait": "Wait",
"set_var": "Set a variable",
"if": "If / otherwise",
"run_automation": "Run another automation",
"stop": "Stop"
},
"statuses": {
"running": "Running",
"success": "Success",
"failed": "Failed",
"timeout": "Timed out",
"skipped": "Skipped",
"cancelled": "Cancelled"
},
"fields": {
"host": "Host",
"metric": "Metric",
"mount": "Mount point",
"operator": "Is",
"value": "Value",
"forSeconds": "Sustained for (seconds)",
"cooldown": "Cooldown (minutes)",
"cron": "Cron expression",
"intervalSeconds": "Every (seconds)",
"timezone": "Time zone",
"timezoneServer": "Server time",
"timezoneHint": "Cron times are read in this zone. Does not apply to interval schedules.",
"container": "Container",
"command": "Command",
"url": "URL",
"method": "Method",
"body": "Body",
"channels": "Also send to",
"title": "Title",
"message": "Message",
"snippet": "Snippet",
"seconds": "Seconds",
"conditionLeftPlaceholder": "Value to check",
"conditionRightPlaceholder": "Compare against",
"variableName": "Variable name",
"variableValue": "Value",
"elevated": "Run with sudo",
"onError": "If this fails",
"allowPrivateNetwork": "Allow local network addresses",
"allowPrivateNetworkHint": "Needed for a self-hosted service on your LAN. Off by default to prevent requests to internal addresses."
},
"onError": {
"stop": "Stop the run",
"continue": "Carry on"
},
"needsPlugin": "Needs {{plugin}}",
"needsPluginHint": "This automation is skipped until the feature it needs is available again.",
"offBadge": "Off",
"docsLink": "View docs",
"ago": {
"seconds": "{{count}}s ago",
"minutes": "{{count}}m ago",
"hours": "{{count}}h ago",
"days": "{{count}}d ago"
}
}
}
},
"nav": {
"automations": "Automations"
},
"onboarding": {
"feature_automations": "Automations",
"feature_automations_desc": "Run steps automatically when something happens, and get notified."
},
"maintenance": {
"title": "Maintenance",
"description": "Pause host status, health, metric, container and tunnel-disconnection automations while work is in progress. Monitoring, manual runs and scheduled automations continue.",
"denied": "You do not have permission to view maintenance.",
"sharedReadOnly": "This host was shared with you. Only its owner can change maintenance.",
"failed": "Maintenance could not be updated.",
"active": "Maintenance active",
"estimated": "Estimated completion",
"noAutoEnd": "The estimate does not end maintenance. End it explicitly when the work is complete.",
"endConfirm": "End maintenance and resume event-triggered automations for this host?",
"confirmEnd": "Confirm end",
"cancel": "Cancel",
"end": "End maintenance",
"action": "Action",
"startNow": "Start now",
"schedule": "Schedule for later",
"reason": "Reason",
"startUtc": "First start (UTC)",
"repeat": "Repeat",
"once": "Once",
"weekly": "Weekly",
"monthly": "Monthly",
"utcHelp": "Schedules use UTC throughout the year. Monthly schedules skip months without the selected day. Planned windows cannot overlap; overrunning maintenance continues across later windows.",
"duration": "Estimated duration (minutes)",
"grace": "Grace period (minutes)",
"notify": "Notify once if still offline after the estimate plus grace period",
"addSchedule": "Add schedule",
"schedules": "Schedules",
"noSchedules": "No maintenance schedules.",
"completed": "Already started",
"minutes_one": "{{count}} minute",
"minutes_other": "{{count}} minutes",
"remove": "Remove schedule",
"loadFailed": "Could not refresh maintenance status. Changes are disabled until it can be loaded.",
"loading": "Loading maintenance status…"
}
}
@@ -0,0 +1,211 @@
{
"permissions": {
"view": {
"title": "View automations",
"description": "See the automations and their history."
},
"create": {
"title": "Create automations",
"description": "Add new automations."
},
"edit": {
"title": "Edit automations",
"description": "Change existing automations."
},
"delete": {
"title": "Delete automations",
"description": "Remove automations."
},
"run": {
"title": "Run automations",
"description": "Trigger an automation by hand."
}
},
"newUi": {
"sidebar": {
"automations": {
"title": "Automations",
"tabAutomations": "Automations",
"tabRuns": "Runs",
"tabChannels": "Kanallar",
"createAutomation": "New Automation",
"backToAutomations": "Back to Automations",
"createTitle": "New Automation",
"editTitle": "Edit Automation",
"empty": "No automations yet. Create one to react to a metric, a schedule, or an event.",
"emptyRuns": "No runs yet.",
"channelsHint": "Channels are set up in Alerts",
"noChannelsHint": "The alert always goes to your inbox. Add channels in Alerts to also send it somewhere else.",
"nameLabel": "Ad",
"namePlaceholder": "e.g., Clean up /data when it fills",
"nameRequired": "Ad zorunludur",
"descriptionLabel": "Açıklama",
"descriptionPlaceholder": "İsteğe bağlı açıklama",
"enabledLabel": "Etkin",
"concurrencyLabel": "If it is already running",
"concurrencyHint": "What to do when this is triggered again before the last run has finished.",
"concurrency": {
"skip": "Skip the new run",
"queue": "Wait and run after",
"allow": "Run both at once"
},
"save": "Kaydet",
"created": "Automation created",
"updated": "Automation saved",
"deleted": "Automation deleted",
"deleteConfirm": "Delete this automation? Its run history is deleted too.",
"runNow": "Run now",
"testRun": "Test run",
"testRunHint": "Runs every step but does not touch anything outside Termix.",
"runStarted": "Run started",
"runFinished": "Run finished: {{status}}",
"trigger": "Tetikleyici",
"steps": "Steps",
"addStep": "Add step",
"removeStep": "Remove step",
"moveUp": "Move up",
"moveDown": "Move down",
"noSteps": "No steps yet. Add one to say what should happen.",
"webhookTokenTitle": "Webhook URL",
"webhookTokenDescription": "This is shown once. Save it now, it cannot be retrieved later.",
"copy": "Kopyala",
"copied": "Kopyalandı",
"viewJson": "JSON",
"viewBuilder": "Builder",
"viewGraph": "Graph",
"lastRun": "Last run",
"never": "Hiçbir zaman",
"duration": "{{seconds}}s",
"stepOutput": "Çıktı",
"stepError": "Hata",
"dryRunBadge": "Test run",
"hostSelector": {
"trigger": "Triggering host",
"host": "Specific host",
"fleet": "Fleet",
"all": "Tüm sunucular"
},
"triggerKinds": {
"metric_threshold": "Metric threshold",
"host_status": "Host goes up or down",
"health_check": "Health check changes",
"schedule": "On a schedule",
"docker_event": "Container event",
"internal_event": "Termix event",
"webhook": "Incoming webhook"
},
"stepTypes": {
"notify": "Send an alert",
"http": "Call a URL",
"run_snippet": "Run a snippet",
"run_command": "Run a command",
"docker": "Control a container",
"tunnel": "Control a tunnel",
"wol": "Wake a host",
"wait": "Wait",
"set_var": "Set a variable",
"if": "If / otherwise",
"run_automation": "Run another automation",
"stop": "Durdurma"
},
"statuses": {
"running": "Çalışıyor",
"success": "Başarılı",
"failed": "Başarısız",
"timeout": "Timed out",
"skipped": "Skipped",
"cancelled": "Cancelled"
},
"fields": {
"host": "Ana Bilgisayar",
"metric": "Metrik",
"mount": "Mount point",
"operator": "Is",
"value": "Value",
"forSeconds": "Sustained for (seconds)",
"cooldown": "Bekleme süresi (dakika)",
"cron": "Cron expression",
"intervalSeconds": "Every (seconds)",
"timezone": "Time zone",
"timezoneServer": "Server time",
"timezoneHint": "Cron times are read in this zone. Does not apply to interval schedules.",
"container": "Container",
"command": "Komut",
"url": "URL",
"method": "Method",
"body": "Body",
"channels": "Also send to",
"title": "Başlık",
"message": "Message",
"snippet": "Snippet",
"seconds": "Seconds",
"conditionLeftPlaceholder": "Value to check",
"conditionRightPlaceholder": "Compare against",
"variableName": "Variable name",
"variableValue": "Value",
"elevated": "Run with sudo",
"onError": "If this fails",
"allowPrivateNetwork": "Allow local network addresses",
"allowPrivateNetworkHint": "Needed for a self-hosted service on your LAN. Off by default to prevent requests to internal addresses."
},
"onError": {
"stop": "Stop the run",
"continue": "Carry on"
},
"needsPlugin": "Needs {{plugin}}",
"needsPluginHint": "This automation is skipped until the feature it needs is available again.",
"offBadge": "Kapalı",
"docsLink": "Belgelere bak",
"ago": {
"seconds": "{{count}}s ago",
"minutes": "{{count}} dakika önce",
"hours": "{{count}} saat önce",
"days": "{{count}} gün önce"
}
}
}
},
"nav": {
"automations": "Automations"
},
"onboarding": {
"feature_automations": "Automations",
"feature_automations_desc": "Run steps automatically when something happens, and get notified."
},
"maintenance": {
"title": "Bakım",
"description": "Pause host status, health, metric, container and tunnel-disconnection automations while work is in progress. Monitoring, manual runs and scheduled automations continue.",
"denied": "You do not have permission to view maintenance.",
"sharedReadOnly": "This host was shared with you. Only its owner can change maintenance.",
"failed": "Maintenance could not be updated.",
"active": "Maintenance active",
"estimated": "Estimated completion",
"noAutoEnd": "The estimate does not end maintenance. End it explicitly when the work is complete.",
"endConfirm": "End maintenance and resume event-triggered automations for this host?",
"confirmEnd": "Confirm end",
"cancel": "İptal",
"end": "End maintenance",
"action": "İşlem",
"startNow": "Start now",
"schedule": "Schedule for later",
"reason": "Reason",
"startUtc": "First start (UTC)",
"repeat": "Repeat",
"once": "Once",
"weekly": "Weekly",
"monthly": "Monthly",
"utcHelp": "Schedules use UTC throughout the year. Monthly schedules skip months without the selected day. Planned windows cannot overlap; overrunning maintenance continues across later windows.",
"duration": "Estimated duration (minutes)",
"grace": "Grace period (minutes)",
"notify": "Notify once if still offline after the estimate plus grace period",
"addSchedule": "Add schedule",
"schedules": "Schedules",
"noSchedules": "No maintenance schedules.",
"completed": "Already started",
"minutes_one": "{{count}} minute",
"minutes_other": "{{count}} minutes",
"remove": "Remove schedule",
"loadFailed": "Could not refresh maintenance status. Changes are disabled until it can be loaded.",
"loading": "Loading maintenance status…"
}
}
@@ -0,0 +1,211 @@
{
"permissions": {
"view": {
"title": "View automations",
"description": "See the automations and their history."
},
"create": {
"title": "Create automations",
"description": "Add new automations."
},
"edit": {
"title": "Edit automations",
"description": "Change existing automations."
},
"delete": {
"title": "Delete automations",
"description": "Remove automations."
},
"run": {
"title": "Run automations",
"description": "Trigger an automation by hand."
}
},
"newUi": {
"sidebar": {
"automations": {
"title": "Automations",
"tabAutomations": "Automations",
"tabRuns": "Runs",
"tabChannels": "Channels",
"createAutomation": "New Automation",
"backToAutomations": "Back to Automations",
"createTitle": "New Automation",
"editTitle": "Edit Automation",
"empty": "No automations yet. Create one to react to a metric, a schedule, or an event.",
"emptyRuns": "No runs yet.",
"channelsHint": "Channels are set up in Alerts",
"noChannelsHint": "The alert always goes to your inbox. Add channels in Alerts to also send it somewhere else.",
"nameLabel": "Name",
"namePlaceholder": "e.g., Clean up /data when it fills",
"nameRequired": "Name is required",
"descriptionLabel": "Description",
"descriptionPlaceholder": "Optional description",
"enabledLabel": "Enabled",
"concurrencyLabel": "If it is already running",
"concurrencyHint": "What to do when this is triggered again before the last run has finished.",
"concurrency": {
"skip": "Skip the new run",
"queue": "Wait and run after",
"allow": "Run both at once"
},
"save": "Save",
"created": "Automation created",
"updated": "Automation saved",
"deleted": "Automation deleted",
"deleteConfirm": "Delete this automation? Its run history is deleted too.",
"runNow": "Run now",
"testRun": "Test run",
"testRunHint": "Runs every step but does not touch anything outside Termix.",
"runStarted": "Run started",
"runFinished": "Run finished: {{status}}",
"trigger": "Trigger",
"steps": "Steps",
"addStep": "Add step",
"removeStep": "Remove step",
"moveUp": "Move up",
"moveDown": "Move down",
"noSteps": "No steps yet. Add one to say what should happen.",
"webhookTokenTitle": "Webhook URL",
"webhookTokenDescription": "This is shown once. Save it now, it cannot be retrieved later.",
"copy": "Copy",
"copied": "Copied",
"viewJson": "JSON",
"viewBuilder": "Builder",
"viewGraph": "Graph",
"lastRun": "Last run",
"never": "Never",
"duration": "{{seconds}}s",
"stepOutput": "Output",
"stepError": "Error",
"dryRunBadge": "Test run",
"hostSelector": {
"trigger": "Triggering host",
"host": "Specific host",
"fleet": "Fleet",
"all": "All hosts"
},
"triggerKinds": {
"metric_threshold": "Metric threshold",
"host_status": "Host goes up or down",
"health_check": "Health check changes",
"schedule": "On a schedule",
"docker_event": "Container event",
"internal_event": "Termix event",
"webhook": "Incoming webhook"
},
"stepTypes": {
"notify": "Send an alert",
"http": "Call a URL",
"run_snippet": "Run a snippet",
"run_command": "Run a command",
"docker": "Control a container",
"tunnel": "Control a tunnel",
"wol": "Wake a host",
"wait": "Wait",
"set_var": "Set a variable",
"if": "If / otherwise",
"run_automation": "Run another automation",
"stop": "Stop"
},
"statuses": {
"running": "Running",
"success": "Success",
"failed": "Failed",
"timeout": "Timed out",
"skipped": "Skipped",
"cancelled": "Cancelled"
},
"fields": {
"host": "Host",
"metric": "Metric",
"mount": "Mount point",
"operator": "Is",
"value": "Value",
"forSeconds": "Sustained for (seconds)",
"cooldown": "Cooldown (minutes)",
"cron": "Cron expression",
"intervalSeconds": "Every (seconds)",
"timezone": "Time zone",
"timezoneServer": "Server time",
"timezoneHint": "Cron times are read in this zone. Does not apply to interval schedules.",
"container": "Container",
"command": "Command",
"url": "URL",
"method": "Method",
"body": "Body",
"channels": "Also send to",
"title": "Title",
"message": "Message",
"snippet": "Snippet",
"seconds": "Seconds",
"conditionLeftPlaceholder": "Value to check",
"conditionRightPlaceholder": "Compare against",
"variableName": "Variable name",
"variableValue": "Value",
"elevated": "Run with sudo",
"onError": "If this fails",
"allowPrivateNetwork": "Allow local network addresses",
"allowPrivateNetworkHint": "Needed for a self-hosted service on your LAN. Off by default to prevent requests to internal addresses."
},
"onError": {
"stop": "Stop the run",
"continue": "Carry on"
},
"needsPlugin": "Needs {{plugin}}",
"needsPluginHint": "This automation is skipped until the feature it needs is available again.",
"offBadge": "Off",
"docsLink": "View docs",
"ago": {
"seconds": "{{count}}s ago",
"minutes": "{{count}}m ago",
"hours": "{{count}}h ago",
"days": "{{count}}d ago"
}
}
}
},
"nav": {
"automations": "Automations"
},
"onboarding": {
"feature_automations": "Automations",
"feature_automations_desc": "Run steps automatically when something happens, and get notified."
},
"maintenance": {
"title": "Maintenance",
"description": "Pause host status, health, metric, container and tunnel-disconnection automations while work is in progress. Monitoring, manual runs and scheduled automations continue.",
"denied": "You do not have permission to view maintenance.",
"sharedReadOnly": "This host was shared with you. Only its owner can change maintenance.",
"failed": "Maintenance could not be updated.",
"active": "Maintenance active",
"estimated": "Estimated completion",
"noAutoEnd": "The estimate does not end maintenance. End it explicitly when the work is complete.",
"endConfirm": "End maintenance and resume event-triggered automations for this host?",
"confirmEnd": "Confirm end",
"cancel": "Cancel",
"end": "End maintenance",
"action": "Action",
"startNow": "Start now",
"schedule": "Schedule for later",
"reason": "Reason",
"startUtc": "First start (UTC)",
"repeat": "Repeat",
"once": "Once",
"weekly": "Weekly",
"monthly": "Monthly",
"utcHelp": "Schedules use UTC throughout the year. Monthly schedules skip months without the selected day. Planned windows cannot overlap; overrunning maintenance continues across later windows.",
"duration": "Estimated duration (minutes)",
"grace": "Grace period (minutes)",
"notify": "Notify once if still offline after the estimate plus grace period",
"addSchedule": "Add schedule",
"schedules": "Schedules",
"noSchedules": "No maintenance schedules.",
"completed": "Already started",
"minutes_one": "{{count}} minute",
"minutes_other": "{{count}} minutes",
"remove": "Remove schedule",
"loadFailed": "Could not refresh maintenance status. Changes are disabled until it can be loaded.",
"loading": "Loading maintenance status…"
}
}
@@ -0,0 +1,211 @@
{
"permissions": {
"view": {
"title": "View automations",
"description": "See the automations and their history."
},
"create": {
"title": "Create automations",
"description": "Add new automations."
},
"edit": {
"title": "Edit automations",
"description": "Change existing automations."
},
"delete": {
"title": "Delete automations",
"description": "Remove automations."
},
"run": {
"title": "Run automations",
"description": "Trigger an automation by hand."
}
},
"newUi": {
"sidebar": {
"automations": {
"title": "Automations",
"tabAutomations": "Automations",
"tabRuns": "Runs",
"tabChannels": "Kênh",
"createAutomation": "New Automation",
"backToAutomations": "Back to Automations",
"createTitle": "New Automation",
"editTitle": "Edit Automation",
"empty": "No automations yet. Create one to react to a metric, a schedule, or an event.",
"emptyRuns": "No runs yet.",
"channelsHint": "Channels are set up in Alerts",
"noChannelsHint": "The alert always goes to your inbox. Add channels in Alerts to also send it somewhere else.",
"nameLabel": "Tên",
"namePlaceholder": "e.g., Clean up /data when it fills",
"nameRequired": "Tên là bắt buộc",
"descriptionLabel": "Mô tả",
"descriptionPlaceholder": "Mô tả tùy chọn",
"enabledLabel": "Đã bật",
"concurrencyLabel": "If it is already running",
"concurrencyHint": "What to do when this is triggered again before the last run has finished.",
"concurrency": {
"skip": "Skip the new run",
"queue": "Wait and run after",
"allow": "Run both at once"
},
"save": "Lưu",
"created": "Automation created",
"updated": "Automation saved",
"deleted": "Automation deleted",
"deleteConfirm": "Delete this automation? Its run history is deleted too.",
"runNow": "Run now",
"testRun": "Test run",
"testRunHint": "Runs every step but does not touch anything outside Termix.",
"runStarted": "Run started",
"runFinished": "Run finished: {{status}}",
"trigger": "Kích hoạt",
"steps": "Steps",
"addStep": "Add step",
"removeStep": "Remove step",
"moveUp": "Move up",
"moveDown": "Move down",
"noSteps": "No steps yet. Add one to say what should happen.",
"webhookTokenTitle": "Webhook URL",
"webhookTokenDescription": "This is shown once. Save it now, it cannot be retrieved later.",
"copy": "Sao chép",
"copied": "Đã sao chép",
"viewJson": "JSON",
"viewBuilder": "Builder",
"viewGraph": "Graph",
"lastRun": "Last run",
"never": "Chưa bao giờ",
"duration": "{{seconds}}s",
"stepOutput": "Đầu ra",
"stepError": "Lỗi",
"dryRunBadge": "Test run",
"hostSelector": {
"trigger": "Triggering host",
"host": "Specific host",
"fleet": "Fleet",
"all": "Tất cả máy chủ"
},
"triggerKinds": {
"metric_threshold": "Metric threshold",
"host_status": "Host goes up or down",
"health_check": "Health check changes",
"schedule": "On a schedule",
"docker_event": "Container event",
"internal_event": "Termix event",
"webhook": "Incoming webhook"
},
"stepTypes": {
"notify": "Send an alert",
"http": "Call a URL",
"run_snippet": "Run a snippet",
"run_command": "Run a command",
"docker": "Control a container",
"tunnel": "Control a tunnel",
"wol": "Wake a host",
"wait": "Wait",
"set_var": "Set a variable",
"if": "If / otherwise",
"run_automation": "Run another automation",
"stop": "Bit dừng"
},
"statuses": {
"running": "Đang chạy",
"success": "Thành công",
"failed": "Thất bại",
"timeout": "Timed out",
"skipped": "Skipped",
"cancelled": "Cancelled"
},
"fields": {
"host": "Máy chủ",
"metric": "Chỉ số",
"mount": "Mount point",
"operator": "Is",
"value": "Value",
"forSeconds": "Sustained for (seconds)",
"cooldown": "Thời gian chờ (phút)",
"cron": "Cron expression",
"intervalSeconds": "Every (seconds)",
"timezone": "Time zone",
"timezoneServer": "Server time",
"timezoneHint": "Cron times are read in this zone. Does not apply to interval schedules.",
"container": "Container",
"command": "Lệnh",
"url": "URL",
"method": "Method",
"body": "Body",
"channels": "Also send to",
"title": "Tiêu đề",
"message": "Message",
"snippet": "Snippet",
"seconds": "Seconds",
"conditionLeftPlaceholder": "Value to check",
"conditionRightPlaceholder": "Compare against",
"variableName": "Variable name",
"variableValue": "Value",
"elevated": "Run with sudo",
"onError": "If this fails",
"allowPrivateNetwork": "Allow local network addresses",
"allowPrivateNetworkHint": "Needed for a self-hosted service on your LAN. Off by default to prevent requests to internal addresses."
},
"onError": {
"stop": "Stop the run",
"continue": "Carry on"
},
"needsPlugin": "Needs {{plugin}}",
"needsPluginHint": "This automation is skipped until the feature it needs is available again.",
"offBadge": "Tắt",
"docsLink": "Xem tài liệu",
"ago": {
"seconds": "{{count}}s ago",
"minutes": "{{count}} phút trước",
"hours": "{{count}} giờ trước",
"days": "{{count}} ngày trước"
}
}
}
},
"nav": {
"automations": "Automations"
},
"onboarding": {
"feature_automations": "Automations",
"feature_automations_desc": "Run steps automatically when something happens, and get notified."
},
"maintenance": {
"title": "Bảo trì",
"description": "Pause host status, health, metric, container and tunnel-disconnection automations while work is in progress. Monitoring, manual runs and scheduled automations continue.",
"denied": "You do not have permission to view maintenance.",
"sharedReadOnly": "This host was shared with you. Only its owner can change maintenance.",
"failed": "Maintenance could not be updated.",
"active": "Maintenance active",
"estimated": "Estimated completion",
"noAutoEnd": "The estimate does not end maintenance. End it explicitly when the work is complete.",
"endConfirm": "End maintenance and resume event-triggered automations for this host?",
"confirmEnd": "Confirm end",
"cancel": "Hủy",
"end": "End maintenance",
"action": "Hành động",
"startNow": "Start now",
"schedule": "Schedule for later",
"reason": "Reason",
"startUtc": "First start (UTC)",
"repeat": "Repeat",
"once": "Once",
"weekly": "Weekly",
"monthly": "Monthly",
"utcHelp": "Schedules use UTC throughout the year. Monthly schedules skip months without the selected day. Planned windows cannot overlap; overrunning maintenance continues across later windows.",
"duration": "Estimated duration (minutes)",
"grace": "Grace period (minutes)",
"notify": "Notify once if still offline after the estimate plus grace period",
"addSchedule": "Add schedule",
"schedules": "Schedules",
"noSchedules": "No maintenance schedules.",
"completed": "Already started",
"minutes_one": "{{count}} minute",
"minutes_other": "{{count}} minutes",
"remove": "Remove schedule",
"loadFailed": "Could not refresh maintenance status. Changes are disabled until it can be loaded.",
"loading": "Loading maintenance status…"
}
}
@@ -0,0 +1,211 @@
{
"permissions": {
"view": {
"title": "View automations",
"description": "See the automations and their history."
},
"create": {
"title": "Create automations",
"description": "Add new automations."
},
"edit": {
"title": "Edit automations",
"description": "Change existing automations."
},
"delete": {
"title": "Delete automations",
"description": "Remove automations."
},
"run": {
"title": "Run automations",
"description": "Trigger an automation by hand."
}
},
"newUi": {
"sidebar": {
"automations": {
"title": "Automations",
"tabAutomations": "Automations",
"tabRuns": "运行记录",
"tabChannels": "通道",
"createAutomation": "New Automation",
"backToAutomations": "Back to Automations",
"createTitle": "New Automation",
"editTitle": "Edit Automation",
"empty": "No automations yet. Create one to react to a metric, a schedule, or an event.",
"emptyRuns": "暂无运行记录。",
"channelsHint": "Channels are set up in Alerts",
"noChannelsHint": "The alert always goes to your inbox. Add channels in Alerts to also send it somewhere else.",
"nameLabel": "名称",
"namePlaceholder": "e.g., Clean up /data when it fills",
"nameRequired": "名称为必填项",
"descriptionLabel": "描述",
"descriptionPlaceholder": "可选描述",
"enabledLabel": "已启用",
"concurrencyLabel": "If it is already running",
"concurrencyHint": "What to do when this is triggered again before the last run has finished.",
"concurrency": {
"skip": "Skip the new run",
"queue": "稍等会再运行",
"allow": "Run both at once"
},
"save": "保存",
"created": "自动化已创建",
"updated": "自动化已保存",
"deleted": "自动化已删除",
"deleteConfirm": "Delete this automation? Its run history is deleted too.",
"runNow": "Run now",
"testRun": "Test run",
"testRunHint": "Runs every step but does not touch anything outside Termix.",
"runStarted": "Run started",
"runFinished": "Run finished: {{status}}",
"trigger": "触发器",
"steps": "Steps",
"addStep": "Add step",
"removeStep": "Remove step",
"moveUp": "Move up",
"moveDown": "Move down",
"noSteps": "No steps yet. Add one to say what should happen.",
"webhookTokenTitle": "Webhook URL",
"webhookTokenDescription": "This is shown once. Save it now, it cannot be retrieved later.",
"copy": "复制",
"copied": "已复制",
"viewJson": "JSON",
"viewBuilder": "构建器",
"viewGraph": "Graph",
"lastRun": "上次运行",
"never": "从未",
"duration": "{{seconds}}s",
"stepOutput": "输出",
"stepError": "错误",
"dryRunBadge": "Test run",
"hostSelector": {
"trigger": "Triggering host",
"host": "Specific host",
"fleet": "Fleet",
"all": "所有主机"
},
"triggerKinds": {
"metric_threshold": "Metric threshold",
"host_status": "主机上线或离线",
"health_check": "Health check changes",
"schedule": "On a schedule",
"docker_event": "Container event",
"internal_event": "Termix 事件",
"webhook": "Incoming webhook"
},
"stepTypes": {
"notify": "Send an alert",
"http": "Call a URL",
"run_snippet": "Run a snippet",
"run_command": "Run a command",
"docker": "Control a container",
"tunnel": "Control a tunnel",
"wol": "Wake a host",
"wait": "Wait",
"set_var": "Set a variable",
"if": "If / otherwise",
"run_automation": "运行另一个自动化程序",
"stop": "停止"
},
"statuses": {
"running": "运行中",
"success": "成功",
"failed": "失败",
"timeout": "Timed out",
"skipped": "Skipped",
"cancelled": "已取消"
},
"fields": {
"host": "主机",
"metric": "指标",
"mount": "挂载点",
"operator": "Is",
"value": "值",
"forSeconds": "Sustained for (seconds)",
"cooldown": "冷却时间(分钟)",
"cron": "Cron 表达式",
"intervalSeconds": "Every (seconds)",
"timezone": "Time zone",
"timezoneServer": "Server time",
"timezoneHint": "Cron times are read in this zone. Does not apply to interval schedules.",
"container": "Container",
"command": "命令",
"url": "URL",
"method": "Method",
"body": "请求体",
"channels": "Also send to",
"title": "标题",
"message": "消息",
"snippet": "Snippet",
"seconds": "Seconds",
"conditionLeftPlaceholder": "Value to check",
"conditionRightPlaceholder": "Compare against",
"variableName": "变量名",
"variableValue": "值",
"elevated": "Run with sudo",
"onError": "If this fails",
"allowPrivateNetwork": "Allow local network addresses",
"allowPrivateNetworkHint": "Needed for a self-hosted service on your LAN. Off by default to prevent requests to internal addresses."
},
"onError": {
"stop": "停止运行",
"continue": "Carry on"
},
"needsPlugin": "Needs {{plugin}}",
"needsPluginHint": "This automation is skipped until the feature it needs is available again.",
"offBadge": "关闭",
"docsLink": "查看文档",
"ago": {
"seconds": "{{count}}s ago",
"minutes": "{{count}} 分钟前",
"hours": "{{count}} 小时前",
"days": "{{count}} 天前"
}
}
}
},
"nav": {
"automations": "Automations"
},
"onboarding": {
"feature_automations": "Automations",
"feature_automations_desc": "触发特定事件时自动执行任务,并接收通知。"
},
"maintenance": {
"title": "维护中",
"description": "Pause host status, health, metric, container and tunnel-disconnection automations while work is in progress. Monitoring, manual runs and scheduled automations continue.",
"denied": "You do not have permission to view maintenance.",
"sharedReadOnly": "This host was shared with you. Only its owner can change maintenance.",
"failed": "Maintenance could not be updated.",
"active": "Maintenance active",
"estimated": "Estimated completion",
"noAutoEnd": "The estimate does not end maintenance. End it explicitly when the work is complete.",
"endConfirm": "End maintenance and resume event-triggered automations for this host?",
"confirmEnd": "Confirm end",
"cancel": "取消",
"end": "End maintenance",
"action": "操作",
"startNow": "Start now",
"schedule": "Schedule for later",
"reason": "Reason",
"startUtc": "First start (UTC)",
"repeat": "Repeat",
"once": "Once",
"weekly": "Weekly",
"monthly": "Monthly",
"utcHelp": "Schedules use UTC throughout the year. Monthly schedules skip months without the selected day. Planned windows cannot overlap; overrunning maintenance continues across later windows.",
"duration": "Estimated duration (minutes)",
"grace": "Grace period (minutes)",
"notify": "Notify once if still offline after the estimate plus grace period",
"addSchedule": "Add schedule",
"schedules": "Schedules",
"noSchedules": "No maintenance schedules.",
"completed": "Already started",
"minutes_one": "{{count}} minute",
"minutes_other": "{{count}} minutes",
"remove": "Remove schedule",
"loadFailed": "Could not refresh maintenance status. Changes are disabled until it can be loaded.",
"loading": "Loading maintenance status…"
}
}
@@ -0,0 +1,211 @@
{
"permissions": {
"view": {
"title": "View automations",
"description": "See the automations and their history."
},
"create": {
"title": "Create automations",
"description": "Add new automations."
},
"edit": {
"title": "Edit automations",
"description": "Change existing automations."
},
"delete": {
"title": "Delete automations",
"description": "Remove automations."
},
"run": {
"title": "Run automations",
"description": "Trigger an automation by hand."
}
},
"newUi": {
"sidebar": {
"automations": {
"title": "Automations",
"tabAutomations": "Automations",
"tabRuns": "Runs",
"tabChannels": "通道",
"createAutomation": "New Automation",
"backToAutomations": "Back to Automations",
"createTitle": "New Automation",
"editTitle": "Edit Automation",
"empty": "No automations yet. Create one to react to a metric, a schedule, or an event.",
"emptyRuns": "No runs yet.",
"channelsHint": "Channels are set up in Alerts",
"noChannelsHint": "The alert always goes to your inbox. Add channels in Alerts to also send it somewhere else.",
"nameLabel": "名稱",
"namePlaceholder": "e.g., Clean up /data when it fills",
"nameRequired": "名稱為必填",
"descriptionLabel": "說明",
"descriptionPlaceholder": "選用說明",
"enabledLabel": "已啟用",
"concurrencyLabel": "If it is already running",
"concurrencyHint": "What to do when this is triggered again before the last run has finished.",
"concurrency": {
"skip": "Skip the new run",
"queue": "Wait and run after",
"allow": "Run both at once"
},
"save": "儲存",
"created": "Automation created",
"updated": "Automation saved",
"deleted": "Automation deleted",
"deleteConfirm": "Delete this automation? Its run history is deleted too.",
"runNow": "Run now",
"testRun": "Test run",
"testRunHint": "Runs every step but does not touch anything outside Termix.",
"runStarted": "Run started",
"runFinished": "Run finished: {{status}}",
"trigger": "觸發條件",
"steps": "Steps",
"addStep": "Add step",
"removeStep": "Remove step",
"moveUp": "Move up",
"moveDown": "Move down",
"noSteps": "No steps yet. Add one to say what should happen.",
"webhookTokenTitle": "Webhook URL",
"webhookTokenDescription": "This is shown once. Save it now, it cannot be retrieved later.",
"copy": "複製",
"copied": "已複製",
"viewJson": "JSON",
"viewBuilder": "Builder",
"viewGraph": "Graph",
"lastRun": "Last run",
"never": "從未",
"duration": "{{seconds}}s",
"stepOutput": "輸出",
"stepError": "錯誤",
"dryRunBadge": "Test run",
"hostSelector": {
"trigger": "Triggering host",
"host": "Specific host",
"fleet": "Fleet",
"all": "所有主機"
},
"triggerKinds": {
"metric_threshold": "Metric threshold",
"host_status": "Host goes up or down",
"health_check": "Health check changes",
"schedule": "On a schedule",
"docker_event": "Container event",
"internal_event": "Termix event",
"webhook": "Incoming webhook"
},
"stepTypes": {
"notify": "Send an alert",
"http": "Call a URL",
"run_snippet": "Run a snippet",
"run_command": "Run a command",
"docker": "Control a container",
"tunnel": "Control a tunnel",
"wol": "Wake a host",
"wait": "Wait",
"set_var": "Set a variable",
"if": "If / otherwise",
"run_automation": "Run another automation",
"stop": "停止位元"
},
"statuses": {
"running": "執行中",
"success": "成功",
"failed": "失敗",
"timeout": "Timed out",
"skipped": "Skipped",
"cancelled": "Cancelled"
},
"fields": {
"host": "主機",
"metric": "指標",
"mount": "Mount point",
"operator": "Is",
"value": "Value",
"forSeconds": "Sustained for (seconds)",
"cooldown": "冷卻時間(分鐘)",
"cron": "Cron expression",
"intervalSeconds": "Every (seconds)",
"timezone": "Time zone",
"timezoneServer": "Server time",
"timezoneHint": "Cron times are read in this zone. Does not apply to interval schedules.",
"container": "Container",
"command": "指令",
"url": "URL",
"method": "Method",
"body": "Body",
"channels": "Also send to",
"title": "標題",
"message": "Message",
"snippet": "Snippet",
"seconds": "Seconds",
"conditionLeftPlaceholder": "Value to check",
"conditionRightPlaceholder": "Compare against",
"variableName": "Variable name",
"variableValue": "Value",
"elevated": "Run with sudo",
"onError": "If this fails",
"allowPrivateNetwork": "Allow local network addresses",
"allowPrivateNetworkHint": "Needed for a self-hosted service on your LAN. Off by default to prevent requests to internal addresses."
},
"onError": {
"stop": "Stop the run",
"continue": "Carry on"
},
"needsPlugin": "Needs {{plugin}}",
"needsPluginHint": "This automation is skipped until the feature it needs is available again.",
"offBadge": "關閉",
"docsLink": "檢視文件",
"ago": {
"seconds": "{{count}}s ago",
"minutes": "{{count}} 分鐘前",
"hours": "{{count}} 小時前",
"days": "{{count}} 天前"
}
}
}
},
"nav": {
"automations": "Automations"
},
"onboarding": {
"feature_automations": "Automations",
"feature_automations_desc": "Run steps automatically when something happens, and get notified."
},
"maintenance": {
"title": "維護",
"description": "Pause host status, health, metric, container and tunnel-disconnection automations while work is in progress. Monitoring, manual runs and scheduled automations continue.",
"denied": "You do not have permission to view maintenance.",
"sharedReadOnly": "This host was shared with you. Only its owner can change maintenance.",
"failed": "Maintenance could not be updated.",
"active": "Maintenance active",
"estimated": "Estimated completion",
"noAutoEnd": "The estimate does not end maintenance. End it explicitly when the work is complete.",
"endConfirm": "End maintenance and resume event-triggered automations for this host?",
"confirmEnd": "Confirm end",
"cancel": "取消",
"end": "End maintenance",
"action": "動作",
"startNow": "Start now",
"schedule": "Schedule for later",
"reason": "Reason",
"startUtc": "First start (UTC)",
"repeat": "Repeat",
"once": "Once",
"weekly": "Weekly",
"monthly": "Monthly",
"utcHelp": "Schedules use UTC throughout the year. Monthly schedules skip months without the selected day. Planned windows cannot overlap; overrunning maintenance continues across later windows.",
"duration": "Estimated duration (minutes)",
"grace": "Grace period (minutes)",
"notify": "Notify once if still offline after the estimate plus grace period",
"addSchedule": "Add schedule",
"schedules": "Schedules",
"noSchedules": "No maintenance schedules.",
"completed": "Already started",
"minutes_one": "{{count}} minute",
"minutes_other": "{{count}} minutes",
"remove": "Remove schedule",
"loadFailed": "Could not refresh maintenance status. Changes are disabled until it can be loaded.",
"loading": "Loading maintenance status…"
}
}
+138
View File
@@ -0,0 +1,138 @@
{
"id": "automations",
"name": "Automations",
"version": "1.0.0",
"description": "Run actions on your hosts on a schedule or when something happens, like a host going down or a metric crossing a limit.",
"author": {
"name": "Termix"
},
"license": "MIT",
"repository": "https://github.com/Termix-SSH/Termix",
"category": "Infrastructure",
"icon": "Workflow",
"engine": {
"termix": ">=2.9.0",
"api": "1"
},
"capabilities": [
"hosts:read",
"credentials:use",
"ssh:connect",
"db:own",
"events:core",
"notify:send",
"network:outbound",
"network:serve",
"settings:read-core",
"ui:surface",
"users:impersonate"
],
"optionalDependencies": {
"fleets": "^1.0.0",
"tunnels": "^1.0.0",
"docker": "^1.0.0",
"host-metrics": "^1.0.0",
"wake-on-lan": "^1.0.0"
},
"requires": [
{
"service": "snippets.access",
"versionRange": "^1.0.0"
},
{
"service": "fleets.access",
"versionRange": "^1.0.0",
"optional": true
},
{
"service": "tunnels.access",
"versionRange": "^1.0.0",
"optional": true
},
{
"service": "docker.containers",
"versionRange": "^1.0.0",
"optional": true
},
{
"service": "docker.events",
"versionRange": "^1.0.0",
"optional": true
},
{
"service": "host-metrics.viewers",
"versionRange": "^1.0.0",
"optional": true
},
{
"service": "wake-on-lan.send",
"versionRange": "^1.0.0",
"optional": true
}
],
"provides": [
{
"service": "automations.access",
"version": "1.0.0",
"permission": "automations.view"
}
],
"contributes": {
"tabs": [
{
"id": "automations",
"titleKey": "nav.automations",
"icon": "Workflow",
"openFrom": ["rail"]
},
{
"id": "host_maintenance",
"titleKey": "maintenance.title",
"icon": "Wrench",
"openFrom": ["host-context-menu"]
}
],
"permissions": [
{
"name": "view",
"titleKey": "permissions.view.title",
"descriptionKey": "permissions.view.description",
"defaultRoles": ["user"]
},
{
"name": "create",
"titleKey": "permissions.create.title",
"descriptionKey": "permissions.create.description",
"defaultRoles": ["user"]
},
{
"name": "edit",
"titleKey": "permissions.edit.title",
"descriptionKey": "permissions.edit.description",
"defaultRoles": ["user"]
},
{
"name": "delete",
"titleKey": "permissions.delete.title",
"descriptionKey": "permissions.delete.description",
"defaultRoles": ["user"]
},
{
"name": "run",
"titleKey": "permissions.run.title",
"descriptionKey": "permissions.run.description",
"defaultRoles": ["user"]
}
],
"http": {
"legacyPaths": ["/automations/webhook"]
},
"uses": ["alerts.openChannels"]
},
"locales": "locales",
"platforms": ["linux", "win32", "darwin"],
"dependencies": {
"snippets": "^1.0.0"
},
"desktop": "server"
}
@@ -0,0 +1,86 @@
-- automations 0001: adopt_automations
-- Generated by termix-plugin migrations. Review before committing.
-- Hand-edited: keeps the legacy foreign keys the SDK cannot declare
-- (links between these tables, and a channel link to notification_channels).
CREATE TABLE IF NOT EXISTS `automations` (
`id` int AUTO_INCREMENT PRIMARY KEY,
`user_id` varchar(255) NOT NULL,
`name` text NOT NULL,
`description` text,
`enabled` boolean NOT NULL DEFAULT true,
`definition` text NOT NULL,
`definition_version` int NOT NULL DEFAULT 1,
`concurrency_policy` text NOT NULL DEFAULT ('skip'),
`max_run_seconds` int NOT NULL DEFAULT 300,
`dry_run` boolean NOT NULL DEFAULT false,
`last_run_at` text,
`last_run_status` text,
`created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
`updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
);
RENAME TABLE `automations` TO `p_automations_automations`;
CREATE TABLE IF NOT EXISTS `automation_trigger_state` (
`id` int AUTO_INCREMENT PRIMARY KEY,
`automation_id` int NOT NULL,
`state_key` varchar(255) NOT NULL,
`breach_started_at` text,
`last_fired_at` text,
`last_value` double,
`last_observed_state` text,
`updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
FOREIGN KEY (`automation_id`) REFERENCES `p_automations_automations` (`id`) ON DELETE CASCADE
);
RENAME TABLE `automation_trigger_state` TO `p_automations_trigger_state`;
CREATE TABLE IF NOT EXISTS `automation_schedules` (
`id` int AUTO_INCREMENT PRIMARY KEY,
`automation_id` int NOT NULL,
`cron` text,
`interval_seconds` int,
`timezone` text,
`next_due_at` varchar(255),
`last_tick_at` text,
FOREIGN KEY (`automation_id`) REFERENCES `p_automations_automations` (`id`) ON DELETE CASCADE
);
RENAME TABLE `automation_schedules` TO `p_automations_schedules`;
CREATE TABLE IF NOT EXISTS `automation_runs` (
`id` int AUTO_INCREMENT PRIMARY KEY,
`automation_id` int NOT NULL,
`user_id` varchar(255) NOT NULL,
`trigger_type` text NOT NULL,
`trigger_context` text,
`status` text NOT NULL,
`started_at` varchar(255) NOT NULL DEFAULT (CURRENT_TIMESTAMP),
`finished_at` text,
`duration_ms` int,
`error` text,
`dry_run` boolean NOT NULL DEFAULT false,
`parent_run_id` int,
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
FOREIGN KEY (`automation_id`) REFERENCES `p_automations_automations` (`id`) ON DELETE CASCADE
);
RENAME TABLE `automation_runs` TO `p_automations_runs`;
CREATE TABLE IF NOT EXISTS `automation_run_steps` (
`id` int AUTO_INCREMENT PRIMARY KEY,
`run_id` int NOT NULL,
`step_index` int NOT NULL,
`step_id` text NOT NULL,
`step_type` text NOT NULL,
`status` text NOT NULL,
`started_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
`finished_at` text,
`output` text,
`error` text,
`truncated` boolean NOT NULL DEFAULT false,
FOREIGN KEY (`run_id`) REFERENCES `p_automations_runs` (`id`) ON DELETE CASCADE
);
RENAME TABLE `automation_run_steps` TO `p_automations_run_steps`;
CREATE TABLE IF NOT EXISTS `automation_channels` (
`id` int AUTO_INCREMENT PRIMARY KEY,
`automation_id` int NOT NULL,
`channel_id` int NOT NULL,
FOREIGN KEY (`automation_id`) REFERENCES `p_automations_automations` (`id`) ON DELETE CASCADE,
FOREIGN KEY (`channel_id`) REFERENCES `notification_channels` (`id`) ON DELETE CASCADE
);
RENAME TABLE `automation_channels` TO `p_automations_channels`;
@@ -0,0 +1,15 @@
-- automations 0002: unlink_channels
-- Hand-written: channels moved from core's notification_channels to the
-- alerts plugin, so the link table drops its foreign key to the old table.
-- The constraint's name differs between installs, so the table is rebuilt.
CREATE TABLE IF NOT EXISTS `p_automations_channels_v2` (
`id` int AUTO_INCREMENT PRIMARY KEY,
`automation_id` int NOT NULL,
`channel_id` int NOT NULL,
FOREIGN KEY (`automation_id`) REFERENCES `p_automations_automations` (`id`) ON DELETE CASCADE
);
INSERT INTO `p_automations_channels_v2` (`id`, `automation_id`, `channel_id`) SELECT `id`, `automation_id`, `channel_id` FROM `p_automations_channels`;
DROP TABLE `p_automations_channels`;
RENAME TABLE `p_automations_channels_v2` TO `p_automations_channels`;
CREATE UNIQUE INDEX `idx_automation_channels_pair` ON `p_automations_channels` (`automation_id`, `channel_id`);
@@ -0,0 +1,12 @@
-- automations 0003: host_maintenance
-- Generated by termix-plugin migrations. Review before committing.
CREATE TABLE IF NOT EXISTS `p_automations_host_maintenance` (
`id` int AUTO_INCREMENT PRIMARY KEY,
`user_id` varchar(255) NOT NULL,
`host_id` int NOT NULL,
`state` text NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
FOREIGN KEY (`host_id`) REFERENCES `ssh_data` (`id`) ON DELETE CASCADE
);
CREATE UNIQUE INDEX `maintenance_host_owner` ON `p_automations_host_maintenance` (`user_id`, `host_id`);
@@ -0,0 +1,5 @@
-- automations 0004: rename_maintenance_index
-- Renames the maintenance index to the idx_ pattern every other index uses.
CREATE UNIQUE INDEX `idx_automation_maintenance_host_owner` ON `p_automations_host_maintenance` (`user_id`, `host_id`);
DROP INDEX `maintenance_host_owner` ON `p_automations_host_maintenance`;
@@ -0,0 +1,94 @@
-- automations 0001: adopt_automations
-- Generated by termix-plugin migrations. Review before committing.
-- Hand-edited: keeps the legacy foreign keys the SDK cannot declare
-- (links between these tables, and a channel link to notification_channels).
CREATE TABLE IF NOT EXISTS "automations" (
"id" serial PRIMARY KEY,
"user_id" varchar(255) NOT NULL,
"name" text NOT NULL,
"description" text,
"enabled" boolean NOT NULL DEFAULT true,
"definition" text NOT NULL,
"definition_version" integer NOT NULL DEFAULT 1,
"concurrency_policy" text NOT NULL DEFAULT 'skip',
"max_run_seconds" integer NOT NULL DEFAULT 300,
"dry_run" boolean NOT NULL DEFAULT false,
"last_run_at" text,
"last_run_status" text,
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS "idx_automations_user" ON "automations" ("user_id", "enabled");
ALTER TABLE "automations" RENAME TO "p_automations_automations";
CREATE TABLE IF NOT EXISTS "automation_trigger_state" (
"id" serial PRIMARY KEY,
"automation_id" integer NOT NULL,
"state_key" varchar(255) NOT NULL,
"breach_started_at" text,
"last_fired_at" text,
"last_value" double precision,
"last_observed_state" text,
"updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY ("automation_id") REFERENCES "p_automations_automations" ("id") ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS "idx_automation_trigger_state_key" ON "automation_trigger_state" ("automation_id", "state_key");
ALTER TABLE "automation_trigger_state" RENAME TO "p_automations_trigger_state";
CREATE TABLE IF NOT EXISTS "automation_schedules" (
"id" serial PRIMARY KEY,
"automation_id" integer NOT NULL,
"cron" text,
"interval_seconds" integer,
"timezone" text,
"next_due_at" varchar(255),
"last_tick_at" text,
FOREIGN KEY ("automation_id") REFERENCES "p_automations_automations" ("id") ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS "idx_automation_schedules_automation" ON "automation_schedules" ("automation_id");
CREATE INDEX IF NOT EXISTS "idx_automation_schedules_due" ON "automation_schedules" ("next_due_at");
ALTER TABLE "automation_schedules" RENAME TO "p_automations_schedules";
CREATE TABLE IF NOT EXISTS "automation_runs" (
"id" serial PRIMARY KEY,
"automation_id" integer NOT NULL,
"user_id" varchar(255) NOT NULL,
"trigger_type" text NOT NULL,
"trigger_context" text,
"status" text NOT NULL,
"started_at" varchar(255) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"finished_at" text,
"duration_ms" integer,
"error" text,
"dry_run" boolean NOT NULL DEFAULT false,
"parent_run_id" integer,
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE,
FOREIGN KEY ("automation_id") REFERENCES "p_automations_automations" ("id") ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS "idx_automation_runs_automation" ON "automation_runs" ("automation_id", "started_at");
CREATE INDEX IF NOT EXISTS "idx_automation_runs_user" ON "automation_runs" ("user_id", "started_at");
ALTER TABLE "automation_runs" RENAME TO "p_automations_runs";
CREATE TABLE IF NOT EXISTS "automation_run_steps" (
"id" serial PRIMARY KEY,
"run_id" integer NOT NULL,
"step_index" integer NOT NULL,
"step_id" text NOT NULL,
"step_type" text NOT NULL,
"status" text NOT NULL,
"started_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
"finished_at" text,
"output" text,
"error" text,
"truncated" boolean NOT NULL DEFAULT false,
FOREIGN KEY ("run_id") REFERENCES "p_automations_runs" ("id") ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS "idx_automation_run_steps_run" ON "automation_run_steps" ("run_id", "step_index");
ALTER TABLE "automation_run_steps" RENAME TO "p_automations_run_steps";
CREATE TABLE IF NOT EXISTS "automation_channels" (
"id" serial PRIMARY KEY,
"automation_id" integer NOT NULL,
"channel_id" integer NOT NULL,
FOREIGN KEY ("automation_id") REFERENCES "p_automations_automations" ("id") ON DELETE CASCADE,
FOREIGN KEY ("channel_id") REFERENCES "notification_channels" ("id") ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS "idx_automation_channels_pair" ON "automation_channels" ("automation_id", "channel_id");
ALTER TABLE "automation_channels" RENAME TO "p_automations_channels";
@@ -0,0 +1,16 @@
-- automations 0002: unlink_channels
-- Hand-written: channels moved from core's notification_channels to the
-- alerts plugin, so the link table drops its foreign key to the old table.
-- The constraint's name differs between installs, so the table is rebuilt.
CREATE TABLE IF NOT EXISTS "p_automations_channels_v2" (
"id" serial PRIMARY KEY,
"automation_id" integer NOT NULL,
"channel_id" integer NOT NULL,
FOREIGN KEY ("automation_id") REFERENCES "p_automations_automations" ("id") ON DELETE CASCADE
);
INSERT INTO "p_automations_channels_v2" ("id", "automation_id", "channel_id") SELECT "id", "automation_id", "channel_id" FROM "p_automations_channels";
DROP TABLE "p_automations_channels";
ALTER TABLE "p_automations_channels_v2" RENAME TO "p_automations_channels";
SELECT setval(pg_get_serial_sequence('p_automations_channels', 'id'), COALESCE((SELECT MAX("id") FROM "p_automations_channels"), 1));
CREATE UNIQUE INDEX IF NOT EXISTS "idx_automation_channels_pair" ON "p_automations_channels" ("automation_id", "channel_id");
@@ -0,0 +1,12 @@
-- automations 0003: host_maintenance
-- Generated by termix-plugin migrations. Review before committing.
CREATE TABLE IF NOT EXISTS "p_automations_host_maintenance" (
"id" serial PRIMARY KEY,
"user_id" varchar(255) NOT NULL,
"host_id" integer NOT NULL,
"state" text NOT NULL,
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE,
FOREIGN KEY ("host_id") REFERENCES "ssh_data" ("id") ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS "maintenance_host_owner" ON "p_automations_host_maintenance" ("user_id", "host_id");
@@ -0,0 +1,5 @@
-- automations 0004: rename_maintenance_index
-- Renames the maintenance index to the idx_ pattern every other index uses.
CREATE UNIQUE INDEX IF NOT EXISTS "idx_automation_maintenance_host_owner" ON "p_automations_host_maintenance" ("user_id", "host_id");
DROP INDEX IF EXISTS "maintenance_host_owner";
@@ -0,0 +1,332 @@
{
"version": 1,
"tables": {
"host_maintenance": {
"columns": {
"id": {
"type": "id",
"primaryKey": true,
"notNull": true
},
"userId": {
"type": "refUser",
"length": 255,
"notNull": true
},
"hostId": {
"type": "refHost",
"notNull": true
},
"state": {
"type": "text",
"notNull": true
}
},
"indexes": [
{
"name": "idx_automation_maintenance_host_owner",
"columns": ["userId", "hostId"],
"unique": true
}
]
},
"automations": {
"columns": {
"id": {
"type": "id",
"primaryKey": true,
"notNull": true
},
"userId": {
"type": "refUser",
"length": 255,
"notNull": true
},
"name": {
"type": "text",
"notNull": true
},
"description": {
"type": "text"
},
"enabled": {
"type": "boolean",
"notNull": true,
"defaultValue": true
},
"definition": {
"type": "text",
"notNull": true
},
"definitionVersion": {
"type": "integer",
"notNull": true,
"defaultValue": 1
},
"concurrencyPolicy": {
"type": "text",
"notNull": true,
"defaultValue": "skip"
},
"maxRunSeconds": {
"type": "integer",
"notNull": true,
"defaultValue": 300
},
"dryRun": {
"type": "boolean",
"notNull": true,
"defaultValue": false
},
"lastRunAt": {
"type": "text"
},
"lastRunStatus": {
"type": "text"
},
"createdAt": {
"type": "timestamp",
"notNull": true,
"defaultNow": true
},
"updatedAt": {
"type": "timestamp",
"notNull": true,
"defaultNow": true
}
},
"indexes": [
{
"name": "idx_automations_user",
"columns": ["userId", "enabled"]
}
],
"adopts": "automations"
},
"trigger_state": {
"columns": {
"id": {
"type": "id",
"primaryKey": true,
"notNull": true
},
"automationId": {
"type": "integer",
"notNull": true
},
"stateKey": {
"type": "varchar",
"length": 255,
"notNull": true
},
"breachStartedAt": {
"type": "text"
},
"lastFiredAt": {
"type": "text"
},
"lastValue": {
"type": "real"
},
"lastObservedState": {
"type": "text"
},
"updatedAt": {
"type": "timestamp",
"notNull": true,
"defaultNow": true
}
},
"indexes": [
{
"name": "idx_automation_trigger_state_key",
"columns": ["automationId", "stateKey"],
"unique": true
}
],
"adopts": "automation_trigger_state"
},
"schedules": {
"columns": {
"id": {
"type": "id",
"primaryKey": true,
"notNull": true
},
"automationId": {
"type": "integer",
"notNull": true
},
"cron": {
"type": "text"
},
"intervalSeconds": {
"type": "integer"
},
"timezone": {
"type": "text"
},
"nextDueAt": {
"type": "timestamp"
},
"lastTickAt": {
"type": "text"
}
},
"indexes": [
{
"name": "idx_automation_schedules_automation",
"columns": ["automationId"],
"unique": true
},
{
"name": "idx_automation_schedules_due",
"columns": ["nextDueAt"]
}
],
"adopts": "automation_schedules"
},
"runs": {
"columns": {
"id": {
"type": "id",
"primaryKey": true,
"notNull": true
},
"automationId": {
"type": "integer",
"notNull": true
},
"userId": {
"type": "refUser",
"length": 255,
"notNull": true
},
"triggerType": {
"type": "text",
"notNull": true
},
"triggerContext": {
"type": "text"
},
"status": {
"type": "text",
"notNull": true
},
"startedAt": {
"type": "timestamp",
"notNull": true,
"defaultNow": true
},
"finishedAt": {
"type": "text"
},
"durationMs": {
"type": "integer"
},
"error": {
"type": "text"
},
"dryRun": {
"type": "boolean",
"notNull": true,
"defaultValue": false
},
"parentRunId": {
"type": "integer"
}
},
"indexes": [
{
"name": "idx_automation_runs_automation",
"columns": ["automationId", "startedAt"]
},
{
"name": "idx_automation_runs_user",
"columns": ["userId", "startedAt"]
}
],
"adopts": "automation_runs"
},
"run_steps": {
"columns": {
"id": {
"type": "id",
"primaryKey": true,
"notNull": true
},
"runId": {
"type": "integer",
"notNull": true
},
"stepIndex": {
"type": "integer",
"notNull": true
},
"stepId": {
"type": "text",
"notNull": true
},
"stepType": {
"type": "text",
"notNull": true
},
"status": {
"type": "text",
"notNull": true
},
"startedAt": {
"type": "timestamp",
"notNull": true,
"defaultNow": true
},
"finishedAt": {
"type": "text"
},
"output": {
"type": "text"
},
"error": {
"type": "text"
},
"truncated": {
"type": "boolean",
"notNull": true,
"defaultValue": false
}
},
"indexes": [
{
"name": "idx_automation_run_steps_run",
"columns": ["runId", "stepIndex"]
}
],
"adopts": "automation_run_steps"
},
"channels": {
"columns": {
"id": {
"type": "id",
"primaryKey": true,
"notNull": true
},
"automationId": {
"type": "integer",
"notNull": true
},
"channelId": {
"type": "integer",
"notNull": true
}
},
"indexes": [
{
"name": "idx_automation_channels_pair",
"columns": ["automationId", "channelId"],
"unique": true
}
],
"adopts": "automation_channels"
}
}
}
@@ -0,0 +1,94 @@
-- automations 0001: adopt_automations
-- Generated by termix-plugin migrations. Review before committing.
-- Hand-edited: keeps the legacy foreign keys the SDK cannot declare
-- (links between these tables, and a channel link to notification_channels).
CREATE TABLE IF NOT EXISTS "automations" (
"id" integer PRIMARY KEY AUTOINCREMENT,
"user_id" text NOT NULL,
"name" text NOT NULL,
"description" text,
"enabled" integer NOT NULL DEFAULT 1,
"definition" text NOT NULL,
"definition_version" integer NOT NULL DEFAULT 1,
"concurrency_policy" text NOT NULL DEFAULT 'skip',
"max_run_seconds" integer NOT NULL DEFAULT 300,
"dry_run" integer NOT NULL DEFAULT 0,
"last_run_at" text,
"last_run_status" text,
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS "idx_automations_user" ON "automations" ("user_id", "enabled");
ALTER TABLE "automations" RENAME TO "p_automations_automations";
CREATE TABLE IF NOT EXISTS "automation_trigger_state" (
"id" integer PRIMARY KEY AUTOINCREMENT,
"automation_id" integer NOT NULL,
"state_key" text NOT NULL,
"breach_started_at" text,
"last_fired_at" text,
"last_value" real,
"last_observed_state" text,
"updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY ("automation_id") REFERENCES "p_automations_automations" ("id") ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS "idx_automation_trigger_state_key" ON "automation_trigger_state" ("automation_id", "state_key");
ALTER TABLE "automation_trigger_state" RENAME TO "p_automations_trigger_state";
CREATE TABLE IF NOT EXISTS "automation_schedules" (
"id" integer PRIMARY KEY AUTOINCREMENT,
"automation_id" integer NOT NULL,
"cron" text,
"interval_seconds" integer,
"timezone" text,
"next_due_at" text,
"last_tick_at" text,
FOREIGN KEY ("automation_id") REFERENCES "p_automations_automations" ("id") ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS "idx_automation_schedules_automation" ON "automation_schedules" ("automation_id");
CREATE INDEX IF NOT EXISTS "idx_automation_schedules_due" ON "automation_schedules" ("next_due_at");
ALTER TABLE "automation_schedules" RENAME TO "p_automations_schedules";
CREATE TABLE IF NOT EXISTS "automation_runs" (
"id" integer PRIMARY KEY AUTOINCREMENT,
"automation_id" integer NOT NULL,
"user_id" text NOT NULL,
"trigger_type" text NOT NULL,
"trigger_context" text,
"status" text NOT NULL,
"started_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
"finished_at" text,
"duration_ms" integer,
"error" text,
"dry_run" integer NOT NULL DEFAULT 0,
"parent_run_id" integer,
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE,
FOREIGN KEY ("automation_id") REFERENCES "p_automations_automations" ("id") ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS "idx_automation_runs_automation" ON "automation_runs" ("automation_id", "started_at");
CREATE INDEX IF NOT EXISTS "idx_automation_runs_user" ON "automation_runs" ("user_id", "started_at");
ALTER TABLE "automation_runs" RENAME TO "p_automations_runs";
CREATE TABLE IF NOT EXISTS "automation_run_steps" (
"id" integer PRIMARY KEY AUTOINCREMENT,
"run_id" integer NOT NULL,
"step_index" integer NOT NULL,
"step_id" text NOT NULL,
"step_type" text NOT NULL,
"status" text NOT NULL,
"started_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
"finished_at" text,
"output" text,
"error" text,
"truncated" integer NOT NULL DEFAULT 0,
FOREIGN KEY ("run_id") REFERENCES "p_automations_runs" ("id") ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS "idx_automation_run_steps_run" ON "automation_run_steps" ("run_id", "step_index");
ALTER TABLE "automation_run_steps" RENAME TO "p_automations_run_steps";
CREATE TABLE IF NOT EXISTS "automation_channels" (
"id" integer PRIMARY KEY AUTOINCREMENT,
"automation_id" integer NOT NULL,
"channel_id" integer NOT NULL,
FOREIGN KEY ("automation_id") REFERENCES "p_automations_automations" ("id") ON DELETE CASCADE,
FOREIGN KEY ("channel_id") REFERENCES "notification_channels" ("id") ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS "idx_automation_channels_pair" ON "automation_channels" ("automation_id", "channel_id");
ALTER TABLE "automation_channels" RENAME TO "p_automations_channels";
@@ -0,0 +1,15 @@
-- automations 0002: unlink_channels
-- Hand-written: channels moved from core's notification_channels to the
-- alerts plugin, so the link table drops its foreign key to the old table.
-- SQLite cannot drop a constraint, so the table is rebuilt.
CREATE TABLE IF NOT EXISTS "p_automations_channels_v2" (
"id" integer PRIMARY KEY AUTOINCREMENT,
"automation_id" integer NOT NULL,
"channel_id" integer NOT NULL,
FOREIGN KEY ("automation_id") REFERENCES "p_automations_automations" ("id") ON DELETE CASCADE
);
INSERT INTO "p_automations_channels_v2" ("id", "automation_id", "channel_id") SELECT "id", "automation_id", "channel_id" FROM "p_automations_channels";
DROP TABLE "p_automations_channels";
ALTER TABLE "p_automations_channels_v2" RENAME TO "p_automations_channels";
CREATE UNIQUE INDEX IF NOT EXISTS "idx_automation_channels_pair" ON "p_automations_channels" ("automation_id", "channel_id");
@@ -0,0 +1,12 @@
-- automations 0003: host_maintenance
-- Generated by termix-plugin migrations. Review before committing.
CREATE TABLE IF NOT EXISTS "p_automations_host_maintenance" (
"id" integer PRIMARY KEY AUTOINCREMENT,
"user_id" text NOT NULL,
"host_id" integer NOT NULL,
"state" text NOT NULL,
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE,
FOREIGN KEY ("host_id") REFERENCES "ssh_data" ("id") ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS "maintenance_host_owner" ON "p_automations_host_maintenance" ("user_id", "host_id");
@@ -0,0 +1,5 @@
-- automations 0004: rename_maintenance_index
-- Renames the maintenance index to the idx_ pattern every other index uses.
CREATE UNIQUE INDEX IF NOT EXISTS "idx_automation_maintenance_host_owner" ON "p_automations_host_maintenance" ("user_id", "host_id");
DROP INDEX IF EXISTS "maintenance_host_owner";
+34
View File
@@ -0,0 +1,34 @@
{
"name": "@termix-plugin/automations",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -p .",
"validate": "termix-plugin validate"
},
"dependencies": {
"lucide-react": "^1.33.0"
},
"devDependencies": {
"@termix/plugin-sdk": "^1.0.0",
"@testing-library/dom": "^10.4.2",
"@testing-library/react": "^16.3.3",
"@types/node": "^26.2.0",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"better-sqlite3": "^13.0.3",
"jsdom": "^30.0.1",
"vitest": "^4.1.11"
},
"peerDependencies": {
"@termix/plugin-sdk": "^1.0.0",
"drizzle-orm": "^0.45.2",
"express": "^5.2.1",
"react": "^19.2.8",
"sonner": "^2.0.8",
"ssh2": "^1.17.0"
}
}
@@ -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);
}
@@ -0,0 +1,82 @@
import express from "express";
import { afterEach, describe, expect, it } from "vitest";
import { PluginCapabilityError } from "@termix/plugin-sdk/backend";
import {
createMockCtx,
createTestDb,
type TestDb,
} from "@termix/plugin-sdk/testing";
import { activate } from "../../src/backend/index.js";
import {
NOTIFICATION_CHANNELS_DDL,
definition,
manifest,
pluginDir,
startServer,
type TestServer,
} from "./helpers";
let db: TestDb | null = null;
let server: TestServer | null = null;
afterEach(async () => {
db?.close();
db = null;
await server?.close();
server = null;
});
describe("automations activate", () => {
it("provides automations.access and mounts the webhook as public", async () => {
server = await startServer();
expect(server.mock.services.get("automations.access")).toBeDefined();
expect(server.mock.httpRouters).toEqual([{ public: ["/webhook/:token"] }]);
});
it.each(["db:own", "network:serve"])(
"fails closed without %s",
async (capability) => {
db = await createTestDb(pluginDir, {
before: (sqlite) => sqlite.exec(NOTIFICATION_CHANNELS_DDL),
});
const mock = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: manifest.capabilities.filter((c) => c !== capability),
db: db.database,
router: () => express.Router(),
});
await expect(activate(mock.ctx)).rejects.toBeInstanceOf(
PluginCapabilityError,
);
for (const dispose of [...mock.disposals].reverse()) await dispose();
},
);
it("cannot notify without notify:send", async () => {
server = await startServer();
const withoutNotify = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: manifest.capabilities.filter((c) => c !== "notify:send"),
});
await expect(
withoutNotify.ctx.notify.send({ title: "t", body: "b" } as never),
).rejects.toBeInstanceOf(PluginCapabilityError);
});
it("stops every timer on deactivate", async () => {
server = await startServer();
await server.request("POST", "/", {
body: {
name: "Tick",
definition: definition({ kind: "schedule", intervalSeconds: 300 }),
},
});
for (const dispose of [...server.mock.disposals].reverse()) {
await Promise.resolve(dispose()).catch(() => {});
}
server.mock.disposals.length = 0;
expect(server.mock.scheduled.every((job) => job.stopped)).toBe(true);
});
});
@@ -0,0 +1,192 @@
import { describe, expect, it } from "vitest";
import {
compare,
extractMetricValue,
hasDwelled,
isCoolingDown,
metricStateKey,
severityForValue,
type MetricsSnapshot,
} from "../../src/backend/conditions.js";
const metrics: MetricsSnapshot = {
cpu: { percent: 42.5, load: [1.5, 1.2, 0.9] },
memory: { percent: 61, usedGiB: 7.5 },
disk: {
percent: 30,
filesystems: [
{ mount: "/", percent: 30, availableBytes: 100 },
{ mount: "/data", percent: 93.4, availableBytes: 25 },
],
},
network: {
interfaces: [
{ name: "eth0", rxBytes: "1000", txBytes: "2000" },
{ name: "eth1", rxBytes: "50", txBytes: "60" },
],
},
temperature: { highestCelsius: 71 },
uptime: { seconds: 86400 },
processes: { total: 210 },
};
describe("extractMetricValue", () => {
it("reads simple scalar paths", () => {
expect(extractMetricValue(metrics, { path: "cpu.percent" })).toBe(42.5);
expect(extractMetricValue(metrics, { path: "memory.percent" })).toBe(61);
expect(
extractMetricValue(metrics, { path: "temperature.highestCelsius" }),
).toBe(71);
expect(extractMetricValue(metrics, { path: "uptime.seconds" })).toBe(86400);
expect(extractMetricValue(metrics, { path: "processes.total" })).toBe(210);
});
it("reads load averages positionally", () => {
expect(extractMetricValue(metrics, { path: "cpu.load1" })).toBe(1.5);
expect(extractMetricValue(metrics, { path: "cpu.load15" })).toBe(0.9);
});
it("reads a specific mount rather than the aggregate", () => {
// The motivating case: /data is nearly full while / is fine.
expect(
extractMetricValue(metrics, { path: "disk.percent", mount: "/data" }),
).toBe(93.4);
expect(extractMetricValue(metrics, { path: "disk.percent" })).toBe(30);
});
it("returns null for a mount that is not present", () => {
expect(
extractMetricValue(metrics, { path: "disk.percent", mount: "/nope" }),
).toBeNull();
});
it("selects a named interface and coerces string counters", () => {
expect(
extractMetricValue(metrics, { path: "network.rxBytes", iface: "eth1" }),
).toBe(50);
expect(extractMetricValue(metrics, { path: "network.rxBytes" })).toBe(1000);
});
it("extracts per-interface network rates for bandwidth alerts", () => {
const rateMetrics = {
network: {
interfaces: [
{ name: "eth0", rxRateBps: 1024, txRateBps: 2048 },
{ name: "eth1", rxRateBps: 4096, txRateBps: 8192 },
],
},
};
expect(
extractMetricValue(rateMetrics, {
path: "network.rxRateBps",
iface: "eth1",
}),
).toBe(4096);
expect(
extractMetricValue(rateMetrics, {
path: "network.txRateBps",
iface: "eth0",
}),
).toBe(2048);
});
it("returns null for missing metrics rather than throwing", () => {
expect(extractMetricValue(null, { path: "cpu.percent" })).toBeNull();
expect(extractMetricValue({}, { path: "cpu.percent" })).toBeNull();
expect(
extractMetricValue({ cpu: { percent: null } }, { path: "cpu.percent" }),
).toBeNull();
});
});
describe("metricStateKey", () => {
it("scopes state per mount so dwell tracks one filesystem", () => {
expect(metricStateKey(7, { path: "disk.percent" })).toBe("7");
expect(metricStateKey(7, { path: "disk.percent", mount: "/data" })).toBe(
"7:/data",
);
expect(metricStateKey(7, { path: "network.rxBytes", iface: "eth1" })).toBe(
"7:eth1",
);
});
});
describe("compare", () => {
it("handles numeric operators", () => {
expect(compare(93, ">", 90)).toBe(true);
expect(compare(90, ">", 90)).toBe(false);
expect(compare(90, ">=", 90)).toBe(true);
expect(compare(10, "<", 90)).toBe(true);
expect(compare(90, "<=", 90)).toBe(true);
});
it("compares numeric strings numerically", () => {
expect(compare("93", ">", "90")).toBe(true);
// Lexically "9" > "10", so this would be wrong as a string compare.
expect(compare("9", "<", "10")).toBe(true);
});
it("falls back to string equality for non-numeric values", () => {
expect(compare("running", "==", "running")).toBe(true);
expect(compare("running", "!=", "exited")).toBe(true);
});
it("handles containment", () => {
expect(compare("disk full", "contains", "full")).toBe(true);
expect(compare("disk full", "not_contains", "full")).toBe(false);
expect(compare("all good", "not_contains", "error")).toBe(true);
});
it("treats changed as inequality of the rendered values", () => {
expect(compare("online", "changed", "offline")).toBe(true);
expect(compare("online", "changed", "online")).toBe(false);
});
it("is false when a numeric comparison has a non-numeric side", () => {
expect(compare("abc", ">", 5)).toBe(false);
});
});
describe("isCoolingDown", () => {
const now = Date.parse("2026-01-01T12:00:00.000Z");
it("is false when nothing has fired yet", () => {
expect(isCoolingDown(null, 15, now)).toBe(false);
});
it("is true inside the window and false outside it", () => {
expect(isCoolingDown("2026-01-01T11:50:00.000Z", 15, now)).toBe(true);
expect(isCoolingDown("2026-01-01T11:40:00.000Z", 15, now)).toBe(false);
});
it("treats a zero cooldown as always ready", () => {
expect(isCoolingDown("2026-01-01T11:59:59.000Z", 0, now)).toBe(false);
});
});
describe("hasDwelled", () => {
const now = Date.parse("2026-01-01T12:00:00.000Z");
it("fires immediately when no window is configured", () => {
expect(hasDwelled(null, undefined, now)).toBe(true);
expect(hasDwelled(null, 0, now)).toBe(true);
});
it("requires the window to have elapsed", () => {
expect(hasDwelled("2026-01-01T11:49:00.000Z", 600, now)).toBe(true);
expect(hasDwelled("2026-01-01T11:55:00.000Z", 600, now)).toBe(false);
});
it("is false when a window is set but no breach is open", () => {
expect(hasDwelled(null, 600, now)).toBe(false);
});
});
describe("severityForValue", () => {
it("escalates at 95 and honours an explicit override", () => {
expect(severityForValue(96)).toBe("critical");
expect(severityForValue(90)).toBe("warning");
expect(severityForValue(96, "info")).toBe("info");
});
});
@@ -0,0 +1,182 @@
import { describe, expect, it } from "vitest";
import {
computeNextDueAt,
isValidCron,
isValidTimezone,
nextCronRun,
parseCron,
} from "../../src/backend/cron.js";
describe("parseCron", () => {
it("rejects anything that is not five fields", () => {
expect(() => parseCron("* * * *")).toThrow(/five fields/);
expect(() => parseCron("* * * * * *")).toThrow(/five fields/);
});
it("expands wildcards, lists, ranges and steps", () => {
const fields = parseCron("0,30 9-17 * * 1-5");
expect([...fields.minutes]).toEqual([0, 30]);
expect([...fields.hours]).toEqual([9, 10, 11, 12, 13, 14, 15, 16, 17]);
expect([...fields.daysOfWeek]).toEqual([1, 2, 3, 4, 5]);
expect(fields.dowRestricted).toBe(true);
expect(fields.domRestricted).toBe(false);
});
it("supports step syntax", () => {
expect([...parseCron("*/15 * * * *").minutes]).toEqual([0, 15, 30, 45]);
});
it("accepts month and day names", () => {
expect([...parseCron("0 0 1 jan *").months]).toEqual([1]);
expect([...parseCron("0 0 * * sun").daysOfWeek]).toEqual([0]);
});
it("treats day 7 as Sunday", () => {
expect([...parseCron("0 0 * * 7").daysOfWeek]).toEqual([0]);
});
it("rejects out of range values", () => {
expect(() => parseCron("60 * * * *")).toThrow(/out of range/);
expect(() => parseCron("* 24 * * *")).toThrow(/out of range/);
expect(() => parseCron("* * 0 * *")).toThrow(/out of range/);
});
it("reports validity without throwing", () => {
expect(isValidCron("*/5 * * * *")).toBe(true);
expect(isValidCron("nonsense")).toBe(false);
});
});
describe("nextCronRun", () => {
it("finds the next matching minute", () => {
const from = new Date(2026, 0, 1, 10, 3, 30);
expect(nextCronRun("*/15 * * * *", from)).toEqual(
new Date(2026, 0, 1, 10, 15, 0, 0),
);
});
it("never returns the starting minute", () => {
const from = new Date(2026, 0, 1, 10, 0, 0);
expect(nextCronRun("0 * * * *", from)).toEqual(
new Date(2026, 0, 1, 11, 0, 0, 0),
);
});
it("rolls into the next day", () => {
const from = new Date(2026, 0, 1, 23, 45, 0);
expect(nextCronRun("0 2 * * *", from)).toEqual(
new Date(2026, 0, 2, 2, 0, 0, 0),
);
});
it("unions day-of-month and day-of-week when both are set", () => {
// The 15th, or any Monday.
const from = new Date(2026, 0, 1, 0, 0, 0);
const next = nextCronRun("0 0 15 * 1", from);
expect(next).not.toBeNull();
const isFifteenth = next!.getDate() === 15;
const isMonday = next!.getDay() === 1;
expect(isFifteenth || isMonday).toBe(true);
});
it("gives up on a date that can never match", () => {
expect(nextCronRun("0 0 30 2 *", new Date(2026, 0, 1))).toBeNull();
});
});
describe("computeNextDueAt", () => {
it("prefers an interval over a cron expression", () => {
const from = new Date("2026-01-01T00:00:00.000Z");
expect(
computeNextDueAt({ intervalSeconds: 300, cron: "0 0 * * *" }, from),
).toBe("2026-01-01T00:05:00.000Z");
});
it("falls back to cron", () => {
const from = new Date(2026, 0, 1, 10, 0, 0);
const due = computeNextDueAt({ cron: "30 10 * * *" }, from);
expect(due).toBe(new Date(2026, 0, 1, 10, 30, 0, 0).toISOString());
});
it("returns null when nothing is scheduled", () => {
expect(computeNextDueAt({})).toBeNull();
});
it("passes the zone through to the cron evaluation", () => {
// 02:00 in Tokyo on 2026-06-02 is 17:00 UTC on 2026-06-01.
const from = new Date("2026-06-01T00:00:00.000Z");
expect(
computeNextDueAt({ cron: "0 2 * * *", timezone: "Asia/Tokyo" }, from),
).toBe("2026-06-01T17:00:00.000Z");
});
it("ignores the zone for interval schedules", () => {
const from = new Date("2026-01-01T00:00:00.000Z");
expect(
computeNextDueAt({ intervalSeconds: 600, timezone: "Asia/Tokyo" }, from),
).toBe("2026-01-01T00:10:00.000Z");
});
});
describe("time zone handling", () => {
it("resolves a daily cron against the given zone", () => {
const from = new Date("2026-06-01T00:00:00.000Z");
// 09:30 New York in June (UTC-4) is 13:30 UTC.
const next = nextCronRun("30 9 * * *", from, "America/New_York");
expect(next?.toISOString()).toBe("2026-06-01T13:30:00.000Z");
});
it("tracks daylight saving, so the UTC instant shifts by an hour", () => {
const summer = nextCronRun(
"0 12 * * *",
new Date("2026-07-01T00:00:00.000Z"),
"America/New_York",
);
const winter = nextCronRun(
"0 12 * * *",
new Date("2026-01-01T00:00:00.000Z"),
"America/New_York",
);
// Noon local both times, but UTC-4 in July and UTC-5 in January.
expect(summer?.toISOString()).toBe("2026-07-01T16:00:00.000Z");
expect(winter?.toISOString()).toBe("2026-01-01T17:00:00.000Z");
});
it("matches the day of week in the target zone, not the server's", () => {
// 23:00 UTC Sunday is already Monday in Tokyo.
const next = nextCronRun(
"0 8 * * mon",
new Date("2026-06-07T22:00:00.000Z"),
"Asia/Tokyo",
);
expect(next?.toISOString()).toBe("2026-06-07T23:00:00.000Z");
});
it("falls back to server time for an unknown zone instead of throwing", () => {
const from = new Date(2026, 0, 1, 10, 0, 0);
const next = nextCronRun("30 10 * * *", from, "Not/AZone");
expect(next).toEqual(new Date(2026, 0, 1, 10, 30, 0, 0));
});
it("handles midnight, which some hour cycles format as 24", () => {
const next = nextCronRun(
"0 0 * * *",
new Date("2026-06-01T10:00:00.000Z"),
"UTC",
);
expect(next?.toISOString()).toBe("2026-06-02T00:00:00.000Z");
});
});
describe("isValidTimezone", () => {
it("accepts real zones", () => {
expect(isValidTimezone("UTC")).toBe(true);
expect(isValidTimezone("Europe/London")).toBe(true);
});
it("rejects made-up ones", () => {
expect(isValidTimezone("Middle/Earth")).toBe(false);
expect(isValidTimezone("")).toBe(false);
});
});
@@ -0,0 +1,458 @@
import { afterEach, describe, expect, it } from "vitest";
import { definition, startServer, type TestServer } from "./helpers";
/**
* The engine as activate() wires it: the ctx.schedule tick, the services it
* reaches, and what happens when a plugin an automation needs is off.
*/
let server: TestServer | null = null;
afterEach(async () => {
await server?.close();
server = null;
});
async function create(
s: TestServer,
name: string,
body: ReturnType<typeof definition>,
): Promise<number> {
const created = await s.request("POST", "/", {
body: { name, definition: body },
});
expect(created.status).toBe(201);
return created.body.id;
}
function makeDue(s: TestServer, automationId: number) {
s.db.sqlite
.prepare(
"UPDATE p_automations_schedules SET next_due_at = ? WHERE automation_id = ?",
)
.run(new Date(Date.now() - 60_000).toISOString(), automationId);
}
describe("scheduled automations", () => {
it("fire from the ctx.schedule tick and notify as the owner", async () => {
server = await startServer();
const id = await create(
server,
"Every five minutes",
definition({ kind: "schedule", intervalSeconds: 300 }, [
{
id: "n",
type: "notify",
channelIds: [1],
title: "Ping {{trigger.type}}",
body: "hello",
},
]),
);
makeDue(server, id);
// The tick is registered through ctx.schedule, never a bare timer.
expect(server.mock.scheduled.filter((job) => !job.stopped).length).toBe(2);
await server.mock.runScheduled();
expect(await server.waitForRun(id)).toEqual({
status: "success",
error: null,
});
expect(server.mock.notifications).toEqual([
{
actor: "alice",
notification: expect.objectContaining({
title: "Ping schedule",
body: "hello",
category: "automations.notify",
audience: { userId: "alice" },
channelIds: [1],
}),
},
]);
// The next due time moved forward, so the next tick does not re-run it.
const due = server.db.sqlite
.prepare(
"SELECT next_due_at FROM p_automations_schedules WHERE automation_id = ?",
)
.get(id) as { next_due_at: string };
expect(new Date(due.next_due_at).getTime()).toBeGreaterThan(Date.now());
});
it("are skipped with a reason while the docker plugin is off, and the engine keeps going", async () => {
server = await startServer();
const needsDocker = await create(
server,
"Restart app",
definition({ kind: "schedule", intervalSeconds: 300 }, [
{
id: "d",
type: "docker",
action: "restart",
container: "app",
hostSelector: { kind: "host", hostId: 1 },
},
]),
);
const plain = await create(
server,
"Plain",
definition({ kind: "schedule", intervalSeconds: 300 }),
);
makeDue(server, needsDocker);
makeDue(server, plain);
await server.mock.runScheduled();
expect(await server.waitForRun(needsDocker)).toEqual({
status: "skipped",
error: "Needs the docker plugin",
});
expect(await server.waitForRun(plain)).toEqual({
status: "success",
error: null,
});
// A manual run says the same thing instead of throwing.
const manual = await server.request("POST", `/${needsDocker}/run`, {
body: {},
});
expect(manual.status).toBe(200);
expect(manual.body).toMatchObject({
status: "skipped",
error: "Needs the docker plugin",
});
});
it("run once docker is back", async () => {
const actions: unknown[] = [];
server = await startServer({
services: {
"docker.containers": {
action: async (...args: unknown[]) => {
actions.push(args);
},
},
},
});
const id = await create(
server,
"Restart app",
definition({ kind: "schedule", intervalSeconds: 300 }, [
{
id: "d",
type: "docker",
action: "restart",
container: "app",
hostSelector: { kind: "host", hostId: 1 },
},
]),
);
const outcome = await server.request("POST", `/${id}/run`, { body: {} });
// Host 1 does not resolve here, so the step has no target, but the
// automation is no longer skipped for a missing plugin.
expect(outcome.body.status).not.toBe("skipped");
});
});
describe("automations.access", () => {
it("lists, reads, creates disabled and runs as the caller", async () => {
server = await startServer();
const service = server.service;
const created = await server.mock.ctx.asUser("alice", () =>
service.create({
name: "From the assistant",
definition: definition({ kind: "schedule", intervalSeconds: 600 }),
}),
);
const listed = await server.mock.ctx.asUser("alice", () => service.list());
expect(listed).toEqual([
expect.objectContaining({
id: created.id,
enabled: false,
triggerKind: "schedule",
missingPlugins: [],
}),
]);
const one = await server.mock.ctx.asUser("alice", () =>
service.get(created.id),
);
expect(one?.definition?.trigger.kind).toBe("schedule");
expect(
await server.mock.ctx.asUser("bob", () => service.get(created.id)),
).toBeNull();
const outcome = await server.mock.ctx.asUser("alice", () =>
service.run(created.id, { dryRun: true }),
);
expect(outcome.status).toBe("success");
});
it("refuses an invalid definition", async () => {
server = await startServer();
await expect(
server.mock.ctx.asUser("alice", () =>
server!.service.create({ name: "x", definition: { steps: [] } }),
),
).rejects.toThrow(/trigger/);
});
});
describe("optional steps", () => {
it("fail the step cleanly while the tunnels plugin is off", async () => {
server = await startServer();
const id = await create(
server,
"Tunnel",
definition({ kind: "webhook" }, [
{ id: "t", type: "tunnel", action: "connect", tunnelName: "db" },
]),
);
const outcome = await server.request("POST", `/${id}/run`, { body: {} });
expect(outcome.body).toMatchObject({
status: "skipped",
error: "Needs the tunnels plugin",
});
});
it("start a tunnel through tunnels.access", async () => {
const started: string[] = [];
server = await startServer({
services: {
"tunnels.access": {
start: async (name: string) => {
started.push(name);
},
stop: async () => {},
},
},
});
const id = await create(
server,
"Tunnel",
definition({ kind: "webhook" }, [
{ id: "t", type: "tunnel", action: "connect", tunnelName: "db" },
]),
);
const outcome = await server.request("POST", `/${id}/run`, { body: {} });
expect(outcome.body.status).toBe("success");
expect(started).toEqual(["db"]);
});
it("wake a host through the wake-on-lan service", async () => {
const woken: number[] = [];
server = await startServer({
services: {
"wake-on-lan.send": {
wake: async (hostId: number) => {
woken.push(hostId);
},
},
},
});
const id = await create(
server,
"Wake",
definition({ kind: "webhook" }, [{ id: "w", type: "wol", hostId: 2 }]),
);
const outcome = await server.request("POST", `/${id}/run`, { body: {} });
expect(outcome.body.status).toBe("success");
expect(woken).toEqual([2]);
});
});
describe("watchers", () => {
it("subscribe to docker.events as the owner and fire docker_event triggers", async () => {
let listener: ((event: unknown) => void) | null = null;
const subscribedAs: Array<string | undefined> = [];
server = await startServer({
services: {
"docker.events": {
subscribe: async (_hostId: number, fn: (event: unknown) => void) => {
listener = fn;
subscribedAs.push(server?.mock.ctx.currentActor());
return () => {
listener = null;
};
},
},
},
});
const id = await create(
server,
"Container died",
definition({
kind: "docker_event",
hostSelector: { kind: "host", hostId: 1 },
container: "app",
event: "exited",
cooldownMinutes: 0,
}),
);
await server.mock.runScheduled();
expect(subscribedAs).toEqual(["alice"]);
expect(listener).not.toBeNull();
listener!({ hostId: 1, container: "app", event: "exited" });
expect(await server.waitForRun(id)).toEqual({
status: "success",
error: null,
});
});
it("keep headless metrics viewers registered as the owner", async () => {
const registered: Array<[number, string | undefined]> = [];
let beats = 0;
server = await startServer({
services: {
"host-metrics.viewers": {
register: async (hostId: number) => {
registered.push([hostId, server?.mock.ctx.currentActor()]);
return { viewerSessionId: `v${hostId}` };
},
heartbeat: () => {
beats++;
return true;
},
unregister: () => {},
},
},
});
await create(
server,
"CPU",
definition({
kind: "metric_threshold",
hostSelector: { kind: "hosts", hostIds: [1, 2] },
metric: { path: "cpu.percent" },
operator: ">",
value: 90,
cooldownMinutes: 15,
}),
);
await server.mock.runScheduled();
expect(registered).toEqual([
[1, "alice"],
[2, "alice"],
]);
const before = beats;
await server.mock.runScheduled();
// Each tick heartbeats both viewers and registers nothing new.
expect(beats).toBeGreaterThan(before);
expect((beats - before) % 2).toBe(0);
expect(registered).toHaveLength(2);
});
it("fire a metric trigger from a host-metrics snapshot event", async () => {
server = await startServer({
services: {
"host-metrics.viewers": {
register: async () => ({ viewerSessionId: "v" }),
heartbeat: () => true,
unregister: () => {},
},
},
});
const id = await create(
server,
"CPU",
definition({
kind: "metric_threshold",
hostSelector: { kind: "host", hostId: 1 },
metric: { path: "cpu.percent" },
operator: ">",
value: 90,
cooldownMinutes: 15,
}),
);
server.mock.ctx.events.emit("plugin.host-metrics.snapshot", {
hostId: 1,
ownerUserId: "alice",
metrics: { cpu: { percent: 97 } },
});
expect(await server.waitForRun(id)).toEqual({
status: "success",
error: null,
});
});
});
describe("alerts", () => {
const schedule = { kind: "schedule", intervalSeconds: 300 };
it("sends a notify step to the owner's inbox when no channel is picked", async () => {
server = await startServer();
const id = await create(
server,
"Inbox only",
definition(schedule, [
{ id: "n", type: "notify", channelIds: [], title: "Heads up" },
]),
);
const outcome = await server.request("POST", `/${id}/run`, { body: {} });
expect(outcome.body.status).toBe("success");
expect(server.mock.notifications).toEqual([
{
actor: "alice",
notification: expect.objectContaining({
title: "Heads up",
audience: { userId: "alice" },
channelIds: [],
link: { tab: "automations" },
}),
},
]);
});
it("only describes the alert on a dry run", async () => {
server = await startServer();
const id = await create(
server,
"Dry",
definition(schedule, [
{ id: "n", type: "notify", channelIds: [1], title: "Heads up" },
]),
);
const outcome = await server.request("POST", `/${id}/run`, {
body: { dryRun: true },
});
expect(outcome.body.status).toBe("success");
expect(server.mock.notifications).toEqual([]);
});
it("alerts the owner when a run fails", async () => {
server = await startServer();
const id = await create(
server,
"Breaks",
definition(schedule, [{ id: "s", type: "stop", status: "failed" }]),
);
const outcome = await server.request("POST", `/${id}/run`, { body: {} });
expect(outcome.body.status).toBe("failed");
expect(server.mock.notifications).toEqual([
{
actor: "alice",
notification: expect.objectContaining({
title: 'Automation "Breaks" failed',
severity: "warning",
category: "automations.run_failed",
audience: { userId: "alice" },
dedupeKey: `automations.run_failed:${id}`,
}),
},
]);
});
});
@@ -0,0 +1,615 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createFakeContext } from "@termix/plugin-sdk/testing";
import type { PluginHostSummary } from "@termix/plugin-sdk/backend";
import type { AutomationDefinition, Step } from "../../src/types.js";
/**
* The engine reaches the database through its repository and the outside
* world through the step executors, so both are faked here. What is under
* test is the run loop itself: ordering, branching, error policy,
* concurrency, recursion and dry-run.
*/
interface FakeAutomation {
id: number;
userId: string;
name: string;
enabled: boolean;
definition: string;
concurrencyPolicy: string;
maxRunSeconds: number;
dryRun: boolean;
}
const automations = new Map<number, FakeAutomation>();
const runs: Array<Record<string, unknown>> = [];
const runSteps: Array<Record<string, unknown>> = [];
let nextRunId = 1;
let nextStepRowId = 1;
const repository = {
findById: vi.fn(async (id: number) => automations.get(id) ?? null),
createRun: vi.fn(async (input: Record<string, unknown>) => {
const run = { id: nextRunId++, ...input };
runs.push(run);
return run;
}),
finishRun: vi.fn(async (runId: number, input: Record<string, unknown>) => {
const run = runs.find((entry) => entry.id === runId);
if (run) Object.assign(run, input);
}),
createRunStep: vi.fn(async (input: Record<string, unknown>) => {
const id = nextStepRowId++;
runSteps.push({ id, ...input });
return id;
}),
finishRunStep: vi.fn(async (id: number, input: Record<string, unknown>) => {
const step = runSteps.find((entry) => entry.id === id);
if (step) Object.assign(step, input);
}),
};
let hosts: PluginHostSummary[] = [];
const executeStep = vi.fn();
vi.mock("../../src/backend/actions/index.js", () => ({
executeStep: (...args: unknown[]) => executeStep(...args),
}));
const { AutomationEngine } = await import("../../src/backend/engine.js");
// Every provider is running unless a test says otherwise.
const deps = { available: () => true };
let current: InstanceType<typeof AutomationEngine>;
function getEngine() {
return current;
}
function freshEngine() {
const { ctx } = createFakeContext({ pluginId: "automations", hosts });
current = new AutomationEngine(ctx, repository as never, deps as never);
}
function defineAutomation(
steps: Step[],
overrides: Partial<FakeAutomation> = {},
): FakeAutomation {
const definition: AutomationDefinition = {
version: 1,
trigger: { kind: "webhook", tokenHash: "x" },
steps,
};
const automation: FakeAutomation = {
id: overrides.id ?? 1,
userId: "user-1",
name: "Test",
enabled: true,
definition: JSON.stringify(definition),
concurrencyPolicy: "skip",
maxRunSeconds: 300,
dryRun: false,
...overrides,
};
automations.set(automation.id, automation);
return automation;
}
function step(partial: Partial<Step> & { id: string; type: string }): Step {
return partial as Step;
}
beforeEach(() => {
automations.clear();
runs.length = 0;
runSteps.length = 0;
nextRunId = 1;
nextStepRowId = 1;
vi.clearAllMocks();
hosts = [];
executeStep.mockResolvedValue({ success: true, output: "ok" });
freshEngine();
});
describe("AutomationEngine.run", () => {
it("adds the trigger host name to the template context", async () => {
defineAutomation([step({ id: "notify", type: "notify" })]);
hosts = [
{
id: 11,
userId: "user-1",
name: "Proxmox Node",
ip: "10.0.0.11",
port: 22,
username: "root",
tags: null,
folder: null,
authType: "password",
},
];
freshEngine();
await getEngine().run({
automationId: 1,
triggerType: "metric_threshold",
triggerHostId: 11,
triggerContext: { hostId: 11, value: 97 },
});
const context = executeStep.mock.calls[0][1] as {
template: {
host: { id: number; name: string };
trigger: { hostName: string };
};
};
expect(context.template.host).toMatchObject({
id: 11,
name: "Proxmox Node",
});
expect(context.template.trigger.hostName).toBe("Proxmox Node");
});
it("runs steps in order and records each one", async () => {
defineAutomation([
step({ id: "a", type: "run_command" }),
step({ id: "b", type: "http" }),
]);
const outcome = await getEngine().run({
automationId: 1,
triggerType: "manual",
});
expect(outcome.status).toBe("success");
expect(executeStep).toHaveBeenCalledTimes(2);
expect(runSteps.map((s) => s.stepId)).toEqual(["a", "b"]);
expect(runSteps.map((s) => s.stepIndex)).toEqual([0, 1]);
});
it("fails the run and stops when a step fails under the default policy", async () => {
defineAutomation([
step({ id: "a", type: "run_command" }),
step({ id: "b", type: "http" }),
]);
executeStep.mockResolvedValueOnce({ success: false, error: "boom" });
const outcome = await getEngine().run({
automationId: 1,
triggerType: "manual",
});
expect(outcome.status).toBe("failed");
expect(outcome.error).toBe("boom");
expect(executeStep).toHaveBeenCalledTimes(1);
});
it("keeps going when a step is marked continue-on-error", async () => {
defineAutomation([
step({ id: "a", type: "run_command", onError: "continue" }),
step({ id: "b", type: "http" }),
]);
executeStep.mockResolvedValueOnce({ success: false, error: "boom" });
const outcome = await getEngine().run({
automationId: 1,
triggerType: "manual",
});
expect(outcome.status).toBe("success");
expect(executeStep).toHaveBeenCalledTimes(2);
});
it("skips disabled steps", async () => {
defineAutomation([
step({ id: "a", type: "run_command", enabled: false }),
step({ id: "b", type: "http" }),
]);
await getEngine().run({
automationId: 1,
triggerType: "manual",
});
expect(executeStep).toHaveBeenCalledTimes(1);
expect(runSteps.map((s) => s.stepId)).toEqual(["b"]);
});
it("passes earlier step output to later steps", async () => {
defineAutomation([
step({ id: "first", type: "run_command" }),
step({ id: "second", type: "http" }),
]);
executeStep.mockResolvedValueOnce({ success: true, output: "hello" });
await getEngine().run({
automationId: 1,
triggerType: "manual",
});
const secondCallContext = executeStep.mock.calls[1][1] as {
template: { steps: Record<string, { stdout: string }> };
};
expect(secondCallContext.template.steps.first.stdout).toBe("hello");
});
it("merges variables set by a step into the template context", async () => {
defineAutomation([
step({ id: "setter", type: "set_var" }),
step({ id: "next", type: "http" }),
]);
executeStep.mockResolvedValueOnce({
success: true,
output: "x = 1",
vars: { x: "1" },
});
await getEngine().run({
automationId: 1,
triggerType: "manual",
});
const context = executeStep.mock.calls[1][1] as {
template: { vars: Record<string, string> };
};
expect(context.template.vars.x).toBe("1");
});
describe("if branching", () => {
it("runs the then branch when the condition matches", async () => {
defineAutomation([
step({
id: "cond",
type: "if",
condition: { left: "93", operator: ">", right: "90" },
then: [step({ id: "yes", type: "http" })],
else: [step({ id: "no", type: "http" })],
}),
]);
await getEngine().run({
automationId: 1,
triggerType: "manual",
});
expect(executeStep).toHaveBeenCalledTimes(1);
expect(runSteps.map((s) => s.stepId)).toEqual(["cond", "yes"]);
});
it("runs the else branch when it does not", async () => {
defineAutomation([
step({
id: "cond",
type: "if",
condition: { left: "10", operator: ">", right: "90" },
then: [step({ id: "yes", type: "http" })],
else: [step({ id: "no", type: "http" })],
}),
]);
await getEngine().run({
automationId: 1,
triggerType: "manual",
});
expect(runSteps.map((s) => s.stepId)).toEqual(["cond", "no"]);
});
it("resolves templates on both sides of the condition", async () => {
defineAutomation([
step({
id: "cond",
type: "if",
condition: {
left: "{{trigger.value}}",
operator: ">=",
right: "90",
},
then: [step({ id: "yes", type: "http" })],
}),
]);
await getEngine().run({
automationId: 1,
triggerType: "metric_threshold",
triggerContext: { value: 95 },
});
expect(runSteps.map((s) => s.stepId)).toEqual(["cond", "yes"]);
});
it("treats an empty else as a no-op", async () => {
defineAutomation([
step({
id: "cond",
type: "if",
condition: { left: "1", operator: "==", right: "2" },
then: [step({ id: "yes", type: "http" })],
}),
step({ id: "after", type: "http" }),
]);
await getEngine().run({
automationId: 1,
triggerType: "manual",
});
expect(runSteps.map((s) => s.stepId)).toEqual(["cond", "after"]);
});
});
it("halts the run when a step returns a stop signal", async () => {
defineAutomation([
step({ id: "a", type: "run_command" }),
step({ id: "b", type: "http" }),
]);
executeStep.mockResolvedValueOnce({
success: true,
halt: { status: "success" },
});
const outcome = await getEngine().run({
automationId: 1,
triggerType: "manual",
});
expect(outcome.status).toBe("success");
expect(executeStep).toHaveBeenCalledTimes(1);
});
it("marks the run failed when a stop step asks for failure", async () => {
defineAutomation([step({ id: "a", type: "run_command" })]);
executeStep.mockResolvedValueOnce({
success: true,
halt: { status: "failed" },
});
const outcome = await getEngine().run({
automationId: 1,
triggerType: "manual",
});
expect(outcome.status).toBe("failed");
});
describe("recursion protection", () => {
it("refuses to re-enter an automation already in the chain", async () => {
defineAutomation([
step({ id: "nested", type: "run_automation", automationId: 1 }),
]);
const outcome = await getEngine().run({
automationId: 1,
triggerType: "manual",
});
// The nested call is refused, and the refusal is recorded on the step.
const nestedStep = runSteps.find((s) => s.stepId === "nested");
expect(nestedStep?.status).toBe("failed");
expect(String(nestedStep?.error)).toMatch(
/already running in this chain/,
);
expect(outcome.status).toBe("failed");
});
it("refuses a mutual cycle between two automations", async () => {
defineAutomation(
[step({ id: "toB", type: "run_automation", automationId: 2 })],
{ id: 1 },
);
defineAutomation(
[step({ id: "toA", type: "run_automation", automationId: 1 })],
{ id: 2 },
);
await getEngine().run({
automationId: 1,
triggerType: "manual",
});
const inner = runSteps.find((s) => s.stepId === "toA");
expect(inner?.status).toBe("failed");
expect(String(inner?.error)).toMatch(/already running in this chain/);
});
it("refuses to nest deeper than the maximum depth", async () => {
defineAutomation([step({ id: "a", type: "http" })]);
const outcome = await getEngine().run({
automationId: 1,
triggerType: "manual",
depth: 99,
});
expect(outcome.status).toBe("failed");
expect(outcome.error).toMatch(/depth/);
expect(runs).toHaveLength(0);
});
});
describe("concurrency", () => {
it("records a skipped run rather than dropping it silently", async () => {
defineAutomation([step({ id: "slow", type: "run_command" })], {
concurrencyPolicy: "skip",
});
let release: () => void = () => {};
executeStep.mockImplementationOnce(
() =>
new Promise((resolve) => {
release = () => resolve({ success: true, output: "done" });
}),
);
const engine = getEngine();
const first = engine.run({ automationId: 1, triggerType: "manual" });
// Let the first run register itself as in flight.
await new Promise((resolve) => setTimeout(resolve, 10));
const second = await engine.run({
automationId: 1,
triggerType: "manual",
});
expect(second.status).toBe("skipped");
release();
await first;
const skipped = runs.find((run) => run.status === "skipped");
expect(skipped).toBeDefined();
expect(String(skipped?.error)).toMatch(/still in progress/);
});
it("skips the second of two triggers that arrive in the same tick", async () => {
defineAutomation([step({ id: "slow", type: "run_command" })], {
concurrencyPolicy: "skip",
});
executeStep.mockImplementation(
() =>
new Promise((resolve) =>
setTimeout(() => resolve({ success: true, output: "done" }), 20),
),
);
// No gap between the two: the in-flight slot used to be claimed several
// awaits after it was checked, so both runs got through.
const engine = getEngine();
const [first, second] = await Promise.all([
engine.run({ automationId: 1, triggerType: "manual" }),
engine.run({ automationId: 1, triggerType: "manual" }),
]);
expect([first.status, second.status].sort()).toEqual([
"skipped",
"success",
]);
expect(executeStep).toHaveBeenCalledTimes(1);
});
it("releases the in-flight slot when the run row cannot be created", async () => {
defineAutomation([step({ id: "a", type: "run_command" })]);
repository.createRun.mockRejectedValueOnce(new Error("db down"));
const engine = getEngine();
const failed = await engine.run({
automationId: 1,
triggerType: "manual",
});
expect(failed.status).toBe("failed");
// A leaked slot would make every later run skip forever.
const after = await engine.run({
automationId: 1,
triggerType: "manual",
});
expect(after.status).toBe("success");
});
});
it("propagates the dry-run flag to executors", async () => {
defineAutomation([step({ id: "a", type: "http" })], { dryRun: true });
await getEngine().run({
automationId: 1,
triggerType: "manual",
});
const context = executeStep.mock.calls[0][1] as { dryRun: boolean };
expect(context.dryRun).toBe(true);
});
it("lets a caller force a dry run on a live automation", async () => {
defineAutomation([step({ id: "a", type: "http" })], { dryRun: false });
await getEngine().run({
automationId: 1,
triggerType: "manual",
dryRun: true,
});
const context = executeStep.mock.calls[0][1] as { dryRun: boolean };
expect(context.dryRun).toBe(true);
});
it("fails cleanly when the automation is missing", async () => {
const outcome = await getEngine().run({
automationId: 404,
triggerType: "manual",
});
expect(outcome).toMatchObject({ status: "failed", runId: null });
});
it("fails cleanly when the definition is not valid JSON", async () => {
automations.set(1, {
id: 1,
userId: "user-1",
name: "Broken",
enabled: true,
definition: "not json",
concurrencyPolicy: "skip",
maxRunSeconds: 300,
dryRun: false,
});
const outcome = await getEngine().run({
automationId: 1,
triggerType: "manual",
});
expect(outcome.status).toBe("failed");
expect(outcome.error).toMatch(/not valid JSON/);
});
it("turns a thrown executor error into a failed step", async () => {
defineAutomation([step({ id: "a", type: "run_command" })]);
executeStep.mockRejectedValueOnce(new Error("connection reset"));
const outcome = await getEngine().run({
automationId: 1,
triggerType: "manual",
});
expect(outcome.status).toBe("failed");
expect(runSteps[0].status).toBe("failed");
expect(String(runSteps[0].error)).toMatch(/connection reset/);
});
it("truncates very large step output", async () => {
defineAutomation([step({ id: "a", type: "run_command" })]);
executeStep.mockResolvedValueOnce({
success: true,
output: "x".repeat(40_000),
});
await getEngine().run({
automationId: 1,
triggerType: "manual",
});
expect(runSteps[0].truncated).toBe(true);
expect(String(runSteps[0].output)).toMatch(/truncated/);
});
it("stops once the run deadline has passed", async () => {
defineAutomation(
[step({ id: "a", type: "run_command" }), step({ id: "b", type: "http" })],
{ maxRunSeconds: 1 },
);
// The first step consumes the whole budget, so the second must not start.
executeStep.mockImplementationOnce(async () => {
vi.setSystemTime(Date.now() + 5_000);
return { success: true, output: "slow" };
});
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
const outcome = await getEngine().run({
automationId: 1,
triggerType: "manual",
});
expect(outcome.status).toBe("timeout");
expect(executeStep).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
}
});
});
@@ -0,0 +1,205 @@
import http from "node:http";
import type { AddressInfo } from "node:net";
import { fileURLToPath } from "node:url";
import express, { type Router } from "express";
import {
createMockCtx,
createTestDb,
type MockPluginContext,
type TestDb,
} from "@termix/plugin-sdk/testing";
import type {
PluginHostSummary,
PluginNotificationChannel,
} from "@termix/plugin-sdk/backend";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import manifestJson from "../../manifest.json";
import type { AutomationsAccessV1 } from "../../src/backend/service.js";
export const pluginDir = fileURLToPath(new URL("../..", import.meta.url));
export const manifest = manifestJson as unknown as PluginManifest;
export const ALL_PERMISSIONS = [
"automations.view",
"automations.create",
"automations.edit",
"automations.delete",
"automations.run",
];
/** Core's notification_channels, which automation_channels points at. */
export const NOTIFICATION_CHANNELS_DDL = `
CREATE TABLE notification_channels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
type TEXT NOT NULL,
config TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`;
export function host(id: number, userId = "alice"): PluginHostSummary {
return {
id,
userId,
name: `host-${id}`,
ip: `10.0.0.${id}`,
port: 22,
username: "root",
tags: null,
folder: null,
authType: "password",
};
}
/** A snippets.access provider, always there since snippets is a hard dependency. */
export function fakeSnippets() {
return {
list: async () => [{ id: 5, name: "Restart nginx", isNote: false }],
get: async (id: number) =>
id === 5
? {
id: 5,
name: "Restart nginx",
content: "systemctl restart nginx",
isNote: false,
}
: null,
resolveCommand: async (id: number) =>
id === 5 ? "systemctl restart nginx" : null,
};
}
export interface TestServer {
db: TestDb;
mock: MockPluginContext;
service: AutomationsAccessV1;
request: (
method: string,
path: string,
options?: { user?: string | null; body?: unknown },
// Route bodies vary per test; asserting on them is the point.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) => Promise<{ status: number; body: any }>;
/** Waits until a run for the automation reaches a final status. */
waitForRun: (
automationId: number,
) => Promise<{ status: string; error: string | null }>;
close: () => Promise<void>;
}
/**
* The plugin activated against a real in-memory database, its router mounted
* the way core mounts it: JSON parsed and the acting user set from the
* request, everything else left to the plugin.
*/
export async function startServer(
options: {
permissions?: string[];
services?: Record<string, object>;
hosts?: PluginHostSummary[];
channels?: PluginNotificationChannel[];
before?: (db: TestDb["sqlite"]) => void;
} = {},
): Promise<TestServer> {
const db = await createTestDb(pluginDir, {
before: (sqlite) => {
sqlite.exec(NOTIFICATION_CHANNELS_DDL);
options.before?.(sqlite);
},
});
for (const user of ["alice", "bob"]) {
db.sqlite
.prepare("INSERT INTO users (id, username) VALUES (?, ?)")
.run(user, user);
}
const channels = options.channels ?? [
{ id: 1, name: "ops", type: "webhook", enabled: true },
];
for (const channel of channels) {
db.sqlite
.prepare(
"INSERT INTO notification_channels (id, user_id, name, type, config, enabled) VALUES (?, 'alice', ?, ?, '{}', ?)",
)
.run(channel.id, channel.name, channel.type, channel.enabled ? 1 : 0);
}
let router: Router | null = null;
const mock = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: manifest.capabilities,
db: db.database,
router: () => (router = express.Router()),
permissions: options.permissions ?? ALL_PERMISSIONS,
hosts: options.hosts ?? [host(1), host(2)],
notificationChannels: channels,
services: { "snippets.access": fakeSnippets(), ...options.services },
});
const { activate } = await import("../../src/backend/index.js");
await activate(mock.ctx);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
mock.setActor(req.header("x-test-user") || undefined);
next();
});
app.use((req, res, next) => router!(req, res, next));
const server = http.createServer(app);
await new Promise<void>((resolve) => server.listen(0, resolve));
const { port } = server.address() as AddressInfo;
return {
db,
mock,
service: mock.services.get("automations.access") as AutomationsAccessV1,
async request(method, path, { user = "alice", body: given } = {}) {
const body = method === "GET" ? undefined : given;
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
method,
headers: {
...(user ? { "x-test-user": user } : {}),
...(body !== undefined ? { "content-type": "application/json" } : {}),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await response.text();
return { status: response.status, body: text ? JSON.parse(text) : null };
},
async waitForRun(automationId) {
for (let attempt = 0; attempt < 100; attempt++) {
const row = db.sqlite
.prepare(
"SELECT status, error FROM p_automations_runs WHERE automation_id = ? AND status != 'running' ORDER BY id DESC LIMIT 1",
)
.get(automationId) as
{ status: string; error: string | null } | undefined;
if (row) return row;
await new Promise((resolve) => setTimeout(resolve, 10));
}
throw new Error(`No finished run for automation ${automationId}`);
},
async close() {
await new Promise<void>((resolve) => server.close(() => resolve()));
for (const dispose of [...mock.disposals].reverse()) {
await Promise.resolve(dispose()).catch(() => {});
}
db.close();
},
};
}
/** A definition with one step, for tests that only care about the trigger. */
export function definition(
trigger: Record<string, unknown>,
steps: Array<Record<string, unknown>> = [
{ id: "v", type: "set_var", name: "done", value: "yes" },
],
) {
return { version: 1, trigger, steps };
}
@@ -0,0 +1,47 @@
import { expect, it, vi } from "vitest";
import { createScheduler } from "../../src/backend/scheduler.js";
it("discards sustained breaches during maintenance but still runs scheduled work", async () => {
const run = vi.fn(async () => ({ runId: 1, status: "success" as const }));
const clearBreach = vi.fn(async () => {});
const repository = {
listDueSchedules: async () => [{ automationId: 2, intervalSeconds: 60 }],
markScheduleTicked: vi.fn(),
listOpenBreaches: async () => [
{
automationId: 1,
stateKey: "7:cpu.percent",
breachStartedAt: "2000-01-01T00:00:00Z",
},
],
findById: async () => ({
id: 1,
userId: "alice",
enabled: true,
definition: JSON.stringify({
trigger: { kind: "metric_threshold", forSeconds: 60 },
}),
}),
clearBreach,
failStaleRunningRuns: vi.fn(),
pruneRunsOlderThan: async () => 0,
};
const maintaining = vi.fn(async () => true);
const scheduler = createScheduler({
repository: repository as never,
engine: { run },
log: { debug() {}, info() {}, warn() {}, error() {} },
reconcile: [],
isMaintaining: maintaining,
});
await scheduler.tick();
expect(maintaining).toHaveBeenCalledWith("alice", 7);
expect(clearBreach).toHaveBeenCalledWith(1, "7:cpu.percent");
expect(run).toHaveBeenCalledTimes(1);
expect(run).toHaveBeenCalledWith(
expect.objectContaining({
automationId: 2,
triggerType: "schedule",
}),
);
});
@@ -0,0 +1,130 @@
import { describe, expect, it } from "vitest";
import {
advanceMaintenance,
beginMaintenance,
nextOccurrence,
parsePlan,
plansOverlap,
} from "../../src/backend/maintenance-time.js";
import {
emptyMaintenance,
type MaintenancePlan,
} from "../../src/maintenance.js";
const at = (value: string) => Date.parse(value);
const plan = (overrides: Partial<MaintenancePlan> = {}): MaintenancePlan => ({
id: "one",
reason: "Upgrade",
start: "2026-01-31T21:00:00.000Z",
nextStart: "2026-01-31T21:00:00.000Z",
durationMinutes: 60,
recurrence: "once",
graceMinutes: 20,
notifyOverdue: true,
...overrides,
});
describe("maintenance calendar", () => {
it("keeps weekly UTC time across DST and skips missing monthly days", () => {
expect(
new Date(
nextOccurrence(
plan({ recurrence: "weekly" }),
at("2026-03-28T21:00Z"),
)!,
).toISOString(),
).toBe("2026-04-04T21:00:00.000Z");
expect(
new Date(
nextOccurrence(
plan({ recurrence: "monthly" }),
at("2026-01-31T21:00Z"),
)!,
).toISOString(),
).toBe("2026-03-31T21:00:00.000Z");
expect(nextOccurrence(plan(), at("2026-01-31T21:00Z"))).toBeNull();
});
it("rejects actual overlaps and accepts touching windows", () => {
expect(plansOverlap(plan(), plan({ start: "2026-01-31T21:59:00Z" }))).toBe(
true,
);
expect(plansOverlap(plan(), plan({ start: "2026-01-31T22:00:00Z" }))).toBe(
false,
);
expect(
plansOverlap(
plan({ recurrence: "monthly" }),
plan({ start: "2030-03-31T21:30:00Z" }),
),
).toBe(true);
expect(
plansOverlap(
plan({ recurrence: "weekly" }),
plan({ recurrence: "monthly", start: "2026-03-31T21:30:00Z" }),
),
).toBe(true);
expect(
plansOverlap(
plan({ recurrence: "weekly" }),
plan({ recurrence: "monthly", start: "2026-03-31T23:00:00Z" }),
),
).toBe(false);
});
it("does not end at ETM and merges a later nonoverlapping schedule", () => {
const state = emptyMaintenance();
state.plans = [
plan(),
plan({
id: "two",
reason: "Firmware",
start: "2026-01-31T22:30:00Z",
nextStart: "2026-01-31T22:30:00Z",
}),
];
advanceMaintenance(state, at("2026-01-31T22:15Z"));
expect(state.active?.estimatedEnd).toBe("2026-01-31T22:00:00.000Z");
advanceMaintenance(state, at("2026-01-31T22:45Z"));
expect(state.active?.startedAt).toBe("2026-01-31T21:00:00.000Z");
expect(state.active?.estimatedEnd).toBe("2026-01-31T23:30:00.000Z");
expect(state.active?.reasons).toEqual(["Upgrade", "Firmware"]);
advanceMaintenance(state, at("2026-02-02T00:00Z"));
expect(state.active).not.toBeNull();
});
it("catches up after restart and never re-enters a consumed one-time plan", () => {
const state = emptyMaintenance();
state.plans = [plan({ recurrence: "monthly" })];
advanceMaintenance(state, at("2026-05-01T00:00Z"));
expect(state.active?.startedAt).toBe("2026-03-31T21:00:00.000Z");
expect(state.plans[0].nextStart).toBe("2026-05-31T21:00:00.000Z");
state.active = null;
advanceMaintenance(state, at("2026-05-01T00:00Z"));
expect(state.active).toBeNull();
});
it("preserves a delivered reminder unless a later estimate extends maintenance", () => {
const state = emptyMaintenance();
beginMaintenance(state, plan(), at("2026-01-31T21:00Z"));
state.active!.notified = true;
beginMaintenance(
state,
plan({ durationMinutes: 10 }),
at("2026-01-31T21:05Z"),
);
expect(state.active!.notified).toBe(true);
beginMaintenance(state, plan(), at("2026-01-31T22:30Z"));
expect(state.active!.notified).toBe(false);
});
it.each([
{ reason: " " },
{ durationMinutes: 0 },
{ durationMinutes: 10081 },
{ graceMinutes: -1 },
{ recurrence: "daily" },
{ notifyOverdue: "true" },
{ start: "2020-01-01T00:00Z" },
{ start: "2026-01-31T21:00:01Z" },
])("rejects invalid input %j", (override) => {
expect(() =>
parsePlan(plan(override as never), at("2026-01-01T00:00Z")),
).toThrow();
});
});
@@ -0,0 +1,224 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { startServer as createServer, host, type TestServer } from "./helpers";
import { createMaintenanceRepository } from "../../src/backend/maintenance-repository.js";
import { createMaintenanceService } from "../../src/backend/maintenance-service.js";
function startServer(options: Parameters<typeof createServer>[0] = {}) {
return createServer({
...options,
before: (db) => {
db.exec("INSERT INTO ssh_data (id) VALUES (1), (2)");
options.before?.(db);
},
});
}
let server: TestServer | undefined;
afterEach(async () => {
vi.restoreAllMocks();
await server?.close();
server = undefined;
});
const input = {
action: "start",
reason: "OS upgrade",
durationMinutes: 60,
graceMinutes: 20,
notifyOverdue: true,
};
describe("host maintenance", () => {
it("shows a shared host the owner's maintenance read only and enforces edit permission", async () => {
// The mock serves every host to every user, as if alice shared host 1.
server = await startServer({ hosts: [host(1), host(2, "bob")] });
const started = await server.request("POST", "/maintenance/1", {
body: input,
});
expect(started.status).toBe(200);
expect(started.body).toMatchObject({ owned: true });
const listed = (
await server.request("GET", "/maintenance", { user: "bob" })
).body as Array<{
hostId: number;
state: { owned: boolean; active: unknown };
}>;
expect(listed).toHaveLength(1);
expect(listed[0].hostId).toBe(1);
expect(listed[0].state.owned).toBe(false);
expect(listed[0].state.active).toBeTruthy();
const read = await server.request("GET", "/maintenance/1", { user: "bob" });
expect(read.status).toBe(200);
expect(read.body).toMatchObject({ owned: false });
expect(
(
await server.request("POST", "/maintenance/1", {
user: "bob",
body: { action: "end" },
})
).status,
).toBe(403);
expect(
(await server.request("GET", "/maintenance/99", { user: "bob" })).status,
).toBe(404);
expect(
(await server.request("POST", "/maintenance/1x", { body: input })).status,
).toBe(400);
expect(
(await server.request("GET", "/maintenance/1", { user: null })).status,
).toBe(401);
await server.close();
server = await startServer({ permissions: ["automations.view"] });
expect(
(await server.request("POST", "/maintenance/1", { body: input })).status,
).toBe(403);
});
it("survives service restart, remains active beyond ETM, and reminds only once with fresh offline evidence", async () => {
server = await startServer();
let now = Date.parse("2026-01-01T12:00Z");
vi.spyOn(Date, "now").mockImplementation(() => now);
await server.request("POST", "/maintenance/1", { body: input });
const repository = await createMaintenanceRepository(server.db.database);
let service = createMaintenanceService(server.mock.ctx, repository);
now = Date.parse("2026-01-01T13:21Z");
server.mock.hostStatuses.set(1, {
status: "offline",
lastChecked: "2026-01-01T12:00Z",
});
await service.tick();
expect(server.mock.notifications).toHaveLength(0);
server.mock.hostStatuses.set(1, {
status: "reachable",
lastChecked: "2026-01-01T13:21Z",
});
await service.tick();
expect(server.mock.notifications).toHaveLength(0);
server.mock.hostStatuses.set(1, {
status: "offline",
lastChecked: "2026-01-01T13:21Z",
});
await service.tick();
expect(server.mock.notifications).toHaveLength(1);
expect(server.mock.notifications[0].actor).toBe("alice");
service = createMaintenanceService(server.mock.ctx, repository);
await service.tick();
expect(server.mock.notifications).toHaveLength(1);
expect((await service.read("alice", 1)).active).not.toBeNull();
await service.edit("alice", 1, "end", {});
expect(await service.isMaintaining("alice", 1)).toBe(false);
});
it("serializes concurrent starts and conflicting schedules without losing state", async () => {
server = await startServer();
const service = createMaintenanceService(
server.mock.ctx,
await createMaintenanceRepository(server.db.database),
);
const starts = await Promise.allSettled([
service.edit("alice", 1, "start", input),
service.edit("alice", 1, "start", input),
]);
expect(starts.map((r) => r.status).sort()).toEqual([
"fulfilled",
"rejected",
]);
const start = new Date(
Math.ceil((Date.now() + 86400000) / 60000) * 60000,
).toISOString();
const schedules = await Promise.allSettled([
service.edit("alice", 1, "schedule", {
...input,
start,
recurrence: "weekly",
}),
service.edit("alice", 1, "schedule", {
...input,
start,
recurrence: "monthly",
}),
]);
expect(schedules.map((r) => r.status).sort()).toEqual([
"fulfilled",
"rejected",
]);
expect((await service.read("alice", 1)).plans).toHaveLength(1);
});
it("starts a due schedule before accepting status events and handles deletion", async () => {
server = await startServer();
let now = Date.parse("2026-01-01T12:00Z");
vi.spyOn(Date, "now").mockImplementation(() => now);
const service = createMaintenanceService(
server.mock.ctx,
await createMaintenanceRepository(server.db.database),
);
await service.edit("alice", 1, "schedule", {
...input,
start: "2026-01-01T13:00:00Z",
recurrence: "once",
});
expect(await service.isMaintaining("alice", 1)).toBe(false);
now = Date.parse("2026-01-01T13:00Z");
expect(await service.isMaintaining("alice", 1)).toBe(true);
const state = await service.read("alice", 1);
await service.edit("alice", 1, "remove", { id: state.plans[0].id });
expect(await service.isMaintaining("alice", 1)).toBe(true);
await service.wipeUser("alice");
expect(await service.list("alice")).toEqual([]);
});
it("clears old dwell windows on start and end and cascades host deletion", async () => {
server = await startServer();
const created = await server.request("POST", "/", {
body: {
name: "CPU",
definition: {
version: 1,
trigger: {
kind: "metric_threshold",
hostSelector: { kind: "host", hostId: 1 },
metric: { path: "cpu.percent" },
operator: ">",
value: 90,
forSeconds: 60,
cooldownMinutes: 0,
},
steps: [],
},
},
});
expect(created.status).toBe(201);
const { createAutomationRepository } =
await import("../../src/backend/repository.js");
const repository = await createAutomationRepository(
server.db.database,
async () => [],
);
const seed = () =>
repository.upsertTriggerState({
automationId: created.body.id,
stateKey: "1",
breachStartedAt: "2000-01-01T00:00:00Z",
});
await seed();
expect(
(await server.request("POST", "/maintenance/1", { body: input })).status,
).toBe(200);
expect(
(await repository.getTriggerState(created.body.id, "1"))?.breachStartedAt,
).toBeNull();
await seed();
expect(
(
await server.request("POST", "/maintenance/1", {
body: { action: "end" },
})
).status,
).toBe(200);
expect(
(await repository.getTriggerState(created.body.id, "1"))?.breachStartedAt,
).toBeNull();
server.db.sqlite.prepare("DELETE FROM ssh_data WHERE id = 1").run();
expect(
await (await createMaintenanceRepository(server.db.database)).list(),
).toEqual([]);
});
});
@@ -0,0 +1,259 @@
import { afterEach, describe, expect, it } from "vitest";
import { createTestDb, type TestDb } from "@termix/plugin-sdk/testing";
import { NOTIFICATION_CHANNELS_DDL, pluginDir } from "./helpers";
// The six tables as core's SQLite bootstrap created them before 2.9.0, with
// the indexes performance-indexes.ts added.
const LEGACY_DDL = `
CREATE TABLE automations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT,
enabled INTEGER NOT NULL DEFAULT 1,
definition TEXT NOT NULL,
definition_version INTEGER NOT NULL DEFAULT 1,
concurrency_policy TEXT NOT NULL DEFAULT 'skip',
max_run_seconds INTEGER NOT NULL DEFAULT 300,
dry_run INTEGER NOT NULL DEFAULT 0,
last_run_at TEXT,
last_run_status TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE automation_trigger_state (
id INTEGER PRIMARY KEY AUTOINCREMENT,
automation_id INTEGER NOT NULL REFERENCES automations(id) ON DELETE CASCADE,
state_key TEXT NOT NULL,
breach_started_at TEXT,
last_fired_at TEXT,
last_value REAL,
last_observed_state TEXT,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE automation_schedules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
automation_id INTEGER NOT NULL REFERENCES automations(id) ON DELETE CASCADE,
cron TEXT,
interval_seconds INTEGER,
timezone TEXT,
next_due_at TEXT,
last_tick_at TEXT
);
CREATE TABLE automation_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
automation_id INTEGER NOT NULL REFERENCES automations(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
trigger_type TEXT NOT NULL,
trigger_context TEXT,
status TEXT NOT NULL,
started_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
finished_at TEXT,
duration_ms INTEGER,
error TEXT,
dry_run INTEGER NOT NULL DEFAULT 0,
parent_run_id INTEGER
);
CREATE TABLE automation_run_steps (
id INTEGER PRIMARY KEY AUTOINCREMENT,
run_id INTEGER NOT NULL REFERENCES automation_runs(id) ON DELETE CASCADE,
step_index INTEGER NOT NULL,
step_id TEXT NOT NULL,
step_type TEXT NOT NULL,
status TEXT NOT NULL,
started_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
finished_at TEXT,
output TEXT,
error TEXT,
truncated INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE automation_channels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
automation_id INTEGER NOT NULL REFERENCES automations(id) ON DELETE CASCADE,
channel_id INTEGER NOT NULL REFERENCES notification_channels(id) ON DELETE CASCADE
);
CREATE INDEX idx_automations_user ON automations (user_id, enabled);
CREATE UNIQUE INDEX idx_automation_trigger_state_key ON automation_trigger_state (automation_id, state_key);
CREATE UNIQUE INDEX idx_automation_schedules_automation ON automation_schedules (automation_id);
CREATE INDEX idx_automation_schedules_due ON automation_schedules (next_due_at);
CREATE INDEX idx_automation_runs_automation ON automation_runs (automation_id, started_at);
CREATE INDEX idx_automation_runs_user ON automation_runs (user_id, started_at);
CREATE INDEX idx_automation_run_steps_run ON automation_run_steps (run_id, step_index);
CREATE UNIQUE INDEX idx_automation_channels_pair ON automation_channels (automation_id, channel_id);
`;
const SEED = `
INSERT INTO users (id, username) VALUES ('alice', 'alice');
INSERT INTO notification_channels (id, user_id, name, type, config) VALUES (4, 'alice', 'ops', 'webhook', '{}');
INSERT INTO automations (id, user_id, name, definition) VALUES (7, 'alice', 'Disk', '{"version":1,"trigger":{"kind":"webhook","tokenHash":"x"},"steps":[]}');
INSERT INTO automation_trigger_state (automation_id, state_key, last_fired_at) VALUES (7, '1:/data', '2026-01-01T00:00:00Z');
INSERT INTO automation_schedules (automation_id, interval_seconds, next_due_at) VALUES (7, 300, '2026-01-01T00:05:00Z');
INSERT INTO automation_runs (id, automation_id, user_id, trigger_type, status) VALUES (3, 7, 'alice', 'manual', 'success');
INSERT INTO automation_run_steps (run_id, step_index, step_id, step_type, status) VALUES (3, 0, 'a', 'notify', 'success');
INSERT INTO automation_channels (automation_id, channel_id) VALUES (7, 4);
`;
const ADOPTED = [
"p_automations_automations",
"p_automations_trigger_state",
"p_automations_schedules",
"p_automations_runs",
"p_automations_run_steps",
"p_automations_channels",
];
const LEGACY = [
"automations",
"automation_trigger_state",
"automation_schedules",
"automation_runs",
"automation_run_steps",
"automation_channels",
];
let db: TestDb | null = null;
afterEach(() => {
db?.close();
db = null;
});
function tables(target: TestDb): string[] {
return (
target.sqlite
.prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
.all() as Array<{ name: string }>
).map((row) => row.name);
}
function indexes(target: TestDb, table: string): string[] {
return (
target.sqlite
.prepare(
"SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = ? AND name NOT LIKE 'sqlite_%'",
)
.all(table) as Array<{ name: string }>
)
.map((row) => row.name)
.sort();
}
const count = (target: TestDb, table: string) =>
(
target.sqlite.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get() as {
n: number;
}
).n;
describe("adopting the automation tables", () => {
it("keeps every row, index and cascade when the legacy tables exist", async () => {
db = await createTestDb(pluginDir, {
before: (sqlite) => {
sqlite.exec(NOTIFICATION_CHANNELS_DDL);
sqlite.exec(LEGACY_DDL);
sqlite.exec(SEED);
},
});
const names = tables(db);
for (const table of ADOPTED) expect(names).toContain(table);
for (const table of LEGACY) expect(names).not.toContain(table);
for (const table of ADOPTED) expect(count(db, table)).toBe(1);
expect(
db.sqlite
.prepare("SELECT name FROM p_automations_automations WHERE id = 7")
.get(),
).toEqual({ name: "Disk" });
// Only one copy of each legacy index, never a second one.
expect(indexes(db, "p_automations_trigger_state")).toEqual([
"idx_automation_trigger_state_key",
]);
expect(indexes(db, "p_automations_runs")).toEqual([
"idx_automation_runs_automation",
"idx_automation_runs_user",
]);
// Channels moved to the alerts plugin, so a link may name a channel the
// old table never had, and the old one's link is still there.
expect(indexes(db, "p_automations_channels")).toEqual([
"idx_automation_channels_pair",
]);
expect(
db.sqlite.prepare("SELECT channel_id FROM p_automations_channels").all(),
).toEqual([{ channel_id: 4 }]);
db.sqlite
.prepare(
"INSERT INTO p_automations_channels (automation_id, channel_id) VALUES (7, 99)",
)
.run();
db.sqlite
.prepare("DELETE FROM p_automations_channels WHERE channel_id = 99")
.run();
// The foreign keys followed the rename: deleting the automation still
// takes its state, schedule, runs, steps and channel links with it.
db.sqlite
.prepare("DELETE FROM p_automations_automations WHERE id = 7")
.run();
for (const table of ADOPTED) expect(count(db, table)).toBe(0);
});
it("creates the same tables on a fresh install", async () => {
db = await createTestDb(pluginDir, {
before: (sqlite) => sqlite.exec(NOTIFICATION_CHANNELS_DDL),
});
const names = tables(db);
for (const table of ADOPTED) expect(names).toContain(table);
for (const table of LEGACY) expect(names).not.toContain(table);
expect(indexes(db, "p_automations_schedules")).toEqual([
"idx_automation_schedules_automation",
"idx_automation_schedules_due",
]);
db.sqlite.exec(`
INSERT INTO users (id, username) VALUES ('alice', 'alice');
INSERT INTO p_automations_automations (id, user_id, name, definition) VALUES (1, 'alice', 'A', '{}');
INSERT INTO p_automations_runs (id, automation_id, user_id, trigger_type, status) VALUES (1, 1, 'alice', 'manual', 'success');
INSERT INTO p_automations_run_steps (run_id, step_index, step_id, step_type, status) VALUES (1, 0, 'a', 'notify', 'success');
`);
// Deleting the user cascades all the way down.
db.sqlite.prepare("DELETE FROM users WHERE id = 'alice'").run();
expect(count(db, "p_automations_automations")).toBe(0);
expect(count(db, "p_automations_run_steps")).toBe(0);
});
it("creates host maintenance with one row per owner and host, removed with either", async () => {
db = await createTestDb(pluginDir, {
before: (sqlite) => sqlite.exec(NOTIFICATION_CHANNELS_DDL),
});
const maintenanceIndexes = indexes(db, "p_automations_host_maintenance");
expect(maintenanceIndexes).toContain(
"idx_automation_maintenance_host_owner",
);
expect(maintenanceIndexes).not.toContain("maintenance_host_owner");
db.sqlite.exec(`
INSERT INTO users (id, username) VALUES ('alice', 'alice');
INSERT INTO ssh_data (id) VALUES (1);
INSERT INTO ssh_data (id) VALUES (2);
INSERT INTO p_automations_host_maintenance (user_id, host_id, state) VALUES ('alice', 1, '{}');
INSERT INTO p_automations_host_maintenance (user_id, host_id, state) VALUES ('alice', 2, '{}');
`);
expect(() =>
db.sqlite
.prepare(
"INSERT INTO p_automations_host_maintenance (user_id, host_id, state) VALUES ('alice', 1, '{}')",
)
.run(),
).toThrow();
db.sqlite.prepare("DELETE FROM ssh_data WHERE id = 1").run();
expect(count(db, "p_automations_host_maintenance")).toBe(1);
db.sqlite.prepare("DELETE FROM users WHERE id = 'alice'").run();
expect(count(db, "p_automations_host_maintenance")).toBe(0);
});
});
@@ -0,0 +1,60 @@
import { describe, it, expect } from "vitest";
import fs from "node:fs";
import path from "node:path";
import { parseManifest, qualifyPermission } from "@termix/plugin-sdk/manifest";
/**
* The permission ids an admin grants.
*
* Core registers each declared name as `<pluginId>.<name>`, so these strings
* are what end up stored in a role. They have to keep matching what automations
* shipped before the permissions moved out of the core catalog, or every role
* holding one would silently stop working.
*/
const EXPECTED = [
"automations.view",
"automations.create",
"automations.edit",
"automations.delete",
"automations.run",
];
describe("automations permissions", () => {
const manifest = parseManifest(
JSON.parse(
fs.readFileSync(
path.resolve(import.meta.dirname, "../../manifest.json"),
"utf8",
),
),
);
it("parses cleanly", () => {
expect(manifest.errors).toEqual([]);
});
it("registers exactly the ids roles already hold", () => {
const ids = (manifest.manifest?.contributes?.permissions ?? []).map(
(permission) => qualifyPermission("automations", permission.name),
);
expect(ids).toEqual(EXPECTED);
});
it("declares a title and description for every permission", () => {
for (const permission of manifest.manifest?.contributes?.permissions ??
[]) {
expect(permission.titleKey).toBeTruthy();
expect(permission.descriptionKey).toBeTruthy();
}
});
// Core used to grant these through SYSTEM_ROLE_DEFAULTS, so a fresh install
// has to keep giving them to the user role.
it("keeps the fresh-install defaults core used to seed", () => {
for (const permission of manifest.manifest?.contributes?.permissions ??
[]) {
expect(permission.defaultRoles).toEqual(["user"]);
}
});
});
@@ -0,0 +1,231 @@
import crypto from "node:crypto";
import { afterEach, describe, expect, it } from "vitest";
import { definition, startServer, type TestServer } from "./helpers";
let server: TestServer | null = null;
afterEach(async () => {
await server?.close();
server = null;
});
const schedule = definition({ kind: "schedule", intervalSeconds: 300 });
describe("automation routes", () => {
it("creates, lists, updates and deletes an automation", async () => {
server = await startServer();
const created = await server.request("POST", "/", {
body: { name: " Nightly ", definition: schedule, channels: [1, 99] },
});
expect(created.status).toBe(201);
expect(created.body).toMatchObject({
name: "Nightly",
enabled: 1,
// 99 is not one of alice's channels, so it is not linked.
channels: [1],
missingPlugins: [],
});
const id = created.body.id;
// A schedule trigger registers its next due time.
const scheduleRow = server.db.sqlite
.prepare(
"SELECT next_due_at FROM p_automations_schedules WHERE automation_id = ?",
)
.get(id) as { next_due_at: string | null };
expect(scheduleRow.next_due_at).toBeTruthy();
const listed = await server.request("GET", "/");
expect(listed.body.map((row: { id: number }) => row.id)).toEqual([id]);
expect(listed.body[0].definition.trigger.kind).toBe("schedule");
const updated = await server.request("PUT", `/${id}`, {
body: { enabled: false, name: "Nightly job" },
});
expect(updated.status).toBe(200);
expect(updated.body).toMatchObject({ enabled: 0, name: "Nightly job" });
expect((await server.request("DELETE", `/${id}`)).status).toBe(200);
expect((await server.request("GET", `/${id}`)).status).toBe(404);
// The schedule goes with it.
expect(
server.db.sqlite
.prepare("SELECT COUNT(*) AS n FROM p_automations_schedules")
.get(),
).toEqual({ n: 0 });
});
it("never shows one user's automations to another", async () => {
server = await startServer();
const created = await server.request("POST", "/", {
body: { name: "Mine", definition: schedule },
});
expect((await server.request("GET", "/", { user: "bob" })).body).toEqual(
[],
);
expect(
(await server.request("GET", `/${created.body.id}`, { user: "bob" }))
.status,
).toBe(404);
expect(
(await server.request("DELETE", `/${created.body.id}`, { user: "bob" }))
.status,
).toBe(404);
});
it("rejects an invalid definition", async () => {
server = await startServer();
const response = await server.request("POST", "/", {
body: {
name: "Bad",
definition: {
trigger: { kind: "schedule", intervalSeconds: 5 },
steps: [],
},
},
});
expect(response.status).toBe(400);
expect(response.body.error).toMatch(/60 seconds/);
});
it("marks an automation that needs a plugin that is off", async () => {
server = await startServer();
const created = await server.request("POST", "/", {
body: {
name: "Restart app",
definition: definition({ kind: "schedule", intervalSeconds: 300 }, [
{
id: "d",
type: "docker",
action: "restart",
container: "app",
hostSelector: { kind: "host", hostId: 1 },
},
]),
},
});
expect(created.body.missingPlugins).toEqual(["docker"]);
});
it("offers editor options and the running providers", async () => {
server = await startServer({
services: {
"fleets.access": { list: async () => [{ id: 3, name: "web" }] },
},
});
const response = await server.request("GET", "/editor-options");
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
hosts: [
{ id: 1, name: "host-1" },
{ id: 2, name: "host-2" },
],
snippets: [{ id: 5, name: "Restart nginx" }],
fleets: [{ id: 3, name: "web" }],
channels: [{ id: 1, name: "ops" }],
});
expect(response.body.providers).toMatchObject({
snippets: true,
fleets: true,
docker: false,
"host-metrics": false,
"wake-on-lan": false,
});
});
it("returns a webhook token once and runs the automation from it", async () => {
server = await startServer();
const created = await server.request("POST", "/", {
body: { name: "Hook", definition: definition({ kind: "webhook" }) },
});
const token = created.body.webhookToken as string;
expect(token).toHaveLength(64);
expect(created.body.definition.trigger.tokenHash).toBe("");
const stored = server.db.sqlite
.prepare("SELECT definition FROM p_automations_automations WHERE id = ?")
.get(created.body.id) as { definition: string };
expect(JSON.parse(stored.definition).trigger.tokenHash).toBe(
crypto.createHash("sha256").update(token).digest("hex"),
);
const fired = await server.request("POST", `/webhook/${token}`, {
user: null,
body: { hello: "world" },
});
expect(fired.status).toBe(202);
expect(fired.body.status).toBe("success");
const wrong = await server.request("POST", `/webhook/${"0".repeat(64)}`, {
user: null,
body: {},
});
expect(wrong.status).toBe(404);
});
it("runs now, records the steps and lists the run", async () => {
server = await startServer();
const created = await server.request("POST", "/", {
body: { name: "Now", definition: schedule },
});
const outcome = await server.request("POST", `/${created.body.id}/run`, {
body: { dryRun: true },
});
expect(outcome.body.status).toBe("success");
const runs = await server.request("GET", "/runs/history");
expect(runs.body[0]).toMatchObject({
automation_id: created.body.id,
automation_name: "Now",
status: "success",
dry_run: 1,
trigger_type: "manual",
});
const steps = await server.request("GET", `/runs/${runs.body[0].id}/steps`);
expect(steps.body).toMatchObject([
{ step_type: "set_var", status: "success" },
]);
expect(
(
await server.request("GET", `/runs/${runs.body[0].id}/steps`, {
user: "bob",
})
).status,
).toBe(404);
});
});
describe("permissions", () => {
const routes: Array<[string, string, string]> = [
["GET", "/", "view"],
["GET", "/editor-options", "view"],
["GET", "/runs/history", "view"],
["GET", "/runs/1/steps", "view"],
["GET", "/1", "view"],
["POST", "/", "create"],
["PUT", "/1", "edit"],
["DELETE", "/1", "delete"],
["POST", "/1/run", "run"],
];
it.each(routes)(
"%s %s answers 403 without automations.%s",
async (method, path) => {
server = await startServer({ permissions: [] });
const response = await server.request(method, path, { body: {} });
expect(response.status).toBe(403);
},
);
it("serves the webhook without a session", async () => {
server = await startServer({ permissions: [] });
const response = await server.request(
"POST",
`/webhook/${"a".repeat(64)}`,
{ user: null, body: {} },
);
expect(response.status).toBe(404);
});
});
@@ -0,0 +1,99 @@
import { describe, expect, it } from "vitest";
import {
hasUnresolvedTokens,
redactSecrets,
renderRecord,
renderTemplate,
} from "../../src/backend/template.js";
const context = {
host: { id: 7, name: "Zeus", ip: "10.0.0.5", username: "root", port: 22 },
trigger: { value: 93.4, mount: "/data" },
steps: { check: { stdout: "ok\n", stderr: "", code: 0 } },
vars: { target: "/var/log" },
};
describe("renderTemplate", () => {
it("substitutes host, trigger, step and var tokens", () => {
expect(renderTemplate("{{host.name}} at {{trigger.value}}%", context)).toBe(
"Zeus at 93.4%",
);
expect(renderTemplate("{{steps.check.stdout}}", context)).toBe("ok\n");
expect(renderTemplate("{{vars.target}}", context)).toBe("/var/log");
});
it("tolerates whitespace inside the braces", () => {
expect(renderTemplate("{{ host.name }}", context)).toBe("Zeus");
});
it("leaves unknown tokens in place so typos are visible", () => {
expect(renderTemplate("{{host.nope}}", context)).toBe("{{host.nope}}");
expect(hasUnresolvedTokens(renderTemplate("{{host.nope}}", context))).toBe(
true,
);
});
it("returns the input untouched when there is nothing to render", () => {
expect(renderTemplate("plain text", context)).toBe("plain text");
expect(renderTemplate("", context)).toBe("");
});
it("does not escape or alter shell metacharacters", () => {
// Quoting is the caller's job; this keeps that boundary explicit.
const rendered = renderTemplate("{{vars.payload}}", {
vars: { payload: "; rm -rf /" },
});
expect(rendered).toBe("; rm -rf /");
});
it("does not recursively expand a value that looks like a token", () => {
const rendered = renderTemplate("{{vars.a}}", {
vars: { a: "{{vars.b}}", b: "gotcha" },
});
expect(rendered).toBe("{{vars.b}}");
});
it("serializes objects rather than printing [object Object]", () => {
expect(renderTemplate("{{trigger}}", { trigger: { a: 1 } })).toBe(
'{"a":1}',
);
});
it("renders missing branches as the literal token", () => {
expect(renderTemplate("{{steps.other.stdout}}", context)).toBe(
"{{steps.other.stdout}}",
);
});
});
describe("renderRecord", () => {
it("renders values and leaves keys alone", () => {
expect(renderRecord({ "X-Host": "{{host.name}}" }, context)).toEqual({
"X-Host": "Zeus",
});
});
it("passes undefined through", () => {
expect(renderRecord(undefined, context)).toBeUndefined();
});
});
describe("redactSecrets", () => {
it("masks credential-shaped keys", () => {
expect(
redactSecrets({
Authorization: "Bearer abc",
"X-Api-Key": "k",
token: "t",
password: "p",
Accept: "application/json",
}),
).toEqual({
Authorization: "***",
"X-Api-Key": "***",
token: "***",
password: "***",
Accept: "application/json",
});
});
});
Loaded 100 of 4405 files, more files were not shown because too many files have changed in this diff. Show more