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

+206
View File
@@ -0,0 +1,206 @@
import type { Client } from "ssh2";
import type { PluginSsh } from "@termix/plugin-sdk/backend";
import { detectHostPlatform } from "@termix/plugin-sdk/host-commands";
import { collectCpuMetrics } from "./widgets/cpu-collector.js";
import { collectMemoryMetrics } from "./widgets/memory-collector.js";
import { collectDiskMetrics } from "./widgets/disk-collector.js";
import { collectNetworkMetrics } from "./widgets/network-collector.js";
import { collectUptimeMetrics } from "./widgets/uptime-collector.js";
import { collectProcessesMetrics } from "./widgets/processes-collector.js";
import { collectSystemMetrics } from "./widgets/system-collector.js";
import { collectLoginStats } from "./widgets/login-stats-collector.js";
import { collectPortsMetrics } from "./widgets/ports-collector.js";
import { collectFirewallMetrics } from "./widgets/firewall-collector.js";
import { collectTemperatureMetrics } from "./widgets/temperature-collector.js";
import { collectGpuMetrics, type GpuMetrics } from "./widgets/gpu-collector.js";
import type { FirewallMetrics, PortsMetrics } from "../shared/stats-widgets.js";
import { supportsMetrics, type MetricsHost } from "./helpers.js";
import { sessionKey, type MetricsSessions } from "./sessions.js";
import type { MetricsState } from "./state.js";
import type { CollectorRegistry } from "./collectors.js";
export function isHostKeyVerificationError(error: unknown): boolean {
return (
error instanceof Error &&
(error.message.includes("Host denied (verification failed)") ||
error.message.includes("Host key changed"))
);
}
export type CollectedMetrics = {
cpu: Awaited<ReturnType<typeof collectCpuMetrics>>;
memory: Awaited<ReturnType<typeof collectMemoryMetrics>>;
disk: Awaited<ReturnType<typeof collectDiskMetrics>>;
network: Awaited<ReturnType<typeof collectNetworkMetrics>>;
uptime: Awaited<ReturnType<typeof collectUptimeMetrics>>;
processes: Awaited<ReturnType<typeof collectProcessesMetrics>>;
system: Awaited<ReturnType<typeof collectSystemMetrics>>;
login_stats: Awaited<ReturnType<typeof collectLoginStats>>;
ports: PortsMetrics;
firewall: FirewallMetrics;
temperature: Awaited<ReturnType<typeof collectTemperatureMetrics>>;
gpu: GpuMetrics;
/** Results from other plugins' collectors, by collector id. */
extra?: Record<string, unknown>;
};
export interface CollectDeps {
ssh: PluginSsh;
state: MetricsState;
sessions: MetricsSessions;
collectors: CollectorRegistry;
}
async function optional<T>(collect: () => Promise<T>, fallback: T) {
try {
return await collect();
} catch {
return fallback;
}
}
/**
* One sample. Reuses a connection the person opened from the tab when there
* is one, otherwise borrows the "stats" pool, which answers password prompts
* only and fails fast on anything that needs a person.
*/
export async function collectMetrics(
deps: CollectDeps,
host: MetricsHost,
mounts: {
excludedMounts: string[];
monitoredMounts: Array<{ path: string; label?: string }>;
},
onAuthenticated?: () => void,
): Promise<CollectedMetrics> {
const { ssh, state, sessions, collectors } = deps;
if (!supportsMetrics(host, ssh)) {
throw new Error("Metrics collection only supported for SSH hosts");
}
if (state.authFailures.shouldSkip(host.id)) {
const reason = state.authFailures.getSkipReason(host.id);
throw new Error(reason || "Authentication failed");
}
const cached = state.metricsCache.get(host.id) as CollectedMetrics | null;
if (cached) {
onAuthenticated?.();
return cached;
}
return state.requestQueue.queueRequest(host.id, async () => {
const key = sessionKey(host.id, host.userId);
const existing = sessions.get(key);
try {
const collect = async (client: Client): Promise<CollectedMetrics> => {
onAuthenticated?.();
const platform = await detectHostPlatform(client);
const result: CollectedMetrics = {
cpu: await collectCpuMetrics(
client,
platform,
host.id,
state.rateSamples.cpu,
),
memory: await collectMemoryMetrics(client, platform),
disk: await collectDiskMetrics(
client,
mounts.excludedMounts,
mounts.monitoredMounts,
platform,
),
network: await collectNetworkMetrics(
client,
platform,
host.id,
state.rateSamples.network,
),
uptime: await collectUptimeMetrics(client, platform),
processes: await collectProcessesMetrics(client),
system: await collectSystemMetrics(client, platform),
login_stats: await optional(() => collectLoginStats(client), {
recentLogins: [],
failedLogins: [],
totalLogins: 0,
uniqueIPs: 0,
}),
ports: await optional(() => collectPortsMetrics(client), {
source: "none",
ports: [],
}),
firewall: await optional(() => collectFirewallMetrics(client), {
type: "none",
status: "unknown",
chains: [],
}),
temperature: await optional(() => collectTemperatureMetrics(client), {
source: "none",
highestCelsius: null,
sensors: [],
}),
gpu: await optional(() => collectGpuMetrics(client), {
source: "none",
gpus: [],
processes: [],
}),
};
const extra = await collectors.run(client, host.id, host.userId);
if (Object.keys(extra).length > 0) result.extra = extra;
state.metricsCache.set(host.id, result);
return result;
};
if (existing?.isConnected) {
existing.activeOperations++;
try {
const result = await collect(existing.client);
sessions.touch(key);
return result;
} finally {
existing.activeOperations--;
}
}
return await ssh.withConnection<CollectedMetrics, Client>(
host,
{
pool: "stats",
purpose: "metrics",
overrides: { readyTimeout: 60000 },
},
collect,
);
} catch (error) {
if (error instanceof Error) {
if (error.message.includes("TOTP authentication required")) {
throw error;
} else if (
error.message.includes("No password available") ||
error.message.includes("Unsupported authentication type") ||
error.message.includes("No SSH key available") ||
error.message.includes("Invalid SSH key format")
) {
state.authFailures.recordFailure(host.id, "AUTH", true);
} else if (isHostKeyVerificationError(error)) {
state.authFailures.recordFailure(host.id, "HOST_KEY", true);
} else if (
error.message.includes("authentication") ||
error.message.includes("Permission denied") ||
error.message.includes("All configured authentication methods failed")
) {
state.authFailures.recordFailure(host.id, "AUTH");
} else if (
error.message.includes("timeout") ||
error.message.includes("ETIMEDOUT")
) {
state.authFailures.recordFailure(host.id, "TIMEOUT");
}
}
throw error;
}
});
}
@@ -0,0 +1,48 @@
import type { Client } from "ssh2";
import type { PluginServices } from "@termix/plugin-sdk/backend";
import type { MetricsLogger } from "./log.js";
export const COLLECTORS_SERVICE = "host-metrics.collectors";
/**
* The "host-metrics.collectors" service, version 1. Another plugin provides
* it under its own provider name (manifest `provides[].names`) to add data
* to every sample. It runs on the sample's SSH connection, as the host's
* owner, and its result lands in the snapshot under `extra[<name>]`. A
* collector that throws is skipped for that sample.
*/
export interface MetricsCollectorV1 {
collect: (client: Client, hostId: number) => Promise<unknown>;
}
export class CollectorRegistry {
constructor(
private readonly services: PluginServices,
private readonly log: MetricsLogger,
) {}
async run(
client: Client,
hostId: number,
ownerUserId: string,
): Promise<Record<string, unknown>> {
const results: Record<string, unknown> = {};
for (const provider of this.services.providers(COLLECTORS_SERVICE)) {
if (!provider) continue;
try {
const collector = this.services.get<Partial<MetricsCollectorV1>>(
COLLECTORS_SERVICE,
{ provider, userId: ownerUserId },
);
if (typeof collector.collect !== "function") continue;
results[provider] = await collector.collect(client, hostId);
} catch (error) {
this.log.debug(`Metrics collector ${provider} failed`, {
hostId,
error: error instanceof Error ? error.message : String(error),
});
}
}
return results;
}
}
@@ -0,0 +1,50 @@
import type { PluginSsh, PluginSshHost } from "@termix/plugin-sdk/backend";
/** A host as ctx.ssh.resolveHost returns it, with the fields polling reads. */
export type MetricsHost = PluginSshHost & {
userId: string;
name?: string | null;
connectionType?: string | null;
enableSsh?: boolean | null;
password?: string | null;
sudoPassword?: string | null;
enableDocker?: boolean | null;
jumpHosts?: Array<{ hostId: number }> | null;
};
/** Metrics run over SSH, unattended, so the auth type has to allow that. */
export function supportsMetrics(
host: Pick<MetricsHost, "connectionType" | "enableSsh" | "authType">,
ssh: Pick<PluginSsh, "supportsBackground">,
): boolean {
const connectionType = host.connectionType || "ssh";
if (connectionType !== "ssh" && host.enableSsh !== true) return false;
return ssh.supportsBackground(host.authType || "none");
}
export function sudoPasswordOf(host: MetricsHost): string | undefined {
return host.sudoPassword || undefined;
}
export type ConnectionLogType = "info" | "success" | "warning" | "error";
export interface ConnectionLog {
type: ConnectionLogType;
stage: string;
message: string;
details?: Record<string, unknown>;
}
/** The entry shape the connection log panel reads. */
export function connectionLog(
type: ConnectionLogType,
stage: string,
message: string,
details?: Record<string, unknown>,
): ConnectionLog {
return { type, stage, message, ...(details ? { details } : {}) };
}
export function newSessionId(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
}
@@ -0,0 +1,61 @@
import { readHostMetricsSettings } from "../shared/stats-widgets.js";
/**
* Registered as ctx.registry.provide("host-metrics.hostImportNormalizer", ...)
* for core's bulk host import. A Termix export carries these settings under
* pluginSettings["host-metrics"]; exports from before 2.9.0 carried them in
* statsConfig, where the host's own interval only counted when
* useGlobalMetricsInterval was false.
*/
export function hostImportNormalizer(
raw: Record<string, unknown>,
): Record<string, unknown> | null {
const bag = raw.pluginSettings as
Record<string, Record<string, unknown>> | undefined;
const own = bag?.["host-metrics"];
if (own && typeof own === "object") {
const settings = readHostMetricsSettings(own);
return { ...settings };
}
const legacy =
raw.statsConfig && typeof raw.statsConfig === "object"
? (raw.statsConfig as Record<string, unknown>)
: null;
if (!legacy) return null;
const settings = readHostMetricsSettings({
...legacy,
metricsInterval:
legacy.useGlobalMetricsInterval === false ? legacy.metricsInterval : null,
});
return { ...settings };
}
/**
* Registered as "host-metrics.hostPayloadLegacy": the 2.8 statsConfig shape
* Termix-Mobile still reads. Remove once the mobile app reads
* pluginSettings["host-metrics"] and GET /host/status.
*/
export function hostPayloadLegacy(
values: Record<string, unknown>,
host: Record<string, unknown>,
): Record<string, unknown> {
const settings = readHostMetricsSettings(values);
const statusInterval =
typeof host.statusCheckInterval === "number"
? host.statusCheckInterval
: null;
return {
statsConfig: {
enabledWidgets: settings.enabledWidgets,
statusCheckEnabled: host.statusCheckEnabled !== false,
statusCheckInterval: statusInterval ?? 30,
useGlobalStatusInterval: statusInterval === null,
metricsEnabled: settings.metricsEnabled,
metricsInterval: settings.metricsInterval ?? 30,
useGlobalMetricsInterval: settings.metricsInterval == null,
excludedMounts: settings.excludedMounts,
monitoredMounts: settings.monitoredMounts,
},
};
}
+109
View File
@@ -0,0 +1,109 @@
import type { Router } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { createLogger } from "./log.js";
import { createHostMetricsRepository } from "./repository.js";
import { createMetricsState } from "./state.js";
import { MetricsSessions } from "./sessions.js";
import { CollectorRegistry } from "./collectors.js";
import { MetricsPoller, TOPIC_HEALTH_CHECK } from "./poller.js";
import { registerRoutes } from "./routes.js";
import { hostImportNormalizer, hostPayloadLegacy } from "./host-import.js";
import { newSessionId, supportsMetrics } from "./helpers.js";
export type { MetricsCollectorV1 } from "./collectors.js";
export type { CollectionStatusPayload, SnapshotPayload } from "./poller.js";
/** The "host-metrics.viewers" service, version 1. Runs as the caller's user. */
export interface MetricsViewersV1 {
register: (
hostId: number,
) => Promise<{ viewerSessionId: string } | { skipped: true; reason: string }>;
heartbeat: (viewerSessionId: string) => boolean;
unregister: (hostId: number, viewerSessionId: string) => void;
}
export async function activate(ctx: PluginContext) {
const log = createLogger(ctx.log);
const repository = await createHostMetricsRepository(ctx.db);
const state = createMetricsState();
const sessions = new MetricsSessions(ctx.schedule, log);
const collectors = new CollectorRegistry(ctx.services, log);
const poller = new MetricsPoller({
ctx,
log,
repository,
ssh: ctx.ssh,
state,
sessions,
collectors,
});
ctx.disposables.add(() => {
poller.dispose();
sessions.dispose();
});
registerRoutes(ctx.http.router<Router>(), {
ctx,
log,
poller,
sessions,
repository,
onHealthCheck: (event) => ctx.events.emit(TOPIC_HEALTH_CHECK, event),
});
ctx.schedule.every(60_000, () => poller.cleanupInactiveViewers());
ctx.schedule.every(10 * 60_000, () => {
state.authFailures.cleanup();
state.backoff.cleanup();
});
ctx.events.on("host.updated", (payload) => {
const { hostId } = payload as { hostId?: number };
if (hostId) void poller.hostUpdated(hostId);
});
ctx.events.on("host.deleted", (payload) => {
const { hostId } = payload as { hostId?: number };
if (hostId) poller.hostDeleted(hostId);
});
ctx.events.on("host.status", (payload) => {
const { hostId, status } = payload as { hostId?: number; status?: string };
if (hostId && status) poller.hostStatusChanged(hostId, status);
});
ctx.events.on("host.key.updated", (payload) => {
const { hostId } = payload as { hostId?: number };
if (hostId) poller.hostKeyAccepted(hostId);
});
ctx.settings.onChange("metricsInterval", () => void poller.retimeAll());
ctx.services.provide<MetricsViewersV1>("host-metrics.viewers", {
register: async (hostId) => {
const userId = ctx.currentActor();
if (!userId) return { skipped: true, reason: "no_user" };
const host = await poller.resolve(hostId, userId);
if (!host) return { skipped: true, reason: "host_not_found" };
if (!supportsMetrics(host, ctx.ssh)) {
return { skipped: true, reason: "metrics_unsupported" };
}
if (!(await poller.settingsFor(hostId)).metricsEnabled) {
return { skipped: true, reason: "metrics_disabled" };
}
const viewerSessionId = newSessionId("service");
poller.registerViewer(hostId, viewerSessionId, userId);
return { viewerSessionId };
},
heartbeat: (viewerSessionId) =>
poller.updateHeartbeat(viewerSessionId, ctx.currentActor()),
unregister: (hostId, viewerSessionId) =>
poller.unregisterViewer(hostId, viewerSessionId, ctx.currentActor()),
});
ctx.registry.provide(
"host-metrics.hostImportNormalizer",
hostImportNormalizer,
);
ctx.registry.provide("host-metrics.hostPayloadLegacy", hostPayloadLegacy);
ctx.log.info("Host Metrics mounted at /plugin-api/host-metrics");
}
export async function deactivate() {}
@@ -0,0 +1,412 @@
import { Client, type ConnectConfig } from "ssh2";
import type {
PluginSsh,
PluginSshPromptChannel,
} from "@termix/plugin-sdk/backend";
import {
connectionLog,
newSessionId,
type ConnectionLog,
type MetricsHost,
} from "./helpers.js";
import type { MetricsLogger } from "./log.js";
import { sessionKey, type MetricsSessions } from "./sessions.js";
const CONNECT_TIMEOUT_MS = 60_000;
const TOTP_TTL_MS = 180_000;
const MAX_TOTP_ATTEMPTS = 3;
export type StartResult =
| { status: 200; body: Record<string, unknown> }
| { status: 400 | 401 | 500; body: Record<string, unknown> };
export interface InteractiveDeps {
ssh: PluginSsh;
sessions: MetricsSessions;
log: MetricsLogger;
registerViewer: (hostId: number, sessionId: string, userId: string) => void;
}
function stageForError(message: string): [string, string] {
if (message.includes("ENOTFOUND") || message.includes("getaddrinfo")) {
return ["dns", `DNS resolution failed: ${message}`];
}
if (message.includes("ECONNREFUSED") || message.includes("ETIMEDOUT")) {
return ["tcp", `TCP connection failed: ${message}`];
}
if (message.includes("handshake") || message.includes("key exchange")) {
return ["handshake", `SSH handshake failed: ${message}`];
}
if (/authentication/i.test(message)) {
return ["auth", `Authentication failed: ${message}`];
}
if (message.includes("verification failed")) {
return [
"handshake",
"SSH host key has changed. For security, please open a Terminal connection to this host first to verify and accept the new key fingerprint.",
];
}
return ["error", `SSH connection failed: ${message}`];
}
/**
* Opens a connection from the tab, where a person can answer a TOTP prompt,
* and keeps it for polling. Resolves with the HTTP answer.
*/
export async function startInteractive(
deps: InteractiveDeps,
host: MetricsHost,
userId: string,
): Promise<StartResult> {
const { ssh, sessions, log } = deps;
const logs: ConnectionLog[] = [
connectionLog("info", "stats_connecting", "Starting metrics collection"),
connectionLog("info", "dns", `Resolving DNS for ${host.ip}`),
connectionLog("info", "tcp", `Connecting to ${host.ip}:${host.port}`),
connectionLog("info", "handshake", "Initiating SSH handshake"),
];
if (host.authType === "password") {
logs.push(connectionLog("info", "auth", "Authenticating with password"));
} else if (host.authType === "key") {
logs.push(connectionLog("info", "auth", "Authenticating with SSH key"));
}
const key = sessionKey(host.id, userId);
if (sessions.get(key)?.isConnected) {
logs.push(
connectionLog(
"success",
"stats_polling",
"Using existing metrics session",
),
);
return { status: 200, body: { success: true, connectionLogs: logs } };
}
const client = new Client();
const prepared = await ssh.prepare(host, {
purpose: "metrics",
client,
log: (level, message) => logs.push(connectionLog(level, "auth", message)),
});
const config = prepared.config as ConnectConfig;
config.readyTimeout = CONNECT_TIMEOUT_MS;
if (prepared.outcome.status !== "ready") {
const outcome = prepared.outcome;
logs.push(connectionLog("error", "auth", outcome.message));
if (outcome.status === "interaction-required") {
return {
status: 401,
body: {
error: outcome.message,
requiresAuthInteraction: outcome.interaction,
...(outcome.flag ? { [outcome.flag]: true } : {}),
connectionLogs: logs,
},
};
}
return {
status: 400,
body: { error: outcome.message, connectionLogs: logs },
};
}
try {
const result = await new Promise<Record<string, unknown>>(
(resolve, reject) => {
let settled = false;
const settle = (fn: () => void) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
fn();
};
const timeout = setTimeout(
() =>
settle(() => {
client.end();
reject(new Error("Connection timeout"));
}),
CONNECT_TIMEOUT_MS,
);
client.on(
"keyboard-interactive",
(name, instructions, _lang, prompts, finish) => {
const decision = ssh.classifyKeyboardInteractive(
{ name, instructions, prompts },
host,
);
if (decision.kind !== "totp") {
finish(ssh.autoResponses(prompts, host.password));
return;
}
const sessionId = newSessionId("totp");
sessions.addPending({
client,
finish,
config,
createdAt: Date.now(),
sessionId,
hostId: host.id,
userId,
prompts: prompts.map((p) => ({
prompt: p.prompt,
echo: p.echo ?? false,
})),
totpPromptIndex: decision.promptIndex,
resolvedPassword: host.password ?? undefined,
totpAttempts: 0,
});
logs.push(
connectionLog("info", "stats_totp", "TOTP verification required"),
);
settle(() =>
resolve({
success: false,
requires_totp: true,
sessionId,
prompt: prompts[decision.promptIndex].prompt,
}),
);
},
);
client.on("ready", () =>
settle(() => {
logs.push(
connectionLog(
"success",
"connected",
"SSH connection established successfully",
),
connectionLog(
"success",
"stats_polling",
"Metrics session established",
),
);
sessions.open(key, {
client,
isConnected: true,
lastActive: Date.now(),
activeOperations: 0,
hostId: host.id,
userId,
});
const viewerSessionId = newSessionId("viewer");
deps.registerViewer(host.id, viewerSessionId, userId);
resolve({ success: true, viewerSessionId });
}),
);
client.on("error", (error) =>
settle(() => {
const message =
error instanceof Error ? error.message : String(error);
const [stage, text] = stageForError(message);
logs.push(connectionLog("error", stage, text));
log.error("SSH connection error in metrics/start", {
operation: "metrics_start_ssh_error",
hostId: host.id,
error: message,
});
reject(error);
}),
);
if (Array.isArray(host.jumpHosts) && host.jumpHosts.length > 0) {
logs.push(
connectionLog("info", "proxy", "Connecting via jump host chain"),
);
}
const jumpHostPrompt: PluginSshPromptChannel = {
ask: (request) =>
new Promise((answer) => {
if (request.kind !== "totp" || settled) {
answer(null);
return;
}
const sessionId = newSessionId("totp");
sessions.addPending({
client,
finish: (responses) => answer(responses[0] ?? null),
config,
createdAt: Date.now(),
sessionId,
hostId: host.id,
userId,
prompts: [{ prompt: request.prompt, echo: false }],
totpPromptIndex: 0,
resolvedPassword: host.password ?? undefined,
totpAttempts: 0,
});
logs.push(
connectionLog(
"info",
"stats_totp",
"Jump host TOTP verification required",
),
);
settle(() =>
resolve({
success: false,
requires_totp: true,
sessionId,
prompt: request.prompt,
}),
);
}),
};
ssh
.openTransport(host, config as Record<string, unknown>, {
prompt: jumpHostPrompt,
})
.then((transport) => {
if (transport.jumpClient) {
const jumpClient = transport.jumpClient as Client;
client.on("close", () => jumpClient.end());
}
client.connect(config);
})
.catch((error) => {
// Already answered with a jump host prompt: fail the pending submit.
if (settled) client.emit("error", error);
settle(() => {
logs.push(
connectionLog(
"error",
"proxy",
`Connection setup failed: ${error instanceof Error ? error.message : String(error)}`,
),
);
reject(error);
});
});
},
);
return { status: 200, body: { ...result, connectionLogs: logs } };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
logs.push(
connectionLog(
"error",
"stats_connecting",
`Failed to start metrics: ${message}`,
),
);
return {
status: 500,
body: {
error: message || "Failed to start metrics collection",
connectionLogs: logs,
},
};
}
}
export type TotpResult = { status: number; body: Record<string, unknown> };
/** Sends the TOTP code a person typed and keeps the connection on success. */
export async function submitTotp(
deps: InteractiveDeps,
userId: string,
sessionId: string,
totpCode: string,
): Promise<TotpResult> {
const { sessions, log } = deps;
const session = sessions.getPending(sessionId);
if (!session) {
return {
status: 404,
body: { error: "TOTP session not found or expired" },
};
}
if (Date.now() - session.createdAt > TOTP_TTL_MS) {
sessions.dropPending(sessionId, true);
return { status: 408, body: { error: "TOTP session timeout" } };
}
if (session.userId !== userId) {
return { status: 403, body: { error: "Unauthorized" } };
}
session.totpAttempts++;
if (session.totpAttempts > MAX_TOTP_ATTEMPTS) {
sessions.dropPending(sessionId, true);
return { status: 429, body: { error: "Too many TOTP attempts" } };
}
const passwordFor = (prompt: string) =>
/password/i.test(prompt) && session.resolvedPassword
? session.resolvedPassword
: "";
try {
const responses = (session.prompts || []).map((p, index) =>
index === session.totpPromptIndex
? totpCode.trim()
: passwordFor(p.prompt),
);
const ready = new Promise<void>((resolve, reject) => {
const timeout = setTimeout(
() => reject(new Error("TOTP verification timeout")),
30_000,
);
session.client.once(
"keyboard-interactive",
(_name, _instructions, _lang, prompts, finish) => {
log.warn("Second keyboard-interactive received after TOTP", {
operation: "totp_second_keyboard_interactive",
hostId: session.hostId,
});
finish(prompts.map((p) => passwordFor(p.prompt)));
},
);
session.client.once("ready", () => {
clearTimeout(timeout);
resolve();
});
session.client.once("error", (error) => {
clearTimeout(timeout);
reject(error);
});
});
session.finish(responses);
await ready;
sessions.open(sessionKey(session.hostId, userId), {
client: session.client,
isConnected: true,
lastActive: Date.now(),
activeOperations: 0,
hostId: session.hostId,
userId,
});
sessions.dropPending(sessionId);
const viewerSessionId = newSessionId("viewer");
deps.registerViewer(session.hostId, viewerSessionId, userId);
return { status: 200, body: { success: true, viewerSessionId } };
} catch (error) {
log.error("TOTP verification failed", {
operation: "totp_verification_failed",
hostId: session.hostId,
error: error instanceof Error ? error.message : String(error),
});
if (session.totpAttempts >= MAX_TOTP_ATTEMPTS) {
sessions.dropPending(sessionId, true);
}
return {
status: 401,
body: {
error: "TOTP verification failed",
attemptsRemaining: Math.max(
0,
MAX_TOTP_ATTEMPTS - session.totpAttempts,
),
},
};
}
}
+38
View File
@@ -0,0 +1,38 @@
import type { PluginLogger } from "@termix/plugin-sdk/backend";
export type Meta = Record<string, unknown>;
export interface MetricsLogger {
debug: (message: string, meta?: Meta) => void;
info: (message: string, meta?: Meta) => void;
warn: (message: string, meta?: Meta) => void;
/** An error, or structured context, or both. */
error: (message: string, errorOrMeta?: unknown, meta?: Meta) => void;
}
function withMeta(message: string, meta?: Meta): string {
return meta && Object.keys(meta).length > 0
? `${message} ${JSON.stringify(meta)}`
: message;
}
/** ctx.log with the structured context the old stats logger carried. */
export function createLogger(log: PluginLogger): MetricsLogger {
return {
debug: (message, meta) => log.debug(withMeta(message, meta)),
info: (message, meta) => log.info(withMeta(message, meta)),
warn: (message, meta) => log.warn(withMeta(message, meta)),
error: (message, errorOrMeta, meta) => {
if (errorOrMeta instanceof Error) {
log.error(withMeta(message, meta), errorOrMeta);
return;
}
log.error(
withMeta(message, {
...((errorOrMeta as Meta | undefined) ?? {}),
...meta,
}),
);
},
};
}
@@ -0,0 +1,170 @@
import {
execCommand,
shellSingleQuote,
} from "@termix/plugin-sdk/host-commands";
import type { Router } from "express";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import type { ManagerRoutesDeps } from "./types.js";
export interface CronEntry {
raw: string;
enabled: boolean;
schedule: string;
command: string;
}
const READ_CRONTAB_CMD = "crontab -l 2>/dev/null";
/** Parse a crontab into entries (comments/blank lines are dropped except as toggles). */
export function parseCrontab(output: string): CronEntry[] {
const entries: CronEntry[] = [];
for (const raw of output.split("\n")) {
const line = raw.replace(/\s+$/, "");
if (!line.trim()) continue;
// A commented-out job: "# <schedule> <command>" (our toggle convention).
const disabled = line.match(
/^#\s*((?:@\w+|\S+\s+\S+\s+\S+\s+\S+\s+\S+)\s+.+)$/,
);
if (disabled) {
const { schedule, command } = splitScheduleCommand(disabled[1]);
if (command) {
entries.push({ raw: line, enabled: false, schedule, command });
continue;
}
}
// Skip pure comments / env assignments.
if (line.trimStart().startsWith("#")) continue;
if (/^\s*[A-Z_]+=/.test(line)) continue;
const { schedule, command } = splitScheduleCommand(line.trim());
if (!command) continue;
entries.push({ raw: line, enabled: true, schedule, command });
}
return entries;
}
function splitScheduleCommand(line: string): {
schedule: string;
command: string;
} {
if (line.startsWith("@")) {
const [sched, ...rest] = line.split(/\s+/);
return { schedule: sched, command: rest.join(" ") };
}
const parts = line.split(/\s+/);
if (parts.length < 6) return { schedule: "", command: "" };
return {
schedule: parts.slice(0, 5).join(" "),
command: parts.slice(5).join(" "),
};
}
const CRON_SCHEDULE_RE =
/^(@(reboot|yearly|annually|monthly|weekly|daily|midnight|hourly)|[\d*/,-]+\s+[\d*/,-]+\s+[\d*/,-]+\s+[\d*/,A-Za-z-]+\s+[\d*/,A-Za-z-]+)$/;
export function isValidCronSchedule(schedule: string): boolean {
return CRON_SCHEDULE_RE.test(schedule.trim());
}
/** Serialize entries into a full crontab body (toggled entries are commented). */
export function serializeCrontab(entries: CronEntry[]): string {
const lines = entries.map((e) => {
const body = `${e.schedule} ${e.command}`.trim();
return e.enabled ? body : `# ${body}`;
});
return lines.join("\n") + (lines.length ? "\n" : "");
}
/** Build a command that atomically replaces the user crontab from the given body. */
export function buildApplyCrontabCommand(body: string): string {
// printf the exact bytes into `crontab -` (reads new crontab from stdin).
return `printf '%s' ${shellSingleQuote(body)} | crontab -`;
}
export function registerCronRoutes(app: Router, deps: ManagerRoutesDeps): void {
const { validateHostId } = deps;
/**
* @openapi
* /plugin-api/host-metrics/managers/cron/{id}:
* get:
* summary: List the SSH user's crontab entries
* tags: [Host Metrics]
* parameters:
* - in: path
* name: id
* required: true
* schema: { type: integer }
* responses:
* 200: { description: The parsed crontab entries. }
* 400: { description: Invalid input. }
* 403: { description: No access to the host, or elevation denied. }
* 500: { description: The command failed on the host. }
*/
app.get(
"/host-metrics/managers/cron/:id",
validateHostId,
managerHandler(deps, "connect", "cron_list", async (client) => {
const { stdout } = await execCommand(client, READ_CRONTAB_CMD, 15000);
return { entries: parseCrontab(stdout) };
}),
);
/**
* @openapi
* /plugin-api/host-metrics/managers/cron/{id}:
* post:
* summary: Replace the SSH user's crontab
* tags: [Host Metrics]
* parameters:
* - in: path
* name: id
* required: true
* schema: { type: integer }
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [entries]
* properties:
* entries: { type: array, items: { type: object, properties: { schedule: { type: string }, command: { type: string }, enabled: { type: boolean } } } }
* responses:
* 200: { description: The crontab was written. }
* 400: { description: Invalid input. }
* 403: { description: No access to the host, or elevation denied. }
* 500: { description: The command failed on the host. }
*/
app.post(
"/host-metrics/managers/cron/:id",
validateHostId,
managerHandler(
deps,
"connect",
"cron_replace",
async (client, _host, req) => {
const { entries } = req.body as { entries?: CronEntry[] };
if (!Array.isArray(entries)) {
throw new ManagerInputError("entries must be an array");
}
for (const e of entries) {
if (typeof e?.command !== "string" || !e.command.trim()) {
throw new ManagerInputError("Each entry needs a command");
}
if (e.command.includes("\n")) {
throw new ManagerInputError("Commands cannot contain newlines");
}
if (!isValidCronSchedule(String(e.schedule))) {
throw new ManagerInputError(`Invalid schedule: ${e.schedule}`);
}
}
const body = serializeCrontab(entries);
const { stdout, stderr, code } = await execCommand(
client,
buildApplyCrontabCommand(body),
15000,
);
return { success: code === 0, output: stdout || stderr };
},
),
);
}
@@ -0,0 +1,192 @@
import { execElevated, detectPlatform } from "@termix/plugin-sdk/host-commands";
import {
isValidPort,
isValidIpProtocol,
isValidFirewallTarget,
type IpProtocol,
type FirewallTarget,
} from "./validation.js";
import type { Router } from "express";
import { collectFirewallMetrics } from "../widgets/firewall-collector.js";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import type { ManagerRoutesDeps } from "./types.js";
export interface FirewallRuleSpec {
protocol: IpProtocol;
port: number;
target: FirewallTarget;
}
/**
* Build an iptables add/delete for an INPUT rule. We only ever touch INPUT for a
* specific dport with an explicit target, and never the chain policy, so an
* existing ESTABLISHED/SSH rule is left intact.
*/
export function buildIptablesRuleCommand(
op: "add" | "delete",
spec: FirewallRuleSpec,
): string {
const flag = op === "add" ? "-A" : "-D";
return `iptables ${flag} INPUT -p ${spec.protocol} --dport ${spec.port} -j ${spec.target}`;
}
export function buildNftRuleCommand(
op: "add" | "delete",
spec: FirewallRuleSpec,
): string {
// nftables uses the inet filter table's input chain by convention.
const verb = op === "add" ? "add" : "delete";
const action =
spec.target.toLowerCase() === "reject"
? "reject"
: spec.target.toLowerCase();
return `nft ${verb} rule inet filter input ${spec.protocol} dport ${spec.port} ${action}`;
}
export function registerFirewallRoutes(
app: Router,
deps: ManagerRoutesDeps,
): void {
const { validateHostId } = deps;
/**
* @openapi
* /plugin-api/host-metrics/managers/firewall/{id}:
* get:
* summary: Read the host firewall (iptables, nftables or ufw)
* tags: [Host Metrics]
* parameters:
* - in: path
* name: id
* required: true
* schema: { type: integer }
* responses:
* 200: { description: The firewall type, status and rules. }
* 400: { description: Invalid input. }
* 403: { description: No access to the host, or elevation denied. }
* 500: { description: The command failed on the host. }
*/
app.get(
"/host-metrics/managers/firewall/:id",
validateHostId,
managerHandler(deps, "connect", "firewall_read", async (client) => {
return await collectFirewallMetrics(client);
}),
);
/**
* @openapi
* /plugin-api/host-metrics/managers/firewall/{id}/rule:
* post:
* summary: Add or delete an input rule
* tags: [Host Metrics]
* parameters:
* - in: path
* name: id
* required: true
* schema: { type: integer }
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [op, protocol, port, target]
* properties:
* op: { type: string, enum: [add, delete] }
* protocol: { type: string, enum: [tcp, udp] }
* port: { type: integer }
* target: { type: string, description: ACCEPT, DROP or REJECT }
* responses:
* 200: { description: The command result and firewall backend used. }
* 400: { description: Invalid input. }
* 403: { description: No access to the host, or elevation denied. }
* 500: { description: The command failed on the host. }
*/
app.post(
"/host-metrics/managers/firewall/:id/rule",
validateHostId,
managerHandler(
deps,
"connect",
"firewall_rule",
async (client, host, req) => {
const { op, protocol, port, target } = req.body as {
op?: "add" | "delete";
protocol?: string;
port?: number;
target?: string;
};
if (op !== "add" && op !== "delete") {
throw new ManagerInputError("Invalid op");
}
if (!isValidIpProtocol(protocol))
throw new ManagerInputError("Invalid protocol");
if (!isValidPort(port)) throw new ManagerInputError("Invalid port");
if (!isValidFirewallTarget(target))
throw new ManagerInputError("Invalid target");
const spec: FirewallRuleSpec = {
protocol,
port: Number(port),
target,
};
const fw = await collectFirewallMetrics(client);
const cmd =
fw.type === "nftables"
? buildNftRuleCommand(op, spec)
: buildIptablesRuleCommand(op, spec);
const result = await execElevated(client, cmd, host.sudoPassword, {
forceSudo: true,
});
return {
success: result.code === 0,
output: result.stdout || result.stderr,
backend: fw.type,
};
},
),
);
/**
* @openapi
* /plugin-api/host-metrics/managers/firewall/{id}/persist:
* post:
* summary: Save the current firewall rules so they survive a reboot
* tags: [Host Metrics]
* parameters:
* - in: path
* name: id
* required: true
* schema: { type: integer }
* responses:
* 200: { description: The save result. }
* 400: { description: Invalid input. }
* 403: { description: No access to the host, or elevation denied. }
* 500: { description: The command failed on the host. }
*/
app.post(
"/host-metrics/managers/firewall/:id/persist",
validateHostId,
managerHandler(
deps,
"connect",
"firewall_persist",
async (client, host) => {
const platform = await detectPlatform(client);
// Best-effort persistence across common tools.
const cmd =
"(command -v netfilter-persistent >/dev/null 2>&1 && netfilter-persistent save) || " +
"(command -v service >/dev/null 2>&1 && service iptables save) || " +
"(command -v nft >/dev/null 2>&1 && nft list ruleset > /etc/nftables.conf) || true";
const result = await execElevated(client, cmd, host.sudoPassword, {
forceSudo: true,
});
return {
success: result.code === 0,
output: result.stdout || result.stderr,
pkg: platform.pkg,
};
},
),
);
}
@@ -0,0 +1,269 @@
import {
execCommand,
shellSingleQuote,
} from "@termix/plugin-sdk/host-commands";
import { isValidPort } from "./validation.js";
import type { Router } from "express";
import type { Client } from "ssh2";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import type { ManagerRoutesDeps } from "./types.js";
import type { HostMetricsRepository } from "../repository.js";
export interface HealthCheck {
id: string;
name: string;
type: "tcp" | "http";
target: string;
port?: number;
path?: string;
}
export interface HealthResult {
checkId: string;
ok: boolean;
latencyMs: number | null;
detail: string;
}
const TARGET_RE = /^[A-Za-z0-9.\-_:]+$/;
const PATH_RE = /^\/[A-Za-z0-9._~!$&'()*+,;=:@/%-]*$/;
export function isValidHealthCheck(c: unknown): c is HealthCheck {
if (!c || typeof c !== "object") return false;
const o = c as Record<string, unknown>;
if (typeof o.id !== "string" || !o.id) return false;
if (typeof o.name !== "string") return false;
if (o.type !== "tcp" && o.type !== "http") return false;
if (typeof o.target !== "string" || !TARGET_RE.test(o.target)) return false;
if (o.type === "tcp" && !isValidPort(o.port)) return false;
if (
o.path !== undefined &&
(typeof o.path !== "string" || !PATH_RE.test(o.path))
)
return false;
return true;
}
/** Build a command that runs the check from the host and prints "ok latency". */
export function buildHealthCheckCommand(check: HealthCheck): string {
if (check.type === "tcp") {
const host = shellSingleQuote(check.target);
const port = check.port;
// Prefer bash /dev/tcp; time it with date in ms.
return `start=$(date +%s%3N); if timeout 3 bash -c '</dev/tcp/'${host}'/'${port} 2>/dev/null; then echo "ok $(( $(date +%s%3N) - start ))"; else echo "fail $(( $(date +%s%3N) - start ))"; fi`;
}
// http
const scheme = check.target.includes("://") ? "" : "http://";
const url = shellSingleQuote(`${scheme}${check.target}${check.path ?? ""}`);
return `curl -s -o /dev/null -m 5 -w '%{http_code} %{time_total}' ${url} || echo '000 0'`;
}
export function parseHealthResult(
check: HealthCheck,
output: string,
): HealthResult {
const line = output.trim().split("\n").pop() ?? "";
if (check.type === "tcp") {
const [status, ms] = line.split(/\s+/);
return {
checkId: check.id,
ok: status === "ok",
latencyMs: Number(ms) || null,
detail: status === "ok" ? "open" : "closed/timeout",
};
}
const m = line.match(/^(\d{3})\s+([\d.]+)/);
if (!m)
return { checkId: check.id, ok: false, latencyMs: null, detail: line };
const code = Number(m[1]);
return {
checkId: check.id,
ok: code >= 200 && code < 400,
latencyMs: Math.round(Number(m[2]) * 1000),
detail: `HTTP ${code}`,
};
}
async function runChecks(
client: Client,
checks: HealthCheck[],
): Promise<HealthResult[]> {
return Promise.all(
checks.map(async (check) => {
try {
const { stdout } = await execCommand(
client,
buildHealthCheckCommand(check),
8000,
);
return parseHealthResult(check, stdout);
} catch {
return {
checkId: check.id,
ok: false,
latencyMs: null,
detail: "error",
};
}
}),
);
}
const HISTORY_KEEP = 500;
async function loadChecks(
repository: HostMetricsRepository,
userId: string,
hostId: number,
): Promise<HealthCheck[]> {
const row = await repository.findChecks(userId, hostId);
if (!row?.checks) return [];
try {
const parsed = JSON.parse(row.checks);
return Array.isArray(parsed) ? parsed.filter(isValidHealthCheck) : [];
} catch {
return [];
}
}
export function registerHealthRoutes(
app: Router,
deps: ManagerRoutesDeps,
): void {
const { validateHostId, repository } = deps;
const record = async (
userId: string,
hostId: number,
results: HealthResult[],
) => {
if (!results.length) return;
await repository.recordHealth(userId, hostId, results, HISTORY_KEEP);
for (const result of results) {
deps.onHealthCheck({
hostId,
userId,
checkId: result.checkId,
ok: result.ok,
detail: result.detail ?? undefined,
});
}
};
/**
* @openapi
* /plugin-api/host-metrics/managers/health/{id}:
* get:
* summary: Get the host's health checks and their last results
* tags: [Host Metrics]
* parameters:
* - in: path
* name: id
* required: true
* schema: { type: integer }
* responses:
* 200: { description: The configured checks and results. }
* 400: { description: Invalid input. }
* 403: { description: No access to the host, or elevation denied. }
* 500: { description: The command failed on the host. }
*/
app.get(
"/host-metrics/managers/health/:id",
validateHostId,
managerHandler(deps, "connect", "health_get", async (client, host) => {
const userId = host.actorId;
const checks = await loadChecks(repository, userId, host.id);
const results = checks.length ? await runChecks(client, checks) : [];
await record(userId, host.id, results);
const history = await repository.listHealth(userId, host.id, 200);
return { checks, results, history };
}),
);
/**
* @openapi
* /plugin-api/host-metrics/managers/health/{id}/config:
* post:
* summary: Save the host's health checks
* tags: [Host Metrics]
* parameters:
* - in: path
* name: id
* required: true
* schema: { type: integer }
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [checks]
* properties:
* checks: { type: array, items: { type: object, properties: { id: { type: string }, name: { type: string }, type: { type: string }, target: { type: string }, port: { type: integer }, path: { type: string } } } }
* intervalSeconds: { type: integer }
* responses:
* 200: { description: The saved checks. }
* 400: { description: Invalid input. }
* 403: { description: No access to the host, or elevation denied. }
* 500: { description: The command failed on the host. }
*/
app.post(
"/host-metrics/managers/health/:id/config",
validateHostId,
managerHandler(
deps,
"connect",
"health_config",
async (_client, host, req) => {
const userId = host.actorId;
const { checks, intervalSeconds } = req.body as {
checks?: unknown;
intervalSeconds?: number;
};
if (!Array.isArray(checks) || !checks.every(isValidHealthCheck)) {
throw new ManagerInputError("Invalid checks");
}
const interval =
typeof intervalSeconds === "number" &&
intervalSeconds >= 30 &&
intervalSeconds <= 86400
? Math.round(intervalSeconds)
: 300;
await repository.saveChecks(
userId,
host.id,
JSON.stringify(checks),
interval,
);
return { success: true };
},
),
);
/**
* @openapi
* /plugin-api/host-metrics/managers/health/{id}/run:
* post:
* summary: Run the host's health checks now
* tags: [Host Metrics]
* parameters:
* - in: path
* name: id
* required: true
* schema: { type: integer }
* responses:
* 200: { description: The result of each check. }
* 400: { description: Invalid input. }
* 403: { description: No access to the host, or elevation denied. }
* 500: { description: The command failed on the host. }
*/
app.post(
"/host-metrics/managers/health/:id/run",
validateHostId,
managerHandler(deps, "connect", "health_run", async (client, host) => {
const checks = await loadChecks(repository, host.actorId, host.id);
const results = await runChecks(client, checks);
await record(host.actorId, host.id, results);
return { results };
}),
);
}
@@ -0,0 +1,63 @@
import { detectPlatform } from "@termix/plugin-sdk/host-commands";
import type { Router } from "express";
import { managerHandler } from "./route-helpers.js";
import type { ManagerRoutesDeps } from "./types.js";
import { registerServiceRoutes } from "./services.js";
import { registerProcessRoutes } from "./processes.js";
import { registerSimpleReadRoutes } from "./simple-reads.js";
import { registerCronRoutes } from "./cron.js";
import { registerPackageRoutes } from "./packages.js";
import { registerSslRoutes } from "./ssl.js";
import { registerFirewallRoutes } from "./firewall.js";
import { registerUserRoutes } from "./users.js";
import { registerHealthRoutes } from "./health.js";
import { registerLogRoutes } from "./logs.js";
import { registerWireGuardRoutes } from "./wireguard.js";
/**
* Registers every Host Metrics manager route under the `/host-metrics/managers`
* prefix on the stats app. All routes are on-demand (not polled).
*/
export function registerManagerRoutes(
app: Router,
deps: ManagerRoutesDeps,
): void {
const { validateHostId } = deps;
/**
* @openapi
* /host-metrics/platform/{id}:
* get:
* summary: Detect available management tooling on a host
* tags:
* - Host Metrics
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Platform capabilities (systemd, package manager, certbot, docker).
*/
app.get(
"/host-metrics/platform/:id",
validateHostId,
managerHandler(deps, "connect", "platform_detect", (client) =>
detectPlatform(client),
),
);
registerServiceRoutes(app, deps);
registerProcessRoutes(app, deps);
registerSimpleReadRoutes(app, deps);
registerCronRoutes(app, deps);
registerPackageRoutes(app, deps);
registerSslRoutes(app, deps);
registerFirewallRoutes(app, deps);
registerUserRoutes(app, deps);
registerHealthRoutes(app, deps);
registerLogRoutes(app, deps);
registerWireGuardRoutes(app, deps);
}
@@ -0,0 +1,132 @@
import {
execCommand,
execElevated,
shellSingleQuote,
} from "@termix/plugin-sdk/host-commands";
import { isAllowedPath, isValidSystemdUnit } from "./validation.js";
import type { Router } from "express";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import type { ManagerRoutesDeps } from "./types.js";
/** Directories from which arbitrary log files may be tailed. */
export const LOG_PATH_ALLOWLIST = ["/var/log"];
const COMMON_LOGS = [
"/var/log/syslog",
"/var/log/messages",
"/var/log/auth.log",
"/var/log/secure",
"/var/log/kern.log",
"/var/log/dpkg.log",
"/var/log/nginx/access.log",
"/var/log/nginx/error.log",
];
const LIST_LOGS_CMD = `ls -1 ${LOG_PATH_ALLOWLIST.map(shellSingleQuote).join(" ")} 2>/dev/null`;
export function clampLines(n: unknown): number {
const v = typeof n === "string" ? Number(n) : n;
if (typeof v !== "number" || !Number.isFinite(v)) return 200;
return Math.min(2000, Math.max(1, Math.round(v)));
}
export function buildTailCommand(path: string, lines: number): string {
// Keep stderr intact so execElevated can detect a permission error and
// escalate; suppressing it (2>/dev/null) would hide the denial and return an
// empty log with no chance to retry under sudo.
return `tail -n ${lines} ${shellSingleQuote(path)}`;
}
export function buildJournalCommand(unit: string, lines: number): string {
return `journalctl -u ${shellSingleQuote(unit)} -n ${lines} --no-pager`;
}
export function registerLogRoutes(app: Router, deps: ManagerRoutesDeps): void {
const { validateHostId } = deps;
/**
* @openapi
* /plugin-api/host-metrics/managers/logs/{id}/files:
* get:
* summary: List readable log files under /var/log
* tags: [Host Metrics]
* parameters:
* - in: path
* name: id
* required: true
* schema: { type: integer }
* responses:
* 200: { description: Common logs and the files found. }
* 400: { description: Invalid input. }
* 403: { description: No access to the host, or elevation denied. }
* 500: { description: The command failed on the host. }
*/
app.get(
"/host-metrics/managers/logs/:id/files",
validateHostId,
managerHandler(deps, "connect", "logs_list", async (client) => {
const { stdout } = await execCommand(client, LIST_LOGS_CMD, 10000);
const found = stdout
.split("\n")
.map((l) => l.trim())
.filter(Boolean)
.map((name) => `/var/log/${name}`);
return { common: COMMON_LOGS, files: found };
}),
);
/**
* @openapi
* /plugin-api/host-metrics/managers/logs/{id}:
* get:
* summary: Tail a log file or a systemd unit's journal
* tags: [Host Metrics]
* parameters:
* - in: path
* name: id
* required: true
* schema: { type: integer }
* - in: query
* name: path
* schema: { type: string }
* description: A log file under an allowed directory.
* - in: query
* name: unit
* schema: { type: string }
* description: A systemd unit. Used instead of path when given.
* - in: query
* name: lines
* schema: { type: integer }
* responses:
* 200: { description: The last lines of the log. }
* 400: { description: Invalid input. }
* 403: { description: No access to the host, or elevation denied. }
* 500: { description: The command failed on the host. }
*/
app.get(
"/host-metrics/managers/logs/:id",
validateHostId,
managerHandler(deps, "connect", "logs_tail", async (client, host, req) => {
const path = req.query.path as string | undefined;
const unit = req.query.unit as string | undefined;
const lines = clampLines(req.query.lines);
let cmd: string;
if (unit) {
if (!isValidSystemdUnit(unit))
throw new ManagerInputError("Invalid unit");
cmd = buildJournalCommand(unit, lines);
} else if (path) {
if (!isAllowedPath(path, LOG_PATH_ALLOWLIST)) {
throw new ManagerInputError("Path not allowed");
}
cmd = buildTailCommand(path, lines);
} else {
throw new ManagerInputError("Provide a path or unit");
}
// Try unprivileged; many logs need root (auth.log, etc.).
const result = await execElevated(client, cmd, host.sudoPassword);
return { content: result.stdout, lines };
}),
);
}
@@ -0,0 +1,116 @@
import {
execCommand,
execElevated,
detectPlatform,
buildListUpgradableCommand,
buildPackageActionCommand,
parseUpgradable,
type PackageAction,
} from "@termix/plugin-sdk/host-commands";
import { isValidPackageName } from "./validation.js";
import type { Router } from "express";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import type { ManagerRoutesDeps } from "./types.js";
export function registerPackageRoutes(
app: Router,
deps: ManagerRoutesDeps,
): void {
const { validateHostId } = deps;
/**
* @openapi
* /plugin-api/host-metrics/managers/packages/{id}:
* get:
* summary: List upgradable packages and the package manager
* tags: [Host Metrics]
* parameters:
* - in: path
* name: id
* required: true
* schema: { type: integer }
* responses:
* 200: { description: The package manager and upgradable packages. }
* 400: { description: Invalid input. }
* 403: { description: No access to the host, or elevation denied. }
* 500: { description: The command failed on the host. }
*/
app.get(
"/host-metrics/managers/packages/:id",
validateHostId,
managerHandler(deps, "connect", "packages_list", async (client) => {
const platform = await detectPlatform(client);
const cmd = buildListUpgradableCommand(platform.pkg);
if (!cmd) return { pkg: platform.pkg, upgradable: [] };
const { stdout } = await execCommand(client, cmd, 60000);
return {
pkg: platform.pkg,
upgradable: parseUpgradable(platform.pkg, stdout),
};
}),
);
/**
* @openapi
* /plugin-api/host-metrics/managers/packages/{id}/action:
* post:
* summary: Install or upgrade a package, or upgrade everything
* tags: [Host Metrics]
* parameters:
* - in: path
* name: id
* required: true
* schema: { type: integer }
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [action]
* properties:
* action: { type: string, enum: [install, upgrade, upgrade-all] }
* pkg: { type: string, description: The package name. Not needed for upgrade-all. }
* responses:
* 200: { description: The command output. }
* 400: { description: Invalid input. }
* 403: { description: No access to the host, or elevation denied. }
* 500: { description: The command failed on the host. }
*/
app.post(
"/host-metrics/managers/packages/:id/action",
validateHostId,
managerHandler(
deps,
"connect",
"packages_action",
async (client, host, req) => {
const { action, pkg: name } = req.body as {
action?: PackageAction;
pkg?: string;
};
if (
action !== "upgrade-all" &&
action !== "install" &&
action !== "upgrade"
) {
throw new ManagerInputError("Invalid action");
}
if (action !== "upgrade-all" && !isValidPackageName(name)) {
throw new ManagerInputError("Invalid package name");
}
const platform = await detectPlatform(client);
const cmd = buildPackageActionCommand(platform.pkg, action, name);
if (!cmd) throw new ManagerInputError("No supported package manager");
// Package operations can be slow; allow up to 10 minutes.
const result = await execElevated(client, cmd, host.sudoPassword, {
forceSudo: true,
timeoutMs: 600000,
});
return {
success: result.code === 0,
output: (result.stdout || result.stderr).slice(-8000),
};
},
),
);
}
@@ -0,0 +1,130 @@
import { execCommand, execElevated } from "@termix/plugin-sdk/host-commands";
import { isValidPid, isValidSignal, type Signal } from "./validation.js";
import type { Router } from "express";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import type { ManagerRoutesDeps } from "./types.js";
export interface ProcessRow {
pid: number;
ppid: number;
user: string;
cpu: number;
mem: number;
rss: number;
stat: string;
command: string;
args: string;
}
const LIST_PROCESSES_CMD =
"ps -eo pid,ppid,user:20,pcpu,pmem,rss,stat,comm,args --sort=-pcpu --no-headers 2>/dev/null | head -n 300";
/** Parse `ps -eo pid,ppid,user,pcpu,pmem,rss,stat,comm,args` output. */
export function parseProcessList(output: string): ProcessRow[] {
const rows: ProcessRow[] = [];
for (const raw of output.split("\n")) {
const line = raw.trim();
if (!line) continue;
const m = line.match(
/^(\d+)\s+(\d+)\s+(\S+)\s+([\d.]+)\s+([\d.]+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(.*)$/,
);
if (!m) continue;
rows.push({
pid: Number(m[1]),
ppid: Number(m[2]),
user: m[3],
cpu: Number(m[4]),
mem: Number(m[5]),
rss: Number(m[6]),
stat: m[7],
command: m[8],
args: m[9],
});
}
return rows;
}
export function buildKillCommand(pid: number, signal: Signal): string {
return `kill -${signal} ${pid}`;
}
export function registerProcessRoutes(
app: Router,
deps: ManagerRoutesDeps,
): void {
const { validateHostId } = deps;
/**
* @openapi
* /host-metrics/managers/processes/{id}:
* get:
* summary: List processes (rich, sortable, filterable client-side)
* tags: [Host Metrics]
* parameters:
* - in: path
* name: id
* required: true
* schema: { type: integer }
* responses:
* 200: { description: Process list. }
*/
app.get(
"/host-metrics/managers/processes/:id",
validateHostId,
managerHandler(deps, "connect", "processes_list", async (client) => {
const { stdout } = await execCommand(client, LIST_PROCESSES_CMD, 20000);
return { processes: parseProcessList(stdout) };
}),
);
/**
* @openapi
* /host-metrics/managers/processes/{id}/signal:
* post:
* summary: Send a signal to a process (TERM/KILL/HUP/INT)
* tags: [Host Metrics]
* parameters:
* - in: path
* name: id
* required: true
* schema: { type: integer }
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* pid: { type: integer }
* signal: { type: string }
* responses:
* 200: { description: Signal result. }
* 400: { description: Invalid pid or signal. }
* 403: { description: Elevation required or denied. }
*/
app.post(
"/host-metrics/managers/processes/:id/signal",
validateHostId,
managerHandler(
deps,
"connect",
"processes_signal",
async (client, host, req) => {
const { pid, signal } = req.body as {
pid?: number;
signal?: string;
};
if (!isValidPid(pid)) throw new ManagerInputError("Invalid pid");
if (!isValidSignal(signal))
throw new ManagerInputError("Invalid signal");
const cmd = buildKillCommand(Number(pid), signal);
// Try unprivileged first; elevate only if the process isn't owned.
const result = await execElevated(client, cmd, host.sudoPassword);
return {
success: result.code === 0,
output: result.stdout || result.stderr,
usedSudo: result.usedSudo,
};
},
),
);
}
@@ -0,0 +1,60 @@
import { ElevationError } from "@termix/plugin-sdk/host-commands";
import type { Request, Response } from "express";
import type { Client } from "ssh2";
import type { PluginHostShareLevel } from "@termix/plugin-sdk/backend";
import { errorMessage } from "../util.js";
import type { MetricsLogger } from "../log.js";
import type { ManagerHost, RunOnHost } from "./types.js";
export class AccessDeniedError extends Error {
constructor(message = "No access to this host") {
super(message);
this.name = "AccessDeniedError";
}
}
export class ManagerInputError extends Error {
constructor(message: string) {
super(message);
this.name = "ManagerInputError";
}
}
/**
* Wrap a manager handler: parses hostId, runs `fn` on a pooled connection at the
* given access level, and maps known errors to clean HTTP responses.
*/
export function managerHandler(
{ runOnHost, log }: { runOnHost: RunOnHost; log: MetricsLogger },
level: PluginHostShareLevel,
operation: string,
fn: (client: Client, host: ManagerHost, req: Request) => Promise<unknown>,
) {
return async (req: Request, res: Response) => {
const hostId = parseInt(String(req.params.id), 10);
try {
const result = await runOnHost(hostId, level, (client, host) =>
fn(client, host, req),
);
return res.json(result);
} catch (error) {
if (error instanceof ManagerInputError) {
return res.status(400).json({ error: error.message });
}
if (error instanceof AccessDeniedError) {
return res.status(403).json({ error: error.message });
}
if (error instanceof ElevationError) {
return res.status(403).json({ error: error.message, code: error.code });
}
log.error(`Manager operation failed: ${operation}`, {
operation,
hostId,
error: error instanceof Error ? error.message : String(error),
});
return res.status(500).json({
error: errorMessage(error, "Operation failed"),
});
}
};
}
@@ -0,0 +1,135 @@
import { execCommand, execElevated } from "@termix/plugin-sdk/host-commands";
import {
isValidSystemdUnit,
isValidServiceAction,
type ServiceAction,
} from "./validation.js";
import type { Router } from "express";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import type { ManagerRoutesDeps } from "./types.js";
export interface SystemdService {
unit: string;
load: string;
active: string;
sub: string;
description: string;
}
const LIST_SERVICES_CMD =
"systemctl list-units --type=service --all --no-legend --no-pager --plain 2>/dev/null";
/** Parse `systemctl list-units --plain` output into structured rows. */
export function parseServiceList(output: string): SystemdService[] {
const services: SystemdService[] = [];
for (const raw of output.split("\n")) {
const line = raw.trim();
if (!line) continue;
// Columns: UNIT LOAD ACTIVE SUB DESCRIPTION (description may contain spaces)
const parts = line.split(/\s+/);
if (parts.length < 4) continue;
const [unit, load, active, sub, ...rest] = parts;
if (!unit.endsWith(".service")) continue;
services.push({
unit,
load,
active,
sub,
description: rest.join(" "),
});
}
return services;
}
export function buildServiceActionCommand(
unit: string,
action: ServiceAction,
): string {
return `systemctl ${action} ${unit}`;
}
export function registerServiceRoutes(
app: Router,
deps: ManagerRoutesDeps,
): void {
const { validateHostId } = deps;
/**
* @openapi
* /host-metrics/managers/services/{id}:
* get:
* summary: List systemd services
* tags: [Host Metrics]
* parameters:
* - in: path
* name: id
* required: true
* schema: { type: integer }
* responses:
* 200: { description: List of services. }
*/
app.get(
"/host-metrics/managers/services/:id",
validateHostId,
managerHandler(deps, "connect", "services_list", async (client) => {
const { stdout } = await execCommand(client, LIST_SERVICES_CMD, 20000);
return { services: parseServiceList(stdout) };
}),
);
/**
* @openapi
* /host-metrics/managers/services/{id}/action:
* post:
* summary: Start/stop/restart/enable/disable a systemd service
* tags: [Host Metrics]
* parameters:
* - in: path
* name: id
* required: true
* schema: { type: integer }
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* unit: { type: string }
* action: { type: string }
* responses:
* 200: { description: Action result. }
* 400: { description: Invalid unit or action. }
* 403: { description: Elevation required or denied. }
*/
app.post(
"/host-metrics/managers/services/:id/action",
validateHostId,
managerHandler(
deps,
"connect",
"services_action",
async (client, host, req) => {
const { unit, action } = req.body as {
unit?: string;
action?: string;
};
if (!isValidSystemdUnit(unit)) {
throw new ManagerInputError("Invalid unit name");
}
if (!isValidServiceAction(action)) {
throw new ManagerInputError("Invalid action");
}
const result = await execElevated(
client,
buildServiceActionCommand(unit, action),
host.sudoPassword,
{ forceSudo: true, timeoutMs: 30000 },
);
return {
success: result.code === 0,
output: result.stdout || result.stderr,
};
},
),
);
}
@@ -0,0 +1,190 @@
import { execCommand } from "@termix/plugin-sdk/host-commands";
import type { Router } from "express";
import { managerHandler } from "./route-helpers.js";
import type { ManagerRoutesDeps } from "./types.js";
// ─── Top by memory ──────────────────────────────────────────────────────────
export interface MemProcessRow {
pid: number;
user: string;
mem: number;
rss: number;
command: string;
}
const TOP_MEM_CMD =
"ps -eo pid,user:20,pmem,rss,comm --sort=-pmem --no-headers 2>/dev/null | head -n 20";
export function parseTopMemory(output: string): MemProcessRow[] {
const rows: MemProcessRow[] = [];
for (const raw of output.split("\n")) {
const line = raw.trim();
if (!line) continue;
const m = line.match(/^(\d+)\s+(\S+)\s+([\d.]+)\s+(\d+)\s+(.*)$/);
if (!m) continue;
rows.push({
pid: Number(m[1]),
user: m[2],
mem: Number(m[3]),
rss: Number(m[4]),
command: m[5],
});
}
return rows;
}
// ─── Systemd timers ─────────────────────────────────────────────────────────
export interface TimerRow {
next: string;
left: string;
last: string;
unit: string;
activates: string;
}
const TIMERS_CMD =
"systemctl list-timers --all --no-legend --no-pager 2>/dev/null";
export function parseTimers(output: string): TimerRow[] {
const rows: TimerRow[] = [];
for (const raw of output.split("\n")) {
const line = raw.trim();
if (!line || line.startsWith("NEXT")) continue;
// NEXT(3) LEFT(2) LAST(3) PASSED(2) UNIT ACTIVATES -> columns vary; grab
// the trailing UNIT + ACTIVATES which always end the line.
const parts = line.split(/\s+/);
if (parts.length < 2) continue;
const activates = parts[parts.length - 1];
const unit = parts[parts.length - 2];
if (!unit.endsWith(".timer")) continue;
rows.push({
next: parts.slice(0, 3).join(" "),
left: "",
last: "",
unit,
activates,
});
}
return rows;
}
// ─── Disk breakdown (per-mount) ─────────────────────────────────────────────
export interface MountUsage {
filesystem: string;
sizeKb: number;
usedKb: number;
availKb: number;
usePct: number;
mount: string;
}
const DF_CMD = "df -Pk 2>/dev/null | tail -n +2";
export function parseDfMounts(output: string): MountUsage[] {
const mounts: MountUsage[] = [];
for (const raw of output.split("\n")) {
const line = raw.trim();
if (!line) continue;
const m = line.match(/^(\S+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)%\s+(.+)$/);
if (!m) continue;
const fs = m[1];
// Skip pseudo/virtual filesystems that clutter the view.
if (/^(tmpfs|devtmpfs|overlay|udev|none|shm)$/.test(fs)) continue;
mounts.push({
filesystem: fs,
sizeKb: Number(m[2]),
usedKb: Number(m[3]),
availKb: Number(m[4]),
usePct: Number(m[5]),
mount: m[6],
});
}
return mounts;
}
export function registerSimpleReadRoutes(
app: Router,
deps: ManagerRoutesDeps,
): void {
const { validateHostId } = deps;
/**
* @openapi
* /plugin-api/host-metrics/managers/top-memory/{id}:
* get:
* summary: List the processes using the most memory
* tags: [Host Metrics]
* parameters:
* - in: path
* name: id
* required: true
* schema: { type: integer }
* responses:
* 200: { description: The top processes by memory. }
* 400: { description: Invalid input. }
* 403: { description: No access to the host, or elevation denied. }
* 500: { description: The command failed on the host. }
*/
app.get(
"/host-metrics/managers/top-memory/:id",
validateHostId,
managerHandler(deps, "connect", "top_memory", async (client) => {
const { stdout } = await execCommand(client, TOP_MEM_CMD, 15000);
return { processes: parseTopMemory(stdout) };
}),
);
/**
* @openapi
* /plugin-api/host-metrics/managers/timers/{id}:
* get:
* summary: List systemd timers
* tags: [Host Metrics]
* parameters:
* - in: path
* name: id
* required: true
* schema: { type: integer }
* responses:
* 200: { description: The timers with their next and last runs. }
* 400: { description: Invalid input. }
* 403: { description: No access to the host, or elevation denied. }
* 500: { description: The command failed on the host. }
*/
app.get(
"/host-metrics/managers/timers/:id",
validateHostId,
managerHandler(deps, "connect", "systemd_timers", async (client) => {
const { stdout } = await execCommand(client, TIMERS_CMD, 15000);
return { timers: parseTimers(stdout) };
}),
);
/**
* @openapi
* /plugin-api/host-metrics/managers/disk-breakdown/{id}:
* get:
* summary: List mounted filesystems and their usage
* tags: [Host Metrics]
* parameters:
* - in: path
* name: id
* required: true
* schema: { type: integer }
* responses:
* 200: { description: Size, used and free space per mount. }
* 400: { description: Invalid input. }
* 403: { description: No access to the host, or elevation denied. }
* 500: { description: The command failed on the host. }
*/
app.get(
"/host-metrics/managers/disk-breakdown/:id",
validateHostId,
managerHandler(deps, "connect", "disk_breakdown", async (client) => {
const { stdout } = await execCommand(client, DF_CMD, 15000);
return { mounts: parseDfMounts(stdout) };
}),
);
}
@@ -0,0 +1,377 @@
import {
execCommand,
execElevated,
shellSingleQuote,
detectPlatform,
} from "@termix/plugin-sdk/host-commands";
import {
isValidDomain,
isValidDnsProvider,
isAllowedPath,
} from "./validation.js";
import type { Router } from "express";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import type { ManagerRoutesDeps } from "./types.js";
export type AcmeClient = "certbot" | "acme.sh";
export type ChallengeType = "http-standalone" | "http-webroot" | "dns";
export interface CertInfo {
client: AcmeClient | "other";
name: string;
domains: string[];
expiry: string | null;
path?: string;
}
const CERTBOT_LIST_CMD = "certbot certificates 2>/dev/null";
const ACMESH_BIN = '"$(command -v acme.sh || echo "$HOME/.acme.sh/acme.sh")"';
const ACMESH_LIST_CMD = `${ACMESH_BIN} --list 2>/dev/null`;
/** Parse `certbot certificates` output. */
export function parseCertbotCertificates(output: string): CertInfo[] {
const certs: CertInfo[] = [];
let current: CertInfo | null = null;
for (const raw of output.split("\n")) {
const line = raw.trim();
const nameM = line.match(/^Certificate Name:\s+(.+)$/);
if (nameM) {
if (current) certs.push(current);
current = {
client: "certbot",
name: nameM[1],
domains: [],
expiry: null,
};
continue;
}
if (!current) continue;
const domM = line.match(/^Domains:\s+(.+)$/);
if (domM) current.domains = domM[1].split(/\s+/).filter(Boolean);
const expM = line.match(/^Expiry Date:\s+(\S+\s+\S+)/);
if (expM) current.expiry = expM[1];
const pathM = line.match(/^Certificate Path:\s+(.+)$/);
if (pathM) current.path = pathM[1];
}
if (current) certs.push(current);
return certs;
}
/** Parse `acme.sh --list` (tab/space separated columns with a header). */
export function parseAcmeShList(output: string): CertInfo[] {
const certs: CertInfo[] = [];
const lines = output.split("\n").filter((l) => l.trim());
if (lines.length < 2) return certs;
for (const line of lines.slice(1)) {
const cols = line
.split(/\s{2,}|\t/)
.map((c) => c.trim())
.filter(Boolean);
if (cols.length < 1) continue;
certs.push({
client: "acme.sh",
name: cols[0],
domains: [cols[0]],
expiry: cols[cols.length - 1] || null,
});
}
return certs;
}
/**
* A certbot cert name or acme.sh primary domain. Allows letters, digits, dots,
* hyphens, underscores and the wildcard `*` (acme.sh), but no shell metachars.
*/
const CERT_NAME_RE = /^[A-Za-z0-9._*-]+$/;
export function isValidCertName(name: unknown): name is string {
return typeof name === "string" && name.length > 0 && CERT_NAME_RE.test(name);
}
export interface IssueRequest {
client: AcmeClient;
domains: string[];
challenge: ChallengeType;
webroot?: string;
dnsProvider?: string;
}
/**
* Build the certificate issuance command for the chosen client/challenge.
* DNS-01 provider credentials are expected to already be present in the
* environment / provider config file; we never put secrets in argv.
*/
export function buildIssueCommand(req: IssueRequest): string {
const domains = req.domains;
if (req.client === "certbot") {
const dFlags = domains.map((d) => `-d ${shellSingleQuote(d)}`).join(" ");
if (req.challenge === "dns") {
return `certbot certonly --non-interactive --agree-tos --dns-${req.dnsProvider} ${dFlags}`;
}
if (req.challenge === "http-webroot") {
return `certbot certonly --non-interactive --agree-tos --webroot -w ${shellSingleQuote(
req.webroot as string,
)} ${dFlags}`;
}
return `certbot certonly --non-interactive --agree-tos --standalone ${dFlags}`;
}
// acme.sh
const dFlags = domains.map((d) => `-d ${shellSingleQuote(d)}`).join(" ");
if (req.challenge === "dns") {
return `${ACMESH_BIN} --issue --dns dns_${req.dnsProvider} ${dFlags}`;
}
if (req.challenge === "http-webroot") {
return `${ACMESH_BIN} --issue -w ${shellSingleQuote(
req.webroot as string,
)} ${dFlags}`;
}
return `${ACMESH_BIN} --issue --standalone ${dFlags}`;
}
export function buildRenewCommand(client: AcmeClient, dryRun: boolean): string {
if (client === "certbot") {
return `certbot renew${dryRun ? " --dry-run" : ""}`;
}
return `${ACMESH_BIN} --renew-all${dryRun ? " --staging" : ""}`;
}
/**
* Revoke (and remove) a certificate. certbot revokes by its certificate name and
* deletes the lineage afterwards; acme.sh revokes by primary domain then removes
* it from management. `name` is the certbot cert name or the acme.sh domain.
*/
export function buildRevokeCommand(client: AcmeClient, name: string): string {
if (client === "certbot") {
return `certbot revoke --non-interactive --cert-name ${shellSingleQuote(
name,
)} --delete-after-revoke`;
}
const d = shellSingleQuote(name);
return `${ACMESH_BIN} --revoke -d ${d} && ${ACMESH_BIN} --remove -d ${d}`;
}
export function registerSslRoutes(app: Router, deps: ManagerRoutesDeps): void {
const { validateHostId } = deps;
/**
* @openapi
* /plugin-api/host-metrics/managers/ssl/{id}:
* get:
* summary: List certificates from certbot and acme.sh
* tags: [Host Metrics]
* parameters:
* - in: path
* name: id
* required: true
* schema: { type: integer }
* responses:
* 200: { description: The installed ACME clients and certificates. }
* 400: { description: Invalid input. }
* 403: { description: No access to the host, or elevation denied. }
* 500: { description: The command failed on the host. }
*/
app.get(
"/host-metrics/managers/ssl/:id",
validateHostId,
managerHandler(deps, "connect", "ssl_list", async (client, host) => {
const platform = await detectPlatform(client);
const certs: CertInfo[] = [];
if (platform.hasCertbot) {
const r = await execElevated(
client,
CERTBOT_LIST_CMD,
host.sudoPassword,
).catch(() => null);
if (r) certs.push(...parseCertbotCertificates(r.stdout));
}
if (platform.hasAcmeSh) {
const { stdout } = await execCommand(
client,
ACMESH_LIST_CMD,
15000,
).catch(() => ({ stdout: "" }) as { stdout: string });
certs.push(...parseAcmeShList(stdout));
}
return {
clients: {
certbot: platform.hasCertbot,
acmeSh: platform.hasAcmeSh,
},
certs,
};
}),
);
/**
* @openapi
* /plugin-api/host-metrics/managers/ssl/{id}/issue:
* post:
* summary: Issue a certificate with certbot or acme.sh
* tags: [Host Metrics]
* parameters:
* - in: path
* name: id
* required: true
* schema: { type: integer }
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [client, domains, challenge]
* properties:
* client: { type: string, enum: [certbot, acme.sh] }
* domains: { type: array, items: { type: string } }
* challenge: { type: string, enum: [http-standalone, http-webroot, dns] }
* webroot: { type: string }
* dnsProvider: { type: string }
* responses:
* 200: { description: The issue result. }
* 400: { description: Invalid input. }
* 403: { description: No access to the host, or elevation denied. }
* 500: { description: The command failed on the host. }
*/
app.post(
"/host-metrics/managers/ssl/:id/issue",
validateHostId,
managerHandler(deps, "connect", "ssl_issue", async (client, host, req) => {
const body = req.body as Partial<IssueRequest>;
if (body.client !== "certbot" && body.client !== "acme.sh") {
throw new ManagerInputError("Invalid ACME client");
}
if (!Array.isArray(body.domains) || body.domains.length === 0) {
throw new ManagerInputError("At least one domain is required");
}
for (const d of body.domains) {
if (!isValidDomain(d))
throw new ManagerInputError(`Invalid domain: ${d}`);
}
const challenge = body.challenge;
if (
challenge !== "http-standalone" &&
challenge !== "http-webroot" &&
challenge !== "dns"
) {
throw new ManagerInputError("Invalid challenge type");
}
if (challenge === "dns" && !isValidDnsProvider(body.dnsProvider)) {
throw new ManagerInputError("Invalid DNS provider");
}
if (
challenge === "http-webroot" &&
!isAllowedPath(body.webroot, ["/var/www", "/srv", "/usr/share/nginx"])
) {
throw new ManagerInputError("Invalid or disallowed webroot path");
}
const cmd = buildIssueCommand(body as IssueRequest);
const result = await execElevated(client, cmd, host.sudoPassword, {
forceSudo: true,
timeoutMs: 300000,
});
return {
success: result.code === 0,
output: (result.stdout || result.stderr).slice(-8000),
};
}),
);
/**
* @openapi
* /plugin-api/host-metrics/managers/ssl/{id}/renew:
* post:
* summary: Renew certificates
* tags: [Host Metrics]
* parameters:
* - in: path
* name: id
* required: true
* schema: { type: integer }
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [client]
* properties:
* client: { type: string, enum: [certbot, acme.sh] }
* dryRun: { type: boolean }
* responses:
* 200: { description: The renew result. }
* 400: { description: Invalid input. }
* 403: { description: No access to the host, or elevation denied. }
* 500: { description: The command failed on the host. }
*/
app.post(
"/host-metrics/managers/ssl/:id/renew",
validateHostId,
managerHandler(deps, "connect", "ssl_renew", async (client, host, req) => {
const { client: acmeClient, dryRun } = req.body as {
client?: AcmeClient;
dryRun?: boolean;
};
if (acmeClient !== "certbot" && acmeClient !== "acme.sh") {
throw new ManagerInputError("Invalid ACME client");
}
const cmd = buildRenewCommand(acmeClient, !!dryRun);
const result = await execElevated(client, cmd, host.sudoPassword, {
forceSudo: true,
timeoutMs: 300000,
});
return {
success: result.code === 0,
output: (result.stdout || result.stderr).slice(-8000),
};
}),
);
/**
* @openapi
* /host-metrics/managers/ssl/{id}/revoke:
* post:
* summary: Revoke and remove an issued certificate (certbot or acme.sh)
* tags: [Host Metrics]
* parameters:
* - in: path
* name: id
* required: true
* schema: { type: integer }
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* client: { type: string, enum: [certbot, acme.sh] }
* name: { type: string, description: certbot cert name or acme.sh domain }
* responses:
* 200: { description: Revoke result. }
* 400: { description: Invalid client or certificate name. }
* 403: { description: Elevation required or denied. }
*/
app.post(
"/host-metrics/managers/ssl/:id/revoke",
validateHostId,
managerHandler(deps, "connect", "ssl_revoke", async (client, host, req) => {
const { client: acmeClient, name } = req.body as {
client?: AcmeClient;
name?: string;
};
if (acmeClient !== "certbot" && acmeClient !== "acme.sh") {
throw new ManagerInputError("Invalid ACME client");
}
if (!isValidCertName(name)) {
throw new ManagerInputError("Invalid certificate name");
}
const cmd = buildRevokeCommand(acmeClient, name);
const result = await execElevated(client, cmd, host.sudoPassword, {
forceSudo: true,
timeoutMs: 120000,
});
return {
success: result.code === 0,
output: (result.stdout || result.stderr).slice(-8000),
};
}),
);
}
@@ -0,0 +1,43 @@
import type { Client } from "ssh2";
import type { RequestHandler } from "express";
import type { PluginHostShareLevel } from "@termix/plugin-sdk/backend";
import type { HostMetricsRepository } from "../repository.js";
import type { MetricsLogger } from "../log.js";
/** Minimal host shape managers need (includes the decrypted sudo password). */
export interface ManagerHost {
id: number;
userId: string;
/** The user making the request, who may not own the host. */
actorId: string;
sudoPassword?: string;
enableDocker?: boolean;
}
/**
* Runs `fn` against a pooled SSH connection for the host, after verifying the
* acting user has at least `level` access. Resolves the host (with
* sudoPassword) so managers can elevate. Rejects with an access error if not
* permitted.
*/
export type RunOnHost = <T>(
hostId: number,
level: PluginHostShareLevel,
fn: (client: Client, host: ManagerHost) => Promise<T>,
) => Promise<T>;
export interface HealthCheckEvent {
hostId: number;
userId: string;
checkId: string;
ok: boolean;
detail?: string;
}
export interface ManagerRoutesDeps {
validateHostId: RequestHandler;
runOnHost: RunOnHost;
log: MetricsLogger;
repository: HostMetricsRepository;
onHealthCheck: (event: HealthCheckEvent) => void;
}
@@ -0,0 +1,193 @@
import { execCommand, execElevated } from "@termix/plugin-sdk/host-commands";
import { isValidUsername, isValidGroupName } from "./validation.js";
import type { Router } from "express";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import type { ManagerRoutesDeps } from "./types.js";
export interface SystemUser {
name: string;
uid: number;
gid: number;
home: string;
shell: string;
}
export interface SystemGroup {
name: string;
gid: number;
members: string[];
}
// Human users only (uid >= 1000, excluding nobody at 65534).
const READ_USERS_CMD = "getent passwd 2>/dev/null";
const READ_GROUPS_CMD = "getent group 2>/dev/null";
const READ_SUDOERS_CMD = "getent group sudo wheel 2>/dev/null";
export function parsePasswd(output: string): SystemUser[] {
const users: SystemUser[] = [];
for (const line of output.split("\n")) {
const parts = line.split(":");
if (parts.length < 7) continue;
const uid = Number(parts[2]);
if (!Number.isFinite(uid)) continue;
if (uid < 1000 || uid === 65534) continue;
users.push({
name: parts[0],
uid,
gid: Number(parts[3]),
home: parts[5],
shell: parts[6],
});
}
return users;
}
export function parseGroups(output: string): SystemGroup[] {
const groups: SystemGroup[] = [];
for (const line of output.split("\n")) {
const parts = line.split(":");
if (parts.length < 4) continue;
groups.push({
name: parts[0],
gid: Number(parts[2]),
members: parts[3].split(",").filter(Boolean),
});
}
return groups;
}
export function parseSudoers(output: string): string[] {
const members = new Set<string>();
for (const line of output.split("\n")) {
const parts = line.split(":");
if (parts.length < 4) continue;
parts[3]
.split(",")
.filter(Boolean)
.forEach((m) => members.add(m));
}
return [...members];
}
export type UserAction = "create" | "delete" | "addToGroup" | "removeFromGroup";
export function registerUserRoutes(app: Router, deps: ManagerRoutesDeps): void {
const { validateHostId } = deps;
/**
* @openapi
* /plugin-api/host-metrics/managers/users/{id}:
* get:
* summary: List local users and groups
* tags: [Host Metrics]
* parameters:
* - in: path
* name: id
* required: true
* schema: { type: integer }
* responses:
* 200: { description: The users and groups. }
* 400: { description: Invalid input. }
* 403: { description: No access to the host, or elevation denied. }
* 500: { description: The command failed on the host. }
*/
app.get(
"/host-metrics/managers/users/:id",
validateHostId,
managerHandler(deps, "connect", "users_list", async (client) => {
const [passwd, groups, sudoers] = await Promise.all([
execCommand(client, READ_USERS_CMD, 15000),
execCommand(client, READ_GROUPS_CMD, 15000),
execCommand(client, READ_SUDOERS_CMD, 15000),
]);
return {
users: parsePasswd(passwd.stdout),
groups: parseGroups(groups.stdout),
sudoers: parseSudoers(sudoers.stdout),
};
}),
);
/**
* @openapi
* /plugin-api/host-metrics/managers/users/{id}/action:
* post:
* summary: Create or delete a user, or change a user's groups
* tags: [Host Metrics]
* parameters:
* - in: path
* name: id
* required: true
* schema: { type: integer }
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [action, username]
* properties:
* action: { type: string, enum: [create, delete, addToGroup, removeFromGroup] }
* username: { type: string }
* group: { type: string, description: Needed for the group actions. }
* responses:
* 200: { description: The command result. }
* 400: { description: Invalid input. }
* 403: { description: No access to the host, or elevation denied. }
* 500: { description: The command failed on the host. }
*/
app.post(
"/host-metrics/managers/users/:id/action",
validateHostId,
managerHandler(
deps,
"connect",
"users_action",
async (client, host, req) => {
const { action, username, group } = req.body as {
action?: UserAction;
username?: string;
group?: string;
};
if (!isValidUsername(username))
throw new ManagerInputError("Invalid username");
// Never modify/delete the user we're connected as, or root.
const who = (await execCommand(client, "id -un", 8000)).stdout.trim();
if (username === who || username === "root") {
throw new ManagerInputError(
"Refusing to modify the connected user or root",
);
}
let cmd: string;
switch (action) {
case "create":
cmd = `useradd -m ${username}`;
break;
case "delete":
cmd = `userdel -r ${username}`;
break;
case "addToGroup":
case "removeFromGroup":
if (!isValidGroupName(group))
throw new ManagerInputError("Invalid group");
cmd =
action === "addToGroup"
? `usermod -aG ${group} ${username}`
: `gpasswd -d ${username} ${group}`;
break;
default:
throw new ManagerInputError("Invalid action");
}
const result = await execElevated(client, cmd, host.sudoPassword, {
forceSudo: true,
});
return {
success: result.code === 0,
output: result.stdout || result.stderr,
};
},
),
);
}
@@ -0,0 +1,127 @@
/**
* Strict allowlist validators for every dynamic value that reaches a shell
* command. Managers MUST validate inputs through these before interpolation;
* never pass raw user text to the shell.
*/
const SYSTEMD_UNIT_RE =
/^[A-Za-z0-9@._:\\-]+\.(service|socket|timer|target|path|mount)$/;
const PACKAGE_RE = /^[A-Za-z0-9][A-Za-z0-9.+_-]*$/;
const USERNAME_RE = /^[a-z_][a-z0-9_-]*\$?$/;
const GROUP_RE = /^[a-z_][a-z0-9_-]*$/;
const DOMAIN_RE =
/^(\*\.)?(?!-)[A-Za-z0-9-]{1,63}(?<!-)(\.(?!-)[A-Za-z0-9-]{1,63}(?<!-))*$/;
const PROVIDER_RE = /^[a-z0-9_]+$/;
export function isValidSystemdUnit(unit: unknown): unit is string {
return (
typeof unit === "string" && unit.length <= 256 && SYSTEMD_UNIT_RE.test(unit)
);
}
export function isValidPid(pid: unknown): pid is number {
const n = typeof pid === "string" ? Number(pid) : pid;
return typeof n === "number" && Number.isInteger(n) && n > 0 && n < 2 ** 31;
}
export function isValidPort(port: unknown): port is number {
const n = typeof port === "string" ? Number(port) : port;
return typeof n === "number" && Number.isInteger(n) && n >= 1 && n <= 65535;
}
export function isValidPackageName(pkg: unknown): pkg is string {
return typeof pkg === "string" && pkg.length <= 128 && PACKAGE_RE.test(pkg);
}
export function isValidUsername(name: unknown): name is string {
return (
typeof name === "string" && name.length <= 32 && USERNAME_RE.test(name)
);
}
export function isValidGroupName(name: unknown): name is string {
return typeof name === "string" && name.length <= 32 && GROUP_RE.test(name);
}
export function isValidDomain(domain: unknown): domain is string {
return (
typeof domain === "string" && domain.length <= 253 && DOMAIN_RE.test(domain)
);
}
export function isValidDnsProvider(provider: unknown): provider is string {
return (
typeof provider === "string" &&
provider.length <= 64 &&
PROVIDER_RE.test(provider)
);
}
export type Signal = "TERM" | "KILL" | "HUP" | "INT";
const SIGNALS: Signal[] = ["TERM", "KILL", "HUP", "INT"];
export function isValidSignal(sig: unknown): sig is Signal {
return typeof sig === "string" && (SIGNALS as string[]).includes(sig);
}
export type ServiceAction =
"start" | "stop" | "restart" | "reload" | "enable" | "disable";
const SERVICE_ACTIONS: ServiceAction[] = [
"start",
"stop",
"restart",
"reload",
"enable",
"disable",
];
export function isValidServiceAction(a: unknown): a is ServiceAction {
return typeof a === "string" && (SERVICE_ACTIONS as string[]).includes(a);
}
export type IpProtocol = "tcp" | "udp";
export function isValidIpProtocol(p: unknown): p is IpProtocol {
return p === "tcp" || p === "udp";
}
export type FirewallTarget = "ACCEPT" | "DROP" | "REJECT";
const FW_TARGETS: FirewallTarget[] = ["ACCEPT", "DROP", "REJECT"];
export function isValidFirewallTarget(t: unknown): t is FirewallTarget {
return typeof t === "string" && (FW_TARGETS as string[]).includes(t);
}
const WG_IFACE_RE = /^[A-Za-z0-9_-]{1,15}$/;
export function isValidWireGuardInterface(name: unknown): name is string {
return typeof name === "string" && WG_IFACE_RE.test(name);
}
export type WireGuardAction = "up" | "down";
const WG_ACTIONS: WireGuardAction[] = ["up", "down"];
export function isValidWireGuardAction(a: unknown): a is WireGuardAction {
return typeof a === "string" && (WG_ACTIONS as string[]).includes(a);
}
export type TailscaleAction = "up" | "down";
const TAILSCALE_ACTIONS: TailscaleAction[] = ["up", "down"];
export function isValidTailscaleAction(a: unknown): a is TailscaleAction {
return typeof a === "string" && (TAILSCALE_ACTIONS as string[]).includes(a);
}
/**
* Validate an absolute file path against an allowlist of permitted prefixes and
* reject traversal. Used by the log viewer (e.g. only under /var/log).
*/
export function isAllowedPath(
path: unknown,
allowedPrefixes: string[],
): path is string {
if (typeof path !== "string" || path.length === 0 || path.length > 4096) {
return false;
}
if (!path.startsWith("/")) return false;
if (path.includes("\0")) return false;
if (path.split("/").some((seg) => seg === "..")) return false;
return allowedPrefixes.some(
(prefix) =>
path === prefix ||
path.startsWith(prefix.endsWith("/") ? prefix : `${prefix}/`),
);
}
@@ -0,0 +1,213 @@
import { execElevated } from "@termix/plugin-sdk/host-commands";
import {
isValidWireGuardInterface,
isValidWireGuardAction,
} from "./validation.js";
import type { Router } from "express";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import type { ManagerRoutesDeps } from "./types.js";
export interface WireGuardPeer {
publicKey: string;
endpoint: string | null;
allowedIPs: string[];
latestHandshake: number | null;
rxBytes: number;
txBytes: number;
}
export interface WireGuardInterface {
name: string;
publicKey: string | null;
listenPort: number | null;
up: boolean;
peers: WireGuardPeer[];
}
export interface WireGuardData {
installed: boolean;
interfaces: WireGuardInterface[];
}
const PROBE_CMD = [
"command -v wg >/dev/null 2>&1 && echo wg_installed=1 || echo wg_installed=0",
"ip link show type wireguard 2>/dev/null",
"echo __DUMP__",
"wg show all dump 2>/dev/null",
].join("; ");
export function parseWireGuardData(output: string): WireGuardData {
if (output.includes("wg_installed=0")) {
return { installed: false, interfaces: [] };
}
const dumpIdx = output.indexOf("__DUMP__");
const ipLinkPart = dumpIdx >= 0 ? output.slice(0, dumpIdx) : "";
const dumpPart =
dumpIdx >= 0 ? output.slice(dumpIdx + "__DUMP__".length) : "";
// Collect up interface names from `ip link show type wireguard`
const upSet = new Set<string>();
for (const line of ipLinkPart.split("\n")) {
const m = line.match(/^\d+:\s+([A-Za-z0-9_-]+):/);
if (m) upSet.add(m[1]);
}
const ifaceMap = new Map<string, WireGuardInterface>();
for (const raw of dumpPart.split("\n")) {
const line = raw.trim();
if (!line) continue;
const cols = line.split("\t");
if (cols.length === 5) {
// Interface row: iface private_key public_key listen_port fwmark
const [name, , publicKey, listenPortStr] = cols;
if (!name) continue;
ifaceMap.set(name, {
name,
publicKey: publicKey && publicKey !== "(none)" ? publicKey : null,
listenPort:
listenPortStr && listenPortStr !== "(none)"
? Number(listenPortStr)
: null,
up: upSet.has(name),
peers: [],
});
} else if (cols.length === 9) {
// Peer row: iface public_key preshared_key endpoint allowed_ips latest_handshake rx_bytes tx_bytes persistent_keepalive
const [
name,
publicKey,
,
endpoint,
allowedIPsStr,
handshakeStr,
rxStr,
txStr,
] = cols;
if (!name) continue;
if (!ifaceMap.has(name)) {
ifaceMap.set(name, {
name,
publicKey: null,
listenPort: null,
up: upSet.has(name),
peers: [],
});
}
const handshake = Number(handshakeStr);
ifaceMap.get(name)!.peers.push({
publicKey: publicKey ?? "",
endpoint: endpoint && endpoint !== "(none)" ? endpoint : null,
allowedIPs:
allowedIPsStr && allowedIPsStr !== "(none)"
? allowedIPsStr.split(",").map((s) => s.trim())
: [],
latestHandshake: handshake > 0 ? handshake : null,
rxBytes: Number(rxStr) || 0,
txBytes: Number(txStr) || 0,
});
}
}
return { installed: true, interfaces: Array.from(ifaceMap.values()) };
}
export function registerWireGuardRoutes(
app: Router,
deps: ManagerRoutesDeps,
): void {
const { validateHostId } = deps;
/**
* @openapi
* /host-metrics/managers/wireguard/{id}:
* get:
* summary: Get WireGuard interfaces and peers
* tags:
* - Host Metrics
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: WireGuard installation status, interfaces, and peer details.
*/
app.get(
"/host-metrics/managers/wireguard/:id",
validateHostId,
managerHandler(deps, "connect", "wireguard_read", async (client, host) => {
const result = await execElevated(client, PROBE_CMD, host.sudoPassword, {
forceSudo: false,
timeoutMs: 15000,
});
return parseWireGuardData(result.stdout);
}),
);
/**
* @openapi
* /host-metrics/managers/wireguard/{id}/action:
* post:
* summary: Bring a WireGuard interface up or down
* tags:
* - Host Metrics
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* interface:
* type: string
* action:
* type: string
* enum: [up, down]
* responses:
* 200:
* description: Action result.
*/
app.post(
"/host-metrics/managers/wireguard/:id/action",
validateHostId,
managerHandler(
deps,
"connect",
"wireguard_action",
async (client, host, req) => {
const { interface: iface, action } = req.body as {
interface: unknown;
action: unknown;
};
if (!isValidWireGuardInterface(iface)) {
throw new ManagerInputError("Invalid WireGuard interface name");
}
if (!isValidWireGuardAction(action)) {
throw new ManagerInputError("Invalid action, must be 'up' or 'down'");
}
const result = await execElevated(
client,
`wg-quick ${action} ${iface}`,
host.sudoPassword,
{ forceSudo: true, timeoutMs: 30000 },
);
return {
success: result.code === 0,
output: (result.stdout + result.stderr).trim(),
};
},
),
);
}
+465
View File
@@ -0,0 +1,465 @@
import type {
PluginContext,
PluginHostStatusEntry,
} from "@termix/plugin-sdk/backend";
import { readHostMetricsSettings } from "../shared/stats-widgets.js";
import {
collectMetrics,
isHostKeyVerificationError,
type CollectDeps,
type CollectedMetrics,
} from "./collect.js";
import { supportsMetrics, type MetricsHost } from "./helpers.js";
import type { MetricsLogger } from "./log.js";
import type { HostMetricsRepository } from "./repository.js";
import type { MetricsViewer } from "./sessions.js";
import {
canStartInitialMetrics,
metricsConcurrencyFor,
type MetricsState,
} from "./state.js";
export const DEFAULT_METRICS_INTERVAL = 30;
export const DEFAULT_RETENTION_DAYS = 7;
/** Viewers that stop sending heartbeats are dropped after this long. */
const VIEWER_TIMEOUT_MS = 120_000;
export const TOPIC_SNAPSHOT = "plugin.host-metrics.snapshot";
export const TOPIC_STATUS = "plugin.host-metrics.status";
export const TOPIC_HEALTH_CHECK = "plugin.host-metrics.health-check";
/** Where collection stands for a host, sent when it changes. */
export type CollectionState =
"collecting" | "auth_failed" | "host_key_changed" | "unreachable";
export interface SnapshotPayload {
hostId: number;
ownerUserId: string;
metrics: CollectedMetrics;
}
export interface CollectionStatusPayload {
hostId: number;
ownerUserId: string;
state: CollectionState;
previous: CollectionState | null;
}
interface Polled {
host: MetricsHost;
viewerUserId: string;
stop: () => void;
}
export interface PollerDeps extends CollectDeps {
ctx: PluginContext;
log: MetricsLogger;
repository: HostMetricsRepository;
}
/**
* Heavy SSH metrics for hosts somebody is looking at. Polling starts with the
* first viewer and stops with the last one; a viewer is a tab, a dashboard
* card, or another plugin through the "host-metrics.viewers" service.
*/
export class MetricsPoller {
private readonly polled = new Map<number, Polled>();
private readonly metrics = new Map<
number,
{ data: CollectedMetrics; timestamp: number }
>();
private readonly viewers = new Map<number, Set<string>>();
private readonly viewerDetails = new Map<string, MetricsViewer>();
private readonly inFlight = new Set<number>();
private readonly initialRequested = new Set<number>();
private readonly collectionState = new Map<number, CollectionState>();
constructor(private readonly deps: PollerDeps) {}
private get ctx() {
return this.deps.ctx;
}
private get state(): MetricsState {
return this.deps.state;
}
getMetrics(hostId: number) {
return this.metrics.get(hostId);
}
isPolling(hostId: number): boolean {
return this.polled.has(hostId);
}
hasViewers(hostId: number): boolean {
return (this.viewers.get(hostId)?.size ?? 0) > 0;
}
registerViewer(hostId: number, sessionId: string, userId: string): void {
let set = this.viewers.get(hostId);
if (!set) {
set = new Set();
this.viewers.set(hostId, set);
}
set.add(sessionId);
this.viewerDetails.set(sessionId, {
sessionId,
userId,
hostId,
lastHeartbeat: Date.now(),
});
if (set.size === 1) {
// Never let a failed start reach the request that registered it.
void this.start(hostId, userId).catch((error) => {
this.deps.log.warn("Could not start metrics polling", {
operation: "start_metrics_unhandled",
hostId,
error: error instanceof Error ? error.message : String(error),
});
});
}
}
/** False when the viewer is gone, so the caller registers again. */
updateHeartbeat(sessionId: string, userId?: string): boolean {
const viewer = this.viewerDetails.get(sessionId);
if (!viewer || (userId && viewer.userId !== userId)) return false;
viewer.lastHeartbeat = Date.now();
return true;
}
unregisterViewer(hostId: number, sessionId: string, userId?: string): void {
const viewer = this.viewerDetails.get(sessionId);
if (viewer && userId && viewer.userId !== userId) return;
const set = this.viewers.get(hostId);
if (set) {
set.delete(sessionId);
if (set.size === 0) {
this.viewers.delete(hostId);
this.stop(hostId, false);
}
}
this.viewerDetails.delete(sessionId);
}
/** Stops a host's polling without dropping its viewers. */
pause(hostId: number): void {
this.stop(hostId, false);
}
cleanupInactiveViewers(now = Date.now()): void {
for (const [sessionId, viewer] of [...this.viewerDetails.entries()]) {
if (now - viewer.lastHeartbeat > VIEWER_TIMEOUT_MS) {
this.unregisterViewer(viewer.hostId, sessionId);
}
}
}
/** A host's details changed: drop what was cached and start over. */
async hostUpdated(hostId: number): Promise<void> {
this.state.hostCache.invalidate(hostId);
this.state.metricsCache.clear(hostId);
this.ctx.ssh.dropPooled("stats", hostId);
const polled = this.polled.get(hostId);
if (polled) await this.start(hostId, polled.viewerUserId);
}
hostDeleted(hostId: number): void {
this.stop(hostId, true);
for (const sessionId of this.viewers.get(hostId) ?? []) {
this.viewerDetails.delete(sessionId);
}
this.viewers.delete(hostId);
this.collectionState.delete(hostId);
this.state.authFailures.reset(hostId);
this.state.backoff.reset(hostId);
}
/** Core's status check says the host answers again. */
hostStatusChanged(hostId: number, status: string): void {
if (status === "offline") return;
const polled = this.polled.get(hostId);
if (polled) this.scheduleInitialPoll(polled.host);
}
hostKeyAccepted(hostId: number): void {
this.state.authFailures.resetHostKeyFailure(hostId);
}
/** Re-times every running host, after the admin interval changed. */
async retimeAll(): Promise<void> {
for (const [hostId, polled] of [...this.polled.entries()]) {
await this.start(hostId, polled.viewerUserId);
}
}
dispose(): void {
for (const hostId of [...this.polled.keys()]) this.stop(hostId, true);
this.viewers.clear();
this.viewerDetails.clear();
this.state.rateSamples.clear();
}
/** The host as `userId` may reach it, cached for a few minutes. */
async resolve(hostId: number, userId: string): Promise<MetricsHost | null> {
const cached = this.state.hostCache.get(
hostId,
userId,
) as MetricsHost | null;
if (cached) return cached;
const host = (await this.ctx.asUser(userId, () =>
this.ctx.ssh.resolveHost(hostId),
)) as MetricsHost | null;
if (!host) return null;
this.state.hostCache.set(hostId, userId, host);
return host;
}
async settingsFor(hostId: number) {
const settings = readHostMetricsSettings(
await this.ctx.settings.getAll("host", hostId),
);
const adminInterval = Number(
await this.ctx.settings.get<number>("metricsInterval"),
);
return {
...settings,
intervalSeconds:
settings.metricsInterval ??
(Number.isInteger(adminInterval) && adminInterval >= 5
? adminInterval
: DEFAULT_METRICS_INTERVAL),
};
}
private async start(hostId: number, userId: string): Promise<void> {
const host = await this.resolve(hostId, userId);
this.stop(hostId, false);
if (!host || !supportsMetrics(host, this.ctx.ssh)) return;
const settings = await this.settingsFor(hostId);
if (!settings.metricsEnabled) {
this.metrics.delete(hostId);
return;
}
const intervalMs = settings.intervalSeconds * 1000;
const stop = this.ctx.schedule.every(
intervalMs,
() => {
const latest = this.polled.get(hostId);
if (latest) this.schedulePoll(latest.host);
},
// Spread timers so a fleet does not fire on the same second.
{ jitterMs: Math.min(intervalMs * 0.2, 15_000) },
);
this.polled.set(hostId, { host, viewerUserId: userId, stop });
this.syncConcurrency();
this.scheduleInitialPoll(host);
}
private stop(hostId: number, clearData: boolean): void {
const polled = this.polled.get(hostId);
if (polled) {
polled.stop();
this.polled.delete(hostId);
this.syncConcurrency();
}
this.inFlight.delete(hostId);
if (clearData) {
this.metrics.delete(hostId);
this.state.hostCache.invalidate(hostId);
this.state.rateSamples.clear(hostId);
}
}
/** Keeps poll concurrency matched to how many hosts are being polled. */
private syncConcurrency(): void {
const target = metricsConcurrencyFor(this.polled.size);
const limiter = this.state.metricsLimiter;
if (target === limiter.limit) return;
const previous = limiter.limit;
limiter.setLimit(target);
this.deps.log.info(
`Metrics poll concurrency ${previous} -> ${target} for ${this.polled.size} host(s)`,
{ operation: "metrics_concurrency_resize" },
);
}
/**
* The first heavy sample waits for core's cheap status check to say the
* host answers, so a whole fleet registering at once does not open a
* burst of SSH connections to hosts that are down.
*/
private scheduleInitialPoll(host: MetricsHost): void {
if (this.metrics.has(host.id) || this.initialRequested.has(host.id)) {
return;
}
this.initialRequested.add(host.id);
void this.state.initialLimiter
.run(async () => {
let status: PluginHostStatusEntry | null =
await this.ctx.hosts.status.get(host.id);
if (!status) status = await this.ctx.hosts.status.check(host.id);
if (!canStartInitialMetrics(status?.status, this.hasViewers(host.id))) {
return;
}
if (this.inFlight.has(host.id)) return;
this.inFlight.add(host.id);
try {
await this.state.metricsLimiter.run(() => this.poll(host));
} finally {
this.inFlight.delete(host.id);
}
})
.catch((error) => {
this.deps.log.error("Initial metrics polling failed", error, {
operation: "initial_metrics_poll_unhandled",
hostId: host.id,
});
})
.finally(() => this.initialRequested.delete(host.id));
}
private schedulePoll(host: MetricsHost): void {
if (this.inFlight.has(host.id)) return;
this.inFlight.add(host.id);
void this.state.metricsLimiter
.run(() => this.poll(host))
.catch((error) => {
this.deps.log.error("Metrics polling failed", error, {
operation: "metrics_poll_unhandled",
hostId: host.id,
});
})
.finally(() => this.inFlight.delete(host.id));
}
/** One sample for one host. Public for tests. */
async poll(host: MetricsHost): Promise<void> {
const { state, log } = this.deps;
if (!this.polled.has(host.id)) return;
if (state.authFailures.shouldSkip(host.id)) return;
if (state.backoff.shouldSkip(host.id)) return;
const settings = await this.settingsFor(host.id);
if (!settings.metricsEnabled) return;
let authenticated = false;
try {
const metrics = await collectMetrics(this.deps, host, settings, () => {
authenticated = true;
});
this.metrics.set(host.id, { data: metrics, timestamp: Date.now() });
this.ctx.hosts.status.reportLogin(host.id, { ok: true });
this.setCollectionState(host, "collecting");
await this.recordSample(host.id, metrics);
this.ctx.events.emit(TOPIC_SNAPSHOT, {
hostId: host.id,
ownerUserId: host.userId,
metrics,
} satisfies SnapshotPayload);
state.backoff.reset(host.id);
state.authFailures.reset(host.id);
} catch (error) {
const hostKeyChanged = isHostKeyVerificationError(error);
const message = error instanceof Error ? error.message : "";
const isAuthError = /authentication|permission denied/i.test(message);
// A timeout or a dropped connection says nothing about the login.
if (!authenticated && (isAuthError || hostKeyChanged)) {
this.ctx.hosts.status.reportLogin(host.id, {
ok: false,
hostKeyChanged,
});
}
this.setCollectionState(
host,
hostKeyChanged
? "host_key_changed"
: isAuthError
? "auth_failed"
: "unreachable",
);
if (isAuthError) {
// collectMetrics already recorded it; log the first occurrence only.
if (!state.authFailures.shouldSkip(host.id)) {
log.error("Stats collector connection failed", error, {
operation: "stats_connect_failed",
hostId: host.id,
});
}
return;
}
if (hostKeyChanged) {
state.authFailures.recordFailure(host.id, "HOST_KEY", true);
log.error("Stats collector host key verification failed", error, {
operation: "stats_host_key_verification_failed",
hostId: host.id,
});
return;
}
state.backoff.recordFailure(host.id);
// Only log when a new retry window opens, not on every skipped poll.
const backoff = state.backoff.getBackoffInfo(host.id);
if (backoff !== null && !backoff.includes("polling suspended")) {
log.error("Stats collector connection failed", error, {
operation: "stats_connect_failed",
hostId: host.id,
});
}
}
}
private setCollectionState(host: MetricsHost, next: CollectionState): void {
const previous = this.collectionState.get(host.id) ?? null;
if (previous === next) return;
this.collectionState.set(host.id, next);
this.ctx.events.emit(TOPIC_STATUS, {
hostId: host.id,
ownerUserId: host.userId,
state: next,
previous,
} satisfies CollectionStatusPayload);
}
private async recordSample(
hostId: number,
metrics: CollectedMetrics,
): Promise<void> {
try {
const iface = metrics.network?.interfaces?.[0];
const bytes = (value: string | null | undefined) => {
const parsed = value ? parseInt(value, 10) : NaN;
return Number.isNaN(parsed) ? null : parsed;
};
const retention = Number(
await this.ctx.settings.get<number>("historyRetentionDays"),
);
await this.deps.repository.recordSample(
{
hostId,
cpuPercent: metrics.cpu?.percent ?? null,
memPercent: metrics.memory?.percent ?? null,
diskPercent: metrics.disk?.percent ?? null,
netRxBytes: bytes(iface?.rxBytes),
netTxBytes: bytes(iface?.txBytes),
},
Number.isInteger(retention) && retention >= 1
? Math.min(retention, 90)
: DEFAULT_RETENTION_DAYS,
);
} catch (error) {
this.deps.log.warn("Failed to write metrics history", {
operation: "insert_metrics_history",
hostId,
error: error instanceof Error ? error.message : String(error),
});
}
}
}
@@ -0,0 +1,233 @@
import { and, asc, desc, eq, gte, lt, lte, notInArray } from "drizzle-orm";
import type { PluginDatabase } from "@termix/plugin-sdk/backend";
import {
hostHealthChecks,
hostHealthHistory,
hostMetricsHistory,
hostMetricsPreferences,
} from "./tables.js";
/* eslint-disable @typescript-eslint/no-explicit-any */
// ctx.db.define hands tables back untyped, and the drizzle handle is the
// server's own. Typed at this module's edge instead.
type Table = any;
type Drizzle = any;
/* eslint-enable @typescript-eslint/no-explicit-any */
export interface HealthResultInput {
checkId: string;
ok: boolean;
latencyMs: number | null;
detail: string;
}
export interface HealthHistoryRow {
checkId: string;
ts: string;
ok: boolean;
latencyMs: number | null;
detail: string | null;
}
export interface MetricsHistoryInput {
hostId: number;
cpuPercent: number | null;
memPercent: number | null;
diskPercent: number | null;
netRxBytes: number | null;
netTxBytes: number | null;
}
export interface MetricsHistoryRow {
ts: string;
cpuPercent: number | null;
memPercent: number | null;
diskPercent: number | null;
netRxBytes: number | null;
netTxBytes: number | null;
}
/** Timestamps are text in `YYYY-MM-DD HH:MM:SS` UTC, which sorts in time order. */
export function sqlTimestamp(date: Date): string {
return date.toISOString().slice(0, 19).replace("T", " ");
}
export type HostMetricsRepository = Awaited<
ReturnType<typeof createHostMetricsRepository>
>;
/**
* Written without RETURNING so it runs the same on all three engines: an
* upsert reads the row by its unique (user, host) pair first.
*/
export async function createHostMetricsRepository(db: PluginDatabase) {
const preferences: Table = await db.define(hostMetricsPreferences);
const checks: Table = await db.define(hostHealthChecks);
const health: Table = await db.define(hostHealthHistory);
const history: Table = await db.define(hostMetricsHistory);
const client = () => db.client<Drizzle>();
async function findOne(table: Table, userId: string, hostId: number) {
const drizzle = await client();
const rows = await drizzle
.select()
.from(table)
.where(and(eq(table.userId, userId), eq(table.hostId, hostId)))
.limit(1);
return rows[0] ?? null;
}
async function upsert(
table: Table,
userId: string,
hostId: number,
values: Record<string, unknown>,
) {
const drizzle = await client();
const now = sqlTimestamp(new Date());
const existing = await findOne(table, userId, hostId);
if (existing) {
await drizzle
.update(table)
.set({ ...values, updatedAt: now })
.where(eq(table.id, existing.id));
} else {
await drizzle.insert(table).values({
...values,
userId,
hostId,
createdAt: now,
updatedAt: now,
});
}
await db.persist();
}
return {
async findLayout(userId: string, hostId: number): Promise<string | null> {
return (await findOne(preferences, userId, hostId))?.layout ?? null;
},
saveLayout(userId: string, hostId: number, layout: string) {
return upsert(preferences, userId, hostId, { layout });
},
async findChecks(
userId: string,
hostId: number,
): Promise<{ checks: string; intervalSeconds: number } | null> {
return findOne(checks, userId, hostId);
},
saveChecks(
userId: string,
hostId: number,
json: string,
intervalSeconds: number,
) {
return upsert(checks, userId, hostId, { checks: json, intervalSeconds });
},
/** Adds results and keeps only the newest `keep` rows for the host. */
async recordHealth(
userId: string,
hostId: number,
results: HealthResultInput[],
keep: number,
): Promise<void> {
if (results.length === 0) return;
const drizzle = await client();
const ts = sqlTimestamp(new Date());
await drizzle
.insert(health)
.values(results.map((result) => ({ ...result, userId, hostId, ts })));
const scope = and(eq(health.userId, userId), eq(health.hostId, hostId));
const retained = await drizzle
.select({ id: health.id })
.from(health)
.where(scope)
.orderBy(desc(health.ts), desc(health.id))
.limit(keep);
await drizzle.delete(health).where(
retained.length
? and(
scope,
notInArray(
health.id,
retained.map((row: { id: number }) => row.id),
),
)
: scope,
);
await db.persist();
},
async listHealth(
userId: string,
hostId: number,
limit: number,
): Promise<HealthHistoryRow[]> {
const drizzle = await client();
const rows = await drizzle
.select()
.from(health)
.where(and(eq(health.userId, userId), eq(health.hostId, hostId)))
.orderBy(desc(health.ts), desc(health.id))
.limit(limit);
return rows.map((row: Record<string, unknown>) => ({
checkId: row.checkId as string,
ts: row.ts as string,
ok: row.ok === true || row.ok === 1,
latencyMs: (row.latencyMs as number | null) ?? null,
detail: (row.detail as string | null) ?? null,
}));
},
/** One sample, then drops the host's samples older than the retention. */
async recordSample(
input: MetricsHistoryInput,
retentionDays: number,
): Promise<void> {
const drizzle = await client();
const now = new Date();
await drizzle.insert(history).values({ ...input, ts: sqlTimestamp(now) });
const cutoff = new Date(now.getTime() - retentionDays * 86_400_000);
await drizzle
.delete(history)
.where(
and(
eq(history.hostId, input.hostId),
lt(history.ts, sqlTimestamp(cutoff)),
),
);
// A sample every few seconds per host: let the debounced save pick it up.
await db.persist({ lazy: true });
},
async listSamples(
hostId: number,
fromTs: string,
toTs: string,
): Promise<MetricsHistoryRow[]> {
const drizzle = await client();
return drizzle
.select({
ts: history.ts,
cpuPercent: history.cpuPercent,
memPercent: history.memPercent,
diskPercent: history.diskPercent,
netRxBytes: history.netRxBytes,
netTxBytes: history.netTxBytes,
})
.from(history)
.where(
and(
eq(history.hostId, hostId),
gte(history.ts, fromTs),
lte(history.ts, toTs),
),
)
.orderBy(asc(history.ts));
},
};
}
+686
View File
@@ -0,0 +1,686 @@
import type {
NextFunction,
Request,
RequestHandler,
Response,
Router,
} from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import {
defaultLayoutFromWidgets,
deriveEnabledWidgets,
type HostMetricsLayout,
} from "../shared/host-metrics.js";
import { newSessionId, supportsMetrics } from "./helpers.js";
import {
startInteractive,
submitTotp,
type InteractiveDeps,
} from "./interactive.js";
import type { MetricsLogger } from "./log.js";
import { registerManagerRoutes } from "./managers/index.js";
import { AccessDeniedError } from "./managers/route-helpers.js";
import type { HealthCheckEvent } from "./managers/types.js";
import type { MetricsPoller } from "./poller.js";
import { sqlTimestamp, type HostMetricsRepository } from "./repository.js";
import { sessionKey, type MetricsSessions } from "./sessions.js";
import { sudoPasswordOf } from "./helpers.js";
export interface RouteDeps {
ctx: PluginContext;
log: MetricsLogger;
poller: MetricsPoller;
sessions: MetricsSessions;
repository: HostMetricsRepository;
onHealthCheck: (event: HealthCheckEvent) => void;
}
const RANGE_OFFSETS: Record<string, number> = {
"1h": 60 * 60 * 1000,
"6h": 6 * 60 * 60 * 1000,
"24h": 24 * 60 * 60 * 1000,
"7d": 7 * 24 * 60 * 60 * 1000,
"30d": 30 * 24 * 60 * 60 * 1000,
};
function validateHostId(req: Request, res: Response, next: NextFunction) {
const id = Number(req.params.id);
if (!id || !Number.isInteger(id) || id <= 0) {
return res.status(400).json({ error: "Invalid host ID" });
}
next();
}
export function sanitizeLayout(input: unknown): HostMetricsLayout | null {
if (!input || typeof input !== "object") return null;
const obj = input as Record<string, unknown>;
if (!Array.isArray(obj.slots)) return null;
const columns =
typeof obj.columns === "number" && obj.columns >= 1 && obj.columns <= 4
? Math.round(obj.columns)
: 3;
const slots = obj.slots
.filter((s): s is Record<string, unknown> => !!s && typeof s === "object")
.map((s, i) => ({
id: String(s.id),
order: typeof s.order === "number" ? s.order : i,
colSpan:
s.colSpan === 1 || s.colSpan === 2 || s.colSpan === 3 ? s.colSpan : 1,
height:
typeof s.height === "number" && s.height > 0
? Math.round(s.height)
: null,
}))
.filter((s) => s.id && s.id !== "undefined");
return { slots, columns } as HostMetricsLayout;
}
/** Everything under /plugin-api/host-metrics. Core authenticates first. */
export function registerRoutes(router: Router, deps: RouteDeps): void {
const { ctx, log, poller, sessions, repository } = deps;
router.use(ctx.rbac.require("use") as unknown as RequestHandler);
router.use((_req, res, next) => {
res.setHeader("Cache-Control", "no-store");
next();
});
const actor = (res: Response): string | null => {
const userId = ctx.currentActor();
if (!userId) res.status(401).json({ error: "Authentication required" });
return userId ?? null;
};
const canConnect = async (hostId: number) =>
(await ctx.hosts.checkAccess(hostId, "connect")).hasAccess;
const interactive: InteractiveDeps = {
ssh: ctx.ssh,
sessions,
log,
registerViewer: (hostId, sessionId, userId) =>
poller.registerViewer(hostId, sessionId, userId),
};
/**
* @openapi
* /plugin-api/host-metrics/metrics/{id}:
* get:
* summary: Get host metrics
* description: Returns the latest sample for a host, including CPU, memory, disk, network, processes and system information.
* tags:
* - Host Metrics
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Host metrics data.
* 403:
* description: No access to this host.
* 404:
* description: Metrics not available.
*/
router.get("/metrics/:id", validateHostId, async (req, res) => {
const id = Number(req.params.id);
if (!actor(res)) return;
if (!(await canConnect(id))) {
return res.status(403).json({ error: "No access to this host" });
}
const sample = poller.getMetrics(id);
if (!sample) {
return res.status(404).json({
error: "Metrics not available",
cpu: { percent: null, cores: null, load: null },
memory: { percent: null, usedGiB: null, totalGiB: null },
disk: {
percent: null,
usedHuman: null,
totalHuman: null,
availableHuman: null,
mount: null,
filesystems: [],
},
network: { interfaces: [] },
uptime: { seconds: null, formatted: null },
processes: { total: null, running: null, top: [] },
system: { hostname: null, kernel: null, os: null },
lastChecked: new Date().toISOString(),
});
}
res.json({
...sample.data,
lastChecked: new Date(sample.timestamp).toISOString(),
});
});
/**
* @openapi
* /plugin-api/host-metrics/metrics/start/{id}:
* post:
* summary: Start metrics collection
* description: Opens an SSH connection from the tab, where a TOTP prompt can be answered, and starts collecting metrics for the host.
* tags:
* - Host Metrics
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Metrics collection started, or a TOTP code is required.
* 401:
* description: The host needs a sign-in step first.
* 404:
* description: Host not found.
* 500:
* description: Failed to start metrics collection.
*/
router.post("/metrics/start/:id", validateHostId, async (req, res) => {
const id = Number(req.params.id);
const userId = actor(res);
if (!userId) return;
try {
const host = await poller.resolve(id, userId);
if (!host) {
return res.status(404).json({
error: "Host not found",
connectionLogs: [
{
type: "error",
stage: "stats_connecting",
message: "Host not found",
},
],
});
}
const result = await startInteractive(interactive, host, userId);
res.status(result.status).json(result.body);
} catch (error) {
log.error("Failed to start metrics collection", error, {
operation: "metrics_start_error",
hostId: id,
});
res.status(500).json({ error: "Failed to start metrics collection" });
}
});
/**
* @openapi
* /plugin-api/host-metrics/metrics/stop/{id}:
* post:
* summary: Stop metrics collection
* description: Closes the tab's connection and drops its viewer, which stops polling when it was the last one.
* tags:
* - Host Metrics
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* requestBody:
* content:
* application/json:
* schema:
* type: object
* properties:
* viewerSessionId:
* type: string
* responses:
* 200:
* description: Metrics collection stopped.
*/
router.post("/metrics/stop/:id", validateHostId, async (req, res) => {
const id = Number(req.params.id);
const userId = actor(res);
if (!userId) return;
sessions.close(sessionKey(id, userId));
const { viewerSessionId } = (req.body ?? {}) as {
viewerSessionId?: unknown;
};
if (typeof viewerSessionId === "string" && viewerSessionId) {
poller.unregisterViewer(id, viewerSessionId, userId);
} else if (await canConnect(id)) {
poller.pause(id);
}
res.json({ success: true });
});
/**
* @openapi
* /plugin-api/host-metrics/metrics/connect-totp:
* post:
* summary: Submit a TOTP code
* description: Answers the TOTP prompt a metrics connection is waiting on.
* tags:
* - Host Metrics
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* sessionId:
* type: string
* totpCode:
* type: string
* responses:
* 200:
* description: Connected.
* 400:
* description: Missing sessionId or totpCode.
* 401:
* description: TOTP verification failed.
* 404:
* description: TOTP session not found or expired.
* 429:
* description: Too many TOTP attempts.
*/
router.post("/metrics/connect-totp", async (req, res) => {
const userId = actor(res);
if (!userId) return;
const { sessionId, totpCode } = (req.body ?? {}) as {
sessionId?: unknown;
totpCode?: unknown;
};
if (typeof sessionId !== "string" || typeof totpCode !== "string") {
return res.status(400).json({ error: "Missing sessionId or totpCode" });
}
const result = await submitTotp(interactive, userId, sessionId, totpCode);
res.status(result.status).json(result.body);
});
/**
* @openapi
* /plugin-api/host-metrics/metrics/heartbeat:
* post:
* summary: Keep a metrics viewer alive
* description: A viewer without a heartbeat for two minutes is dropped.
* tags:
* - Host Metrics
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* viewerSessionId:
* type: string
* responses:
* 200:
* description: Heartbeat updated.
* 400:
* description: Invalid viewerSessionId.
* 404:
* description: Viewer session not found.
*/
router.post("/metrics/heartbeat", (req, res) => {
const userId = actor(res);
if (!userId) return;
const { viewerSessionId } = (req.body ?? {}) as {
viewerSessionId?: unknown;
};
if (typeof viewerSessionId !== "string" || !viewerSessionId) {
return res.status(400).json({ error: "Invalid viewerSessionId" });
}
if (poller.updateHeartbeat(viewerSessionId, userId)) {
return res.json({ success: true });
}
res.status(404).json({ error: "Viewer session not found" });
});
/**
* @openapi
* /plugin-api/host-metrics/metrics/register-viewer:
* post:
* summary: Register a metrics viewer
* description: Starts polling a host while something shows its metrics. Answers skipped instead of failing when the host has no metrics.
* tags:
* - Host Metrics
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* hostId:
* type: integer
* responses:
* 200:
* description: Viewer registered, or skipped with a reason.
* 400:
* description: Invalid hostId.
*/
router.post("/metrics/register-viewer", async (req, res) => {
const userId = actor(res);
if (!userId) return;
const { hostId } = (req.body ?? {}) as { hostId?: unknown };
if (
typeof hostId !== "number" ||
!Number.isInteger(hostId) ||
hostId <= 0
) {
return res.status(400).json({ error: "Invalid hostId" });
}
const skipped = (reason: string) =>
res.json({ success: true, skipped: true, reason });
try {
const host = await poller.resolve(hostId, userId);
if (!host) return skipped("host_not_found");
if (!supportsMetrics(host, ctx.ssh))
return skipped("metrics_unsupported");
if (!(await poller.settingsFor(hostId)).metricsEnabled) {
return skipped("metrics_disabled");
}
const viewerSessionId = newSessionId("viewer");
poller.registerViewer(hostId, viewerSessionId, userId);
res.json({ success: true, viewerSessionId });
} catch (error) {
log.warn("Failed to register viewer", {
operation: "register_viewer_error",
hostId,
error: error instanceof Error ? error.message : String(error),
});
res.json({ success: false, skipped: true, reason: "internal_error" });
}
});
/**
* @openapi
* /plugin-api/host-metrics/metrics/unregister-viewer:
* post:
* summary: Unregister a metrics viewer
* description: Drops a viewer. Polling stops with the last one.
* tags:
* - Host Metrics
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* hostId:
* type: integer
* viewerSessionId:
* type: string
* responses:
* 200:
* description: Viewer unregistered.
* 400:
* description: Invalid hostId or viewerSessionId.
*/
router.post("/metrics/unregister-viewer", (req, res) => {
const userId = actor(res);
if (!userId) return;
const { hostId, viewerSessionId } = (req.body ?? {}) as {
hostId?: unknown;
viewerSessionId?: unknown;
};
if (typeof hostId !== "number" || !hostId) {
return res.status(400).json({ error: "Invalid hostId" });
}
if (typeof viewerSessionId !== "string" || !viewerSessionId) {
return res.status(400).json({ error: "Invalid viewerSessionId" });
}
poller.unregisterViewer(hostId, viewerSessionId, userId);
res.json({ success: true });
});
/**
* @openapi
* /plugin-api/host-metrics/metrics/history/{id}:
* get:
* summary: Get metrics history
* description: Returns stored CPU, memory, disk and network samples for a host over a range.
* tags:
* - Host Metrics
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* - in: query
* name: range
* schema:
* type: string
* enum: [1h, 6h, 24h, 7d, 30d]
* - in: query
* name: from
* schema:
* type: string
* - in: query
* name: to
* schema:
* type: string
* responses:
* 200:
* description: The samples in the range.
* 400:
* description: Invalid range or dates.
* 403:
* description: No access to this host.
*/
router.get("/metrics/history/:id", validateHostId, async (req, res) => {
const hostId = Number(req.params.id);
if (!actor(res)) return;
try {
if (!(await canConnect(hostId))) {
return res.status(403).json({ error: "Access denied" });
}
const { range, from, to } = req.query as Record<
string,
string | undefined
>;
let fromDate: Date;
let toDate = new Date();
if (range) {
const offsetMs = RANGE_OFFSETS[range];
if (!offsetMs) {
return res
.status(400)
.json({ error: "Invalid range. Use 1h, 6h, 24h, 7d, or 30d" });
}
fromDate = new Date(Date.now() - offsetMs);
} else if (from && to) {
fromDate = new Date(from);
toDate = new Date(to);
if (isNaN(fromDate.getTime()) || isNaN(toDate.getTime())) {
return res.status(400).json({ error: "Invalid from/to date format" });
}
} else {
fromDate = new Date(Date.now() - 24 * 60 * 60 * 1000);
}
const rows = (
await repository.listSamples(
hostId,
sqlTimestamp(fromDate),
sqlTimestamp(toDate),
)
).map((row) => ({
ts: row.ts,
cpu_percent: row.cpuPercent,
mem_percent: row.memPercent,
disk_percent: row.diskPercent,
net_rx_bytes: row.netRxBytes,
net_tx_bytes: row.netTxBytes,
}));
res.json({
rows,
fromTs: fromDate.toISOString(),
toTs: toDate.toISOString(),
});
} catch (error) {
log.error("Failed to fetch metrics history", error, {
operation: "metrics_history_fetch_error",
hostId,
});
res.status(500).json({ error: "Failed to fetch metrics history" });
}
});
/**
* @openapi
* /plugin-api/host-metrics/host-metrics/preferences/{id}:
* get:
* summary: Get the Host Metrics layout for a host
* description: Returns the current user's saved card layout for the host, or a default layout from the host's enabled widgets when none is saved.
* tags:
* - Host Metrics
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: The layout for this host.
* 403:
* description: No access to this host.
*/
router.get(
"/host-metrics/preferences/:id",
validateHostId,
async (req, res) => {
const userId = actor(res);
if (!userId) return;
const hostId = Number(req.params.id);
try {
if (!(await canConnect(hostId))) {
return res.status(403).json({ error: "No access to this host" });
}
const saved = await repository.findLayout(userId, hostId);
if (saved) {
try {
const parsed = sanitizeLayout(JSON.parse(saved));
if (parsed) return res.json({ layout: parsed });
} catch {
// fall through to the default
}
}
const settings = await poller.settingsFor(hostId);
res.json({ layout: defaultLayoutFromWidgets(settings.enabledWidgets) });
} catch (error) {
log.error("Failed to fetch host metrics preferences", error, {
operation: "host_metrics_prefs_fetch_error",
hostId,
});
res
.status(500)
.json({ error: "Failed to fetch host metrics preferences" });
}
},
);
/**
* @openapi
* /plugin-api/host-metrics/host-metrics/preferences/{id}:
* post:
* summary: Save the Host Metrics layout for a host
* description: Saves the current user's card layout for the host. For the host's owner it also updates the host's enabled widgets.
* tags:
* - Host Metrics
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* slots:
* type: array
* columns:
* type: integer
* responses:
* 200:
* description: Layout saved.
* 400:
* description: Invalid layout.
* 403:
* description: No access to this host.
*/
router.post(
"/host-metrics/preferences/:id",
validateHostId,
async (req, res) => {
const userId = actor(res);
if (!userId) return;
const hostId = Number(req.params.id);
try {
const access = await ctx.hosts.checkAccess(hostId, "connect");
if (!access.hasAccess) {
return res.status(403).json({ error: "No access to this host" });
}
const layout = sanitizeLayout(req.body);
if (!layout) return res.status(400).json({ error: "Invalid layout" });
await repository.saveLayout(userId, hostId, JSON.stringify(layout));
if (access.isOwner) {
await ctx.settings.setHost(
hostId,
"enabledWidgets",
deriveEnabledWidgets(layout.slots),
);
}
res.json({ success: true });
} catch (error) {
log.error("Failed to save host metrics preferences", error, {
operation: "host_metrics_prefs_save_error",
hostId,
});
res
.status(500)
.json({ error: "Failed to save host metrics preferences" });
}
},
);
registerManagerRoutes(router, {
validateHostId,
log,
repository,
onHealthCheck: deps.onHealthCheck,
runOnHost: async (hostId, level, fn) => {
const userId = ctx.currentActor();
if (!userId) throw new AccessDeniedError("Authentication required");
const access = await ctx.hosts.checkAccess(hostId, level);
if (!access.hasAccess) throw new AccessDeniedError();
const host = await poller.resolve(hostId, userId);
if (!host) throw new AccessDeniedError("Host not found");
return ctx.ssh.withConnection(
host,
{
pool: "stats",
purpose: "metrics",
overrides: { readyTimeout: 60000 },
},
(client) =>
fn(client as never, {
id: host.id,
userId: host.userId,
actorId: userId,
sudoPassword: sudoPasswordOf(host),
enableDocker: !!host.enableDocker,
}),
);
},
});
}
@@ -0,0 +1,140 @@
import type { Client, ConnectConfig } from "ssh2";
import type { PluginSchedule } from "@termix/plugin-sdk/backend";
import type { MetricsLogger } from "./log.js";
/** A connection a person opened from the tab, reused by polling. */
export interface MetricsSession {
client: Client;
isConnected: boolean;
lastActive: number;
activeOperations: number;
hostId: number;
userId: string;
cancelCleanup?: () => void;
}
/** A connection waiting for the TOTP code the person is typing. */
export interface PendingTOTPSession {
client: Client;
finish: (responses: string[]) => void;
config: ConnectConfig;
createdAt: number;
sessionId: string;
hostId: number;
userId: string;
prompts?: Array<{ prompt: string; echo: boolean }>;
totpPromptIndex?: number;
resolvedPassword?: string;
totpAttempts: number;
}
export interface MetricsViewer {
sessionId: string;
userId: string;
hostId: number;
lastHeartbeat: number;
}
const IDLE_MS = 30 * 60 * 1000;
export function sessionKey(hostId: number, userId: string): string {
return `${userId}:${hostId}`;
}
function endQuietly(client: Client): void {
try {
client.end();
} catch {
// expected
}
}
/** Interactive connections, owned by one activation of the plugin. */
export class MetricsSessions {
private readonly sessions = new Map<string, MetricsSession>();
private readonly pending = new Map<string, PendingTOTPSession>();
constructor(
private readonly schedule: PluginSchedule,
private readonly log: MetricsLogger,
) {}
get(key: string): MetricsSession | undefined {
return this.sessions.get(key);
}
/** Stores a ready connection and closes it after 30 idle minutes. */
open(key: string, session: Omit<MetricsSession, "cancelCleanup">): void {
const existing = this.sessions.get(key);
if (existing && existing.client !== session.client) this.close(key);
this.sessions.set(key, { ...session });
this.scheduleCleanup(key);
session.client.once("close", () => {
const current = this.sessions.get(key);
if (current?.client === session.client) {
current.isConnected = false;
current.cancelCleanup?.();
this.sessions.delete(key);
}
});
}
touch(key: string): void {
const session = this.sessions.get(key);
if (!session) return;
session.lastActive = Date.now();
this.scheduleCleanup(key);
}
/** Closes a session, or waits for the operations still using it. */
close(key: string): void {
const session = this.sessions.get(key);
if (!session) return;
if (session.activeOperations > 0) {
this.log.warn(
`Deferring metrics session cleanup - ${session.activeOperations} active operations`,
{ operation: "cleanup_deferred", sessionKey: key },
);
this.scheduleCleanup(key);
return;
}
session.cancelCleanup?.();
this.sessions.delete(key);
endQuietly(session.client);
}
addPending(session: PendingTOTPSession): void {
this.pending.set(session.sessionId, session);
}
getPending(sessionId: string): PendingTOTPSession | undefined {
return this.pending.get(sessionId);
}
dropPending(sessionId: string, endClient = false): void {
const session = this.pending.get(sessionId);
this.pending.delete(sessionId);
if (session && endClient) endQuietly(session.client);
}
dispose(): void {
for (const key of [...this.sessions.keys()]) {
const session = this.sessions.get(key)!;
session.cancelCleanup?.();
endQuietly(session.client);
}
this.sessions.clear();
for (const session of this.pending.values()) endQuietly(session.client);
this.pending.clear();
}
private scheduleCleanup(key: string): void {
const session = this.sessions.get(key);
if (!session) return;
session.cancelCleanup?.();
session.cancelCleanup = this.schedule.after(IDLE_MS, () => {
session.cancelCleanup = undefined;
this.close(key);
});
}
}
+464
View File
@@ -0,0 +1,464 @@
import type { CpuSamples } from "./widgets/cpu-collector.js";
import { createNetworkSamples } from "./widgets/network-collector.js";
export type HostStatus = "online" | "reachable" | "offline";
export class RequestQueue {
private queues = new Map<number, Array<() => Promise<unknown>>>();
private processing = new Set<number>();
private requestTimeout = 60000;
async queueRequest<T>(hostId: number, request: () => Promise<T>): Promise<T> {
return new Promise<T>((resolve, reject) => {
const wrappedRequest = async () => {
try {
const result = await Promise.race<T>([
request(),
new Promise<never>((_, rej) =>
setTimeout(
() =>
rej(
new Error(
`Request timeout after ${this.requestTimeout}ms for host ${hostId}`,
),
),
this.requestTimeout,
),
),
]);
resolve(result);
} catch (error) {
reject(error);
}
};
const queue = this.queues.get(hostId) || [];
queue.push(wrappedRequest);
this.queues.set(hostId, queue);
this.processQueue(hostId);
});
}
private async processQueue(hostId: number): Promise<void> {
if (this.processing.has(hostId)) return;
this.processing.add(hostId);
const queue = this.queues.get(hostId) || [];
while (queue.length > 0) {
const request = queue.shift();
if (request) {
try {
await request();
} catch {
// expected
}
}
}
this.processing.delete(hostId);
const currentQueue = this.queues.get(hostId);
if (currentQueue && currentQueue.length > 0) {
this.processQueue(hostId);
}
}
}
interface CachedMetrics {
data: unknown;
timestamp: number;
hostId: number;
}
export class MetricsCache {
private cache = new Map<number, CachedMetrics>();
private ttl = 30000;
get(hostId: number): unknown | null {
const cached = this.cache.get(hostId);
if (cached && Date.now() - cached.timestamp < this.ttl) {
return cached.data;
}
return null;
}
set(hostId: number, data: unknown): void {
this.cache.set(hostId, {
data,
timestamp: Date.now(),
hostId,
});
}
clear(hostId?: number): void {
if (hostId) {
this.cache.delete(hostId);
} else {
this.cache.clear();
}
}
}
interface AuthFailureRecord {
count: number;
lastFailure: number;
reason: "TOTP" | "AUTH" | "TIMEOUT" | "HOST_KEY";
permanent: boolean;
}
export class AuthFailureTracker {
private failures = new Map<number, AuthFailureRecord>();
private maxRetries = 3;
private backoffBase = 5000;
recordFailure(
hostId: number,
reason: "TOTP" | "AUTH" | "TIMEOUT" | "HOST_KEY",
permanent = false,
): void {
const existing = this.failures.get(hostId);
if (existing) {
existing.count++;
existing.lastFailure = Date.now();
existing.reason = reason;
if (permanent) existing.permanent = true;
} else {
this.failures.set(hostId, {
count: 1,
lastFailure: Date.now(),
reason,
permanent,
});
}
}
shouldSkip(hostId: number): boolean {
const record = this.failures.get(hostId);
if (!record) return false;
if (record.reason === "TOTP" || record.permanent) {
return true;
}
if (record.count >= this.maxRetries) {
return true;
}
const backoffTime = this.backoffBase * Math.pow(2, record.count - 1);
const timeSinceFailure = Date.now() - record.lastFailure;
return timeSinceFailure < backoffTime;
}
getSkipReason(hostId: number): string | null {
const record = this.failures.get(hostId);
if (!record) return null;
if (record.reason === "TOTP") {
return "TOTP authentication required (metrics unavailable)";
}
if (record.reason === "HOST_KEY") {
return "SSH host key changed (verification required)";
}
if (record.permanent) {
return "Authentication permanently failed";
}
if (record.count >= this.maxRetries) {
return `Too many authentication failures (${record.count} attempts)`;
}
const backoffTime = this.backoffBase * Math.pow(2, record.count - 1);
const timeSinceFailure = Date.now() - record.lastFailure;
const remainingTime = Math.ceil((backoffTime - timeSinceFailure) / 1000);
if (timeSinceFailure < backoffTime) {
return `Retry in ${remainingTime}s (attempt ${record.count}/${this.maxRetries})`;
}
return null;
}
resetHostKeyFailure(hostId: number): void {
if (this.failures.get(hostId)?.reason === "HOST_KEY") this.reset(hostId);
}
reset(hostId: number): void {
this.failures.delete(hostId);
}
cleanup(): void {
const maxAge = 60 * 60 * 1000;
const now = Date.now();
for (const [hostId, record] of this.failures.entries()) {
if (!record.permanent && now - record.lastFailure > maxAge) {
this.failures.delete(hostId);
}
}
}
}
export class PollingBackoff {
private failures = new Map<number, { count: number; nextRetry: number }>();
private baseDelay = 30000;
private maxDelay = 600000;
private maxRetries = 5;
recordFailure(hostId: number): void {
const existing = this.failures.get(hostId) || { count: 0, nextRetry: 0 };
const delay = Math.min(
this.baseDelay * Math.pow(2, existing.count),
this.maxDelay,
);
this.failures.set(hostId, {
count: existing.count + 1,
nextRetry: Date.now() + delay,
});
}
shouldSkip(hostId: number): boolean {
const backoff = this.failures.get(hostId);
if (!backoff) return false;
if (backoff.count >= this.maxRetries) {
return true;
}
return Date.now() < backoff.nextRetry;
}
getBackoffInfo(hostId: number): string | null {
const backoff = this.failures.get(hostId);
if (!backoff) return null;
if (backoff.count >= this.maxRetries) {
return `Max retries exceeded (${backoff.count} failures) - polling suspended`;
}
const remainingMs = backoff.nextRetry - Date.now();
if (remainingMs > 0) {
const remainingSec = Math.ceil(remainingMs / 1000);
return `Retry in ${remainingSec}s (attempt ${backoff.count}/${this.maxRetries})`;
}
return null;
}
reset(hostId: number): void {
this.failures.delete(hostId);
}
cleanup(): void {
const maxAge = 60 * 60 * 1000;
const now = Date.now();
for (const [hostId, backoff] of this.failures.entries()) {
if (backoff.count < this.maxRetries && now - backoff.nextRetry > maxAge) {
this.failures.delete(hostId);
}
}
}
}
/**
* Limits how many async jobs run at once. Extra callers wait in FIFO order.
* Used to stop metrics polls from stampeding under load.
*/
export class ConcurrentLimiter {
private active = 0;
private readonly waiters: Array<() => void> = [];
constructor(private maxConcurrent: number) {
if (maxConcurrent < 1) {
throw new Error("maxConcurrent must be >= 1");
}
}
get activeCount(): number {
return this.active;
}
get pendingCount(): number {
return this.waiters.length;
}
get limit(): number {
return this.maxConcurrent;
}
/**
* Changes the ceiling at runtime.
*
* Raising it wakes the waiters the new headroom allows, so a queue that built
* up under the old limit drains at once instead of one job at a time as
* running work finishes. Lowering it never interrupts work already running;
* the new limit simply applies from the next release onward.
*/
setLimit(maxConcurrent: number): void {
if (maxConcurrent < 1) {
throw new Error("maxConcurrent must be >= 1");
}
this.maxConcurrent = maxConcurrent;
this.releaseWaiters();
}
/**
* Wakes as many queued callers as there is now room for.
*
* `woken` counts callers that have been resumed but have not yet reached the
* `active += 1` on the far side of their await. Without it the occupancy
* looks lower than it really is for a microtask, and the loop would release
* past the ceiling.
*/
private releaseWaiters(): void {
while (
this.active + this.woken < this.maxConcurrent &&
this.waiters.length > 0
) {
this.woken += 1;
this.waiters.shift()!();
}
}
private woken = 0;
async run<T>(fn: () => Promise<T>): Promise<T> {
// Queue behind anything already woken, so a caller arriving mid-handoff
// cannot jump the queue and push occupancy past the ceiling.
if (this.active + this.woken >= this.maxConcurrent) {
await new Promise<void>((resolve) => {
this.waiters.push(resolve);
});
// Resumed by a slot handoff; that reservation is now consumed.
this.woken = Math.max(0, this.woken - 1);
}
this.active += 1;
try {
return await fn();
} finally {
this.active -= 1;
this.releaseWaiters();
}
}
}
/**
* How many metrics polls may run at once for a given number of polled hosts.
*
* A metrics poll is an SSH exec, so this cannot simply be unbounded — but the
* old fixed ceiling of 5 meant a sweep of 500 hosts took ~40s against a 30s
* interval, so polling fell permanently behind and a host could wait over a
* minute for a "30 second" metric. Scaling with the fleet keeps a sweep inside
* its interval; the cap keeps file descriptors and CPU bounded.
*/
export const METRICS_CONCURRENCY_ENV = "METRICS_POLL_CONCURRENCY";
const MIN_METRICS_CONCURRENCY = 5;
const MAX_METRICS_CONCURRENCY = 50;
/** Aim to spend about a twentieth of the interval per sweep wave. */
const HOSTS_PER_WORKER = 20;
export function metricsConcurrencyFor(
hostCount: number,
env: NodeJS.ProcessEnv = process.env,
): number {
const override = Number(env[METRICS_CONCURRENCY_ENV]);
if (Number.isFinite(override) && override >= 1) {
return Math.min(Math.floor(override), MAX_METRICS_CONCURRENCY);
}
const scaled = Math.ceil(Math.max(0, hostCount) / HOSTS_PER_WORKER);
return Math.min(
Math.max(scaled, MIN_METRICS_CONCURRENCY),
MAX_METRICS_CONCURRENCY,
);
}
/** Short-lived host snapshots for polling — avoids decrypting host rows every tick. */
export class HostPollCache<THost extends { id: number } = { id: number }> {
private cache = new Map<
number,
{ host: THost; userId: string; expiresAt: number }
>();
constructor(private readonly ttlMs = 30_000) {}
get(hostId: number, userId: string): THost | null {
const entry = this.cache.get(hostId);
if (!entry) return null;
if (entry.userId !== userId || Date.now() >= entry.expiresAt) {
this.cache.delete(hostId);
return null;
}
return entry.host;
}
set(hostId: number, userId: string, host: THost): void {
this.cache.set(hostId, {
host,
userId,
expiresAt: Date.now() + this.ttlMs,
});
}
invalidate(hostId?: number): void {
if (hostId === undefined) {
this.cache.clear();
return;
}
this.cache.delete(hostId);
}
}
/**
* The first heavy SSH sample waits until core's cheap status check says the
* host answers. A host core does not check (status checks off) is tried.
*/
export function canStartInitialMetrics(
status: HostStatus | null | undefined,
hasViewers: boolean,
): boolean {
return hasViewers && status !== "offline";
}
/** Everything one activation of the plugin polls with. */
/** The last CPU and network sample per host, for rates between polls. */
export function createRateSamples() {
const cpu: CpuSamples = new Map();
const network = createNetworkSamples();
return {
cpu,
network,
clear(hostId?: number) {
if (hostId === undefined) {
cpu.clear();
network.counters.clear();
network.windows.clear();
return;
}
cpu.delete(hostId);
network.counters.delete(hostId);
network.windows.delete(hostId);
},
};
}
export function createMetricsState() {
return {
rateSamples: createRateSamples(),
/** SSH metrics execs are expensive; resized with the fleet. */
metricsLimiter: new ConcurrentLimiter(5),
/** Viewer registration is bursty; admit only two first samples at a time. */
initialLimiter: new ConcurrentLimiter(2),
hostCache: new HostPollCache(30_000),
requestQueue: new RequestQueue(),
metricsCache: new MetricsCache(),
authFailures: new AuthFailureTracker(),
backoff: new PollingBackoff(),
};
}
export type MetricsState = ReturnType<typeof createMetricsState>;
+120
View File
@@ -0,0 +1,120 @@
import {
adoptLegacyTable,
boolean,
defineTable,
id,
integer,
real,
refHost,
refUser,
text,
timestamp,
varchar,
} from "@termix/plugin-sdk/db";
/** One card layout per user per host, as JSON. */
export const hostMetricsPreferences = adoptLegacyTable(
"host_metrics_preferences",
defineTable(
"host_metrics_preferences",
{
id: id(),
userId: refUser(),
hostId: refHost(),
layout: text().notNull(),
createdAt: timestamp().notNull().defaultNow(),
updatedAt: timestamp().notNull().defaultNow(),
},
{
uniques: [
{
name: "idx_host_metrics_prefs_user_host",
columns: ["userId", "hostId"],
},
],
},
),
);
/** A user's health checks for a host, as a JSON array. */
export const hostHealthChecks = adoptLegacyTable(
"host_health_checks",
defineTable(
"host_health_checks",
{
id: id(),
userId: refUser(),
hostId: refHost(),
checks: text().notNull(),
intervalSeconds: integer().notNull().default(300),
createdAt: timestamp().notNull().defaultNow(),
updatedAt: timestamp().notNull().defaultNow(),
},
{
uniques: [
{
name: "idx_host_health_checks_user_host",
columns: ["userId", "hostId"],
},
],
},
),
);
export const hostHealthHistory = adoptLegacyTable(
"host_health_history",
defineTable(
"host_health_history",
{
id: id(),
userId: refUser(),
hostId: refHost(),
checkId: varchar().notNull(),
ts: timestamp().notNull().defaultNow(),
ok: boolean().notNull(),
latencyMs: integer(),
detail: text(),
},
{
indexes: [
{
name: "idx_host_health_history_lookup",
columns: ["userId", "hostId", "checkId", "ts"],
},
],
},
),
);
/** One row per metrics sample, pruned to the retention setting. */
export const hostMetricsHistory = adoptLegacyTable(
"host_metrics_history",
defineTable(
"host_metrics_history",
{
id: id(),
hostId: refHost(),
ts: timestamp().notNull().defaultNow(),
cpuPercent: real(),
memPercent: real(),
diskPercent: real(),
netRxBytes: integer(),
netTxBytes: integer(),
},
{
indexes: [
{
name: "idx_host_metrics_history_host_ts",
columns: ["hostId", "ts"],
},
],
},
),
);
export const tables = [
hostMetricsPreferences,
hostHealthChecks,
hostHealthHistory,
hostMetricsHistory,
];
+17
View File
@@ -0,0 +1,17 @@
export function toFixedNum(
n: number | null | undefined,
digits = 2,
): number | null {
if (typeof n !== "number" || !Number.isFinite(n)) return null;
return Number(n.toFixed(digits));
}
export function kibToGiB(kib: number): number {
return kib / (1024 * 1024);
}
export function errorMessage(error: unknown, fallback = "Unknown error") {
if (error instanceof Error && error.message) return error.message;
if (typeof error === "string" && error) return error;
return fallback;
}
@@ -0,0 +1,223 @@
import {
execCommand,
execPowerShell,
type HostPlatform,
} from "@termix/plugin-sdk/host-commands";
import { toFixedNum } from "../util.js";
import type { Client } from "ssh2";
/**
* CPU percent is derived from the delta against the previous poll's
* /proc/stat sample rather than a same-poll double-read, so a poll no longer
* has to block for a fixed settle window. Keyed by host, and owned by the
* poller so it goes away with the plugin.
*/
export type CpuSamples = Map<
number,
{ total: number; idle: number; timestamp: number }
>;
export function parseCpuLine(
cpuLine: string,
): { total: number; idle: number } | undefined {
const parts = cpuLine.trim().split(/\s+/);
if (parts[0] !== "cpu") return undefined;
const nums = parts
.slice(1)
.map((n) => Number(n))
.filter((n) => Number.isFinite(n));
if (nums.length < 4) return undefined;
const idle = (nums[3] ?? 0) + (nums[4] ?? 0);
const total = nums.reduce((a, b) => a + b, 0);
return { total, idle };
}
// Parses macOS `top -l 1 -n 0` output, e.g.
// "CPU usage: 12.34% user, 5.67% sys, 81.99% idle"
export function parseDarwinCpuUsageLine(
line: string,
): { percent: number } | undefined {
const match = line.match(
/([\d.]+)%\s*user,\s*([\d.]+)%\s*sys,\s*([\d.]+)%\s*idle/i,
);
if (!match) return undefined;
const user = Number(match[1]);
const sys = Number(match[2]);
if (!Number.isFinite(user) || !Number.isFinite(sys)) return undefined;
return { percent: Math.max(0, Math.min(100, user + sys)) };
}
async function collectDarwinCpuMetrics(client: Client): Promise<{
percent: number | null;
cores: number | null;
load: [number, number, number] | null;
}> {
let cpuPercent: number | null = null;
let cores: number | null = null;
let loadTriplet: [number, number, number] | null = null;
try {
const [topOut, coresOut, loadOut] = await Promise.race([
Promise.all([
execCommand(client, "top -l 1 -n 0"),
execCommand(client, "sysctl -n hw.ncpu"),
execCommand(client, "sysctl -n vm.loadavg"),
]),
new Promise<never>((_, reject) =>
setTimeout(
() => reject(new Error("CPU metrics collection timeout")),
25000,
),
),
]);
const usageLine = topOut.stdout
.split("\n")
.find((l) => l.includes("CPU usage:"));
const parsed = usageLine ? parseDarwinCpuUsageLine(usageLine) : undefined;
if (parsed) cpuPercent = parsed.percent;
const coresNum = Number((coresOut.stdout || "").trim());
cores = Number.isFinite(coresNum) && coresNum > 0 ? coresNum : null;
// sysctl -n vm.loadavg -> "{ 1.23 1.45 1.67 }"
const loadMatch = loadOut.stdout.match(/([\d.]+)\s+([\d.]+)\s+([\d.]+)/);
if (loadMatch) {
loadTriplet = [
Number(loadMatch[1]),
Number(loadMatch[2]),
Number(loadMatch[3]),
].map((v) => (Number.isFinite(v) ? Number(v) : 0)) as [
number,
number,
number,
];
}
} catch {
cpuPercent = null;
loadTriplet = null;
}
return {
percent: toFixedNum(cpuPercent, 0),
cores,
load: loadTriplet,
};
}
// No POSIX-style load average exists on Windows, so `load` always stays null.
async function collectWindowsCpuMetrics(client: Client): Promise<{
percent: number | null;
cores: number | null;
load: [number, number, number] | null;
}> {
let cpuPercent: number | null = null;
let cores: number | null = null;
try {
const { stdout } = await execPowerShell(
client,
"$cpu=(Get-CimInstance Win32_Processor | Measure-Object -Property LoadPercentage -Average).Average; $cores=(Get-CimInstance Win32_ComputerSystem).NumberOfLogicalProcessors; [PSCustomObject]@{cpu=$cpu; cores=$cores} | ConvertTo-Json -Compress",
25000,
);
const parsed = JSON.parse(stdout.trim());
const cpuNum = Number(parsed?.cpu);
const coresNum = Number(parsed?.cores);
cpuPercent = Number.isFinite(cpuNum)
? Math.max(0, Math.min(100, cpuNum))
: null;
cores = Number.isFinite(coresNum) && coresNum > 0 ? coresNum : null;
} catch {
cpuPercent = null;
}
return {
percent: toFixedNum(cpuPercent, 0),
cores,
load: null,
};
}
export async function collectCpuMetrics(
client: Client,
platform?: HostPlatform,
hostId?: number,
previousStat: CpuSamples = new Map(),
): Promise<{
percent: number | null;
cores: number | null;
load: [number, number, number] | null;
}> {
if (platform === "darwin") {
return collectDarwinCpuMetrics(client);
}
if (platform === "windows") {
return collectWindowsCpuMetrics(client);
}
let cpuPercent: number | null = null;
let cores: number | null = null;
let loadTriplet: [number, number, number] | null = null;
try {
const readAt = Date.now();
const [statOut, loadAvgOut, coresOut] = await Promise.race([
Promise.all([
execCommand(client, "cat /proc/stat"),
execCommand(client, "cat /proc/loadavg"),
execCommand(
client,
"nproc 2>/dev/null || grep -c ^processor /proc/cpuinfo",
),
]),
new Promise<never>((_, reject) =>
setTimeout(
() => reject(new Error("CPU metrics collection timeout")),
25000,
),
),
]);
const cpuLine = (
statOut.stdout.split("\n").find((l) => l.startsWith("cpu ")) || ""
).trim();
const current = parseCpuLine(cpuLine);
const previous =
hostId !== undefined ? previousStat.get(hostId) : undefined;
if (current && previous) {
const totalDiff = current.total - previous.total;
const idleDiff = current.idle - previous.idle;
const used = totalDiff - idleDiff;
if (totalDiff > 0)
cpuPercent = Math.max(0, Math.min(100, (used / totalDiff) * 100));
}
if (current && hostId !== undefined) {
previousStat.set(hostId, { ...current, timestamp: readAt });
}
const laParts = loadAvgOut.stdout.trim().split(/\s+/);
if (laParts.length >= 3) {
loadTriplet = [
Number(laParts[0]),
Number(laParts[1]),
Number(laParts[2]),
].map((v) => (Number.isFinite(v) ? Number(v) : 0)) as [
number,
number,
number,
];
}
const coresNum = Number((coresOut.stdout || "").trim());
cores = Number.isFinite(coresNum) && coresNum > 0 ? coresNum : null;
} catch {
cpuPercent = null;
loadTriplet = null;
}
return {
percent: toFixedNum(cpuPercent, 0),
cores,
load: loadTriplet,
};
}
@@ -0,0 +1,531 @@
import {
execCommand,
execPowerShell,
type HostPlatform,
} from "@termix/plugin-sdk/host-commands";
import { toFixedNum } from "../util.js";
import type { Client } from "ssh2";
const PSEUDO_FS_RE =
/^(tmpfs|devtmpfs|overlay|udev|none|shm|squashfs|efivarfs)$/;
export interface DfRow {
filesystem: string;
type: string;
mount: string;
parts: string[];
}
export interface DiskFilesystem {
filesystem: string;
type: string;
mount: string;
percent: number | null;
usedHuman: string | null;
totalHuman: string | null;
availableHuman: string | null;
usedBytes: number | null;
totalBytes: number | null;
availableBytes: number | null;
label?: string;
}
export interface MonitoredMount {
path: string;
label?: string;
}
function shellQuote(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`;
}
// Parses `df -T -P`-style output: Filesystem, Type, then the size columns,
// with Mounted-on last. The Type column (e.g. ext4, nfs4, cifs) is what lets
// callers filter network shares out by filesystem type rather than guessing
// from the source path.
export function parseDfLines(output: string): DfRow[] {
return output
.split("\n")
.map((l) => l.trim())
.filter(Boolean)
.map((line) => {
const parts = line.split(/\s+/);
return {
filesystem: parts[0] || "",
type: parts[1] || "",
mount: parts[6] || "",
parts,
};
})
.filter((row) => row.parts.length >= 7 && !PSEUDO_FS_RE.test(row.type));
}
// Finds the index of the most-utilized real filesystem in a GNU `df -TkP`-style
// row set (parts[2] = total KB, parts[3] = used KB), so a nearly-full
// secondary mount (e.g. /data) isn't hidden behind a healthy root filesystem.
export function findWorstMountIndex(bytesRows: DfRow[]): {
index: number;
usedBytes: number;
totalBytes: number;
} {
let worstIndex = -1;
let worstUsedBytes = -1;
let worstTotalBytes = 0;
bytesRows.forEach((row, index) => {
const totalBytes = Number(row.parts[2]);
const usedBytes = Number(row.parts[3]);
if (
!Number.isFinite(totalBytes) ||
!Number.isFinite(usedBytes) ||
totalBytes <= 0
) {
return;
}
const usedRatio = usedBytes / totalBytes;
const worstRatio =
worstTotalBytes > 0 ? worstUsedBytes / worstTotalBytes : -1;
if (usedRatio > worstRatio) {
worstIndex = index;
worstUsedBytes = usedBytes;
worstTotalBytes = totalBytes;
}
});
return {
index: worstIndex,
usedBytes: worstUsedBytes,
totalBytes: worstTotalBytes,
};
}
// Merges a GNU `df -TkP` size-row set (parts[2..4] in `blockSizeBytes` units)
// and a `df -hT -P` human-row set into one filesystem list. Byte rows drive
// the maths; human rows only supply the display strings, matched by mount
// point so a mismatched row count can't shift the columns.
export function buildFilesystemList(
bytesRows: DfRow[],
humanRows: DfRow[],
blockSizeBytes = 1,
): DiskFilesystem[] {
const aligned = humanRows.length === bytesRows.length;
return bytesRows
.map((row, index) => {
const totalBytes = Number(row.parts[2]) * blockSizeBytes;
const usedBytes = Number(row.parts[3]) * blockSizeBytes;
const availableBytes = Number(row.parts[4]) * blockSizeBytes;
if (!Number.isFinite(totalBytes) || totalBytes <= 0) return null;
const humanRow = aligned
? humanRows[index]
: humanRows.find((h) => h.mount === row.mount);
const percent = Number.isFinite(usedBytes)
? Math.max(0, Math.min(100, (usedBytes / totalBytes) * 100))
: null;
return {
filesystem: row.filesystem,
type: row.type,
mount: row.mount,
percent: toFixedNum(percent, 0),
usedHuman: humanRow?.parts[3] || null,
totalHuman: humanRow?.parts[2] || null,
availableHuman: humanRow?.parts[4] || null,
usedBytes: Number.isFinite(usedBytes) ? usedBytes : null,
totalBytes,
availableBytes: Number.isFinite(availableBytes) ? availableBytes : null,
};
})
.filter((fs): fs is DiskFilesystem => fs !== null);
}
// The headline disk figure should be the root filesystem - that is what users
// mean by "the server's disk". Only when there is no root mount (containers,
// chroots) do we fall back to the most-utilized mount.
export function selectPrimaryFilesystem(
filesystems: DiskFilesystem[],
): DiskFilesystem | null {
if (filesystems.length === 0) return null;
const root = filesystems.find((fs) => fs.mount === "/");
if (root) return root;
let best = filesystems[0];
for (const fs of filesystems) {
const ratio = (fs.usedBytes ?? 0) / (fs.totalBytes || 1);
const bestRatio = (best.usedBytes ?? 0) / (best.totalBytes || 1);
if (ratio > bestRatio) best = fs;
}
return best;
}
// Excluded mounts are user-configured per host: an exact mount-path match
// (e.g. "/mnt/nas") or a filesystem-type substring match (e.g. "nfs" matches
// nfs/nfs4, "cifs" matches cifs/smb3), so network shares can be dropped from
// the headline percent and the filesystem list without hiding local disks.
export function filterExcludedFilesystems(
filesystems: DiskFilesystem[],
excludedMounts?: string[] | null,
): DiskFilesystem[] {
if (!excludedMounts || excludedMounts.length === 0) return filesystems;
const normalized = excludedMounts
.map((entry) => entry.trim().toLowerCase())
.filter(Boolean);
if (normalized.length === 0) return filesystems;
return filesystems.filter((fs) => {
const mount = fs.mount.toLowerCase();
const type = fs.type.toLowerCase();
return !normalized.some(
(entry) => mount === entry || (type && type.includes(entry)),
);
});
}
export function mergeMonitoredFilesystems(
filesystems: DiskFilesystem[],
monitored: MonitoredMount[],
customFilesystems: Array<DiskFilesystem | null>,
): DiskFilesystem[] {
const result = [...filesystems];
monitored.forEach((entry, index) => {
const path = entry.path.trim();
const custom = customFilesystems[index];
if (!path || !custom) return;
const existing = result.find((fs) => fs.mount === path);
if (existing) {
existing.label = entry.label?.trim() || undefined;
return;
}
result.push({
...custom,
mount: path,
label: entry.label?.trim() || undefined,
});
});
return result;
}
function humanizeBytes(bytes: number): string {
const units = ["B", "K", "M", "G", "T", "P"];
let value = Math.max(0, bytes);
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex++;
}
const rounded =
unitIndex > 0 && value < 10 ? value.toFixed(1) : Math.round(value);
return `${rounded}${units[unitIndex]}`;
}
// BSD/macOS `df -T` takes a filesystem-type argument to *filter* by, unlike
// GNU `df -T` which *prints* a type column - so macOS needs its own command
// (`df -Pk`, no type column) and gets the type separately from `mount`.
const DARWIN_SKIP_MOUNT_RE =
/^\/System\/Volumes\/(VM|Preboot|Update|xarts|iSCPreboot|Hardware)(\/|$)/;
const DARWIN_SKIP_TYPE_RE = /^(devfs|autofs)$/;
export function parseDarwinMountTypes(output: string): Map<string, string> {
const map = new Map<string, string>();
for (const line of output.split("\n")) {
// Device names can contain spaces (e.g. autofs's "map auto_home"), so
// match the literal " on " / " (" separators rather than a single token.
const match = line.match(/^.+? on (.+?) \(([^,)]+)/);
if (match) map.set(match[1], match[2].trim());
}
return map;
}
export function parseDarwinDfRows(
output: string,
typeByMount: Map<string, string>,
): DiskFilesystem[] {
return output
.split("\n")
.map((l) => l.trim())
.filter(Boolean)
.map((line) => {
const parts = line.split(/\s+/);
if (parts.length < 6) return null;
const filesystem = parts[0];
const totalKb = Number(parts[1]);
const usedKb = Number(parts[2]);
const availableKb = Number(parts[3]);
const mount = parts.slice(5).join(" ");
if (!Number.isFinite(totalKb) || totalKb <= 0) return null;
if (DARWIN_SKIP_MOUNT_RE.test(mount)) return null;
const type = typeByMount.get(mount) || "";
if (DARWIN_SKIP_TYPE_RE.test(type)) return null;
const totalBytes = totalKb * 1024;
const usedBytes = Number.isFinite(usedKb) ? usedKb * 1024 : null;
const availableBytes = Number.isFinite(availableKb)
? availableKb * 1024
: null;
const percent =
usedBytes !== null
? Math.max(0, Math.min(100, (usedBytes / totalBytes) * 100))
: null;
return {
filesystem,
type,
mount,
percent: toFixedNum(percent, 0),
usedHuman: usedBytes !== null ? humanizeBytes(usedBytes) : null,
totalHuman: humanizeBytes(totalBytes),
availableHuman:
availableBytes !== null ? humanizeBytes(availableBytes) : null,
usedBytes,
totalBytes,
availableBytes,
};
})
.filter((fs): fs is DiskFilesystem => fs !== null);
}
async function collectDarwinDiskMetrics(
client: Client,
excludedMounts?: string[] | null,
monitoredMounts?: MonitoredMount[] | null,
): Promise<{
percent: number | null;
usedHuman: string | null;
totalHuman: string | null;
availableHuman: string | null;
mount: string | null;
filesystems: DiskFilesystem[];
}> {
try {
const [dfOut, mountOut] = await Promise.all([
execCommand(client, "df -Pk | tail -n +2"),
execCommand(client, "mount"),
]);
const typeByMount = parseDarwinMountTypes(mountOut.stdout);
let detected = parseDarwinDfRows(dfOut.stdout, typeByMount);
const monitored = (monitoredMounts ?? []).filter((entry) =>
Boolean(entry.path.trim()),
);
if (monitored.length > 0) {
const customFilesystems = await Promise.all(
monitored.map(async (entry) => {
const path = shellQuote(entry.path.trim());
try {
const custom = await execCommand(
client,
`df -Pk -- ${path} | tail -n +2`,
);
return parseDarwinDfRows(custom.stdout, typeByMount)[0] ?? null;
} catch {
return null;
}
}),
);
detected = mergeMonitoredFilesystems(
detected,
monitored,
customFilesystems,
);
}
const filesystems = filterExcludedFilesystems(detected, excludedMounts);
const primary = selectPrimaryFilesystem(filesystems);
return {
percent: primary?.percent ?? null,
usedHuman: primary?.usedHuman ?? null,
totalHuman: primary?.totalHuman ?? null,
availableHuman: primary?.availableHuman ?? null,
mount: primary?.mount ?? null,
filesystems,
};
} catch {
return {
percent: null,
usedHuman: null,
totalHuman: null,
availableHuman: null,
mount: null,
filesystems: [],
};
}
}
interface WindowsDiskRow {
drive: string;
total: number;
free: number;
}
const WINDOWS_DISK_SCRIPT =
'Get-CimInstance Win32_LogicalDisk -Filter "DriveType=3" | ForEach-Object {' +
" [PSCustomObject]@{drive=$_.DeviceID; total=$_.Size; free=$_.FreeSpace}" +
" } | ConvertTo-Json -Compress";
export function parseWindowsDiskJson(output: string): WindowsDiskRow[] {
const trimmed = output.trim();
if (!trimmed) return [];
try {
const parsed = JSON.parse(trimmed);
const rows = Array.isArray(parsed) ? parsed : [parsed];
return rows
.filter((row): row is Record<string, unknown> => Boolean(row?.drive))
.map((row) => ({
drive: String(row.drive),
total: Number(row.total),
free: Number(row.free),
}))
.filter((row) => Number.isFinite(row.total) && row.total > 0);
} catch {
return [];
}
}
async function collectWindowsDiskMetrics(
client: Client,
excludedMounts?: string[] | null,
): Promise<{
percent: number | null;
usedHuman: string | null;
totalHuman: string | null;
availableHuman: string | null;
mount: string | null;
filesystems: DiskFilesystem[];
}> {
try {
const { stdout } = await execPowerShell(client, WINDOWS_DISK_SCRIPT);
const rows = parseWindowsDiskJson(stdout);
const filesystems: DiskFilesystem[] = rows.map((row) => {
const usedBytes = row.total - row.free;
const percent = Math.max(0, Math.min(100, (usedBytes / row.total) * 100));
return {
filesystem: row.drive,
type: "NTFS",
mount: row.drive,
percent: toFixedNum(percent, 0),
usedHuman: humanizeBytes(usedBytes),
totalHuman: humanizeBytes(row.total),
availableHuman: humanizeBytes(row.free),
usedBytes,
totalBytes: row.total,
availableBytes: row.free,
};
});
const filtered = filterExcludedFilesystems(filesystems, excludedMounts);
const primary =
filtered.find((fs) => fs.mount.toUpperCase().startsWith("C")) ??
filtered[0] ??
null;
return {
percent: primary?.percent ?? null,
usedHuman: primary?.usedHuman ?? null,
totalHuman: primary?.totalHuman ?? null,
availableHuman: primary?.availableHuman ?? null,
mount: primary?.mount ?? null,
filesystems: filtered,
};
} catch {
return {
percent: null,
usedHuman: null,
totalHuman: null,
availableHuman: null,
mount: null,
filesystems: [],
};
}
}
export async function collectDiskMetrics(
client: Client,
excludedMounts?: string[] | null,
monitoredMounts?: MonitoredMount[] | null,
platform?: HostPlatform,
): Promise<{
percent: number | null;
usedHuman: string | null;
totalHuman: string | null;
availableHuman: string | null;
mount: string | null;
filesystems: DiskFilesystem[];
}> {
if (platform === "windows") {
return collectWindowsDiskMetrics(client, excludedMounts);
}
if (platform === "darwin") {
return collectDarwinDiskMetrics(client, excludedMounts, monitoredMounts);
}
try {
const [diskOutHuman, diskOutBytes] = await Promise.all([
execCommand(client, "df -hT -P | tail -n +2"),
execCommand(client, "df -TkP | tail -n +2"),
]);
const humanRows = parseDfLines(diskOutHuman.stdout);
const bytesRows = parseDfLines(diskOutBytes.stdout);
let detected = buildFilesystemList(bytesRows, humanRows, 1024);
const monitored = (monitoredMounts ?? []).filter((entry) =>
Boolean(entry.path.trim()),
);
if (monitored.length > 0) {
const customFilesystems = await Promise.all(
monitored.map(async (entry) => {
const path = shellQuote(entry.path.trim());
try {
const [customHuman, customBytes] = await Promise.all([
execCommand(client, `df -hT -P -- ${path} | tail -n +2`),
execCommand(client, `df -TkP -- ${path} | tail -n +2`),
]);
return (
buildFilesystemList(
parseDfLines(customBytes.stdout),
parseDfLines(customHuman.stdout),
1024,
)[0] ?? null
);
} catch {
return null;
}
}),
);
detected = mergeMonitoredFilesystems(
detected,
monitored,
customFilesystems,
);
}
const filesystems = filterExcludedFilesystems(detected, excludedMounts);
const primary = selectPrimaryFilesystem(filesystems);
return {
percent: primary?.percent ?? null,
usedHuman: primary?.usedHuman ?? null,
totalHuman: primary?.totalHuman ?? null,
availableHuman: primary?.availableHuman ?? null,
mount: primary?.mount ?? null,
filesystems,
};
} catch {
return {
percent: null,
usedHuman: null,
totalHuman: null,
availableHuman: null,
mount: null,
filesystems: [],
};
}
}
@@ -0,0 +1,254 @@
import { execCommand } from "@termix/plugin-sdk/host-commands";
import type { Client } from "ssh2";
import type {
FirewallMetrics,
FirewallChain,
FirewallRule,
} from "../../shared/stats-widgets.js";
function parseIptablesRule(line: string): FirewallRule | null {
if (!line.startsWith("-A ")) return null;
const rule: FirewallRule = {
chain: "",
target: "",
protocol: "all",
source: "0.0.0.0/0",
destination: "0.0.0.0/0",
};
const chainMatch = line.match(/^-A\s+(\S+)/);
if (chainMatch) {
rule.chain = chainMatch[1];
}
const targetMatch = line.match(/-j\s+(\S+)/);
if (targetMatch) {
rule.target = targetMatch[1];
}
const protocolMatch = line.match(/-p\s+(\S+)/);
if (protocolMatch) {
rule.protocol = protocolMatch[1];
}
const sourceMatch = line.match(/-s\s+(\S+)/);
if (sourceMatch) {
rule.source = sourceMatch[1];
}
const destMatch = line.match(/-d\s+(\S+)/);
if (destMatch) {
rule.destination = destMatch[1];
}
const dportMatch = line.match(/--dport\s+(\S+)/);
if (dportMatch) {
rule.dport = dportMatch[1];
}
const sportMatch = line.match(/--sport\s+(\S+)/);
if (sportMatch) {
rule.sport = sportMatch[1];
}
const stateMatch = line.match(/--state\s+(\S+)/);
if (stateMatch) {
rule.state = stateMatch[1];
}
const interfaceMatch = line.match(/-i\s+(\S+)/);
if (interfaceMatch) {
rule.interface = interfaceMatch[1];
}
return rule;
}
function parseIptablesOutput(output: string): FirewallChain[] {
const chains: Map<string, FirewallChain> = new Map();
const lines = output.split("\n");
for (const line of lines) {
const trimmed = line.trim();
const policyMatch = trimmed.match(/^:(\S+)\s+(\S+)/);
if (policyMatch) {
const [, chainName, policy] = policyMatch;
chains.set(chainName, {
name: chainName,
policy: policy,
rules: [],
});
continue;
}
const rule = parseIptablesRule(trimmed);
if (rule) {
let chain = chains.get(rule.chain);
if (!chain) {
chain = {
name: rule.chain,
policy: "ACCEPT",
rules: [],
};
chains.set(rule.chain, chain);
}
chain.rules.push(rule);
}
}
return Array.from(chains.values());
}
function parseNftablesOutput(output: string): FirewallChain[] {
const chains: FirewallChain[] = [];
let currentChain: FirewallChain | null = null;
const lines = output.split("\n");
for (const line of lines) {
const trimmed = line.trim();
const chainMatch = trimmed.match(
/chain\s+(\S+)\s*\{?\s*(?:type\s+\S+\s+hook\s+(\S+))?/,
);
if (chainMatch) {
if (currentChain) {
chains.push(currentChain);
}
currentChain = {
name: chainMatch[1].toUpperCase(),
policy: "ACCEPT",
rules: [],
};
continue;
}
if (currentChain && trimmed.startsWith("policy ")) {
const policyMatch = trimmed.match(/policy\s+(\S+)/);
if (policyMatch) {
currentChain.policy = policyMatch[1].toUpperCase();
}
continue;
}
if (currentChain && trimmed && !trimmed.startsWith("}")) {
const rule: FirewallRule = {
chain: currentChain.name,
target: "",
protocol: "all",
source: "0.0.0.0/0",
destination: "0.0.0.0/0",
};
if (trimmed.includes("accept")) rule.target = "ACCEPT";
else if (trimmed.includes("drop")) rule.target = "DROP";
else if (trimmed.includes("reject")) rule.target = "REJECT";
const tcpMatch = trimmed.match(/tcp\s+dport\s+(\S+)/);
if (tcpMatch) {
rule.protocol = "tcp";
rule.dport = tcpMatch[1];
}
const udpMatch = trimmed.match(/udp\s+dport\s+(\S+)/);
if (udpMatch) {
rule.protocol = "udp";
rule.dport = udpMatch[1];
}
const saddrMatch = trimmed.match(/saddr\s+(\S+)/);
if (saddrMatch) {
rule.source = saddrMatch[1];
}
const daddrMatch = trimmed.match(/daddr\s+(\S+)/);
if (daddrMatch) {
rule.destination = daddrMatch[1];
}
const iifMatch = trimmed.match(/iif\s+"?(\S+)"?/);
if (iifMatch) {
rule.interface = iifMatch[1].replace(/"/g, "");
}
const ctStateMatch = trimmed.match(/ct\s+state\s+(\S+)/);
if (ctStateMatch) {
rule.state = ctStateMatch[1].toUpperCase();
}
if (rule.target) {
currentChain.rules.push(rule);
}
}
if (trimmed === "}") {
if (currentChain) {
chains.push(currentChain);
currentChain = null;
}
}
}
if (currentChain) {
chains.push(currentChain);
}
return chains;
}
export async function collectFirewallMetrics(
client: Client,
): Promise<FirewallMetrics> {
try {
const iptablesResult = await execCommand(
client,
"iptables-save 2>/dev/null",
15000,
);
if (iptablesResult.stdout && iptablesResult.stdout.includes("*filter")) {
const chains = parseIptablesOutput(iptablesResult.stdout);
const hasRules = chains.some((c) => c.rules.length > 0);
return {
type: "iptables",
status: hasRules ? "active" : "inactive",
chains: chains.filter(
(c) =>
c.name === "INPUT" || c.name === "OUTPUT" || c.name === "FORWARD",
),
};
}
const nftResult = await execCommand(
client,
"nft list ruleset 2>/dev/null",
15000,
);
if (nftResult.stdout && nftResult.stdout.trim()) {
const chains = parseNftablesOutput(nftResult.stdout);
const hasRules = chains.some((c) => c.rules.length > 0);
return {
type: "nftables",
status: hasRules ? "active" : "inactive",
chains,
};
}
return {
type: "none",
status: "unknown",
chains: [],
};
} catch {
return {
type: "none",
status: "unknown",
chains: [],
};
}
}
@@ -0,0 +1,187 @@
import { execCommand } from "@termix/plugin-sdk/host-commands";
import { toFixedNum } from "../util.js";
import type { Client } from "ssh2";
export interface GpuDevice {
index: number;
uuid: string;
name: string;
driverVersion: string | null;
utilizationPercent: number | null;
memoryUsedMiB: number | null;
memoryTotalMiB: number | null;
memoryPercent: number | null;
temperatureCelsius: number | null;
powerDrawWatts: number | null;
powerLimitWatts: number | null;
fanPercent: number | null;
}
export interface GpuProcess {
/** Null when nvidia-smi reports a uuid that matches none of the listed GPUs. */
gpuIndex: number | null;
pid: number;
name: string;
memoryUsedMiB: number | null;
}
export interface GpuMetrics {
source: "nvidia-smi" | "none";
gpus: GpuDevice[];
processes: GpuProcess[];
}
const SECTION_SEPARATOR = "---";
// The free-text name is queried last so commas inside it cannot shift the
// numeric columns; the parsers rejoin everything past the fixed fields.
const GPU_QUERY_FIELDS = [
"index",
"uuid",
"driver_version",
"utilization.gpu",
"memory.used",
"memory.total",
"temperature.gpu",
"power.draw",
"power.limit",
"fan.speed",
"name",
];
const APPS_QUERY_FIELDS = ["gpu_uuid", "pid", "used_memory", "process_name"];
const NVIDIA_SMI_COMMAND = [
"command -v nvidia-smi >/dev/null 2>&1 || exit 0;",
`nvidia-smi --query-gpu=${GPU_QUERY_FIELDS.join(",")} --format=csv,noheader,nounits 2>/dev/null;`,
`echo ${SECTION_SEPARATOR};`,
`nvidia-smi --query-compute-apps=${APPS_QUERY_FIELDS.join(",")} --format=csv,noheader,nounits 2>/dev/null`,
].join(" ");
const NO_GPU: GpuMetrics = { source: "none", gpus: [], processes: [] };
/** Reads a numeric field; nvidia-smi writes "[N/A]", "[Not Supported]" etc. for missing ones. */
function parseReading(raw: string | undefined, digits = 1): number | null {
const value = raw?.trim();
if (!value || value.startsWith("[")) return null;
return toFixedNum(Number(value), digits);
}
function parseInteger(raw: string | undefined): number | null {
const value = raw?.trim();
return value && /^\d+$/.test(value) ? Number(value) : null;
}
function splitCsvRow(line: string, fixedFields: number): string[] | null {
const parts = line.split(",").map((part) => part.trim());
if (parts.length <= fixedFields) return null;
return [...parts.slice(0, fixedFields), parts.slice(fixedFields).join(", ")];
}
export function parseNvidiaSmiGpus(output: string): GpuDevice[] {
const fixedFields = GPU_QUERY_FIELDS.length - 1;
const gpus: GpuDevice[] = [];
for (const line of output.split("\n")) {
const row = splitCsvRow(line, fixedFields);
if (!row) continue;
const [
rawIndex,
uuid,
driverVersion,
utilization,
memoryUsed,
memoryTotal,
temperature,
powerDraw,
powerLimit,
fan,
name,
] = row;
const index = parseInteger(rawIndex);
if (index === null || !uuid) continue;
const memoryUsedMiB = parseReading(memoryUsed, 0);
const memoryTotalMiB = parseReading(memoryTotal, 0);
gpus.push({
index,
uuid,
name,
driverVersion:
driverVersion && !driverVersion.startsWith("[") ? driverVersion : null,
utilizationPercent: parseReading(utilization),
memoryUsedMiB,
memoryTotalMiB,
memoryPercent:
memoryUsedMiB !== null &&
memoryTotalMiB !== null &&
memoryTotalMiB !== 0
? toFixedNum((memoryUsedMiB / memoryTotalMiB) * 100, 1)
: null,
temperatureCelsius: parseReading(temperature),
powerDrawWatts: parseReading(powerDraw),
powerLimitWatts: parseReading(powerLimit),
fanPercent: parseReading(fan),
});
}
return gpus;
}
export function parseNvidiaSmiProcesses(
output: string,
gpus: GpuDevice[],
): GpuProcess[] {
const indexByUuid = new Map(gpus.map((gpu) => [gpu.uuid, gpu.index]));
const processes: GpuProcess[] = [];
for (const line of output.split("\n")) {
const row = splitCsvRow(line, APPS_QUERY_FIELDS.length - 1);
if (!row) continue;
const [uuid, rawPid, memoryUsed, name] = row;
const pid = parseInteger(rawPid);
if (pid === null) continue;
processes.push({
gpuIndex: indexByUuid.get(uuid) ?? null,
pid,
name,
memoryUsedMiB: parseReading(memoryUsed, 0),
});
}
return processes;
}
export function parseNvidiaSmiOutput(stdout: string): GpuMetrics {
const lines = stdout.split("\n");
const separator = lines.findIndex(
(line) => line.trim() === SECTION_SEPARATOR,
);
const gpuSection =
separator === -1 ? stdout : lines.slice(0, separator).join("\n");
const appsSection =
separator === -1 ? "" : lines.slice(separator + 1).join("\n");
const gpus = parseNvidiaSmiGpus(gpuSection);
if (gpus.length === 0) return NO_GPU;
return {
source: "nvidia-smi",
gpus,
processes: parseNvidiaSmiProcesses(appsSection, gpus),
};
}
export async function collectGpuMetrics(client: Client): Promise<GpuMetrics> {
try {
// nvidia-smi can take a few seconds on hosts without persistence mode.
const result = await execCommand(client, NVIDIA_SMI_COMMAND, 15000);
return parseNvidiaSmiOutput(result.stdout);
} catch {
// expected when the host has no NVIDIA driver or the query times out
return NO_GPU;
}
}
@@ -0,0 +1,149 @@
import { execCommand } from "@termix/plugin-sdk/host-commands";
import type { Client } from "ssh2";
export interface LoginRecord {
user: string;
ip: string;
time: string;
status: "success" | "failed";
}
export interface LoginStats {
recentLogins: LoginRecord[];
failedLogins: LoginRecord[];
totalLogins: number;
uniqueIPs: number;
}
export async function collectLoginStats(client: Client): Promise<LoginStats> {
const recentLogins: LoginRecord[] = [];
const failedLogins: LoginRecord[] = [];
const ipSet = new Set<string>();
try {
const lastOut = await execCommand(
client,
"last -n 20 -F -w | grep -v 'reboot' | grep -v 'wtmp' | head -20",
);
const lastLines = lastOut.stdout
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
for (const line of lastLines) {
const parts = line.split(/\s+/);
if (parts.length >= 10) {
const user = parts[0];
const tty = parts[1];
const ip =
parts[2] === ":" || parts[2].startsWith(":") ? "local" : parts[2];
const timeStart = parts.indexOf(
parts.find((p) => /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)/.test(p)) || "",
);
if (timeStart > 0 && parts.length > timeStart + 4) {
const timeStr = parts.slice(timeStart, timeStart + 5).join(" ");
if (user && user !== "wtmp" && tty !== "system") {
let parsedTime: string;
try {
const date = new Date(timeStr);
parsedTime = isNaN(date.getTime())
? timeStr || "unknown"
: date.toISOString();
} catch {
parsedTime = timeStr || "unknown";
}
recentLogins.push({
user,
ip,
time: parsedTime,
status: "success",
});
if (ip !== "local") {
ipSet.add(ip);
}
}
}
}
}
} catch {
// expected
}
try {
const failedOut = await execCommand(
client,
"grep 'Failed password' /var/log/auth.log 2>/dev/null | tail -10 || grep 'authentication failure' /var/log/secure 2>/dev/null | tail -10 || echo ''",
);
const failedLines = failedOut.stdout
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
for (const line of failedLines) {
let user = "unknown";
let ip = "unknown";
let timeStr = "";
const userMatch = line.match(/for (?:invalid user )?(\S+)/);
if (userMatch) {
user = userMatch[1];
}
const ipMatch = line.match(/from (\d+\.\d+\.\d+\.\d+)/);
if (ipMatch) {
ip = ipMatch[1];
}
const dateMatch = line.match(/^(\w+)\s+(\d+)\s+(\d+:\d+:\d+)/);
if (dateMatch) {
const [, month, day, time] = dateMatch;
const now = new Date();
const currentYear = now.getFullYear();
const candidate = new Date(`${month} ${day}, ${currentYear} ${time}`);
if (!isNaN(candidate.getTime()) && candidate > now) {
// If parsed date is in the future, it's from last year
timeStr = `${month} ${day}, ${currentYear - 1} ${time}`;
} else {
timeStr = `${month} ${day}, ${currentYear} ${time}`;
}
}
if (user && ip) {
let parsedTime: string;
try {
const date = timeStr ? new Date(timeStr) : null;
parsedTime =
date && !isNaN(date.getTime())
? date.toISOString()
: timeStr || "unknown";
} catch {
parsedTime = timeStr || "unknown";
}
failedLogins.push({
user,
ip,
time: parsedTime,
status: "failed",
});
if (ip !== "unknown") {
ipSet.add(ip);
}
}
}
} catch {
// expected
}
return {
recentLogins: recentLogins.slice(0, 10),
failedLogins: failedLogins.slice(0, 10),
totalLogins: recentLogins.length,
uniqueIPs: ipSet.size,
};
}
@@ -0,0 +1,150 @@
import {
execCommand,
execPowerShell,
type HostPlatform,
} from "@termix/plugin-sdk/host-commands";
import { toFixedNum, kibToGiB } from "../util.js";
import type { Client } from "ssh2";
async function collectDarwinMemoryMetrics(client: Client): Promise<{
percent: number | null;
usedGiB: number | null;
totalGiB: number | null;
}> {
let memPercent: number | null = null;
let usedGiB: number | null = null;
let totalGiB: number | null = null;
try {
const [vmStatOut, memSizeOut] = await Promise.all([
execCommand(client, "vm_stat"),
execCommand(client, "sysctl -n hw.memsize"),
]);
const totalBytes = Number((memSizeOut.stdout || "").trim());
const pageSizeMatch = vmStatOut.stdout.match(/page size of (\d+) bytes/);
const pageSize = pageSizeMatch ? Number(pageSizeMatch[1]) : 4096;
const getPages = (key: string): number | null => {
const line = vmStatOut.stdout
.split("\n")
.find((l) => l.trim().startsWith(key));
if (!line) return null;
const m = line.match(/(\d+)\./);
return m ? Number(m[1]) : null;
};
const active = getPages("Pages active");
const wired = getPages("Pages wired down");
const compressed = getPages("Pages occupied by compressor");
if (
Number.isFinite(totalBytes) &&
totalBytes > 0 &&
active !== null &&
wired !== null
) {
const usedPages = active + wired + (compressed ?? 0);
const usedBytes = usedPages * pageSize;
memPercent = Math.max(0, Math.min(100, (usedBytes / totalBytes) * 100));
usedGiB = kibToGiB(usedBytes / 1024);
totalGiB = kibToGiB(totalBytes / 1024);
}
} catch {
memPercent = null;
usedGiB = null;
totalGiB = null;
}
return {
percent: toFixedNum(memPercent, 0),
usedGiB: usedGiB ? toFixedNum(usedGiB, 2) : null,
totalGiB: totalGiB ? toFixedNum(totalGiB, 2) : null,
};
}
// Win32_OperatingSystem reports TotalVisibleMemorySize/FreePhysicalMemory in KB.
async function collectWindowsMemoryMetrics(client: Client): Promise<{
percent: number | null;
usedGiB: number | null;
totalGiB: number | null;
}> {
let memPercent: number | null = null;
let usedGiB: number | null = null;
let totalGiB: number | null = null;
try {
const { stdout } = await execPowerShell(
client,
"$os=Get-CimInstance Win32_OperatingSystem; [PSCustomObject]@{total=$os.TotalVisibleMemorySize; free=$os.FreePhysicalMemory} | ConvertTo-Json -Compress",
);
const parsed = JSON.parse(stdout.trim());
const totalKb = Number(parsed?.total);
const freeKb = Number(parsed?.free);
if (Number.isFinite(totalKb) && Number.isFinite(freeKb) && totalKb > 0) {
const usedKb = totalKb - freeKb;
memPercent = Math.max(0, Math.min(100, (usedKb / totalKb) * 100));
usedGiB = kibToGiB(usedKb);
totalGiB = kibToGiB(totalKb);
}
} catch {
memPercent = null;
usedGiB = null;
totalGiB = null;
}
return {
percent: toFixedNum(memPercent, 0),
usedGiB: usedGiB ? toFixedNum(usedGiB, 2) : null,
totalGiB: totalGiB ? toFixedNum(totalGiB, 2) : null,
};
}
export async function collectMemoryMetrics(
client: Client,
platform?: HostPlatform,
): Promise<{
percent: number | null;
usedGiB: number | null;
totalGiB: number | null;
}> {
if (platform === "darwin") {
return collectDarwinMemoryMetrics(client);
}
if (platform === "windows") {
return collectWindowsMemoryMetrics(client);
}
let memPercent: number | null = null;
let usedGiB: number | null = null;
let totalGiB: number | null = null;
try {
const memInfo = await execCommand(client, "cat /proc/meminfo");
const lines = memInfo.stdout.split("\n");
const getVal = (key: string) => {
const line = lines.find((l) => l.startsWith(key));
if (!line) return null;
const m = line.match(/\d+/);
return m ? Number(m[0]) : null;
};
const totalKb = getVal("MemTotal:");
const availKb = getVal("MemAvailable:");
if (totalKb && availKb && totalKb > 0) {
const usedKb = totalKb - availKb;
memPercent = Math.max(0, Math.min(100, (usedKb / totalKb) * 100));
usedGiB = kibToGiB(usedKb);
totalGiB = kibToGiB(totalKb);
}
} catch {
memPercent = null;
usedGiB = null;
totalGiB = null;
}
return {
percent: toFixedNum(memPercent, 0),
usedGiB: usedGiB ? toFixedNum(usedGiB, 2) : null,
totalGiB: totalGiB ? toFixedNum(totalGiB, 2) : null,
};
}
@@ -0,0 +1,416 @@
import {
execCommand,
execPowerShell,
type HostPlatform,
} from "@termix/plugin-sdk/host-commands";
import type { Client } from "ssh2";
export interface NetworkCounters {
rx: string;
tx: string;
}
export function parseNetworkCounters(
output: string,
): Map<string, NetworkCounters> {
const counters = new Map<string, NetworkCounters>();
for (const line of output.split("\n").slice(2)) {
const parts = line.trim().split(/\s+/);
if (parts.length >= 10) {
counters.set(parts[0].replace(":", ""), {
rx: parts[1],
tx: parts[9],
});
}
}
return counters;
}
export function counterRate(
before: string | undefined,
after: string | undefined,
elapsedSeconds: number,
): number | null {
const first = Number(before);
const second = Number(after);
if (
!Number.isFinite(first) ||
!Number.isFinite(second) ||
second < first ||
elapsedSeconds <= 0
) {
return null;
}
return Math.round((second - first) / elapsedSeconds);
}
/**
* Rate is derived from the previous poll's counters rather than a same-poll
* double-sample, so a poll no longer has to block for a fixed settle window.
* Keyed by host, since counters from one host are meaningless against another.
*/
export interface NetworkSamples {
counters: Map<
number,
{ counters: Map<string, NetworkCounters>; timestamp: number }
>;
windows: Map<number, { rows: WindowsAdapterRow[]; timestamp: number }>;
}
export function createNetworkSamples(): NetworkSamples {
return { counters: new Map(), windows: new Map() };
}
export function parseDarwinIfconfig(
output: string,
): Map<string, { ip: string; state: string }> {
const map = new Map<string, { ip: string; state: string }>();
let current: string | null = null;
for (const rawLine of output.split("\n")) {
const ifaceMatch = rawLine.match(/^(\S+):\s*flags=\d+<([^>]*)>/);
if (ifaceMatch) {
current = ifaceMatch[1] === "lo0" ? null : ifaceMatch[1];
if (current) {
const state = ifaceMatch[2].includes("UP") ? "UP" : "DOWN";
map.set(current, { ip: "", state });
}
continue;
}
if (!current) continue;
const inetMatch = rawLine.match(/^\s+inet\s+(\d+\.\d+\.\d+\.\d+)/);
if (inetMatch) {
const existing = map.get(current);
if (existing && !existing.ip) existing.ip = inetMatch[1];
}
}
return map;
}
export function parseDarwinNetstat(
output: string,
): Map<string, NetworkCounters> {
const counters = new Map<string, NetworkCounters>();
for (const line of output.split("\n").slice(1)) {
const parts = line.trim().split(/\s+/);
if (parts.length < 10) continue;
const name = parts[0];
if (name === "lo0" || !parts[2]?.startsWith("<Link")) continue;
if (!counters.has(name)) {
counters.set(name, { rx: parts[6], tx: parts[9] });
}
}
return counters;
}
async function collectDarwinNetworkMetrics(
client: Client,
hostId: number | undefined,
samples: NetworkSamples,
): Promise<{
interfaces: Array<{
name: string;
ip: string;
state: string;
rxBytes: string | null;
txBytes: string | null;
rxRateBps: number | null;
txRateBps: number | null;
}>;
}> {
const interfaces: Array<{
name: string;
ip: string;
state: string;
rxBytes: string | null;
txBytes: string | null;
rxRateBps: number | null;
txRateBps: number | null;
}> = [];
try {
const ifconfigOut = await execCommand(client, "ifconfig -a 2>/dev/null");
const ifMap = parseDarwinIfconfig(ifconfigOut.stdout);
try {
const readAt = Date.now();
const netstatOut = await execCommand(client, "netstat -ib 2>/dev/null");
const current = parseDarwinNetstat(netstatOut.stdout);
const previous =
hostId !== undefined ? samples.counters.get(hostId) : undefined;
const elapsedSeconds = previous
? (readAt - previous.timestamp) / 1000
: 0;
if (ifMap.size === 0) {
for (const name of current.keys()) {
ifMap.set(name, { ip: "", state: "UNKNOWN" });
}
}
for (const [name, data] of ifMap.entries()) {
const c = current.get(name);
const p = previous?.counters.get(name);
interfaces.push({
name,
ip: data.ip,
state: data.state,
rxBytes: c?.rx ?? null,
txBytes: c?.tx ?? null,
rxRateBps: p ? counterRate(p.rx, c?.rx, elapsedSeconds) : null,
txRateBps: p ? counterRate(p.tx, c?.tx, elapsedSeconds) : null,
});
}
if (hostId !== undefined) {
samples.counters.set(hostId, { counters: current, timestamp: readAt });
}
} catch {
for (const [name, data] of ifMap.entries()) {
interfaces.push({
name,
ip: data.ip,
state: data.state,
rxBytes: null,
txBytes: null,
rxRateBps: null,
txRateBps: null,
});
}
}
} catch {
// expected
}
return { interfaces };
}
interface WindowsAdapterRow {
name: string;
ip: string;
state: string;
rx: string;
tx: string;
}
const WINDOWS_ADAPTER_SCRIPT =
"Get-NetAdapter | Where-Object {$_.Status -eq 'Up'} | ForEach-Object {" +
" $stats = Get-NetAdapterStatistics -Name $_.Name -ErrorAction SilentlyContinue;" +
" $addr = (Get-NetIPAddress -InterfaceIndex $_.ifIndex -AddressFamily IPv4 -ErrorAction SilentlyContinue | Select-Object -First 1).IPAddress;" +
" [PSCustomObject]@{name=$_.Name; ip=$addr; state='UP'; rx=$stats.ReceivedBytes; tx=$stats.SentBytes}" +
" } | ConvertTo-Json -Compress";
export function parseWindowsAdapterJson(output: string): WindowsAdapterRow[] {
const trimmed = output.trim();
if (!trimmed) return [];
try {
const parsed = JSON.parse(trimmed);
const rows = Array.isArray(parsed) ? parsed : [parsed];
return rows
.filter((row): row is Record<string, unknown> => Boolean(row?.name))
.map((row) => ({
name: String(row.name),
ip: row.ip ? String(row.ip) : "",
state: String(row.state ?? "UNKNOWN"),
rx: String(row.rx ?? ""),
tx: String(row.tx ?? ""),
}));
} catch {
return [];
}
}
async function collectWindowsNetworkMetrics(
client: Client,
hostId: number | undefined,
samples: NetworkSamples,
): Promise<{
interfaces: Array<{
name: string;
ip: string;
state: string;
rxBytes: string | null;
txBytes: string | null;
rxRateBps: number | null;
txRateBps: number | null;
}>;
}> {
const interfaces: Array<{
name: string;
ip: string;
state: string;
rxBytes: string | null;
txBytes: string | null;
rxRateBps: number | null;
txRateBps: number | null;
}> = [];
try {
const readAt = Date.now();
const result = await execPowerShell(client, WINDOWS_ADAPTER_SCRIPT);
const currentRows = parseWindowsAdapterJson(result.stdout);
const previous =
hostId !== undefined ? samples.windows.get(hostId) : undefined;
const elapsedSeconds = previous ? (readAt - previous.timestamp) / 1000 : 0;
const previousMap = new Map(
(previous?.rows ?? []).map((row) => [row.name, row]),
);
for (const row of currentRows) {
const previousRow = previousMap.get(row.name);
interfaces.push({
name: row.name,
ip: row.ip,
state: row.state,
rxBytes: row.rx || null,
txBytes: row.tx || null,
rxRateBps: previousRow
? counterRate(previousRow.rx, row.rx, elapsedSeconds)
: null,
txRateBps: previousRow
? counterRate(previousRow.tx, row.tx, elapsedSeconds)
: null,
});
}
if (hostId !== undefined) {
samples.windows.set(hostId, {
rows: currentRows,
timestamp: readAt,
});
}
} catch {
// expected
}
return { interfaces };
}
export async function collectNetworkMetrics(
client: Client,
platform?: HostPlatform,
hostId?: number,
samples: NetworkSamples = createNetworkSamples(),
): Promise<{
interfaces: Array<{
name: string;
ip: string;
state: string;
rxBytes: string | null;
txBytes: string | null;
rxRateBps: number | null;
txRateBps: number | null;
}>;
}> {
if (platform === "darwin") {
return collectDarwinNetworkMetrics(client, hostId, samples);
}
if (platform === "windows") {
return collectWindowsNetworkMetrics(client, hostId, samples);
}
const interfaces: Array<{
name: string;
ip: string;
state: string;
rxBytes: string | null;
txBytes: string | null;
rxRateBps: number | null;
txRateBps: number | null;
}> = [];
try {
const ifconfigOut = await execCommand(
client,
"ip -o addr show 2>/dev/null | awk '{print $2,$4}' | grep -v '^lo' || true",
);
const netStatOut = await execCommand(
client,
"ip -o link show 2>/dev/null | awk '{gsub(/:/, \"\", $2); print $2,$9}' || true",
);
const addrs = ifconfigOut.stdout
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
const states = netStatOut.stdout
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
const ifMap = new Map<string, { ip: string; state: string }>();
for (const line of addrs) {
const parts = line.split(/\s+/);
if (parts.length >= 2) {
const name = parts[0];
const ip = parts[1].split("/")[0];
if (!ifMap.has(name)) ifMap.set(name, { ip, state: "UNKNOWN" });
}
}
for (const line of states) {
const parts = line.split(/\s+/);
if (parts.length >= 2) {
const name = parts[0];
if (name === "lo") continue;
const state = parts[1];
const existing = ifMap.get(name);
if (existing) {
existing.state = state;
} else {
ifMap.set(name, { ip: "", state });
}
}
}
try {
const readAt = Date.now();
const procNet = await execCommand(client, "cat /proc/net/dev");
const rxTxMap = parseNetworkCounters(procNet.stdout);
const previous =
hostId !== undefined ? samples.counters.get(hostId) : undefined;
const elapsedSeconds = previous
? (readAt - previous.timestamp) / 1000
: 0;
if (ifMap.size === 0) {
for (const name of rxTxMap.keys()) {
if (name !== "lo") ifMap.set(name, { ip: "", state: "UNKNOWN" });
}
}
for (const [name, data] of ifMap.entries()) {
const rxTx = rxTxMap.get(name);
const previousCounters = previous?.counters.get(name);
interfaces.push({
name,
ip: data.ip,
state: data.state,
rxBytes: rxTx?.rx ?? null,
txBytes: rxTx?.tx ?? null,
rxRateBps: previousCounters
? counterRate(previousCounters.rx, rxTx?.rx, elapsedSeconds)
: null,
txRateBps: previousCounters
? counterRate(previousCounters.tx, rxTx?.tx, elapsedSeconds)
: null,
});
}
if (hostId !== undefined) {
samples.counters.set(hostId, {
counters: rxTxMap,
timestamp: readAt,
});
}
} catch {
for (const [name, data] of ifMap.entries()) {
interfaces.push({
name,
ip: data.ip,
state: data.state,
rxBytes: null,
txBytes: null,
rxRateBps: null,
txRateBps: null,
});
}
}
} catch {
// expected
}
return { interfaces };
}
@@ -0,0 +1,154 @@
import { execCommand } from "@termix/plugin-sdk/host-commands";
import type { Client } from "ssh2";
import type {
PortsMetrics,
ListeningPort,
} from "../../shared/stats-widgets.js";
function parseSsOutput(output: string): ListeningPort[] {
const ports: ListeningPort[] = [];
const lines = output.split("\n").slice(1);
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
const parts = trimmed.split(/\s+/);
if (parts.length < 5) continue;
const protocol = parts[0]?.toLowerCase();
if (protocol !== "tcp" && protocol !== "udp") continue;
const state = parts[1];
const localAddr = parts[4];
if (!localAddr) continue;
const lastColon = localAddr.lastIndexOf(":");
if (lastColon === -1) continue;
const address = localAddr.substring(0, lastColon);
const portStr = localAddr.substring(lastColon + 1);
const port = parseInt(portStr, 10);
if (isNaN(port)) continue;
const portEntry: ListeningPort = {
protocol: protocol as "tcp" | "udp",
localAddress: address.replace(/^\[|\]$/g, ""),
localPort: port,
state: protocol === "tcp" ? state : undefined,
};
const processInfo = parts[6];
if (processInfo && processInfo.startsWith("users:")) {
const pidMatch = processInfo.match(/pid=(\d+)/);
const nameMatch = processInfo.match(/\("([^"]+)"/);
if (pidMatch) portEntry.pid = parseInt(pidMatch[1], 10);
if (nameMatch) portEntry.process = nameMatch[1];
}
ports.push(portEntry);
}
return ports;
}
function parseNetstatOutput(output: string): ListeningPort[] {
const ports: ListeningPort[] = [];
const lines = output.split("\n");
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
const parts = trimmed.split(/\s+/);
if (parts.length < 4) continue;
const proto = parts[0]?.toLowerCase();
if (!proto) continue;
let protocol: "tcp" | "udp";
if (proto.startsWith("tcp")) {
protocol = "tcp";
} else if (proto.startsWith("udp")) {
protocol = "udp";
} else {
continue;
}
const localAddr = parts[3];
if (!localAddr) continue;
const lastColon = localAddr.lastIndexOf(":");
if (lastColon === -1) continue;
const address = localAddr.substring(0, lastColon);
const portStr = localAddr.substring(lastColon + 1);
const port = parseInt(portStr, 10);
if (isNaN(port)) continue;
const portEntry: ListeningPort = {
protocol,
localAddress: address,
localPort: port,
};
if (protocol === "tcp" && parts.length >= 6) {
portEntry.state = parts[5];
}
const pidProgram = parts[parts.length - 1];
if (pidProgram && pidProgram.includes("/")) {
const [pidStr, process] = pidProgram.split("/");
const pid = parseInt(pidStr, 10);
if (!isNaN(pid)) portEntry.pid = pid;
if (process) portEntry.process = process;
}
ports.push(portEntry);
}
return ports;
}
export async function collectPortsMetrics(
client: Client,
): Promise<PortsMetrics> {
try {
const ssResult = await execCommand(client, "ss -tulnp 2>/dev/null", 15000);
if (ssResult.stdout && ssResult.stdout.includes("Local")) {
const ports = parseSsOutput(ssResult.stdout);
return {
source: "ss",
ports: ports.sort((a, b) => a.localPort - b.localPort),
};
}
const netstatResult = await execCommand(
client,
"netstat -tulnp 2>/dev/null",
15000,
);
if (netstatResult.stdout && netstatResult.stdout.includes("Local")) {
const ports = parseNetstatOutput(netstatResult.stdout);
return {
source: "netstat",
ports: ports.sort((a, b) => a.localPort - b.localPort),
};
}
return {
source: "none",
ports: [],
};
} catch {
return {
source: "none",
ports: [],
};
}
}
@@ -0,0 +1,68 @@
import { execCommand } from "@termix/plugin-sdk/host-commands";
import type { Client } from "ssh2";
export async function collectProcessesMetrics(client: Client): Promise<{
total: number | null;
running: number | null;
top: Array<{
pid: string;
user: string;
cpu: string;
mem: string;
command: string;
}>;
}> {
let totalProcesses: number | null = null;
let runningProcesses: number | null = null;
const topProcesses: Array<{
pid: string;
user: string;
cpu: string;
mem: string;
command: string;
}> = [];
try {
const psOut = await execCommand(
client,
"(ps aux --sort=-%cpu 2>/dev/null || ps aux) | head -n 11",
);
const psLines = psOut.stdout
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
if (psLines.length > 1) {
for (let i = 1; i < Math.min(psLines.length, 11); i++) {
const parts = psLines[i].split(/\s+/);
if (parts.length >= 11) {
const cpuVal = Number(parts[2]);
const memVal = Number(parts[3]);
topProcesses.push({
pid: parts[1],
user: parts[0],
cpu: Number.isFinite(cpuVal) ? cpuVal.toString() : "0",
mem: Number.isFinite(memVal) ? memVal.toString() : "0",
command: parts.slice(10).join(" ").substring(0, 50),
});
}
}
}
const procCount = await execCommand(client, "ps aux | wc -l");
const runningCount = await execCommand(client, "ps aux | grep -c ' R '");
const totalCount = Number(procCount.stdout.trim()) - 1;
totalProcesses = Number.isFinite(totalCount) ? totalCount : null;
const runningCount2 = Number(runningCount.stdout.trim());
runningProcesses = Number.isFinite(runningCount2) ? runningCount2 : null;
} catch {
// expected
}
return {
total: totalProcesses,
running: runningProcesses,
top: topProcesses,
};
}
@@ -0,0 +1,69 @@
import {
execCommand,
execPowerShell,
type HostPlatform,
} from "@termix/plugin-sdk/host-commands";
import type { Client } from "ssh2";
async function collectWindowsSystemMetrics(client: Client): Promise<{
hostname: string | null;
kernel: string | null;
os: string | null;
}> {
let hostname: string | null = null;
let kernel: string | null = null;
let os: string | null = null;
try {
const { stdout } = await execPowerShell(
client,
"$os=Get-CimInstance Win32_OperatingSystem; [PSCustomObject]@{hostname=$env:COMPUTERNAME; kernel=[System.Environment]::OSVersion.Version.ToString(); os=$os.Caption} | ConvertTo-Json -Compress",
);
const parsed = JSON.parse(stdout.trim());
hostname = parsed?.hostname ? String(parsed.hostname) : null;
kernel = parsed?.kernel ? String(parsed.kernel) : null;
os = parsed?.os ? String(parsed.os).trim() : null;
} catch {
// expected
}
return { hostname, kernel, os };
}
export async function collectSystemMetrics(
client: Client,
platform?: HostPlatform,
): Promise<{
hostname: string | null;
kernel: string | null;
os: string | null;
}> {
if (platform === "windows") {
return collectWindowsSystemMetrics(client);
}
let hostname: string | null = null;
let kernel: string | null = null;
let os: string | null = null;
try {
const hostnameOut = await execCommand(client, "hostname");
const kernelOut = await execCommand(client, "uname -r");
const osOut = await execCommand(
client,
"command -v sw_vers >/dev/null 2>&1 && echo \"$(sw_vers -productName) $(sw_vers -productVersion)\" || (cat /etc/os-release 2>/dev/null | grep '^PRETTY_NAME=' | cut -d'\"' -f2)",
);
hostname = hostnameOut.stdout.trim() || null;
kernel = kernelOut.stdout.trim() || null;
os = osOut.stdout.trim() || null;
} catch {
// expected
}
return {
hostname,
kernel,
os,
};
}
@@ -0,0 +1,125 @@
import { execCommand } from "@termix/plugin-sdk/host-commands";
import { toFixedNum } from "../util.js";
import type { Client } from "ssh2";
export interface TemperatureSensor {
label: string;
celsius: number;
}
export interface TemperatureMetrics {
source: "sysfs" | "sensors" | "none";
highestCelsius: number | null;
sensors: TemperatureSensor[];
}
function normalizeSensor(
label: string,
celsius: number,
): TemperatureSensor | null {
const cleanLabel = label.trim().replace(/\s+/g, " ");
if (!cleanLabel || !Number.isFinite(celsius)) return null;
return {
label: cleanLabel,
celsius: toFixedNum(celsius, 1) ?? celsius,
};
}
function summarizeSensors(
source: TemperatureMetrics["source"],
sensors: TemperatureSensor[],
): TemperatureMetrics {
const highest = sensors.reduce<number | null>(
(max, sensor) =>
max === null ? sensor.celsius : Math.max(max, sensor.celsius),
null,
);
return {
source: sensors.length > 0 ? source : "none",
highestCelsius:
highest === null ? null : (toFixedNum(highest, 1) ?? highest),
sensors,
};
}
export function parseSysfsThermalOutput(output: string): TemperatureSensor[] {
return output
.split("\n")
.map((line) => {
const [label, rawValue] = line.split("\t");
const value = Number(rawValue);
if (!label || !Number.isFinite(value)) return null;
const celsius = Math.abs(value) >= 1000 ? value / 1000 : value;
return normalizeSensor(label, celsius);
})
.filter((sensor): sensor is TemperatureSensor => sensor !== null);
}
export function parseSensorsOutput(output: string): TemperatureSensor[] {
const seen = new Set<string>();
const sensors: TemperatureSensor[] = [];
let device = "";
for (const line of output.split("\n")) {
const trimmed = line.trim();
if (trimmed && !line.startsWith(" ") && !trimmed.includes(":")) {
device = trimmed;
continue;
}
const match = line.match(/^\s*([^:]+):\s*([+-]?\d+(?:\.\d+)?)\s*°?C\b/i);
if (!match) continue;
const label = device ? `${device}: ${match[1]}` : match[1];
const sensor = normalizeSensor(label, Number(match[2]));
if (!sensor) continue;
const key = `${sensor.label.toLowerCase()}:${sensor.celsius}`;
if (seen.has(key)) continue;
seen.add(key);
sensors.push(sensor);
}
return sensors;
}
export async function collectTemperatureMetrics(
client: Client,
): Promise<TemperatureMetrics> {
try {
const sysfs = await execCommand(
client,
[
"for zone in /sys/class/thermal/thermal_zone*; do",
'[ -r "$zone/temp" ] || continue;',
'label="$(cat "$zone/type" 2>/dev/null || basename "$zone")";',
'value="$(cat "$zone/temp" 2>/dev/null || true)";',
'[ -n "$value" ] && printf "%s\\t%s\\n" "$label" "$value";',
"done",
].join(" "),
10000,
);
const sensors = parseSysfsThermalOutput(sysfs.stdout);
if (sensors.length > 0) {
return summarizeSensors("sysfs", sensors);
}
} catch {
// expected on systems without readable thermal zones
}
try {
const lmSensors = await execCommand(client, "sensors 2>/dev/null", 10000);
const sensors = parseSensorsOutput(lmSensors.stdout);
if (sensors.length > 0) {
return summarizeSensors("sensors", sensors);
}
} catch {
// expected when lm-sensors is not installed
}
return {
source: "none",
highestCelsius: null,
sensors: [],
};
}
@@ -0,0 +1,105 @@
import {
execCommand,
execPowerShell,
type HostPlatform,
} from "@termix/plugin-sdk/host-commands";
import type { Client } from "ssh2";
function formatUptime(uptimeSeconds: number): string {
const days = Math.floor(uptimeSeconds / 86400);
const hours = Math.floor((uptimeSeconds % 86400) / 3600);
const minutes = Math.floor((uptimeSeconds % 3600) / 60);
return `${days}d ${hours}h ${minutes}m`;
}
async function collectDarwinUptimeMetrics(client: Client): Promise<{
seconds: number | null;
formatted: string | null;
}> {
let uptimeSeconds: number | null = null;
let uptimeFormatted: string | null = null;
try {
const bootTimeOut = await execCommand(client, "sysctl -n kern.boottime");
// "{ sec = 1700000000, usec = 123456 } Thu Nov 16 ..."
const match = bootTimeOut.stdout.match(/sec\s*=\s*(\d+)/);
if (match) {
const bootSeconds = Number(match[1]);
if (Number.isFinite(bootSeconds)) {
uptimeSeconds = Math.max(0, Date.now() / 1000 - bootSeconds);
uptimeFormatted = formatUptime(uptimeSeconds);
}
}
} catch {
// expected
}
return {
seconds: uptimeSeconds,
formatted: uptimeFormatted,
};
}
async function collectWindowsUptimeMetrics(client: Client): Promise<{
seconds: number | null;
formatted: string | null;
}> {
let uptimeSeconds: number | null = null;
let uptimeFormatted: string | null = null;
try {
const { stdout } = await execPowerShell(
client,
"$os=Get-CimInstance Win32_OperatingSystem; [PSCustomObject]@{seconds=((Get-Date) - $os.LastBootUpTime).TotalSeconds} | ConvertTo-Json -Compress",
);
const parsed = JSON.parse(stdout.trim());
const seconds = Number(parsed?.seconds);
if (Number.isFinite(seconds)) {
uptimeSeconds = Math.max(0, seconds);
uptimeFormatted = formatUptime(uptimeSeconds);
}
} catch {
// expected
}
return {
seconds: uptimeSeconds,
formatted: uptimeFormatted,
};
}
export async function collectUptimeMetrics(
client: Client,
platform?: HostPlatform,
): Promise<{
seconds: number | null;
formatted: string | null;
}> {
if (platform === "darwin") {
return collectDarwinUptimeMetrics(client);
}
if (platform === "windows") {
return collectWindowsUptimeMetrics(client);
}
let uptimeSeconds: number | null = null;
let uptimeFormatted: string | null = null;
try {
const uptimeOut = await execCommand(client, "cat /proc/uptime");
const uptimeParts = uptimeOut.stdout.trim().split(/\s+/);
if (uptimeParts.length >= 1) {
uptimeSeconds = Number(uptimeParts[0]);
if (Number.isFinite(uptimeSeconds)) {
uptimeFormatted = formatUptime(uptimeSeconds);
}
}
} catch {
// expected
}
return {
seconds: uptimeSeconds,
formatted: uptimeFormatted,
};
}
@@ -0,0 +1,266 @@
import {
Button,
HostDefaultBadge,
Input,
SectionCard,
SettingRow,
FakeSwitch,
useIsDefaultsEditor,
} from "@termix/plugin-sdk/ui";
import { useState } from "react";
import {
useTranslation,
type HostEditorSectionProps,
} from "@termix/plugin-sdk/frontend";
import { HardDrive, LayoutDashboard, Plus, Server, Trash2 } from "lucide-react";
import { readHostMetricsSettings } from "../shared/stats-widgets.js";
const PLUGIN_ID = "host-metrics";
type PluginSettingsBag = Record<string, Record<string, unknown>>;
/**
* The Host Metrics section of the host editor. Its values are this plugin's
* host settings, carried on form.pluginSettings and saved by the editor after
* the host itself.
*/
export function HostStatsTab({ form, updateForm }: HostEditorSectionProps) {
const { t } = useTranslation();
const [newMount, setNewMount] = useState("");
const [newMonitoredPath, setNewMonitoredPath] = useState("");
const [newMonitoredLabel, setNewMonitoredLabel] = useState("");
const bag = (form.pluginSettings as PluginSettingsBag | undefined)?.[
PLUGIN_ID
];
const settings = readHostMetricsSettings(bag);
const { excludedMounts, monitoredMounts } = settings;
const defaultsEditor = useIsDefaultsEditor();
const patch = (values: Record<string, unknown>) =>
updateForm((current) => {
const all =
(current.pluginSettings as PluginSettingsBag | undefined) ?? {};
return {
pluginSettings: {
...all,
[PLUGIN_ID]: { ...(all[PLUGIN_ID] ?? {}), ...values },
},
};
});
const addExcludedMount = () => {
const value = newMount.trim();
if (!value || excludedMounts.includes(value)) {
setNewMount("");
return;
}
patch({ excludedMounts: [...excludedMounts, value] });
setNewMount("");
};
const addMonitoredMount = () => {
const path = newMonitoredPath.trim();
if (!path || monitoredMounts.some((entry) => entry.path === path)) return;
patch({
monitoredMounts: [
...monitoredMounts,
{ path, label: newMonitoredLabel.trim() || undefined },
],
});
setNewMonitoredPath("");
setNewMonitoredLabel("");
};
return (
<>
<SectionCard
title={t("hosts.metricsCollectionLabel")}
icon={<Server className="size-3.5" />}
>
<div className="flex flex-col gap-0 py-1">
<SettingRow
label={t("hosts.enableMetricsLabel")}
description={t("hosts.enableMetricsDesc")}
defaultKey="metricsEnabled"
>
<FakeSwitch
checked={settings.metricsEnabled}
onChange={(v) => patch({ metricsEnabled: v })}
/>
</SettingRow>
{settings.metricsEnabled && (
<SettingRow
label={t("hosts.useGlobalMetrics")}
description={t("hosts.useGlobalMetricsDesc")}
defaultKey="metricsInterval"
>
<FakeSwitch
checked={settings.metricsInterval === null}
onChange={(v) => patch({ metricsInterval: v ? null : 30 })}
/>
</SettingRow>
)}
{settings.metricsEnabled && settings.metricsInterval !== null && (
<SettingRow
label={t("hosts.metricsIntervalS")}
description={t("hosts.metricsIntervalDesc2")}
>
<Input
type="number"
value={settings.metricsInterval ?? 30}
onChange={(e) =>
patch({ metricsInterval: Number(e.target.value) })
}
className="w-20 h-7 text-xs text-right [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
</SettingRow>
)}
</div>
</SectionCard>
<SectionCard
title={t("hosts.monitoredMountsLabel")}
icon={<HardDrive className="size-3.5" />}
>
<div className="flex flex-col gap-3 py-3">
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
{t("hosts.monitoredMountsDesc")}
<HostDefaultBadge settingKey="monitoredMounts" />
</p>
<div className="grid grid-cols-[1fr_0.7fr_auto] gap-2">
<Input
className="h-7 text-xs"
placeholder={t("hosts.monitoredMountPathPlaceholder")}
value={newMonitoredPath}
onChange={(e) => setNewMonitoredPath(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addMonitoredMount();
}
}}
/>
<Input
className="h-7 text-xs"
placeholder={t("hosts.monitoredMountLabelPlaceholder")}
value={newMonitoredLabel}
onChange={(e) => setNewMonitoredLabel(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addMonitoredMount();
}
}}
/>
<Button
variant="outline"
size="sm"
className="h-7 px-2 text-[10px]"
onClick={addMonitoredMount}
>
<Plus className="mr-1 size-3" /> {t("hosts.addMonitoredMount")}
</Button>
</div>
{monitoredMounts.map((entry) => (
<div
key={entry.path}
className="flex items-center gap-2 border border-border bg-muted/20 p-2 group"
>
<span className="min-w-0 flex-1 truncate font-mono text-xs">
{entry.path}
</span>
{entry.label && (
<span className="truncate text-xs text-muted-foreground">
{entry.label}
</span>
)}
<button
className="text-muted-foreground opacity-0 transition-opacity hover:text-destructive group-hover:opacity-100"
onClick={() =>
patch({
monitoredMounts: monitoredMounts.filter(
(mount) => mount.path !== entry.path,
),
})
}
>
<Trash2 className="size-3.5" />
</button>
</div>
))}
</div>
</SectionCard>
<SectionCard
title={t("hosts.excludedMountsLabel")}
icon={<HardDrive className="size-3.5" />}
>
<div className="flex flex-col gap-3 py-3">
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
{t("hosts.excludedMountsDesc")}
<HostDefaultBadge settingKey="excludedMounts" />
</p>
<div className="flex items-center gap-2">
<Input
className="h-7 text-xs flex-1"
placeholder={t("hosts.excludedMountsPlaceholder")}
value={newMount}
onChange={(e) => setNewMount(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addExcludedMount();
}
}}
/>
<Button
variant="outline"
size="sm"
className="h-7 text-[10px] px-2 border-accent-brand/40 text-accent-brand"
onClick={addExcludedMount}
>
<Plus className="size-3 mr-1" /> {t("hosts.addExcludedMount")}
</Button>
</div>
{excludedMounts.length === 0 && (
<div className="flex flex-col items-center justify-center py-4 text-muted-foreground/40 gap-1.5">
<HardDrive className="size-6" />
<span className="text-xs">{t("hosts.excludedMountsEmpty")}</span>
</div>
)}
{excludedMounts.map((mount, i) => (
<div
key={`${mount}-${i}`}
className="flex items-center gap-2 p-2 bg-muted/20 border border-border group"
>
<span className="text-xs flex-1 font-mono truncate">{mount}</span>
<button
className="text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() =>
patch({
excludedMounts: excludedMounts.filter(
(_, idx) => idx !== i,
),
})
}
>
<Trash2 className="size-3.5" />
</button>
</div>
))}
</div>
</SectionCard>
{!defaultsEditor && (
<SectionCard
title={t("hosts.visibleWidgets")}
icon={<LayoutDashboard className="size-3.5" />}
>
<div className="flex flex-col gap-2 py-3">
<p className="text-xs text-muted-foreground">
{t("hosts.widgetsMovedToHostMetrics")}
</p>
</div>
</SectionCard>
)}
</>
);
}
@@ -0,0 +1,51 @@
import { FullScreenAppWrapper, ConnectionScreen } from "@termix/plugin-sdk/ui";
import React from "react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { HostMetricsTab } from "./HostMetricsTab.tsx";
interface HostMetricsAppProps {
hostId?: string;
}
const HostMetricsApp: React.FC<HostMetricsAppProps> = ({ hostId }) => {
const { t } = useTranslation();
return (
<FullScreenAppWrapper hostId={hostId}>
{(hostConfig, phase) => {
if (phase === "loading") {
return (
<div className="relative h-full w-full">
<ConnectionScreen
status="connecting"
message={t("hosts.loadingHost")}
/>
</div>
);
}
if (!hostConfig) {
return (
<div className="relative h-full w-full">
<ConnectionScreen
status="disconnected"
message={t("hosts.hostNotFound")}
/>
</div>
);
}
return (
<HostMetricsTab
hostConfig={hostConfig}
title={hostConfig.name || `${hostConfig.username}@${hostConfig.ip}`}
isVisible={true}
isTopbarOpen={false}
embedded={true}
/>
);
}}
</FullScreenAppWrapper>
);
};
export default HostMetricsApp;
@@ -0,0 +1,655 @@
import {
Button,
ComponentSlot,
TOTPDialog,
useTabsSafe,
ConnectionLogProvider,
useConnectionLog,
ConnectionScreen,
runAdaptivePolling,
CardGridCanvas,
ColumnCountStepper,
type GridCardCatalogEntry,
} from "@termix/plugin-sdk/ui";
import { readHostMetricsSettings } from "../shared/stats-widgets.js";
import {
defaultLayoutFromWidgets,
type HostMetricsLayout,
} from "../shared/host-metrics.js";
import type { ServerMetrics } from "../shared/metrics.js";
/* eslint-disable react-hooks/exhaustive-deps */
import React from "react";
import { useHostMetricsApi } from "./host-metrics-api";
import {
useTranslation,
useSlotContributions,
useConnectionRetry,
useHost,
useHostStatus,
logActivity,
usePluginUiPreferences,
} from "@termix/plugin-sdk/frontend";
import { toast } from "sonner";
import { RefreshCw, Server, LayoutDashboard } from "lucide-react";
import { useHostMetricsPreferences } from "./hooks/useHostMetricsPreferences.ts";
import {
CARD_DEFINITIONS,
IMPLEMENTED_CARD_IDS,
getCardDefinition,
definitionsFromSlot,
defaultColSpanFor,
defaultHeightFor,
type MetricCardHistories,
} from "./cards";
import { appendGpuHistories } from "./cards/gpu-history";
import { metricsChangeKey } from "./metrics-change-key";
const HISTORY_LEN = 30;
type ConnectionLogPayload = Parameters<
ReturnType<typeof useConnectionLog>["addLog"]
>[0];
type ConnectionLogError = Error & {
connectionLogs?: ConnectionLogPayload[];
};
interface HostConfig {
id: number;
name: string;
ip: string;
username: string;
statusCheckEnabled?: boolean;
pluginSettings?: Record<string, Record<string, unknown>>;
authType?: string;
port?: number;
[key: string]: unknown;
}
interface HostMetricsProps {
hostConfig?: HostConfig;
title?: string;
isVisible?: boolean;
isTopbarOpen?: boolean;
embedded?: boolean;
}
function HostMetricsInner({
hostConfig,
title,
isVisible = true,
isTopbarOpen = true,
embedded = false,
}: HostMetricsProps): React.ReactElement {
const { t } = useTranslation();
const metricsApi = useHostMetricsApi();
const { addLog, clearLogs } = useConnectionLog();
const { currentTab, removeTab } = useTabsSafe();
const [serverStatus, setServerStatus] = React.useState<"online" | "offline">(
"offline",
);
const [metrics, setMetrics] = React.useState<ServerMetrics | null>(null);
const [histories, setHistories] = React.useState<MetricCardHistories>({
cpu: [],
memory: [],
disk: [],
gpu: {},
});
const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig);
const [isRefreshing, setIsRefreshing] = React.useState(false);
const [totpRequired, setTotpRequired] = React.useState(false);
const [totpSessionId, setTotpSessionId] = React.useState<string | null>(null);
const [totpPrompt, setTotpPrompt] = React.useState<string>("");
const [isPageVisible, setIsPageVisible] = React.useState(!document.hidden);
const [totpVerified, setTotpVerified] = React.useState(false);
const [viewerSessionId, setViewerSessionId] = React.useState<string | null>(
null,
);
const [editMode, setEditMode] = React.useState(false);
const activityLoggedRef = React.useRef(false);
const activityLoggingRef = React.useRef(false);
const statsConfig = React.useMemo(() => {
const settings = readHostMetricsSettings(
currentHostConfig?.pluginSettings?.["host-metrics"],
);
return { ...settings, metricsInterval: settings.metricsInterval ?? 30 };
}, [currentHostConfig?.pluginSettings]);
const metricsEnabled = statsConfig.metricsEnabled !== false;
const statusCheckEnabled = currentHostConfig?.statusCheckEnabled !== false;
const hostId = currentHostConfig?.id ?? null;
const { layout, setLayout } = useHostMetricsPreferences(hostId);
const { values: metricsPrefs } = usePluginUiPreferences<{
columns: number;
}>();
const effectiveLayout: HostMetricsLayout = React.useMemo(() => {
// A saved layout is user-authored, so the preset never rewrites it -- it
// only decides the shape of the first layout a host gets.
if (layout) return layout;
return defaultLayoutFromWidgets(
statsConfig.enabledWidgets ?? [],
metricsPrefs.columns,
);
}, [layout, statsConfig.enabledWidgets, metricsPrefs.columns]);
// Manager cards other plugins contribute (tailscale's, say), merged in next
// to the ones this plugin ships. A contribution for a slot that never
// arrives (its plugin is off) just never appears here.
const managerContributions = useSlotContributions("host-metrics.managers");
const pluginManagerDefs = React.useMemo(
() => definitionsFromSlot(managerContributions),
[managerContributions],
);
// Only render/keep cards that are implemented (metric cards in Phase A).
const visibleSlots = React.useMemo(
() =>
effectiveLayout.slots.filter((s) =>
getCardDefinition(s.id, pluginManagerDefs),
),
[effectiveLayout.slots, pluginManagerDefs],
);
const cardCatalog: GridCardCatalogEntry[] = React.useMemo(
() => [
...IMPLEMENTED_CARD_IDS.map((id) => ({
id,
label: t(CARD_DEFINITIONS[id].labelKey),
defaultColSpan: defaultColSpanFor(id),
defaultHeight: defaultHeightFor(id),
})),
...Object.values(pluginManagerDefs).map((def) => ({
id: def.id,
label: t(def.labelKey),
defaultColSpan: defaultColSpanFor(def.id),
defaultHeight: defaultHeightFor(def.id),
})),
],
[t, pluginManagerDefs],
);
const cardLabel = React.useCallback(
(id: string) => {
const def = getCardDefinition(id, pluginManagerDefs);
return def ? t(def.labelKey) : id;
},
[t, pluginManagerDefs],
);
const renderCard = React.useCallback(
(id: string) => {
const def = getCardDefinition(id, pluginManagerDefs);
if (!def) return null;
return def.render({ metrics, histories, hostId });
},
[metrics, histories, hostId, pluginManagerDefs],
);
React.useEffect(() => {
const onVis = () => setIsPageVisible(!document.hidden);
document.addEventListener("visibilitychange", onVis);
return () => document.removeEventListener("visibilitychange", onVis);
}, []);
const isActuallyVisible = isVisible && isPageVisible;
React.useEffect(() => {
if (!viewerSessionId || !isActuallyVisible) return;
const interval = setInterval(() => {
metricsApi.heartbeat(viewerSessionId).catch(() => {});
}, 30000);
return () => clearInterval(interval);
}, [viewerSessionId, isActuallyVisible]);
React.useEffect(() => {
if (hostConfig?.id !== currentHostConfig?.id) {
setServerStatus("offline");
setMetrics(null);
setHistories({ cpu: [], memory: [], disk: [], gpu: {} });
}
setCurrentHostConfig(hostConfig);
}, [hostConfig?.id]);
const logServerActivity = async () => {
if (
!currentHostConfig?.id ||
activityLoggedRef.current ||
activityLoggingRef.current
) {
return;
}
activityLoggingRef.current = true;
activityLoggedRef.current = true;
try {
const hostName =
currentHostConfig.name ||
`${currentHostConfig.username}@${currentHostConfig.ip}`;
await logActivity("server_stats", currentHostConfig.id, hostName);
} catch {
activityLoggedRef.current = false;
} finally {
activityLoggingRef.current = false;
}
};
const pushHistory = React.useCallback((data: ServerMetrics) => {
setHistories((prev) => {
const add = (arr: number[], v: number | null | undefined) =>
[...arr, v ?? 0].slice(-HISTORY_LEN);
return {
cpu: add(prev.cpu, data.cpu?.percent),
memory: add(prev.memory, data.memory?.percent),
disk: add(prev.disk, data.disk?.percent),
gpu: appendGpuHistories(prev.gpu, data.gpu?.gpus, HISTORY_LEN),
};
});
}, []);
const handleTOTPSubmit = async (totpCode: string) => {
if (!totpSessionId || !currentHostConfig) return;
try {
const result = await metricsApi.submitTotp(totpSessionId, totpCode);
if (result.success) {
setTotpRequired(false);
setTotpSessionId(null);
setTotpVerified(true);
if (result.viewerSessionId) setViewerSessionId(result.viewerSessionId);
} else {
toast.error(t("hostMetrics.totpFailed"));
}
} catch {
toast.error(t("hostMetrics.totpFailed"));
}
};
const handleTOTPCancel = async () => {
setTotpRequired(false);
if (currentHostConfig?.id) {
await metricsApi.stopMetrics(currentHostConfig.id).catch(() => {});
}
if (currentTab !== null) removeTab(currentTab);
};
// The shell keeps the host list current, including plugin settings.
const latestHost = useHost(hostConfig?.id);
React.useEffect(() => {
if (latestHost) {
setCurrentHostConfig({
...(latestHost as unknown as HostConfig),
id: Number(latestHost.id),
});
}
}, [latestHost]);
// Core's status check, the same one behind the host list's dot.
const hostStatus = useHostStatus(hostId ?? undefined);
React.useEffect(() => {
if (!statusCheckEnabled) {
setServerStatus("offline");
return;
}
if (hostStatus) {
setServerStatus(hostStatus.status === "online" ? "online" : "offline");
}
}, [hostStatus?.status, statusCheckEnabled]);
const stopMetricsPollingRef = React.useRef<(() => void) | null>(null);
const fetchMetrics = React.useCallback(async (): Promise<void> => {
if (!currentHostConfig?.id) return;
if (currentHostConfig.authType === "none") {
toast.error(t("hostMetrics.noneAuthNotSupported"));
if (currentTab !== null) removeTab(currentTab);
throw new Error(t("hostMetrics.noneAuthNotSupported"));
}
if (!totpVerified) {
addLog({
type: "info",
stage: "stats_connecting",
message: `Connecting to ${currentHostConfig.username}@${currentHostConfig.ip}:${currentHostConfig.port}`,
});
const result = await metricsApi.startMetrics(currentHostConfig.id);
result?.connectionLogs?.forEach((log) =>
addLog(log as ConnectionLogPayload),
);
if (result.requires_totp) {
setTotpRequired(true);
setTotpSessionId(result.sessionId || null);
setTotpPrompt(result.prompt || "Verification code");
// The TOTP dialog gates further progress; retry loop pauses via `enabled`.
return;
}
if (result.viewerSessionId) setViewerSessionId(result.viewerSessionId);
}
// The connect above only opens the SSH session; the first real sample is
// collected asynchronously on the backend (status check, then metrics
// exec), so it isn't ready the instant the connection succeeds. Give it
// a few short retries before treating the initial fetch as a failure.
let data = await metricsApi.getMetrics(currentHostConfig.id);
for (let i = 0; !data && i < 5; i++) {
await new Promise((resolve) => setTimeout(resolve, 1500));
data = await metricsApi.getMetrics(currentHostConfig.id);
}
if (!data) {
throw new Error(t("hostMetrics.connectionFailed"));
}
setMetrics(data);
pushHistory(data);
setServerStatus("online");
logServerActivity();
addLog({
type: "success",
stage: "connected",
message: t("terminal.connected"),
});
let signature = metricsChangeKey(data);
const minIntervalMs = statsConfig.metricsInterval * 1000;
stopMetricsPollingRef.current?.();
stopMetricsPollingRef.current = runAdaptivePolling(
async () => {
const next = await metricsApi.getMetrics(currentHostConfig.id);
if (!next) throw new Error(t("hostMetrics.connectionFailed"));
const nextSignature = metricsChangeKey(next);
const changed = nextSignature !== signature;
signature = nextSignature;
setMetrics(next);
pushHistory(next);
return changed;
},
{
minIntervalMs,
maxIntervalMs: Math.min(120_000, minIntervalMs * 6),
stablePollsPerStep: 3,
},
{ runImmediately: false },
);
}, [
currentHostConfig,
totpVerified,
statsConfig.metricsInterval,
addLog,
t,
currentTab,
removeTab,
pushHistory,
metricsApi,
]);
const metricsRetry = useConnectionRetry({
connect: async () => {
try {
await fetchMetrics();
if (!totpRequired) metricsRetry.markConnected();
} catch (error: unknown) {
const logError = error as ConnectionLogError;
if (logError.connectionLogs) {
logError.connectionLogs.forEach((log) => addLog(log));
} else {
addLog({
type: "error",
stage: "connection",
message:
error instanceof Error
? error.message
: t("hostMetrics.connectionFailed"),
});
}
metricsRetry.markFailed();
}
},
enabled:
isPageVisible &&
metricsEnabled &&
!totpRequired &&
!!currentHostConfig?.id,
autoStart: false,
});
const metricsRetryRef = React.useRef(metricsRetry);
metricsRetryRef.current = metricsRetry;
// Connects once per host and stays connected while this tab exists, even
// when the user switches to another tab and back. Only the browser tab
// going into the background (isPageVisible) pauses/resumes it -- switching
// between Termix tabs must not tear down and reconnect the session.
React.useEffect(() => {
if (!metricsEnabled || !currentHostConfig?.id) return;
let cancelled = false;
const stopMetrics = async () => {
stopMetricsPollingRef.current?.();
stopMetricsPollingRef.current = null;
if (currentHostConfig?.id) {
await metricsApi
.stopMetrics(currentHostConfig.id, viewerSessionId || undefined)
.catch(() => {});
}
};
const debounce = setTimeout(() => {
if (cancelled) return;
if (isPageVisible) {
clearLogs();
metricsRetryRef.current.reset();
metricsRetryRef.current.retryNow();
} else {
stopMetrics();
}
}, 500);
return () => {
cancelled = true;
clearTimeout(debounce);
stopMetricsPollingRef.current?.();
stopMetricsPollingRef.current = null;
if (currentHostConfig?.id) {
metricsApi.stopMetrics(currentHostConfig.id).catch(() => {});
}
};
}, [currentHostConfig?.id, isPageVisible, metricsEnabled]);
// After a successful TOTP submit, resume the connect flow immediately.
React.useEffect(() => {
if (totpVerified && !totpRequired) {
metricsRetryRef.current.reset();
metricsRetryRef.current.retryNow();
}
}, [totpVerified, totpRequired]);
const wrapperStyle: React.CSSProperties = embedded
? { opacity: isVisible ? 1 : 0, height: "100%", width: "100%" }
: {
opacity: isVisible ? 1 : 0,
margin: isTopbarOpen ? "74px 17px 8px 8px" : "16px 17px 8px 8px",
height: isTopbarOpen ? "calc(100vh - 82px)" : "calc(100vh - 24px)",
};
const handleRefresh = async () => {
if (!currentHostConfig?.id) return;
if (metricsRetry.status !== "connected") {
metricsRetry.retryNow();
return;
}
try {
setIsRefreshing(true);
const data = await metricsApi.getMetrics(currentHostConfig.id);
if (data) {
setMetrics(data);
pushHistory(data);
}
} catch {
setServerStatus("offline");
} finally {
setIsRefreshing(false);
}
};
const showCards =
metricsEnabled && metricsRetry.status === "connected" && metrics;
const showOffline =
metricsEnabled &&
metricsRetry.status !== "connecting" &&
!metrics &&
serverStatus === "offline";
return (
<div
style={wrapperStyle}
className="relative flex flex-col overflow-hidden"
>
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<div className="flex flex-1 flex-col overflow-x-hidden overflow-y-auto">
{!totpRequired &&
(metricsRetry.status === "connected" || showOffline) && (
<div className="mx-3 mt-3 flex shrink-0 items-center justify-between border border-border bg-card px-3 py-3">
<div className="flex items-center gap-3">
<div className="flex size-10 shrink-0 items-center justify-center border border-border bg-muted">
<Server className="size-5 text-accent-brand" />
</div>
<h1 className="text-lg font-bold md:text-2xl">{title}</h1>
</div>
<div className="flex items-center gap-0">
<ComponentSlot
slotId="host-metrics.toolbar"
props={{
hostId: currentHostConfig?.id,
host: currentHostConfig,
}}
/>
{editMode && (
<>
<ColumnCountStepper
columns={effectiveLayout.columns}
onChange={(columns) =>
setLayout({ ...effectiveLayout, columns })
}
/>
<Button
variant="ghost"
size="sm"
className="ml-2 text-xs text-muted-foreground"
onClick={() =>
setLayout(
defaultLayoutFromWidgets(
statsConfig.enabledWidgets ?? [],
),
)
}
>
{t("hostMetrics.reset")}
</Button>
</>
)}
<Button
variant={editMode ? "default" : "ghost"}
size="icon"
className="ml-1"
title={t("hostMetrics.customize")}
onClick={() => setEditMode((v) => !v)}
>
<LayoutDashboard className="size-4" />
</Button>
<Button
variant="outline"
size="default"
onClick={handleRefresh}
disabled={isRefreshing}
className="ml-1 gap-2 font-semibold"
>
<RefreshCw
className={`size-3.5 ${isRefreshing ? "animate-spin" : ""}`}
/>
{t("hostMetrics.refresh")}
</Button>
</div>
</div>
)}
{editMode && (
<div className="mx-3 mt-3 flex shrink-0 items-center gap-2 border border-dashed border-accent-brand/40 bg-accent-brand/5 px-4 py-2">
<LayoutDashboard className="size-3.5 shrink-0 text-accent-brand" />
<span className="text-xs font-semibold text-accent-brand">
{t("hostMetrics.editModeInstructions")}
</span>
</div>
)}
{showCards && (
<div className="px-3 pt-3 pb-3">
<CardGridCanvas
slots={visibleSlots}
columns={effectiveLayout.columns}
editMode={editMode}
renderCard={renderCard}
cardCatalog={cardCatalog}
cardLabel={cardLabel}
onChange={(slots, columns) =>
setLayout({
slots: slots as HostMetricsLayout["slots"],
columns,
})
}
/>
</div>
)}
</div>
{metricsEnabled && !totpRequired && (
<ConnectionScreen
status={showOffline ? "connected" : metricsRetry.status}
message={t("hostMetrics.connecting")}
attempt={metricsRetry.attempt}
maxAttempts={metricsRetry.maxAttempts}
nextRetryInMs={metricsRetry.nextRetryInMs}
onManualRetry={metricsRetry.retryNow}
logPosition={
metricsRetry.status === "error" ||
metricsRetry.status === "disconnected"
? "top"
: "bottom"
}
emptyState={
showOffline ? (
<div className="text-center opacity-40">
<Server className="mx-auto mb-4 size-16" />
<p className="text-xl font-bold uppercase tracking-widest">
{t("hostMetrics.serverOffline")}
</p>
<p className="text-sm font-semibold">
{t("hostMetrics.cannotFetchMetrics")}
</p>
</div>
) : undefined
}
/>
)}
</div>
<TOTPDialog
isOpen={totpRequired}
prompt={totpPrompt}
onSubmit={handleTOTPSubmit}
onCancel={handleTOTPCancel}
backgroundColor="var(--bg-canvas)"
/>
</div>
);
}
export function HostMetricsTab(props: HostMetricsProps): React.ReactElement {
return (
<ConnectionLogProvider>
<HostMetricsInner {...props} />
</ConnectionLogProvider>
);
}
@@ -0,0 +1,107 @@
import { Select2 } from "@termix/plugin-sdk/ui";
import { useHosts, useTranslation } from "@termix/plugin-sdk/frontend";
import type {
MetricsChartConfig,
MetricsChartMetric,
MetricsChartRange,
WidgetEditFormProps,
} from "../shared/homepage.js";
const METRICS: { id: MetricsChartMetric; label: string }[] = [
{ id: "cpu", label: "CPU" },
{ id: "memory", label: "Memory" },
{ id: "disk", label: "Disk" },
{ id: "net_rx", label: "RX MB/s" },
{ id: "net_tx", label: "TX MB/s" },
];
const RANGES: { id: MetricsChartRange; label: string }[] = [
{ id: "15m", label: "15m" },
{ id: "1h", label: "1h" },
{ id: "6h", label: "6h" },
{ id: "24h", label: "24h" },
];
export function MetricsChartEditForm({
config,
onChange,
}: WidgetEditFormProps<MetricsChartConfig>) {
const { t } = useTranslation();
const { hosts: records } = useHosts();
const hosts = records.map((host) => ({
id: Number(host.id),
name: host.name,
}));
return (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
{t("homepage.host")}
</label>
<Select2
value={config.hostId}
onChange={(e) =>
onChange({ ...config, hostId: Number(e.target.value) })
}
className="h-8 text-xs bg-background border border-border px-2 text-foreground"
>
<option value={0}>{t("homepage.selectHost")}</option>
{hosts.map((h) => (
<option key={h.id} value={h.id}>
{h.name}
</option>
))}
</Select2>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
{t("homepage.chartMetric")}
</label>
<div className="flex flex-wrap gap-1">
{METRICS.map((m) => (
<button
key={m.id}
type="button"
onClick={() => onChange({ ...config, metric: m.id })}
className={`px-2 py-0.5 text-[10px] font-medium border transition-colors ${config.metric === m.id ? "bg-accent-brand border-accent-brand text-white" : "border-border text-muted-foreground hover:border-accent-brand/50"}`}
>
{m.label}
</button>
))}
</div>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
{t("homepage.metricRange")}
</label>
<div className="flex gap-1">
{RANGES.map((r) => (
<button
key={r.id}
type="button"
onClick={() => onChange({ ...config, range: r.id })}
className={`px-2 py-0.5 text-[10px] font-medium border transition-colors ${config.range === r.id ? "bg-accent-brand border-accent-brand text-white" : "border-border text-muted-foreground hover:border-accent-brand/50"}`}
>
{r.label}
</button>
))}
</div>
</div>
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={config.showCurrentValue}
onChange={(e) =>
onChange({ ...config, showCurrentValue: e.target.checked })
}
className="accent-accent-brand"
/>
{t("homepage.showCurrentValue")}
</label>
</div>
);
}
@@ -0,0 +1,243 @@
import { WidgetTitle, runVisibleInterval } from "@termix/plugin-sdk/ui";
import { useEffect, useState, useRef } from "react";
import { TrendingUp } from "lucide-react";
import { useHost, useTranslation } from "@termix/plugin-sdk/frontend";
import { MetricsChartEditForm } from "./MetricsChartEditForm";
import {
GRID_SIZE,
type MetricsChartConfig,
type MetricsChartMetric,
type WidgetComponentProps,
type WidgetDefinition,
} from "../shared/homepage.js";
import { type MetricsHistoryRow, useHostMetricsApi } from "./host-metrics-api";
function getAccentColor(): string {
return (
getComputedStyle(document.documentElement)
.getPropertyValue("--accent-brand")
.trim() || "#f59145"
);
}
function getMetricValue(
row: MetricsHistoryRow,
metric: MetricsChartMetric,
): number | null {
switch (metric) {
case "cpu":
return row.cpu_percent;
case "memory":
return row.mem_percent;
case "disk":
return row.disk_percent;
case "net_rx":
return row.net_rx_bytes != null ? row.net_rx_bytes / 1_000_000 : null;
case "net_tx":
return row.net_tx_bytes != null ? row.net_tx_bytes / 1_000_000 : null;
}
}
function getMetricLabel(
metric: MetricsChartMetric,
t: (k: string) => string,
): string {
switch (metric) {
case "cpu":
return t("homepage.metricCpu");
case "memory":
return t("homepage.metricMemory");
case "disk":
return t("homepage.metricDisk");
case "net_rx":
return "RX MB/s";
case "net_tx":
return "TX MB/s";
}
}
function getMetricUnit(metric: MetricsChartMetric): string {
if (metric === "net_rx" || metric === "net_tx") return " MB/s";
return "%";
}
interface SparklineProps {
values: number[];
width: number;
height: number;
color: string;
}
function Sparkline({ values, width, height, color }: SparklineProps) {
if (values.length < 2) return null;
const max = Math.max(...values, 1);
const min = 0;
const range = max - min;
const pts = values.map((v, i) => {
const x = (i / (values.length - 1)) * width;
const y = height - ((v - min) / range) * height;
return `${x.toFixed(1)},${y.toFixed(1)}`;
});
const polyPts = [`0,${height}`, ...pts, `${width},${height}`].join(" ");
return (
<svg
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
preserveAspectRatio="none"
className="overflow-visible"
>
<defs>
<linearGradient
id={`mg-${color.replace("#", "")}`}
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop offset="0%" stopColor={color} stopOpacity="0.3" />
<stop offset="100%" stopColor={color} stopOpacity="0.02" />
</linearGradient>
</defs>
<polygon points={polyPts} fill={`url(#mg-${color.replace("#", "")})`} />
<polyline
points={pts.join(" ")}
fill="none"
stroke={color}
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function MetricsChartWidget({
widget,
config,
}: WidgetComponentProps<MetricsChartConfig>) {
const { getMetricsHistory } = useHostMetricsApi();
const { t } = useTranslation();
const { hostId, metric, range, showCurrentValue } = config;
const hostName = useHost(hostId || undefined)?.name ?? null;
const [values, setValues] = useState<number[]>([]);
const [loading, setLoading] = useState(true);
const containerRef = useRef<HTMLDivElement>(null);
const [dims, setDims] = useState({ w: 0, h: 0 });
useEffect(() => {
if (!containerRef.current) return;
const obs = new ResizeObserver((entries) => {
const e = entries[0]?.contentRect;
if (e) setDims({ w: Math.floor(e.width), h: Math.floor(e.height) });
});
obs.observe(containerRef.current);
return () => obs.disconnect();
}, []);
const fetchData = () => {
if (!hostId) return;
getMetricsHistory(hostId, { range })
.then((res) => {
const vals = res.rows
.map((r) => getMetricValue(r, metric))
.filter((v): v is number => v != null);
setValues(vals);
setLoading(false);
})
.catch(() => setLoading(false));
};
useEffect(() => {
fetchData();
return runVisibleInterval(() => {
void fetchData();
}, 60_000);
}, [hostId, metric, range]);
const accent = getAccentColor();
const current = values.length > 0 ? values[values.length - 1] : null;
const unit = getMetricUnit(metric);
const label = getMetricLabel(metric, t);
if (!hostId) {
return (
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
{t("homepage.noHostSelected")}
</div>
);
}
return (
<div className="flex flex-col w-full h-full overflow-hidden">
<WidgetTitle title={widget.title} icon={<TrendingUp size={11} />} />
<div className="flex flex-col flex-1 p-2 gap-1 overflow-hidden">
<div className="flex items-center justify-between gap-1 shrink-0">
<div className="flex flex-col min-w-0">
<span className="text-[10px] font-semibold text-foreground truncate">
{hostName ?? `Host #${hostId}`}
</span>
<span className="flex gap-2 text-[9px] text-muted-foreground">
<span className="truncate">{label}</span>
<span className="shrink-0">{range}</span>
</span>
</div>
{showCurrentValue && current != null && (
<span
className="text-sm font-bold tabular-nums shrink-0"
style={{ color: accent }}
>
{metric === "net_rx" || metric === "net_tx"
? current.toFixed(2)
: Math.round(current)}
{unit}
</span>
)}
</div>
<div ref={containerRef} className="flex-1 min-h-0 relative">
{loading ? (
<div className="absolute inset-0 flex items-center justify-center text-[10px] text-muted-foreground/60">
{t("homepage.loading")}
</div>
) : values.length < 2 ? (
<div className="absolute inset-0 flex items-center justify-center text-[10px] text-muted-foreground/60">
{t("homepage.noData")}
</div>
) : (
dims.w > 0 &&
dims.h > 0 && (
<Sparkline
values={values}
width={dims.w}
height={dims.h}
color={accent}
/>
)
)}
</div>
</div>
</div>
);
}
/** Registered by the plugin while it runs. */
export const metricsChartWidget: WidgetDefinition<MetricsChartConfig> = {
id: "metrics_chart",
name: "Metrics Chart",
description: "Historical CPU, memory, disk, or network chart for a host",
category: "monitoring",
icon: <TrendingUp size={14} />,
defaultConfig: {
hostId: 0,
metric: "cpu",
range: "1h",
showCurrentValue: true,
},
defaultSize: { w: GRID_SIZE * 12, h: GRID_SIZE * 6 },
minSize: { w: GRID_SIZE * 6, h: GRID_SIZE * 4 },
components: { view: MetricsChartWidget, editForm: MetricsChartEditForm },
};
export { MetricsChartWidget };
@@ -0,0 +1,269 @@
import { usePermission, useTranslation } from "@termix/plugin-sdk/frontend";
import type { MetricsSummaryStore } from "./summary-store";
import { useMetricsSummary } from "./summary-store";
type MetricKey =
"cpu" | "memory" | "disk" | "uptime" | "system" | "network" | "processes";
function RowBar({ label, value }: { label: string; value: number }) {
const tone =
value >= 90
? ["bg-red-500", "text-red-400"]
: value >= 70
? ["bg-yellow-500", "text-yellow-400"]
: ["bg-accent-brand", "text-accent-brand"];
return (
<div className="flex flex-col gap-0.5 w-16">
<div className="flex items-center justify-between">
<span className="text-[10px] text-muted-foreground">{label}</span>
<span className={`text-[10px] font-bold ${tone[1]}`}>
{value.toFixed(0)}%
</span>
</div>
<div className="h-0.5 bg-muted w-full">
<div className={`h-full ${tone[0]}`} style={{ width: `${value}%` }} />
</div>
</div>
);
}
/** CPU, RAM and disk bars in a dashboard host row ("dashboard.hostRow"). */
export function createDashboardHostMetrics(store: MetricsSummaryStore) {
return function DashboardHostMetrics(props: Record<string, unknown>) {
const { t } = useTranslation();
const allowed = usePermission("use");
const hostId = Number(props.hostId) || null;
const online = props.online === true;
const metrics = useMetricsSummary(store, allowed && online ? hostId : null);
const cpu = metrics?.cpu?.percent ?? null;
const ram = metrics?.memory?.percent ?? null;
const disk = metrics?.disk?.percent ?? null;
if (!allowed) return null;
if (!online || (cpu === null && ram === null && disk === null)) {
return (
<div className="flex items-center gap-3">
{[0, 1, 2].map((slot) => (
<span
key={slot}
className="text-[10px] text-muted-foreground w-16 text-center"
>
-
</span>
))}
</div>
);
}
return (
<div className="flex items-center gap-3">
{cpu !== null && <RowBar label={t("dashboard.cpu")} value={cpu} />}
{ram !== null && <RowBar label={t("dashboard.ram")} value={ram} />}
{disk !== null && (
<RowBar label={t("dashboardTab.disk")} value={disk} />
)}
</div>
);
};
}
function accentColor(): string {
return (
getComputedStyle(document.documentElement)
.getPropertyValue("--accent-brand")
.trim() || "#f59145"
);
}
function WidgetBar({
label,
value,
sublabel,
}: {
label: string;
value: number | null;
sublabel?: string;
}) {
const pct = value ?? 0;
const color = pct > 90 ? "#ef4444" : pct > 70 ? "#f97316" : accentColor();
return (
<div className="flex flex-col gap-0.5">
<div className="flex justify-between text-[10px] text-muted-foreground">
<span>{label}</span>
<span className="text-right">
{value != null ? `${Math.round(pct)}%` : "N/A"}
{sublabel && <span className="ml-1 opacity-60">{sublabel}</span>}
</span>
</div>
<div className="h-1 bg-muted overflow-hidden">
<div
className="h-full transition-all"
style={{ width: `${pct}%`, background: color }}
/>
</div>
</div>
);
}
function InfoRow({
label,
value,
}: {
label: string;
value: string | null | undefined;
}) {
if (!value) return null;
return (
<div className="flex justify-between text-[10px] gap-2">
<span className="text-muted-foreground shrink-0">{label}</span>
<span className="text-foreground truncate text-right">{value}</span>
</div>
);
}
function formatBytes(n: number): string {
if (n >= 1073741824) return `${(n / 1073741824).toFixed(1)} GB`;
if (n >= 1048576) return `${(n / 1048576).toFixed(1)} MB`;
if (n >= 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${n} B`;
}
/** The numbers under core's homepage host status widget ("homepage.hostMetrics"). */
export function createHomepageHostMetrics(store: MetricsSummaryStore) {
return function HomepageHostMetrics(props: Record<string, unknown>) {
const { t } = useTranslation();
const allowed = usePermission("use");
const hostId = Number(props.hostId) || null;
const shown = (
Array.isArray(props.shownMetrics) ? props.shownMetrics : []
) as MetricKey[];
const online = props.online === true;
const metrics = useMetricsSummary(store, allowed ? hostId : null);
if (!allowed || shown.length === 0) return null;
const interfaces = metrics?.network?.interfaces ?? [];
const sum = (pick: (iface: (typeof interfaces)[number]) => unknown) =>
interfaces.reduce((total, iface) => {
const raw = pick(iface);
return raw ? total + parseFloat(String(raw)) : total;
}, 0);
const totalRx = sum((iface) => iface.rxBytes);
const totalTx = sum((iface) => iface.txBytes);
return (
<div className="flex flex-col gap-2">
{shown.includes("cpu") && (
<WidgetBar
label={t("homepage.metricCpu")}
value={metrics?.cpu?.percent ?? null}
sublabel={metrics?.cpu?.cores ? `${metrics.cpu.cores}c` : undefined}
/>
)}
{shown.includes("memory") && (
<WidgetBar
label={t("homepage.metricMemory")}
value={metrics?.memory?.percent ?? null}
sublabel={
metrics?.memory?.usedGiB != null &&
metrics?.memory?.totalGiB != null
? `${metrics.memory.usedGiB.toFixed(1)}/${metrics.memory.totalGiB.toFixed(1)} GiB`
: undefined
}
/>
)}
{shown.includes("disk") && (
<WidgetBar
label={t("homepage.metricDisk")}
value={metrics?.disk?.percent ?? null}
sublabel={
metrics?.disk?.usedHuman && metrics?.disk?.totalHuman
? `${metrics.disk.usedHuman}/${metrics.disk.totalHuman}`
: undefined
}
/>
)}
{shown.includes("uptime") && (
<InfoRow
label={t("homepage.metricUptime")}
value={metrics?.uptime?.formatted ?? null}
/>
)}
{shown.includes("system") && metrics?.system && (
<div className="flex flex-col gap-0.5 border-t border-border/40 pt-1.5 mt-0.5">
<InfoRow
label={t("homepage.metricOs")}
value={metrics.system.os ?? null}
/>
<InfoRow
label={t("homepage.metricKernel")}
value={metrics.system.kernel ?? null}
/>
<InfoRow
label={t("homepage.metricHostname")}
value={metrics.system.hostname ?? null}
/>
</div>
)}
{shown.includes("network") && interfaces.length > 0 && (
<div className="flex flex-col gap-0.5 border-t border-border/40 pt-1.5 mt-0.5">
<div className="text-[10px] font-medium text-muted-foreground mb-0.5">
{t("homepage.metricNetwork")}
</div>
{interfaces.slice(0, 3).map((iface) => (
<div
key={iface.name}
className="flex justify-between text-[10px] gap-2"
>
<span className="text-muted-foreground shrink-0 truncate max-w-[60px]">
{iface.name}
</span>
<span className="text-foreground">
{iface.ip || iface.state}
</span>
</div>
))}
{(totalRx > 0 || totalTx > 0) && (
<div className="flex justify-between text-[10px] gap-2 mt-0.5">
<span className="text-muted-foreground">RX/TX</span>
<span className="text-foreground">
{formatBytes(totalRx)} / {formatBytes(totalTx)}
</span>
</div>
)}
</div>
)}
{shown.includes("processes") && metrics?.processes && (
<div className="flex flex-col gap-0.5 border-t border-border/40 pt-1.5 mt-0.5">
<div className="flex justify-between text-[10px]">
<span className="text-muted-foreground">
{t("homepage.metricProcesses")}
</span>
<span className="text-foreground">
{metrics.processes.total ?? "?"}{" "}
{t("homepage.metricProcessesTotal")}
{metrics.processes.running != null
? `, ${metrics.processes.running} ${t("homepage.metricProcessesRunning")}`
: ""}
</span>
</div>
{metrics.processes.top?.slice(0, 3).map((proc) => (
<div
key={proc.pid}
className="flex justify-between text-[10px] gap-2"
>
<span className="text-muted-foreground truncate max-w-[80px]">
{proc.command}
</span>
<span className="text-foreground shrink-0">{proc.cpu}%</span>
</div>
))}
</div>
)}
{!metrics && online && (
<span className="text-[10px] text-muted-foreground/60 italic">
{t("homepage.metricsNotAvailable")}
</span>
)}
</div>
);
};
}
@@ -0,0 +1,198 @@
import { useEffect, useState } from "react";
import {
usePluginApi,
usePluginApiFor,
useTranslation,
} from "@termix/plugin-sdk/frontend";
import {
cn,
getPollingEnvironmentMultiplier,
resolveConnectionOrigin,
resolveRemoteHostId,
runAdaptivePolling,
} from "@termix/plugin-sdk/ui";
/** What the ssh-terminal plugin hands a "terminal.toolbarStatus" component. */
interface TerminalToolbarStatusProps {
host: {
id: number | string;
connectionOrigin?: "local" | "remote" | null;
syncId?: string | null;
};
isConnected: boolean;
/** Visible on a desktop viewport and connected: poll only while true. */
active: boolean;
}
interface Sample {
cpu: number | null;
memory: number | null;
disk: number | null;
}
function StatBar({
label,
percent,
}: {
label: string;
percent: number | null;
}) {
const value = percent == null ? null : Math.max(0, Math.min(100, percent));
return (
<div className="flex items-center gap-1.5 px-1.5">
<span className="w-12 shrink-0 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
{label}
</span>
<div className="h-1.5 w-12 shrink-0 overflow-hidden rounded-full bg-muted">
{value != null && (
<div
className={cn(
"h-full rounded-full transition-all",
value >= 90
? "bg-red-500"
: value >= 70
? "bg-yellow-500"
: "bg-accent-brand",
)}
style={{ width: `${value}%` }}
/>
)}
</div>
<span className="w-7 shrink-0 text-right text-[10px] tabular-nums text-muted-foreground">
{value == null ? "--" : `${Math.round(value)}%`}
</span>
</div>
);
}
/** CPU, memory and disk bars in the terminal toolbar's expanded view. */
export function TerminalMetricsStatus(props: Record<string, unknown>) {
const { host, active } = props as unknown as TerminalToolbarStatusProps;
const { t } = useTranslation();
const localApi = usePluginApi();
const remoteApi = usePluginApiFor("remote");
const [metrics, setMetrics] = useState<Sample | null>(null);
const [available, setAvailable] = useState(true);
const hostId = host?.id ? Number(host.id) : null;
useEffect(() => {
if (!active || !hostId) {
setMetrics(null);
return;
}
let cancelled = false;
let stopPolling: (() => void) | undefined;
let viewerSessionId: string | undefined;
let previous: Sample | null = null;
// A host on the desktop app's remote server has its own id there.
let api = localApi;
let targetId: number = hostId;
setAvailable(true);
const poll = async () => {
try {
const response = await api.get<{
cpu?: { percent?: number };
memory?: { percent?: number };
disk?: { percent?: number };
} | null>(`/metrics/${targetId}`, {
validateStatus: (status: number) => status === 200 || status === 404,
});
const data = response.data;
if (cancelled || !data) return;
const next: Sample = {
cpu: data.cpu?.percent ?? null,
memory: data.memory?.percent ?? null,
disk: data.disk?.percent ?? null,
};
const changed =
!previous ||
(["cpu", "memory", "disk"] as const).some((key) => {
const before = previous?.[key];
const now = next[key];
return before == null || now == null || Math.abs(now - before) >= 2;
});
previous = next;
setMetrics(next);
return changed;
} catch {
// Keep the previous sample after a transient polling error.
return false;
}
};
const start = async () => {
try {
if ((await resolveConnectionOrigin(host)) === "remote") {
const remoteId = await resolveRemoteHostId(host.syncId);
if (cancelled) return;
if (remoteId === null) {
setAvailable(false);
return;
}
api = remoteApi;
targetId = remoteId;
}
const { data } = await api.post<{
requires_totp?: boolean;
viewerSessionId?: string;
}>(`/metrics/start/${targetId}`);
if (cancelled) {
if (data.viewerSessionId) {
void api
.post(`/metrics/stop/${targetId}`, {
viewerSessionId: data.viewerSessionId,
})
.catch(() => {});
}
return;
}
if (data.requires_totp) {
setAvailable(false);
return;
}
viewerSessionId = data.viewerSessionId;
stopPolling = runAdaptivePolling(
poll,
{
minIntervalMs: 5_000,
maxIntervalMs: 20_000,
stablePollsPerStep: 2,
maxRequestDutyCycle: 0.2,
},
{ intervalMultiplier: getPollingEnvironmentMultiplier },
);
} catch {
if (!cancelled) setAvailable(false);
}
};
void start();
return () => {
cancelled = true;
stopPolling?.();
void api
.post(`/metrics/stop/${targetId}`, { viewerSessionId })
.catch(() => {});
};
}, [active, hostId, localApi, remoteApi]); // eslint-disable-line react-hooks/exhaustive-deps
if (!available) {
return (
<span role="status" className="px-2 py-1 text-xs text-muted-foreground">
{t("terminalStatus.unavailable")}
</span>
);
}
return (
<>
<StatBar label={t("hostMetrics.cpu")} percent={metrics?.cpu ?? null} />
<StatBar
label={t("hostMetrics.memory")}
percent={metrics?.memory ?? null}
/>
<StatBar label={t("hostMetrics.disk")} percent={metrics?.disk ?? null} />
</>
);
}
@@ -0,0 +1,34 @@
import { cn } from "@termix/plugin-sdk/ui";
import { useTranslation } from "@termix/plugin-sdk/frontend";
export type HistoryTab = "live" | "1h" | "6h" | "24h" | "7d";
const TABS: HistoryTab[] = ["live", "1h", "6h", "24h", "7d"];
export function CardTimeTabs({
value,
onChange,
}: {
value: HistoryTab;
onChange: (tab: HistoryTab) => void;
}) {
const { t } = useTranslation();
return (
<div className="flex items-center gap-0.5">
{TABS.map((tab) => (
<button
key={tab}
onClick={() => onChange(tab)}
className={cn(
"px-2 py-0.5 text-[10px] font-semibold uppercase tracking-widest transition-colors border",
value === tab
? "border-accent-brand bg-accent-brand/10 text-accent-brand"
: "border-transparent text-muted-foreground hover:text-foreground",
)}
>
{tab === "live" ? t("hostMetrics.tabLive") : tab}
</button>
))}
</div>
);
}
@@ -0,0 +1,128 @@
import {
RadialGauge,
Sparkline,
MiniStat,
MetricCard,
LineChart,
type LineChartSeries,
} from "@termix/plugin-sdk/ui";
import { type ServerMetrics } from "../../shared/metrics.js";
import { useState, useEffect } from "react";
import { Cpu } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { type MetricsHistoryRow, useHostMetricsApi } from "../host-metrics-api";
import { CardTimeTabs, type HistoryTab } from "./CardTimeTabs";
export function CpuCard({
metrics,
history,
hostId,
}: {
metrics: ServerMetrics | null;
history: number[];
hostId: number | null;
}) {
const { getMetricsHistory } = useHostMetricsApi();
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<HistoryTab>("live");
const [rows, setRows] = useState<MetricsHistoryRow[] | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (activeTab === "live" || hostId == null) return;
let cancelled = false;
setLoading(true);
setRows(null);
getMetricsHistory(hostId, { range: activeTab })
.then((res) => {
if (!cancelled) {
setRows(res.rows);
setLoading(false);
}
})
.catch(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [activeTab, hostId]);
const percent = metrics?.cpu?.percent ?? null;
const cores = metrics?.cpu?.cores ?? null;
const load = metrics?.cpu?.load ?? null;
const series: LineChartSeries[] = [
{
key: "cpu",
label: t("hostMetrics.cpuUsage"),
color: "var(--accent-brand)",
data: rows?.map((r) => r.cpu_percent) ?? [],
},
];
const timestamps = rows?.map((r) => r.ts) ?? [];
return (
<MetricCard
title={t("hostMetrics.cpuUsage")}
icon={<Cpu className="size-3.5" />}
action={
hostId != null ? (
<CardTimeTabs value={activeTab} onChange={setActiveTab} />
) : undefined
}
>
<div className="flex flex-col gap-3">
{activeTab === "live" && (
<div className="flex items-center gap-4">
<RadialGauge
value={percent}
caption={
cores !== null
? t("hostMetrics.cpuCores", { count: cores })
: undefined
}
/>
<div className="flex min-w-0 flex-1 flex-col gap-2">
{load && (
<div className="grid grid-cols-3 gap-2">
<MiniStat caption="1m" value={load[0].toFixed(2)} />
<MiniStat caption="5m" value={load[1].toFixed(2)} />
<MiniStat caption="15m" value={load[2].toFixed(2)} />
</div>
)}
<Sparkline
data={[...history, percent ?? 0]}
domain={[0, 100]}
height={48}
/>
</div>
</div>
)}
{activeTab !== "live" && (
<>
{loading && (
<div className="flex h-36 items-center justify-center text-xs text-muted-foreground">
{t("common.loading")}
</div>
)}
{!loading && rows !== null && rows.length === 0 && (
<div className="flex h-36 items-center justify-center text-xs text-muted-foreground">
{t("metricsHistory.noData")}
</div>
)}
{!loading && rows !== null && rows.length > 0 && (
<LineChart
series={series}
timestamps={timestamps}
domain={[0, 100]}
yFormatter={(v) => `${v.toFixed(0)}%`}
height={160}
/>
)}
</>
)}
</div>
</MetricCard>
);
}
@@ -0,0 +1,170 @@
import {
RadialGauge,
Sparkline,
MiniStat,
MetricCard,
LineChart,
type LineChartSeries,
} from "@termix/plugin-sdk/ui";
import { type ServerMetrics } from "../../shared/metrics.js";
import { useState, useEffect, useMemo } from "react";
import { HardDrive } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { FilesystemPicker } from "./FilesystemPicker";
import { type MetricsHistoryRow, useHostMetricsApi } from "../host-metrics-api";
import { CardTimeTabs, type HistoryTab } from "./CardTimeTabs";
export function DiskCard({
metrics,
history,
hostId,
}: {
metrics: ServerMetrics | null;
history: number[];
hostId: number | null;
}) {
const { getMetricsHistory } = useHostMetricsApi();
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<HistoryTab>("live");
const [rows, setRows] = useState<MetricsHistoryRow[] | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (activeTab === "live" || hostId == null) return;
let cancelled = false;
setLoading(true);
setRows(null);
getMetricsHistory(hostId, { range: activeTab })
.then((res) => {
if (!cancelled) {
setRows(res.rows);
setLoading(false);
}
})
.catch(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [activeTab, hostId]);
const filesystems = useMemo(
() => metrics?.disk?.filesystems ?? [],
[metrics?.disk?.filesystems],
);
const defaultMount = metrics?.disk?.mount ?? null;
const [selectedMount, setSelectedMount] = useState<string | null>(null);
// Drop a stale selection if the mount disappears between polls, so the card
// falls back to the primary filesystem instead of rendering nothing.
useEffect(() => {
if (
selectedMount !== null &&
filesystems.length > 0 &&
!filesystems.some((fs) => fs.mount === selectedMount)
) {
setSelectedMount(null);
}
}, [filesystems, selectedMount]);
const activeMount = selectedMount ?? defaultMount;
const activeFs = filesystems.find((fs) => fs.mount === activeMount) ?? null;
const isCustomMount = selectedMount !== null && activeFs !== null;
const percent = isCustomMount
? activeFs.percent
: (metrics?.disk?.percent ?? null);
const usedHuman = isCustomMount
? activeFs.usedHuman
: (metrics?.disk?.usedHuman ?? null);
const totalHuman = isCustomMount
? activeFs.totalHuman
: (metrics?.disk?.totalHuman ?? null);
const availableHuman = isCustomMount
? activeFs.availableHuman
: (metrics?.disk?.availableHuman ?? null);
const series: LineChartSeries[] = [
{
key: "disk",
label: t("hostMetrics.diskUsage"),
color: "var(--chart-4)",
data: rows?.map((r) => r.disk_percent) ?? [],
},
];
const timestamps = rows?.map((r) => r.ts) ?? [];
return (
<MetricCard
title={t("hostMetrics.diskUsage")}
icon={<HardDrive className="size-3.5" />}
action={
<div className="flex items-center gap-1.5">
{activeTab === "live" && (
<FilesystemPicker
filesystems={filesystems}
value={activeMount}
onChange={setSelectedMount}
/>
)}
{hostId != null && (
<CardTimeTabs value={activeTab} onChange={setActiveTab} />
)}
</div>
}
>
<div className="flex flex-col gap-3">
{activeTab === "live" && (
<div className="flex items-center gap-4">
<RadialGauge value={percent} />
<div className="flex min-w-0 flex-1 flex-col gap-2">
<div className="grid grid-cols-2 gap-2">
<MiniStat
caption={activeMount ?? t("hostMetrics.disk")}
value={
usedHuman && totalHuman
? `${usedHuman}/${totalHuman}`
: "N/A"
}
/>
<MiniStat
caption={t("hostMetrics.available")}
value={availableHuman ?? "N/A"}
/>
</div>
<Sparkline
data={[...history, percent ?? 0]}
domain={[0, 100]}
height={48}
/>
</div>
</div>
)}
{activeTab !== "live" && (
<>
{loading && (
<div className="flex h-36 items-center justify-center text-xs text-muted-foreground">
{t("common.loading")}
</div>
)}
{!loading && rows !== null && rows.length === 0 && (
<div className="flex h-36 items-center justify-center text-xs text-muted-foreground">
{t("metricsHistory.noData")}
</div>
)}
{!loading && rows !== null && rows.length > 0 && (
<LineChart
series={series}
timestamps={timestamps}
domain={[0, 100]}
yFormatter={(v) => `${v.toFixed(0)}%`}
height={160}
/>
)}
</>
)}
</div>
</MetricCard>
);
}
@@ -0,0 +1,82 @@
import {
cn,
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from "@termix/plugin-sdk/ui";
import { type DiskFilesystem } from "../../shared/metrics.js";
import { ChevronDown, Check } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
export function FilesystemPicker({
filesystems,
value,
onChange,
align = "end",
className,
}: {
filesystems: DiskFilesystem[];
value: string | null;
onChange: (mount: string) => void;
align?: "start" | "end";
className?: string;
}) {
const { t } = useTranslation();
if (filesystems.length <= 1) return null;
const selected = filesystems.find((fs) => fs.mount === value);
return (
<DropdownMenu>
<DropdownMenuTrigger
className={cn(
"flex max-w-[160px] items-center gap-1 border border-border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground transition-colors hover:text-foreground",
className,
)}
title={t("hostMetrics.selectFilesystem")}
>
<span className="truncate">
{selected?.label || selected?.mount || value || "-"}
</span>
<ChevronDown className="size-3 shrink-0" />
</DropdownMenuTrigger>
<DropdownMenuContent
align={align}
className="w-auto min-w-[220px] max-w-[min(360px,calc(100vw-2rem))]"
>
{filesystems.map((fs) => (
<DropdownMenuItem
key={fs.mount}
onSelect={() => onChange(fs.mount)}
className="gap-3"
>
<Check
className={cn(
"size-3 shrink-0",
fs.mount === value ? "opacity-100" : "opacity-0",
)}
/>
<span className="min-w-0 flex-1 truncate font-medium">
{fs.label || fs.mount}
</span>
{fs.label && (
<span className="max-w-24 truncate font-mono text-[10px] text-muted-foreground">
{fs.mount}
</span>
)}
<span className="ml-auto flex shrink-0 gap-2 whitespace-nowrap text-[10px] tabular-nums text-muted-foreground">
<span>
{fs.usedHuman && fs.totalHuman
? `${fs.usedHuman}/${fs.totalHuman}`
: "N/A"}
</span>
{fs.percent != null && <span>{fs.percent}%</span>}
</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -0,0 +1,56 @@
import { MetricCard } from "@termix/plugin-sdk/ui";
import { type ServerMetrics } from "../../shared/metrics.js";
import { ShieldCheck } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
export function FirewallCard({ metrics }: { metrics: ServerMetrics | null }) {
const { t } = useTranslation();
const fw = metrics?.firewall;
const totalRules =
fw?.chains?.reduce((sum, c) => sum + (c.rules?.length ?? 0), 0) ?? 0;
return (
<MetricCard
title={t("hostMetrics.firewall.title")}
icon={<ShieldCheck className="size-3.5" />}
>
{!fw || fw.type === "none" ? (
<span className="text-xs text-muted-foreground">
{t("hostMetrics.firewall.noData")}
</span>
) : (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<span
className={`text-xs font-semibold uppercase tracking-wide ${
fw.status === "active"
? "text-accent-brand"
: "text-muted-foreground"
}`}
>
{fw.status === "inactive"
? t("hostMetrics.firewall.inactive")
: fw.status}
</span>
<span className="font-mono text-xs text-muted-foreground">
{fw.type}
</span>
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span>
<span className="font-bold text-foreground">
{fw.chains?.length ?? 0}
</span>{" "}
{t("hostMetrics.firewall.chains")}
</span>
<span>
<span className="font-bold text-foreground">{totalRules}</span>{" "}
{t("hostMetrics.firewall.rules")}
</span>
</div>
</div>
)}
</MetricCard>
);
}
@@ -0,0 +1,210 @@
import {
MiniStat,
RadialGauge,
Sparkline,
StatRow,
MetricCard,
} from "@termix/plugin-sdk/ui";
import { type ServerMetrics } from "../../shared/metrics.js";
import { type GpuDevice, type GpuProcess } from "../../shared/stats-widgets.js";
import { useId } from "react";
import { Gpu } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import type { GpuHistories, GpuHistory } from "./gpu-history";
const NA = "N/A";
const EMPTY_HISTORY: GpuHistory = { utilization: [], memory: [] };
function formatGiB(mib: number | null): string {
return mib === null ? NA : (mib / 1024).toFixed(1);
}
function formatVram(gpu: GpuDevice): string {
return gpu.memoryUsedMiB === null || gpu.memoryTotalMiB === null
? NA
: `${formatGiB(gpu.memoryUsedMiB)}/${formatGiB(gpu.memoryTotalMiB)}G`;
}
function formatWhole(value: number | null, unit: string): string {
return value === null ? NA : `${Math.round(value)}${unit}`;
}
function formatPower(gpu: GpuDevice): string {
if (gpu.powerDrawWatts === null && gpu.powerLimitWatts === null) return NA;
return `${formatWhole(gpu.powerDrawWatts, "")}/${formatWhole(gpu.powerLimitWatts, "")}W`;
}
function processVram(process: GpuProcess): string {
const vram =
process.memoryUsedMiB === null
? NA
: `${formatGiB(process.memoryUsedMiB)}G`;
return process.gpuIndex === null ? vram : `${vram} (GPU ${process.gpuIndex})`;
}
function executableName(path: string): string {
return path.split(/[\\/]/).pop() || path;
}
/** A captioned 0-100% sparkline, so the two trends under each GPU are told apart. */
function TrendSparkline({
caption,
data,
colorClassName,
}: {
caption: string;
data: number[];
colorClassName?: string;
}) {
const captionId = useId();
return (
<figure aria-labelledby={captionId} className="flex min-w-0 flex-col gap-1">
<figcaption
id={captionId}
className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground"
>
{caption}
</figcaption>
<Sparkline
data={data}
domain={[0, 100]}
height={36}
colorClassName={colorClassName}
/>
</figure>
);
}
function GpuDeviceRow({
gpu,
history,
}: {
gpu: GpuDevice;
history: GpuHistory;
}) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-2">
<div className="flex min-w-0 items-baseline gap-1.5 text-xs">
<span className="font-mono text-muted-foreground">#{gpu.index}</span>
<span className="truncate font-semibold">{gpu.name}</span>
</div>
<div className="flex items-center gap-4">
<RadialGauge
value={gpu.utilizationPercent}
size={96}
caption={t("hostMetrics.gpu.utilization")}
/>
<div className="flex min-w-0 flex-1 flex-col gap-2">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
<MiniStat
caption={t("hostMetrics.gpu.memory")}
value={formatVram(gpu)}
/>
<MiniStat
caption={t("hostMetrics.gpu.temperature")}
value={formatWhole(gpu.temperatureCelsius, "°C")}
/>
<MiniStat
caption={t("hostMetrics.gpu.power")}
value={formatPower(gpu)}
/>
<MiniStat
caption={t("hostMetrics.gpu.fan")}
value={formatWhole(gpu.fanPercent, "%")}
/>
</div>
<div className="grid grid-cols-2 gap-2">
<TrendSparkline
caption={t("hostMetrics.gpu.utilization")}
data={[...history.utilization, gpu.utilizationPercent ?? 0]}
/>
<TrendSparkline
caption={t("hostMetrics.gpu.memory")}
data={[...history.memory, gpu.memoryPercent ?? 0]}
colorClassName="text-muted-foreground"
/>
</div>
</div>
</div>
</div>
);
}
export function GpuCard({
metrics,
gpuHistories,
}: {
metrics: ServerMetrics | null;
gpuHistories: GpuHistories;
}) {
const { t } = useTranslation();
const gpus = metrics?.gpu?.gpus ?? [];
const processes = metrics?.gpu?.processes ?? [];
const driverVersion = gpus[0]?.driverVersion;
return (
<MetricCard
title={t("hostMetrics.gpu.title")}
icon={<Gpu className="size-3.5" />}
action={
driverVersion ? (
<span className="font-mono text-[10px] text-muted-foreground">
{t("hostMetrics.gpu.driver", { version: driverVersion })}
</span>
) : undefined
}
scroll={gpus.length > 2 || processes.length > 6}
scrollMax={520}
>
{gpus.length === 0 ? (
<span className="text-xs text-muted-foreground">
{t("hostMetrics.gpu.noDevice")}
</span>
) : (
<div className="flex flex-col gap-4">
{gpus.map((gpu) => (
<GpuDeviceRow
key={gpu.index}
gpu={gpu}
history={gpuHistories[gpu.index] ?? EMPTY_HISTORY}
/>
))}
<div className="flex flex-col">
<span className="mb-1 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hostMetrics.gpu.processes")}
</span>
{processes.length === 0 ? (
<span className="text-xs text-muted-foreground">
{t("hostMetrics.gpu.noProcesses")}
</span>
) : (
<div className="flex flex-col divide-y divide-border">
{processes.map((process) => (
<StatRow
key={`${process.gpuIndex}-${process.pid}`}
label={
<span className="flex items-baseline gap-1.5">
<span title={process.name} className="text-foreground">
{executableName(process.name)}
</span>
<span className="font-mono text-[10px]">
{process.pid}
</span>
</span>
}
value={processVram(process)}
mono
/>
))}
</div>
)}
</div>
</div>
)}
</MetricCard>
);
}
@@ -0,0 +1,62 @@
import { MetricCard } from "@termix/plugin-sdk/ui";
import { type ServerMetrics } from "../../shared/metrics.js";
import { UserCheck, UserX } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
export function LoginStatsCard({ metrics }: { metrics: ServerMetrics | null }) {
const { t } = useTranslation();
const loginStats = metrics?.login_stats;
const recentLogins = loginStats?.recentLogins ?? [];
const failedLogins = loginStats?.failedLogins ?? [];
const allLogins = [
...recentLogins.map((l) => ({ ...l, status: "success" as const })),
...failedLogins.map((l) => ({ ...l, status: "failed" as const })),
]
.sort((a, b) => new Date(b.time).getTime() - new Date(a.time).getTime())
.slice(0, 10);
return (
<MetricCard
title={t("hostMetrics.loginStats")}
icon={<UserCheck className="size-3.5" />}
scroll
>
{allLogins.length === 0 ? (
<span className="py-2 text-xs italic text-muted-foreground">
{t("hostMetrics.noRecentLoginData")}
</span>
) : (
<div className="flex flex-col gap-2">
{allLogins.map((login, i) => (
<div
key={i}
className={`flex items-center justify-between border p-2 ${login.status === "success" ? "border-border bg-muted/30" : "border-destructive/30 bg-destructive/5"}`}
>
<div className="flex min-w-0 flex-col">
<div className="flex items-center gap-1.5">
{login.status === "failed" ? (
<UserX className="size-3 shrink-0 text-destructive" />
) : (
<UserCheck className="size-3 shrink-0 text-accent-brand" />
)}
<span
className={`truncate text-xs font-bold ${login.status === "failed" ? "text-destructive" : ""}`}
>
{login.user}
</span>
</div>
<span className="truncate font-mono text-[10px] text-muted-foreground">
{login.ip}
</span>
</div>
<span className="shrink-0 text-[10px] text-muted-foreground">
{new Date(login.time).toLocaleTimeString()}
</span>
</div>
))}
</div>
)}
</MetricCard>
);
}
@@ -0,0 +1,130 @@
import {
RadialGauge,
Sparkline,
MiniStat,
MetricCard,
LineChart,
type LineChartSeries,
} from "@termix/plugin-sdk/ui";
import { type ServerMetrics } from "../../shared/metrics.js";
import { useState, useEffect } from "react";
import { MemoryStick } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { type MetricsHistoryRow, useHostMetricsApi } from "../host-metrics-api";
import { CardTimeTabs, type HistoryTab } from "./CardTimeTabs";
export function MemoryCard({
metrics,
history,
hostId,
}: {
metrics: ServerMetrics | null;
history: number[];
hostId: number | null;
}) {
const { getMetricsHistory } = useHostMetricsApi();
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<HistoryTab>("live");
const [rows, setRows] = useState<MetricsHistoryRow[] | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (activeTab === "live" || hostId == null) return;
let cancelled = false;
setLoading(true);
setRows(null);
getMetricsHistory(hostId, { range: activeTab })
.then((res) => {
if (!cancelled) {
setRows(res.rows);
setLoading(false);
}
})
.catch(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [activeTab, hostId]);
const percent = metrics?.memory?.percent ?? null;
const usedGiB = metrics?.memory?.usedGiB ?? null;
const totalGiB = metrics?.memory?.totalGiB ?? null;
const freeGiB =
usedGiB !== null && totalGiB !== null ? totalGiB - usedGiB : null;
const series: LineChartSeries[] = [
{
key: "mem",
label: t("hostMetrics.memoryUsage"),
color: "var(--chart-3)",
data: rows?.map((r) => r.mem_percent) ?? [],
},
];
const timestamps = rows?.map((r) => r.ts) ?? [];
return (
<MetricCard
title={t("hostMetrics.memoryUsage")}
icon={<MemoryStick className="size-3.5" />}
action={
hostId != null ? (
<CardTimeTabs value={activeTab} onChange={setActiveTab} />
) : undefined
}
>
<div className="flex flex-col gap-3">
{activeTab === "live" && (
<div className="flex items-center gap-4">
<RadialGauge value={percent} />
<div className="flex min-w-0 flex-1 flex-col gap-2">
<div className="grid grid-cols-2 gap-2">
<MiniStat
caption={t("hostMetrics.memory")}
value={
usedGiB !== null && totalGiB !== null
? `${usedGiB.toFixed(1)}/${totalGiB.toFixed(1)}G`
: "N/A"
}
/>
<MiniStat
caption={t("hostMetrics.free")}
value={freeGiB !== null ? `${freeGiB.toFixed(1)}G` : "N/A"}
/>
</div>
<Sparkline
data={[...history, percent ?? 0]}
domain={[0, 100]}
height={48}
/>
</div>
</div>
)}
{activeTab !== "live" && (
<>
{loading && (
<div className="flex h-36 items-center justify-center text-xs text-muted-foreground">
{t("common.loading")}
</div>
)}
{!loading && rows !== null && rows.length === 0 && (
<div className="flex h-36 items-center justify-center text-xs text-muted-foreground">
{t("metricsHistory.noData")}
</div>
)}
{!loading && rows !== null && rows.length > 0 && (
<LineChart
series={series}
timestamps={timestamps}
domain={[0, 100]}
yFormatter={(v) => `${v.toFixed(0)}%`}
height={160}
/>
)}
</>
)}
</div>
</MetricCard>
);
}
@@ -0,0 +1,207 @@
import {
MetricCard,
LineChart,
type LineChartSeries,
} from "@termix/plugin-sdk/ui";
import { type ServerMetrics } from "../../shared/metrics.js";
import { useState, useEffect } from "react";
import { Cable, Container, Network, Wifi, WifiOff } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { type MetricsHistoryRow, useHostMetricsApi } from "../host-metrics-api";
import { CardTimeTabs, type HistoryTab } from "./CardTimeTabs";
function ifaceIcon(name: string) {
if (/^(wl|wlan|wifi|ath)/.test(name))
return <Wifi className="size-3 text-muted-foreground/60" />;
if (/^(eth|en|enp|eno|ens)/.test(name))
return <Cable className="size-3 text-muted-foreground/60" />;
if (/^(docker|br-|veth|virbr|vlan|bond|tun|tap|wg|lo)/.test(name))
return <Container className="size-3 text-muted-foreground/60" />;
return <Network className="size-3 text-muted-foreground/60" />;
}
function computeNetRates(rows: MetricsHistoryRow[]): {
rxRates: Array<number | null>;
txRates: Array<number | null>;
} {
const rxRates: Array<number | null> = [];
const txRates: Array<number | null> = [];
for (let i = 0; i < rows.length; i++) {
if (i === 0) {
rxRates.push(null);
txRates.push(null);
continue;
}
const prev = rows[i - 1];
const curr = rows[i];
const dtSec =
(new Date(curr.ts).getTime() - new Date(prev.ts).getTime()) / 1000;
if (
dtSec < 0.5 ||
prev.net_rx_bytes == null ||
curr.net_rx_bytes == null ||
prev.net_tx_bytes == null ||
curr.net_tx_bytes == null
) {
rxRates.push(null);
txRates.push(null);
continue;
}
const rxDelta = curr.net_rx_bytes - prev.net_rx_bytes;
const txDelta = curr.net_tx_bytes - prev.net_tx_bytes;
rxRates.push(rxDelta < 0 ? null : rxDelta / dtSec);
txRates.push(txDelta < 0 ? null : txDelta / dtSec);
}
return { rxRates, txRates };
}
function formatBytes(bps: number): string {
if (bps >= 1024 * 1024) return `${(bps / 1024 / 1024).toFixed(1)} MB/s`;
if (bps >= 1024) return `${(bps / 1024).toFixed(1)} KB/s`;
return `${Math.round(bps)} B/s`;
}
export function NetworkCard({
metrics,
hostId,
}: {
metrics: ServerMetrics | null;
hostId: number | null;
}) {
const { getMetricsHistory } = useHostMetricsApi();
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<HistoryTab>("live");
const [rows, setRows] = useState<MetricsHistoryRow[] | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (activeTab === "live" || hostId == null) return;
let cancelled = false;
setLoading(true);
setRows(null);
getMetricsHistory(hostId, { range: activeTab })
.then((res) => {
if (!cancelled) {
setRows(res.rows);
setLoading(false);
}
})
.catch(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [activeTab, hostId]);
const interfaces = metrics?.network?.interfaces ?? [];
const { rxRates, txRates } = rows
? computeNetRates(rows)
: { rxRates: [], txRates: [] };
const netMax = Math.max(
rxRates.reduce<number>((m, v) => (v != null && v > m ? v : m), 0),
txRates.reduce<number>((m, v) => (v != null && v > m ? v : m), 0),
1024,
);
const timestamps = rows?.map((r) => r.ts) ?? [];
const hasNetData = rxRates.some((v) => v !== null);
const rxSeries: LineChartSeries = {
key: "rx",
label: t("metricsHistory.download"),
color: "var(--chart-2)",
data: rxRates,
};
const txSeries: LineChartSeries = {
key: "tx",
label: t("metricsHistory.upload"),
color: "var(--chart-5)",
data: txRates,
};
return (
<MetricCard
title={t("hostMetrics.networkInterfaces")}
icon={<Network className="size-3.5" />}
scroll={activeTab === "live"}
action={
hostId != null ? (
<CardTimeTabs value={activeTab} onChange={setActiveTab} />
) : undefined
}
>
{activeTab === "live" &&
(interfaces.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-2 py-6 text-muted-foreground">
<WifiOff className="size-6 opacity-40" />
<span className="text-xs">
{t("hostMetrics.noInterfacesFound")}
</span>
</div>
) : (
<div className="flex flex-col gap-2">
{interfaces.map((iface, i) => (
<div
key={`${iface.name}-${i}`}
className="flex flex-col gap-1 border border-border bg-muted/30 p-2"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{ifaceIcon(iface.name)}
<span
className={`size-1.5 rounded-full ${iface.state === "UP" ? "bg-accent-brand" : "bg-muted-foreground/50"}`}
/>
<span className="font-mono text-sm font-bold">
{iface.name}
</span>
</div>
<span className="border border-border px-1.5 py-px text-[10px] font-semibold uppercase text-muted-foreground">
{iface.state}
</span>
</div>
<div className="flex justify-between font-mono text-[10px] text-muted-foreground">
<span className="truncate">{iface.ip}</span>
{(iface.rxRateBps != null || iface.txRateBps != null) && (
<span className="shrink-0">
&#8595;{" "}
{iface.rxRateBps == null
? "—"
: formatBytes(iface.rxRateBps)}{" "}
/ &#8593;{" "}
{iface.txRateBps == null
? "—"
: formatBytes(iface.txRateBps)}
</span>
)}
</div>
</div>
))}
</div>
))}
{activeTab !== "live" && (
<>
{loading && (
<div className="flex h-36 items-center justify-center text-xs text-muted-foreground">
{t("common.loading")}
</div>
)}
{!loading && rows !== null && !hasNetData && (
<div className="flex h-36 items-center justify-center text-xs text-muted-foreground">
{t("metricsHistory.noData")}
</div>
)}
{!loading && hasNetData && (
<LineChart
series={[rxSeries, txSeries]}
timestamps={timestamps}
domain={[0, netMax]}
yFormatter={formatBytes}
height={160}
/>
)}
</>
)}
</MetricCard>
);
}
@@ -0,0 +1,104 @@
import { MetricCard, Select2 } from "@termix/plugin-sdk/ui";
import {
type ServerMetrics,
type ListeningPort,
} from "../../shared/metrics.js";
import { useMemo, useState } from "react";
import { Unplug, Search } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
function formatAddress(addr: string) {
return addr === "0.0.0.0" || addr === "*" || addr === "::" ? "*" : addr;
}
function PortRow({ port }: { port: ListeningPort }) {
return (
<div className="grid grid-cols-[3.5rem_3rem_1fr_4rem] gap-2 overflow-hidden border-b border-border/50 py-1 font-mono text-xs last:border-0">
<span className="truncate font-bold text-accent-brand">
{port.localPort}
</span>
<span className="truncate text-muted-foreground">
{port.protocol.toUpperCase()}
</span>
<span className="truncate font-semibold">
{port.process ?? (port.pid ? `PID:${port.pid}` : "—")}
</span>
<span className="truncate text-right text-muted-foreground">
{formatAddress(port.localAddress)}
</span>
</div>
);
}
export function PortsCard({ metrics }: { metrics: ServerMetrics | null }) {
const { t } = useTranslation();
const [query, setQuery] = useState("");
const [proto, setProto] = useState<"all" | "tcp" | "udp">("all");
const ports = useMemo(() => metrics?.ports?.ports ?? [], [metrics]);
const filtered = useMemo(() => {
const q = query.toLowerCase();
return ports.filter((p) => {
if (proto !== "all" && p.protocol.toLowerCase() !== proto) return false;
if (!q) return true;
return (
String(p.localPort).includes(q) ||
(p.process ?? "").toLowerCase().includes(q) ||
String(p.pid ?? "").includes(q) ||
p.localAddress.toLowerCase().includes(q)
);
});
}, [ports, query, proto]);
return (
<MetricCard
title={t("hostMetrics.ports.title")}
icon={<Unplug className="size-3.5" />}
scroll
>
<div className="mb-2 flex items-center gap-1.5">
<div className="relative flex-1">
<Search className="pointer-events-none absolute left-2 top-1/2 size-3 -translate-y-1/2 text-muted-foreground/60" />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t("hostMetrics.ports.search")}
className="h-7 w-full border border-border bg-background pl-7 pr-2 text-xs outline-none focus:ring-1 focus:ring-ring"
/>
</div>
<Select2
value={proto}
onChange={(e) => setProto(e.target.value as "all" | "tcp" | "udp")}
className="h-7 border border-border bg-background px-1 text-xs"
>
<option value="all">{t("hostMetrics.ports.allProtocols")}</option>
<option value="tcp">TCP</option>
<option value="udp">UDP</option>
</Select2>
<span className="shrink-0 text-[10px] tabular-nums text-muted-foreground">
{filtered.length}
</span>
</div>
<div className="flex flex-col">
<div className="grid grid-cols-[3.5rem_3rem_1fr_4rem] gap-2 border-b border-border pb-1 text-[10px] font-bold uppercase text-muted-foreground">
<span>{t("hostMetrics.ports.port")}</span>
<span>{t("hostMetrics.ports.protocol")}</span>
<span>{t("hostMetrics.ports.process")}</span>
<span className="text-right">{t("hostMetrics.ports.address")}</span>
</div>
{filtered.length === 0 ? (
<span className="py-2 text-xs italic text-muted-foreground">
{t("hostMetrics.ports.noData")}
</span>
) : (
filtered.map((port, i) => (
<PortRow
key={`${port.protocol}-${port.localPort}-${i}`}
port={port}
/>
))
)}
</div>
</MetricCard>
);
}
@@ -0,0 +1,53 @@
import { StatRow, MetricCard } from "@termix/plugin-sdk/ui";
import { type ServerMetrics } from "../../shared/metrics.js";
import { Cpu } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
export function ProcessesCard({ metrics }: { metrics: ServerMetrics | null }) {
const { t } = useTranslation();
const procs = metrics?.processes;
const top = procs?.top ?? [];
return (
<MetricCard
title={t("hostMetrics.processes")}
icon={<Cpu className="size-3.5" />}
scroll
>
<div className="mb-2 flex items-center gap-4 text-xs">
{procs?.total != null && (
<span>
<span className="font-bold text-foreground">{procs.total}</span>{" "}
<span className="text-muted-foreground">
{t("hostMetrics.processesTotal")}
</span>
</span>
)}
{procs?.running != null && (
<span>
<span className="font-bold text-accent-brand">{procs.running}</span>{" "}
<span className="text-muted-foreground">
{t("hostMetrics.processesRunning")}
</span>
</span>
)}
</div>
{top.length === 0 ? (
<span className="text-xs text-muted-foreground">
{t("hostMetrics.noProcessesFound")}
</span>
) : (
<div className="flex flex-col divide-y divide-border">
{top.slice(0, 10).map((p) => (
<StatRow
key={p.pid}
label={p.command}
value={`${p.cpu}% cpu, ${p.mem}% mem`}
mono
/>
))}
</div>
)}
</MetricCard>
);
}
@@ -0,0 +1,36 @@
import { StatRow, MetricCard } from "@termix/plugin-sdk/ui";
import { type ServerMetrics } from "../../shared/metrics.js";
import { Server } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
export function SystemCard({ metrics }: { metrics: ServerMetrics | null }) {
const { t } = useTranslation();
const system = metrics?.system;
const uptime = metrics?.uptime;
const rows = [
{ label: t("hostMetrics.hostname"), value: system?.hostname },
{ label: t("hostMetrics.operatingSystem"), value: system?.os },
{ label: t("hostMetrics.kernel"), value: system?.kernel },
{ label: t("hostMetrics.architecture"), value: system?.arch },
{ label: t("hostMetrics.uptime"), value: uptime?.formatted },
].filter((r) => r.value);
return (
<MetricCard
title={t("hostMetrics.systemInfo")}
icon={<Server className="size-3.5" />}
scroll
>
{rows.length === 0 ? (
<span className="text-xs text-muted-foreground">N/A</span>
) : (
<div className="flex flex-col divide-y divide-border">
{rows.map(({ label, value }) => (
<StatRow key={label} label={label} value={value} mono />
))}
</div>
)}
</MetricCard>
);
}
@@ -0,0 +1,100 @@
import { StatRow, MetricCard } from "@termix/plugin-sdk/ui";
import { type ServerMetrics } from "../../shared/metrics.js";
import { Thermometer } from "lucide-react";
import { useSettings, useTranslation } from "@termix/plugin-sdk/frontend";
import { useEffect, useState } from "react";
import {
formatTemperature,
selectTemperatureSensor,
temperaturePreferenceKey,
temperatureUnitOf,
} from "../temperature-preference";
export function TemperatureCard({
metrics,
hostId,
}: {
metrics: ServerMetrics | null;
hostId: number | null;
}) {
const { t } = useTranslation();
const unit = temperatureUnitOf(useSettings("user").values.temperatureUnit);
const temperature = metrics?.temperature;
const sensors = temperature?.sensors ?? [];
const [preferredLabel, setPreferredLabel] = useState("");
useEffect(() => {
setPreferredLabel(
hostId === null
? ""
: (localStorage.getItem(temperaturePreferenceKey(hostId)) ?? ""),
);
}, [hostId]);
const preferredSensor = selectTemperatureSensor(sensors, preferredLabel);
const displayedTemperature =
preferredSensor?.celsius ?? temperature?.highestCelsius;
const displayedLabel =
preferredSensor?.label ?? t("hostMetrics.highestTemperature");
const chooseSensor = (label: string) => {
setPreferredLabel(label);
if (hostId === null) return;
const key = temperaturePreferenceKey(hostId);
if (label) localStorage.setItem(key, label);
else localStorage.removeItem(key);
};
return (
<MetricCard
title={t("hostMetrics.temperature")}
icon={<Thermometer className="size-3.5" />}
scroll={sensors.length > 4}
scrollMax={220}
>
<div className="flex flex-col gap-3">
<div>
<div className="text-3xl font-semibold tabular-nums">
{formatTemperature(displayedTemperature, unit)}
</div>
<div className="mt-1 text-xs text-muted-foreground">
{displayedLabel}
</div>
</div>
{sensors.length > 1 && (
<label className="flex flex-col gap-1 text-xs text-muted-foreground">
<span>{t("hostMetrics.primaryTemperatureSensor")}</span>
<select
value={preferredSensor?.label ?? ""}
onChange={(event) => chooseSensor(event.target.value)}
className="h-8 border border-border bg-background px-2 text-xs text-foreground outline-none focus:ring-1 focus:ring-ring"
>
<option value="">{t("hostMetrics.highestTemperature")}</option>
{sensors.map((sensor) => (
<option key={sensor.label} value={sensor.label}>
{sensor.label}
</option>
))}
</select>
</label>
)}
{sensors.length === 0 ? (
<span className="text-xs text-muted-foreground">N/A</span>
) : (
<div className="flex flex-col divide-y divide-border">
{sensors.map((sensor) => (
<StatRow
key={`${sensor.label}-${sensor.celsius}`}
label={sensor.label}
value={formatTemperature(sensor.celsius, unit)}
mono
/>
))}
</div>
)}
</div>
</MetricCard>
);
}
@@ -0,0 +1,28 @@
import { MetricCard } from "@termix/plugin-sdk/ui";
import { type ServerMetrics } from "../../shared/metrics.js";
import { Clock } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
export function UptimeCard({ metrics }: { metrics: ServerMetrics | null }) {
const { t } = useTranslation();
const uptime = metrics?.uptime;
return (
<MetricCard
title={t("hostMetrics.uptime")}
icon={<Clock className="size-3.5" />}
>
<div className="flex h-full flex-col justify-center gap-2">
<span className="text-2xl font-bold leading-none text-accent-brand md:text-3xl">
{uptime?.formatted ?? "N/A"}
</span>
{uptime?.seconds != null && (
<span className="font-mono text-xs text-muted-foreground">
{Math.floor(uptime.seconds).toLocaleString()}{" "}
{t("hostMetrics.seconds")}
</span>
)}
</div>
</MetricCard>
);
}
@@ -0,0 +1,30 @@
export interface GpuHistory {
utilization: number[];
memory: number[];
}
/** Live sparkline series keyed by GPU index. */
export type GpuHistories = Record<number, GpuHistory>;
/**
* Appends one poll's readings to each GPU's series. GPUs missing from the poll
* lose their series so a re-enumerated device never inherits stale samples.
*/
export function appendGpuHistories(
prev: GpuHistories,
gpus: GpuDevice[] | undefined,
maxLength: number,
): GpuHistories {
const add = (arr: number[] | undefined, v: number | null) =>
[...(arr ?? []), v ?? 0].slice(-maxLength);
const next: GpuHistories = {};
for (const gpu of gpus ?? []) {
next[gpu.index] = {
utilization: add(prev[gpu.index]?.utilization, gpu.utilizationPercent),
memory: add(prev[gpu.index]?.memory, gpu.memoryPercent),
};
}
return next;
}
import { type GpuDevice } from "../../shared/stats-widgets.js";
@@ -0,0 +1,252 @@
import { type ServerMetrics } from "../../shared/metrics.js";
import {
defaultColSpanFor,
defaultHeightFor,
type HostMetricsCardId,
} from "../../shared/host-metrics.js";
import type { ComponentType, ReactNode } from "react";
import { CpuCard } from "./CpuCard";
import { MemoryCard } from "./MemoryCard";
import { DiskCard } from "./DiskCard";
import { NetworkCard } from "./NetworkCard";
import { UptimeCard } from "./UptimeCard";
import { SystemCard } from "./SystemCard";
import { LoginStatsCard } from "./LoginStatsCard";
import { PortsCard } from "./PortsCard";
import { ProcessesCard } from "./ProcessesCard";
import { FirewallCard } from "./FirewallCard";
import { TemperatureCard } from "./TemperatureCard";
import { GpuCard } from "./GpuCard";
import type { GpuHistories } from "./gpu-history";
import { ServiceManagerCard } from "./managers/ServiceManagerCard";
import { ProcessInspectorCard } from "./managers/ProcessInspectorCard";
import { PackageManagerCard } from "./managers/PackageManagerCard";
import { CronManagerCard } from "./managers/CronManagerCard";
import { SslManagerCard } from "./managers/SslManagerCard";
import { FirewallManagerCard } from "./managers/FirewallManagerCard";
import { UserManagerCard } from "./managers/UserManagerCard";
import { HealthCheckCard } from "./managers/HealthCheckCard";
import { LogViewerCard } from "./managers/LogViewerCard";
import {
TopMemoryCard,
SystemdTimersCard,
DiskBreakdownCard,
} from "./managers/SimpleManagerCards";
import { WireGuardManagerCard } from "./managers/WireGuardManagerCard";
export interface MetricCardHistories {
cpu: number[];
memory: number[];
disk: number[];
gpu: GpuHistories;
}
export interface CardRenderContext {
metrics: ServerMetrics | null;
histories: MetricCardHistories;
hostId: number | null;
}
export interface CardDefinition {
id: HostMetricsCardId;
/** i18n key for the card's display label. */
labelKey: string;
kind: "metric" | "manager";
render: (ctx: CardRenderContext) => ReactNode;
}
type SimpleMetricCard = ComponentType<{
metrics: ServerMetrics | null;
hostId: number | null;
}>;
type HistoryMetricCard = ComponentType<{
metrics: ServerMetrics | null;
history: number[];
hostId: number | null;
}>;
function metricCard(
id: HostMetricsCardId,
labelKey: string,
Comp: SimpleMetricCard,
): CardDefinition {
return {
id,
labelKey,
kind: "metric",
render: ({ metrics, hostId }) => <Comp metrics={metrics} hostId={hostId} />,
};
}
function historyCard(
id: HostMetricsCardId,
labelKey: string,
Comp: HistoryMetricCard,
series: "cpu" | "memory" | "disk",
): CardDefinition {
return {
id,
labelKey,
kind: "metric",
render: ({ metrics, histories, hostId }) => (
<Comp metrics={metrics} history={histories[series]} hostId={hostId} />
),
};
}
type ManagerCardComp = ComponentType<{ hostId: number | null }>;
function managerCard(
id: HostMetricsCardId,
labelKey: string,
Comp: ManagerCardComp,
): CardDefinition {
return {
id,
labelKey,
kind: "manager",
render: ({ hostId }) => <Comp hostId={hostId} />,
};
}
export const CARD_DEFINITIONS: Record<string, CardDefinition> = {
cpu: historyCard("cpu", "hostMetrics.cpuUsage", CpuCard, "cpu"),
memory: historyCard(
"memory",
"hostMetrics.memoryUsage",
MemoryCard,
"memory",
),
disk: historyCard("disk", "hostMetrics.diskUsage", DiskCard, "disk"),
network: metricCard("network", "hostMetrics.networkInterfaces", NetworkCard),
uptime: metricCard("uptime", "hostMetrics.uptime", UptimeCard),
system: metricCard("system", "hostMetrics.systemInfo", SystemCard),
login_stats: metricCard(
"login_stats",
"hostMetrics.loginStats",
LoginStatsCard,
),
ports: metricCard("ports", "hostMetrics.ports.title", PortsCard),
processes: metricCard("processes", "hostMetrics.processes", ProcessesCard),
firewall: metricCard("firewall", "hostMetrics.firewall.title", FirewallCard),
temperature: metricCard(
"temperature",
"hostMetrics.temperature",
TemperatureCard,
),
gpu: {
id: "gpu",
labelKey: "hostMetrics.gpu.title",
kind: "metric",
render: ({ metrics, histories }) => (
<GpuCard metrics={metrics} gpuHistories={histories.gpu} />
),
},
service_manager: managerCard(
"service_manager",
"hostMetrics.managers.services",
ServiceManagerCard,
),
process_inspector: managerCard(
"process_inspector",
"hostMetrics.managers.processInspector",
ProcessInspectorCard,
),
log_viewer: managerCard(
"log_viewer",
"hostMetrics.managers.logViewer",
LogViewerCard,
),
cron_manager: managerCard(
"cron_manager",
"hostMetrics.managers.cron",
CronManagerCard,
),
package_manager: managerCard(
"package_manager",
"hostMetrics.managers.packages",
PackageManagerCard,
),
ssl_manager: managerCard(
"ssl_manager",
"hostMetrics.managers.ssl",
SslManagerCard,
),
firewall_manager: managerCard(
"firewall_manager",
"hostMetrics.managers.firewall",
FirewallManagerCard,
),
user_manager: managerCard(
"user_manager",
"hostMetrics.managers.users",
UserManagerCard,
),
health_check: managerCard(
"health_check",
"hostMetrics.managers.healthCheck",
HealthCheckCard,
),
disk_breakdown: managerCard(
"disk_breakdown",
"hostMetrics.managers.diskBreakdown",
DiskBreakdownCard,
),
systemd_timers: managerCard(
"systemd_timers",
"hostMetrics.managers.systemdTimers",
SystemdTimersCard,
),
top_memory: managerCard(
"top_memory",
"hostMetrics.managers.topMemory",
TopMemoryCard,
),
wireguard_manager: managerCard(
"wireguard_manager",
"hostMetrics.managers.wireguard",
WireGuardManagerCard,
),
};
/**
* Manager cards other plugins contribute to "host-metrics.managers" (a
* component slot), keyed by contribution actionId. Built once per render from
* useSlotContributions and merged with CARD_DEFINITIONS below, so a plugin
* like tailscale can add its own manager card without host-metrics knowing it
* exists.
*/
export function definitionsFromSlot(
contributions: Array<{
actionId: string;
titleKey: string;
component?: ComponentType<Record<string, unknown>>;
}>,
): Record<string, CardDefinition> {
const out: Record<string, CardDefinition> = {};
for (const contribution of contributions) {
if (!contribution.component) continue;
const Comp = contribution.component;
out[contribution.actionId] = {
id: contribution.actionId as HostMetricsCardId,
labelKey: contribution.titleKey,
kind: "manager",
render: ({ hostId }) => <Comp hostId={hostId} />,
};
}
return out;
}
/** All card ids built into this plugin and available in the Add tray. */
export const IMPLEMENTED_CARD_IDS = Object.keys(
CARD_DEFINITIONS,
) as HostMetricsCardId[];
export function getCardDefinition(
id: string,
extra?: Record<string, CardDefinition>,
): CardDefinition | undefined {
return CARD_DEFINITIONS[id] ?? extra?.[id];
}
export { defaultColSpanFor, defaultHeightFor };
@@ -0,0 +1,163 @@
import { Button } from "@termix/plugin-sdk/ui";
import { useEffect, useMemo, useState } from "react";
import { Clock4, Plus, Trash2, Save } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { toast } from "sonner";
import { useHostMetricsApi } from "../../host-metrics-api";
import { useManagerData, extractError } from "./useManagerData";
import { ManagerCardShell } from "@termix/plugin-sdk/ui";
import { ManagerSearch } from "@termix/plugin-sdk/ui";
interface CronEntry {
raw: string;
enabled: boolean;
schedule: string;
command: string;
}
export function CronManagerCard({ hostId }: { hostId: number | null }) {
const { managerPost } = useHostMetricsApi();
const { t } = useTranslation();
const { data, loading, error, refresh } = useManagerData<{
entries: CronEntry[];
}>(hostId, "cron");
const [entries, setEntries] = useState<CronEntry[]>([]);
const [dirty, setDirty] = useState(false);
const [saving, setSaving] = useState(false);
const [query, setQuery] = useState("");
// Keep the real array index so edits/removes target the right entry even when
// the list is filtered by the search box.
const visible = useMemo(() => {
const q = query.toLowerCase();
return entries
.map((entry, index) => ({ entry, index }))
.filter(
({ entry }) =>
!q ||
entry.schedule.toLowerCase().includes(q) ||
entry.command.toLowerCase().includes(q),
);
}, [entries, query]);
useEffect(() => {
if (data?.entries) {
setEntries(data.entries);
setDirty(false);
}
}, [data]);
const update = (i: number, patch: Partial<CronEntry>) => {
setEntries((prev) =>
prev.map((e, idx) => (idx === i ? { ...e, ...patch } : e)),
);
setDirty(true);
};
const remove = (i: number) => {
setEntries((prev) => prev.filter((_, idx) => idx !== i));
setDirty(true);
};
const add = () => {
setEntries((prev) => [
...prev,
{ raw: "", enabled: true, schedule: "0 * * * *", command: "" },
]);
setDirty(true);
};
const save = async () => {
if (hostId == null) return;
setSaving(true);
try {
const res = await managerPost<{ success: boolean; output: string }>(
hostId,
"cron",
{ entries },
);
if (res.success) {
toast.success(t("hostMetrics.managers.cronSaved"));
setDirty(false);
refresh();
} else {
toast.error(res.output || t("hostMetrics.managers.actionFailed"));
}
} catch (e) {
toast.error(extractError(e).message);
} finally {
setSaving(false);
}
};
return (
<ManagerCardShell
title={t("hostMetrics.managers.cron")}
icon={<Clock4 className="size-3.5" />}
loading={loading}
error={error}
onRefresh={refresh}
headerExtra={
<>
<Button variant="ghost" size="xs" onClick={add}>
<Plus className="size-3" />
</Button>
{dirty && (
<Button
variant="outline"
size="xs"
disabled={saving}
onClick={save}
>
<Save className="size-3" />
{t("hostMetrics.managers.save")}
</Button>
)}
</>
}
empty={!loading && entries.length === 0}
>
{entries.length > 5 && (
<ManagerSearch
value={query}
onChange={setQuery}
count={visible.length}
/>
)}
<div className="flex flex-col gap-2">
{visible.map(({ entry: e, index: i }) => (
<div
key={i}
className="flex flex-col gap-1 border border-border bg-muted/20 p-2"
>
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={e.enabled}
onChange={(ev) => update(i, { enabled: ev.target.checked })}
className="accent-accent-brand"
title={t("hostMetrics.managers.enabled")}
/>
<input
value={e.schedule}
onChange={(ev) => update(i, { schedule: ev.target.value })}
placeholder="0 * * * *"
className="h-6 w-28 border border-border bg-background px-1.5 font-mono text-[11px] outline-none focus:ring-1 focus:ring-ring"
/>
<button
onClick={() => remove(i)}
className="ml-auto text-muted-foreground hover:text-destructive"
>
<Trash2 className="size-3.5" />
</button>
</div>
<input
value={e.command}
onChange={(ev) => update(i, { command: ev.target.value })}
placeholder={t("hostMetrics.managers.command")}
className="h-6 w-full border border-border bg-background px-1.5 font-mono text-[11px] outline-none focus:ring-1 focus:ring-ring"
/>
</div>
))}
</div>
</ManagerCardShell>
);
}
@@ -0,0 +1,231 @@
import { Button, Select2 } from "@termix/plugin-sdk/ui";
import { type FirewallChain } from "../../../shared/metrics.js";
import { useMemo, useState } from "react";
import { ShieldCheck, Plus, Trash2, Save } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { toast } from "sonner";
import { useHostMetricsApi } from "../../host-metrics-api";
import { useManagerData, extractError } from "./useManagerData";
import { ManagerCardShell } from "@termix/plugin-sdk/ui";
import { ManagerSearch } from "@termix/plugin-sdk/ui";
interface FirewallData {
type: "iptables" | "nftables" | "none";
status: string;
chains: FirewallChain[];
}
export function FirewallManagerCard({ hostId }: { hostId: number | null }) {
const { managerPost } = useHostMetricsApi();
const { t } = useTranslation();
const { data, loading, error, refresh } = useManagerData<FirewallData>(
hostId,
"firewall",
);
const [proto, setProto] = useState<"tcp" | "udp">("tcp");
const [port, setPort] = useState("");
const [target, setTarget] = useState<"ACCEPT" | "DROP" | "REJECT">("ACCEPT");
const [busy, setBusy] = useState(false);
const [query, setQuery] = useState("");
const rule = async (op: "add" | "delete") => {
if (hostId == null) return;
const portNum = Number(port);
if (!Number.isInteger(portNum) || portNum < 1 || portNum > 65535) {
toast.error(t("hostMetrics.managers.invalidPort"));
return;
}
setBusy(true);
try {
const res = await managerPost<{ success: boolean; output: string }>(
hostId,
"firewall",
{ op, protocol: proto, port: portNum, target },
"rule",
);
if (res.success) {
toast.success(t("hostMetrics.managers.ruleApplied"));
refresh();
} else {
toast.error(res.output || t("hostMetrics.managers.actionFailed"));
}
} catch (e) {
toast.error(extractError(e).message);
} finally {
setBusy(false);
}
};
const persist = async () => {
if (hostId == null) return;
setBusy(true);
toast.loading(t("hostMetrics.managers.working"), { id: "fw-persist" });
try {
const res = await managerPost<{ success: boolean; output: string }>(
hostId,
"firewall",
{},
"persist",
);
toast[res.success ? "success" : "error"](
res.success
? t("hostMetrics.managers.firewallPersisted")
: t("hostMetrics.managers.actionFailed"),
{ id: "fw-persist", description: res.output?.slice(-200) },
);
} catch (e) {
toast.error(extractError(e).message, { id: "fw-persist" });
} finally {
setBusy(false);
}
};
const chains = useMemo(() => {
const q = query.toLowerCase();
return (data?.chains ?? [])
.map((c) => ({
...c,
rules: c.rules.filter(
(r) =>
!q ||
r.target.toLowerCase().includes(q) ||
(r.dport ?? "").includes(q) ||
r.protocol.toLowerCase().includes(q) ||
r.source.toLowerCase().includes(q),
),
}))
.filter((c) => c.rules.length > 0 || !q);
}, [data?.chains, query]);
return (
<ManagerCardShell
title={t("hostMetrics.managers.firewall")}
icon={<ShieldCheck className="size-3.5" />}
loading={loading}
error={error}
onRefresh={refresh}
headerExtra={
<>
<span className="text-[10px] uppercase tracking-widest text-muted-foreground">
{data?.type ?? ""}
</span>
<Button
variant="ghost"
size="xs"
disabled={busy || data?.type === "none"}
onClick={persist}
title={t("hostMetrics.managers.firewallPersist")}
>
<Save className="size-3" />
</Button>
</>
}
>
<div className="mb-3 flex flex-col gap-2 border border-dashed border-border p-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hostMetrics.managers.addInputRule")}
</span>
<div className="flex items-center gap-1.5">
<Select2
value={proto}
onChange={(e) => setProto(e.target.value as "tcp" | "udp")}
className="h-7 border border-border bg-background px-1 text-xs"
>
<option value="tcp">tcp</option>
<option value="udp">udp</option>
</Select2>
<input
value={port}
onChange={(e) => setPort(e.target.value)}
placeholder={t("hostMetrics.ports.port")}
className="h-7 w-16 border border-border bg-background px-1.5 text-xs outline-none focus:ring-1 focus:ring-ring"
/>
<Select2
value={target}
onChange={(e) =>
setTarget(e.target.value as "ACCEPT" | "DROP" | "REJECT")
}
className="h-7 border border-border bg-background px-1 text-xs"
>
<option>ACCEPT</option>
<option>DROP</option>
<option>REJECT</option>
</Select2>
<Button
variant="outline"
size="xs"
disabled={busy}
onClick={() => rule("add")}
>
<Plus className="size-3" />
</Button>
<Button
variant="ghost"
size="xs"
disabled={busy}
onClick={() => rule("delete")}
>
<Trash2 className="size-3" />
</Button>
</div>
<span className="text-[10px] text-yellow-500">
{t("hostMetrics.managers.firewallWarning")}
</span>
</div>
<ManagerSearch value={query} onChange={setQuery} />
{chains.length === 0 ? (
<span className="text-xs text-muted-foreground">
{t("hostMetrics.firewall.noData")}
</span>
) : (
<div className="flex flex-col gap-3">
{chains.map((chain) => (
<div key={chain.name} className="flex flex-col">
<div className="mb-1 flex items-center justify-between">
<span className="text-[10px] font-bold uppercase tracking-widest text-foreground">
{chain.name}
</span>
{chain.policy && (
<span className="text-[10px] text-muted-foreground">
{t("hostMetrics.firewall.policy")}: {chain.policy}
</span>
)}
</div>
{chain.rules.length === 0 ? (
<span className="text-[10px] italic text-muted-foreground/60">
{t("hostMetrics.firewall.noRules")}
</span>
) : (
<>
<div className="grid grid-cols-4 gap-2 border-b border-border pb-1 text-[10px] font-bold uppercase text-muted-foreground">
<span>{t("hostMetrics.firewall.action")}</span>
<span>{t("hostMetrics.firewall.protocol")}</span>
<span>{t("hostMetrics.firewall.port")}</span>
<span>{t("hostMetrics.firewall.source")}</span>
</div>
{chain.rules.map((r, i) => (
<div
key={i}
className="grid grid-cols-4 gap-2 border-b border-border/50 py-1 font-mono text-xs last:border-0"
>
<span className="font-bold">{r.target}</span>
<span className="text-muted-foreground">
{r.protocol.toUpperCase()}
</span>
<span>{r.dport ?? "—"}</span>
<span className="truncate text-muted-foreground">
{r.source}
</span>
</div>
))}
</>
)}
</div>
))}
</div>
)}
</ManagerCardShell>
);
}
@@ -0,0 +1,314 @@
import { Button, Sparkline, Select2 } from "@termix/plugin-sdk/ui";
import { useEffect, useMemo, useState } from "react";
import {
HeartPulse,
CheckCircle2,
XCircle,
Plus,
Trash2,
Play,
Pencil,
} from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { toast } from "sonner";
import { useHostMetricsApi } from "../../host-metrics-api";
import { useManagerData, extractError } from "./useManagerData";
import { ManagerCardShell } from "@termix/plugin-sdk/ui";
interface HealthCheck {
id: string;
name: string;
type: "tcp" | "http";
target: string;
port?: number;
path?: string;
}
interface HealthResult {
checkId: string;
ok: boolean;
latencyMs: number | null;
detail: string;
}
interface HistoryRow {
checkId: string;
ts: string;
ok: boolean;
latencyMs: number | null;
}
interface HealthData {
checks: HealthCheck[];
results: HealthResult[];
history: HistoryRow[];
}
function newCheck(): HealthCheck {
return {
id: `c_${Math.random().toString(36).slice(2, 9)}`,
name: "",
type: "tcp",
target: "",
port: 80,
};
}
export function HealthCheckCard({ hostId }: { hostId: number | null }) {
const { managerPost } = useHostMetricsApi();
const { t } = useTranslation();
const { data, loading, error, refresh } = useManagerData<HealthData>(
hostId,
"health",
);
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState<HealthCheck[]>([]);
const [saving, setSaving] = useState(false);
const [running, setRunning] = useState(false);
useEffect(() => {
if (data?.checks && !editing) setDraft(data.checks);
}, [data?.checks, editing]);
const byCheck = useMemo(() => {
const map = new Map<string, HistoryRow[]>();
for (const h of data?.history ?? []) {
const arr = map.get(h.checkId) ?? [];
arr.push(h);
map.set(h.checkId, arr);
}
return map;
}, [data?.history]);
const save = async () => {
if (hostId == null) return;
for (const c of draft) {
if (!c.name.trim() || !c.target.trim()) {
toast.error(t("hostMetrics.managers.healthMissingFields"));
return;
}
}
setSaving(true);
try {
const res = await managerPost<{ success: boolean }>(
hostId,
"health",
{ checks: draft },
"config",
);
if (res.success) {
toast.success(t("hostMetrics.managers.healthSaved"));
setEditing(false);
refresh();
} else {
toast.error(t("hostMetrics.managers.actionFailed"));
}
} catch (e) {
toast.error(extractError(e).message);
} finally {
setSaving(false);
}
};
const runNow = async () => {
if (hostId == null) return;
setRunning(true);
try {
await managerPost(hostId, "health", {}, "run");
refresh();
} catch (e) {
toast.error(extractError(e).message);
} finally {
setRunning(false);
}
};
const updateDraft = (id: string, patch: Partial<HealthCheck>) =>
setDraft((d) => d.map((c) => (c.id === id ? { ...c, ...patch } : c)));
return (
<ManagerCardShell
title={t("hostMetrics.managers.healthCheck")}
icon={<HeartPulse className="size-3.5" />}
loading={loading}
error={error}
onRefresh={refresh}
headerExtra={
editing ? (
<>
<Button
variant="ghost"
size="xs"
onClick={() => {
setEditing(false);
setDraft(data?.checks ?? []);
}}
>
{t("common.cancel")}
</Button>
<Button
variant="outline"
size="xs"
disabled={saving}
onClick={save}
>
{t("common.save")}
</Button>
</>
) : (
<>
<Button
variant="ghost"
size="xs"
disabled={running || (data?.checks?.length ?? 0) === 0}
onClick={runNow}
>
<Play className="size-3" />
{t("hostMetrics.managers.healthRun")}
</Button>
<Button variant="ghost" size="xs" onClick={() => setEditing(true)}>
<Pencil className="size-3" />
{t("common.edit")}
</Button>
</>
)
}
>
{editing ? (
<div className="flex flex-col gap-2">
{draft.map((c) => (
<div
key={c.id}
className="flex flex-col gap-1.5 border border-border bg-muted/20 p-2"
>
<div className="flex items-center gap-1.5">
<input
value={c.name}
onChange={(e) => updateDraft(c.id, { name: e.target.value })}
placeholder={t("hostMetrics.managers.healthName")}
className="h-7 flex-1 border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
/>
<Select2
value={c.type}
onChange={(e) =>
updateDraft(c.id, {
type: e.target.value as "tcp" | "http",
})
}
className="h-7 border border-border bg-background px-1 text-xs"
>
<option value="tcp">TCP</option>
<option value="http">HTTP</option>
</Select2>
<button
onClick={() =>
setDraft((d) => d.filter((x) => x.id !== c.id))
}
className="flex size-7 items-center justify-center text-muted-foreground hover:text-destructive"
>
<Trash2 className="size-3.5" />
</button>
</div>
<div className="flex items-center gap-1.5">
<input
value={c.target}
onChange={(e) =>
updateDraft(c.id, { target: e.target.value })
}
placeholder={t("hostMetrics.managers.healthTarget")}
className="h-7 flex-1 border border-border bg-background px-2 font-mono text-xs outline-none focus:ring-1 focus:ring-ring"
/>
{c.type === "tcp" ? (
<input
type="number"
value={c.port ?? ""}
onChange={(e) =>
updateDraft(c.id, { port: Number(e.target.value) })
}
placeholder={t("hostMetrics.ports.port")}
className="h-7 w-16 border border-border bg-background px-1.5 text-xs outline-none focus:ring-1 focus:ring-ring"
/>
) : (
<input
value={c.path ?? ""}
onChange={(e) =>
updateDraft(c.id, { path: e.target.value })
}
placeholder="/health"
className="h-7 w-24 border border-border bg-background px-1.5 font-mono text-xs outline-none focus:ring-1 focus:ring-ring"
/>
)}
</div>
</div>
))}
<Button
variant="ghost"
size="sm"
className="self-start"
onClick={() => setDraft((d) => [...d, newCheck()])}
>
<Plus className="size-3" />
{t("hostMetrics.managers.healthAddCheck")}
</Button>
</div>
) : (data?.checks?.length ?? 0) === 0 ? (
<div className="flex flex-col items-center gap-2 py-6 text-center">
<span className="text-xs text-muted-foreground/60">
{t("hostMetrics.managers.noHealthChecks")}
</span>
<Button variant="outline" size="sm" onClick={() => setEditing(true)}>
<Plus className="size-3" />
{t("hostMetrics.managers.healthAddCheck")}
</Button>
</div>
) : (
<div className="flex flex-col gap-3">
{(data?.checks ?? []).map((check) => {
const result = data?.results?.find((r) => r.checkId === check.id);
const hist = (byCheck.get(check.id) ?? []).slice().reverse();
const upPct =
hist.length > 0
? Math.round(
(hist.filter((h) => h.ok).length / hist.length) * 100,
)
: null;
return (
<div
key={check.id}
className="flex flex-col gap-1 border border-border bg-muted/20 p-2"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{result?.ok ? (
<CheckCircle2 className="size-3.5 text-accent-brand" />
) : (
<XCircle className="size-3.5 text-destructive" />
)}
<span className="text-xs font-semibold">{check.name}</span>
<span className="font-mono text-[10px] text-muted-foreground/60">
{check.type === "tcp"
? `${check.target}:${check.port}`
: check.target}
</span>
</div>
<span className="flex gap-2 font-mono text-[10px] text-muted-foreground">
<span>
{result?.latencyMs != null
? `${result.latencyMs}ms`
: "-"}
</span>
{upPct != null && <span>{upPct}%</span>}
</span>
</div>
{hist.length > 1 && (
<Sparkline
data={hist.map((h) => h.latencyMs ?? 0)}
height={28}
showLastDot={false}
/>
)}
</div>
);
})}
</div>
)}
</ManagerCardShell>
);
}
@@ -0,0 +1,199 @@
import { useAdaptivePolling, Select2 } from "@termix/plugin-sdk/ui";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ScrollText } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { useHostMetricsApi } from "../../host-metrics-api";
import { extractError } from "./useManagerData";
import { ManagerCardShell } from "@termix/plugin-sdk/ui";
interface LogFiles {
common: string[];
files: string[];
}
type Mode = "file" | "unit";
export function LogViewerCard({ hostId }: { hostId: number | null }) {
const { managerGet, managerGetSub } = useHostMetricsApi();
const { t } = useTranslation();
const [mode, setMode] = useState<Mode>("file");
const [path, setPath] = useState<string>("/var/log/syslog");
const [unit, setUnit] = useState<string>("");
const [customPath, setCustomPath] = useState("");
const [lines, setLines] = useState(300);
const [content, setContent] = useState("");
const [grep, setGrep] = useState("");
const [follow, setFollow] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [files, setFiles] = useState<string[]>([]);
const bodyRef = useRef<HTMLPreElement | null>(null);
const contentRef = useRef(content);
contentRef.current = content;
// Load the host's actual log files once.
useEffect(() => {
if (hostId == null) return;
managerGetSub<LogFiles>(hostId, "logs", "files")
.then((res) => {
const merged = Array.from(
new Set([...(res.common ?? []), ...(res.files ?? [])]),
);
setFiles(merged);
if (merged.length && !merged.includes(path)) setPath(merged[0]);
})
.catch(() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hostId]);
const fetchLog = useCallback(async () => {
if (hostId == null) return false;
const params: Record<string, string | number> = { lines };
if (mode === "unit") {
if (!unit.trim()) return false;
params.unit = unit.trim();
} else {
const p = customPath.trim() || path;
if (!p) return false;
params.path = p;
}
setLoading(true);
setError(null);
try {
const res = await managerGet<{ content: string }>(hostId, "logs", params);
const next = res.content || "";
const changed = next !== contentRef.current;
if (changed) {
contentRef.current = next;
setContent(next);
requestAnimationFrame(() => {
if (bodyRef.current)
bodyRef.current.scrollTop = bodyRef.current.scrollHeight;
});
}
return changed;
} catch (e) {
setError(extractError(e).message);
} finally {
setLoading(false);
}
}, [customPath, hostId, lines, mode, path, unit]);
useEffect(() => {
void fetchLog();
// Custom paths are applied on blur/manual refresh, not on every keystroke.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hostId, mode, path, unit, lines]);
useAdaptivePolling(
fetchLog,
{
minIntervalMs: 3_000,
maxIntervalMs: 18_000,
stablePollsPerStep: 2,
maxRequestDutyCycle: 0.2,
},
follow,
{ runImmediately: false },
);
const shown = useMemo(() => {
if (!grep) return content;
const needle = grep.toLowerCase();
return content
.split("\n")
.filter((l) => l.toLowerCase().includes(needle))
.join("\n");
}, [content, grep]);
return (
<ManagerCardShell
title={t("hostMetrics.managers.logViewer")}
icon={<ScrollText className="size-3.5" />}
loading={loading}
error={error ? { message: error } : null}
onRefresh={fetchLog}
headerExtra={
<label className="flex items-center gap-1 text-[10px] text-muted-foreground">
<input
type="checkbox"
checked={follow}
onChange={(e) => setFollow(e.target.checked)}
className="accent-accent-brand"
/>
{t("hostMetrics.managers.follow")}
</label>
}
>
<div className="mb-2 flex items-center gap-1.5">
<Select2
value={mode}
onChange={(e) => setMode(e.target.value as Mode)}
className="h-7 border border-border bg-background px-1 text-[11px]"
>
<option value="file">{t("hostMetrics.managers.logFile")}</option>
<option value="unit">{t("hostMetrics.managers.logUnit")}</option>
</Select2>
{mode === "file" ? (
<Select2
value={customPath ? "" : path}
onChange={(e) => {
setCustomPath("");
setPath(e.target.value);
}}
className="h-7 flex-1 border border-border bg-background px-2 font-mono text-[11px] outline-none focus:ring-1 focus:ring-ring"
>
{files.map((f) => (
<option key={f} value={f}>
{f}
</option>
))}
</Select2>
) : (
<input
value={unit}
onChange={(e) => setUnit(e.target.value)}
onBlur={fetchLog}
placeholder="nginx.service"
className="h-7 flex-1 border border-border bg-background px-2 font-mono text-[11px] outline-none focus:ring-1 focus:ring-ring"
/>
)}
<Select2
value={lines}
onChange={(e) => setLines(Number(e.target.value))}
className="h-7 border border-border bg-background px-1 text-[11px]"
>
{[100, 300, 500, 1000, 2000].map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</Select2>
</div>
{mode === "file" && (
<input
value={customPath}
onChange={(e) => setCustomPath(e.target.value)}
onBlur={() => customPath && fetchLog()}
placeholder={t("hostMetrics.managers.logCustomPath")}
className="mb-2 h-7 w-full border border-border bg-background px-2 font-mono text-[11px] outline-none focus:ring-1 focus:ring-ring"
/>
)}
<input
value={grep}
onChange={(e) => setGrep(e.target.value)}
placeholder={t("hostMetrics.managers.logGrep")}
className="mb-2 h-7 w-full border border-border bg-background px-2 text-[11px] outline-none focus:ring-1 focus:ring-ring"
/>
<pre
ref={bodyRef}
className="max-h-[320px] overflow-auto whitespace-pre-wrap break-all border border-border/50 bg-muted/20 p-2 font-mono text-[10px] leading-relaxed"
>
{shown || t("hostMetrics.managers.noLogData")}
</pre>
</ManagerCardShell>
);
}
@@ -0,0 +1,116 @@
import { Button } from "@termix/plugin-sdk/ui";
import { useMemo, useState } from "react";
import { Package, ArrowUpCircle } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { toast } from "sonner";
import { useHostMetricsApi } from "../../host-metrics-api";
import { useManagerData, extractError } from "./useManagerData";
import { ManagerCardShell } from "@termix/plugin-sdk/ui";
import { ManagerSearch } from "@termix/plugin-sdk/ui";
interface UpgradablePackage {
name: string;
currentVersion?: string;
newVersion?: string;
}
export function PackageManagerCard({ hostId }: { hostId: number | null }) {
const { managerPost } = useHostMetricsApi();
const { t } = useTranslation();
const { data, loading, error, refresh } = useManagerData<{
pkg: string | null;
upgradable: UpgradablePackage[];
}>(hostId, "packages");
const [busy, setBusy] = useState<string | null>(null);
const [filter, setFilter] = useState("");
const all = useMemo(() => data?.upgradable ?? [], [data?.upgradable]);
const pkgs = useMemo(
() =>
all.filter(
(p) => !filter || p.name.toLowerCase().includes(filter.toLowerCase()),
),
[all, filter],
);
const run = async (action: string, pkg?: string) => {
if (hostId == null) return;
setBusy(pkg ?? action);
toast.loading(t("hostMetrics.managers.working"), { id: "pkg-op" });
try {
const res = await managerPost<{ success: boolean; output: string }>(
hostId,
"packages",
{ action, pkg },
"action",
);
toast[res.success ? "success" : "error"](
res.success
? t("hostMetrics.managers.actionDone", { name: pkg ?? "upgrade" })
: t("hostMetrics.managers.actionFailed"),
{ id: "pkg-op", description: res.output?.slice(-200) },
);
if (res.success) refresh();
} catch (e) {
toast.error(extractError(e).message, { id: "pkg-op" });
} finally {
setBusy(null);
}
};
return (
<ManagerCardShell
title={t("hostMetrics.managers.packages")}
icon={<Package className="size-3.5" />}
loading={loading}
error={error}
onRefresh={refresh}
empty={!loading && all.length === 0}
emptyMessage={t("hostMetrics.managers.allUpToDate")}
headerExtra={
all.length > 0 ? (
<Button
variant="outline"
size="xs"
disabled={!!busy}
onClick={() => run("upgrade-all")}
>
<ArrowUpCircle className="size-3" />
{t("hostMetrics.managers.upgradeAll")}
</Button>
) : undefined
}
>
{all.length > 5 && (
<ManagerSearch
value={filter}
onChange={setFilter}
count={pkgs.length}
/>
)}
<div className="flex flex-col">
{pkgs.map((p) => (
<div
key={p.name}
className="flex items-center justify-between gap-2 border-b border-border/50 py-1.5 last:border-0"
>
<div className="flex min-w-0 flex-col">
<span className="truncate text-xs font-semibold">{p.name}</span>
<span className="truncate font-mono text-[10px] text-muted-foreground">
{p.currentVersion ? `${p.currentVersion} → ` : ""}
{p.newVersion ?? ""}
</span>
</div>
<Button
variant="ghost"
size="xs"
disabled={busy === p.name}
onClick={() => run("install", p.name)}
>
{t("hostMetrics.managers.update")}
</Button>
</div>
))}
</div>
</ManagerCardShell>
);
}
@@ -0,0 +1,195 @@
import { Select2 } from "@termix/plugin-sdk/ui";
import { useMemo, useState } from "react";
import { ListTree, X, Skull } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { toast } from "sonner";
import { useHostMetricsApi } from "../../host-metrics-api";
import { useManagerData, extractError } from "./useManagerData";
import { ManagerCardShell } from "@termix/plugin-sdk/ui";
import { ManagerSearch } from "@termix/plugin-sdk/ui";
interface ProcessRow {
pid: number;
ppid: number;
user: string;
cpu: number;
mem: number;
command: string;
args: string;
}
type SortKey = "cpu" | "mem" | "pid";
type Signal = "TERM" | "KILL";
export function ProcessInspectorCard({ hostId }: { hostId: number | null }) {
const { managerPost } = useHostMetricsApi();
const { t } = useTranslation();
const { data, loading, error, refresh } = useManagerData<{
processes: ProcessRow[];
}>(hostId, "processes");
const [filter, setFilter] = useState("");
const [sort, setSort] = useState<SortKey>("cpu");
const [tree, setTree] = useState(false);
const [busy, setBusy] = useState<number | null>(null);
const all = useMemo(() => data?.processes ?? [], [data?.processes]);
const filtered = useMemo(() => {
const q = filter.toLowerCase();
const list = all.filter(
(p) =>
!q ||
p.args.toLowerCase().includes(q) ||
p.command.toLowerCase().includes(q) ||
String(p.pid).includes(q) ||
p.user.toLowerCase().includes(q),
);
return [...list].sort((a, b) => {
if (sort === "pid") return a.pid - b.pid;
return b[sort] - a[sort];
});
}, [all, filter, sort]);
// Build a depth map for the tree view from ppid links (only among visible).
const treeRows = useMemo(() => {
if (!tree) return null;
const byId = new Map(filtered.map((p) => [p.pid, p]));
const children = new Map<number, ProcessRow[]>();
const roots: ProcessRow[] = [];
for (const p of filtered) {
if (p.ppid && byId.has(p.ppid)) {
const arr = children.get(p.ppid) ?? [];
arr.push(p);
children.set(p.ppid, arr);
} else {
roots.push(p);
}
}
const out: Array<{ proc: ProcessRow; depth: number }> = [];
const walk = (p: ProcessRow, depth: number) => {
out.push({ proc: p, depth });
for (const c of children.get(p.pid) ?? []) walk(c, depth + 1);
};
roots.forEach((r) => walk(r, 0));
return out;
}, [tree, filtered]);
const kill = async (pid: number, signal: Signal) => {
if (hostId == null) return;
setBusy(pid);
try {
const res = await managerPost<{ success: boolean; output: string }>(
hostId,
"processes",
{ pid, signal },
"signal",
);
if (res.success) {
toast.success(t("hostMetrics.managers.signalSent", { pid }));
refresh();
} else {
toast.error(res.output || t("hostMetrics.managers.actionFailed"));
}
} catch (e) {
toast.error(extractError(e).message);
} finally {
setBusy(null);
}
};
const rows = treeRows
? treeRows.slice(0, 200)
: filtered.slice(0, 200).map((proc) => ({ proc, depth: 0 }));
return (
<ManagerCardShell
title={t("hostMetrics.managers.processInspector")}
icon={<ListTree className="size-3.5" />}
loading={loading}
error={error}
onRefresh={refresh}
empty={!loading && filtered.length === 0}
headerExtra={
<>
<button
onClick={() => setTree((v) => !v)}
className={`flex h-6 items-center gap-1 border px-1.5 text-[10px] transition-colors ${
tree
? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand"
: "border-border text-muted-foreground hover:text-foreground"
}`}
>
<ListTree className="size-3" />
{t("hostMetrics.managers.tree")}
</button>
<Select2
value={sort}
onChange={(e) => setSort(e.target.value as SortKey)}
disabled={tree}
className="h-6 border border-border bg-background px-1 text-[10px] disabled:opacity-40"
>
<option value="cpu">CPU</option>
<option value="mem">MEM</option>
<option value="pid">PID</option>
</Select2>
</>
}
>
<ManagerSearch
value={filter}
onChange={setFilter}
count={filtered.length}
/>
<div className="flex flex-col">
<div className="grid grid-cols-[3rem_2.5rem_2.5rem_1fr_1.5rem] gap-2 border-b border-border pb-1 text-[10px] font-bold uppercase text-muted-foreground">
<span>PID</span>
<span>CPU</span>
<span>MEM</span>
<span>CMD</span>
<span />
</div>
{rows.map(({ proc: p, depth }) => (
<div
key={p.pid}
className="relative grid grid-cols-[3rem_2.5rem_2.5rem_1fr_1.5rem] items-center gap-2 border-b border-border/50 py-1 font-mono text-xs last:border-0"
>
<span className="text-muted-foreground">{p.pid}</span>
<span className="font-bold text-accent-brand">
{p.cpu.toFixed(0)}%
</span>
<span>{p.mem.toFixed(0)}%</span>
<span
className="truncate"
title={`${p.user}\n${p.args}`}
style={
depth ? { paddingLeft: `${(depth * 12) / 14}rem` } : undefined
}
>
{depth > 0 && (
<span className="text-muted-foreground/40">└ </span>
)}
{/* `comm` is capped at 15 chars by the kernel; the full command line is in args. */}
{p.args || p.command}
</span>
<button
onClick={() => kill(p.pid, "TERM")}
onContextMenu={(e) => {
e.preventDefault();
kill(p.pid, "KILL");
}}
disabled={busy === p.pid}
title={t("hostMetrics.managers.killHint")}
className="flex size-5 items-center justify-center text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive disabled:opacity-40"
>
{busy === p.pid ? (
<Skull className="size-3" />
) : (
<X className="size-3" />
)}
</button>
</div>
))}
</div>
</ManagerCardShell>
);
}
@@ -0,0 +1,155 @@
import { useMemo, useState } from "react";
import { Cog, Play, Square, RotateCw, Power } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { toast } from "sonner";
import { useHostMetricsApi } from "../../host-metrics-api";
import { useManagerData, extractError } from "./useManagerData";
import { ManagerCardShell } from "@termix/plugin-sdk/ui";
import { ManagerSearch } from "@termix/plugin-sdk/ui";
interface SystemdService {
unit: string;
active: string;
sub: string;
description: string;
}
export function ServiceManagerCard({ hostId }: { hostId: number | null }) {
const { managerPost } = useHostMetricsApi();
const { t } = useTranslation();
const { data, loading, error, refresh } = useManagerData<{
services: SystemdService[];
}>(hostId, "services");
const [filter, setFilter] = useState("");
const [busy, setBusy] = useState<string | null>(null);
const services = useMemo(
() =>
(data?.services ?? []).filter(
(s) =>
!filter ||
s.unit.toLowerCase().includes(filter.toLowerCase()) ||
s.description.toLowerCase().includes(filter.toLowerCase()),
),
[data?.services, filter],
);
const act = async (unit: string, action: string) => {
if (hostId == null) return;
setBusy(`${unit}:${action}`);
try {
const res = await managerPost<{ success: boolean; output: string }>(
hostId,
"services",
{ unit, action },
"action",
);
if (res.success) {
toast.success(t("hostMetrics.managers.actionDone", { name: unit }));
refresh();
} else {
toast.error(res.output || t("hostMetrics.managers.actionFailed"));
}
} catch (e) {
toast.error(extractError(e).message);
} finally {
setBusy(null);
}
};
return (
<ManagerCardShell
title={t("hostMetrics.managers.services")}
icon={<Cog className="size-3.5" />}
loading={loading}
error={error}
onRefresh={refresh}
empty={!loading && services.length === 0}
>
<ManagerSearch
value={filter}
onChange={setFilter}
count={services.length}
/>
<div className="flex flex-col">
{services.map((s) => (
<div
key={s.unit}
className="flex items-center justify-between gap-2 border-b border-border/50 py-1.5 last:border-0"
>
<div className="flex min-w-0 items-center gap-2">
<span
className={`size-1.5 shrink-0 rounded-full ${s.active === "active" ? "bg-accent-brand" : "bg-muted-foreground/40"}`}
/>
<div className="flex min-w-0 flex-col">
<span className="truncate text-xs font-semibold" title={s.unit}>
{s.unit.replace(/\.service$/, "")}
</span>
<span className="truncate text-[10px] text-muted-foreground">
{s.sub}
</span>
</div>
</div>
<div className="flex shrink-0 items-center gap-0.5">
<ActionBtn
onClick={() => act(s.unit, "start")}
busy={busy === `${s.unit}:start`}
title={t("hostMetrics.managers.start")}
>
<Play className="size-3" />
</ActionBtn>
<ActionBtn
onClick={() => act(s.unit, "restart")}
busy={busy === `${s.unit}:restart`}
title={t("hostMetrics.managers.restart")}
>
<RotateCw className="size-3" />
</ActionBtn>
<ActionBtn
onClick={() => act(s.unit, "stop")}
busy={busy === `${s.unit}:stop`}
title={t("hostMetrics.managers.stop")}
>
<Square className="size-3" />
</ActionBtn>
<ActionBtn
onClick={() =>
act(s.unit, s.sub === "running" ? "disable" : "enable")
}
busy={
busy === `${s.unit}:enable` || busy === `${s.unit}:disable`
}
title={t("hostMetrics.managers.enableDisable")}
>
<Power className="size-3" />
</ActionBtn>
</div>
</div>
))}
</div>
</ManagerCardShell>
);
}
function ActionBtn({
onClick,
busy,
title,
children,
}: {
onClick: () => void;
busy: boolean;
title: string;
children: React.ReactNode;
}) {
return (
<button
onClick={onClick}
disabled={busy}
title={title}
className="flex size-6 items-center justify-center text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-40"
>
{children}
</button>
);
}
@@ -0,0 +1,165 @@
import { BarSeries, StatRow } from "@termix/plugin-sdk/ui";
import { useMemo, useState } from "react";
import { MemoryStick, Timer, HardDrive } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { useManagerData } from "./useManagerData";
import { ManagerCardShell } from "@termix/plugin-sdk/ui";
import { ManagerSearch } from "@termix/plugin-sdk/ui";
interface MemProc {
pid: number;
user: string;
mem: number;
rss: number;
command: string;
}
interface TimerRow {
unit: string;
activates: string;
next: string;
}
interface MountUsage {
filesystem: string;
usePct: number;
usedKb: number;
sizeKb: number;
mount: string;
}
function fmtGiB(kb: number): string {
return `${(kb / 1024 / 1024).toFixed(1)}G`;
}
export function TopMemoryCard({ hostId }: { hostId: number | null }) {
const { t } = useTranslation();
const { data, loading, error, refresh } = useManagerData<{
processes: MemProc[];
}>(hostId, "top-memory");
const [query, setQuery] = useState("");
const all = useMemo(() => data?.processes ?? [], [data?.processes]);
const procs = useMemo(() => {
const q = query.toLowerCase();
return all.filter(
(p) =>
!q ||
p.command.toLowerCase().includes(q) ||
String(p.pid).includes(q) ||
p.user.toLowerCase().includes(q),
);
}, [all, query]);
return (
<ManagerCardShell
title={t("hostMetrics.managers.topMemory")}
icon={<MemoryStick className="size-3.5" />}
loading={loading}
error={error}
onRefresh={refresh}
empty={!loading && all.length === 0}
>
{all.length > 5 && (
<ManagerSearch value={query} onChange={setQuery} count={procs.length} />
)}
<BarSeries
items={procs.map((p) => ({
label: `${p.command} (${p.pid})`,
value: p.mem,
valueLabel: `${p.mem.toFixed(1)}%`,
}))}
max={Math.max(1, ...procs.map((p) => p.mem))}
/>
</ManagerCardShell>
);
}
export function SystemdTimersCard({ hostId }: { hostId: number | null }) {
const { t } = useTranslation();
const { data, loading, error, refresh } = useManagerData<{
timers: TimerRow[];
}>(hostId, "timers");
const [query, setQuery] = useState("");
const all = useMemo(() => data?.timers ?? [], [data?.timers]);
const timers = useMemo(() => {
const q = query.toLowerCase();
return all.filter(
(tm) =>
!q ||
tm.unit.toLowerCase().includes(q) ||
tm.activates.toLowerCase().includes(q),
);
}, [all, query]);
return (
<ManagerCardShell
title={t("hostMetrics.managers.systemdTimers")}
icon={<Timer className="size-3.5" />}
loading={loading}
error={error}
onRefresh={refresh}
empty={!loading && all.length === 0}
>
{all.length > 5 && (
<ManagerSearch
value={query}
onChange={setQuery}
count={timers.length}
/>
)}
<div className="flex flex-col divide-y divide-border">
{timers.map((tm) => (
<StatRow
key={tm.unit}
label={tm.unit.replace(/\.timer$/, "")}
value={tm.activates}
mono
/>
))}
</div>
</ManagerCardShell>
);
}
export function DiskBreakdownCard({ hostId }: { hostId: number | null }) {
const { t } = useTranslation();
const { data, loading, error, refresh } = useManagerData<{
mounts: MountUsage[];
}>(hostId, "disk-breakdown");
const [query, setQuery] = useState("");
const all = useMemo(() => data?.mounts ?? [], [data?.mounts]);
const mounts = useMemo(() => {
const q = query.toLowerCase();
return all.filter(
(m) =>
!q ||
m.mount.toLowerCase().includes(q) ||
m.filesystem.toLowerCase().includes(q),
);
}, [all, query]);
return (
<ManagerCardShell
title={t("hostMetrics.managers.diskBreakdown")}
icon={<HardDrive className="size-3.5" />}
loading={loading}
error={error}
onRefresh={refresh}
empty={!loading && all.length === 0}
>
{all.length > 5 && (
<ManagerSearch
value={query}
onChange={setQuery}
count={mounts.length}
/>
)}
<BarSeries
items={mounts.map((m) => ({
label: `${m.mount} (${fmtGiB(m.usedKb)}/${fmtGiB(m.sizeKb)})`,
value: m.usePct,
valueLabel: `${m.usePct}%`,
}))}
max={100}
/>
</ManagerCardShell>
);
}
@@ -0,0 +1,302 @@
import { Button, Select2 } from "@termix/plugin-sdk/ui";
import { useMemo, useState } from "react";
import { ShieldCheck, RefreshCw, Plus, X, Trash2 } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { useHostMetricsApi } from "../../host-metrics-api";
import {
useManagerData,
useManagerAction,
extractError,
} from "./useManagerData";
import { ManagerCardShell } from "@termix/plugin-sdk/ui";
interface CertInfo {
client: string;
name: string;
domains: string[];
expiry: string | null;
}
interface SslData {
clients: { certbot: boolean; acmeSh: boolean };
certs: CertInfo[];
}
type Challenge = "http-standalone" | "http-webroot" | "dns";
/** Days until the given expiry string, or null if unparseable. */
function daysUntil(expiry: string | null): number | null {
if (!expiry) return null;
const ts = Date.parse(expiry);
if (Number.isNaN(ts)) return null;
return Math.round((ts - Date.now()) / 86400000);
}
function ExpiryBadge({ expiry }: { expiry: string | null }) {
const { t } = useTranslation();
const days = daysUntil(expiry);
if (days == null)
return <span className="text-muted-foreground">{expiry ?? "—"}</span>;
const tone =
days < 0
? "text-destructive"
: days < 14
? "text-yellow-500"
: "text-accent-brand";
return (
<span className={tone} title={expiry ?? undefined}>
{days < 0
? t("hostMetrics.managers.sslExpired")
: t("hostMetrics.managers.sslInDays", { days })}
</span>
);
}
export function SslManagerCard({ hostId }: { hostId: number | null }) {
const { managerPost } = useHostMetricsApi();
const { t } = useTranslation();
const { data, loading, error, refresh } = useManagerData<SslData>(
hostId,
"ssl",
);
const { busy, run } = useManagerAction(hostId);
const [showIssue, setShowIssue] = useState(false);
const [domains, setDomains] = useState("");
const [challenge, setChallenge] = useState<Challenge>("http-standalone");
const [webroot, setWebroot] = useState("");
const [dnsProvider, setDnsProvider] = useState("");
const [issuing, setIssuing] = useState(false);
const clients = data?.clients;
const activeClient: "certbot" | "acme.sh" | null = clients?.certbot
? "certbot"
: clients?.acmeSh
? "acme.sh"
: null;
const certs = useMemo(() => data?.certs ?? [], [data?.certs]);
const renew = (dryRun: boolean) => {
if (!activeClient) return;
run(
"ssl",
{ client: activeClient, dryRun },
{
action: "renew",
toastId: "ssl-op",
loadingMsg: t("hostMetrics.managers.working"),
successMsg: t("hostMetrics.managers.actionDone", { name: "renew" }),
failMsg: t("hostMetrics.managers.actionFailed"),
onDone: refresh,
},
);
};
const revoke = (cert: CertInfo) => {
if (cert.client !== "certbot" && cert.client !== "acme.sh") return;
if (
!window.confirm(
t("hostMetrics.managers.sslRevokeConfirm", { name: cert.name }),
)
)
return;
run(
"ssl",
{ client: cert.client, name: cert.name },
{
action: "revoke",
toastId: "ssl-op",
loadingMsg: t("hostMetrics.managers.working"),
successMsg: t("hostMetrics.managers.sslRevoked"),
failMsg: t("hostMetrics.managers.actionFailed"),
onDone: refresh,
},
);
};
const issue = async () => {
if (hostId == null || !activeClient) return;
const list = domains
.split(/[\s,]+/)
.map((d) => d.trim())
.filter(Boolean);
if (list.length === 0) {
const { toast } = await import("sonner");
toast.error(t("hostMetrics.managers.sslNeedDomain"));
return;
}
setIssuing(true);
const { toast } = await import("sonner");
toast.loading(t("hostMetrics.managers.working"), { id: "ssl-issue" });
try {
const res = await managerPost<{ success: boolean; output: string }>(
hostId,
"ssl",
{
client: activeClient,
domains: list,
challenge,
webroot: challenge === "http-webroot" ? webroot : undefined,
dnsProvider: challenge === "dns" ? dnsProvider : undefined,
},
"issue",
);
toast[res.success ? "success" : "error"](
res.success
? t("hostMetrics.managers.sslIssued")
: t("hostMetrics.managers.actionFailed"),
{ id: "ssl-issue", description: res.output?.slice(-200) },
);
if (res.success) {
setShowIssue(false);
setDomains("");
refresh();
}
} catch (e) {
toast.error(extractError(e).message, { id: "ssl-issue" });
} finally {
setIssuing(false);
}
};
return (
<ManagerCardShell
title={t("hostMetrics.managers.ssl")}
icon={<ShieldCheck className="size-3.5" />}
loading={loading}
error={error}
onRefresh={refresh}
empty={!loading && certs.length === 0 && !activeClient}
emptyMessage={t("hostMetrics.managers.noAcmeClient")}
headerExtra={
activeClient ? (
<>
<Button
variant="ghost"
size="xs"
disabled={busy}
onClick={() => renew(true)}
>
{t("hostMetrics.managers.dryRun")}
</Button>
<Button
variant="outline"
size="xs"
disabled={busy}
onClick={() => renew(false)}
>
<RefreshCw className="size-3" />
{t("hostMetrics.managers.renew")}
</Button>
</>
) : undefined
}
>
<div className="mb-2 flex items-center justify-between text-[10px] uppercase tracking-widest text-muted-foreground">
<div className="flex items-center gap-2">
<span>{t("hostMetrics.managers.clients")}:</span>
<span className={clients?.certbot ? "text-accent-brand" : ""}>
certbot
</span>
<span className={clients?.acmeSh ? "text-accent-brand" : ""}>
acme.sh
</span>
</div>
{activeClient && (
<Button
variant="ghost"
size="xs"
onClick={() => setShowIssue((v) => !v)}
>
{showIssue ? <X className="size-3" /> : <Plus className="size-3" />}
{t("hostMetrics.managers.sslIssueCert")}
</Button>
)}
</div>
{showIssue && activeClient && (
<div className="mb-3 flex flex-col gap-2 border border-dashed border-border p-2">
<input
value={domains}
onChange={(e) => setDomains(e.target.value)}
placeholder={t("hostMetrics.managers.sslDomainsPlaceholder")}
className="h-7 w-full border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
/>
<div className="flex items-center gap-1.5">
<Select2
value={challenge}
onChange={(e) => setChallenge(e.target.value as Challenge)}
className="h-7 flex-1 border border-border bg-background px-1 text-xs"
>
<option value="http-standalone">
{t("hostMetrics.managers.sslHttpStandalone")}
</option>
<option value="http-webroot">
{t("hostMetrics.managers.sslHttpWebroot")}
</option>
<option value="dns">{t("hostMetrics.managers.sslDns")}</option>
</Select2>
</div>
{challenge === "http-webroot" && (
<input
value={webroot}
onChange={(e) => setWebroot(e.target.value)}
placeholder="/var/www/html"
className="h-7 w-full border border-border bg-background px-2 font-mono text-xs outline-none focus:ring-1 focus:ring-ring"
/>
)}
{challenge === "dns" && (
<input
value={dnsProvider}
onChange={(e) => setDnsProvider(e.target.value)}
placeholder={t("hostMetrics.managers.sslDnsProvider")}
className="h-7 w-full border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
/>
)}
<Button
variant="outline"
size="sm"
disabled={issuing}
onClick={issue}
className="self-start"
>
{t("hostMetrics.managers.sslIssueCert")}
</Button>
<span className="text-[10px] text-muted-foreground/70">
{t("hostMetrics.managers.sslIssueHint")}
</span>
</div>
)}
<div className="flex flex-col divide-y divide-border">
{certs.map((c) => (
<div
key={`${c.client}-${c.name}`}
className="flex items-center justify-between gap-2 py-1.5 text-xs"
>
<span
className="truncate font-semibold"
title={c.domains.join(", ")}
>
{c.name}
</span>
<div className="flex shrink-0 items-center gap-2">
<span className="font-mono text-[11px]">
<ExpiryBadge expiry={c.expiry} />
</span>
{(c.client === "certbot" || c.client === "acme.sh") && (
<button
onClick={() => revoke(c)}
disabled={busy}
title={t("hostMetrics.managers.sslRevoke")}
className="text-muted-foreground transition-colors hover:text-destructive disabled:opacity-40"
>
<Trash2 className="size-3.5" />
</button>
)}
</div>
</div>
))}
</div>
</ManagerCardShell>
);
}
@@ -0,0 +1,146 @@
import { Button } from "@termix/plugin-sdk/ui";
import { useMemo, useState } from "react";
import { Users, Plus, Trash2, ShieldCheck, Shield } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { toast } from "sonner";
import { useHostMetricsApi } from "../../host-metrics-api";
import { useManagerData, extractError } from "./useManagerData";
import { ManagerCardShell } from "@termix/plugin-sdk/ui";
import { ManagerSearch } from "@termix/plugin-sdk/ui";
interface SystemUser {
name: string;
uid: number;
shell: string;
}
interface UsersData {
users: SystemUser[];
sudoers: string[];
}
export function UserManagerCard({ hostId }: { hostId: number | null }) {
const { managerPost } = useHostMetricsApi();
const { t } = useTranslation();
const { data, loading, error, refresh } = useManagerData<UsersData>(
hostId,
"users",
);
const [newUser, setNewUser] = useState("");
const [filter, setFilter] = useState("");
const [busy, setBusy] = useState<string | null>(null);
const sudoers = new Set(data?.sudoers ?? []);
const users = useMemo(
() =>
(data?.users ?? []).filter(
(u) => !filter || u.name.toLowerCase().includes(filter.toLowerCase()),
),
[data?.users, filter],
);
const action = async (act: string, username: string, group?: string) => {
if (hostId == null) return;
setBusy(username);
try {
const res = await managerPost<{ success: boolean; output: string }>(
hostId,
"users",
{ action: act, username, group },
"action",
);
if (res.success) {
toast.success(t("hostMetrics.managers.actionDone", { name: username }));
if (act === "create") setNewUser("");
refresh();
} else {
toast.error(res.output || t("hostMetrics.managers.actionFailed"));
}
} catch (e) {
toast.error(extractError(e).message);
} finally {
setBusy(null);
}
};
return (
<ManagerCardShell
title={t("hostMetrics.managers.users")}
icon={<Users className="size-3.5" />}
loading={loading}
error={error}
onRefresh={refresh}
>
<div className="mb-2 flex items-center gap-1.5">
<input
value={newUser}
onChange={(e) => setNewUser(e.target.value)}
placeholder={t("hostMetrics.managers.newUsername")}
className="h-7 flex-1 border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
/>
<Button
variant="outline"
size="xs"
disabled={!newUser || busy === newUser}
onClick={() => action("create", newUser)}
>
<Plus className="size-3" />
{t("hostMetrics.managers.addUser")}
</Button>
</div>
<ManagerSearch value={filter} onChange={setFilter} count={users.length} />
<div className="flex flex-col">
{users.map((u) => {
const isSudoer = sudoers.has(u.name);
return (
<div
key={u.name}
className="flex items-center justify-between gap-2 border-b border-border/50 py-1.5 last:border-0"
>
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-xs font-semibold">{u.name}</span>
{isSudoer && (
<ShieldCheck className="size-3 shrink-0 text-accent-brand" />
)}
<span className="truncate font-mono text-[10px] text-muted-foreground">
{u.uid}
</span>
</div>
<div className="flex shrink-0 items-center gap-1">
<button
onClick={() =>
action(
isSudoer ? "removeFromGroup" : "addToGroup",
u.name,
"sudo",
)
}
disabled={busy === u.name}
title={
isSudoer
? t("hostMetrics.managers.revokeSudo")
: t("hostMetrics.managers.grantSudo")
}
className={`disabled:opacity-40 ${
isSudoer
? "text-accent-brand hover:text-muted-foreground"
: "text-muted-foreground hover:text-accent-brand"
}`}
>
<Shield className="size-3.5" />
</button>
<button
onClick={() => action("delete", u.name)}
disabled={busy === u.name}
title={t("hostMetrics.managers.deleteUser")}
className="text-muted-foreground hover:text-destructive disabled:opacity-40"
>
<Trash2 className="size-3.5" />
</button>
</div>
</div>
);
})}
</div>
</ManagerCardShell>
);
}
@@ -0,0 +1,211 @@
import { useState } from "react";
import {
ChevronDown,
ChevronRight,
Network,
ArrowUp,
ArrowDown,
} from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { useManagerData, useManagerAction } from "./useManagerData";
import { ManagerCardShell } from "@termix/plugin-sdk/ui";
interface WireGuardPeer {
publicKey: string;
endpoint: string | null;
allowedIPs: string[];
latestHandshake: number | null;
rxBytes: number;
txBytes: number;
}
interface WireGuardInterface {
name: string;
publicKey: string | null;
listenPort: number | null;
up: boolean;
peers: WireGuardPeer[];
}
interface WireGuardData {
installed: boolean;
interfaces: WireGuardInterface[];
}
function fmtBytes(n: number): string {
if (n >= 1024 * 1024 * 1024)
return `${(n / 1024 / 1024 / 1024).toFixed(1)}GB`;
if (n >= 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)}MB`;
if (n >= 1024) return `${(n / 1024).toFixed(1)}KB`;
return `${n}B`;
}
function fmtHandshake(ts: number | null): string {
if (!ts) return "";
const diff = Math.floor(Date.now() / 1000) - ts;
if (diff < 60) return `${diff}s ago`;
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
return `${Math.floor(diff / 86400)}d ago`;
}
function truncKey(key: string): string {
return key.length > 12 ? `${key.slice(0, 12)}...` : key;
}
export function WireGuardManagerCard({ hostId }: { hostId: number | null }) {
const { t } = useTranslation();
const { data, loading, error, refresh } = useManagerData<WireGuardData>(
hostId,
"wireguard",
);
const { busy, run } = useManagerAction(hostId);
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const toggleExpand = (name: string) => {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(name)) next.delete(name);
else next.add(name);
return next;
});
};
const handleAction = async (iface: WireGuardInterface) => {
const action = iface.up ? "down" : "up";
await run(
"wireguard",
{ interface: iface.name, action },
{
action: "action",
loadingMsg:
action === "up"
? t("hostMetrics.managers.wgBringingUp", { name: iface.name })
: t("hostMetrics.managers.wgBringingDown", { name: iface.name }),
successMsg:
action === "up"
? t("hostMetrics.managers.wgInterfaceUpDone", { name: iface.name })
: t("hostMetrics.managers.wgInterfaceDownDone", {
name: iface.name,
}),
onDone: refresh,
},
);
};
const isNotInstalled = data && !data.installed;
const isEmpty = data?.installed && data.interfaces.length === 0;
return (
<ManagerCardShell
title={t("hostMetrics.managers.wireguard")}
icon={<Network className="size-3.5" />}
loading={loading}
error={error}
onRefresh={refresh}
empty={isNotInstalled || isEmpty}
emptyMessage={
isNotInstalled
? t("hostMetrics.managers.wgNotInstalled")
: t("hostMetrics.managers.wgNoInterfaces")
}
>
<div className="flex flex-col">
{data?.interfaces.map((iface) => (
<div
key={iface.name}
className="border-b border-border/50 last:border-0"
>
<div className="flex items-center justify-between gap-2 py-1.5">
<button
className="flex min-w-0 flex-1 items-center gap-2 text-left"
onClick={() => toggleExpand(iface.name)}
>
{expanded.has(iface.name) ? (
<ChevronDown className="size-3 shrink-0 text-muted-foreground" />
) : (
<ChevronRight className="size-3 shrink-0 text-muted-foreground" />
)}
<span
className={`size-1.5 shrink-0 rounded-full ${iface.up ? "bg-accent-brand" : "bg-muted-foreground/40"}`}
/>
<div className="flex min-w-0 flex-col">
<span className="text-xs font-semibold font-mono">
{iface.name}
</span>
<span className="flex gap-2 text-[10px] text-muted-foreground">
<span>{iface.up ? "up" : "down"}</span>
{iface.listenPort ? <span>:{iface.listenPort}</span> : null}
{iface.peers.length > 0 && (
<span>{iface.peers.length}p</span>
)}
</span>
</div>
</button>
<button
onClick={() => handleAction(iface)}
disabled={busy}
title={
iface.up
? t("hostMetrics.managers.wgInterfaceDown")
: t("hostMetrics.managers.wgInterfaceUp")
}
className="flex size-6 shrink-0 items-center justify-center text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-40"
>
{iface.up ? (
<ArrowDown className="size-3" />
) : (
<ArrowUp className="size-3" />
)}
</button>
</div>
{expanded.has(iface.name) && (
<div className="mb-1 ml-5 flex flex-col border-l border-border/50">
{iface.peers.length === 0 ? (
<span className="px-3 py-1 text-[10px] text-muted-foreground/50">
{t("hostMetrics.managers.wgNoPeers")}
</span>
) : (
iface.peers.map((peer) => (
<div
key={peer.publicKey}
className="flex flex-col gap-0.5 border-b border-border/30 px-3 py-1.5 last:border-0"
>
<div className="flex items-center justify-between gap-2">
<span className="font-mono text-[10px] text-muted-foreground">
{truncKey(peer.publicKey)}
</span>
{peer.endpoint && (
<span className="font-mono text-[10px] text-muted-foreground/60">
{peer.endpoint}
</span>
)}
</div>
{peer.allowedIPs.length > 0 && (
<span className="text-[10px] text-muted-foreground/70">
{peer.allowedIPs.join(", ")}
</span>
)}
<div className="flex items-center gap-3 text-[10px] text-muted-foreground/50">
<span>
{t("hostMetrics.managers.wgLastHandshake")}:{" "}
{peer.latestHandshake
? fmtHandshake(peer.latestHandshake)
: t("hostMetrics.managers.wgHandshakeNever")}
</span>
<span>
{fmtBytes(peer.rxBytes)} / {fmtBytes(peer.txBytes)}
</span>
</div>
</div>
))
)}
</div>
)}
</div>
))}
</div>
</ManagerCardShell>
);
}
@@ -0,0 +1,121 @@
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { useHostMetricsApi } from "../../host-metrics-api";
interface ManagerError {
message: string;
code?: string;
}
function extractError(err: unknown): ManagerError {
const e = err as {
response?: { data?: { error?: string; code?: string } };
message?: string;
};
return {
message: e?.response?.data?.error || e?.message || "Request failed",
code: e?.response?.data?.code,
};
}
/**
* Fetch a manager read resource on mount + manual refresh, with loading/error
* state. `hostId` null disables fetching.
*/
export function useManagerData<T>(
hostId: number | null,
resource: string,
params?: Record<string, string | number>,
) {
const { managerGet } = useHostMetricsApi();
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<ManagerError | null>(null);
const paramsKey = params ? JSON.stringify(params) : "";
const refresh = useCallback(async () => {
if (hostId == null) return;
setLoading(true);
setError(null);
try {
const res = await managerGet<T>(hostId, resource, params);
setData(res);
} catch (err) {
setError(extractError(err));
} finally {
setLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hostId, resource, paramsKey]);
useEffect(() => {
refresh();
}, [refresh]);
return { data, loading, error, refresh, setData };
}
interface ActionResult {
success: boolean;
output?: string;
}
/**
* Run a manager POST action with consistent toast feedback (loading -> success
* /error) and a busy flag. On success the optional `onDone` callback runs (e.g.
* to refresh the card). Returns the result so callers can branch further.
*/
export function useManagerAction(hostId: number | null) {
const { managerPost } = useHostMetricsApi();
const [busy, setBusy] = useState(false);
const run = useCallback(
async <T extends ActionResult>(
resource: string,
body: unknown,
opts: {
action?: string;
toastId?: string;
loadingMsg?: string;
successMsg?: string;
failMsg?: string;
onDone?: () => void;
} = {},
): Promise<T | null> => {
if (hostId == null) return null;
const id = opts.toastId ?? `${resource}-action`;
setBusy(true);
if (opts.loadingMsg) toast.loading(opts.loadingMsg, { id });
try {
const res = await managerPost<T>(hostId, resource, body, opts.action);
if (res.success) {
if (opts.successMsg)
toast.success(opts.successMsg, {
id,
description: res.output?.slice(-200),
});
else toast.dismiss(id);
opts.onDone?.();
} else {
toast.error(opts.failMsg ?? res.output ?? "Action failed", {
id,
description: res.output?.slice(-200),
});
}
return res;
} catch (err) {
toast.error(extractError(err).message, { id });
return null;
} finally {
setBusy(false);
}
},
[hostId],
);
return { busy, run };
}
export { extractError };
export type { ManagerError };
@@ -0,0 +1,92 @@
import { type HostMetricsLayout } from "../../shared/host-metrics.js";
import { useCallback, useEffect, useRef, useState } from "react";
import { useHostMetricsApi } from "../host-metrics-api";
const SAVE_DEBOUNCE_MS = 800;
function lsKey(hostId: number) {
return `hostMetricsLayout:${hostId}`;
}
function readCache(hostId: number): HostMetricsLayout | null {
try {
const raw = localStorage.getItem(lsKey(hostId));
if (!raw) return null;
const parsed = JSON.parse(raw);
if (parsed && Array.isArray(parsed.slots))
return parsed as HostMetricsLayout;
} catch {
/* ignore */
}
return null;
}
function writeCache(hostId: number, layout: HostMetricsLayout) {
try {
localStorage.setItem(lsKey(hostId), JSON.stringify(layout));
} catch {
/* ignore */
}
}
/**
* Per-host Host Metrics layout, cached in localStorage for instant paint and
* synced to the backend (so it follows the user across devices). Saves are
* debounced. Returns null layout until the first load resolves.
*/
export function useHostMetricsPreferences(hostId: number | null) {
const { getHostMetricsLayout, saveHostMetricsLayout } = useHostMetricsApi();
const [layout, setLayout] = useState<HostMetricsLayout | null>(() =>
hostId != null ? readCache(hostId) : null,
);
const [loaded, setLoaded] = useState(false);
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (hostId == null) return;
let cancelled = false;
setLoaded(false);
const cached = readCache(hostId);
if (cached) setLayout(cached);
getHostMetricsLayout(hostId)
.then((remote) => {
if (cancelled || !remote) return;
setLayout(remote);
writeCache(hostId, remote);
})
.catch(() => {
/* keep cache/default */
})
.finally(() => {
if (!cancelled) setLoaded(true);
});
return () => {
cancelled = true;
};
}, [hostId]);
const save = useCallback(
(next: HostMetricsLayout) => {
if (hostId == null) return;
setLayout(next);
writeCache(hostId, next);
if (saveTimer.current) clearTimeout(saveTimer.current);
saveTimer.current = setTimeout(() => {
saveHostMetricsLayout(hostId, next).catch(() => {
/* best-effort; cache already holds it */
});
}, SAVE_DEBOUNCE_MS);
},
[hostId],
);
useEffect(() => {
return () => {
if (saveTimer.current) clearTimeout(saveTimer.current);
};
}, []);
return { layout, setLayout: save, loaded };
}
@@ -0,0 +1,226 @@
import { useMemo } from "react";
import {
usePluginApi,
type PluginApiClient,
} from "@termix/plugin-sdk/frontend";
import type { HostMetricsLayout } from "../shared/host-metrics.js";
import type { ServerMetrics } from "../shared/metrics.js";
// Every call is keyed by a host's numeric database id, and the receiving
// backend must own that host in its own database: a synced host has a
// different numeric id on each side. They always target this backend.
export interface MetricsHistoryRow {
ts: string;
cpu_percent: number | null;
mem_percent: number | null;
disk_percent: number | null;
net_rx_bytes: number | null;
net_tx_bytes: number | null;
}
export interface MetricsHistoryResponse {
rows: MetricsHistoryRow[];
fromTs: string;
toTs: string;
}
export interface PlatformInfo {
hasSystemd: boolean;
pkg: "apt" | "dnf" | "yum" | "pacman" | null;
hasCertbot: boolean;
hasAcmeSh: boolean;
hasDocker: boolean;
osPrettyName: string | null;
}
export interface ConnectionLogEntry {
type: "info" | "success" | "warning" | "error";
stage: string;
message: string;
details?: Record<string, unknown>;
}
export interface StartMetricsResult {
success: boolean;
requires_totp?: boolean;
sessionId?: string;
prompt?: string;
viewerSessionId?: string;
connectionLogs?: ConnectionLogEntry[];
}
function statusOf(error: unknown): number | undefined {
return (error as { response?: { status?: number } })?.response?.status;
}
function bodyOf(error: unknown): Record<string, unknown> | undefined {
return (error as { response?: { data?: Record<string, unknown> } })?.response
?.data;
}
/** The plugin's routes, relative to /plugin-api/host-metrics/. */
export function createHostMetricsApi(api: PluginApiClient) {
return {
async getMetrics(hostId: number): Promise<ServerMetrics | null> {
try {
return (await api.get<ServerMetrics>(`/metrics/${hostId}`)).data;
} catch (error) {
// No sample yet, or metrics are off for the host.
if (statusOf(error) === 404) return null;
throw error;
}
},
/** Throws an Error carrying the server's connectionLogs on failure. */
async startMetrics(hostId: number): Promise<StartMetricsResult> {
try {
return (await api.post<StartMetricsResult>(`/metrics/start/${hostId}`))
.data;
} catch (error) {
const body = bodyOf(error);
if (body?.connectionLogs) {
const wrapped = new Error(
String(body.error || body.message || "Failed to start metrics"),
);
Object.assign(wrapped, { connectionLogs: body.connectionLogs });
throw wrapped;
}
throw error;
}
},
async stopMetrics(hostId: number, viewerSessionId?: string) {
await api.post(`/metrics/stop/${hostId}`, { viewerSessionId });
},
async submitTotp(
sessionId: string,
totpCode: string,
): Promise<{ success: boolean; viewerSessionId?: string }> {
return (
await api.post<{ success: boolean; viewerSessionId?: string }>(
"/metrics/connect-totp",
{ sessionId, totpCode },
)
).data;
},
/** False when the server no longer knows the viewer. */
async heartbeat(viewerSessionId: string): Promise<boolean> {
try {
await api.post("/metrics/heartbeat", { viewerSessionId });
return true;
} catch (error) {
if (statusOf(error) === 404) return false;
throw error;
}
},
async registerViewer(hostId: number): Promise<{
success: boolean;
viewerSessionId?: string;
skipped?: boolean;
reason?: string;
}> {
return (await api.post("/metrics/register-viewer", { hostId })).data as {
success: boolean;
viewerSessionId?: string;
skipped?: boolean;
reason?: string;
};
},
async unregisterViewer(hostId: number, viewerSessionId: string) {
await api.post("/metrics/unregister-viewer", {
hostId,
viewerSessionId,
});
},
async getMetricsHistory(
hostId: number,
opts: { range?: string; from?: string; to?: string },
): Promise<MetricsHistoryResponse> {
return (
await api.get<MetricsHistoryResponse>(`/metrics/history/${hostId}`, {
params: opts,
})
).data;
},
async getHostMetricsLayout(
hostId: number,
): Promise<HostMetricsLayout | null> {
try {
const res = await api.get<{ layout?: HostMetricsLayout }>(
`/host-metrics/preferences/${hostId}`,
);
return res.data?.layout ?? null;
} catch (error) {
if (statusOf(error) === 404) return null;
throw error;
}
},
async saveHostMetricsLayout(hostId: number, layout: HostMetricsLayout) {
await api.post(`/host-metrics/preferences/${hostId}`, layout);
},
async getHostPlatform(hostId: number): Promise<PlatformInfo> {
return (await api.get<PlatformInfo>(`/host-metrics/platform/${hostId}`))
.data;
},
/** GET a manager resource (read). */
async managerGet<T>(
hostId: number,
resource: string,
params?: Record<string, string | number>,
): Promise<T> {
return (
await api.get<T>(`/host-metrics/managers/${resource}/${hostId}`, {
params,
})
).data;
},
/** GET where the host id sits mid-path: /managers/logs/{id}/files. */
async managerGetSub<T>(
hostId: number,
resource: string,
sub: string,
params?: Record<string, string | number>,
): Promise<T> {
return (
await api.get<T>(
`/host-metrics/managers/${resource}/${hostId}/${sub}`,
{ params },
)
).data;
},
/** POST a manager action: /managers/{resource}/{id}[/{action}]. */
async managerPost<T>(
hostId: number,
resource: string,
body: unknown,
action?: string,
): Promise<T> {
const suffix = action ? `/${action}` : "";
return (
await api.post<T>(
`/host-metrics/managers/${resource}/${hostId}${suffix}`,
body,
)
).data;
},
};
}
export type HostMetricsApi = ReturnType<typeof createHostMetricsApi>;
export function useHostMetricsApi(): HostMetricsApi {
const api = usePluginApi();
return useMemo(() => createHostMetricsApi(api), [api]);
}
+147
View File
@@ -0,0 +1,147 @@
import type { ComponentType } from "react";
import { Activity, Server } from "lucide-react";
import type {
HostEditorSectionProps,
PluginHostRecord,
StandaloneViewProps,
TabProps,
TermixApp,
} from "@termix/plugin-sdk/frontend";
import { HostMetricsTab } from "./HostMetricsTab";
import HostMetricsApp from "./HostMetricsApp";
import { HostStatsTab } from "./HostEditorStatsTab";
import { metricsChartWidget } from "./MetricsChartWidget";
import { TerminalMetricsStatus } from "./TerminalMetricsStatus";
import { createHostMetricsApi } from "./host-metrics-api";
import { createMetricsSummaryStore } from "./summary-store";
import {
createDashboardHostMetrics,
createHomepageHostMetrics,
} from "./SummaryViews";
type HostMetricsTabConfig = Parameters<typeof HostMetricsTab>[0]["hostConfig"];
/** This plugin's metricsEnabled host setting; on unless turned off. */
function metricsEnabledFor(host: unknown): boolean {
const bag = (
host as { pluginSettings?: Record<string, Record<string, unknown>> }
)?.pluginSettings;
return bag?.["host-metrics"]?.metricsEnabled !== false;
}
function MetricsTab({ sshHost, label, isVisible }: TabProps) {
return (
<HostMetricsTab
hostConfig={sshHost as unknown as HostMetricsTabConfig}
title={label}
isVisible={isVisible}
isTopbarOpen={false}
embedded={true}
/>
);
}
function MetricsStandalone({ hostId }: StandaloneViewProps) {
return <HostMetricsApp hostId={hostId} />;
}
function MetricsHostSection(props: HostEditorSectionProps) {
return <HostStatsTab {...props} />;
}
export async function activate(app: TermixApp): Promise<void> {
const allowed = await app.hasPermission("use");
const metricsApi = createHostMetricsApi(app.api);
const summaries = createMetricsSummaryStore(metricsApi);
app.onDispose(() => summaries.dispose());
// The latest disk sample, for the file manager's usage bar.
app.registerAction(
"host-metrics.disk",
(async (hostId: number) =>
(await metricsApi.getMetrics(hostId).catch(() => null))?.disk ??
null) as never,
{ permission: "use" },
);
app.registerTab("host-metrics", MetricsTab, {
icon: Server,
titleKey: "nav.hostMetrics",
requiresHost: true,
noHostMessageKey: "hostMetrics.noHostSelected",
persistent: true,
activityTypes: ["server_stats"],
standalone: MetricsStandalone,
// Links copied before the rename still open.
standaloneViews: ["server-stats"],
preload: () => import("./HostMetricsTab"),
});
app.registerHostAction({
id: "host-metrics",
titleKey: "nav.hostMetrics",
icon: Server,
kind: "open",
order: 50,
tabType: "host-metrics",
copyUrlView: "host-metrics",
overview: true,
when: (host: PluginHostRecord) =>
allowed && !!host.enableSsh && metricsEnabledFor(host),
});
app.registerHostEditorSection({
id: "host-metrics",
group: "ssh",
titleKey: "hosts.tabHostMetrics",
icon: Activity,
order: 70,
defaults: true,
component: MetricsHostSection,
});
app.registerExtension("homepage.widgets", metricsChartWidget);
// Live CPU, memory and disk bars in the terminal toolbar's expanded view.
app.registerSlotContribution("terminal.toolbarStatus", {
actionId: "host-metrics.terminalStatus",
titleKey: "nav.hostMetrics",
kind: "component",
component: TerminalMetricsStatus,
when: (context) => allowed && metricsEnabledFor(context.host),
});
// The usage bars in the dashboard's host status card.
app.registerSlotContribution("dashboard.hostRow", {
actionId: "host-metrics.dashboardHost",
titleKey: "nav.hostMetrics",
kind: "component",
component: createDashboardHostMetrics(summaries),
});
// The numbers in the homepage's host status widget.
app.registerSlotContribution("homepage.hostMetrics", {
actionId: "host-metrics.homepageHost",
titleKey: "nav.hostMetrics",
kind: "component",
component: createHomepageHostMetrics(summaries),
});
app.registerSlotContribution("onboarding.features", {
actionId: "host-metrics.feature",
titleKey: "onboarding.feature_metrics",
descriptionKey: "onboarding.feature_metrics_desc",
icon: Activity as ComponentType<{ className?: string }>,
});
app.declareActionSlot({
id: "host-metrics.managers",
accepts: ["component"],
});
// Buttons before the toolbar's own controls, drawn with { hostId, host }.
// The snippets plugin puts a host's quick actions here.
app.declareActionSlot({
id: "host-metrics.toolbar",
accepts: ["component"],
});
}
@@ -0,0 +1,23 @@
/**
* Coarse fingerprint of a metrics poll. Adaptive polling backs off while it
* stays the same, so it must move whenever something a card shows changes.
*/
export function metricsChangeKey(data: ServerMetrics): string {
const bucket = (value: number | null | undefined) =>
value == null ? null : Math.round(value / 5) * 5;
return JSON.stringify({
cpu: bucket(data.cpu.percent),
memory: bucket(data.memory.percent),
disk: bucket(data.disk.percent),
running: data.processes?.running ?? null,
ports: data.ports?.ports?.length ?? 0,
firewall: data.firewall?.status ?? null,
gpus:
data.gpu?.gpus?.map((gpu) => [
bucket(gpu.utilizationPercent),
bucket(gpu.memoryPercent),
]) ?? [],
gpuProcesses: data.gpu?.processes?.length ?? 0,
});
}
import { type ServerMetrics } from "../shared/metrics.js";
@@ -0,0 +1,115 @@
import { useCallback, useSyncExternalStore } from "react";
import type { ServerMetrics } from "../shared/metrics.js";
import type { HostMetricsApi } from "./host-metrics-api";
const REFRESH_MS = 30_000;
interface Entry {
refs: number;
viewerSessionId?: string;
data: ServerMetrics | null;
listeners: Set<() => void>;
}
/**
* Latest metrics for hosts shown outside the tab (dashboard rows, homepage
* widgets). Each watched host holds one viewer, so polling runs while
* something shows it and stops with the last one.
*/
export function createMetricsSummaryStore(api: HostMetricsApi) {
const entries = new Map<number, Entry>();
let timer: ReturnType<typeof setInterval> | null = null;
const notify = (entry: Entry) => {
for (const listener of entry.listeners) listener();
};
async function refresh(hostId: number): Promise<void> {
const entry = entries.get(hostId);
if (!entry) return;
try {
if (
entry.viewerSessionId &&
!(await api.heartbeat(entry.viewerSessionId))
) {
entry.viewerSessionId = undefined;
}
if (!entry.viewerSessionId) {
const registered = await api.registerViewer(hostId);
if (registered.viewerSessionId) {
entry.viewerSessionId = registered.viewerSessionId;
}
}
entry.data = await api.getMetrics(hostId);
} catch {
// Keep the last value; the next tick tries again.
}
if (entries.get(hostId) === entry) notify(entry);
}
function tick() {
if (typeof document !== "undefined" && document.hidden) return;
for (const hostId of entries.keys()) void refresh(hostId);
}
function release(hostId: number, entry: Entry) {
entries.delete(hostId);
if (entry.viewerSessionId) {
void api.unregisterViewer(hostId, entry.viewerSessionId).catch(() => {});
}
if (entries.size === 0 && timer) {
clearInterval(timer);
timer = null;
}
}
return {
watch(hostId: number, listener: () => void): () => void {
let entry = entries.get(hostId);
if (!entry) {
entry = { refs: 0, data: null, listeners: new Set() };
entries.set(hostId, entry);
void refresh(hostId);
}
entry.refs++;
entry.listeners.add(listener);
if (!timer) timer = setInterval(tick, REFRESH_MS);
const held = entry;
return () => {
held.listeners.delete(listener);
held.refs--;
if (held.refs <= 0) release(hostId, held);
};
},
get(hostId: number): ServerMetrics | null {
return entries.get(hostId)?.data ?? null;
},
dispose() {
for (const [hostId, entry] of [...entries.entries()]) {
release(hostId, entry);
}
},
};
}
export type MetricsSummaryStore = ReturnType<typeof createMetricsSummaryStore>;
/** The host's latest sample, polled while the calling component is shown. */
export function useMetricsSummary(
store: MetricsSummaryStore,
hostId: number | null | undefined,
): ServerMetrics | null {
// Stable per host, or every render would drop and re-add the viewer.
const subscribe = useCallback(
(onChange: () => void) =>
hostId ? store.watch(hostId, onChange) : () => {},
[store, hostId],
);
return useSyncExternalStore(
subscribe,
() => (hostId ? store.get(hostId) : null),
() => null,
);
}
@@ -0,0 +1,30 @@
import type { TemperatureSensor } from "../shared/stats-widgets.js";
export type TemperatureUnit = "celsius" | "fahrenheit";
export function selectTemperatureSensor(
sensors: TemperatureSensor[],
preferredLabel: string,
) {
return sensors.find((sensor) => sensor.label === preferredLabel) ?? null;
}
/** The sensor a person picked is remembered per browser. */
export function temperaturePreferenceKey(hostId: number) {
return `termix-host-metrics:${hostId}:temperature-sensor`;
}
/** The temperatureUnit user setting, celsius for anything else. */
export function temperatureUnitOf(value: unknown): TemperatureUnit {
return value === "fahrenheit" ? "fahrenheit" : "celsius";
}
export function formatTemperature(
celsius: number | null | undefined,
unit: TemperatureUnit = "celsius",
): string {
if (typeof celsius !== "number" || !Number.isFinite(celsius)) return "N/A";
return unit === "fahrenheit"
? `${((celsius * 9) / 5 + 32).toFixed(1)}°F`
: `${celsius.toFixed(1)}°C`;
}
@@ -0,0 +1,43 @@
import type { ComponentType, ReactNode } from "react";
import type { ExtensionContribution } from "@termix/plugin-sdk/frontend";
/** The homepage's grid step, in pixels. */
export const GRID_SIZE = 30;
export type MetricsChartMetric =
"cpu" | "memory" | "disk" | "net_rx" | "net_tx";
export type MetricsChartRange = "15m" | "1h" | "6h" | "24h";
export interface MetricsChartConfig {
hostId: number;
metric: MetricsChartMetric;
range: MetricsChartRange;
showCurrentValue: boolean;
}
/** What the homepage hands a widget; only the fields this plugin reads. */
export interface WidgetComponentProps<C> {
widget: { title?: string | null };
config: C;
}
export interface WidgetEditFormProps<C> {
config: C;
onChange: (config: C) => void;
}
/** What app.registerExtension("homepage.widgets", ...) takes. */
export interface WidgetDefinition<C> extends ExtensionContribution {
id: string;
name: string;
description: string;
category: "links" | "info" | "system" | "monitoring";
icon: ReactNode;
defaultConfig: C;
defaultSize: { w: number; h: number };
minSize: { w: number; h: number };
components: {
view: ComponentType<WidgetComponentProps<C>>;
editForm?: ComponentType<WidgetEditFormProps<C>>;
};
}
@@ -0,0 +1,145 @@
import type { WidgetType } from "./stats-widgets.js";
/**
* Host Metrics layout model. Shared by the frontend tab and the backend
* preferences endpoint. Metric card ids reuse the existing `WidgetType` values
* verbatim (so `enabledWidgets` stays valid for the mobile app); manager card
* ids are new and never enter the `enabledWidgets` host setting.
*/
export type HostMetricCardId = WidgetType;
export type HostMetricManagerId =
| "service_manager"
| "process_inspector"
| "log_viewer"
| "cron_manager"
| "package_manager"
| "ssl_manager"
| "firewall_manager"
| "user_manager"
| "health_check"
| "disk_breakdown"
| "systemd_timers"
| "top_memory"
| "wireguard_manager"
| "tailscale_manager";
export type HostMetricsCardId = HostMetricCardId | HostMetricManagerId;
export type HostMetricsColSpan = 1 | 2 | 3;
export interface HostMetricsSlot {
id: HostMetricsCardId;
order: number;
colSpan: HostMetricsColSpan;
height: number | null;
}
export interface HostMetricsLayout {
slots: HostMetricsSlot[];
columns: number;
}
/** Canonical order of metric cards (also the canonical `enabledWidgets` order). */
export const METRIC_CARD_IDS: HostMetricCardId[] = [
"cpu",
"memory",
"disk",
"network",
"uptime",
"system",
"login_stats",
"processes",
"ports",
"firewall",
"temperature",
"gpu",
];
export const MANAGER_CARD_IDS: HostMetricManagerId[] = [
"service_manager",
"process_inspector",
"log_viewer",
"cron_manager",
"package_manager",
"ssl_manager",
"firewall_manager",
"user_manager",
"health_check",
"disk_breakdown",
"systemd_timers",
"top_memory",
"wireguard_manager",
"tailscale_manager",
];
export function isMetricCardId(id: string): id is HostMetricCardId {
return (METRIC_CARD_IDS as string[]).includes(id);
}
export function isManagerCardId(id: string): id is HostMetricManagerId {
return (MANAGER_CARD_IDS as string[]).includes(id);
}
/**
* Derive the flat `enabledWidgets` array (canonical order, deduped, metric ids
* only) from a set of layout slots. This is the single source of truth keeping
* the `enabledWidgets` host setting valid for the mobile app.
*/
export function deriveEnabledWidgets(
slots: Array<{ id: string }>,
): WidgetType[] {
const present = new Set(slots.map((s) => s.id));
return METRIC_CARD_IDS.filter((id) => present.has(id));
}
const DEFAULT_COLSPAN: Partial<Record<HostMetricsCardId, HostMetricsColSpan>> =
{
cpu: 1,
memory: 1,
disk: 1,
uptime: 1,
system: 1,
network: 1,
temperature: 1,
processes: 2,
ports: 2,
firewall: 2,
login_stats: 2,
gpu: 2,
};
// All cards default to content/auto height (null); the masonry packs them
// tetris-style and scrollable cards cap their own body height. Users can still
// pin an explicit height by dragging a card's resize handle.
const DEFAULT_HEIGHT: Partial<Record<HostMetricsCardId, number | null>> = {};
/**
* Build a default layout from a list of enabled metric-card ids (e.g. the host's
* current the `enabledWidgets` host setting), preserving canonical order. Manager
* cards are not added by default.
*/
export function defaultLayoutFromWidgets(
enabledWidgets: string[],
columns = 3,
): HostMetricsLayout {
const enabled = new Set(enabledWidgets);
const ids = METRIC_CARD_IDS.filter((id) => enabled.has(id));
const slots: HostMetricsSlot[] = ids.map((id, i) => ({
id,
order: i,
colSpan: DEFAULT_COLSPAN[id] ?? 1,
height: DEFAULT_HEIGHT[id] ?? null,
}));
return { slots, columns };
}
export function defaultColSpanFor(id: HostMetricsCardId): HostMetricsColSpan {
return DEFAULT_COLSPAN[id] ?? (isManagerCardId(id) ? 2 : 1);
}
export function defaultHeightFor(id: HostMetricsCardId): number | null {
if (id in DEFAULT_HEIGHT) return DEFAULT_HEIGHT[id] ?? null;
return null;
}
+136
View File
@@ -0,0 +1,136 @@
import type { GpuMetrics } from "./stats-widgets.js";
/** What GET /metrics/:id answers. */
export interface CpuMetrics {
percent: number | null;
cores: number | null;
load: [number, number, number] | null;
}
export interface MemoryMetrics {
percent: number | null;
usedGiB: number | null;
totalGiB: number | null;
}
export interface DiskFilesystem {
filesystem: string;
type: string;
mount: string;
percent: number | null;
usedHuman: string | null;
totalHuman: string | null;
availableHuman: string | null;
usedBytes: number | null;
totalBytes: number | null;
availableBytes: number | null;
label?: string;
}
interface DiskMetrics {
percent: number | null;
usedHuman: string | null;
totalHuman: string | null;
availableHuman?: string | null;
mount?: string | null;
filesystems?: DiskFilesystem[];
}
export interface NetworkInterface {
name: string;
ip: string;
state: string;
rx?: string | null;
tx?: string | null;
rxBytes?: string | null;
txBytes?: string | null;
rxRateBps?: number | null;
txRateBps?: number | null;
}
export interface ProcessInfo {
pid: string;
user: string;
cpu: string;
mem: string;
command: string;
}
export interface LoginRecord {
user: string;
ip: string;
time: string;
status: "success" | "failed";
}
export interface ListeningPort {
protocol: "tcp" | "udp";
localAddress: string;
localPort: number;
state?: string;
pid?: number;
process?: string;
}
export interface FirewallRule {
chain: string;
target: string;
protocol: string;
source: string;
destination: string;
dport?: string;
sport?: string;
state?: string;
interface?: string;
extra?: string;
}
export interface FirewallChain {
name: string;
policy: string;
rules: FirewallRule[];
}
export type ServerMetrics = {
cpu: CpuMetrics;
memory: MemoryMetrics;
disk: DiskMetrics;
network?: { interfaces?: NetworkInterface[] };
uptime?: { seconds?: number | null; formatted?: string | null };
system?: {
hostname?: string | null;
os?: string | null;
kernel?: string | null;
arch?: string | null;
};
processes?: {
total?: number | null;
running?: number | null;
top?: ProcessInfo[];
};
login_stats?: {
recentLogins?: LoginRecord[];
failedLogins?: LoginRecord[];
totalLogins?: number;
uniqueIPs?: number;
};
ports?: {
source?: "ss" | "netstat" | "none";
ports?: ListeningPort[];
};
firewall?: {
type?: "iptables" | "nftables" | "none";
status?: "active" | "inactive" | "unknown";
chains?: FirewallChain[];
};
temperature?: {
source?: "sysfs" | "sensors" | "none";
highestCelsius?: number | null;
sensors?: Array<{
label: string;
celsius: number;
}>;
};
gpu?: GpuMetrics;
lastChecked: string;
};
@@ -0,0 +1,161 @@
export type WidgetType =
| "cpu"
| "memory"
| "disk"
| "network"
| "uptime"
| "processes"
| "system"
| "login_stats"
| "ports"
| "firewall"
| "temperature"
| "gpu";
export interface ListeningPort {
protocol: "tcp" | "udp";
localAddress: string;
localPort: number;
state?: string;
pid?: number;
process?: string;
}
export interface PortsMetrics {
source: "ss" | "netstat" | "none";
ports: ListeningPort[];
}
export interface FirewallRule {
chain: string;
target: string;
protocol: string;
source: string;
destination: string;
dport?: string;
sport?: string;
state?: string;
interface?: string;
extra?: string;
}
export interface FirewallChain {
name: string;
policy: string;
rules: FirewallRule[];
}
export interface FirewallMetrics {
type: "iptables" | "nftables" | "none";
status: "active" | "inactive" | "unknown";
chains: FirewallChain[];
}
export interface TemperatureSensor {
label: string;
celsius: number;
}
export interface TemperatureMetrics {
source: "sysfs" | "sensors" | "none";
highestCelsius: number | null;
sensors: TemperatureSensor[];
}
export interface GpuDevice {
index: number;
uuid: string;
name: string;
driverVersion: string | null;
utilizationPercent: number | null;
memoryUsedMiB: number | null;
memoryTotalMiB: number | null;
memoryPercent: number | null;
temperatureCelsius: number | null;
powerDrawWatts: number | null;
powerLimitWatts: number | null;
fanPercent: number | null;
}
export interface GpuProcess {
gpuIndex: number | null;
pid: number;
name: string;
memoryUsedMiB: number | null;
}
export interface GpuMetrics {
source: "nvidia-smi" | "none";
gpus: GpuDevice[];
processes: GpuProcess[];
}
/** This plugin's host settings, as core stores them in plugin_settings. */
export interface HostMetricsSettings {
metricsEnabled: boolean;
/** Seconds between samples; null follows the admin setting. */
metricsInterval: number | null;
enabledWidgets: WidgetType[];
/** Filesystem mount points to leave out of the disk widget. */
excludedMounts: string[];
/** Extra paths to monitor, including paths inside bind-mounted containers. */
monitoredMounts: Array<{ path: string; label?: string }>;
}
export const DEFAULT_ENABLED_WIDGETS: WidgetType[] = [
"cpu",
"memory",
"disk",
"network",
"uptime",
"system",
"login_stats",
"processes",
"ports",
"firewall",
"temperature",
];
export const DEFAULT_HOST_METRICS_SETTINGS: HostMetricsSettings = {
metricsEnabled: true,
metricsInterval: null,
enabledWidgets: DEFAULT_ENABLED_WIDGETS,
excludedMounts: [],
monitoredMounts: [],
};
/** Host settings with defaults filled in and bad values dropped. */
export function readHostMetricsSettings(
values: Record<string, unknown> | null | undefined,
): HostMetricsSettings {
const bag = values ?? {};
const interval = Number(bag.metricsInterval);
return {
metricsEnabled: bag.metricsEnabled !== false,
metricsInterval:
bag.metricsInterval != null &&
Number.isInteger(interval) &&
interval >= 5 &&
interval <= 3600
? interval
: null,
enabledWidgets: Array.isArray(bag.enabledWidgets)
? (bag.enabledWidgets.filter(
(widget) => typeof widget === "string",
) as WidgetType[])
: DEFAULT_ENABLED_WIDGETS,
excludedMounts: Array.isArray(bag.excludedMounts)
? bag.excludedMounts.filter(
(mount): mount is string => typeof mount === "string",
)
: [],
monitoredMounts: Array.isArray(bag.monitoredMounts)
? (bag.monitoredMounts as unknown[]).filter(
(mount): mount is { path: string; label?: string } =>
!!mount &&
typeof mount === "object" &&
typeof (mount as { path?: unknown }).path === "string",
)
: [],
};
}