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

+78
View File
@@ -0,0 +1,78 @@
export type ProxmoxSource = {
source: "proxmox";
sourceHostId: number;
node: string;
vmid: number;
type: "qemu" | "lxc";
lastSeenAt?: string;
lastStatus?: string;
missingSince?: string | null;
};
export function parseJsonObject(value: unknown): Record<string, unknown> {
if (!value) return {};
if (typeof value === "object") return value as Record<string, unknown>;
if (typeof value !== "string") return {};
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === "object"
? (parsed as Record<string, unknown>)
: {};
} catch {
return {};
}
}
/** The guest a host was imported from, read from its proxmoxConfig setting. */
export function getProxmoxSource(config: unknown): ProxmoxSource | null {
const source = parseJsonObject(config).source;
if (!source || typeof source !== "object") return null;
const src = source as Record<string, unknown>;
if (
src.source !== "proxmox" ||
typeof src.sourceHostId !== "number" ||
typeof src.node !== "string" ||
typeof src.vmid !== "number" ||
(src.type !== "qemu" && src.type !== "lxc")
) {
return null;
}
return src as ProxmoxSource;
}
export function proxmoxSourceKey(source: {
sourceHostId: number;
node: string;
type: string;
vmid: number;
}): string {
return `${source.sourceHostId}:${source.node}:${source.type}:${source.vmid}`;
}
export interface ImportedGuest<H = Record<string, unknown>> {
host: H;
config: Record<string, unknown>;
source: ProxmoxSource;
}
/**
* The hosts already imported from one Proxmox node, keyed by guest. The
* source lives in each guest's proxmoxConfig host setting, not on the host
* row, so the caller passes both.
*/
export function indexImportedGuests<H extends { id: unknown }>(
hosts: H[],
configs: Array<{ hostId: number; value: unknown }>,
sourceHostId: number,
): Map<string, ImportedGuest<H>> {
const configById = new Map(configs.map((c) => [c.hostId, c.value]));
const out = new Map<string, ImportedGuest<H>>();
for (const host of hosts) {
const config = parseJsonObject(configById.get(Number(host.id)));
const source = getProxmoxSource(config);
if (source?.sourceHostId === sourceHostId) {
out.set(proxmoxSourceKey(source), { host, config, source });
}
}
return out;
}
+36
View File
@@ -0,0 +1,36 @@
import type { Router } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { setPluginSsh } from "./ssh.js";
import { setPluginCtx } from "./plugin-ctx.js";
import { startProxmoxService, stopProxmoxService } from "./routes.js";
import {
startProxmoxStatsService,
stopProxmoxStatsService,
} from "./stats-service.js";
import { proxmoxNodeHistory } from "./tables.js";
import { createProxmoxNodeHistoryRepository } from "./proxmox-node-history-repository.js";
export async function activate(ctx: PluginContext) {
setPluginSsh(ctx.ssh);
setPluginCtx(ctx);
ctx.disposables.add(() => setPluginSsh(null));
ctx.disposables.add(() => setPluginCtx(null));
const historyTable = await ctx.db.define(proxmoxNodeHistory);
const historyRepository = createProxmoxNodeHistoryRepository(
ctx.db,
historyTable,
);
const router = ctx.http.router<Router>();
startProxmoxService(router);
ctx.disposables.add(stopProxmoxService);
startProxmoxStatsService(ctx, router, historyRepository);
ctx.disposables.add(stopProxmoxStatsService);
ctx.log.info("Proxmox routes mounted at /plugin-api/proxmox");
}
export async function deactivate() {
stopProxmoxService();
stopProxmoxStatsService();
}
+13
View File
@@ -0,0 +1,13 @@
import type { PluginContext } from "@termix/plugin-sdk/backend";
let current: PluginContext | null = null;
/** Set in activate, cleared on deactivate. */
export function setPluginCtx(ctx: PluginContext | null): void {
current = ctx;
}
export function pluginCtx(): PluginContext {
if (!current) throw new Error("The plugin is not active");
return current;
}
@@ -0,0 +1,44 @@
// Pure decision: which auth settings an imported Proxmox guest inherits.
//
// The frontend carries a parallel copy in
// plugins/proxmox/frontend/proxmox-import-auth.ts. The two drifting apart is
// what produced the reported import bug, so both are kept behaviourally
// identical and each is unit-tested against the same matrix.
export function resolveProxmoxImportAuth(
defaultAuthType: string | undefined,
credentialId: number | null | undefined,
): {
authType: string;
credentialId: number | null;
overrideCredentialUsername: number;
} {
// An explicit special auth type (none/opkssh/tailscale/vault/…) wins.
if (
defaultAuthType &&
defaultAuthType !== "credential" &&
!["password", "key"].includes(defaultAuthType)
) {
return {
authType: defaultAuthType,
credentialId: null,
overrideCredentialUsername: 0,
};
}
// A credential (configured default OR inherited from the source host) is a
// concrete auth source -> use it, even when defaultAuthType is the
// "password"/"key" default.
if (credentialId) {
return {
authType: "credential",
credentialId,
overrideCredentialUsername: 0,
};
}
return {
authType: "none",
credentialId: null,
overrideCredentialUsername: 0,
};
}
@@ -0,0 +1,12 @@
export function parseProxmoxJumpHosts(raw: unknown): unknown[] | null {
if (!raw) return null;
if (Array.isArray(raw)) return raw;
if (typeof raw !== "string") return null;
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : null;
} catch {
return null;
}
}
@@ -0,0 +1,79 @@
import { and, gte, lte, eq, asc, lt } from "drizzle-orm";
import type { PluginDatabase } from "@termix/plugin-sdk/backend";
export interface ProxmoxNodeHistoryRow {
id: number;
hostId: number;
ts: string;
cpuPercent: number | null;
memPercent: number | null;
diskPercent: number | null;
netRxBytes: number | null;
netTxBytes: number | null;
}
export interface ProxmoxNodeHistoryCreateInput {
hostId: number;
cpuPercent: number | null;
memPercent: number | null;
diskPercent: number | null;
netRxBytes: number | null;
netTxBytes: number | null;
}
/* eslint-disable @typescript-eslint/no-explicit-any */
type Table = any;
type Drizzle = any;
/* eslint-enable @typescript-eslint/no-explicit-any */
export type ProxmoxNodeHistoryRepository = ReturnType<
typeof createProxmoxNodeHistoryRepository
>;
export function createProxmoxNodeHistoryRepository(
db: PluginDatabase,
table: Table,
) {
const client = () => db.client<Drizzle>();
return {
async create(input: ProxmoxNodeHistoryCreateInput): Promise<void> {
const drizzle = await client();
await drizzle.insert(table).values(input);
await db.persist();
},
async listRange(
hostId: number,
fromIso: string,
toIso: string,
): Promise<ProxmoxNodeHistoryRow[]> {
const drizzle = await client();
return drizzle
.select()
.from(table)
.where(
and(
eq(table.hostId, hostId),
gte(table.ts, fromIso),
lte(table.ts, toIso),
),
)
.orderBy(asc(table.ts));
},
async pruneOlderThan(hostId: number, retentionDays: number): Promise<void> {
// ts columns store CURRENT_TIMESTAMP as "YYYY-MM-DD HH:MM:SS" UTC, which
// sorts lexicographically, so the cutoff is a plain string comparison.
const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000)
.toISOString()
.slice(0, 19)
.replace("T", " ");
const drizzle = await client();
await drizzle
.delete(table)
.where(and(eq(table.hostId, hostId), lt(table.ts, cutoff)));
await db.persist();
},
};
}
@@ -0,0 +1,7 @@
// Proxmox node names are restricted to [a-zA-Z0-9-] by PVE itself,
// but we validate defensively before using in a shell command.
const SAFE_NODE_RE = /^[a-zA-Z0-9._-]{1,64}$/;
export function isSafeNodeName(name: string): boolean {
return SAFE_NODE_RE.test(name);
}
@@ -0,0 +1,334 @@
import {
collectProxmoxStats,
type ProxmoxStatsSnapshot,
} from "./proxmox/collect-proxmox-stats.js";
import type { ProxmoxNodeHistoryRepository } from "./proxmox-node-history-repository.js";
import { pluginCtx } from "./plugin-ctx.js";
import type { Client } from "ssh2";
/**
* Proxmox stats polling gets its own concurrency limiter so a burst of polls
* on many hosts cannot starve one host's collection.
*/
class ConcurrentLimiter {
private running = 0;
private queue: Array<() => void> = [];
constructor(private readonly max: number) {}
async run<T>(fn: () => Promise<T>): Promise<T> {
if (this.running >= this.max) {
await new Promise<void>((resolve) => this.queue.push(resolve));
}
this.running++;
try {
return await fn();
} finally {
this.running--;
const next = this.queue.shift();
if (next) next();
}
}
}
const proxmoxStatsPollLimiter = new ConcurrentLimiter(5);
export interface ProxmoxStatsPollableHost {
id: number;
userId: string;
proxmoxStatsConfig?: string | ProxmoxStatsPollConfig | null;
}
export interface ProxmoxStatsPollConfig {
nodeName?: string | null;
pollInterval?: number;
enabledCards?: string[];
}
const DEFAULT_POLL_INTERVAL_SECONDS = 60;
export function parseProxmoxStatsConfig(
raw: string | ProxmoxStatsPollConfig | null | undefined,
): ProxmoxStatsPollConfig {
if (!raw) {
return { nodeName: null, pollInterval: DEFAULT_POLL_INTERVAL_SECONDS };
}
if (typeof raw === "object") {
return {
nodeName: raw.nodeName ?? null,
pollInterval: raw.pollInterval ?? DEFAULT_POLL_INTERVAL_SECONDS,
enabledCards: raw.enabledCards,
};
}
try {
const parsed = JSON.parse(raw) as ProxmoxStatsPollConfig;
return {
nodeName: parsed.nodeName ?? null,
pollInterval: parsed.pollInterval ?? DEFAULT_POLL_INTERVAL_SECONDS,
enabledCards: parsed.enabledCards,
};
} catch {
return { nodeName: null, pollInterval: DEFAULT_POLL_INTERVAL_SECONDS };
}
}
interface HostPollingEntry<THost extends ProxmoxStatsPollableHost> {
host: THost;
timer?: NodeJS.Timeout;
viewerUserId?: string;
}
interface ViewerDetail {
sessionId: string;
userId: string;
hostId: number;
lastHeartbeat: number;
}
interface CachedSnapshot {
data: ProxmoxStatsSnapshot;
timestamp: number;
}
interface ErrorSnapshot {
error: string;
timestamp: number;
}
export class ProxmoxPollingManager<
THost extends ProxmoxStatsPollableHost = ProxmoxStatsPollableHost,
> {
private pollingConfigs = new Map<number, HostPollingEntry<THost>>();
private snapshotStore = new Map<number, CachedSnapshot>();
private errorStore = new Map<number, ErrorSnapshot>();
private activeViewers = new Map<number, Set<string>>();
private viewerDetails = new Map<string, ViewerDetail>();
private inFlight = new Set<number>();
private viewerCleanupInterval: NodeJS.Timeout;
constructor(
private readonly deps: {
fetchHostById: (
hostId: number,
userId: string,
) => Promise<THost | undefined>;
withSshConnection: <T>(
host: THost,
fn: (client: Client) => Promise<T>,
) => Promise<T>;
historyRepository: ProxmoxNodeHistoryRepository;
historyEnabled?: () => boolean;
},
) {
this.viewerCleanupInterval = setInterval(() => {
this.cleanupInactiveViewers();
}, 60000);
}
private intervalWithJitter(intervalMs: number, hostId: number): number {
const spread = Math.min(intervalMs * 0.2, 15_000);
const jitter = (hostId * 1103515245) % Math.max(1, Math.floor(spread));
return intervalMs + jitter;
}
// Host Metrics' retention setting belongs to that plugin, so node history
// keeps a fixed week.
private readonly retentionDays = 7;
private async pollHostStats(
host: THost,
viewerUserId?: string,
): Promise<void> {
if (this.inFlight.has(host.id)) return;
this.inFlight.add(host.id);
try {
await proxmoxStatsPollLimiter.run(async () => {
const userId = viewerUserId || host.userId;
const refreshed =
(await this.deps.fetchHostById(host.id, userId)) ?? host;
const config = parseProxmoxStatsConfig(refreshed.proxmoxStatsConfig);
try {
const snapshot = await this.deps.withSshConnection(
refreshed,
(client) => collectProxmoxStats(client, config.nodeName),
);
this.snapshotStore.set(host.id, {
data: snapshot,
timestamp: Date.now(),
});
this.errorStore.delete(host.id);
if (this.deps.historyEnabled?.() ?? true) {
await this.insertHistory(host.id, snapshot);
}
} catch (error) {
const message =
error instanceof Error ? error.message : String(error);
this.errorStore.set(host.id, {
error: message,
timestamp: Date.now(),
});
pluginCtx().log.warn(
`Proxmox stats poll failed for host ${host.id}: ${message}`,
);
}
});
} finally {
this.inFlight.delete(host.id);
}
}
private async insertHistory(
hostId: number,
snapshot: ProxmoxStatsSnapshot,
): Promise<void> {
try {
const iface = snapshot.network?.interfaces?.[0];
const rxRaw = iface?.rxBytes ? parseInt(iface.rxBytes, 10) : null;
const txRaw = iface?.txBytes ? parseInt(iface.txBytes, 10) : null;
await this.deps.historyRepository.create({
hostId,
cpuPercent: snapshot.node?.cpu?.percent ?? null,
memPercent: snapshot.node?.memory?.percent ?? null,
diskPercent: snapshot.node?.disk?.percent ?? null,
netRxBytes: rxRaw !== null && !isNaN(rxRaw) ? rxRaw : null,
netTxBytes: txRaw !== null && !isNaN(txRaw) ? txRaw : null,
});
await this.deps.historyRepository.pruneOlderThan(
hostId,
this.retentionDays,
);
} catch (err) {
pluginCtx().log.warn(
`Failed to write proxmox node history for host ${hostId}: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
private startPollingForHost(host: THost, viewerUserId?: string): void {
const existing = this.pollingConfigs.get(host.id);
if (existing?.timer) {
clearInterval(existing.timer);
}
const config = parseProxmoxStatsConfig(host.proxmoxStatsConfig);
const intervalMs = this.intervalWithJitter(
(config.pollInterval ?? DEFAULT_POLL_INTERVAL_SECONDS) * 1000,
host.id,
);
void this.pollHostStats(host, viewerUserId);
const timer = setInterval(() => {
const latest = this.pollingConfigs.get(host.id);
if (latest) {
void this.pollHostStats(latest.host, latest.viewerUserId);
}
}, intervalMs);
this.pollingConfigs.set(host.id, { host, timer, viewerUserId });
}
private stopPollingForHost(hostId: number): void {
const config = this.pollingConfigs.get(hostId);
if (config?.timer) {
clearInterval(config.timer);
}
this.pollingConfigs.delete(hostId);
}
getStats(
hostId: number,
): { data: ProxmoxStatsSnapshot; timestamp: number } | undefined {
return this.snapshotStore.get(hostId);
}
getError(hostId: number): ErrorSnapshot | undefined {
return this.errorStore.get(hostId);
}
async ensurePolling(host: THost, viewerUserId?: string): Promise<void> {
if (!this.pollingConfigs.has(host.id)) {
this.startPollingForHost(host, viewerUserId);
}
if (!this.snapshotStore.has(host.id) && !this.inFlight.has(host.id)) {
await this.pollHostStats(host, viewerUserId);
}
}
registerViewer = (
hostId: number,
sessionId: string,
userId: string,
): void => {
if (!this.activeViewers.has(hostId)) {
this.activeViewers.set(hostId, new Set());
}
this.activeViewers.get(hostId)!.add(sessionId);
this.viewerDetails.set(sessionId, {
sessionId,
userId,
hostId,
lastHeartbeat: Date.now(),
});
if (this.activeViewers.get(hostId)!.size === 1) {
Promise.resolve()
.then(async () => {
const host = await this.deps.fetchHostById(hostId, userId);
if (host) {
this.startPollingForHost(host, userId);
}
})
.catch((err) => {
pluginCtx().log.warn(
`Proxmox stats startPollingForHost rejected (non-fatal) for host ${hostId}: ${err instanceof Error ? err.message : String(err)}`,
);
});
}
};
unregisterViewer = (hostId: number, sessionId: string): void => {
const viewers = this.activeViewers.get(hostId);
if (viewers) {
viewers.delete(sessionId);
if (viewers.size === 0) {
this.activeViewers.delete(hostId);
this.stopPollingForHost(hostId);
}
}
this.viewerDetails.delete(sessionId);
};
updateHeartbeat(sessionId: string): boolean {
const viewer = this.viewerDetails.get(sessionId);
if (viewer) {
viewer.lastHeartbeat = Date.now();
return true;
}
return false;
}
private cleanupInactiveViewers(): void {
const now = Date.now();
const maxInactivity = 120000;
for (const [sessionId, viewer] of this.viewerDetails.entries()) {
if (now - viewer.lastHeartbeat > maxInactivity) {
this.unregisterViewer(viewer.hostId, sessionId);
}
}
}
destroy(): void {
clearInterval(this.viewerCleanupInterval);
for (const hostId of this.pollingConfigs.keys()) {
this.stopPollingForHost(hostId);
}
}
}
@@ -0,0 +1,67 @@
import type { Client } from "ssh2";
import { execCommand } from "./common-utils.js";
export interface ProxmoxClusterNodeEntry {
name: string;
online: boolean;
local: boolean;
ip: string | null;
}
export type ProxmoxClusterHealthResult =
| { clustered: false }
| {
clustered: true;
quorate: boolean;
clusterName: string | null;
nodes: ProxmoxClusterNodeEntry[];
};
const EMPTY_RESULT: ProxmoxClusterHealthResult = { clustered: false };
export async function collectProxmoxClusterHealth(
client: Client,
): Promise<ProxmoxClusterHealthResult> {
try {
const { stdout, code } = await execCommand(
client,
"pvesh get /cluster/status --output-format json",
15000,
);
if (code !== 0) {
return EMPTY_RESULT;
}
const data = JSON.parse(stdout);
if (!Array.isArray(data)) {
return EMPTY_RESULT;
}
const entries = data as Array<Record<string, unknown>>;
const clusterEntry = entries.find((e) => e.type === "cluster");
if (!clusterEntry) {
return EMPTY_RESULT;
}
const nodes: ProxmoxClusterNodeEntry[] = entries
.filter((e) => e.type === "node")
.map((e) => ({
name: typeof e.name === "string" ? e.name : "",
online: e.online === 1 || e.online === true,
local: e.local === 1 || e.local === true,
ip: typeof e.ip === "string" && e.ip ? e.ip : null,
}));
return {
clustered: true,
quorate: clusterEntry.quorate === 1 || clusterEntry.quorate === true,
clusterName:
typeof clusterEntry.name === "string" && clusterEntry.name
? clusterEntry.name
: null,
nodes,
};
} catch {
return EMPTY_RESULT;
}
}
@@ -0,0 +1,86 @@
import type { Client } from "ssh2";
import { execCommand } from "./common-utils.js";
import { isSafeNodeName } from "../proxmox-shared.js";
import {
collectProxmoxNodeStatus,
type ProxmoxNodeStatusResult,
} from "./node-status-collector.js";
import {
collectProxmoxNodeNetwork,
type ProxmoxNodeNetworkResult,
} from "./node-network-collector.js";
import {
collectProxmoxGuestsSummary,
type ProxmoxGuestsSummaryResult,
} from "./guests-collector.js";
import {
collectProxmoxStorage,
type ProxmoxStorageResult,
} from "./storage-collector.js";
import {
collectProxmoxClusterHealth,
type ProxmoxClusterHealthResult,
} from "./cluster-health-collector.js";
export interface ProxmoxStatsSnapshot {
node: ProxmoxNodeStatusResult;
network: ProxmoxNodeNetworkResult;
guests: ProxmoxGuestsSummaryResult;
storage: ProxmoxStorageResult;
cluster: ProxmoxClusterHealthResult;
lastChecked: string;
}
async function resolveNodeName(
client: Client,
configuredNodeName: string | null | undefined,
): Promise<string> {
if (configuredNodeName) {
// An explicitly configured name is validated and used as-is - an unsafe
// value is rejected outright rather than silently falling back to
// auto-detection, which would mask a misconfigured (or malicious) override.
if (!isSafeNodeName(configuredNodeName)) {
throw new Error("Unable to determine a valid Proxmox node name");
}
return configuredNodeName;
}
const { stdout } = await execCommand(client, "hostname", 10000);
return stdout.trim();
}
export async function collectProxmoxStats(
client: Client,
configuredNodeName: string | null | undefined,
): Promise<ProxmoxStatsSnapshot> {
const pveshCheck = await execCommand(
client,
"command -v pvesh >/dev/null 2>&1 && echo ok || echo missing",
10000,
);
if (pveshCheck.stdout.trim() !== "ok") {
throw new Error("pvesh not found — is this a Proxmox node?");
}
const nodeName = await resolveNodeName(client, configuredNodeName);
if (!isSafeNodeName(nodeName)) {
throw new Error("Unable to determine a valid Proxmox node name");
}
const [node, network, guests, storage, cluster] = await Promise.all([
collectProxmoxNodeStatus(client, nodeName),
collectProxmoxNodeNetwork(client, nodeName),
collectProxmoxGuestsSummary(client, nodeName),
collectProxmoxStorage(client, nodeName),
collectProxmoxClusterHealth(client),
]);
return {
node,
network,
guests,
storage,
cluster,
lastChecked: new Date().toISOString(),
};
}
@@ -0,0 +1,98 @@
import type { Client, ClientChannel } from "ssh2";
/** Runs a command over an already-open ssh2 client and collects its output. */
export function execCommand(
client: Client,
command: string,
timeoutMs = 30000,
): Promise<{
stdout: string;
stderr: string;
code: number | null;
}> {
return new Promise((resolve, reject) => {
let settled = false;
let stream: ClientChannel | null = null;
const timeout = setTimeout(() => {
if (!settled) {
settled = true;
cleanup();
reject(new Error(`Command timeout after ${timeoutMs}ms: ${command}`));
}
}, timeoutMs);
const cleanup = () => {
clearTimeout(timeout);
if (stream) {
try {
stream.removeAllListeners();
if (stream.stderr) {
stream.stderr.removeAllListeners();
}
stream.destroy();
} catch {
// expected - cleanup errors ignored
}
}
};
client.exec(command, { pty: false }, (err, _stream) => {
if (err) {
if (!settled) {
settled = true;
cleanup();
reject(err);
}
return;
}
stream = _stream;
let stdout = "";
let stderr = "";
let exitCode: number | null = null;
stream
.on("close", (code: number | undefined) => {
if (!settled) {
settled = true;
exitCode = typeof code === "number" ? code : null;
cleanup();
resolve({ stdout, stderr, code: exitCode });
}
})
.on("data", (data: Buffer) => {
stdout += data.toString("utf8");
})
.on("error", (streamErr: Error) => {
if (!settled) {
settled = true;
cleanup();
reject(streamErr);
}
});
if (stream.stderr) {
stream.stderr
.on("data", (data: Buffer) => {
stderr += data.toString("utf8");
})
.on("error", (stderrErr: Error) => {
if (!settled) {
settled = true;
cleanup();
reject(stderrErr);
}
});
}
});
});
}
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));
}
@@ -0,0 +1,113 @@
import type { Client } from "ssh2";
import { execCommand, toFixedNum } from "./common-utils.js";
import { isSafeNodeName } from "../proxmox-shared.js";
export interface ProxmoxGuestSummaryEntry {
vmid: number;
name: string;
type: "qemu" | "lxc";
status: string;
cpuPercent: number | null;
memPercent: number | null;
memUsedGiB: number | null;
memTotalGiB: number | null;
diskPercent: number | null;
diskUsedGiB: number | null;
diskTotalGiB: number | null;
uptimeSeconds: number | null;
}
export interface ProxmoxGuestsSummaryResult {
guests: ProxmoxGuestSummaryEntry[];
counts: { running: number; stopped: number; total: number };
}
const EMPTY_RESULT: ProxmoxGuestsSummaryResult = {
guests: [],
counts: { running: 0, stopped: 0, total: 0 },
};
function bytesToGiB(bytes: number): number {
return bytes / (1024 * 1024 * 1024);
}
export async function collectProxmoxGuestsSummary(
client: Client,
nodeName: string,
): Promise<ProxmoxGuestsSummaryResult> {
if (!isSafeNodeName(nodeName)) {
return EMPTY_RESULT;
}
try {
const { stdout, code } = await execCommand(
client,
"pvesh get /cluster/resources --output-format json",
25000,
);
if (code !== 0) {
return EMPTY_RESULT;
}
const resources = JSON.parse(stdout);
if (!Array.isArray(resources)) {
return EMPTY_RESULT;
}
const guests: ProxmoxGuestSummaryEntry[] = [];
for (const r of resources as Array<Record<string, unknown>>) {
const type = r.type;
if (type !== "qemu" && type !== "lxc") continue;
if (r.node !== nodeName) continue;
if (r.template === true || r.template === 1) continue;
const cpuFraction = typeof r.cpu === "number" ? r.cpu : null;
const mem = typeof r.mem === "number" ? r.mem : null;
const maxmem = typeof r.maxmem === "number" ? r.maxmem : null;
const memPercent =
mem !== null && maxmem !== null && maxmem > 0
? Math.max(0, Math.min(100, (mem / maxmem) * 100))
: null;
const disk = typeof r.disk === "number" ? r.disk : null;
const maxdisk = typeof r.maxdisk === "number" ? r.maxdisk : null;
// QEMU guests without a running agent report maxdisk: 0 - a false 0%
// is worse than an honest "unknown", so treat that as no data at all.
const hasDiskData = maxdisk !== null && maxdisk > 0;
const diskPercent =
hasDiskData && disk !== null
? Math.max(0, Math.min(100, (disk / maxdisk) * 100))
: null;
guests.push({
vmid: typeof r.vmid === "number" ? r.vmid : Number(r.vmid),
name:
typeof r.name === "string" && r.name ? r.name : String(r.vmid ?? ""),
type,
status: typeof r.status === "string" ? r.status : "unknown",
cpuPercent: toFixedNum(
cpuFraction !== null ? cpuFraction * 100 : null,
0,
),
memPercent: toFixedNum(memPercent, 0),
memUsedGiB: mem !== null ? toFixedNum(bytesToGiB(mem), 2) : null,
memTotalGiB: maxmem !== null ? toFixedNum(bytesToGiB(maxmem), 2) : null,
diskPercent: toFixedNum(diskPercent, 0),
diskUsedGiB:
hasDiskData && disk !== null ? toFixedNum(bytesToGiB(disk), 2) : null,
diskTotalGiB: hasDiskData ? toFixedNum(bytesToGiB(maxdisk), 2) : null,
uptimeSeconds: typeof r.uptime === "number" ? r.uptime : null,
});
}
const running = guests.filter((g) => g.status === "running").length;
const stopped = guests.filter((g) => g.status !== "running").length;
return {
guests,
counts: { running, stopped, total: guests.length },
};
} catch {
return EMPTY_RESULT;
}
}
@@ -0,0 +1,145 @@
import type { Client } from "ssh2";
import { execCommand } from "./common-utils.js";
import { isSafeNodeName } from "../proxmox-shared.js";
export interface ProxmoxNodeNetworkInterface {
name: string;
ip: string | null;
state: string | null;
rxBytes: string | null;
txBytes: string | null;
}
export interface ProxmoxNodeNetworkResult {
interfaces: ProxmoxNodeNetworkInterface[];
}
const EMPTY_RESULT: ProxmoxNodeNetworkResult = { interfaces: [] };
async function collectFromProcNetDev(
client: Client,
): Promise<ProxmoxNodeNetworkResult> {
const interfaces: ProxmoxNodeNetworkInterface[] = [];
try {
const [addrOut, stateOut, procNetOut] = await Promise.all([
execCommand(
client,
"ip -o addr show | awk '{print $2,$4}' | grep -v '^lo'",
),
execCommand(
client,
"ip -o link show | awk '{gsub(/:/, \"\", $2); print $2,$9}'",
),
execCommand(client, "cat /proc/net/dev"),
]);
const ifMap = new Map<
string,
{ ip: string | null; state: string | null }
>();
for (const line of addrOut.stdout
.split("\n")
.map((l) => l.trim())
.filter(Boolean)) {
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: null });
}
}
for (const line of stateOut.stdout
.split("\n")
.map((l) => l.trim())
.filter(Boolean)) {
const parts = line.split(/\s+/);
if (parts.length >= 2) {
const existing = ifMap.get(parts[0]);
if (existing) existing.state = parts[1];
}
}
const rxTxMap = new Map<string, { rx: string; tx: string }>();
for (const line of procNetOut.stdout.split("\n").slice(2)) {
const parts = line.trim().split(/\s+/);
if (parts.length >= 10) {
const ifName = parts[0].replace(":", "");
rxTxMap.set(ifName, { rx: parts[1], tx: parts[9] });
}
}
for (const [name, data] of ifMap.entries()) {
const rxTx = rxTxMap.get(name);
interfaces.push({
name,
ip: data.ip,
state: data.state,
rxBytes: rxTx?.rx ?? null,
txBytes: rxTx?.tx ?? null,
});
}
} catch {
return EMPTY_RESULT;
}
return { interfaces };
}
export async function collectProxmoxNodeNetwork(
client: Client,
nodeName: string,
): Promise<ProxmoxNodeNetworkResult> {
if (!isSafeNodeName(nodeName)) {
return EMPTY_RESULT;
}
try {
const { stdout, code } = await execCommand(
client,
`pvesh get /nodes/${nodeName}/netstat --output-format json`,
15000,
);
if (code !== 0) {
return collectFromProcNetDev(client);
}
const data = JSON.parse(stdout);
if (!Array.isArray(data)) {
return collectFromProcNetDev(client);
}
const interfaces: ProxmoxNodeNetworkInterface[] = data
.filter(
(entry): entry is Record<string, unknown> =>
!!entry && typeof entry === "object",
)
.map((entry) => ({
name:
typeof entry.dev === "string" ? entry.dev : String(entry.dev ?? ""),
ip: null,
state: null,
rxBytes:
typeof entry.in === "number"
? String(entry.in)
: typeof entry.received === "number"
? String(entry.received)
: null,
txBytes:
typeof entry.out === "number"
? String(entry.out)
: typeof entry.transmitted === "number"
? String(entry.transmitted)
: null,
}))
.filter((iface) => iface.name && iface.name !== "lo");
if (interfaces.length === 0) {
return collectFromProcNetDev(client);
}
return { interfaces };
} catch {
return collectFromProcNetDev(client);
}
}
@@ -0,0 +1,142 @@
import type { Client } from "ssh2";
import { execCommand, toFixedNum } from "./common-utils.js";
import { isSafeNodeName } from "../proxmox-shared.js";
export interface ProxmoxNodeStatusResult {
cpu: {
percent: number | null;
cores: number | null;
load: [number, number, number] | null;
};
memory: {
percent: number | null;
usedGiB: number | null;
totalGiB: number | null;
};
disk: {
percent: number | null;
usedGiB: number | null;
totalGiB: number | null;
};
uptime: {
seconds: number | null;
formatted: string | null;
};
system: {
hostname: string | null;
kernel: string | null;
pveVersion: string | null;
};
}
const EMPTY_RESULT: ProxmoxNodeStatusResult = {
cpu: { percent: null, cores: null, load: null },
memory: { percent: null, usedGiB: null, totalGiB: null },
disk: { percent: null, usedGiB: null, totalGiB: null },
uptime: { seconds: null, formatted: null },
system: { hostname: null, kernel: null, pveVersion: null },
};
function bytesToGiB(bytes: number): number {
return bytes / (1024 * 1024 * 1024);
}
function formatUptime(seconds: number): string {
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
return `${days}d ${hours}h ${minutes}m`;
}
export async function collectProxmoxNodeStatus(
client: Client,
nodeName: string,
): Promise<ProxmoxNodeStatusResult> {
if (!isSafeNodeName(nodeName)) {
return EMPTY_RESULT;
}
try {
const { stdout, code } = await execCommand(
client,
`pvesh get /nodes/${nodeName}/status --output-format json`,
25000,
);
if (code !== 0) {
return EMPTY_RESULT;
}
const data = JSON.parse(stdout) as Record<string, unknown>;
const cpuFraction = typeof data.cpu === "number" ? data.cpu : null;
const cpuinfo = (data.cpuinfo as Record<string, unknown>) || {};
const cores = typeof cpuinfo.cores === "number" ? cpuinfo.cores : null;
const loadavgRaw = data.loadavg;
let load: [number, number, number] | null = null;
if (Array.isArray(loadavgRaw) && loadavgRaw.length >= 3) {
const parsed = loadavgRaw
.slice(0, 3)
.map((v) => Number(v))
.map((v) => (Number.isFinite(v) ? v : 0));
load = parsed as [number, number, number];
}
const memory = (data.memory as Record<string, unknown>) || {};
const memUsed = typeof memory.used === "number" ? memory.used : null;
const memTotal = typeof memory.total === "number" ? memory.total : null;
const memPercent =
memUsed !== null && memTotal !== null && memTotal > 0
? Math.max(0, Math.min(100, (memUsed / memTotal) * 100))
: null;
const rootfs = (data.rootfs as Record<string, unknown>) || {};
const diskUsed = typeof rootfs.used === "number" ? rootfs.used : null;
const diskTotal = typeof rootfs.total === "number" ? rootfs.total : null;
const diskPercent =
diskUsed !== null && diskTotal !== null && diskTotal > 0
? Math.max(0, Math.min(100, (diskUsed / diskTotal) * 100))
: null;
const uptimeSeconds = typeof data.uptime === "number" ? data.uptime : null;
return {
cpu: {
percent: toFixedNum(cpuFraction !== null ? cpuFraction * 100 : null, 0),
cores,
load,
},
memory: {
percent: toFixedNum(memPercent, 0),
usedGiB: memUsed !== null ? toFixedNum(bytesToGiB(memUsed), 2) : null,
totalGiB:
memTotal !== null ? toFixedNum(bytesToGiB(memTotal), 2) : null,
},
disk: {
percent: toFixedNum(diskPercent, 0),
usedGiB: diskUsed !== null ? toFixedNum(bytesToGiB(diskUsed), 2) : null,
totalGiB:
diskTotal !== null ? toFixedNum(bytesToGiB(diskTotal), 2) : null,
},
uptime: {
seconds: uptimeSeconds,
formatted: uptimeSeconds !== null ? formatUptime(uptimeSeconds) : null,
},
system: {
hostname:
typeof data.hostname === "string" && data.hostname
? data.hostname
: null,
kernel:
typeof data.kversion === "string" && data.kversion
? data.kversion
: null,
pveVersion:
typeof data.pveversion === "string" && data.pveversion
? data.pveversion
: null,
},
};
} catch {
return EMPTY_RESULT;
}
}
@@ -0,0 +1,76 @@
import type { Client } from "ssh2";
import { execCommand, toFixedNum } from "./common-utils.js";
import { isSafeNodeName } from "../proxmox-shared.js";
export interface ProxmoxStoragePoolEntry {
name: string;
type: string;
active: boolean;
enabled: boolean;
usedGiB: number | null;
totalGiB: number | null;
availGiB: number | null;
percent: number | null;
}
export interface ProxmoxStorageResult {
pools: ProxmoxStoragePoolEntry[];
}
const EMPTY_RESULT: ProxmoxStorageResult = { pools: [] };
function bytesToGiB(bytes: number): number {
return bytes / (1024 * 1024 * 1024);
}
export async function collectProxmoxStorage(
client: Client,
nodeName: string,
): Promise<ProxmoxStorageResult> {
if (!isSafeNodeName(nodeName)) {
return EMPTY_RESULT;
}
try {
const { stdout, code } = await execCommand(
client,
`pvesh get /nodes/${nodeName}/storage --output-format json`,
25000,
);
if (code !== 0) {
return EMPTY_RESULT;
}
const data = JSON.parse(stdout);
if (!Array.isArray(data)) {
return EMPTY_RESULT;
}
const pools: ProxmoxStoragePoolEntry[] = (
data as Array<Record<string, unknown>>
).map((entry) => {
const used = typeof entry.used === "number" ? entry.used : null;
const total = typeof entry.total === "number" ? entry.total : null;
const avail = typeof entry.avail === "number" ? entry.avail : null;
const percent =
used !== null && total !== null && total > 0
? Math.max(0, Math.min(100, (used / total) * 100))
: null;
return {
name: typeof entry.storage === "string" ? entry.storage : "",
type: typeof entry.type === "string" ? entry.type : "unknown",
active: entry.active === 1 || entry.active === true,
enabled: entry.enabled === 1 || entry.enabled === true,
usedGiB: used !== null ? toFixedNum(bytesToGiB(used), 2) : null,
totalGiB: total !== null ? toFixedNum(bytesToGiB(total), 2) : null,
availGiB: avail !== null ? toFixedNum(bytesToGiB(avail), 2) : null,
percent: toFixedNum(percent, 0),
};
});
return { pools };
} catch {
return EMPTY_RESULT;
}
}
File diff suppressed because it is too large. Load diff
+36
View File
@@ -0,0 +1,36 @@
import type { Client } from "ssh2";
import type {
PluginSsh,
PluginSshConnection,
PluginSshConnectOptions,
PluginSshHost,
} from "@termix/plugin-sdk/backend";
let current: PluginSsh | null = null;
/** Set in activate, cleared on deactivate. */
export function setPluginSsh(ssh: PluginSsh | null): void {
current = ssh;
}
export function pluginSsh(): PluginSsh {
if (!current) throw new Error("The plugin is not active");
return current;
}
/** withConnection with ssh2's Client type filled in. */
export function withSshConnection<T>(
host: number | PluginSshHost,
options: PluginSshConnectOptions & { pool: string },
fn: (client: Client) => Promise<T>,
): Promise<T> {
return pluginSsh().withConnection<T, Client>(host, options, fn);
}
/** connect with ssh2's Client type filled in. */
export function connectSsh(
host: number | PluginSshHost,
options?: PluginSshConnectOptions,
): Promise<PluginSshConnection<Client>> {
return pluginSsh().connect<Client>(host, options);
}
@@ -0,0 +1,366 @@
/**
* Proxmox node stats: polling, viewer sessions and their routes.
*
* Moved here from host-metrics, which only ever hosted this because the
* plugin split predates B5. host-metrics must not import proxmox code, so
* this plugin owns its own polling manager, SSH connections and routes.
*/
import type { Router } from "express";
import express from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { pluginCtx } from "./plugin-ctx.js";
import {
ProxmoxPollingManager,
parseProxmoxStatsConfig,
type ProxmoxStatsPollableHost,
} from "./proxmox-stats-polling.js";
import type { ProxmoxNodeHistoryRepository } from "./proxmox-node-history-repository.js";
const EMPTY_SNAPSHOT = {
node: {
cpu: { percent: null, cores: null, load: null },
memory: { percent: null, usedGiB: null, totalGiB: null },
disk: { percent: null, usedGiB: null, totalGiB: null },
uptime: { seconds: null, formatted: null },
system: { hostname: null, kernel: null, pveVersion: null },
},
network: { interfaces: [] },
guests: { guests: [], counts: { running: 0, stopped: 0, total: 0 } },
storage: { pools: [] },
cluster: { clustered: false },
lastChecked: new Date(0).toISOString(),
};
type PollableHost = ProxmoxStatsPollableHost & { enableProxmoxStats: boolean };
let pollingManager: ProxmoxPollingManager<PollableHost> | undefined;
let router: Router | undefined;
async function fetchHostForPolling(
ctx: PluginContext,
hostId: number,
userId: string,
): Promise<PollableHost | undefined> {
const enabled = await ctx.asUser(userId, () =>
ctx.settings.getHost<boolean>(hostId, "enableProxmoxStats"),
);
if (!enabled) return undefined;
const host = await ctx.asUser(userId, () => ctx.hosts.get(hostId));
if (!host) return undefined;
const proxmoxStatsConfig = await ctx.asUser(userId, () =>
ctx.settings.getHost(hostId, "proxmoxStatsConfig"),
);
return {
id: host.id,
userId: host.userId,
proxmoxStatsConfig: proxmoxStatsConfig as string | undefined,
enableProxmoxStats: true,
};
}
/** Called from activate(). Builds the polling manager and mounts its routes. */
export function startProxmoxStatsService(
ctx: PluginContext,
mountOn: Router,
historyRepository: ProxmoxNodeHistoryRepository,
): void {
pollingManager = new ProxmoxPollingManager<PollableHost>({
fetchHostById: (hostId, userId) => fetchHostForPolling(ctx, hostId, userId),
withSshConnection: (host, fn) =>
ctx.ssh.withConnection(
host.id,
{
pool: "proxmox-stats",
purpose: "proxmox",
overrides: { readyTimeout: 60000 },
},
fn,
),
historyRepository,
});
router = express.Router();
registerRoutes(router, pollingManager, historyRepository);
mountOn.use("/stats", router);
}
export function stopProxmoxStatsService(): void {
pollingManager?.destroy();
pollingManager = undefined;
router = undefined;
}
function registerRoutes(
app: Router,
manager: ProxmoxPollingManager<PollableHost>,
historyRepository: ProxmoxNodeHistoryRepository,
): void {
/**
* @openapi
* /plugin-api/proxmox/stats/{id}:
* get:
* summary: Get cached Proxmox node stats for a host
* description: Returns the most recently polled Proxmox stats snapshot for a host, or an empty skeleton if none has been collected yet.
* tags: [Proxmox]
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Proxmox stats snapshot.
* 404:
* description: Stats not available yet.
*/
app.get("/:id", async (req, res) => {
const id = Number(req.params.id);
const cached = manager.getStats(id);
if (!cached) {
const errorState = manager.getError(id);
return res.status(404).json({
error: errorState?.error || "Stats not available",
...EMPTY_SNAPSHOT,
lastChecked: new Date().toISOString(),
});
}
res.json({
...cached.data,
lastChecked: new Date(cached.timestamp).toISOString(),
});
});
/**
* @openapi
* /plugin-api/proxmox/stats/start/{id}:
* post:
* summary: Start Proxmox stats collection
* description: Registers a viewer and starts (or reuses) polling for a host's Proxmox node stats.
* tags: [Proxmox]
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Polling started, snapshot returned if already available.
* 403:
* description: Proxmox stats is not enabled for this host.
* 404:
* description: Host not found.
*/
app.post("/start/:id", async (req, res) => {
const id = Number(req.params.id);
const userId = pluginCtx().currentActor()!;
try {
const host = await fetchHostForPolling(pluginCtx(), id, userId);
if (!host) {
return res
.status(403)
.json({ error: "Proxmox stats is not enabled for this host" });
}
const viewerSessionId = `proxmox-viewer-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
manager.registerViewer(id, viewerSessionId, userId);
await manager.ensurePolling(host, userId);
const cached = manager.getStats(id);
if (cached) {
return res.json({
success: true,
viewerSessionId,
...cached.data,
lastChecked: new Date(cached.timestamp).toISOString(),
});
}
const errorState = manager.getError(id);
if (errorState) {
return res.json({
success: true,
viewerSessionId,
status: "error",
error: errorState.error,
});
}
return res.json({ success: true, viewerSessionId, status: "collecting" });
} catch (error) {
pluginCtx().log.error(
`Failed to start proxmox stats collection for host ${id}`,
error as Error,
);
res
.status(500)
.json({ error: "Failed to start proxmox stats collection" });
}
});
/**
* @openapi
* /plugin-api/proxmox/stats/stop/{id}:
* post:
* summary: Stop Proxmox stats collection
* description: Unregisters a viewer session for a host's Proxmox node stats polling.
* tags: [Proxmox]
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* requestBody:
* required: false
* content:
* application/json:
* schema:
* type: object
* properties:
* viewerSessionId:
* type: string
* responses:
* 200:
* description: Polling stopped successfully.
*/
app.post("/stop/:id", (req, res) => {
const id = Number(req.params.id);
const { viewerSessionId } = req.body as { viewerSessionId?: string };
if (viewerSessionId && typeof viewerSessionId === "string") {
manager.unregisterViewer(id, viewerSessionId);
}
res.json({ success: true });
});
/**
* @openapi
* /plugin-api/proxmox/stats/heartbeat:
* post:
* summary: Update Proxmox stats viewer heartbeat
* description: Keeps a Proxmox stats viewer session alive.
* tags: [Proxmox]
* 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.
*/
app.post("/heartbeat", (req, res) => {
const { viewerSessionId } = req.body as { viewerSessionId?: string };
if (!viewerSessionId || typeof viewerSessionId !== "string") {
return res.status(400).json({ error: "Invalid viewerSessionId" });
}
const ok = manager.updateHeartbeat(viewerSessionId);
if (!ok) return res.status(404).json({ error: "Viewer session not found" });
res.json({ success: true });
});
/**
* @openapi
* /plugin-api/proxmox/stats/history/{hostId}:
* get:
* summary: Get historical Proxmox node stats for a host
* tags: [Proxmox]
* parameters:
* - in: path
* name: hostId
* 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
* format: date-time
* - in: query
* name: to
* schema:
* type: string
* format: date-time
* responses:
* 200:
* description: Array of node history rows.
* 400:
* description: Invalid range or date.
*/
app.get("/history/:hostId", async (req, res) => {
const hostId = Number(req.params.hostId);
const { range, from, to } = req.query as Record<string, string | undefined>;
const RANGE_OFFSETS: Record<string, number> = {
"1h": 1 * 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,
};
let fromTs: string;
let toTs = new Date().toISOString();
if (range) {
const offsetMs = RANGE_OFFSETS[range];
if (!offsetMs) {
return res
.status(400)
.json({ error: "Invalid range. Use 1h, 6h, 24h, 7d, or 30d" });
}
fromTs = new Date(Date.now() - offsetMs).toISOString();
} else if (from && to) {
const fromDate = new Date(from);
const toDate = new Date(to);
if (isNaN(fromDate.getTime()) || isNaN(toDate.getTime())) {
return res.status(400).json({ error: "Invalid from/to date format" });
}
fromTs = fromDate.toISOString();
toTs = toDate.toISOString();
} else {
fromTs = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
}
try {
const toSqlite = (iso: string) =>
iso.replace("T", " ").replace(/\.\d{3}Z$/, "");
const rows = (
await historyRepository.listRange(
hostId,
toSqlite(fromTs),
toSqlite(toTs),
)
).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, toTs });
} catch (error) {
pluginCtx().log.error(
`Failed to fetch proxmox stats history for host ${hostId}`,
error as Error,
);
res.status(500).json({ error: "Failed to fetch proxmox stats history" });
}
});
}
export { parseProxmoxStatsConfig };
+64
View File
@@ -0,0 +1,64 @@
import {
adoptLegacyTable,
defineTable,
id,
integer,
real,
refHost,
refUser,
text,
timestamp,
} from "@termix/plugin-sdk/db";
/**
* Per-host historical node stats samples (CPU, memory, disk, network),
* polled on an interval while a viewer has the Proxmox stats tab open.
*
* Adopted from core's proxmox_node_history, so the column and index names are
* the legacy ones: the rename carries the existing rows across.
*/
export const proxmoxNodeHistory = adoptLegacyTable(
"proxmox_node_history",
defineTable("node_history", {
id: id(),
hostId: refHost(),
ts: timestamp().notNull().defaultNow(),
cpuPercent: real(),
memPercent: real(),
diskPercent: real(),
netRxBytes: integer(),
netTxBytes: integer(),
}),
);
/**
* One saved dashboard layout per user per host for the Proxmox stats tab.
*
* Adopted from core's proxmox_stats_preferences.
*/
export const proxmoxStatsPreferences = adoptLegacyTable(
"proxmox_stats_preferences",
defineTable(
"stats_preferences",
{
id: id(),
userId: refUser(),
hostId: refHost(),
// JSON-encoded layout. No secrets, so plain text.
layout: text().notNull(),
createdAt: timestamp().notNull().defaultNow(),
updatedAt: timestamp().notNull().defaultNow(),
},
{
indexes: [
{
name: "idx_proxmox_stats_prefs_user_host",
columns: ["userId", "hostId"],
unique: true,
},
],
},
),
);
export const tables = [proxmoxNodeHistory, proxmoxStatsPreferences];
@@ -0,0 +1,263 @@
import { useEffect, useState } from "react";
import { useSshAuthTypes, useTranslation } from "@termix/plugin-sdk/frontend";
import { Server } from "lucide-react";
import {
FakeSwitch,
Input,
SectionCard,
Select2,
SettingRow,
} from "@termix/plugin-sdk/ui";
import { listCredentials } from "@termix/plugin-sdk/frontend";
import type { ProxmoxHostConfig } from "./types";
interface HostProxmoxForm {
enableProxmox: boolean;
proxmoxConfig: ProxmoxHostConfig | null;
}
type SetHostField = (key: string, value: unknown) => void;
export function HostProxmoxTab({
form,
setField,
}: {
form: HostProxmoxForm;
setField: SetHostField;
}) {
const { t } = useTranslation();
const sshAuthTypes = useSshAuthTypes();
const [credentials, setCredentials] = useState<
{ id: number; name: string; username: string | null }[]
>([]);
useEffect(() => {
listCredentials()
.then((list) =>
setCredentials(
list.map((c) => ({
id: c.id,
name: c.name,
username: c.username ?? null,
})),
),
)
.catch(() => {});
}, []);
const cfg = form.proxmoxConfig ?? {
defaultCredentialId: null,
defaultAuthType: "password",
windowsPatterns: "win, windows",
dockerPatterns: "docker",
preferredPrefixes: "10., 192.168.",
autoSyncEnabled: false,
syncIntervalMinutes: 15,
markMissingGuests: true,
};
const lastSyncResult = cfg.lastSyncResult;
const lastSyncSummary = lastSyncResult
? t("hosts.proxmoxLastSyncSummary", {
created: lastSyncResult.created,
updated: lastSyncResult.updated,
markedMissing: lastSyncResult.markedMissing,
skipped: lastSyncResult.skipped,
})
: t("hosts.proxmoxLastSyncNoResult");
const lastSyncDescription = cfg.lastSyncAt
? `${new Date(cfg.lastSyncAt).toLocaleString()}, ${lastSyncSummary}`
: t("hosts.proxmoxLastSyncNever");
return (
<SectionCard
title={t("hosts.proxmoxIntegration")}
icon={<Server className="size-3.5" />}
>
<div className="flex flex-col gap-0 py-1">
<SettingRow
label={t("hosts.enableProxmox")}
description={
<>
{t("hosts.enableProxmoxDesc")}{" "}
<a
href="https://docs.termix.site/features/files-and-hosts/proxmox-import"
target="_blank"
rel="noreferrer"
className="text-accent-brand hover:underline"
>
{t("hosts.docsLink")}
</a>
</>
}
>
<FakeSwitch
checked={form.enableProxmox}
onChange={(v) => setField("enableProxmox", v)}
/>
</SettingRow>
{form.enableProxmox && (
<>
<SettingRow
label={t("hosts.proxmoxDefaultAuthType")}
description={t("hosts.proxmoxDefaultAuthTypeDesc")}
>
<Select2
value={cfg.defaultAuthType ?? "password"}
onChange={(e) =>
setField("proxmoxConfig", {
...cfg,
defaultAuthType: e.target.value,
})
}
className="h-7 w-44 text-xs border border-border bg-background px-2 outline-none focus:ring-1 focus:ring-ring"
>
{sshAuthTypes.types.map((option) => (
<option key={option.type} value={option.type}>
{t(option.labelKey)}
</option>
))}
</Select2>
</SettingRow>
<SettingRow
label={t("hosts.proxmoxDefaultCredential")}
description={t("hosts.proxmoxDefaultCredentialDesc")}
>
<Select2
value={cfg.defaultCredentialId ?? ""}
onChange={(e) =>
setField("proxmoxConfig", {
...cfg,
defaultCredentialId: e.target.value
? Number(e.target.value)
: null,
})
}
className="h-7 w-44 text-xs border border-border bg-background px-2 outline-none focus:ring-1 focus:ring-ring"
>
<option value="">{t("hosts.none")}</option>
{credentials.map((c) => (
<option key={c.id} value={c.id}>
{c.username ? `${c.name} (${c.username})` : c.name}
</option>
))}
</Select2>
</SettingRow>
<SettingRow
label={t("hosts.proxmoxWindowsDetection")}
description={t("hosts.proxmoxWindowsDetectionDesc")}
>
<Input
className="w-44 h-7 text-xs"
value={cfg.windowsPatterns}
onChange={(e) =>
setField("proxmoxConfig", {
...cfg,
windowsPatterns: e.target.value,
})
}
placeholder="win, windows"
/>
</SettingRow>
<SettingRow
label={t("hosts.proxmoxDockerDetection")}
description={t("hosts.proxmoxDockerDetectionDesc")}
>
<Input
className="w-44 h-7 text-xs"
value={cfg.dockerPatterns}
onChange={(e) =>
setField("proxmoxConfig", {
...cfg,
dockerPatterns: e.target.value,
})
}
placeholder="docker"
/>
</SettingRow>
<SettingRow
label={t("hosts.proxmoxPreferredRanges")}
description={t("hosts.proxmoxPreferredRangesDesc")}
>
<Input
className="w-44 h-7 text-xs"
value={cfg.preferredPrefixes}
onChange={(e) =>
setField("proxmoxConfig", {
...cfg,
preferredPrefixes: e.target.value,
})
}
placeholder="10., 192.168."
/>
</SettingRow>
<SettingRow
label={t("hosts.proxmoxAutoSync")}
description={t("hosts.proxmoxAutoSyncDesc")}
>
<FakeSwitch
checked={cfg.autoSyncEnabled === true}
onChange={(v) =>
setField("proxmoxConfig", {
...cfg,
autoSyncEnabled: v,
})
}
/>
</SettingRow>
<SettingRow
label={t("hosts.proxmoxSyncInterval")}
description={t("hosts.proxmoxSyncIntervalDesc")}
>
<Input
className="w-24 h-7 text-xs"
type="number"
min={5}
value={cfg.syncIntervalMinutes ?? 15}
onChange={(e) =>
setField("proxmoxConfig", {
...cfg,
syncIntervalMinutes: Math.max(
5,
Number.parseInt(e.target.value || "15", 10),
),
})
}
/>
</SettingRow>
<SettingRow
label={t("hosts.proxmoxMarkMissing")}
description={t("hosts.proxmoxMarkMissingDesc")}
>
<FakeSwitch
checked={cfg.markMissingGuests !== false}
onChange={(v) =>
setField("proxmoxConfig", {
...cfg,
markMissingGuests: v,
})
}
/>
</SettingRow>
<SettingRow
label={t("hosts.proxmoxLastSync")}
description={lastSyncDescription}
>
<span
className={`text-xs ${
cfg.lastSyncStatus === "error"
? "text-destructive"
: "text-muted-foreground"
}`}
title={cfg.lastSyncError ?? undefined}
>
{cfg.lastSyncStatus
? t(`hosts.proxmoxLastSyncStatus.${cfg.lastSyncStatus}`)
: t("hosts.proxmoxLastSyncStatus.pending")}
</span>
</SettingRow>
</>
)}
</div>
</SectionCard>
);
}
@@ -0,0 +1,434 @@
import { useRef, useState } from "react";
import {
notifyHostsChanged,
useTranslation,
} from "@termix/plugin-sdk/frontend";
import { Server, RefreshCw, CheckSquare, Square, Download } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@termix/plugin-sdk/ui";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@termix/plugin-sdk/ui";
import { Select2 } from "@termix/plugin-sdk/ui";
import {
discoverProxmoxGuestsStream,
importProxmoxHosts,
listHosts,
} from "./proxmox-api";
import type { PluginHostRecord } from "@termix/plugin-sdk/frontend";
import type { ProxmoxGuest } from "./types";
import { resolveProxmoxImportAuth } from "./proxmox-import-auth";
interface ProxmoxDiscoverDialogProps {
open: boolean;
onClose: () => void;
hosts: PluginHostRecord[];
onHostsChanged: (hosts: PluginHostRecord[]) => void;
/** Pre-select a specific host and skip the host picker */
preselectedHostId?: number;
/** Credential to use for imported hosts */
defaultCredentialId?: number | null;
/** Auth type to use for imported hosts */
defaultAuthType?: string;
/** Username from the default credential */
defaultUsername?: string;
}
export function ProxmoxDiscoverDialog({
open,
onClose,
hosts,
onHostsChanged,
preselectedHostId,
defaultCredentialId,
defaultAuthType,
defaultUsername,
}: ProxmoxDiscoverDialogProps) {
const { t } = useTranslation();
const [selectedHostId, setSelectedHostId] = useState<string>(
preselectedHostId ? String(preselectedHostId) : "",
);
const [discovering, setDiscovering] = useState(false);
const [progress, setProgress] = useState<{
done: number;
total: number;
} | null>(null);
const streamCloseRef = useRef<(() => void) | null>(null);
const [guests, setGuests] = useState<ProxmoxGuest[] | null>(null);
const [discoveredCredentialId, setDiscoveredCredentialId] = useState<
number | null
>(null);
const [discoveredJumpHosts, setDiscoveredJumpHosts] = useState<
unknown[] | null
>(null);
const [selected, setSelected] = useState<Set<number>>(new Set());
const [importing, setImporting] = useState(false);
// When opened from the dropdown (no preselectedHostId), only show Proxmox-enabled hosts
const sshHosts = hosts.filter(
(h) =>
!("isFolder" in h) &&
(
h.pluginSettings as
Record<string, Record<string, unknown> | undefined> | undefined
)?.proxmox?.enableProxmox === true,
);
// The Proxmox host the discovery runs against — imported guests are grouped
// into a folder named after it. Use the same id resolution as discovery so
// it also works when launched directly from a host action (preselectedHostId).
const effectiveHostId =
preselectedHostId != null ? String(preselectedHostId) : selectedHostId;
const sourceHost = hosts.find(
(h) => !("isFolder" in h) && String(h.id) === effectiveHostId,
);
const importFolder = sourceHost?.name || "Proxmox";
function reset() {
if (!preselectedHostId) setSelectedHostId("");
setGuests(null);
setDiscoveredCredentialId(null);
setDiscoveredJumpHosts(null);
setSelected(new Set());
streamCloseRef.current?.();
streamCloseRef.current = null;
setProgress(null);
setDiscovering(false);
setImporting(false);
}
function handleDiscover() {
const hostId =
preselectedHostId ?? (selectedHostId ? Number(selectedHostId) : null);
if (!hostId) return;
setDiscovering(true);
setGuests(null);
setDiscoveredCredentialId(null);
setDiscoveredJumpHosts(null);
setSelected(new Set());
setProgress(null);
streamCloseRef.current?.();
streamCloseRef.current = discoverProxmoxGuestsStream(hostId, {
onProgress: (done, total) => setProgress({ done, total }),
onResult: (result) => {
setGuests(result.guests);
setDiscoveredCredentialId(result.credentialId ?? null);
setDiscoveredJumpHosts(result.jumpHosts ?? null);
setSelected(
new Set(
result.guests
.filter((g) => g.status === "running")
.map((g) => g.vmid),
),
);
setDiscovering(false);
setProgress(null);
streamCloseRef.current = null;
},
onError: (message) => {
toast.error(message ?? t("hosts.proxmoxDiscoveryFailed"));
setDiscovering(false);
setProgress(null);
streamCloseRef.current = null;
},
});
}
async function handleImport() {
if (!guests || selected.size === 0) return;
setImporting(true);
try {
// Prefer explicitly configured credential, then fall back to the host's own credential
const credId = defaultCredentialId ?? discoveredCredentialId;
const importAuth = resolveProxmoxImportAuth(defaultAuthType, credId);
const selectedGuests = guests.filter((g) => selected.has(g.vmid));
const toImport = selectedGuests.map((g) => ({
name: g.name,
// No IP discovered (e.g. QEMU without a running guest agent): import
// with a placeholder so the host is created and the user can fill in
// the real IP. Re-sync keeps the manual value (guest.ip || existing.ip).
ip: g.ip || "0.0.0.0",
port: g.connectionType === "rdp" ? 3389 : 22,
username:
importAuth.authType === "credential"
? ""
: (defaultUsername ?? "root"),
folder: importFolder,
// Inherit the jump-host chain from the scanned Proxmox host so the
// imported guests are reachable the same way; user can override.
jumpHosts: discoveredJumpHosts ?? undefined,
...importAuth,
enableSsh: g.connectionType !== "rdp",
connectionType: g.connectionType,
tags: [
"proxmox",
g.type,
g.node,
g.type === "lxc" ? `ct-${g.vmid}` : `vm-${g.vmid}`,
...(g.enableDocker ? ["docker"] : []),
],
// Host settings travel with the import like an export's, keyed by
// the plugin that keeps them.
pluginSettings: {
...(g.connectionType === "rdp"
? { "remote-desktop": { enableRdp: true, rdpPort: 3389 } }
: {}),
...(g.enableDocker ? { docker: { enableDocker: true } } : {}),
proxmox: {
proxmoxConfig: {
source: {
source: "proxmox",
sourceHostId: Number(effectiveHostId),
node: g.node,
vmid: g.vmid,
type: g.type,
lastSeenAt: new Date().toISOString(),
lastStatus: g.status,
missingSince: null,
},
},
},
},
}));
const result = toImport.length
? await importProxmoxHosts(toImport)
: { success: 0, failed: 0, errors: [] };
if (toImport.length) {
const updated = await listHosts();
onHostsChanged(updated);
notifyHostsChanged();
}
const msg = [
result.success
? t("hosts.proxmoxResultImported", { count: result.success })
: null,
result.failed
? t("hosts.proxmoxResultFailed", { count: result.failed })
: null,
]
.filter(Boolean)
.join(", ");
toast.success(t("hosts.proxmoxImportComplete", { summary: msg }));
if (result.failed === 0) {
reset();
onClose();
}
} catch (err: any) {
toast.error(err?.message ?? t("hosts.proxmoxImportFailed"));
} finally {
setImporting(false);
}
}
const nodeGroups = guests
? guests.reduce(
(acc, g) => {
if (!acc[g.node]) acc[g.node] = [];
acc[g.node].push(g);
return acc;
},
{} as Record<string, ProxmoxGuest[]>,
)
: {};
const canDiscover = preselectedHostId != null || !!selectedHostId;
return (
<Dialog
open={open}
onOpenChange={(v) => {
if (!v) {
reset();
onClose();
}
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Server className="size-4" />
{t("hosts.proxmoxImportTitle")}
</DialogTitle>
<a
href="https://docs.termix.site/features/files-and-hosts/proxmox-import"
target="_blank"
rel="noreferrer"
className="text-[10px] text-accent-brand hover:underline"
>
{t("hosts.docsLink")}
</a>
</DialogHeader>
<div className="space-y-3">
{/* Host selector — hidden when launched from a specific host */}
{!preselectedHostId && (
<div className="flex gap-2">
<Select2
value={selectedHostId}
onChange={(event) => setSelectedHostId(event.target.value)}
disabled={discovering}
placeholder={t("hosts.proxmoxSelectHost")}
className="flex-1 text-xs h-8"
>
{sshHosts.map((h) => (
<option key={h.id} value={String(h.id)}>
{h.name || h.ip}
</option>
))}
</Select2>
<Button
size="sm"
variant="outline"
disabled={!canDiscover || discovering}
onClick={handleDiscover}
className="shrink-0"
>
<RefreshCw
className={`size-3.5 mr-1.5 ${discovering ? "animate-spin" : ""}`}
/>
{t("hosts.proxmoxDiscover")}
</Button>
</div>
)}
{/* When launched from a specific host, show Discover directly */}
{preselectedHostId && !guests && (
<Button
size="sm"
variant="outline"
disabled={discovering}
onClick={handleDiscover}
className="w-full"
>
<RefreshCw
className={`size-3.5 mr-1.5 ${discovering ? "animate-spin" : ""}`}
/>
{discovering
? progress
? `${t("hosts.proxmoxDiscovering")} ${progress.done}/${progress.total}`
: t("hosts.proxmoxDiscovering")
: t("hosts.proxmoxDiscoverGuests")}
</Button>
)}
{/* Guest list */}
{guests !== null && (
<div className="space-y-1.5">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>
{t("hosts.proxmoxGuestsSelected", {
count: guests.length,
selected: selected.size,
})}
</span>
<div className="flex gap-3">
<button
className="hover:text-foreground transition-colors"
onClick={() =>
setSelected(new Set(guests.map((g) => g.vmid)))
}
>
{t("hosts.proxmoxSelectAll")}
</button>
<button
className="hover:text-foreground transition-colors"
onClick={() => setSelected(new Set())}
>
{t("hosts.proxmoxDeselectAll")}
</button>
</div>
</div>
{guests.length === 0 ? (
<p className="text-xs text-muted-foreground py-4 text-center">
{t("hosts.proxmoxNoGuests")}
</p>
) : (
<div className="max-h-64 overflow-y-auto rounded-md border border-border divide-y divide-border">
{Object.entries(nodeGroups).map(([node, nodeGuests]) => (
<div key={node}>
<div className="px-2 py-1 text-[10px] font-medium text-muted-foreground bg-muted/50 uppercase tracking-wider">
{node}
</div>
{nodeGuests.map((g) => (
<button
key={g.vmid}
className="w-full flex items-center gap-2 px-2 py-1.5 text-xs hover:bg-accent transition-colors text-left"
onClick={() => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(g.vmid)) next.delete(g.vmid);
else next.add(g.vmid);
return next;
});
}}
>
{selected.has(g.vmid) ? (
<CheckSquare className="size-3.5 text-primary shrink-0" />
) : (
<Square className="size-3.5 text-muted-foreground shrink-0" />
)}
<span className="flex-1 truncate">{g.name}</span>
<span className="text-muted-foreground text-[10px] shrink-0">
{g.type.toUpperCase()} {g.vmid}
</span>
<span
className={`shrink-0 text-[10px] ${g.status === "running" ? "text-green-400" : "text-muted-foreground"}`}
>
{g.status}
</span>
<span
className={`shrink-0 text-[10px] font-mono ${
g.ip
? "text-muted-foreground"
: "text-amber-400/80 italic"
}`}
>
{g.ip || "no IP"}
</span>
</button>
))}
</div>
))}
</div>
)}
</div>
)}
</div>
<DialogFooter>
<Button
variant="ghost"
size="sm"
onClick={() => {
reset();
onClose();
}}
>
{t("common.cancel")}
</Button>
{guests !== null && selected.size > 0 && (
<Button size="sm" disabled={importing} onClick={handleImport}>
{importing ? (
<RefreshCw className="size-3.5 animate-spin mr-1.5" />
) : (
<Download className="size-3.5 mr-1.5" />
)}
{t("hosts.proxmoxImportButton", { count: selected.size })}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+217
View File
@@ -0,0 +1,217 @@
import { useEffect, useState, type ComponentType } from "react";
import { Boxes, HardDrive, Server } from "lucide-react";
import {
useHosts,
useTranslation,
type HostEditorSectionProps,
type StandaloneViewProps,
type TabProps,
type TermixApp,
} from "@termix/plugin-sdk/frontend";
import { ComponentSlot, DropdownMenuItem } from "@termix/plugin-sdk/ui";
import type { PluginHostRecord } from "@termix/plugin-sdk/frontend";
import { setProxmoxApp } from "./proxmox-api";
import { ProxmoxDiscoverDialog } from "./ProxmoxDiscoverDialog";
import { HostProxmoxTab } from "./HostProxmoxTab";
import { ProxmoxStatsTab } from "./stats/ProxmoxStatsTab";
import ProxmoxStatsApp from "./stats/ProxmoxStatsApp";
import { HostProxmoxStatsTab } from "./stats/HostProxmoxStatsTab";
interface DiscoverRequest {
hostId?: number;
defaultCredentialId?: number | null;
defaultAuthType?: string;
}
type Opener = (request: DiscoverRequest) => void;
// The Proxmox fields are this plugin's host settings, saved with the host
// through form.pluginSettings.proxmox.
function ProxmoxHostSection({ form, updateForm }: HostEditorSectionProps) {
const values = (form.pluginSettings?.proxmox ?? {}) as Record<
string,
unknown
>;
const setField = (key: string, value: unknown) =>
updateForm((current) => {
const all = (current.pluginSettings ?? {}) as Record<
string,
Record<string, unknown>
>;
return {
...current,
pluginSettings: {
...all,
proxmox: { ...(all.proxmox ?? {}), [key]: value },
},
};
});
const sectionForm = {
enableProxmox: values.enableProxmox === true,
proxmoxConfig: values.proxmoxConfig ?? null,
enableProxmoxStats: values.enableProxmoxStats === true,
proxmoxStatsConfig: values.proxmoxStatsConfig ?? {
pollInterval: 60,
nodeName: null,
},
};
return (
<>
<HostProxmoxTab
form={sectionForm as Parameters<typeof HostProxmoxTab>[0]["form"]}
setField={setField as Parameters<typeof HostProxmoxTab>[0]["setField"]}
/>
<HostProxmoxStatsTab
form={sectionForm as Parameters<typeof HostProxmoxStatsTab>[0]["form"]}
setField={
setField as Parameters<typeof HostProxmoxStatsTab>[0]["setField"]
}
/>
{/* Other plugins add Proxmox-related settings here. */}
<ComponentSlot
slotId="proxmox.hostEditor"
props={{ form: sectionForm, setField }}
/>
</>
);
}
function ProxmoxStatsTabView({ sshHost, label, isVisible }: TabProps) {
return (
<ProxmoxStatsTab
hostConfig={sshHost as never}
title={label}
isVisible={isVisible}
isTopbarOpen={false}
embedded={true}
/>
);
}
function ProxmoxStatsStandalone({ hostId }: StandaloneViewProps) {
return <ProxmoxStatsApp hostId={hostId} />;
}
/** This plugin's host settings, as they arrive on the host record. */
export function proxmoxSettings(
host: Pick<PluginHostRecord, "pluginSettings">,
): Record<string, unknown> {
return host.pluginSettings?.proxmox ?? {};
}
export function activate(app: TermixApp): void {
setProxmoxApp(app);
app.onDispose(() => setProxmoxApp(null));
// The dialog lives in the hosts panel; the menu item and host action open
// it through this, created per activation.
const openers = new Set<Opener>();
const openDiscover: Opener = (request) => {
for (const open of openers) open(request);
};
function DiscoverDialogHost({
hosts,
onHostsChanged,
}: {
hosts: PluginHostRecord[];
onHostsChanged: (hosts: PluginHostRecord[]) => void;
}) {
const [request, setRequest] = useState<DiscoverRequest | null>(null);
useEffect(() => {
openers.add(setRequest);
return () => {
openers.delete(setRequest);
};
}, []);
if (!request) return null;
return (
<ProxmoxDiscoverDialog
open
onClose={() => setRequest(null)}
hosts={hosts}
onHostsChanged={onHostsChanged}
preselectedHostId={request.hostId}
defaultCredentialId={request.defaultCredentialId ?? null}
defaultAuthType={request.defaultAuthType}
/>
);
}
function ImportMenuItem() {
const { t } = useTranslation();
const { hosts } = useHosts();
return (
<DropdownMenuItem
onClick={() => openDiscover({})}
disabled={!hosts.some((host) => proxmoxSettings(host).enableProxmox)}
>
<Server className="size-3.5 mr-2" />
{t("hosts.proxmoxImportTitle")}
</DropdownMenuItem>
);
}
app.registerSlotContribution("hosts.panel", {
actionId: "proxmox.discoverDialog",
titleKey: "hosts.proxmoxImportTitle",
kind: "component",
component: DiscoverDialogHost as unknown as ComponentType<
Record<string, unknown>
>,
});
app.registerSlotContribution("hosts.importMenu", {
actionId: "proxmox.import",
titleKey: "hosts.proxmoxImportTitle",
kind: "component",
component: ImportMenuItem as ComponentType<Record<string, unknown>>,
});
app.registerHostAction({
id: "proxmox-discover",
titleKey: "hosts.proxmoxDiscoverAction",
icon: Boxes,
kind: "open",
order: 90,
when: (host) => !!proxmoxSettings(host).enableProxmox,
run: (host) => {
const config = proxmoxSettings(host).proxmoxConfig as
| { defaultCredentialId?: number | null; defaultAuthType?: string }
| undefined;
openDiscover({
hostId: Number(host.id),
defaultCredentialId: config?.defaultCredentialId ?? null,
defaultAuthType: config?.defaultAuthType ?? undefined,
});
},
});
app.registerHostEditorSection({
id: "proxmox",
group: "ssh",
titleKey: "hosts.tabProxmox",
icon: Server,
order: 50,
component: ProxmoxHostSection,
});
app.registerTab("proxmox-stats", ProxmoxStatsTabView, {
icon: HardDrive,
titleKey: "nav.proxmoxStats",
requiresHost: true,
noHostMessageKey: "proxmoxStats.noHostSelected",
standalone: ProxmoxStatsStandalone,
preload: () => import("./stats/ProxmoxStatsTab"),
});
app.registerHostAction({
id: "proxmox-stats",
titleKey: "nav.proxmoxStats",
icon: HardDrive,
kind: "open",
order: 60,
tabType: "proxmox-stats",
copyUrlView: "proxmox-stats",
when: (host) => proxmoxSettings(host).enableProxmoxStats === true,
});
}
+121
View File
@@ -0,0 +1,121 @@
import type { TermixApp } from "@termix/plugin-sdk/frontend";
import type { ProxmoxDiscoverResult } from "./types";
let current: TermixApp | null = null;
/** Set in activate, cleared on deactivate. */
export function setProxmoxApp(app: TermixApp | null): void {
current = app;
}
function app(): TermixApp {
if (!current) throw new Error("The Proxmox plugin is not active");
return current;
}
export function listHosts(): ReturnType<TermixApp["listHosts"]> {
return app().listHosts();
}
/**
* Runs a guest discovery over the server-sent event stream and reports
* progress. Returns a function that stops listening.
*/
export function discoverProxmoxGuestsStream(
hostId: number,
handlers: {
onProgress?: (done: number, total: number) => void;
onResult: (result: ProxmoxDiscoverResult) => void;
onError: (message: string) => void;
},
): () => void {
const controller = new AbortController();
let settled = false;
const finish = () => {
settled = true;
controller.abort();
};
const handleEvent = (event: string, data: string) => {
if (event === "progress") {
try {
const parsed = JSON.parse(data);
handlers.onProgress?.(parsed.done, parsed.total);
} catch {
// ignore malformed progress frames
}
} else if (event === "result") {
finish();
try {
handlers.onResult(JSON.parse(data));
} catch {
handlers.onError("Failed to parse discovery result");
}
} else if (event === "fail") {
finish();
let message = "Discovery failed";
try {
message = JSON.parse(data).message || message;
} catch {
// keep default message
}
handlers.onError(message);
}
};
void (async () => {
try {
const response = await app().fetch(
`/discover/stream?hostId=${encodeURIComponent(String(hostId))}`,
{ signal: controller.signal },
);
if (!response.ok || !response.body) throw new Error("stream failed");
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let split = buffer.indexOf("\n\n");
while (split !== -1) {
const frame = buffer.slice(0, split);
buffer = buffer.slice(split + 2);
let event = "message";
const data: string[] = [];
for (const line of frame.split("\n")) {
if (line.startsWith("event:")) event = line.slice(6).trim();
else if (line.startsWith("data:")) data.push(line.slice(5).trim());
}
if (data.length) handleEvent(event, data.join("\n"));
split = buffer.indexOf("\n\n");
}
}
if (!settled) {
settled = true;
handlers.onError("Discovery connection lost");
}
} catch {
if (settled) return;
settled = true;
handlers.onError("Discovery connection lost");
}
})();
return finish;
}
export interface ProxmoxImportResult {
success: number;
failed: number;
errors: string[];
}
export async function importProxmoxHosts(
hosts: Record<string, unknown>[],
): Promise<ProxmoxImportResult> {
const response = await app().api.post<ProxmoxImportResult>("/import", {
hosts,
});
return response.data;
}
@@ -0,0 +1,36 @@
const SECRET_BACKED_AUTH_TYPES = new Set(["password", "key"]);
export type ProxmoxImportAuth = {
authType: string;
credentialId?: number;
overrideCredentialUsername?: boolean;
};
export function resolveProxmoxImportAuth(
defaultAuthType: string | undefined,
credentialId: number | null | undefined,
): ProxmoxImportAuth {
// Any auth type that needs no stored secret (none, agent, or one a plugin
// adds) wins, the same rule the backend copy applies.
if (
defaultAuthType &&
defaultAuthType !== "credential" &&
!SECRET_BACKED_AUTH_TYPES.has(defaultAuthType)
) {
return { authType: defaultAuthType };
}
// A credential (configured default OR inherited from the source Proxmox host)
// is a concrete auth source -> use it, even when defaultAuthType is the
// "password"/"key" default. Otherwise imported guests end up as authType
// "none" although the host authenticates via a credential.
if (credentialId) {
return {
authType: "credential",
credentialId,
overrideCredentialUsername: false,
};
}
return { authType: "none" };
}
@@ -0,0 +1,261 @@
import { useMemo, useState } from "react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { Box, Search, Server as ServerIcon } from "lucide-react";
import { Input } from "@termix/plugin-sdk/ui";
import type { ProxmoxGuestSummary } from "../types";
import { cn } from "@termix/plugin-sdk/ui";
type StatusFilter = "all" | "running" | "stopped";
type TypeFilter = "all" | "qemu" | "lxc";
function formatUptime(seconds: number | null): string {
if (seconds === null || seconds <= 0) return "-";
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (days > 0) return `${days}d ${hours}h`;
if (hours > 0) return `${hours}h ${minutes}m`;
return `${minutes}m`;
}
function UsageCell({
percent,
usedGiB,
totalGiB,
}: {
percent: number | null;
usedGiB?: number | null;
totalGiB?: number | null;
}) {
if (percent === null) {
return <span className="text-[11px] text-muted-foreground">-</span>;
}
const clamped = Math.min(100, Math.max(0, percent));
const barColor =
clamped >= 90
? "bg-red-500"
: clamped >= 75
? "bg-yellow-500"
: "bg-accent-brand";
return (
<div className="flex min-w-[92px] flex-col gap-1">
<div className="flex items-baseline justify-between gap-2">
<span className="text-xs font-semibold tabular-nums">
{clamped.toFixed(0)}%
</span>
{usedGiB != null && totalGiB != null && (
<span className="text-[10px] tabular-nums text-muted-foreground">
{usedGiB.toFixed(1)}/{totalGiB.toFixed(1)}G
</span>
)}
</div>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
<div
className={cn("h-full rounded-full", barColor)}
style={{ width: `${clamped}%` }}
/>
</div>
</div>
);
}
export function GuestTable({ guests }: { guests: ProxmoxGuestSummary[] }) {
const { t } = useTranslation();
const [query, setQuery] = useState("");
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all");
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
return guests
.filter(
(g) =>
statusFilter === "all" ||
(statusFilter === "running") === (g.status === "running"),
)
.filter((g) => typeFilter === "all" || g.type === typeFilter)
.filter(
(g) =>
!q || g.name.toLowerCase().includes(q) || String(g.vmid).includes(q),
)
.sort((a, b) => {
if (a.status === b.status) return a.name.localeCompare(b.name);
return a.status === "running" ? -1 : 1;
});
}, [guests, query, statusFilter, typeFilter]);
const running = guests.filter((g) => g.status === "running").length;
return (
<div className="flex min-h-[420px] flex-1 flex-col overflow-hidden border border-border bg-card">
<div className="flex shrink-0 flex-wrap items-center gap-2 border-b border-border px-3 py-2.5">
<span className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
{t("proxmoxStats.guestsSummary")}
</span>
<span className="text-[11px] text-muted-foreground">
{t("proxmoxStats.guestCounts", { running, total: guests.length })}
</span>
<div className="ml-auto flex items-center gap-1.5">
<div className="relative">
<Search className="pointer-events-none absolute left-2 top-1/2 size-3 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t("proxmoxStats.searchGuests")}
className="h-7 w-40 pl-6 text-xs"
/>
</div>
<div className="flex overflow-hidden rounded-none border border-border">
{(["all", "qemu", "lxc"] as TypeFilter[]).map((f) => (
<button
key={f}
onClick={() => setTypeFilter(f)}
className={cn(
"px-2 py-1 text-[10px] font-semibold uppercase tracking-wide transition-colors",
typeFilter === f
? "bg-accent-brand text-white"
: "bg-background text-muted-foreground hover:bg-muted",
)}
>
{f === "all"
? t("proxmoxStats.filterAll")
: f === "qemu"
? t("proxmoxStats.filterVm")
: t("proxmoxStats.filterLxc")}
</button>
))}
</div>
<div className="flex overflow-hidden rounded-none border border-border">
{(["all", "running", "stopped"] as StatusFilter[]).map((f) => (
<button
key={f}
onClick={() => setStatusFilter(f)}
className={cn(
"px-2 py-1 text-[10px] font-semibold uppercase tracking-wide transition-colors",
statusFilter === f
? "bg-accent-brand text-white"
: "bg-background text-muted-foreground hover:bg-muted",
)}
>
{f === "all"
? t("proxmoxStats.filterAll")
: f === "running"
? t("proxmoxStats.running")
: t("proxmoxStats.stopped")}
</button>
))}
</div>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto thin-scrollbar">
{guests.length === 0 ? (
<div className="flex h-full items-center justify-center py-16 text-xs text-muted-foreground">
{t("proxmoxStats.noGuests")}
</div>
) : filtered.length === 0 ? (
<div className="flex h-full items-center justify-center py-16 text-xs text-muted-foreground">
{t("proxmoxStats.noGuestsMatch")}
</div>
) : (
<table className="w-full border-collapse text-xs">
<thead className="sticky top-0 z-10 bg-card">
<tr className="border-b border-border text-[10px] uppercase tracking-wider text-muted-foreground">
<th className="px-3 py-2 text-left font-semibold">
{t("proxmoxStats.name")}
</th>
<th className="px-3 py-2 text-left font-semibold">
{t("proxmoxStats.status")}
</th>
<th className="px-3 py-2 text-left font-semibold">ID</th>
<th className="px-3 py-2 text-left font-semibold">
{t("proxmoxStats.cpu")}
</th>
<th className="px-3 py-2 text-left font-semibold">
{t("proxmoxStats.mem")}
</th>
<th className="px-3 py-2 text-left font-semibold">
{t("proxmoxStats.disk")}
</th>
<th className="px-3 py-2 text-left font-semibold">
{t("proxmoxStats.uptime")}
</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{filtered.map((guest) => (
<tr
key={`${guest.type}-${guest.vmid}`}
className="transition-colors hover:bg-muted/40"
>
<td className="px-3 py-2">
<div className="flex items-center gap-2">
{guest.type === "lxc" ? (
<Box className="size-3.5 shrink-0 text-muted-foreground" />
) : (
<ServerIcon className="size-3.5 shrink-0 text-muted-foreground" />
)}
<span className="min-w-0 truncate font-semibold">
{guest.name}
</span>
<span className="shrink-0 rounded-full border border-border px-1.5 py-0.5 text-[9px] uppercase text-muted-foreground">
{guest.type === "lxc" ? "LXC" : "VM"}
</span>
</div>
</td>
<td className="px-3 py-2">
<div className="flex items-center gap-1.5">
<span
className={cn(
"size-1.5 shrink-0 rounded-full",
guest.status === "running"
? "bg-green-500"
: "bg-muted-foreground/50",
)}
/>
<span
className={cn(
"font-medium",
guest.status === "running"
? "text-foreground"
: "text-muted-foreground",
)}
>
{guest.status === "running"
? t("proxmoxStats.running")
: t("proxmoxStats.stopped")}
</span>
</div>
</td>
<td className="px-3 py-2 font-mono text-muted-foreground">
{guest.vmid}
</td>
<td className="px-3 py-2">
<UsageCell percent={guest.cpuPercent} />
</td>
<td className="px-3 py-2">
<UsageCell
percent={guest.memPercent}
usedGiB={guest.memUsedGiB}
totalGiB={guest.memTotalGiB}
/>
</td>
<td className="px-3 py-2">
<UsageCell
percent={guest.diskPercent}
usedGiB={guest.diskUsedGiB}
totalGiB={guest.diskTotalGiB}
/>
</td>
<td className="px-3 py-2 whitespace-nowrap text-muted-foreground">
{formatUptime(guest.uptimeSeconds)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
}
@@ -0,0 +1,85 @@
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { Server } from "lucide-react";
import {
Input,
SectionCard,
SettingRow,
FakeSwitch,
} from "@termix/plugin-sdk/ui";
import type { ProxmoxStatsConfig } from "../types";
interface HostProxmoxStatsForm {
enableProxmoxStats: boolean;
proxmoxStatsConfig?: ProxmoxStatsConfig | null;
}
type SetHostField = (key: string, value: unknown) => void;
export function HostProxmoxStatsTab({
form,
setField,
}: {
form: HostProxmoxStatsForm;
setField: SetHostField;
}) {
const { t } = useTranslation();
return (
<SectionCard
title={t("hosts.enableProxmoxStats")}
icon={<Server className="size-3.5" />}
>
<div className="flex flex-col gap-0 py-1">
<SettingRow
label={t("hosts.enableProxmoxStats")}
description={t("hosts.enableProxmoxStatsDesc")}
>
<FakeSwitch
checked={form.enableProxmoxStats}
onChange={(v) => setField("enableProxmoxStats", v)}
/>
</SettingRow>
{form.enableProxmoxStats && (
<>
<SettingRow
label={t("hosts.proxmoxStatsPollInterval")}
description={t("hosts.proxmoxStatsPollIntervalDesc")}
>
<Input
className="w-24 h-7 text-xs"
type="number"
min={15}
value={form.proxmoxStatsConfig?.pollInterval ?? 60}
onChange={(e) =>
setField("proxmoxStatsConfig", {
...form.proxmoxStatsConfig,
pollInterval: Math.max(
15,
Number.parseInt(e.target.value || "60", 10),
),
})
}
/>
</SettingRow>
<SettingRow
label={t("hosts.proxmoxStatsNodeOverride")}
description={t("hosts.proxmoxStatsNodeOverrideDesc")}
>
<Input
className="w-44 h-7 text-xs"
value={form.proxmoxStatsConfig?.nodeName ?? ""}
placeholder={t("hosts.proxmoxStatsNodeOverridePlaceholder")}
onChange={(e) =>
setField("proxmoxStatsConfig", {
...form.proxmoxStatsConfig,
nodeName: e.target.value || null,
})
}
/>
</SettingRow>
</>
)}
</div>
</SectionCard>
);
}
@@ -0,0 +1,147 @@
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { Cpu, MemoryStick, HardDrive, Clock, Server } from "lucide-react";
import type { ProxmoxNodeStats } from "../types";
import { cn } from "@termix/plugin-sdk/ui";
function Tile({
icon,
label,
percent,
detail,
sparkline,
}: {
icon: React.ReactNode;
label: string;
percent: number | null;
detail?: string | null;
sparkline?: number[];
}) {
const clamped = percent === null ? null : Math.min(100, Math.max(0, percent));
const barColor =
clamped === null
? "bg-muted-foreground/30"
: clamped >= 90
? "bg-red-500"
: clamped >= 75
? "bg-yellow-500"
: "bg-accent-brand";
return (
<div className="flex min-w-0 flex-1 flex-col gap-2 border border-border bg-card px-4 py-3">
<div className="flex items-center gap-2 text-muted-foreground">
{icon}
<span className="text-[10px] font-bold uppercase tracking-widest">
{label}
</span>
</div>
<div className="flex items-baseline justify-between gap-2">
<span className="text-2xl font-bold tabular-nums">
{clamped === null ? "N/A" : `${clamped.toFixed(0)}%`}
</span>
{detail && (
<span className="truncate text-[11px] text-muted-foreground">
{detail}
</span>
)}
</div>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
<div
className={cn("h-full rounded-full transition-all", barColor)}
style={{ width: `${clamped ?? 0}%` }}
/>
</div>
{sparkline && sparkline.length > 1 && <MiniSparkline data={sparkline} />}
</div>
);
}
function MiniSparkline({ data }: { data: number[] }) {
const w = 100;
const h = 20;
const max = 100;
const points = data
.map((v, i) => {
const x = (i / (data.length - 1)) * w;
const y = h - (Math.min(100, Math.max(0, v)) / max) * h;
return `${x.toFixed(1)},${y.toFixed(1)}`;
})
.join(" ");
return (
<svg viewBox={`0 0 ${w} ${h}`} className="h-5 w-full text-accent-brand/70">
<polyline
points={points}
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinejoin="round"
strokeLinecap="round"
/>
</svg>
);
}
export function NodeSummaryStrip({
node,
histories,
}: {
node: ProxmoxNodeStats | undefined;
histories: { cpu: number[]; memory: number[]; disk: number[] };
}) {
const { t } = useTranslation();
return (
<div className="flex flex-wrap gap-2">
<Tile
icon={<Cpu className="size-3.5" />}
label={t("proxmoxStats.cpuUsage")}
percent={node?.cpu.percent ?? null}
detail={
node?.cpu.cores != null
? t("proxmoxStats.cpuCores", { count: node.cpu.cores })
: undefined
}
sparkline={histories.cpu}
/>
<Tile
icon={<MemoryStick className="size-3.5" />}
label={t("proxmoxStats.memoryUsage")}
percent={node?.memory.percent ?? null}
detail={
node?.memory.usedGiB != null && node?.memory.totalGiB != null
? `${node.memory.usedGiB.toFixed(1)} / ${node.memory.totalGiB.toFixed(1)} GiB`
: undefined
}
sparkline={histories.memory}
/>
<Tile
icon={<HardDrive className="size-3.5" />}
label={t("proxmoxStats.diskUsage")}
percent={node?.disk.percent ?? null}
detail={
node?.disk.usedGiB != null && node?.disk.totalGiB != null
? `${node.disk.usedGiB.toFixed(1)} / ${node.disk.totalGiB.toFixed(1)} GiB`
: undefined
}
sparkline={histories.disk}
/>
<div className="flex min-w-0 flex-1 flex-col justify-between gap-2 border border-border bg-card px-4 py-3">
<div className="flex items-center gap-2 text-muted-foreground">
<Clock className="size-3.5" />
<span className="text-[10px] font-bold uppercase tracking-widest">
{t("proxmoxStats.uptime")}
</span>
</div>
<span className="text-2xl font-bold">
{node?.uptime.formatted ?? "N/A"}
</span>
<div className="flex items-center gap-1.5 truncate text-[11px] text-muted-foreground">
<Server className="size-3 shrink-0" />
<span className="truncate">{node?.system.hostname ?? "-"}</span>
{node?.system.pveVersion && (
<span className="ml-1 shrink-0">{node.system.pveVersion}</span>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,51 @@
import React from "react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { FullScreenAppWrapper, ConnectionScreen } from "@termix/plugin-sdk/ui";
import { ProxmoxStatsTab } from "./ProxmoxStatsTab";
interface ProxmoxStatsAppProps {
hostId?: string;
}
const ProxmoxStatsApp: React.FC<ProxmoxStatsAppProps> = ({ 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 (
<ProxmoxStatsTab
hostConfig={hostConfig}
title={hostConfig.name || `${hostConfig.username}@${hostConfig.ip}`}
isVisible={true}
isTopbarOpen={false}
embedded={true}
/>
);
}}
</FullScreenAppWrapper>
);
};
export default ProxmoxStatsApp;
@@ -0,0 +1,387 @@
/* eslint-disable react-hooks/exhaustive-deps */
import React from "react";
import {
Button,
ConnectionScreen,
ConnectionLogProvider,
useConnectionLog,
} from "@termix/plugin-sdk/ui";
import {
useTranslation,
usePluginApi,
useHosts,
} from "@termix/plugin-sdk/frontend";
import { RefreshCw, Server } from "lucide-react";
import { createProxmoxStatsApi } from "./proxmox-stats-api";
import type { ProxmoxStatsSnapshot, ProxmoxStatsConfig } from "../types";
import { NodeSummaryStrip } from "./NodeSummaryStrip";
import { GuestTable } from "./GuestTable";
import { NodeNetworkCard } from "./cards/NodeNetworkCard";
import { StoragePoolsCard } from "./cards/StoragePoolsCard";
import { ClusterHealthCard } from "./cards/ClusterHealthCard";
import { useConnectionRetry } from "@termix/plugin-sdk/frontend";
import { runAdaptivePolling } from "@termix/plugin-sdk/ui";
const HISTORY_LEN = 30;
function statsChangeKey(data: ProxmoxStatsSnapshot): string {
const bucket = (value: number | null | undefined) =>
value == null ? null : Math.round(value / 5) * 5;
return JSON.stringify({
cpu: bucket(data.node.cpu.percent),
memory: bucket(data.node.memory.percent),
disk: bucket(data.node.disk.percent),
guests: data.guests.counts,
cluster: data.cluster,
});
}
const DEFAULT_POLL_INTERVAL = 60;
interface HostConfig {
id: number;
name: string;
ip: string;
username: string;
pluginSettings?: {
proxmox?: {
enableProxmoxStats?: boolean;
proxmoxStatsConfig?: string | ProxmoxStatsConfig | null;
};
};
authType?: string;
port?: number;
[key: string]: unknown;
}
interface ProxmoxStatsProps {
hostConfig?: HostConfig;
title?: string;
isVisible?: boolean;
isTopbarOpen?: boolean;
embedded?: boolean;
}
interface ProxmoxStatsHistories {
cpu: number[];
memory: number[];
disk: number[];
}
function parseProxmoxStatsConfig(
raw?: string | ProxmoxStatsConfig | null,
): Required<Pick<ProxmoxStatsConfig, "pollInterval">> & ProxmoxStatsConfig {
const defaults = { pollInterval: DEFAULT_POLL_INTERVAL, nodeName: null };
if (!raw) return defaults;
try {
const parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
return { ...defaults, ...parsed };
} catch {
return defaults;
}
}
export function ProxmoxStatsTab(props: ProxmoxStatsProps): React.ReactElement {
return (
<ConnectionLogProvider>
<ProxmoxStatsInner {...props} />
</ConnectionLogProvider>
);
}
function ProxmoxStatsInner({
hostConfig,
title,
isVisible = true,
isTopbarOpen = true,
embedded = false,
}: ProxmoxStatsProps): React.ReactElement {
const { t } = useTranslation();
const { addLog, clearLogs } = useConnectionLog();
const pluginApi = usePluginApi();
const api = React.useMemo(
() => createProxmoxStatsApi(pluginApi),
[pluginApi],
);
const { hosts } = useHosts();
const [snapshot, setSnapshot] = React.useState<ProxmoxStatsSnapshot | null>(
null,
);
const [histories, setHistories] = React.useState<ProxmoxStatsHistories>({
cpu: [],
memory: [],
disk: [],
});
const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig);
const [isRefreshing, setIsRefreshing] = React.useState(false);
const [isPageVisible, setIsPageVisible] = React.useState(!document.hidden);
const [viewerSessionId, setViewerSessionId] = React.useState<string | null>(
null,
);
const proxmoxSettings = currentHostConfig?.pluginSettings?.proxmox;
const statsConfig = React.useMemo(
() => parseProxmoxStatsConfig(proxmoxSettings?.proxmoxStatsConfig),
[proxmoxSettings?.proxmoxStatsConfig],
);
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(() => {
api.sendHeartbeat(viewerSessionId).catch(() => {});
}, 30000);
return () => clearInterval(interval);
}, [viewerSessionId, isActuallyVisible, api]);
React.useEffect(() => {
if (hostConfig?.id !== currentHostConfig?.id) {
setSnapshot(null);
setHistories({ cpu: [], memory: [], disk: [] });
}
setCurrentHostConfig(hostConfig);
}, [hostConfig?.id]);
React.useEffect(() => {
if (!hostConfig?.id) return;
const updated = hosts.find((h) => String(h.id) === String(hostConfig.id));
if (updated) setCurrentHostConfig(updated as unknown as HostConfig);
}, [hostConfig?.id, hosts]);
const pushHistory = React.useCallback((data: ProxmoxStatsSnapshot) => {
setHistories((prev) => {
const add = (arr: number[], v: number | null | undefined) =>
[...arr, v ?? 0].slice(-HISTORY_LEN);
return {
cpu: add(prev.cpu, data.node?.cpu?.percent),
memory: add(prev.memory, data.node?.memory?.percent),
disk: add(prev.disk, data.node?.disk?.percent),
};
});
}, []);
const notEnabled = proxmoxSettings?.enableProxmoxStats !== true;
const stopPollingRef = React.useRef<(() => void) | null>(null);
const fetchSnapshot = React.useCallback(async (): Promise<void> => {
if (!currentHostConfig?.id) return;
addLog({
type: "info",
stage: "stats_connecting",
message: t("proxmoxStats.connecting"),
});
const result = await api.startPolling(currentHostConfig.id);
if (result.viewerSessionId) setViewerSessionId(result.viewerSessionId);
addLog({
type: "info",
stage: "stats_polling",
message: t("proxmoxStats.connecting"),
});
const data = await api.getStats(currentHostConfig.id);
if (!data) {
throw new Error(t("proxmoxStats.connectionFailed"));
}
setSnapshot(data);
addLog({
type: "success",
stage: "connected",
message: t("terminal.connected"),
});
const intervalMs =
(statsConfig.pollInterval ?? DEFAULT_POLL_INTERVAL) * 1000;
let signature = statsChangeKey(data);
stopPollingRef.current?.();
stopPollingRef.current = runAdaptivePolling(
async () => {
const next = await api.getStats(currentHostConfig.id);
if (!next) throw new Error(t("proxmoxStats.connectionFailed"));
const nextSignature = statsChangeKey(next);
const changed = nextSignature !== signature;
signature = nextSignature;
setSnapshot(next);
pushHistory(next);
return changed;
},
{
minIntervalMs: intervalMs,
maxIntervalMs: Math.min(120_000, intervalMs * 6),
stablePollsPerStep: 3,
},
{ runImmediately: false },
);
}, [
currentHostConfig?.id,
statsConfig.pollInterval,
addLog,
t,
pushHistory,
api,
]);
const retry = useConnectionRetry({
connect: async () => {
try {
await fetchSnapshot();
retry.markConnected();
} catch (error: unknown) {
addLog({
type: "error",
stage: "error",
message:
error instanceof Error
? error.message
: t("proxmoxStats.connectionFailed"),
});
retry.markFailed();
}
},
enabled: isPageVisible && !notEnabled && !!currentHostConfig?.id,
autoStart: false,
});
const retryRef = React.useRef(retry);
retryRef.current = retry;
// 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 (notEnabled || !currentHostConfig?.id) return;
let cancelled = false;
const debounce = setTimeout(() => {
if (isPageVisible && !cancelled) {
clearLogs();
retryRef.current.reset();
retryRef.current.retryNow();
} else if (!isPageVisible) {
stopPollingRef.current?.();
stopPollingRef.current = null;
if (currentHostConfig?.id) {
api.stopPolling(currentHostConfig.id, undefined).catch(() => {});
}
}
}, 500);
return () => {
cancelled = true;
clearTimeout(debounce);
stopPollingRef.current?.();
stopPollingRef.current = null;
if (currentHostConfig?.id) {
api.stopPolling(currentHostConfig.id).catch(() => {});
}
};
}, [currentHostConfig?.id, notEnabled, isPageVisible, api]);
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 (retry.status !== "connected") {
retry.retryNow();
return;
}
try {
setIsRefreshing(true);
const data = await api.getStats(currentHostConfig.id);
if (data) {
setSnapshot(data);
pushHistory(data);
}
} finally {
setIsRefreshing(false);
}
};
const showContent = !notEnabled && retry.status === "connected" && snapshot;
return (
<div
style={wrapperStyle}
className="relative flex flex-col overflow-hidden"
>
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
{retry.status === "connected" && !notEnabled && (
<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>
<Button
variant="outline"
size="default"
onClick={handleRefresh}
disabled={isRefreshing}
className="gap-2 font-semibold"
>
<RefreshCw
className={`size-3.5 ${isRefreshing ? "animate-spin" : ""}`}
/>
{t("proxmoxStats.refresh")}
</Button>
</div>
)}
{showContent && (
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto px-3 pb-3 pt-3">
<div className="shrink-0">
<NodeSummaryStrip node={snapshot.node} histories={histories} />
</div>
<GuestTable guests={snapshot.guests.guests} />
<div className="grid shrink-0 grid-cols-1 gap-3 pb-1 md:grid-cols-3">
<NodeNetworkCard snapshot={snapshot} />
<StoragePoolsCard snapshot={snapshot} />
{snapshot.cluster.clustered && (
<ClusterHealthCard snapshot={snapshot} />
)}
</div>
</div>
)}
<ConnectionScreen
status={notEnabled ? "connected" : retry.status}
message={t("proxmoxStats.connecting")}
attempt={retry.attempt}
maxAttempts={retry.maxAttempts}
nextRetryInMs={retry.nextRetryInMs}
onManualRetry={retry.retryNow}
emptyState={
notEnabled ? (
<div className="text-center opacity-40">
<Server className="mx-auto mb-4 size-16" />
<p className="text-xl font-bold uppercase tracking-widest">
{t("proxmoxStats.noHostSelected")}
</p>
</div>
) : undefined
}
/>
</div>
</div>
);
}
@@ -0,0 +1,67 @@
import { Network } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { MetricCard } from "@termix/plugin-sdk/ui";
import type { ProxmoxStatsSnapshot } from "../../types";
import { cn } from "@termix/plugin-sdk/ui";
export function ClusterHealthCard({
snapshot,
}: {
snapshot: ProxmoxStatsSnapshot | null;
}) {
const { t } = useTranslation();
const cluster = snapshot?.cluster;
return (
<MetricCard
title={t("proxmoxStats.clusterHealth")}
icon={<Network className="size-3.5" />}
scroll
>
{!cluster || !cluster.clustered ? (
<span className="text-xs text-muted-foreground">
{t("proxmoxStats.notClustered")}
</span>
) : (
<div className="flex flex-col gap-2 text-xs">
<div className="flex items-center gap-2">
<span
className={cn(
"size-1.5 shrink-0 rounded-full",
cluster.quorate ? "bg-green-500" : "bg-red-500",
)}
/>
<span className="font-medium">
{cluster.clusterName ?? t("proxmoxStats.clusterHealth")}
</span>
<span className="text-muted-foreground">
{cluster.quorate
? t("proxmoxStats.quorate")
: t("proxmoxStats.notQuorate")}
</span>
</div>
<div className="flex flex-col divide-y divide-border">
{cluster.nodes.map((node) => (
<div key={node.name} className="flex items-center gap-2 py-1.5">
<span
className={cn(
"size-1.5 shrink-0 rounded-full",
node.online ? "bg-green-500" : "bg-muted-foreground/50",
)}
/>
<span className="min-w-0 flex-1 truncate font-medium">
{node.name}
</span>
<span className="shrink-0 text-[10px] text-muted-foreground">
{node.online
? t("proxmoxStats.nodeOnline")
: t("proxmoxStats.nodeOffline")}
</span>
</div>
))}
</div>
</div>
)}
</MetricCard>
);
}
@@ -0,0 +1,36 @@
import { Network } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { StatRow, MetricCard } from "@termix/plugin-sdk/ui";
import type { ProxmoxStatsSnapshot } from "../../types";
export function NodeNetworkCard({
snapshot,
}: {
snapshot: ProxmoxStatsSnapshot | null;
}) {
const { t } = useTranslation();
const interfaces = snapshot?.network.interfaces ?? [];
return (
<MetricCard
title={t("proxmoxStats.networkInterfaces")}
icon={<Network className="size-3.5" />}
scroll
>
{interfaces.length === 0 ? (
<span className="text-xs text-muted-foreground">N/A</span>
) : (
<div className="flex flex-col divide-y divide-border">
{interfaces.map((iface) => (
<StatRow
key={iface.name}
label={iface.name}
value={iface.ip ?? iface.state ?? "-"}
mono
/>
))}
</div>
)}
</MetricCard>
);
}
@@ -0,0 +1,60 @@
import { Database } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { MetricCard } from "@termix/plugin-sdk/ui";
import type { ProxmoxStatsSnapshot } from "../../types";
export function StoragePoolsCard({
snapshot,
}: {
snapshot: ProxmoxStatsSnapshot | null;
}) {
const { t } = useTranslation();
const pools = snapshot?.storage.pools ?? [];
return (
<MetricCard
title={t("proxmoxStats.storagePools")}
icon={<Database className="size-3.5" />}
scroll
>
{pools.length === 0 ? (
<span className="text-xs text-muted-foreground">N/A</span>
) : (
<div className="flex flex-col divide-y divide-border">
{pools.map((pool) => (
<div key={pool.name} className="flex flex-col gap-1 py-2 text-xs">
<div className="flex items-center gap-2">
<span className="min-w-0 flex-1 truncate font-medium">
{pool.name}
</span>
<span className="shrink-0 rounded-full border border-border px-1.5 py-0.5 text-[10px] uppercase text-muted-foreground">
{pool.type}
</span>
{!pool.active && (
<span className="shrink-0 text-[10px] text-muted-foreground">
{t("proxmoxStats.inactive")}
</span>
)}
</div>
<div className="flex items-center gap-2">
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-muted">
<div
className="h-full bg-accent-brand"
style={{
width: `${Math.min(100, Math.max(0, pool.percent ?? 0))}%`,
}}
/>
</div>
<span className="shrink-0 text-[10px] tabular-nums text-muted-foreground">
{pool.usedGiB !== null && pool.totalGiB !== null
? `${pool.usedGiB.toFixed(1)} / ${pool.totalGiB.toFixed(1)} GiB`
: "N/A"}
</span>
</div>
</div>
))}
</div>
)}
</MetricCard>
);
}
@@ -0,0 +1,80 @@
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
import type { ProxmoxStatsSnapshot } from "../types";
/**
* The Proxmox node-stats routes, through the plugin's own client. Paths are
* relative to /plugin-api/proxmox/, which the client already points at.
*/
export function createProxmoxStatsApi(api: PluginApiClient) {
return {
async getStats(hostId: number): Promise<ProxmoxStatsSnapshot | null> {
try {
const response = await api.get<ProxmoxStatsSnapshot>(
`/stats/${hostId}`,
);
return response.data;
} catch (error) {
if (
typeof error === "object" &&
error &&
"response" in error &&
(error as { response?: { status?: number } }).response?.status === 404
) {
return null;
}
throw error;
}
},
async startPolling(hostId: number): Promise<{
success: boolean;
viewerSessionId?: string;
status?: string;
error?: string;
}> {
const response = await api.post<{
success: boolean;
viewerSessionId?: string;
status?: string;
error?: string;
}>(`/stats/start/${hostId}`);
return response.data;
},
async stopPolling(hostId: number, viewerSessionId?: string): Promise<void> {
await api.post(`/stats/stop/${hostId}`, { viewerSessionId });
},
async sendHeartbeat(viewerSessionId: string): Promise<void> {
await api.post("/stats/heartbeat", { viewerSessionId });
},
async getHistory(
hostId: number,
opts: { range?: string; from?: string; to?: string },
): Promise<ProxmoxStatsHistoryResponse> {
const response = await api.get<ProxmoxStatsHistoryResponse>(
`/stats/history/${hostId}`,
{ params: opts },
);
return response.data;
},
};
}
export type ProxmoxStatsApi = ReturnType<typeof createProxmoxStatsApi>;
export interface ProxmoxStatsHistoryRow {
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 ProxmoxStatsHistoryResponse {
rows: ProxmoxStatsHistoryRow[];
fromTs: string;
toTs: string;
}
+153
View File
@@ -0,0 +1,153 @@
// Frontend-side mirror of the backend Proxmox stats collectors' return
// shapes (src/backend/proxmox/*.ts). Kept in sync by hand.
export interface ProxmoxNodeStats {
cpu: {
percent: number | null;
cores: number | null;
load: [number, number, number] | null;
};
memory: {
percent: number | null;
usedGiB: number | null;
totalGiB: number | null;
};
disk: {
percent: number | null;
usedGiB: number | null;
totalGiB: number | null;
};
uptime: {
seconds: number | null;
formatted: string | null;
};
system: {
hostname: string | null;
kernel: string | null;
pveVersion: string | null;
};
}
export interface ProxmoxNodeNetwork {
interfaces: Array<{
name: string;
ip: string | null;
state: string | null;
rxBytes: string | null;
txBytes: string | null;
}>;
}
export interface ProxmoxGuestSummary {
vmid: number;
name: string;
type: "qemu" | "lxc";
status: string;
cpuPercent: number | null;
memPercent: number | null;
memUsedGiB: number | null;
memTotalGiB: number | null;
diskPercent: number | null;
diskUsedGiB: number | null;
diskTotalGiB: number | null;
uptimeSeconds: number | null;
}
export interface ProxmoxGuestsSummary {
guests: ProxmoxGuestSummary[];
counts: { running: number; stopped: number; total: number };
}
export interface ProxmoxStoragePool {
name: string;
type: string;
active: boolean;
enabled: boolean;
usedGiB: number | null;
totalGiB: number | null;
availGiB: number | null;
percent: number | null;
}
export interface ProxmoxStorage {
pools: ProxmoxStoragePool[];
}
export type ProxmoxClusterHealth =
| { clustered: false }
| {
clustered: true;
quorate: boolean;
clusterName: string | null;
nodes: Array<{
name: string;
online: boolean;
local: boolean;
ip: string | null;
}>;
};
export interface ProxmoxStatsSnapshot {
node: ProxmoxNodeStats;
network: ProxmoxNodeNetwork;
guests: ProxmoxGuestsSummary;
storage: ProxmoxStorage;
cluster: ProxmoxClusterHealth;
lastChecked: string;
}
export interface ProxmoxStatsConfig {
nodeName?: string | null;
pollInterval?: number;
enabledCards?: string[];
}
/** The proxmoxConfig host setting. */
export interface ProxmoxHostConfig {
source?: {
source: "proxmox";
sourceHostId: number;
node: string;
vmid: number;
type: "qemu" | "lxc";
lastSeenAt?: string;
lastStatus?: string;
missingSince?: string | null;
};
defaultCredentialId: number | null;
defaultAuthType?: string;
windowsPatterns: string;
dockerPatterns: string;
preferredPrefixes: string;
autoSyncEnabled?: boolean;
syncIntervalMinutes?: number;
markMissingGuests?: boolean;
lastSyncAt?: string;
lastSyncStatus?: "success" | "error";
lastSyncError?: string | null;
lastSyncResult?: {
created: number;
updated: number;
markedMissing: number;
skipped: number;
errors: string[];
};
}
export interface ProxmoxGuest {
name: string;
vmid: number;
type: "qemu" | "lxc";
node: string;
status: string;
ip: string | null;
connectionType: "ssh" | "rdp";
enableDocker: boolean;
}
export interface ProxmoxDiscoverResult {
guests: ProxmoxGuest[];
credentialId: number | null;
defaultCredentialId: number | null;
jumpHosts?: unknown[] | null;
}