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

-59
View File
@@ -1,59 +0,0 @@
import { authApi, handleApiError } from "@/main-axios";
export type AcmeChallengeType = "http-webroot" | "dns-cloudflare" | "manual";
export type AcmeSettings = {
enabled: boolean;
domain: string;
email: string;
challengeType: AcmeChallengeType;
cloudflareToken: string;
lastIssuedAt: string | null;
certStatus: "none" | "valid" | "expiring" | "expired";
certExpiresAt: string | null;
};
export async function getAcmeSslSettings(): Promise<AcmeSettings> {
try {
const response = await authApi.get("/users/acme-ssl-settings");
return response.data;
} catch (error) {
handleApiError(error, "fetch ACME SSL settings");
}
}
export async function updateAcmeSslSettings(
settings: Partial<
Omit<AcmeSettings, "certStatus" | "certExpiresAt" | "lastIssuedAt">
>,
): Promise<AcmeSettings> {
try {
const response = await authApi.patch("/users/acme-ssl-settings", settings);
return response.data;
} catch (error) {
handleApiError(error, "update ACME SSL settings");
}
}
export async function requestAcmeCertificate(): Promise<
AcmeSettings & { success: boolean; reloadMessage?: string }
> {
try {
const response = await authApi.post("/users/acme-ssl-request", {});
return response.data;
} catch (error) {
handleApiError(error, "request ACME certificate");
}
}
export async function uploadManualSslCertificate(payload: {
certificate: string;
privateKey: string;
}): Promise<AcmeSettings & { success: boolean; reloadMessage?: string }> {
try {
const response = await authApi.post("/users/manual-ssl-upload", payload);
return response.data;
} catch (error) {
handleApiError(error, "upload manual SSL certificate");
}
}
+1 -64
View File
@@ -99,14 +99,7 @@ export async function adminDeleteUserHost(
export async function adminGetHostPassword(
targetUserId: string,
hostId: number,
field:
| "password"
| "sudoPassword"
| "rdpPassword"
| "vncPassword"
| "telnetPassword"
| "key"
| "keyPassword" = "password",
field: "password" | "sudoPassword" | "key" | "keyPassword" = "password",
): Promise<string | null> {
try {
const response = await sshHostApi.get(
@@ -190,59 +183,3 @@ export async function adminDeleteUserCredential(
throw handleApiError(error, "delete user's credential");
}
}
export async function adminGetUserSnippets(
targetUserId: string,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.get("/snippets", {
headers: adminHeaders(targetUserId),
});
return response.data;
} catch (error) {
throw handleApiError(error, "fetch user's snippets");
}
}
export async function adminCreateUserSnippet(
targetUserId: string,
snippetData: Record<string, unknown>,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.post("/snippets", snippetData, {
headers: adminHeaders(targetUserId),
});
return response.data;
} catch (error) {
throw handleApiError(error, "create snippet for user");
}
}
export async function adminUpdateUserSnippet(
targetUserId: string,
snippetId: number,
snippetData: Record<string, unknown>,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.put(`/snippets/${snippetId}`, snippetData, {
headers: adminHeaders(targetUserId),
});
return response.data;
} catch (error) {
throw handleApiError(error, "update user's snippet");
}
}
export async function adminDeleteUserSnippet(
targetUserId: string,
snippetId: number,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.delete(`/snippets/${snippetId}`, {
headers: adminHeaders(targetUserId),
});
return response.data;
} catch (error) {
throw handleApiError(error, "delete user's snippet");
}
}
-300
View File
@@ -1,300 +0,0 @@
import { authApi, handleApiError } from "@/main-axios";
export type AiProviderType =
"ollama" | "anthropic" | "openai" | "gemini" | "openai_compatible";
export interface AiProvider {
id: number;
providerType: AiProviderType;
label: string;
baseUrl: string | null;
/** The first few characters only; the key itself never leaves the server. */
apiKeyPrefix: string | null;
defaultModel: string | null;
enabled: boolean;
createdAt: string;
}
export interface AiConversation {
id: number;
title: string | null;
providerId: number | null;
model: string | null;
createdAt: string;
updatedAt: string;
}
export interface AiMessage {
id: number;
conversationId: number;
role: "user" | "assistant" | "tool";
content: string;
toolCalls: string | null;
createdAt: string;
}
export interface AiProposal {
id: number;
conversationId: number;
kind: string;
summary: string | null;
payload: string;
status: "pending" | "applied" | "rejected" | "expired";
resultSummary: string | null;
createdAt: string;
}
export interface AiStatus {
globallyEnabled: boolean;
enabled: boolean;
allowReadOnlyCommands: boolean;
}
export async function getAiStatus(): Promise<AiStatus> {
try {
return (await authApi.get("/ai/status")).data;
} catch (error) {
throw handleApiError(error, "get AI status");
}
}
export async function getAiProviders(): Promise<AiProvider[]> {
try {
return (await authApi.get("/ai/providers")).data.providers;
} catch (error) {
throw handleApiError(error, "list AI providers");
}
}
export async function createAiProvider(input: {
providerType: AiProviderType;
label: string;
baseUrl?: string | null;
apiKey?: string | null;
defaultModel?: string | null;
}): Promise<AiProvider> {
try {
return (await authApi.post("/ai/providers", input)).data.provider;
} catch (error) {
throw handleApiError(error, "create AI provider");
}
}
export async function updateAiProvider(
id: number,
input: Partial<{
label: string;
baseUrl: string | null;
apiKey: string | null;
defaultModel: string | null;
enabled: boolean;
}>,
): Promise<AiProvider> {
try {
return (await authApi.patch(`/ai/providers/${id}`, input)).data.provider;
} catch (error) {
throw handleApiError(error, "update AI provider");
}
}
export async function deleteAiProvider(id: number): Promise<void> {
try {
await authApi.delete(`/ai/providers/${id}`);
} catch (error) {
throw handleApiError(error, "delete AI provider");
}
}
/**
* Model list for a provider that may not be saved yet, so the add form can
* fill its picker before anything is persisted.
*/
export async function probeAiModels(input: {
providerType: AiProviderType;
baseUrl?: string | null;
apiKey?: string | null;
providerId?: number | null;
}): Promise<{
models: string[];
source: "live" | "fallback" | "none";
warning?: string;
}> {
try {
return (await authApi.post("/ai/probe-models", input)).data;
} catch (error) {
throw handleApiError(error, "detect models");
}
}
export async function getAiProviderModels(id: number): Promise<string[]> {
try {
return (await authApi.get(`/ai/providers/${id}/models`)).data.models;
} catch (error) {
throw handleApiError(error, "list provider models");
}
}
export async function getAiConversations(): Promise<AiConversation[]> {
try {
return (await authApi.get("/ai/conversations")).data.conversations;
} catch (error) {
throw handleApiError(error, "list AI conversations");
}
}
export async function getAiConversation(id: number): Promise<{
conversation: AiConversation;
messages: AiMessage[];
proposals: AiProposal[];
}> {
try {
return (await authApi.get(`/ai/conversations/${id}`)).data;
} catch (error) {
throw handleApiError(error, "load AI conversation");
}
}
export async function deleteAiConversation(id: number): Promise<void> {
try {
await authApi.delete(`/ai/conversations/${id}`);
} catch (error) {
throw handleApiError(error, "delete AI conversation");
}
}
export async function applyAiProposal(
id: number,
): Promise<{ success: boolean; summary: string }> {
try {
return (await authApi.post(`/ai/proposals/${id}/apply`)).data;
} catch (error) {
throw handleApiError(error, "apply AI proposal");
}
}
export async function rejectAiProposal(id: number): Promise<void> {
try {
await authApi.post(`/ai/proposals/${id}/reject`);
} catch (error) {
throw handleApiError(error, "reject AI proposal");
}
}
/**
* For the terminal-docked assistant: records that a run_command proposal was
* typed into the user's open terminal instead of run over a pooled connection.
*/
export async function markAiProposalRunInTerminal(
id: number,
hostId: number,
summary?: string,
): Promise<{ success: boolean; summary: string }> {
try {
return (
await authApi.post(`/ai/proposals/${id}/mark-run-in-terminal`, {
hostId,
summary,
})
).data;
} catch (error) {
throw handleApiError(error, "mark AI proposal run in terminal");
}
}
// --- admin ---
export async function getAiGloballyEnabled(): Promise<boolean> {
try {
return (await authApi.get("/users/ai-enabled")).data.enabled;
} catch (error) {
throw handleApiError(error, "get AI enabled setting");
}
}
export async function setAiGloballyEnabled(enabled: boolean): Promise<boolean> {
try {
return (await authApi.patch("/users/ai-enabled", { enabled })).data.enabled;
} catch (error) {
throw handleApiError(error, "update AI enabled setting");
}
}
export async function getAiPrivateEndpoints(): Promise<string[]> {
try {
return (await authApi.get("/users/ai-private-endpoints")).data.hosts;
} catch (error) {
throw handleApiError(error, "get AI endpoint allowlist");
}
}
export async function setAiPrivateEndpoints(
hosts: string[],
): Promise<string[]> {
try {
return (await authApi.patch("/users/ai-private-endpoints", { hosts })).data
.hosts;
} catch (error) {
throw handleApiError(error, "update AI endpoint allowlist");
}
}
export async function getNotificationPrivateEndpoints(): Promise<string[]> {
try {
return (await authApi.get("/users/notification-private-endpoints")).data
.hosts;
} catch (error) {
throw handleApiError(error, "get notification endpoint allowlist");
}
}
export async function getStepCaPrivateEndpoints(): Promise<string[]> {
try {
return (await authApi.get("/users/step-ca-private-endpoints")).data.hosts;
} catch (error) {
throw handleApiError(error, "get Step CA endpoint allowlist");
}
}
export async function setStepCaPrivateEndpoints(
hosts: string[],
): Promise<string[]> {
try {
return (await authApi.patch("/users/step-ca-private-endpoints", { hosts }))
.data.hosts;
} catch (error) {
throw handleApiError(error, "update Step CA endpoint allowlist");
}
}
export async function getSecretSourcePrivateEndpoints(): Promise<string[]> {
try {
return (await authApi.get("/users/secret-source-private-endpoints")).data
.hosts;
} catch (error) {
throw handleApiError(error, "get secret source endpoint allowlist");
}
}
export async function setSecretSourcePrivateEndpoints(
hosts: string[],
): Promise<string[]> {
try {
return (
await authApi.patch("/users/secret-source-private-endpoints", { hosts })
).data.hosts;
} catch (error) {
throw handleApiError(error, "update secret source endpoint allowlist");
}
}
export async function setNotificationPrivateEndpoints(
hosts: string[],
): Promise<string[]> {
try {
return (
await authApi.patch("/users/notification-private-endpoints", { hosts })
).data.hosts;
} catch (error) {
throw handleApiError(error, "update notification endpoint allowlist");
}
}
-192
View File
@@ -1,192 +0,0 @@
import { rbacApi } from "@/main-axios";
export interface NotificationChannel {
id: number;
userId: string;
name: string;
type: "webhook" | "ntfy" | "discord";
config: string;
enabled: boolean;
createdAt: string;
}
export interface AlertRule {
id: number;
userId: string;
hostId: number | null;
name: string;
enabled: boolean;
triggerType: string;
thresholdValue: number | null;
thresholdDurationSeconds: number | null;
cooldownMinutes: number;
createdAt: string;
updatedAt: string;
channelIds: number[];
}
export interface AlertFiring {
id: number;
userId: string;
ruleId: number;
hostId: number;
hostName: string;
firedAt: string;
resolvedAt: string | null;
value: number | null;
message: string;
severity: "info" | "warning" | "critical";
acknowledged: boolean;
ruleName?: string;
}
function stringValue(value: unknown, fallback = ""): string {
return typeof value === "string" ? value : fallback;
}
function numberValue(value: unknown, fallback = 0): number {
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
}
function nullableNumberValue(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
function boolValue(value: unknown): boolean {
return value === true || value === 1 || value === "1";
}
function severityValue(value: unknown): AlertFiring["severity"] {
return value === "info" || value === "critical" || value === "warning"
? value
: "warning";
}
export async function getNotificationChannels(): Promise<
NotificationChannel[]
> {
const res = await rbacApi.get("/notification-channels");
return res.data;
}
export type NotificationChannelPayload = Partial<
Omit<NotificationChannel, "config">
> & {
// When creating/updating the channel the UI may pass a parsed object
// for `config` (e.g., { url, username }) — the backend stores it as
// a JSON string. Accept either a string or any structured object here.
// Use `unknown` to allow the component-local config types to be passed
// without importing them into this module.
config: string | unknown;
};
export async function createNotificationChannel(
data: NotificationChannelPayload,
): Promise<NotificationChannel> {
const res = await rbacApi.post("/notification-channels", data);
return res.data;
}
export async function updateNotificationChannel(
id: number,
data: NotificationChannelPayload,
): Promise<NotificationChannel> {
const res = await rbacApi.put(`/notification-channels/${id}`, data);
return res.data;
}
export async function deleteNotificationChannel(id: number): Promise<void> {
await rbacApi.delete(`/notification-channels/${id}`);
}
export async function testNotificationChannel(id: number): Promise<void> {
const res = await rbacApi.post(`/notification-channels/${id}/test`);
const data = res.data as { success?: boolean; error?: string };
if (data && data.success === false) {
throw new Error(data.error || "Test notification failed");
}
}
function mapRule(r: Record<string, unknown>): AlertRule {
return {
id: r.id as number,
userId: (r.user_id ?? r.userId) as string,
hostId: (r.host_id ?? r.hostId ?? null) as number | null,
name: r.name as string,
enabled: Boolean(r.enabled),
triggerType: (r.trigger_type ?? r.triggerType) as string,
thresholdValue: (r.threshold_value ?? r.thresholdValue ?? null) as
number | null,
thresholdDurationSeconds: (r.threshold_duration_seconds ??
r.thresholdDurationSeconds ??
null) as number | null,
cooldownMinutes: (r.cooldown_minutes ?? r.cooldownMinutes) as number,
createdAt: (r.created_at ?? r.createdAt) as string,
updatedAt: (r.updated_at ?? r.updatedAt) as string,
channelIds: Array.isArray(r.channels) ? (r.channels as number[]) : [],
};
}
export function mapAlertFiring(r: Record<string, unknown>): AlertFiring {
return {
id: numberValue(r.id),
userId: stringValue(r.userId ?? r.user_id),
ruleId: numberValue(r.ruleId ?? r.rule_id),
hostId: numberValue(r.hostId ?? r.host_id),
hostName: stringValue(r.hostName ?? r.host_name),
firedAt: stringValue(r.firedAt ?? r.fired_at, new Date(0).toISOString()),
resolvedAt: stringValue(r.resolvedAt ?? r.resolved_at) || null,
value: nullableNumberValue(r.value),
message: stringValue(r.message),
severity: severityValue(r.severity),
acknowledged: boolValue(r.acknowledged),
ruleName: stringValue(r.ruleName ?? r.rule_name) || undefined,
};
}
export async function getAlertRules(): Promise<AlertRule[]> {
const res = await rbacApi.get("/alert-rules");
return (res.data as Record<string, unknown>[]).map(mapRule);
}
export async function createAlertRule(
data: Partial<AlertRule> & { channels?: number[] },
): Promise<AlertRule> {
const res = await rbacApi.post("/alert-rules", data);
return mapRule(res.data as Record<string, unknown>);
}
export async function updateAlertRule(
id: number,
data: Partial<AlertRule> & { channels?: number[] },
): Promise<AlertRule> {
const res = await rbacApi.put(`/alert-rules/${id}`, data);
return mapRule(res.data as Record<string, unknown>);
}
export async function deleteAlertRule(id: number): Promise<void> {
await rbacApi.delete(`/alert-rules/${id}`);
}
export async function getAlertFirings(opts?: {
limit?: number;
offset?: number;
acknowledged?: boolean;
}): Promise<AlertFiring[]> {
const res = await rbacApi.get("/alert-firings", { params: opts });
const data = res.data as { firings?: unknown } | unknown[];
const rows = Array.isArray(data)
? data
: Array.isArray(data.firings)
? data.firings
: [];
return rows.map((row) => mapAlertFiring(row as Record<string, unknown>));
}
export async function acknowledgeAlertFiring(id: number): Promise<void> {
await rbacApi.post(`/alert-firings/${id}/acknowledge`);
}
export async function acknowledgeAllAlertFirings(): Promise<void> {
await rbacApi.post("/alert-firings/acknowledge-all");
}
+171
View File
@@ -0,0 +1,171 @@
import {
authApi,
handleApiError,
markUserAuthenticated,
type AuthResponse,
} from "@/main-axios";
export interface PublicLoginMethod {
id: string;
pluginId: string;
kind: "redirect" | "form";
labelKey: string;
icon?: string;
instances: Array<{ id: string; label: string; autoStart?: boolean }>;
}
export interface SecondFactorRef {
id: string;
pluginId: string;
labelKey: string;
}
export type LoginResponse = AuthResponse & {
requires_second_factor?: boolean;
second_factors?: SecondFactorRef[];
userId?: string;
};
function remember(data: LoginResponse): LoginResponse {
if (data?.token) localStorage.setItem("jwt", data.token);
if (data?.success && !data.requires_totp) markUserAuthenticated();
return data;
}
/** Enabled login methods for the login screen. Public. */
export async function getLoginMethods(): Promise<PublicLoginMethod[]> {
try {
const response = await authApi.get("/users/auth/methods");
return Array.isArray(response.data?.methods) ? response.data.methods : [];
} catch {
return [];
}
}
export async function submitLoginMethod(
methodId: string,
body: Record<string, unknown>,
instanceId?: string,
): Promise<LoginResponse> {
try {
const response = await authApi.post(
`/users/auth/${encodeURIComponent(methodId)}/verify`,
{ ...body, instanceId },
);
return remember(response.data);
} catch (error) {
throw handleApiError(error, "login");
}
}
export async function startLoginRedirect(
methodId: string,
params: {
instanceId?: string;
rememberMe?: boolean;
desktopCallbackPort?: number;
},
): Promise<string> {
try {
const response = await authApi.get(
`/users/auth/${encodeURIComponent(methodId)}/start`,
{
params: {
instance: params.instanceId,
rememberMe: params.rememberMe,
desktopCallbackPort: params.desktopCallbackPort,
},
},
);
return response.data?.redirectUrl as string;
} catch (error) {
throw handleApiError(error, "start login");
}
}
/**
* Answers a second factor. With no temp token the server reads the pending
* login cookie a redirect method left.
*/
export async function verifySecondFactor(
factorId: string,
body: Record<string, unknown>,
): Promise<LoginResponse> {
try {
const response = await authApi.post(
`/users/auth/second-factor/${encodeURIComponent(factorId)}/verify`,
body,
);
return remember(response.data);
} catch (error) {
throw handleApiError(error, "verify second factor");
}
}
export async function challengeSecondFactor(
factorId: string,
tempToken?: string,
): Promise<unknown> {
const response = await authApi.post(
`/users/auth/second-factor/${encodeURIComponent(factorId)}/challenge`,
{ temp_token: tempToken },
);
return response.data?.challenge ?? null;
}
export interface UserSecondFactorSummary {
pluginId: string;
factorId: string;
labelKey: string | null;
available: boolean;
}
export async function getUserSecondFactors(
userId: string,
): Promise<UserSecondFactorSummary[]> {
try {
const response = await authApi.get(
`/users/admin/${encodeURIComponent(userId)}/second-factors`,
);
return response.data?.factors ?? [];
} catch (error) {
handleApiError(error, "list second factors");
return [];
}
}
export async function resetUserSecondFactors(userId: string): Promise<void> {
try {
await authApi.delete(
`/users/admin/${encodeURIComponent(userId)}/second-factors`,
);
} catch (error) {
throw handleApiError(error, "reset second factors");
}
}
export interface SshAuthProviderSummary {
type: string;
labelKey: string;
descriptionKey?: string;
pluginId: string;
fields: Array<Record<string, unknown>>;
credentialType: boolean;
needsUserInteraction: boolean;
supportsBackground: boolean;
/** Offered in Quick Connect, for a host that is never saved. */
quickConnect?: boolean;
available: boolean;
missingPlugin?: { id: string; name: string };
}
export async function getSshAuthProviders(): Promise<SshAuthProviderSummary[]> {
try {
const response = await authApi.get("/ssh-auth/providers");
return Array.isArray(response.data?.providers)
? response.data.providers
: [];
} catch {
return [];
}
}
-143
View File
@@ -1,143 +0,0 @@
import { authApi, handleApiError } from "@/main-axios";
import type {
AutomationDefinition,
ConcurrencyPolicy,
RunStatus,
StepStatus,
StepType,
TriggerKind,
} from "@/types/automations";
export interface AutomationRow {
id: number;
user_id: string;
name: string;
description: string | null;
enabled: number;
definition: AutomationDefinition | null;
definition_version: number;
concurrency_policy: ConcurrencyPolicy;
max_run_seconds: number;
dry_run: number;
last_run_at: string | null;
last_run_status: RunStatus | null;
created_at: string;
updated_at: string;
channels: number[];
/** Returned once, only when a webhook automation is created. */
webhookToken?: string;
}
export interface AutomationRunRow {
id: number;
automation_id: number;
user_id: string;
trigger_type: TriggerKind | "manual";
trigger_context: string | null;
status: RunStatus;
started_at: string;
finished_at: string | null;
duration_ms: number | null;
error: string | null;
dry_run: number;
parent_run_id: number | null;
automation_name?: string | null;
}
export interface AutomationRunStepRow {
id: number;
run_id: number;
step_index: number;
step_id: string;
step_type: StepType;
status: StepStatus;
started_at: string;
finished_at: string | null;
output: string | null;
error: string | null;
truncated: number;
}
export interface AutomationRunOutcome {
runId: number | null;
status: RunStatus;
error?: string;
}
export interface AutomationInput {
name: string;
description?: string | null;
enabled?: boolean;
definition: AutomationDefinition;
concurrencyPolicy?: ConcurrencyPolicy;
channels?: number[];
}
export async function listAutomations(): Promise<AutomationRow[]> {
try {
return (await authApi.get("/automations")).data;
} catch (error) {
throw handleApiError(error, "fetch automations");
}
}
export async function createAutomation(
input: AutomationInput,
): Promise<AutomationRow> {
try {
return (await authApi.post("/automations", input)).data;
} catch (error) {
throw handleApiError(error, "create automation");
}
}
export async function updateAutomation(
id: number,
input: Partial<AutomationInput>,
): Promise<AutomationRow> {
try {
return (await authApi.put(`/automations/${id}`, input)).data;
} catch (error) {
throw handleApiError(error, "update automation");
}
}
export async function deleteAutomation(id: number): Promise<void> {
try {
await authApi.delete(`/automations/${id}`);
} catch (error) {
throw handleApiError(error, "delete automation");
}
}
export async function runAutomation(
id: number,
options: { dryRun?: boolean } = {},
): Promise<AutomationRunOutcome> {
try {
return (await authApi.post(`/automations/${id}/run`, options)).data;
} catch (error) {
throw handleApiError(error, "run automation");
}
}
export async function listAutomationRuns(
options: { automationId?: number; limit?: number; offset?: number } = {},
): Promise<AutomationRunRow[]> {
try {
return (await authApi.get("/automations/runs/history", { params: options }))
.data;
} catch (error) {
throw handleApiError(error, "fetch automation runs");
}
}
export async function listAutomationRunSteps(
runId: number,
): Promise<AutomationRunStepRow[]> {
try {
return (await authApi.get(`/automations/runs/${runId}/steps`)).data;
} catch (error) {
throw handleApiError(error, "fetch run steps");
}
}
-238
View File
@@ -1,238 +0,0 @@
import axios from "axios";
import { authApi, handleApiError } from "@/main-axios";
import { resolveApiBaseUrl } from "@/api/session-sharing-api";
export interface CollabRoom {
id: string;
name: string;
ownerUserId: string;
persistent: boolean;
presenterUserId: string | null;
stageProtocol: string | null;
stageHostId: number | null;
stageShareId: string | null;
guestLinkEnabled: boolean;
createdAt: string;
endedAt: string | null;
}
export interface CollabRoomMember {
userId: string;
username: string;
roomRole: string;
createdAt: string;
}
export interface CollabOnlineUser {
userId: string;
username: string;
}
export interface CollabStage {
presenterUserId: string | null;
protocol: "ssh" | "rdp" | "vnc" | "telnet" | null;
hostId: number | null;
shareId: string | null;
sessionId?: string;
controllerUserId?: string | null;
connectParams?: { token: string };
}
export interface CollabRoomDetail {
room: CollabRoom;
me: string;
isHost: boolean;
members: CollabRoomMember[];
online: CollabOnlineUser[];
stage: CollabStage;
controllerUserId: string | null;
controlRequests: CollabControlRequest[];
}
export interface CollabControlRequest {
userId: string;
username: string;
requestedAt: string;
}
export async function listCollabRooms(): Promise<{ rooms: CollabRoom[] }> {
try {
const response = await authApi.get("/collab/rooms");
return response.data;
} catch (error) {
throw handleApiError(error, "list collab rooms");
}
}
export async function createCollabRoom(
name: string,
persistent: boolean,
): Promise<{ room: CollabRoom }> {
try {
const response = await authApi.post("/collab/rooms", { name, persistent });
return response.data;
} catch (error) {
throw handleApiError(error, "create collab room");
}
}
export async function getCollabRoom(roomId: string): Promise<CollabRoomDetail> {
try {
const response = await authApi.get(`/collab/rooms/${roomId}`);
return response.data;
} catch (error) {
throw handleApiError(error, "get collab room");
}
}
export async function inviteCollabMembers(
roomId: string,
targets: { userIds?: string[]; roleIds?: number[] },
): Promise<void> {
try {
await authApi.post(`/collab/rooms/${roomId}/members`, targets);
} catch (error) {
throw handleApiError(error, "invite collab members");
}
}
export async function removeCollabMember(
roomId: string,
userId: string,
): Promise<void> {
try {
await authApi.delete(`/collab/rooms/${roomId}/members/${userId}`);
} catch (error) {
throw handleApiError(error, "remove collab member");
}
}
export async function presentCollabStage(
roomId: string,
input: { protocol: string; sessionId: string; hostId: number },
): Promise<{ stage: CollabStage }> {
try {
const response = await authApi.post(
`/collab/rooms/${roomId}/present`,
input,
);
return response.data;
} catch (error) {
throw handleApiError(error, "start presenting");
}
}
export async function stopCollabStage(roomId: string): Promise<void> {
try {
await authApi.post(`/collab/rooms/${roomId}/stop`);
} catch (error) {
throw handleApiError(error, "stop presenting");
}
}
export async function getCollabStage(
roomId: string,
): Promise<{ stage: CollabStage | null }> {
try {
const response = await authApi.get(`/collab/rooms/${roomId}/stage`);
return response.data;
} catch (error) {
throw handleApiError(error, "resolve collab stage");
}
}
export async function setCollabStageControl(
roomId: string,
userId: string | null,
): Promise<void> {
try {
await authApi.post(`/collab/rooms/${roomId}/control`, { userId });
} catch (error) {
throw handleApiError(error, "change stage control");
}
}
export async function requestCollabStageControl(
roomId: string,
): Promise<{ request: CollabControlRequest }> {
try {
const response = await authApi.post(
`/collab/rooms/${roomId}/control/request`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "request stage control");
}
}
export async function listCollabControlRequests(
roomId: string,
): Promise<{ requests: CollabControlRequest[] }> {
try {
const response = await authApi.get(
`/collab/rooms/${roomId}/control/requests`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "list control requests");
}
}
export async function dismissCollabControlRequest(
roomId: string,
userId: string,
): Promise<void> {
try {
await authApi.delete(`/collab/rooms/${roomId}/control/requests/${userId}`);
} catch (error) {
throw handleApiError(error, "dismiss control request");
}
}
export async function endCollabRoom(roomId: string): Promise<void> {
try {
await authApi.post(`/collab/rooms/${roomId}/end`);
} catch (error) {
throw handleApiError(error, "end collab room");
}
}
export async function deleteCollabRoom(roomId: string): Promise<void> {
try {
await authApi.delete(`/collab/rooms/${roomId}`);
} catch (error) {
throw handleApiError(error, "delete collab room");
}
}
export async function setCollabGuestLink(
roomId: string,
enabled: boolean,
): Promise<{ guestLinkToken: string | null }> {
try {
const response = await authApi.post(`/collab/rooms/${roomId}/guest-link`, {
enabled,
});
return response.data;
} catch (error) {
throw handleApiError(error, "update guest link");
}
}
export interface CollabGuestStage {
protocol: "ssh" | "rdp" | "vnc" | "telnet";
shareId: string;
wsPath?: string;
connectParams?: { token: string };
}
/** Anonymous: guests poll this to follow the presenter. Throws on 404/429. */
export async function resolveCollabGuestStage(
token: string,
): Promise<{ roomName: string; stage: CollabGuestStage | null }> {
const baseUrl = await resolveApiBaseUrl();
const response = await axios.get(
`${baseUrl}/collab/guest/${encodeURIComponent(token)}`,
);
return response.data;
}
-63
View File
@@ -1,63 +0,0 @@
import { authApi, handleApiError } from "@/main-axios";
// COMMAND HISTORY API
// ============================================================================
export async function saveCommandToHistory(
hostId: number,
command: string,
): Promise<{ id: number; command: string; executedAt: string }> {
try {
const response = await authApi.post("/terminal/command_history", {
hostId,
command,
});
return response.data;
} catch (error) {
throw handleApiError(error, "save command to history");
}
}
export async function getCommandHistory(
hostId: number,
limit: number = 100,
): Promise<string[]> {
try {
const response = await authApi.get(`/terminal/command_history/${hostId}`, {
params: { limit },
});
return response.data;
} catch (error) {
throw handleApiError(error, "fetch command history");
}
}
export async function deleteCommandFromHistory(
hostId: number,
command: string,
): Promise<{ success: boolean }> {
try {
const response = await authApi.post("/terminal/command_history/delete", {
hostId,
command,
});
return response.data;
} catch (error) {
throw handleApiError(error, "delete command from history");
}
}
export async function clearCommandHistory(
hostId: number,
): Promise<{ success: boolean }> {
try {
const response = await authApi.delete(
`/terminal/command_history/${hostId}`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "clear command history");
}
}
// ============================================================================
+3 -8
View File
@@ -116,14 +116,7 @@ export async function getSSHHostWithCredentials(
export async function getHostPassword(
hostId: number,
field:
| "password"
| "sudoPassword"
| "rdpPassword"
| "vncPassword"
| "telnetPassword"
| "key"
| "keyPassword" = "password",
field: "password" | "sudoPassword" | "key" | "keyPassword" = "password",
): Promise<string | null> {
try {
const response = await sshHostApi.get(
@@ -232,6 +225,7 @@ export async function updateFolderMetadata(
color?: string,
icon?: string,
credentialId?: number | null,
localOnly?: boolean,
): Promise<void> {
try {
sshLogger.info("Updating folder metadata", {
@@ -247,6 +241,7 @@ export async function updateFolderMetadata(
color,
icon,
credentialId,
localOnly,
});
invalidateSSHFoldersCache();
+8 -86
View File
@@ -1,5 +1,4 @@
import { dashboardApi, handleApiError } from "@/main-axios";
import { normalizeServiceLinkUrl } from "@/lib/service-link-url";
import { authApi, handleApiError } from "@/main-axios";
// DASHBOARD API
// ============================================================================
@@ -13,15 +12,8 @@ export interface UptimeInfo {
export interface RecentActivityItem {
id: number;
userId: string;
type:
| "terminal"
| "file_manager"
| "server_stats"
| "tunnel"
| "docker"
| "telnet"
| "vnc"
| "rdp";
/** The activity type; each plugin records its own. */
type: string;
hostId: number;
hostName: string;
timestamp: string;
@@ -29,7 +21,7 @@ export interface RecentActivityItem {
export async function getUptime(): Promise<UptimeInfo> {
try {
const response = await dashboardApi.get("/uptime");
const response = await authApi.get("/dashboard/uptime");
return response.data;
} catch (error) {
throw handleApiError(error, "fetch uptime");
@@ -40,7 +32,7 @@ export async function getRecentActivity(
limit?: number,
): Promise<RecentActivityItem[]> {
try {
const response = await dashboardApi.get("/activity/recent", {
const response = await authApi.get("/dashboard/activity/recent", {
params: { limit },
});
return response.data;
@@ -50,20 +42,12 @@ export async function getRecentActivity(
}
export async function logActivity(
type:
| "terminal"
| "file_manager"
| "server_stats"
| "tunnel"
| "docker"
| "rdp"
| "vnc"
| "telnet",
type: string,
hostId: number,
hostName: string,
): Promise<{ message: string; id: number | string }> {
try {
const response = await dashboardApi.post("/activity/log", {
const response = await authApi.post("/dashboard/activity/log", {
type,
hostId,
hostName,
@@ -76,71 +60,9 @@ export async function logActivity(
export async function resetRecentActivity(): Promise<{ message: string }> {
try {
const response = await dashboardApi.delete("/activity/reset");
const response = await authApi.delete("/dashboard/activity/reset");
return response.data;
} catch (error) {
throw handleApiError(error, "reset recent activity");
}
}
export interface ServiceLink {
id: number;
userId: string;
label: string;
url: string;
order: number;
createdAt: string;
}
export async function getServiceLinks(): Promise<ServiceLink[]> {
try {
const response = await dashboardApi.get("/service-links");
return response.data;
} catch (error) {
throw handleApiError(error, "fetch service links");
}
}
export async function createServiceLink(
label: string,
url: string,
): Promise<ServiceLink> {
try {
const response = await dashboardApi.post("/service-links", {
label,
url: normalizeServiceLinkUrl(url),
});
return response.data;
} catch (error) {
throw handleApiError(error, "create service link");
}
}
export async function deleteServiceLink(
id: number,
): Promise<{ message: string }> {
try {
const response = await dashboardApi.delete(`/service-links/${id}`);
return response.data;
} catch (error) {
throw handleApiError(error, "delete service link");
}
}
export async function updateServiceLink(
id: number,
updates: { label?: string; url?: string },
): Promise<ServiceLink> {
try {
const response = await dashboardApi.put(`/service-links/${id}`, {
...updates,
url:
updates.url !== undefined
? normalizeServiceLinkUrl(updates.url)
: undefined,
});
return response.data;
} catch (error) {
throw handleApiError(error, "update service link");
}
}
-345
View File
@@ -1,345 +0,0 @@
import axios from "axios";
import { dockerApi, handleApiError } from "@/main-axios";
import type {
DockerContainer,
DockerLogOptions,
DockerStats,
DockerValidation,
} from "@/types/index";
type ApiConnectionLog = {
type: "info" | "success" | "warning" | "error";
stage: string;
message: string;
details?: Record<string, unknown>;
};
type ConnectErrorResponse = {
error?: string;
message?: string;
connectionLogs?: ApiConnectionLog[];
requires_totp?: boolean;
requires_warpgate?: boolean;
sessionId?: string;
prompt?: string;
url?: string;
securityKey?: string;
status?: string;
reason?: string;
};
export async function connectDockerSession(
sessionId: string,
hostId: number,
config?: {
userProvidedPassword?: string;
userProvidedSshKey?: string;
userProvidedKeyPassword?: string;
forceKeyboardInteractive?: boolean;
useSocks5?: boolean;
socks5Host?: string;
socks5Port?: number;
socks5Username?: string;
socks5Password?: string;
socks5ProxyChain?: unknown;
},
): Promise<{
success?: boolean;
message?: string;
requires_totp?: boolean;
prompt?: string;
isPassword?: boolean;
status?: string;
reason?: string;
connectionLogs?: ApiConnectionLog[];
requires_warpgate?: boolean;
url?: string;
securityKey?: string;
}> {
try {
const response = await dockerApi.post("/ssh/connect", {
sessionId,
hostId,
...config,
});
return response.data;
} catch (error: unknown) {
if (
axios.isAxiosError<ConnectErrorResponse>(error) &&
error.response?.data?.status === "auth_required"
) {
return error.response.data;
}
if (
axios.isAxiosError<ConnectErrorResponse>(error) &&
error.response?.data?.requires_totp
) {
return error.response.data;
}
if (
axios.isAxiosError<ConnectErrorResponse>(error) &&
error.response?.data?.requires_warpgate
) {
return error.response.data;
}
if (
axios.isAxiosError<ConnectErrorResponse>(error) &&
error.response?.data?.connectionLogs
) {
const data = error.response.data;
const errorWithLogs = new Error(
data.error || data.message || error.message,
);
Object.assign(errorWithLogs, {
connectionLogs: data.connectionLogs,
});
throw errorWithLogs;
}
throw handleApiError(error, "connect to Docker SSH session");
}
}
export async function verifyDockerTOTP(
sessionId: string,
totpCode: string,
): Promise<{ status: string; message: string }> {
try {
const response = await dockerApi.post("/ssh/connect-totp", {
sessionId,
totpCode,
});
return response.data;
} catch (error) {
throw handleApiError(error, "verify Docker TOTP");
}
}
export async function verifyDockerWarpgate(
sessionId: string,
): Promise<{ status: string; message: string }> {
try {
const response = await dockerApi.post("/ssh/connect-warpgate", {
sessionId,
});
return response.data;
} catch (error) {
throw handleApiError(error, "verify Docker Warpgate");
}
}
export async function disconnectDockerSession(
sessionId: string,
): Promise<{ success: boolean; message: string }> {
try {
const response = await dockerApi.post("/ssh/disconnect", {
sessionId,
});
return response.data;
} catch (error) {
throw handleApiError(error, "disconnect from Docker SSH session");
}
}
export async function keepaliveDockerSession(
sessionId: string,
): Promise<{ success: boolean }> {
try {
const response = await dockerApi.post("/ssh/keepalive", {
sessionId,
});
return response.data;
} catch (error) {
throw handleApiError(error, "keepalive Docker SSH session");
}
}
export async function getDockerSessionStatus(
sessionId: string,
): Promise<{ success: boolean; connected: boolean }> {
try {
const response = await dockerApi.get("/ssh/status", {
params: { sessionId },
});
return response.data;
} catch (error) {
throw handleApiError(error, "get Docker session status");
}
}
export async function validateDockerAvailability(
sessionId: string,
): Promise<DockerValidation> {
try {
const response = await dockerApi.get(`/validate/${sessionId}`);
return response.data;
} catch (error) {
throw handleApiError(error, "validate Docker availability");
}
}
export async function listDockerContainers(
sessionId: string,
all: boolean = true,
): Promise<DockerContainer[]> {
try {
const response = await dockerApi.get(`/containers/${sessionId}`, {
params: { all },
});
return response.data;
} catch (error) {
throw handleApiError(error, "list Docker containers");
}
}
export async function getDockerContainerDetails(
sessionId: string,
containerId: string,
): Promise<DockerContainer> {
try {
const response = await dockerApi.get(
`/containers/${sessionId}/${containerId}`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "get Docker container details");
}
}
export async function startDockerContainer(
sessionId: string,
containerId: string,
): Promise<{ success: boolean; message: string }> {
try {
const response = await dockerApi.post(
`/containers/${sessionId}/${containerId}/start`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "start Docker container");
}
}
export async function stopDockerContainer(
sessionId: string,
containerId: string,
): Promise<{ success: boolean; message: string }> {
try {
const response = await dockerApi.post(
`/containers/${sessionId}/${containerId}/stop`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "stop Docker container");
}
}
export async function restartDockerContainer(
sessionId: string,
containerId: string,
): Promise<{ success: boolean; message: string }> {
try {
const response = await dockerApi.post(
`/containers/${sessionId}/${containerId}/restart`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "restart Docker container");
}
}
export async function pauseDockerContainer(
sessionId: string,
containerId: string,
): Promise<{ success: boolean; message: string }> {
try {
const response = await dockerApi.post(
`/containers/${sessionId}/${containerId}/pause`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "pause Docker container");
}
}
export async function unpauseDockerContainer(
sessionId: string,
containerId: string,
): Promise<{ success: boolean; message: string }> {
try {
const response = await dockerApi.post(
`/containers/${sessionId}/${containerId}/unpause`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "unpause Docker container");
}
}
export async function removeDockerContainer(
sessionId: string,
containerId: string,
force: boolean = false,
): Promise<{ success: boolean; message: string }> {
try {
const response = await dockerApi.delete(
`/containers/${sessionId}/${containerId}/remove`,
{
params: { force },
},
);
return response.data;
} catch (error) {
throw handleApiError(error, "remove Docker container");
}
}
export async function getContainerLogs(
sessionId: string,
containerId: string,
options?: DockerLogOptions,
): Promise<{ logs: string }> {
try {
const response = await dockerApi.get(
`/containers/${sessionId}/${containerId}/logs`,
{
params: options,
},
);
return response.data;
} catch (error) {
throw handleApiError(error, "get container logs");
}
}
export async function downloadContainerLogs(
sessionId: string,
containerId: string,
options?: DockerLogOptions,
): Promise<Blob> {
try {
const response = await dockerApi.get(
`/containers/${sessionId}/${containerId}/logs`,
{
params: { ...options, download: true },
responseType: "blob",
},
);
return response.data;
} catch (error) {
throw handleApiError(error, "download container logs");
}
}
export async function getContainerStats(
sessionId: string,
containerId: string,
): Promise<DockerStats> {
try {
const response = await dockerApi.get(
`/containers/${sessionId}/${containerId}/stats`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "get container stats");
}
}
+36
View File
@@ -0,0 +1,36 @@
import { authApi, handleApiError } from "@/main-axios";
// EXTERNAL ACCOUNT LINKING
// ============================================================================
export async function linkExternalToPasswordAccount(
externalUserId: string,
targetUsername: string,
): Promise<{ success: boolean; message: string }> {
try {
const response = await authApi.post("/users/link-external-to-password", {
externalUserId,
targetUsername,
});
return response.data;
} catch (error) {
throw handleApiError(error, "link external account to password account");
}
}
export async function unlinkExternalFromPasswordAccount(
userId: string,
): Promise<{ success: boolean; message: string }> {
try {
const response = await authApi.post(
"/users/unlink-external-from-password",
{ userId },
);
return response.data;
} catch (error) {
throw handleApiError(
error,
"unlink external sign-in from password account",
);
}
}
-156
View File
@@ -1,156 +0,0 @@
import { authApi, handleApiError } from "@/main-axios";
/** Row shape shared by the recent / pinned / shortcut list endpoints. */
export interface FileManagerEntry {
id: number;
name: string;
path: string;
lastOpened?: string;
[key: string]: unknown;
}
// FILE MANAGER DATA
// ============================================================================
export async function getRecentFiles(
hostId: number,
): Promise<FileManagerEntry[]> {
try {
const response = await authApi.get("/host/file_manager/recent", {
params: { hostId },
});
return response.data;
} catch (error) {
handleApiError(error, "get recent files");
throw error;
}
}
export async function addRecentFile(
hostId: number,
path: string,
name?: string,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.post("/host/file_manager/recent", {
hostId,
path,
name,
});
return response.data;
} catch (error) {
handleApiError(error, "add recent file");
throw error;
}
}
export async function removeRecentFile(
hostId: number,
path: string,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.delete("/host/file_manager/recent", {
data: { hostId, path },
});
return response.data;
} catch (error) {
handleApiError(error, "remove recent file");
throw error;
}
}
export async function getPinnedFiles(
hostId: number,
): Promise<FileManagerEntry[]> {
try {
const response = await authApi.get("/host/file_manager/pinned", {
params: { hostId },
});
return response.data;
} catch (error) {
handleApiError(error, "get pinned files");
throw error;
}
}
export async function addPinnedFile(
hostId: number,
path: string,
name?: string,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.post("/host/file_manager/pinned", {
hostId,
path,
name,
});
return response.data;
} catch (error) {
handleApiError(error, "add pinned file");
throw error;
}
}
export async function removePinnedFile(
hostId: number,
path: string,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.delete("/host/file_manager/pinned", {
data: { hostId, path },
});
return response.data;
} catch (error) {
handleApiError(error, "remove pinned file");
throw error;
}
}
export async function getFolderShortcuts(
hostId: number,
): Promise<FileManagerEntry[]> {
try {
const response = await authApi.get("/host/file_manager/shortcuts", {
params: { hostId },
});
return response.data;
} catch (error) {
handleApiError(error, "get folder shortcuts");
throw error;
}
}
export async function addFolderShortcut(
hostId: number,
path: string,
name?: string,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.post("/host/file_manager/shortcuts", {
hostId,
path,
name,
});
return response.data;
} catch (error) {
handleApiError(error, "add folder shortcut");
throw error;
}
}
export async function removeFolderShortcut(
hostId: number,
path: string,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.delete("/host/file_manager/shortcuts", {
data: { hostId, path },
});
return response.data;
} catch (error) {
handleApiError(error, "remove folder shortcut");
throw error;
}
}
// ============================================================================
-126
View File
@@ -1,126 +0,0 @@
import { handleApiError, sshHostApi } from "@/main-axios";
import type { FileManagerFile, FileManagerShortcut } from "@/types/index";
interface FileManagerOperation {
name: string;
path: string;
isSSH: boolean;
sshSessionId?: string;
hostId: number;
}
// FILE MANAGER METADATA (Recent, Pinned, Shortcuts)
// ============================================================================
export async function getFileManagerRecent(
hostId: number,
): Promise<FileManagerFile[]> {
try {
const response = await sshHostApi.get(
`/file_manager/recent?hostId=${hostId}`,
);
return response.data || [];
} catch {
return [];
}
}
export async function addFileManagerRecent(
file: FileManagerOperation,
): Promise<Record<string, unknown>> {
try {
const response = await sshHostApi.post("/file_manager/recent", file);
return response.data;
} catch (error) {
handleApiError(error, "add recent file");
}
}
export async function removeFileManagerRecent(
file: FileManagerOperation,
): Promise<Record<string, unknown>> {
try {
const response = await sshHostApi.delete("/file_manager/recent", {
data: file,
});
return response.data;
} catch (error) {
handleApiError(error, "remove recent file");
}
}
export async function getFileManagerPinned(
hostId: number,
): Promise<FileManagerFile[]> {
try {
const response = await sshHostApi.get(
`/file_manager/pinned?hostId=${hostId}`,
);
return response.data || [];
} catch {
return [];
}
}
export async function addFileManagerPinned(
file: FileManagerOperation,
): Promise<Record<string, unknown>> {
try {
const response = await sshHostApi.post("/file_manager/pinned", file);
return response.data;
} catch (error) {
handleApiError(error, "add pinned file");
}
}
export async function removeFileManagerPinned(
file: FileManagerOperation,
): Promise<Record<string, unknown>> {
try {
const response = await sshHostApi.delete("/file_manager/pinned", {
data: file,
});
return response.data;
} catch (error) {
handleApiError(error, "remove pinned file");
}
}
export async function getFileManagerShortcuts(
hostId: number,
): Promise<FileManagerShortcut[]> {
try {
const response = await sshHostApi.get(
`/file_manager/shortcuts?hostId=${hostId}`,
);
return response.data || [];
} catch {
return [];
}
}
export async function addFileManagerShortcut(
shortcut: FileManagerOperation,
): Promise<Record<string, unknown>> {
try {
const response = await sshHostApi.post("/file_manager/shortcuts", shortcut);
return response.data;
} catch (error) {
handleApiError(error, "add shortcut");
}
}
export async function removeFileManagerShortcut(
shortcut: FileManagerOperation,
): Promise<Record<string, unknown>> {
try {
const response = await sshHostApi.delete("/file_manager/shortcuts", {
data: shortcut,
});
return response.data;
} catch (error) {
handleApiError(error, "remove shortcut");
}
}
// ============================================================================
-247
View File
@@ -1,247 +0,0 @@
import { authApi, handleApiError } from "@/main-axios";
export interface FleetRow {
id: number;
userId: string;
name: string;
description: string | null;
color: string | null;
icon: string | null;
tagRules: string[];
syncId: string | null;
createdAt: string;
updatedAt: string;
memberCount: number;
}
export interface FleetMemberRow {
id: number;
name: string;
ip: string;
tags: string[];
static: boolean;
permissionLevel: "connect" | "view" | "edit" | "manage" | null;
}
export interface FleetHostResult {
hostId: number;
hostName: string;
success: boolean;
output?: string;
error?: string;
}
export interface FleetInventoryRecord {
id: number;
hostId: number;
userId: string;
osPrettyName: string | null;
kernel: string | null;
architecture: string | null;
hostname: string | null;
uptimeSeconds: number | null;
ip: string | null;
packageManager: string | null;
collectedAt: string;
}
export interface FleetInventoryEntry {
hostId: number;
hostName: string;
inventory: FleetInventoryRecord | null;
}
export type FleetPackageAction = "install" | "remove" | "upgrade-all";
export async function listFleets(): Promise<FleetRow[]> {
try {
const response = await authApi.get("/fleets");
return response.data;
} catch (error) {
throw handleApiError(error, "fetch fleets");
}
}
export async function createFleet(fleetData: {
name: string;
description?: string | null;
color?: string | null;
icon?: string | null;
tagRules?: string[];
}): Promise<FleetRow> {
try {
const response = await authApi.post("/fleets", fleetData);
return response.data;
} catch (error) {
throw handleApiError(error, "create fleet");
}
}
export async function updateFleet(
fleetId: number,
fleetData: {
name?: string;
description?: string | null;
color?: string | null;
icon?: string | null;
tagRules?: string[];
},
): Promise<FleetRow> {
try {
const response = await authApi.patch(`/fleets/${fleetId}`, fleetData);
return response.data;
} catch (error) {
throw handleApiError(error, "update fleet");
}
}
export async function deleteFleet(
fleetId: number,
): Promise<{ success: boolean }> {
try {
const response = await authApi.delete(`/fleets/${fleetId}`);
return response.data;
} catch (error) {
throw handleApiError(error, "delete fleet");
}
}
export async function getFleetMembers(
fleetId: number,
): Promise<FleetMemberRow[]> {
try {
const response = await authApi.get(`/fleets/${fleetId}/members`);
return response.data;
} catch (error) {
throw handleApiError(error, "fetch fleet members");
}
}
export async function addFleetMember(
fleetId: number,
hostId: number,
): Promise<{ success: boolean }> {
try {
const response = await authApi.post(`/fleets/${fleetId}/members`, {
hostId,
});
return response.data;
} catch (error) {
throw handleApiError(error, "add fleet member");
}
}
export async function removeFleetMember(
fleetId: number,
hostId: number,
): Promise<{ success: boolean }> {
try {
const response = await authApi.delete(
`/fleets/${fleetId}/members/${hostId}`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "remove fleet member");
}
}
export async function runFleetCommand(
fleetId: number,
command: string,
inputValues?: Record<string, string>,
): Promise<{ results: FleetHostResult[] }> {
try {
const response = await authApi.post(`/fleets/${fleetId}/execute`, {
command,
...(inputValues ? { inputValues } : {}),
});
return response.data;
} catch (error) {
throw handleApiError(error, "run fleet command");
}
}
export async function pushFleetFile(
fleetId: number,
file: File,
remotePath: string,
): Promise<{ results: FleetHostResult[] }> {
try {
const form = new FormData();
form.append("file", file);
form.append("remotePath", remotePath);
const response = await authApi.post(
`/fleets/${fleetId}/transfer/push`,
form,
{ headers: { "Content-Type": "multipart/form-data" } },
);
return response.data;
} catch (error) {
throw handleApiError(error, "push file to fleet");
}
}
export async function pullFleetFile(
fleetId: number,
remotePath: string,
): Promise<{ results: FleetHostResult[]; blob: Blob; fileName: string }> {
try {
const response = await authApi.post(
`/fleets/${fleetId}/transfer/pull`,
{ remotePath },
{ responseType: "blob" },
);
const resultsHeader = response.headers["x-fleet-transfer-results"];
const results: FleetHostResult[] = resultsHeader
? JSON.parse(atob(resultsHeader))
: [];
const disposition = response.headers["content-disposition"] as
string | undefined;
const match = disposition?.match(/filename="([^"]+)"/);
const fileName = match?.[1] ?? "fleet-transfer.zip";
return { results, blob: response.data, fileName };
} catch (error) {
throw handleApiError(error, "pull file from fleet");
}
}
export async function getFleetInventory(
fleetId: number,
): Promise<FleetInventoryEntry[]> {
try {
const response = await authApi.get(`/fleets/${fleetId}/inventory`);
return response.data;
} catch (error) {
throw handleApiError(error, "fetch fleet inventory");
}
}
export async function refreshFleetInventory(
fleetId: number,
): Promise<{ results: FleetHostResult[] }> {
try {
const response = await authApi.post(`/fleets/${fleetId}/inventory`);
return response.data;
} catch (error) {
throw handleApiError(error, "refresh fleet inventory");
}
}
export async function runFleetPackageAction(
fleetId: number,
action: FleetPackageAction,
packageName?: string,
): Promise<{ results: FleetHostResult[] }> {
try {
const response = await authApi.post(`/fleets/${fleetId}/packages`, {
action,
...(packageName ? { package: packageName } : {}),
});
return response.data;
} catch (error) {
throw handleApiError(error, "run fleet package action");
}
}
-254
View File
@@ -1,254 +0,0 @@
import {
authApi,
getRemoteGuacamoleApi,
handleApiError,
isElectron,
} from "@/main-axios";
import type { AxiosInstance } from "axios";
import type { GuacamoleConfig } from "@/types/guacamole-config";
import { resolveRemoteHostId } from "@/lib/remote-server-api";
import type { ConnectionOrigin } from "@/lib/connection-origin";
/**
* Picks the backend that will actually serve this session, so the token and
* status come from the same place as the connection.
*
* Outside Electron there is only one backend. Inside it, a host left on
* Default still resolves to the connected server -- asking the embedded
* backend would report the guacd *it* can reach rather than the one serving
* the session. A host explicitly set to "This Device" (Support#1240) is
* served by the embedded backend against the user's own guacd, so its calls
* have to go there instead.
*
* Callers pass the origin explicitly rather than defaulting it: a default
* silently sent a missed call site to the remote server, which fails with a
* bare network error on a desktop that has none configured.
*/
function guacamoleApi(origin: ConnectionOrigin): AxiosInstance {
if (!isElectron()) return authApi;
return origin === "local" ? authApi : getRemoteGuacamoleApi();
}
export interface GuacamoleTokenRequest {
protocol: "rdp" | "vnc" | "telnet";
hostname: string;
port?: number;
username?: string;
password?: string;
domain?: string;
security?: string;
ignoreCert?: boolean;
guacamoleConfig?: GuacamoleConfig;
}
export interface GuacamoleTokenResponse {
token: string;
guacamoleConnectionId?: string | null;
termixConnectId?: string;
}
type GuacamoleConfigSource = {
guacamoleConfig?: string | Record<string, unknown> | null;
};
export function parseGuacamoleConfig(
config?: string | GuacamoleConfig | null,
): GuacamoleConfig {
if (!config) return {};
if (typeof config !== "string") return config;
try {
return JSON.parse(config) as GuacamoleConfig;
} catch {
return {};
}
}
export function getGuacamoleDpi(
source?: GuacamoleConfigSource,
): number | undefined {
const config = source?.guacamoleConfig;
if (!config) return undefined;
let dpi: unknown;
if (typeof config === "string") {
try {
dpi = JSON.parse(config).dpi;
} catch {
return undefined;
}
} else {
dpi = config.dpi;
}
const parsedDpi = typeof dpi === "string" ? Number(dpi) : dpi;
if (
typeof parsedDpi !== "number" ||
!Number.isFinite(parsedDpi) ||
parsedDpi <= 0
) {
return undefined;
}
return Math.trunc(parsedDpi);
}
function toGuacamoleParams(
config: GuacamoleTokenRequest["guacamoleConfig"],
): Record<string, unknown> {
if (!config) return {};
const params: Record<string, unknown> = {};
const mappings: Record<string, string> = {
colorDepth: "color-depth",
resizeMethod: "resize-method",
forceLossless: "force-lossless",
disableAudio: "disable-audio",
enableAudioInput: "enable-audio-input",
enableWallpaper: "enable-wallpaper",
enableTheming: "enable-theming",
enableFontSmoothing: "enable-font-smoothing",
enableFullWindowDrag: "enable-full-window-drag",
enableDesktopComposition: "enable-desktop-composition",
enableMenuAnimations: "enable-menu-animations",
disableBitmapCaching: "disable-bitmap-caching",
disableOffscreenCaching: "disable-offscreen-caching",
disableGlyphCaching: "disable-glyph-caching",
disableGfx: "disable-gfx",
enablePrinting: "enable-printing",
printerName: "printer-name",
enableDrive: "enable-drive",
driveName: "drive-name",
drivePath: "drive-path",
createDrivePath: "create-drive-path",
disableDownload: "disable-download",
disableUpload: "disable-upload",
enableTouch: "enable-touch",
clientName: "client-name",
initialProgram: "initial-program",
serverLayout: "server-layout",
gatewayHostname: "gateway-hostname",
gatewayPort: "gateway-port",
gatewayUsername: "gateway-username",
gatewayPassword: "gateway-password",
gatewayDomain: "gateway-domain",
remoteApp: "remote-app",
remoteAppDir: "remote-app-dir",
remoteAppArgs: "remote-app-args",
normalizeClipboard: "normalize-clipboard",
disableCopy: "disable-copy",
disablePaste: "disable-paste",
swapRedBlue: "swap-red-blue",
readOnly: "read-only",
recordingPath: "recording-path",
recordingName: "recording-name",
createRecordingPath: "create-recording-path",
recordingExcludeOutput: "recording-exclude-output",
recordingExcludeMouse: "recording-exclude-mouse",
recordingIncludeKeys: "recording-include-keys",
wolSendPacket: "wol-send-packet",
wolMacAddr: "wol-mac-addr",
wolBroadcastAddr: "wol-broadcast-addr",
wolUdpPort: "wol-udp-port",
wolWaitTime: "wol-wait-time",
};
for (const [key, value] of Object.entries(config)) {
if (value !== undefined && value !== null && value !== "") {
const paramName = mappings[key] || key;
if (typeof value === "boolean") {
params[paramName] = value ? "true" : "false";
} else {
params[paramName] = value;
}
}
}
return params;
}
export async function getGuacamoleToken(
request: GuacamoleTokenRequest,
origin: ConnectionOrigin,
): Promise<GuacamoleTokenResponse> {
try {
const guacParams = toGuacamoleParams(request.guacamoleConfig);
const response = await guacamoleApi(origin).post("/guacamole/token", {
type: request.protocol,
hostname: request.hostname,
port: request.port,
username: request.username,
password: request.password,
domain: request.domain,
security: request.security,
"ignore-cert": request.ignoreCert,
...guacParams,
});
return response.data;
} catch (error) {
throw handleApiError(error, "get guacamole token");
}
}
export async function getGuacamoleTokenFromHost(
hostId: number,
origin: ConnectionOrigin,
protocol?: "rdp" | "vnc" | "telnet",
promptedCredentials?: {
username?: string;
password?: string;
domain?: string;
},
syncId?: string | null,
): Promise<GuacamoleTokenResponse> {
try {
// A locally-originated session is served by the embedded backend, which
// knows the host by its local id -- remapping it onto the remote
// server's id would address the wrong row, or fail outright when no
// server is configured at all.
const useRemote = isElectron() && origin === "remote";
const remoteHostId = useRemote ? await resolveRemoteHostId(syncId) : null;
if (useRemote && syncId && remoteHostId === null) {
throw new Error("The synced host does not exist on the remote server");
}
const targetHostId = remoteHostId ?? hostId;
const response = await guacamoleApi(origin).post(
`/guacamole/connect-host/${targetHostId}`,
{
...(protocol ? { protocol } : {}),
...(promptedCredentials?.username
? { promptedUsername: promptedCredentials.username }
: {}),
...(promptedCredentials?.password
? { promptedPassword: promptedCredentials.password }
: {}),
...(promptedCredentials
? { promptedDomain: promptedCredentials.domain ?? "" }
: {}),
},
);
return response.data;
} catch (error) {
throw handleApiError(error, "get guacamole token from host");
}
}
export async function getGuacdStatus(origin: ConnectionOrigin): Promise<{
guacd: { status: string };
}> {
const response = await guacamoleApi(origin).get("/guacamole/status");
return response.data;
}
export async function getGuacamoleConnectionId(
connectId: string,
origin: ConnectionOrigin,
signal?: AbortSignal,
): Promise<string | null> {
const response = await guacamoleApi(origin).get(
`/guacamole/connection/${encodeURIComponent(connectId)}`,
{ signal },
);
return response.data.guacamoleConnectionId ?? null;
}
-72
View File
@@ -1,72 +0,0 @@
import { homepageApi, handleApiError } from "@/main-axios";
import type {
HomepageItemRow,
HomepageLayoutData,
HomepageLayoutRow,
WidgetTypeId,
} from "@/types/homepage-types";
export async function getHomepageItems(): Promise<HomepageItemRow[]> {
try {
const res = await homepageApi.get("/items");
return res.data;
} catch (error) {
throw handleApiError(error, "fetch homepage items");
}
}
export async function createHomepageItem(data: {
typeId: WidgetTypeId;
title?: string | null;
config?: Record<string, unknown>;
}): Promise<HomepageItemRow> {
try {
const res = await homepageApi.post("/items", data);
return res.data;
} catch (error) {
throw handleApiError(error, "create homepage item");
}
}
export async function updateHomepageItem(
id: number,
data: {
title?: string | null;
config?: Record<string, unknown>;
},
): Promise<HomepageItemRow> {
try {
const res = await homepageApi.put(`/items/${id}`, data);
return res.data;
} catch (error) {
throw handleApiError(error, "update homepage item");
}
}
export async function deleteHomepageItem(id: number): Promise<void> {
try {
await homepageApi.delete(`/items/${id}`);
} catch (error) {
throw handleApiError(error, "delete homepage item");
}
}
export async function getHomepageLayout(): Promise<HomepageLayoutRow | null> {
try {
const res = await homepageApi.get("/layout");
return res.data;
} catch (error) {
throw handleApiError(error, "fetch homepage layout");
}
}
export async function saveHomepageLayout(
layout: HomepageLayoutData,
): Promise<HomepageLayoutRow> {
try {
const res = await homepageApi.put("/layout", layout);
return res.data;
} catch (error) {
throw handleApiError(error, "save homepage layout");
}
}
+161
View File
@@ -0,0 +1,161 @@
import { handleApiError, sshHostApi } from "@/main-axios";
import { invalidateHostsAndStatusCaches } from "@/lib/hosts-request-cache";
import type {
HostDefaultsLevel,
ResolvedHostDefault,
} from "@/types/host-defaults";
// HOST DEFAULTS
// ============================================================================
export interface HostDefaultsLevelData {
/** Values set at this level, by "namespace.key". */
values: Record<string, unknown>;
/** What each key reads when this level leaves it unset. */
inherited: Record<string, ResolvedHostDefault>;
}
export interface HostDefaultsTarget {
level: HostDefaultsLevel;
folderId?: number;
}
export interface HostDefaultsChange {
set: Record<string, unknown>;
unset: string[];
}
function levelPath(target: HostDefaultsTarget): string {
if (target.level === "admin") return "/defaults/admin";
if (target.level === "user") return "/defaults/user";
return `/defaults/folders/${target.folderId}`;
}
function adminHeaders(targetUserId?: string): Record<string, string> {
return targetUserId ? { "X-Admin-Target-User": targetUserId } : {};
}
export async function getHostDefaultsLevel(
target: HostDefaultsTarget,
): Promise<HostDefaultsLevelData> {
try {
const response = await sshHostApi.get(levelPath(target));
return response.data;
} catch (error) {
handleApiError(error, "fetch host defaults");
}
}
export async function saveHostDefaultsLevel(
target: HostDefaultsTarget,
change: HostDefaultsChange,
): Promise<{ changedHosts?: number; jobId?: string }> {
try {
const response = await sshHostApi.put(levelPath(target), change);
invalidateHostsAndStatusCaches();
return response.data;
} catch (error) {
handleApiError(error, "save host defaults");
}
}
export async function previewHostDefaultsLevel(
target: HostDefaultsTarget,
change: HostDefaultsChange,
): Promise<{ changedHosts: number }> {
try {
const response = await sshHostApi.post("/defaults/preview", {
...target,
...change,
});
return response.data;
} catch (error) {
handleApiError(error, "preview host defaults");
}
}
export async function getHostDefaultsJob(
id: string,
): Promise<{ status: "running" | "done" | "failed"; changedHosts: number }> {
try {
const response = await sshHostApi.get(`/defaults/jobs/${id}`);
return response.data;
} catch (error) {
handleApiError(error, "fetch host defaults progress");
}
}
/** A folder's id for its defaults, created when it only exists on hosts. */
export async function getFolderDefaultsId(name: string): Promise<number> {
try {
const response = await sshHostApi.post("/defaults/folders", { name });
return response.data.id;
} catch (error) {
handleApiError(error, "open folder defaults");
}
}
/** What a host, or a new one in a folder or under a parent, resolves to. */
export async function resolveHostDefaults(
where: {
hostId?: number;
folder?: string | null;
parentHostId?: number | null;
},
adminTargetUserId?: string,
): Promise<Record<string, ResolvedHostDefault>> {
try {
const params: Record<string, string> = {};
if (where.hostId !== undefined) params.hostId = String(where.hostId);
if (where.folder !== undefined) params.folder = where.folder ?? "";
if (where.parentHostId !== undefined && where.parentHostId !== null) {
params.parentHostId = String(where.parentHostId);
}
const response = await sshHostApi.get("/defaults/resolve", {
params,
headers: adminHeaders(adminTargetUserId),
});
return response.data.values ?? {};
} catch (error) {
handleApiError(error, "resolve host defaults");
}
}
export async function resetHostToDefaults(
hostIds: number[],
reset: { all?: boolean; namespaces?: string[]; keys?: string[] },
): Promise<void> {
try {
if (hostIds.length === 1) {
await sshHostApi.post(`/db/host/${hostIds[0]}/reset-defaults`, reset);
} else {
await sshHostApi.patch("/bulk-update", {
hostIds,
updates: { resetDefaults: reset },
});
}
invalidateHostsAndStatusCaches();
} catch (error) {
handleApiError(error, "reset hosts to defaults");
}
}
/** Waits for a server-wide defaults job. Resolves to the hosts it changed, or null. */
export async function waitForDefaultsJob(
id: string,
intervalMs = 1500,
maxWaitMs = 10 * 60 * 1000,
): Promise<number | null> {
const started = Date.now();
while (Date.now() - started < maxWaitMs) {
await new Promise((resolve) => setTimeout(resolve, intervalMs));
try {
const job = await getHostDefaultsJob(id);
if (job.status === "done") return job.changedHosts;
if (job.status === "failed") return null;
} catch {
return null;
}
}
return null;
}
-146
View File
@@ -1,146 +0,0 @@
import { handleApiError, statsApi } from "@/main-axios";
import type { HostMetricsLayout } from "@/types/host-metrics";
// Every function below is keyed by a host's numeric database id, and the
// receiving backend must own that host in its own database -- a synced
// host has a different numeric id on each side (only its syncId matches
// across them). These calls always target the embedded local backend; see
// getAllServerStatuses in host-metrics-status-api.ts for the one metrics
// call that IS safely merged across local + remote (a process-local,
// in-memory aggregate keyed by whichever host ids that process happens to
// know about, not a per-host lookup).
export interface MetricsHistoryRow {
ts: string;
cpu_percent: number | null;
mem_percent: number | null;
disk_percent: number | null;
net_rx_bytes: number | null;
net_tx_bytes: number | null;
}
export interface MetricsHistoryResponse {
rows: MetricsHistoryRow[];
fromTs: string;
toTs: string;
}
export async function getMetricsHistory(
hostId: number,
opts: { range?: string; from?: string; to?: string },
): Promise<MetricsHistoryResponse> {
const res = await statsApi.get(`/metrics/history/${hostId}`, {
params: opts,
});
return res.data as MetricsHistoryResponse;
}
export async function getMetricsHistoryRetention(): Promise<number> {
const res = await statsApi.get("/global-settings/history");
return (res.data as { metricsHistoryRetentionDays: number })
.metricsHistoryRetentionDays;
}
export async function saveMetricsHistoryRetention(days: number): Promise<void> {
await statsApi.post("/global-settings/history", {
metricsHistoryRetentionDays: days,
});
}
/**
* Host Metrics layout persistence (server-synced per user/host) + the manager
* card endpoints. All routes live under the `/host-metrics/*` prefix on the
* stats app (port 30005).
*/
export async function getHostMetricsLayout(
hostId: number,
): Promise<HostMetricsLayout | null> {
try {
const res = await statsApi.get(`/host-metrics/preferences/${hostId}`, {
validateStatus: (status) => status === 200 || status === 404,
});
if (res.status === 404) return null;
return res.data?.layout ?? null;
} catch (error) {
handleApiError(error, "fetch host metrics layout");
throw error;
}
}
export async function saveHostMetricsLayout(
hostId: number,
layout: HostMetricsLayout,
): Promise<void> {
try {
await statsApi.post(`/host-metrics/preferences/${hostId}`, layout);
} catch (error) {
handleApiError(error, "save host metrics layout");
throw error;
}
}
// ─── Managers ───────────────────────────────────────────────────────────────
export interface PlatformInfo {
hasSystemd: boolean;
pkg: "apt" | "dnf" | "yum" | "pacman" | null;
hasCertbot: boolean;
hasAcmeSh: boolean;
hasDocker: boolean;
osPrettyName: string | null;
}
export async function getHostPlatform(hostId: number): Promise<PlatformInfo> {
const res = await statsApi.get(`/host-metrics/platform/${hostId}`);
return res.data;
}
/** GET a manager resource (read). */
export async function managerGet<T>(
hostId: number,
resource: string,
params?: Record<string, string | number>,
): Promise<T> {
const res = await statsApi.get(
`/host-metrics/managers/${resource}/${hostId}`,
{ params },
);
return res.data as T;
}
/**
* GET a manager sub-resource where the host id sits in the middle of the path,
* e.g. /managers/logs/{id}/files.
*/
export async function managerGetSub<T>(
hostId: number,
resource: string,
sub: string,
params?: Record<string, string | number>,
): Promise<T> {
const res = await statsApi.get(
`/host-metrics/managers/${resource}/${hostId}/${sub}`,
{ params },
);
return res.data as T;
}
/**
* POST a manager action. `resource` is the manager name; `action` is the
* optional sub-path (e.g. "action", "signal", "renew"). The host id always
* sits between them: /managers/{resource}/{id}[/{action}].
*/
export async function managerPost<T>(
hostId: number,
resource: string,
body: unknown,
action?: string,
): Promise<T> {
const suffix = action ? `/${action}` : "";
const res = await statsApi.post(
`/host-metrics/managers/${resource}/${hostId}${suffix}`,
body,
);
return res.data as T;
}
-348
View File
@@ -1,348 +0,0 @@
import axios, { type AxiosRequestConfig } from "axios";
import {
handleApiError,
statsApi,
getRemoteStatsApi,
isElectron,
sshHostApi,
} from "@/main-axios";
import type { ServerMetrics, ServerStatus } from "@/main-axios";
import { getCachedServerStatuses } from "@/lib/hosts-request-cache";
import { resolveConnectionOrigin } from "@/lib/connection-origin";
import type { SSHHost } from "@/types/index";
import { createKeyedRequestCache } from "@/lib/keyed-request-cache";
// Metrics collection/viewer registration below (startMetricsPolling,
// registerMetricsViewer, etc.) is NOT origin-routed: the backend that
// receives the call must own the target host by numeric database id, and a
// synced host has a different numeric id in each database (only its
// syncId matches across them). Only the aggregate status read is merged
// across local + remote, same as tunnel status.
async function isRemoteSyncConnected(): Promise<boolean> {
if (!isElectron()) return false;
try {
const config = (await window.electronAPI?.invoke?.(
"get-remote-sync-config",
)) as { serverUrl?: string } | null;
return !!config?.serverUrl;
} catch {
return false;
}
}
type ApiConnectionLog = {
type: "info" | "success" | "warning" | "error";
stage: string;
message: string;
details?: Record<string, unknown>;
};
type ConnectErrorResponse = {
error?: string;
message?: string;
connectionLogs?: ApiConnectionLog[];
requires_totp?: boolean;
requires_warpgate?: boolean;
sessionId?: string;
prompt?: string;
url?: string;
securityKey?: string;
status?: string;
reason?: string;
};
// SERVER STATISTICS
// ============================================================================
const metricsCache = createKeyedRequestCache<ServerMetrics | null>(1_500, 100);
/**
* Progressive retry schedule for the background /status poll.
*
* Each entry describes one attempt's per-request timeout and the pause to
* observe before the next attempt. The pause on the last entry is `null`:
* after that final failure we surface the network error, which flows
* through the response interceptor + dbHealthMonitor (which decides
* between the degraded toast and the full-outage overlay based on whether
* any WebSocket is still alive).
*
* Sequence: try(2s) -> wait 3s -> try(5s) -> wait 5s -> try(8s) -> fail.
* Worst-case wall-clock = 23s, which fits inside the 30s ServerStatusContext
* poll cadence, so the next tick acts as the next retry without overlap.
*/
const STATUS_RETRY_SCHEDULE: ReadonlyArray<{
timeoutMs: number;
pauseAfterMs: number | null;
}> = [
{ timeoutMs: 2000, pauseAfterMs: 3000 },
{ timeoutMs: 5000, pauseAfterMs: 5000 },
{ timeoutMs: 8000, pauseAfterMs: null },
];
function isTransientStatusError(error: unknown): boolean {
if (!axios.isAxiosError(error)) return false;
if (error.response) {
// Definitive server response (even 5xx) is not something more retries
// will fix in a useful timeframe; bail out and report it normally.
return false;
}
const code = error.code;
if (!code) {
// No code + no response means classic network error (offline / DNS / TCP)
return true;
}
return (
code === "ECONNABORTED" ||
code === "ETIMEDOUT" ||
code === "ERR_NETWORK" ||
code === "ECONNREFUSED" ||
code === "ECONNRESET"
);
}
export async function getAllServerStatuses(): Promise<
Record<number, ServerStatus>
> {
return getCachedServerStatuses(async () => {
let lastError: unknown = null;
let localStatuses: Record<number, ServerStatus> = {};
let localHostIds: number[] | null = null;
if (isElectron()) {
try {
const response = await sshHostApi.get<SSHHost[]>("/db/host");
const defaultOrigin = await resolveConnectionOrigin({
connectionType: "ssh",
connectionOrigin: null,
});
localHostIds = (response.data || [])
.filter(
(host) => (host.connectionOrigin ?? defaultOrigin) === "local",
)
.map((host) => host.id);
} catch {
// A host-list failure must not start local probes for hosts whose
// configured origin may be remote.
localHostIds = [];
}
}
for (let i = 0; i < STATUS_RETRY_SCHEDULE.length; i++) {
const { timeoutMs, pauseAfterMs } = STATUS_RETRY_SCHEDULE[i];
const isFinalAttempt = i === STATUS_RETRY_SCHEDULE.length - 1;
try {
const response = await statsApi.get("/status", {
timeout: timeoutMs,
...(localHostIds === null
? {}
: { params: { hostIds: localHostIds.join(",") } }),
// Silence per-attempt interceptor logging & health-monitor side
// effects on all attempts except the final one, so background
// blips don't look like real outages.
__silentRetry: !isFinalAttempt,
} as AxiosRequestConfig & { __silentRetry?: boolean });
localStatuses = response.data || {};
lastError = null;
break;
} catch (error) {
lastError = error;
if (!isTransientStatusError(error)) {
break;
}
if (pauseAfterMs === null) {
break;
}
await new Promise((resolve) => setTimeout(resolve, pauseAfterMs));
}
}
if (lastError) {
handleApiError(lastError, "fetch server statuses");
return {};
}
if (await isRemoteSyncConnected()) {
try {
const remoteResult = await getRemoteStatsApi().get("/status", {
timeout: 8000,
__silentRetry: true,
} as AxiosRequestConfig & { __silentRetry?: boolean });
return { ...localStatuses, ...(remoteResult.data || {}) };
} catch {
// remote unreachable this tick -- fall back to local-only statuses
}
}
return localStatuses;
});
}
export async function getServerStatusById(id: number): Promise<ServerStatus> {
try {
const response = await statsApi.get(`/status/${id}`);
return response.data;
} catch (error) {
handleApiError(error, "fetch server status");
throw error;
}
}
export async function getServerMetricsById(
id: number,
): Promise<ServerMetrics | null> {
return metricsCache.get(String(id), async () => {
try {
const response = await statsApi.get(`/metrics/${id}`, {
// Treat 404 as an expected "no metrics yet / disabled" signal rather
// than an error so we don't spam warn logs on the client.
validateStatus: (status) => status === 200 || status === 404,
});
if (response.status === 404) return null;
return response.data;
} catch (error) {
// If a 404 still slips through (e.g. intercepted before reaching here),
// swallow it quietly; everything else still flows through handleApiError.
if (axios.isAxiosError(error) && error.response?.status === 404) {
return null;
}
handleApiError(error, "fetch server metrics");
}
});
}
export async function startMetricsPolling(hostId: number): Promise<{
success: boolean;
requires_totp?: boolean;
sessionId?: string;
prompt?: string;
viewerSessionId?: string;
connectionLogs?: ApiConnectionLog[];
}> {
try {
const response = await statsApi.post(`/metrics/start/${hostId}`);
metricsCache.invalidate(String(hostId));
return response.data;
} catch (error: unknown) {
if (
axios.isAxiosError<ConnectErrorResponse>(error) &&
error.response?.data?.connectionLogs
) {
const data = error.response.data;
const errorWithLogs = new Error(
data.error || data.message || error.message,
);
Object.assign(errorWithLogs, {
connectionLogs: data.connectionLogs,
});
throw errorWithLogs;
}
handleApiError(error, "start metrics polling");
throw error;
}
}
export async function stopMetricsPolling(
hostId: number,
viewerSessionId?: string,
): Promise<void> {
try {
await statsApi.post(`/metrics/stop/${hostId}`, { viewerSessionId });
} catch (error) {
handleApiError(error, "stop metrics polling");
throw error;
}
}
export async function sendMetricsHeartbeat(
viewerSessionId: string,
): Promise<boolean> {
try {
const response = await statsApi.post(
"/metrics/heartbeat",
{ viewerSessionId },
{ validateStatus: (status) => status === 200 || status === 404 },
);
return response.status !== 404;
} catch (error) {
if (axios.isAxiosError(error) && error.response?.status === 404) {
return false;
}
handleApiError(error, "send metrics heartbeat");
throw error;
}
}
export async function registerMetricsViewer(hostId: number): Promise<{
success: boolean;
viewerSessionId?: string;
skipped?: boolean;
reason?: string;
}> {
try {
const response = await statsApi.post("/metrics/register-viewer", {
hostId,
});
metricsCache.invalidate(String(hostId));
return response.data;
} catch (error) {
handleApiError(error, "register metrics viewer");
throw error;
}
}
export async function unregisterMetricsViewer(
hostId: number,
viewerSessionId: string,
): Promise<void> {
try {
await statsApi.post("/metrics/unregister-viewer", {
hostId,
viewerSessionId,
});
} catch (error) {
handleApiError(error, "unregister metrics viewer");
throw error;
}
}
export async function submitMetricsTOTP(
sessionId: string,
totpCode: string,
): Promise<{
success: boolean;
viewerSessionId?: string;
}> {
try {
const response = await statsApi.post("/metrics/connect-totp", {
sessionId,
totpCode,
});
metricsCache.invalidate();
return response.data;
} catch (error) {
handleApiError(error, "submit metrics TOTP");
throw error;
}
}
export async function refreshServerPolling(): Promise<void> {
try {
await statsApi.post("/refresh");
} catch (error) {
console.warn("Failed to refresh server polling:", error);
}
}
export async function notifyHostCreatedOrUpdated(
hostId: number,
): Promise<void> {
try {
await statsApi.post("/host-updated", { hostId });
metricsCache.invalidate(String(hostId));
} catch (error) {
console.warn("Failed to notify stats server of host update:", error);
}
}
// ============================================================================
+201
View File
@@ -0,0 +1,201 @@
import axios, { type AxiosRequestConfig } from "axios";
import {
handleApiError,
getRemoteCoreApi,
isElectron,
sshHostApi,
} from "@/main-axios";
import type { ServerStatus } from "@/main-axios";
import { getCachedServerStatuses } from "@/lib/hosts-request-cache";
import { resolveConnectionOrigin } from "@/lib/connection-origin";
import { getLinkedSession } from "@/lib/linked-server";
import type { SSHHost } from "@/types/index";
// Core's host status checks (/host/status). On a linked desktop, hosts that
// connect through the server take their status from it, matched by sync id.
// HOST STATUS
// ============================================================================
/**
* Progressive retry schedule for the background /status poll.
*
* Each entry describes one attempt's per-request timeout and the pause to
* observe before the next attempt. The pause on the last entry is `null`:
* after that final failure we surface the network error, which flows
* through the response interceptor + dbHealthMonitor (which decides
* between the degraded toast and the full-outage overlay based on whether
* any WebSocket is still alive).
*
* Sequence: try(2s) -> wait 3s -> try(5s) -> wait 5s -> try(8s) -> fail.
* Worst-case wall-clock = 23s, which fits inside the 30s ServerStatusContext
* poll cadence, so the next tick acts as the next retry without overlap.
*/
const STATUS_RETRY_SCHEDULE: ReadonlyArray<{
timeoutMs: number;
pauseAfterMs: number | null;
}> = [
{ timeoutMs: 2000, pauseAfterMs: 3000 },
{ timeoutMs: 5000, pauseAfterMs: 5000 },
{ timeoutMs: 8000, pauseAfterMs: null },
];
function isTransientStatusError(error: unknown): boolean {
if (!axios.isAxiosError(error)) return false;
if (error.response) {
// Definitive server response (even 5xx) is not something more retries
// will fix in a useful timeframe; bail out and report it normally.
return false;
}
const code = error.code;
if (!code) {
// No code + no response means classic network error (offline / DNS / TCP)
return true;
}
return (
code === "ECONNABORTED" ||
code === "ETIMEDOUT" ||
code === "ERR_NETWORK" ||
code === "ECONNREFUSED" ||
code === "ECONNRESET"
);
}
export async function getAllServerStatuses(): Promise<
Record<number, ServerStatus>
> {
return getCachedServerStatuses(async () => {
let lastError: unknown = null;
let localStatuses: Record<number, ServerStatus> = {};
let localHostIds: number[] | null = null;
let remoteHostsBySyncId: Map<string, number> | null = null;
if (isElectron()) {
try {
const response = await sshHostApi.get<SSHHost[]>("/db/host");
const defaultOrigin = await resolveConnectionOrigin({
connectionOrigin: null,
});
const hosts = response.data || [];
localHostIds = hosts
.filter(
(host) => (host.connectionOrigin ?? defaultOrigin) === "local",
)
.map((host) => host.id);
remoteHostsBySyncId = new Map(
hosts
.filter(
(host) =>
(host.connectionOrigin ?? defaultOrigin) === "remote" &&
!!host.syncId,
)
.map((host) => [host.syncId as string, host.id]),
);
} catch {
// A host-list failure must not start local probes for hosts whose
// configured origin may be remote.
localHostIds = [];
}
}
for (let i = 0; i < STATUS_RETRY_SCHEDULE.length; i++) {
const { timeoutMs, pauseAfterMs } = STATUS_RETRY_SCHEDULE[i];
const isFinalAttempt = i === STATUS_RETRY_SCHEDULE.length - 1;
try {
const response = await sshHostApi.get("/status", {
timeout: timeoutMs,
...(localHostIds === null
? {}
: { params: { hostIds: localHostIds.join(",") } }),
// Silence per-attempt interceptor logging & health-monitor side
// effects on all attempts except the final one, so background
// blips don't look like real outages.
__silentRetry: !isFinalAttempt,
} as AxiosRequestConfig & { __silentRetry?: boolean });
localStatuses = response.data || {};
lastError = null;
break;
} catch (error) {
lastError = error;
if (!isTransientStatusError(error)) {
break;
}
if (pauseAfterMs === null) {
break;
}
await new Promise((resolve) => setTimeout(resolve, pauseAfterMs));
}
}
if (lastError) {
handleApiError(lastError, "fetch server statuses");
return {};
}
if (remoteHostsBySyncId?.size && (await getLinkedSession())) {
try {
const remoteResult = await getRemoteCoreApi().get(
"/sync/v2/host-status",
{
timeout: 8000,
__silentRetry: true,
} as AxiosRequestConfig & { __silentRetry?: boolean },
);
const merged = { ...localStatuses };
for (const [syncId, status] of Object.entries(
(remoteResult.data || {}) as Record<string, ServerStatus>,
)) {
const localId = remoteHostsBySyncId.get(syncId);
if (localId !== undefined) merged[localId] = status;
}
return merged;
} catch {
// The server is unreachable this tick; local statuses still stand.
}
}
return localStatuses;
});
}
export async function getServerStatusById(id: number): Promise<ServerStatus> {
try {
const response = await sshHostApi.get(`/status/${id}`);
return response.data;
} catch (error) {
handleApiError(error, "fetch server status");
throw error;
}
}
export async function refreshServerPolling(): Promise<void> {
try {
await sshHostApi.post("/status/refresh");
} catch (error) {
console.warn("Failed to refresh status checks:", error);
}
}
export async function getStatusCheckSettings(): Promise<{
statusCheckInterval: number;
}> {
try {
const response = await sshHostApi.get("/status/settings");
return response.data;
} catch (error) {
handleApiError(error, "fetch status check settings");
}
}
export async function updateStatusCheckSettings(settings: {
statusCheckInterval: number;
}): Promise<void> {
try {
await sshHostApi.put("/status/settings", settings);
} catch (error) {
handleApiError(error, "update status check settings");
}
}
// ============================================================================
-15
View File
@@ -1,15 +0,0 @@
import { authApi, handleApiError } from "@/main-axios";
/** Flips Auto-Tmux for one host without round-tripping the whole editor form. */
export async function setHostAutoTmux(
hostId: number,
autoTmux: boolean,
): Promise<void> {
try {
await authApi.patch(`/host/db/host/${hostId}/terminal-config`, {
autoTmux,
});
} catch (error) {
throw handleApiError(error, "update host auto-tmux");
}
}
-184
View File
@@ -1,184 +0,0 @@
// Local disk <-> remote SFTP transfers for the desktop app's dual-pane file
// manager. The bytes never pass through the renderer: the Electron main
// process streams them between disk and the backend's existing
// `uploadFileStream` / `downloadFileStream` routes (see
// electron/local-files.cjs), and we only orchestrate + relay progress here.
//
// The renderer deliberately does not choose the URL or the auth headers. It
// names the origin that owns the SSH session ("local" embedded backend or the
// configured Remote Sync server) and the main process resolves the rest, so a
// compromised renderer cannot turn the transfer bridge into an HTTP client.
import type {
LocalTransferOrigin,
LocalTransferProgress,
} from "@/types/electron";
import { getSessionOrigin } from "@/main-axios";
import { getDeviceId } from "@/lib/device-id";
export interface LocalTransferProgressEvent {
transferred: number;
total?: number;
}
type ProgressListener = (event: LocalTransferProgressEvent) => void;
/** Error from the main process; `code` is e.g. "EEXIST" or "EBUSY". */
export class LocalTransferError extends Error {
code?: string;
constructor(message: string, code?: string) {
super(message);
this.name = "LocalTransferError";
this.code = code;
}
}
const progressListeners = new Map<string, ProgressListener>();
let unsubscribeProgress: (() => void) | null = null;
function ensureProgressSubscription() {
if (unsubscribeProgress) return;
const api = window.electronAPI?.localTransfer;
if (!api) return;
unsubscribeProgress = api.onProgress((payload: LocalTransferProgress) => {
const listener = progressListeners.get(payload.transferId);
listener?.({ transferred: payload.transferred, total: payload.total });
});
}
function requireTransferApi() {
const api = window.electronAPI?.localTransfer;
if (!api) {
throw new Error("Local transfers are only available in the desktop app");
}
return api;
}
function transferOriginFor(sessionId: string): LocalTransferOrigin {
return getSessionOrigin(sessionId);
}
/**
* The token the desktop renderer uses for the embedded backend. The `jwt`
* cookie the main process would otherwise rely on lasts only a day after an
* interactive login, while this token (and every request the app makes with
* it) keeps working, so transfers must carry it too. Remote-origin transfers
* are authenticated by the main process from its own Remote Sync state.
*/
function localAuthTokenFor(origin: LocalTransferOrigin): string | undefined {
if (origin !== "local") return undefined;
try {
return localStorage.getItem("jwt") || undefined;
} catch {
return undefined;
}
}
export function createLocalTransferId(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
}
/**
* Streams a file from the local disk into `remoteDir` on the SSH session,
* using the same multipart route the in-browser upload uses.
*/
export async function uploadLocalFileToSession(options: {
sessionId: string;
remoteDir: string;
localPath: string;
fileName: string;
hostId?: number;
transferId?: string;
onProgress?: ProgressListener;
}): Promise<void> {
const api = requireTransferApi();
ensureProgressSubscription();
const transferId =
options.transferId ?? createLocalTransferId("local-upload");
const fields: Record<string, string> = {
sessionId: options.sessionId,
path: options.remoteDir,
};
if (options.hostId !== undefined) fields.hostId = String(options.hostId);
if (options.onProgress) {
progressListeners.set(transferId, options.onProgress);
}
const origin = transferOriginFor(options.sessionId);
try {
const result = await api.upload({
transferId,
origin,
deviceId: getDeviceId() ?? undefined,
authToken: localAuthTokenFor(origin),
fields,
localPath: options.localPath,
fileName: options.fileName,
});
if (result.success !== true) {
throw new LocalTransferError(
result.error || "Upload failed",
result.code,
);
}
} finally {
progressListeners.delete(transferId);
}
}
/**
* Streams a remote file from the SSH session straight to `destPath` on the
* local disk. Refuses to replace an existing file (code "EEXIST") unless
* `overwrite` is set.
*/
export async function downloadSessionFileToLocal(options: {
sessionId: string;
remotePath: string;
destPath: string;
/** The folder the user picked; the main process refuses any destination outside it. */
rootPath: string;
expectedSize?: number;
overwrite?: boolean;
transferId?: string;
onProgress?: ProgressListener;
}): Promise<void> {
const api = requireTransferApi();
ensureProgressSubscription();
const transferId =
options.transferId ?? createLocalTransferId("local-download");
if (options.onProgress) {
progressListeners.set(transferId, options.onProgress);
}
const origin = transferOriginFor(options.sessionId);
try {
const result = await api.download({
transferId,
origin,
deviceId: getDeviceId() ?? undefined,
authToken: localAuthTokenFor(origin),
body: { sessionId: options.sessionId, path: options.remotePath },
destPath: options.destPath,
rootPath: options.rootPath,
expectedSize: options.expectedSize,
overwrite: options.overwrite === true,
});
if (result.success !== true) {
throw new LocalTransferError(
result.error || "Download failed",
result.code,
);
}
} finally {
progressListeners.delete(transferId);
}
}
export async function cancelLocalTransfer(transferId: string): Promise<void> {
const api = window.electronAPI?.localTransfer;
if (!api) return;
await api.cancel(transferId);
}
-32
View File
@@ -1,32 +0,0 @@
import { authApi, handleApiError } from "@/main-axios";
// OIDC ACCOUNT LINKING
// ============================================================================
export async function linkOIDCToPasswordAccount(
oidcUserId: string,
targetUsername: string,
): Promise<{ success: boolean; message: string }> {
try {
const response = await authApi.post("/users/link-oidc-to-password", {
oidcUserId,
targetUsername,
});
return response.data;
} catch (error) {
throw handleApiError(error, "link OIDC account to password account");
}
}
export async function unlinkOIDCFromPasswordAccount(
userId: string,
): Promise<{ success: boolean; message: string }> {
try {
const response = await authApi.post("/users/unlink-oidc-from-password", {
userId,
});
return response.data;
} catch (error) {
throw handleApiError(error, "unlink OIDC from password account");
}
}
+19 -32
View File
@@ -1,6 +1,5 @@
import { authApi } from "@/main-axios";
import { createTtlRequestCache } from "@/lib/ttl-request-cache";
import type { TerminalTheme } from "@/lib/terminal-themes";
import type { CustomKeybinding } from "@/types/keybindings";
// OPEN TABS API
@@ -43,10 +42,6 @@ export interface ActiveSessionInfo {
tabInstanceId: string | null;
isConnected: boolean;
createdAt: number;
isOwnSession: boolean;
sharedByUsername: string | null;
permissionLevel: string | null;
shareId: string | null;
}
// Negative IDs identify remote-only shared hosts, absent from the local DB.
@@ -84,6 +79,7 @@ export async function addOpenTab(tab: OpenTabUpsertPayload): Promise<void> {
await authApi.post("/open-tabs", tab);
}
/** The caller's own live sessions. */
export async function getActiveSessions(): Promise<ActiveSessionInfo[]> {
return activeSessionsCache.get(async () => {
const response = await authApi.get("/open-tabs/active-sessions");
@@ -91,16 +87,16 @@ export async function getActiveSessions(): Promise<ActiveSessionInfo[]> {
});
}
/** How long a detached terminal session is kept, in minutes. */
export async function getSessionTimeoutMinutes(): Promise<number> {
const response = await authApi.get("/open-tabs/session-timeout");
return Number(response.data?.minutes) || 30;
}
// ============================================================================
// USER PREFERENCES API
// ============================================================================
export interface SavedCustomTheme {
id: string;
name: string;
colors: TerminalTheme["colors"];
}
export interface UserPreferences {
reopenTabsOnLogin: boolean;
theme?: string | null;
@@ -108,37 +104,18 @@ export interface UserPreferences {
accentColor?: string | null;
language?: string | null;
storageMode?: string | null;
commandAutocomplete?: boolean | null;
commandPaletteEnabled?: boolean | null;
showHostTags?: boolean | null;
hostTrayOnClick?: boolean | null;
pinAppRail?: boolean | null;
expandAppRailOnHover?: boolean | null;
showPinAppRailButton?: boolean | null;
foldersCollapsed?: boolean | null;
confirmSnippetExecution?: boolean | null;
disableUpdateCheck?: boolean | null;
confirmTabClose?: boolean | null;
hiddenRailTabs?: string | null;
aiAssistantEnabled?: boolean | null;
aiReadOnlyCommands?: boolean | null;
compactHostView?: boolean | null;
statusColorScheme?: string | null;
customThemes?: string | null;
customKeybindings?: string | null;
terminalDefaults?: string | null;
rdpDefaults?: string | null;
terminalMacros?: string | null;
}
export function parseCustomThemes(raw?: string | null): SavedCustomTheme[] {
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
export function parseCustomKeybindings(
@@ -153,9 +130,19 @@ export function parseCustomKeybindings(
}
}
const userPreferencesCache = createTtlRequestCache<UserPreferences>(10_000);
/**
* Cached briefly so the loading gate's prefetch and the shell's own read on
* mount share one request instead of two, which is what let stale
* localStorage flags (hiddenRailTabs, pinAppRail) flash before the real
* values landed a moment after the shell first rendered.
*/
export async function getUserPreferences(): Promise<UserPreferences> {
const response = await authApi.get("/user-preferences");
return response.data;
return userPreferencesCache.get(async () => {
const response = await authApi.get("/user-preferences");
return response.data;
});
}
export async function saveUserPreferences(
+245
View File
@@ -0,0 +1,245 @@
import { rbacApi } from "@/main-axios";
export interface PluginTabContribution {
id: string;
titleKey: string;
icon: string;
openFrom: string[];
}
export type PluginSettingsFieldType =
| "boolean"
| "string"
| "number"
| "select"
| "multiselect"
| "secret"
| "textarea"
| "json"
| "custom";
export interface PluginSettingsField {
key: string;
type: PluginSettingsFieldType;
labelKey?: string;
descriptionKey?: string;
placeholderKey?: string;
default?: unknown;
options?: { value: string; labelKey: string }[];
min?: number;
max?: number;
requires?: string;
permission?: string;
group?: string;
component?: string;
hidden?: boolean;
defaultable?: boolean;
defaultLevels?: Array<"admin" | "user" | "folder">;
personal?: boolean;
secretKeys?: string[];
shareRead?: "connect" | "view" | "edit" | "manage";
ownerOnly?: boolean;
}
export interface PluginHostSettingsContribution {
enableKey?: string;
enableLabelKey?: string;
enableDescriptionKey?: string;
enableDefault?: boolean;
editorGroup?: "top" | "ssh";
editorOrder?: number;
fields: PluginSettingsField[];
}
/** A plugin's Appearance defaults for each interface preset. */
export type PluginUiPresets = Record<
"simple" | "balanced" | "advanced",
Record<string, unknown>
>;
export interface PluginSettingsContribution {
admin?: PluginSettingsField[];
user?: PluginSettingsField[];
host?: PluginHostSettingsContribution;
}
/** A panel or dashboard card, declared so its owner is known while it is off. */
export interface PluginViewContribution {
id: string;
titleKey: string;
icon?: string;
}
export interface PluginContributions {
tabs?: PluginTabContribution[];
panels?: PluginViewContribution[];
dashboardCards?: PluginViewContribution[];
/** The frontend also runs on anonymous guest pages. */
guest?: boolean;
settings?: PluginSettingsContribution;
permissions?: { name: string; titleKey: string; descriptionKey: string }[];
uiPresets?: PluginUiPresets;
}
export {
isRedactedSecret,
type RedactedSecret,
} from "@termix/plugin-sdk/settings";
export type PluginSettingsValues = Record<string, unknown>;
/** Per-field messages from a rejected PUT, keyed by field key. */
export interface PluginSettingsErrors {
[key: string]: string;
}
export class PluginSettingsValidationError extends Error {
readonly errors: PluginSettingsErrors;
constructor(errors: PluginSettingsErrors) {
super("Some settings were rejected");
this.name = "PluginSettingsValidationError";
this.errors = errors;
}
}
function settingsPath(pluginId: string, suffix: string): string {
return `/plugins/${encodeURIComponent(pluginId)}/settings/${suffix}`;
}
/** Turns a 400 carrying per-field errors into something a form can render. */
async function putSettings(
path: string,
values: PluginSettingsValues,
): Promise<PluginSettingsValues> {
try {
const response = await rbacApi.put(path, values);
return response.data?.values ?? {};
} catch (error) {
const data = (
error as { response?: { status?: number; data?: { errors?: unknown } } }
).response;
if (data?.status === 400 && data.data?.errors) {
throw new PluginSettingsValidationError(
data.data.errors as PluginSettingsErrors,
);
}
throw error;
}
}
export async function getPluginAdminSettings(
pluginId: string,
): Promise<PluginSettingsValues> {
const response = await rbacApi.get(settingsPath(pluginId, "admin"));
return response.data?.values ?? {};
}
/** Tells the plugin's own frontend, through app.onSettingsChanged. */
export const PLUGIN_SETTINGS_CHANGED_EVENT = "termix:plugin-settings-changed";
function announceSettingsChange(
pluginId: string,
scope: "admin" | "user" | "host",
hostId?: number,
): void {
if (typeof window === "undefined") return;
window.dispatchEvent(
new CustomEvent(PLUGIN_SETTINGS_CHANGED_EVENT, {
detail: { pluginId, scope, hostId },
}),
);
}
export async function updatePluginAdminSettings(
pluginId: string,
values: PluginSettingsValues,
): Promise<PluginSettingsValues> {
const saved = await putSettings(settingsPath(pluginId, "admin"), values);
announceSettingsChange(pluginId, "admin");
return saved;
}
export async function getPluginUserSettings(
pluginId: string,
): Promise<PluginSettingsValues> {
const response = await rbacApi.get(settingsPath(pluginId, "user"));
return response.data?.values ?? {};
}
export async function updatePluginUserSettings(
pluginId: string,
values: PluginSettingsValues,
): Promise<PluginSettingsValues> {
const saved = await putSettings(settingsPath(pluginId, "user"), values);
announceSettingsChange(pluginId, "user");
return saved;
}
export async function getPluginHostSettings(
pluginId: string,
hostId: number,
): Promise<PluginSettingsValues> {
const response = await rbacApi.get(
settingsPath(pluginId, `host/${encodeURIComponent(String(hostId))}`),
);
return response.data?.values ?? {};
}
export async function updatePluginHostSettings(
pluginId: string,
hostId: number,
values: PluginSettingsValues,
): Promise<PluginSettingsValues> {
const saved = await putSettings(
settingsPath(pluginId, `host/${encodeURIComponent(String(hostId))}`),
values,
);
announceSettingsChange(pluginId, "host", hostId);
return saved;
}
/**
* What GET /plugins returns.
*
* The admin-only fields are absent for a caller without
* admin.plugins.manage: what a plugin may do, what it has been granted and
* why it failed are operational details the shell does not need.
*/
export interface PluginSummary {
id: string;
name: string;
version: string;
enabled: boolean;
/** enabled | disabled | blocked | failed, or the loader's live state. */
state: string;
contributes: PluginContributions | null;
/** Lucide icon name from the manifest. */
icon?: string;
dependencies?: Record<string, string>;
optionalDependencies?: Record<string, string>;
/** A built frontend bundle is served at /plugin-assets/<id>/frontend.js. */
frontend?: boolean;
/** A frontend.css sits beside the bundle. */
css?: boolean;
/** Cache key for the bundle; changes when it is rebuilt. */
assetVersion?: string | null;
/** "en" plus the xx_YY names of shipped translations. */
locales?: string[];
tier?: string;
source?: string;
/** Capabilities this plugin's manifest declares. Admins only. */
capabilities?: string[];
/** The subset of `capabilities` actually granted. Admins only. */
grantedCapabilities?: string[];
/** Why the plugin is blocked or failed. Admins only. */
lastError?: string | null;
/** Paths a running plugin serves without login. Admins only. */
publicRoutes?: { http: string[]; ws: string[] };
}
export async function getPlugins(): Promise<PluginSummary[]> {
const response = await rbacApi.get("/plugins");
return Array.isArray(response.data) ? response.data : [];
}
+27
View File
@@ -0,0 +1,27 @@
import { authApi, handleApiError } from "@/main-axios";
/**
* Admin allowlists of private addresses that outbound requests may reach, one
* per core feature that makes them.
*/
export async function getNotificationPrivateEndpoints(): Promise<string[]> {
try {
return (await authApi.get("/users/notification-private-endpoints")).data
.hosts;
} catch (error) {
throw handleApiError(error, "get notification endpoint allowlist");
}
}
export async function setNotificationPrivateEndpoints(
hosts: string[],
): Promise<string[]> {
try {
return (
await authApi.patch("/users/notification-private-endpoints", { hosts })
).data.hosts;
} catch (error) {
throw handleApiError(error, "update notification endpoint allowlist");
}
}
-93
View File
@@ -1,93 +0,0 @@
import axios from "axios";
import { handleApiError, statsApi } from "@/main-axios";
import type { ProxmoxStatsSnapshot } from "@/types/proxmox";
// Every function below is keyed by a host's numeric database id, and the
// receiving backend must own that host in its own database -- same caveat as
// host-metrics-api.ts / host-metrics-status-api.ts. All routes live under the
// `/proxmox-stats/*` prefix on the stats app (port 30005).
export async function getProxmoxStats(
hostId: number,
): Promise<ProxmoxStatsSnapshot | null> {
try {
const response = await statsApi.get(`/proxmox-stats/${hostId}`, {
// Treat 404 as an expected "no stats yet / disabled" signal rather than
// an error so we don't spam warn logs on the client.
validateStatus: (status) => status === 200 || status === 404,
});
if (response.status === 404) {
return null;
}
return response.data;
} catch (error) {
if (axios.isAxiosError(error) && error.response?.status === 404) {
return null;
}
handleApiError(error, "fetch proxmox stats");
throw error;
}
}
export async function startProxmoxStatsPolling(hostId: number): Promise<{
success: boolean;
viewerSessionId?: string;
status?: string;
error?: string;
}> {
try {
const response = await statsApi.post(`/proxmox-stats/start/${hostId}`);
return response.data;
} catch (error) {
handleApiError(error, "start proxmox stats polling");
throw error;
}
}
export async function stopProxmoxStatsPolling(
hostId: number,
viewerSessionId?: string,
): Promise<void> {
try {
await statsApi.post(`/proxmox-stats/stop/${hostId}`, { viewerSessionId });
} catch (error) {
handleApiError(error, "stop proxmox stats polling");
throw error;
}
}
export async function sendProxmoxStatsHeartbeat(
viewerSessionId: string,
): Promise<void> {
try {
await statsApi.post("/proxmox-stats/heartbeat", { viewerSessionId });
} catch (error) {
handleApiError(error, "send proxmox stats heartbeat");
throw error;
}
}
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;
}
export async function getProxmoxStatsHistory(
hostId: number,
opts: { range?: string; from?: string; to?: string },
): Promise<ProxmoxStatsHistoryResponse> {
const res = await statsApi.get(`/proxmox-stats/history/${hostId}`, {
params: opts,
});
return res.data as ProxmoxStatsHistoryResponse;
}
+57 -98
View File
@@ -21,6 +21,17 @@ async function getSharingTarget(hostId: number, syncId?: string | null) {
return { api, hostId: remoteHostId };
}
/** A shared host's copy on a linked desktop is managed on the server. */
async function getSharedCopyTarget(hostId: number, syncId?: string | null) {
if (!syncId) return { api: rbacApi, hostId };
const api = await getConnectedRemoteApi();
const remoteHostId = api ? await resolveRemoteHostId(syncId) : null;
if (!api || remoteHostId === null) {
throw new Error("The linked server is not reachable");
}
return { api, hostId: remoteHostId };
}
export async function getRoles(): Promise<{ roles: Role[] }> {
try {
const api = (await getConnectedRemoteApi()) ?? rbacApi;
@@ -54,6 +65,13 @@ export async function updateRole(
): Promise<{ role: Role }> {
try {
const response = await rbacApi.put(`/rbac/roles/${roleId}`, roleData);
if (roleData.permissions) {
// The editing admin may hold this role, so re-read our own grants
// rather than leaving gated UI stale until the next reload.
const { notifyPermissionsChanged } =
await import("@/hooks/use-permissions");
notifyPermissionsChanged();
}
return response.data;
} catch (error) {
throw handleApiError(error, "update role");
@@ -261,9 +279,25 @@ export async function getHostAccess(
}
}
export interface PermissionCatalogItem {
permission: string;
/** Plugin-relative keys; resolve them with pluginKey(). */
titleKey: string;
descriptionKey: string;
}
export interface PermissionCatalogEntry {
group: string;
permissions: string[];
/** Set for a group a plugin contributed. */
pluginId?: string;
/** Core groups carry an i18n key, plugin groups their display name. */
labelKey?: string;
label?: string;
icon?: string;
/** False once the owning plugin is disabled or gone. */
enabled?: boolean;
items?: PermissionCatalogItem[];
}
export async function getPermissionsCatalog(): Promise<{
@@ -277,6 +311,21 @@ export async function getPermissionsCatalog(): Promise<{
}
}
export interface MyPermissions {
permissions: string[];
isAdmin: boolean;
}
/** The current user's own grants, for hiding UI they could not use. */
export async function getMyPermissions(): Promise<MyPermissions> {
try {
const response = await rbacApi.get("/rbac/permissions/me");
return response.data;
} catch (error) {
throw handleApiError(error, "fetch permissions");
}
}
export async function getSharedHosts(): Promise<{
sharedHosts: Array<{
id: number;
@@ -320,14 +369,12 @@ export async function revokeHostAccess(
export async function getHostAuthOverride(
hostId: number,
protocol: AuthOverrideProtocol,
remoteShared = false,
sharedCopySyncId?: string | null,
): Promise<{ protocol: AuthOverrideProtocol; credentialId: number | null }> {
try {
const api = remoteShared ? await getConnectedRemoteApi() : rbacApi;
if (!api) throw new Error("Remote server is not connected");
const targetHostId = remoteShared ? Math.abs(hostId) : hostId;
const response = await api.get(
`/rbac/host-access/${targetHostId}/auth/${protocol}`,
const target = await getSharedCopyTarget(hostId, sharedCopySyncId);
const response = await target.api.get(
`/rbac/host-access/${target.hostId}/auth/${protocol}`,
);
return response.data;
} catch (error) {
@@ -339,18 +386,16 @@ export async function setHostAuthOverride(
hostId: number,
protocol: AuthOverrideProtocol,
credentialId: number | null,
remoteShared = false,
sharedCopySyncId?: string | null,
): Promise<{
success: boolean;
protocol: AuthOverrideProtocol;
credentialId: number | null;
}> {
try {
const api = remoteShared ? await getConnectedRemoteApi() : rbacApi;
if (!api) throw new Error("Remote server is not connected");
const targetHostId = remoteShared ? Math.abs(hostId) : hostId;
const response = await api.put(
`/rbac/host-access/${targetHostId}/auth/${protocol}`,
const target = await getSharedCopyTarget(hostId, sharedCopySyncId);
const response = await target.api.put(
`/rbac/host-access/${target.hostId}/auth/${protocol}`,
{ credentialId },
);
return response.data;
@@ -359,47 +404,6 @@ export async function setHostAuthOverride(
}
}
// ============================================================================
// SNIPPET SHARING
// ============================================================================
export async function shareSnippet(
snippetId: number,
shareData: {
targetType: "user" | "role";
targetUserId?: string;
targetRoleId?: number;
durationHours?: number;
},
): Promise<{ success: boolean }> {
try {
const response = await rbacApi.post(
`/rbac/snippet/${snippetId}/share`,
shareData,
);
return response.data;
} catch (error) {
throw handleApiError(error, "share snippet");
}
}
export async function shareSnippetFolder(
folder: string,
targets: ShareTarget[],
durationHours?: number,
): Promise<{ success: boolean; snippetsShared: number }> {
try {
const response = await rbacApi.post("/rbac/snippet-folder/share", {
folder,
targets,
durationHours,
});
return response.data;
} catch (error) {
throw handleApiError(error, "share snippet folder");
}
}
export type CredentialPermissionLevel = "use" | "manage";
export async function shareCredential(
@@ -442,48 +446,3 @@ export async function revokeCredentialAccess(
throw handleApiError(error, "revoke credential access");
}
}
export async function getSnippetAccess(
snippetId: number,
): Promise<{ accessList: AccessRecord[] }> {
try {
const response = await rbacApi.get(`/rbac/snippet/${snippetId}/access`);
return response.data;
} catch (error) {
throw handleApiError(error, "fetch snippet access");
}
}
export async function revokeSnippetAccess(
snippetId: number,
accessId: number,
): Promise<{ success: boolean }> {
try {
const response = await rbacApi.delete(
`/rbac/snippet/${snippetId}/access/${accessId}`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "revoke snippet access");
}
}
export async function getSharedSnippets(): Promise<{
sharedSnippets: Array<{
id: number;
name: string;
content: string;
description: string | null;
folder: string | null;
ownerUsername: string;
permissionLevel: string;
expiresAt: string | null;
}>;
}> {
try {
const response = await rbacApi.get("/rbac/shared-snippets");
return response.data;
} catch (error) {
handleApiError(error, "fetch shared snippets");
}
}
-69
View File
@@ -1,69 +0,0 @@
import { authApi, handleApiError } from "@/main-axios";
export interface SecretSource {
id: string;
userId: string;
name: string;
kind: "onepassword-connect";
baseUrl: string;
shared: boolean;
hasToken: boolean;
owned: boolean;
createdAt: string;
updatedAt: string;
}
export interface SecretSourcePayload {
name: string;
kind?: "onepassword-connect";
baseUrl: string;
token?: string;
shared?: boolean;
}
export async function listSecretSources(): Promise<SecretSource[]> {
try {
return (await authApi.get("/secret-sources")).data.sources;
} catch (error) {
throw handleApiError(error, "list secret sources");
}
}
export async function createSecretSource(
payload: SecretSourcePayload,
): Promise<SecretSource> {
try {
return (await authApi.post("/secret-sources", payload)).data.source;
} catch (error) {
throw handleApiError(error, "create secret source");
}
}
export async function updateSecretSource(
id: string,
payload: Partial<SecretSourcePayload>,
): Promise<void> {
try {
await authApi.put(`/secret-sources/${id}`, payload);
} catch (error) {
throw handleApiError(error, "update secret source");
}
}
export async function deleteSecretSource(id: string): Promise<void> {
try {
await authApi.delete(`/secret-sources/${id}`);
} catch (error) {
throw handleApiError(error, "delete secret source");
}
}
export async function testSecretSource(
id: string,
): Promise<{ ok: boolean; vaults?: number; error?: string }> {
try {
return (await authApi.post(`/secret-sources/${id}/test`)).data;
} catch (error) {
throw handleApiError(error, "test secret source");
}
}
-75
View File
@@ -1,75 +0,0 @@
import { authApi, handleApiError } from "@/main-axios";
export type SessionLogRecord = {
id: number;
hostId: number;
userId: string;
startedAt: string;
endedAt: string | null;
duration: number | null;
recordingPath: string | null;
hostName: string | null;
hostIp: string | null;
sizeBytes: number | null;
protocol: "ssh" | "rdp" | "vnc" | "telnet";
format: "text" | "asciicast" | "guacamole";
username: string | null;
};
export async function getSessionLogs(): Promise<SessionLogRecord[]> {
try {
const response = await authApi.get("/session_logs/");
return response.data.logs;
} catch (error) {
throw handleApiError(error, "fetch session logs");
}
}
export async function getSessionLogBlob(id: number): Promise<Blob> {
try {
const response = await authApi.get(`/session_logs/${id}/content`, {
responseType: "blob",
});
return response.data;
} catch (error) {
throw handleApiError(error, "fetch session recording");
}
}
export async function getSessionRecordingRetention(): Promise<number> {
try {
const response = await authApi.get("/session_logs/retention");
return response.data.retentionDays;
} catch (error) {
throw handleApiError(error, "fetch session recording retention");
}
}
export async function setSessionRecordingRetention(
retentionDays: number,
): Promise<void> {
try {
await authApi.put("/session_logs/retention", { retentionDays });
} catch (error) {
throw handleApiError(error, "update session recording retention");
}
}
export async function getSessionLogContent(id: number): Promise<string> {
try {
const response = await authApi.get(`/session_logs/${id}/content`, {
responseType: "text",
});
return response.data;
} catch (error) {
throw handleApiError(error, "fetch session log content");
}
}
export async function deleteSessionLog(id: number): Promise<void> {
try {
await authApi.delete(`/session_logs/${id}`);
} catch (error) {
throw handleApiError(error, "delete session log");
}
}
-181
View File
@@ -1,181 +0,0 @@
import axios from "axios";
import { getBasePath } from "@/lib/base-path";
import { isElectron } from "@/lib/electron";
import { authApi, handleApiError } from "@/main-axios";
export interface ResolvedShareLink {
protocol: "ssh" | "rdp" | "vnc" | "telnet";
permissionLevel: "read-only" | "read-write";
wsPath: string;
connectParams?: { token: string };
}
export type ShareLinkErrorKind = "not-found" | "rate-limited" | "unknown";
export class ShareLinkError extends Error {
constructor(
message: string,
public readonly kind: ShareLinkErrorKind,
) {
super(message);
this.name = "ShareLinkError";
}
}
const isDev = (): boolean =>
!isElectron() &&
process.env.NODE_ENV === "development" &&
(window.location.port === "3000" ||
window.location.port === "5173" ||
window.location.port === "");
// Guests have no session/JWT, so this deliberately builds a bare base URL
// rather than going through main-axios's authenticated instances. The
// desktop app always runs its embedded local backend as the source of
// truth, so a share link opened there always resolves against it --
// joining a session hosted on someone else's remote server isn't
// supported from the desktop app today.
export async function resolveApiBaseUrl(): Promise<string> {
if (isDev()) {
const protocol = window.location.protocol === "https:" ? "https" : "http";
return `${protocol}://localhost:30001`;
}
if (isElectron()) {
return "http://127.0.0.1:30001";
}
return getBasePath();
}
export async function resolveShareLink(
linkToken: string,
): Promise<ResolvedShareLink> {
const baseUrl = await resolveApiBaseUrl();
try {
const response = await axios.get(
`${baseUrl}/session-sharing/resolve/${encodeURIComponent(linkToken)}`,
);
return response.data;
} catch (error) {
if (axios.isAxiosError(error)) {
if (error.response?.status === 404) {
throw new ShareLinkError(
"Share link is invalid, expired, or revoked",
"not-found",
);
}
if (error.response?.status === 429) {
throw new ShareLinkError(
"Too many attempts, please try again shortly",
"rate-limited",
);
}
}
throw new ShareLinkError("Failed to resolve share link", "unknown");
}
}
// ============================================================================
// SESSION SHARING (authenticated owner-side API)
// ============================================================================
export type SessionShareProtocol = "ssh" | "rdp" | "vnc" | "telnet";
export type SessionShareType = "link" | "user";
export type SessionSharePermissionLevel = "read-only" | "read-write";
export interface SessionShareRecord {
id: string;
hostId: number;
ownerUserId: string;
protocol: SessionShareProtocol;
sessionId: string;
tabInstanceId: string | null;
shareType: SessionShareType;
targetUserId: string | null;
linkToken: string | null;
permissionLevel: SessionSharePermissionLevel;
createdAt: string;
expiresAt: string;
revokedAt: string | null;
lastJoinedAt: string | null;
joinCount: number;
}
export interface CreateSessionShareRequest {
hostId: number;
sessionId: string;
tabInstanceId?: string;
protocol: SessionShareProtocol;
shareType: SessionShareType;
targetUserId?: string;
permissionLevel: SessionSharePermissionLevel;
expiryHours?: number;
}
export interface CreateSessionShareResponse {
shareId: string;
linkToken: string | null;
expiresAt: string;
}
export async function createSessionShare(
request: CreateSessionShareRequest,
): Promise<CreateSessionShareResponse> {
try {
const response = await authApi.post("/session-sharing/create", request);
return response.data;
} catch (error) {
throw handleApiError(error, "create session share");
}
}
export async function getActiveSessionShares(
hostId: number,
): Promise<{ shares: SessionShareRecord[] }> {
try {
const response = await authApi.get(
`/session-sharing/host/${hostId}/active`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "fetch active session shares");
}
}
export async function revokeSessionShare(
shareId: string,
): Promise<{ success: true }> {
try {
const response = await authApi.delete(`/session-sharing/${shareId}`);
return response.data;
} catch (error) {
throw handleApiError(error, "revoke session share");
}
}
// ============================================================================
// GLOBAL ADMIN TOGGLE
// ============================================================================
export async function getSessionSharingGloballyEnabled(): Promise<{
enabled: boolean;
}> {
try {
const response = await authApi.get("/users/session-sharing-enabled");
return response.data;
} catch (error) {
throw handleApiError(error, "fetch session sharing enabled setting");
}
}
export async function updateSessionSharingGloballyEnabled(
enabled: boolean,
): Promise<{ enabled: boolean }> {
try {
const response = await authApi.patch("/users/session-sharing-enabled", {
enabled,
});
return response.data;
} catch (error) {
throw handleApiError(error, "update session sharing enabled setting");
}
}
+4 -326
View File
@@ -1,33 +1,5 @@
import axios from "axios";
import { authApi, handleApiError, statsApi } from "@/main-axios";
import { authApi, handleApiError } from "@/main-axios";
// GLOBAL MONITORING SETTINGS
// ============================================================================
export async function getGlobalMonitoringSettings(): Promise<{
statusCheckInterval: number;
metricsInterval: number;
}> {
try {
const response = await statsApi.get("/global-settings");
return response.data;
} catch (error) {
handleApiError(error, "fetch global monitoring settings");
}
}
export async function updateGlobalMonitoringSettings(settings: {
statusCheckInterval?: number;
metricsInterval?: number;
}): Promise<void> {
try {
await statsApi.post("/global-settings", settings);
} catch (error) {
handleApiError(error, "update global monitoring settings");
}
}
// ============================================================================
// LOG LEVEL SETTINGS
// ============================================================================
@@ -61,63 +33,6 @@ export async function getSessionTimeout(): Promise<{ timeoutHours: number }> {
}
}
// How long a detached terminal session is kept alive server-side.
export async function getTerminalSessionSettings(): Promise<{
timeoutMinutes: number;
enabled: boolean;
}> {
try {
const response = await authApi.get("/terminal/session_settings");
return response.data;
} catch (error) {
handleApiError(error, "fetch terminal session settings");
}
}
export async function updateTerminalSessionSettings(input: {
timeoutMinutes?: number;
enabled?: boolean;
}): Promise<void> {
try {
await authApi.patch("/terminal/session_settings", input);
} catch (error) {
handleApiError(error, "update terminal session settings");
}
}
export interface StepCaSettings {
configured: boolean;
caUrl: string;
fingerprint: string;
provisioner: string;
}
export async function getStepCaSettings(): Promise<StepCaSettings> {
try {
const response = await authApi.get("/users/step-ca-settings");
return {
configured: !!response.data.configured,
caUrl: response.data.caUrl ?? "",
fingerprint: response.data.fingerprint ?? "",
provisioner: response.data.provisioner ?? "",
};
} catch (error) {
handleApiError(error, "fetch Step CA settings");
}
}
export async function updateStepCaSettings(input: {
caUrl: string;
fingerprint: string;
provisioner: string;
}): Promise<void> {
try {
await authApi.patch("/users/step-ca-settings", input);
} catch (error) {
handleApiError(error, "update Step CA settings");
}
}
export async function updateSessionTimeout(
timeoutHours: number,
): Promise<void> {
@@ -128,205 +43,11 @@ export async function updateSessionTimeout(
}
}
// ============================================================================
// TAILSCALE SETTINGS
// ============================================================================
export async function getTailscaleSettings(): Promise<{
apiKey: string;
hasApiKey: boolean;
apiBaseUrl: string;
}> {
try {
const response = await authApi.get("/users/tailscale-settings");
return response.data;
} catch (error) {
handleApiError(error, "fetch Tailscale settings");
}
}
export async function updateTailscaleSettings(
apiKey: string,
apiBaseUrl?: string,
): Promise<{ hasApiKey: boolean }> {
try {
const response = await authApi.patch("/users/tailscale-settings", {
apiKey,
apiBaseUrl,
});
return response.data;
} catch (error) {
handleApiError(error, "update Tailscale settings");
}
}
export async function getTailscaleDevices(): Promise<{
devices: Array<{
id: string;
name: string;
hostname: string;
addresses: string[];
os: string;
lastSeen: string;
}>;
hasApiKey: boolean;
error?: string;
}> {
try {
const response = await authApi.get("/tailscale/devices");
return response.data;
} catch (error) {
if (axios.isAxiosError(error)) {
const data = error.response?.data;
if (
data &&
typeof data === "object" &&
"hasApiKey" in data &&
typeof data.hasApiKey === "boolean"
) {
return data as {
devices: [];
hasApiKey: boolean;
error?: string;
};
}
}
handleApiError(error, "fetch Tailscale devices");
throw error;
}
}
// Tailscale settings/device API wrappers moved to
// plugins/tailscale/src/frontend/tailscale-api.ts.
// ============================================================================
// GUACAMOLE SETTINGS
// ============================================================================
export async function getGuacamoleSettings(): Promise<{
enabled: boolean;
url: string;
}> {
try {
const response = await authApi.get("/users/guacamole-settings");
return response.data;
} catch (error) {
handleApiError(error, "fetch guacamole settings");
}
}
export async function updateGuacamoleSettings(settings: {
enabled?: boolean;
url?: string;
}): Promise<void> {
try {
await authApi.patch("/users/guacamole-settings", settings);
} catch (error) {
handleApiError(error, "update guacamole settings");
}
}
// ============================================================================
// ANALYTICS SETTINGS
// ============================================================================
export async function getAnalyticsEnabled(): Promise<{
enabled: boolean;
locked?: boolean;
}> {
try {
const response = await authApi.get("/users/analytics-enabled");
return response.data;
} catch (error) {
handleApiError(error, "fetch analytics enabled setting");
}
}
export async function updateAnalyticsEnabled(
enabled: boolean,
): Promise<{ enabled: boolean }> {
try {
const response = await authApi.patch("/users/analytics-enabled", {
enabled,
});
return response.data;
} catch (error) {
handleApiError(error, "update analytics enabled setting");
}
}
// ============================================================================
// TERMINAL IMAGE STORAGE SETTINGS
// ============================================================================
export type TerminalImageStorageMode = "auto" | "local" | "remote-sftp";
/** Public settings shape: the backend-internal localDir is never returned. */
export interface TerminalImageStorageSettings {
mode: TerminalImageStorageMode;
hostPath: string;
ttlMs: number;
maxCount: number;
maxBytes: number;
localMappingConfigured: boolean;
}
export interface TerminalImageStorageSettingsUpdate {
mode?: TerminalImageStorageMode;
localDir?: string;
hostPath?: string;
ttlMs?: number;
maxCount?: number;
maxBytes?: number;
}
export interface TerminalImageStorageTestResult {
mode: TerminalImageStorageMode;
connected: boolean;
remoteSftpAvailable: boolean;
localHostVisible: boolean | null;
selectedMode: "local" | "remote-sftp" | "unavailable";
localMappingConfigured: boolean;
}
export async function getTerminalImageStorageSettings(): Promise<TerminalImageStorageSettings> {
try {
const response = await authApi.get(
"/users/terminal-image-storage-settings",
);
return response.data;
} catch (error) {
handleApiError(error, "fetch terminal image storage settings");
}
}
export async function updateTerminalImageStorageSettings(
settings: TerminalImageStorageSettingsUpdate,
): Promise<TerminalImageStorageSettings> {
try {
const response = await authApi.patch(
"/users/terminal-image-storage-settings",
settings,
);
return response.data;
} catch (error) {
handleApiError(error, "update terminal image storage settings");
}
}
export async function testTerminalImageStorage(
instanceId: string,
): Promise<TerminalImageStorageTestResult> {
try {
const response = await authApi.post(
"/users/terminal-image-storage-settings/test",
{ instanceId },
);
return response.data;
} catch (error) {
handleApiError(error, "test terminal image storage");
}
}
// ============================================================================
// WHITE LABEL BRANDING
// BRANDING SETTINGS
// ============================================================================
export interface BrandingSettings {
@@ -362,46 +83,3 @@ export async function updateBranding(
handleApiError(error, "update branding settings");
}
}
// ============================================================================
// HOST DEFAULTS SETTINGS
// ============================================================================
export type HostDefaults = {
useSocks5?: boolean;
socks5Host?: string;
socks5Port?: number;
socks5Username?: string;
socks5Password?: string;
credentialId?: number | null;
metricsEnabled?: boolean;
statusCheckEnabled?: boolean;
fontSize?: number;
fontFamily?: string;
theme?: string;
cursorStyle?: string;
cursorBlink?: boolean;
enableSessionLogging?: boolean;
enableCommandHistory?: boolean;
autoTmux?: boolean;
};
export async function getHostDefaults(): Promise<HostDefaults> {
try {
const response = await authApi.get("/users/host-defaults");
return response.data;
} catch (error) {
handleApiError(error, "fetch host defaults");
}
}
export async function updateHostDefaults(
defaults: HostDefaults,
): Promise<HostDefaults> {
try {
const response = await authApi.patch("/users/host-defaults", defaults);
return response.data;
} catch (error) {
handleApiError(error, "update host defaults");
}
}
-248
View File
@@ -1,248 +0,0 @@
import { authApi, handleApiError } from "@/main-axios";
export interface NetworkTopologyNode {
data: {
id: string;
label?: string;
ip?: string;
status?: string;
tags?: string[];
parent?: string;
color?: string;
/** Absent on nodes; callers tell nodes from edges by testing these. */
source?: undefined;
target?: undefined;
};
position?: { x: number; y: number };
}
export interface NetworkTopologyEdge {
data: {
id?: string;
source: string;
target: string;
label?: undefined;
ip?: undefined;
};
}
export interface NetworkTopologyData {
nodes: NetworkTopologyNode[];
edges: NetworkTopologyEdge[];
}
/**
* A snippet row as the list endpoint returns it. Callers that need the full
* shape narrow it themselves; only the id is relied on across the app.
*/
export interface SnippetRow {
id: number;
[key: string]: unknown;
}
export async function getSnippets(): Promise<SnippetRow[]> {
try {
const response = await authApi.get("/snippets");
return response.data;
} catch (error) {
throw handleApiError(error, "fetch snippets");
}
}
export async function createSnippet(
snippetData: Record<string, unknown>,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.post("/snippets", snippetData);
return response.data;
} catch (error) {
throw handleApiError(error, "create snippet");
}
}
export async function updateSnippet(
snippetId: number,
snippetData: Record<string, unknown>,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.put(`/snippets/${snippetId}`, snippetData);
return response.data;
} catch (error) {
throw handleApiError(error, "update snippet");
}
}
export async function deleteSnippet(
snippetId: number,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.delete(`/snippets/${snippetId}`);
return response.data;
} catch (error) {
throw handleApiError(error, "delete snippet");
}
}
export async function executeSnippet(
snippetId: number,
hostId: number,
inputValues?: Record<string, string>,
): Promise<{ success: boolean; output: string; error?: string }> {
try {
const response = await authApi.post("/snippets/execute", {
snippetId,
hostId,
...(inputValues ? { inputValues } : {}),
});
return response.data;
} catch (error) {
throw handleApiError(error, "execute snippet");
}
}
export async function getNetworkTopology(): Promise<NetworkTopologyData | null> {
try {
const response = await authApi.get("/network-topology/");
return response.data;
} catch (error) {
throw handleApiError(error, "fetch network topology");
}
}
export async function saveNetworkTopology(
topology: NetworkTopologyData,
): Promise<{ success: boolean }> {
try {
const response = await authApi.post("/network-topology/", { topology });
return response.data;
} catch (error) {
throw handleApiError(error, "save network topology");
}
}
export async function getSnippetFolders(): Promise<Record<string, unknown>> {
try {
const response = await authApi.get("/snippets/folders");
return response.data;
} catch (error) {
throw handleApiError(error, "fetch snippet folders");
}
}
export async function createSnippetFolder(folderData: {
name: string;
color?: string;
icon?: string;
}): Promise<Record<string, unknown>> {
try {
const response = await authApi.post("/snippets/folders", folderData);
return response.data;
} catch (error) {
throw handleApiError(error, "create snippet folder");
}
}
export async function updateSnippetFolderMetadata(
folderName: string,
metadata: { color?: string; icon?: string },
): Promise<Record<string, unknown>> {
try {
const response = await authApi.put(
`/snippets/folders/${encodeURIComponent(folderName)}/metadata`,
metadata,
);
return response.data;
} catch (error) {
throw handleApiError(error, "update snippet folder metadata");
}
}
export async function renameSnippetFolder(
oldName: string,
newName: string,
): Promise<{ success: boolean; oldName: string; newName: string }> {
try {
const response = await authApi.put("/snippets/folders/rename", {
oldName,
newName,
});
return response.data;
} catch (error) {
throw handleApiError(error, "rename snippet folder");
}
}
export async function deleteSnippetFolder(
folderName: string,
): Promise<{ success: boolean }> {
try {
const response = await authApi.delete(
`/snippets/folders/${encodeURIComponent(folderName)}`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "delete snippet folder");
}
}
export async function reorderSnippets(
updates: Array<{ id: number; order: number; folder?: string }>,
): Promise<{ success: boolean }> {
try {
const response = await authApi.put("/snippets/reorder", {
snippets: updates,
});
return response.data;
} catch (error) {
throw handleApiError(error, "reorder snippets");
}
}
export interface SnippetExportData {
snippets: Array<{
name: string;
content: string;
description?: string | null;
folder?: string | null;
order?: number;
hostFilter?: string | null;
}>;
folders: Array<{
name: string;
color?: string | null;
icon?: string | null;
}>;
}
export async function exportSnippets(): Promise<SnippetExportData> {
try {
const response = await authApi.get("/snippets/export");
return response.data;
} catch (error) {
throw handleApiError(error, "export snippets");
}
}
export async function importSnippets(
data: SnippetExportData,
overwrite: boolean,
): Promise<{
success: boolean;
snippetsImported: number;
snippetsSkipped: number;
snippetsUpdated: number;
foldersImported: number;
foldersSkipped: number;
failed: number;
errors: string[];
}> {
try {
const response = await authApi.post("/snippets/bulk-import", {
...data,
overwrite,
});
return response.data;
} catch (error) {
throw handleApiError(error, "import snippets");
}
}
-46
View File
@@ -1,46 +0,0 @@
export interface ServerSentEvent {
event: string;
data: string;
}
export async function streamServerSentEvents(
url: string,
init: RequestInit,
onEvent: (event: ServerSentEvent) => void,
): Promise<void> {
const response = await fetch(url, init);
if (!response.ok) {
throw new Error(`SSE request failed with status ${response.status}`);
}
if (!response.body) throw new Error("SSE response has no body");
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let eventName = "message";
let data: string[] = [];
const dispatch = () => {
if (data.length > 0) onEvent({ event: eventName, data: data.join("\n") });
eventName = "message";
data = [];
};
while (true) {
const { done, value } = await reader.read();
buffer += decoder.decode(value, { stream: !done });
const lines = buffer.split(/\r?\n/);
buffer = done ? "" : (lines.pop() ?? "");
for (const line of lines) {
if (line === "") dispatch();
else if (line.startsWith("event:")) eventName = line.slice(6).trimStart();
else if (line.startsWith("data:")) data.push(line.slice(5).trimStart());
}
if (done) {
dispatch();
return;
}
}
}
File diff suppressed because it is too large. Load diff
+2 -174
View File
@@ -1,22 +1,11 @@
import { AxiosError } from "axios";
import {
authApi,
getAllServerStatuses,
handleApiError,
sshHostApi,
} from "@/main-axios";
import { getAllServerStatuses, handleApiError, sshHostApi } from "@/main-axios";
import type { SSHHost, SSHHostData, ProxyNode } from "@/types/index";
import type { ServerStatus, SSHHostWithStatus } from "@/main-axios";
import type { ProxmoxDiscoverResult, ProxmoxSyncResult } from "@/types/proxmox";
import {
getCachedSSHHosts,
invalidateHostsAndStatusCaches,
} from "@/lib/hosts-request-cache";
import { requestRemoteSync } from "@/lib/remote-sync-trigger";
import {
getConnectedRemoteApi,
markRemoteSharedHosts,
} from "@/lib/remote-server-api";
// SSH HOST MANAGEMENT
// ============================================================================
@@ -28,22 +17,7 @@ export type GetSSHHostsOptions = {
async function loadSSHHostsFromApi(): Promise<SSHHost[]> {
const hostsResponse = await sshHostApi.get("/db/host");
const localHosts = Array.isArray(hostsResponse.data)
? hostsResponse.data
: [];
const remoteApi = await getConnectedRemoteApi();
if (!remoteApi) return localHosts;
try {
const remoteResponse = await remoteApi.get("/host/db/host");
const remoteSharedHosts = Array.isArray(remoteResponse.data)
? markRemoteSharedHosts(remoteResponse.data)
: [];
return [...localHosts, ...remoteSharedHosts];
} catch {
// Keep the last locally synced host set usable while the server is offline.
return localHosts;
}
return Array.isArray(hostsResponse.data) ? hostsResponse.data : [];
}
export async function getSSHHosts(
@@ -88,12 +62,10 @@ export async function createSSHHost(hostData: SSHHostData): Promise<SSHHost> {
headers: { "Content-Type": "multipart/form-data" },
});
invalidateHostsAndStatusCaches();
void requestRemoteSync();
return response.data;
}
const response = await sshHostApi.post("/db/host", hostData);
invalidateHostsAndStatusCaches();
void requestRemoteSync();
return response.data;
} catch (error) {
throw handleApiError(error, "create SSH host");
@@ -114,27 +86,16 @@ export async function updateSSHHost(
headers: { "Content-Type": "multipart/form-data" },
});
invalidateHostsAndStatusCaches();
void requestRemoteSync();
return response.data;
}
const response = await sshHostApi.put(`/db/host/${hostId}`, hostData);
invalidateHostsAndStatusCaches();
void requestRemoteSync();
return response.data;
} catch (error) {
throw handleApiError(error, "update SSH host");
}
}
export async function wakeOnLan(hostId: number): Promise<{ success: boolean }> {
try {
const response = await sshHostApi.post(`/db/host/${hostId}/wake`);
return response.data;
} catch (error) {
throw handleApiError(error, "wake on LAN");
}
}
export async function bulkImportSSHHosts(
hosts: SSHHostData[],
overwrite = false,
@@ -183,90 +144,6 @@ export async function importSSHConfigHosts(
}
}
export async function discoverProxmoxGuests(
hostId: number,
): Promise<ProxmoxDiscoverResult> {
try {
const response = await authApi.post(
"/proxmox/discover",
{ hostId },
{ timeout: 120000 },
);
return response.data;
} catch (error) {
handleApiError(error, "discover Proxmox guests");
}
}
export function discoverProxmoxGuestsStream(
hostId: number,
handlers: {
onProgress?: (done: number, total: number) => void;
onResult: (result: ProxmoxDiscoverResult) => void;
onError: (message: string) => void;
},
): () => void {
const baseURL = (authApi.defaults.baseURL || "").replace(/\/$/, "");
const source = new EventSource(
`${baseURL}/proxmox/discover/stream?hostId=${encodeURIComponent(
String(hostId),
)}`,
{ withCredentials: true },
);
let settled = false;
const close = () => {
settled = true;
source.close();
};
source.addEventListener("progress", (event) => {
try {
const data = JSON.parse((event as MessageEvent).data);
handlers.onProgress?.(data.done, data.total);
} catch {
// ignore malformed progress frames
}
});
source.addEventListener("result", (event) => {
close();
try {
handlers.onResult(JSON.parse((event as MessageEvent).data));
} catch {
handlers.onError("Failed to parse discovery result");
}
});
source.addEventListener("fail", (event) => {
close();
let message = "Discovery failed";
try {
message = JSON.parse((event as MessageEvent).data).message || message;
} catch {
// keep default message
}
handlers.onError(message);
});
source.onerror = () => {
if (settled) return;
close();
handlers.onError("Discovery connection lost");
};
return close;
}
export async function syncProxmoxGuests(
hostId: number,
): Promise<ProxmoxSyncResult> {
try {
const response = await authApi.post(
"/proxmox/sync",
{ hostId },
{ timeout: 120000 },
);
return response.data;
} catch (error) {
handleApiError(error, "sync Proxmox guests");
}
}
export async function bulkUpdateSSHHosts(
hostIds: number[],
updates: Record<string, unknown>,
@@ -301,7 +178,6 @@ export async function deleteSSHHost(
try {
const response = await sshHostApi.delete(`/db/host/${hostId}`);
invalidateHostsAndStatusCaches();
void requestRemoteSync();
return response.data;
} catch (error) {
handleApiError(error, "delete SSH host");
@@ -355,54 +231,6 @@ export async function exportAllSSHHosts(options?: {
}
}
// ============================================================================
// SSH AUTOSTART MANAGEMENT
// ============================================================================
export async function enableAutoStart(
sshConfigId: number,
): Promise<Record<string, unknown>> {
try {
const response = await sshHostApi.post("/autostart/enable", {
sshConfigId,
});
return response.data;
} catch (error) {
handleApiError(error, "enable autostart");
}
}
export async function disableAutoStart(
sshConfigId: number,
): Promise<Record<string, unknown>> {
try {
const response = await sshHostApi.delete("/autostart/disable", {
data: { sshConfigId },
});
return response.data;
} catch (error) {
handleApiError(error, "disable autostart");
}
}
export async function getAutoStartStatus(): Promise<{
autostart_configs: Array<{
sshConfigId: number;
host: string;
port: number;
username: string;
authType: string;
}>;
total_count: number;
}> {
try {
const response = await sshHostApi.get("/autostart/status");
return response.data;
} catch (error) {
handleApiError(error, "fetch autostart status");
}
}
// ============================================================================
// PROXY CONNECTIVITY TEST
// ============================================================================
-76
View File
@@ -1,76 +0,0 @@
import { authApi, handleApiError } from "@/main-axios";
import type { SSOProvider, SSOProviderPublic } from "@/types/index";
export async function getSSOProviders(): Promise<SSOProviderPublic[]> {
try {
const response = await authApi.get("/users/sso-providers");
return response.data;
} catch (error: unknown) {
console.warn("Failed to fetch SSO providers:", error);
return [];
}
}
export async function getAdminSSOProviders(): Promise<SSOProvider[]> {
try {
const response = await authApi.get("/users/sso-providers/admin");
return Array.isArray(response.data) ? response.data : [];
} catch (error) {
handleApiError(error, "fetch admin SSO providers");
return [];
}
}
export async function createSSOProvider(
data: Omit<SSOProvider, "id" | "createdAt" | "updatedAt"> & {
config: Record<string, unknown>;
},
): Promise<SSOProvider> {
try {
const response = await authApi.post("/users/sso-providers", data);
return response.data;
} catch (error) {
handleApiError(error, "create SSO provider");
}
}
export async function updateSSOProvider(
id: number,
data: Partial<Omit<SSOProvider, "id" | "createdAt" | "updatedAt">> & {
config?: Record<string, unknown>;
},
): Promise<SSOProvider> {
try {
const response = await authApi.put(`/users/sso-providers/${id}`, data);
return response.data;
} catch (error) {
handleApiError(error, "update SSO provider");
}
}
export async function deleteSSOProvider(id: number): Promise<void> {
try {
await authApi.delete(`/users/sso-providers/${id}`);
} catch (error) {
handleApiError(error, "delete SSO provider");
}
}
export async function ldapLogin(
providerId: number,
username: string,
password: string,
rememberMe: boolean,
): Promise<{ success: boolean; message: string }> {
try {
const response = await authApi.post("/users/ldap/login", {
providerId,
username,
password,
rememberMe,
});
return response.data;
} catch (error) {
handleApiError(error, "LDAP login");
}
}
+155
View File
@@ -0,0 +1,155 @@
import { authApi } from "@/main-axios";
export type SyncState = "idle" | "syncing" | "offline" | "error" | "signed_out";
export interface SyncProxyHeader {
name: string;
value: string;
/** A saved header whose value is not sent back. */
set?: boolean;
}
export interface SyncBasicAuth {
username: string;
password: string;
set?: boolean;
}
export interface SyncEntityInfo {
type: string;
owner: string;
ownerName: string | null;
readOnly: boolean;
onServer: boolean;
onDevice: boolean;
enabled: boolean;
}
export interface SyncStatus {
linked: boolean;
serverUrl?: string;
serverName?: string | null;
serverVersion?: string | null;
account?: {
username?: string;
isAdmin?: boolean;
roles?: string[];
permissions?: string[];
};
status?: SyncState;
lastError?: string | null;
linkedAt?: string;
lastSyncAt?: string | null;
pending?: number;
conflicts?: number;
errors?: number;
firstSyncDone?: boolean;
allowInvalidCertificate?: boolean;
customHeaders?: SyncProxyHeader[];
basicAuth?: SyncBasicAuth | null;
entities: SyncEntityInfo[];
}
export type ProbeProblem =
| "invalid_url"
| "unreachable"
| "tls_untrusted"
| "basic_auth_required"
| "proxy_login"
| "not_termix"
| "version_mismatch"
| "different_account";
export interface ProbeResult {
ok: boolean;
serverUrl?: string;
name?: string;
version?: string | null;
problem?: ProbeProblem;
message?: string;
}
export interface ServerTarget {
serverUrl: string;
customHeaders?: SyncProxyHeader[];
basicAuth?: SyncBasicAuth | null;
allowInvalidCertificate?: boolean;
}
export interface SyncConflictItem {
id: number;
entityType: string;
syncId: string;
name: string;
createdAt: string;
}
export interface SyncErrorItem {
entityType: string;
syncId: string;
reason: string | null;
}
export async function getSyncStatus(): Promise<SyncStatus> {
return (await authApi.get("/sync/link/status")).data;
}
export async function probeServer(target: ServerTarget): Promise<ProbeResult> {
return (await authApi.post("/sync/link/probe", target)).data;
}
export async function getLinkPreview(): Promise<Record<string, number>> {
return (await authApi.get("/sync/link/preview")).data?.counts ?? {};
}
export async function completeLink(
input: ServerTarget & {
token: string;
mode: "merge" | "replace";
serverName?: string;
},
): Promise<SyncStatus> {
return (await authApi.post("/sync/link/complete", input)).data;
}
export async function reloginLink(token: string): Promise<SyncStatus> {
return (await authApi.post("/sync/link/relogin", { token })).data;
}
export async function unlinkServer(keepData: boolean): Promise<SyncStatus> {
return (await authApi.post("/sync/link/unlink", { keepData })).data;
}
export async function syncNow(): Promise<SyncStatus> {
return (await authApi.post("/sync/link/sync", {}, { timeout: 300_000 })).data;
}
export async function updateSyncSettings(settings: {
disabledTypes?: string[];
customHeaders?: SyncProxyHeader[];
basicAuth?: SyncBasicAuth | null;
allowInvalidCertificate?: boolean;
}): Promise<SyncStatus> {
return (await authApi.put("/sync/link/settings", settings)).data;
}
export async function getSyncConflicts(): Promise<SyncConflictItem[]> {
return (await authApi.get("/sync/link/conflicts")).data?.conflicts ?? [];
}
export async function settleSyncConflict(
id: number,
keep: "mine" | "server",
): Promise<void> {
await authApi.post(`/sync/link/conflicts/${id}`, { keep });
}
export async function getSyncErrors(): Promise<SyncErrorItem[]> {
return (await authApi.get("/sync/link/errors")).data?.errors ?? [];
}
export async function retrySyncErrors(): Promise<SyncStatus> {
return (
await authApi.post("/sync/link/errors/retry", {}, { timeout: 300_000 })
).data;
}
+1 -116
View File
@@ -1,119 +1,4 @@
import { AxiosError } from "axios";
import type { TermixAlert } from "@/types";
import {
authApi,
handleApiError,
markUserAuthenticated,
type AuthResponse,
} from "@/main-axios";
// ALERTS
// ============================================================================
export async function setupTOTP(credential?: string): Promise<{
secret: string;
qr_code: string;
additional?: boolean;
}> {
try {
const response = await authApi.post("/users/totp/setup", { credential });
return response.data;
} catch (error) {
handleApiError(error as AxiosError, "setup TOTP");
throw error;
}
}
export async function enableTOTP(
totp_code: string,
): Promise<{ message: string; backup_codes: string[] }> {
try {
const response = await authApi.post("/users/totp/enable", { totp_code });
return response.data;
} catch (error) {
handleApiError(error as AxiosError, "enable TOTP");
throw error;
}
}
export async function disableTOTP(
password?: string,
totp_code?: string,
): Promise<{ message: string }> {
try {
const response = await authApi.post("/users/totp/disable", {
password,
totp_code,
});
return response.data;
} catch (error) {
handleApiError(error as AxiosError, "disable TOTP");
throw error;
}
}
export async function verifyTOTPLogin(
temp_token: string,
totp_code: string,
rememberMe: boolean = false,
): Promise<AuthResponse> {
try {
const response = await authApi.post("/users/totp/verify-login", {
temp_token,
totp_code,
rememberMe,
});
if (response.data.success) {
markUserAuthenticated();
}
return response.data;
} catch (error) {
handleApiError(error as AxiosError, "verify TOTP login");
throw error;
}
}
export async function generateBackupCodes(
password?: string,
totp_code?: string,
): Promise<{ backup_codes: string[] }> {
try {
const response = await authApi.post("/users/totp/backup-codes", {
password,
totp_code,
});
return response.data;
} catch (error) {
handleApiError(error as AxiosError, "generate backup codes");
throw error;
}
}
export async function getUserAlerts(): Promise<{
alerts: TermixAlert[];
}> {
try {
const response = await authApi.get(`/alerts`);
return response.data;
} catch (error) {
handleApiError(error, "fetch user alerts");
throw error;
}
}
export async function dismissAlert(
alertId: string,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.post("/alerts/dismiss", { alertId });
return response.data;
} catch (error) {
handleApiError(error, "dismiss alert");
throw error;
}
}
import { authApi, handleApiError } from "@/main-axios";
// ============================================================================
// UPDATES & RELEASES
-217
View File
@@ -1,217 +0,0 @@
import { authApi, handleApiError } from "@/main-axios";
export interface TermixIdentity {
id: number;
userId: string;
handle: string;
description: string | null;
resolverPath?: string;
createdAt: string;
updatedAt: string;
}
export interface TermixIdentityKey {
id: number;
identityId: number;
userId: string;
publicKey: string;
keyType: string;
algorithm: string;
label: string | null;
comment: string | null;
source: string;
credentialId: number | null;
enabled: boolean;
createdAt: string;
}
export interface TermixIdMe {
identity: TermixIdentity | null;
keys: TermixIdentityKey[];
}
export async function getMyTermixId(): Promise<TermixIdMe> {
try {
const response = await authApi.get("/termix-id/me");
return response.data;
} catch (error) {
throw handleApiError(error, "fetch Termix ID");
}
}
export async function checkTermixIdHandle(
handle: string,
): Promise<{ available: boolean; valid: boolean }> {
try {
const response = await authApi.get(
`/termix-id/check/${encodeURIComponent(handle)}`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "check handle");
}
}
export async function createTermixId(
handle: string,
description?: string,
): Promise<TermixIdentity> {
try {
const response = await authApi.post("/termix-id", { handle, description });
return response.data;
} catch (error) {
throw handleApiError(error, "create Termix ID");
}
}
export async function updateTermixId(data: {
handle?: string;
description?: string;
}): Promise<TermixIdentity> {
try {
const response = await authApi.put("/termix-id", data);
return response.data;
} catch (error) {
throw handleApiError(error, "update Termix ID");
}
}
export async function deleteTermixId(): Promise<void> {
try {
await authApi.delete("/termix-id");
} catch (error) {
throw handleApiError(error, "delete Termix ID");
}
}
export async function addTermixIdKey(data: {
publicKey?: string;
credentialId?: number;
label?: string;
}): Promise<TermixIdentityKey> {
try {
const response = await authApi.post("/termix-id/keys", data);
return response.data;
} catch (error) {
throw handleApiError(error, "add key");
}
}
export interface GeneratedKey {
key: TermixIdentityKey;
privateKey: string;
publicKey: string;
credentialId: number | null;
}
export async function generateTermixIdKey(
type: "ed25519" | "rsa" = "ed25519",
saveCredential = true,
): Promise<GeneratedKey> {
try {
const response = await authApi.post("/termix-id/keys/generate", {
type,
saveCredential,
});
return response.data;
} catch (error) {
throw handleApiError(error, "generate key");
}
}
export async function setTermixIdKeyEnabled(
id: number,
enabled: boolean,
): Promise<TermixIdentityKey> {
try {
const response = await authApi.patch(`/termix-id/keys/${id}`, { enabled });
return response.data;
} catch (error) {
throw handleApiError(error, "update key");
}
}
export async function deleteTermixIdKey(id: number): Promise<void> {
try {
await authApi.delete(`/termix-id/keys/${id}`);
} catch (error) {
throw handleApiError(error, "delete key");
}
}
export interface TermixIdCa {
publicKey: string;
validityDays: number;
resolverPath: string;
}
export interface IssuedCertificate {
certificate: string;
keyId: string;
validBefore: number;
principals: string[];
validityDays: number;
}
export async function getMyCa(): Promise<{ ca: TermixIdCa | null }> {
try {
const response = await authApi.get("/termix-id/ca");
return response.data;
} catch (error) {
throw handleApiError(error, "fetch CA");
}
}
export async function createCa(validityDays?: number): Promise<TermixIdCa> {
try {
const response = await authApi.post("/termix-id/ca", { validityDays });
return response.data;
} catch (error) {
throw handleApiError(error, "create CA");
}
}
export async function rotateCa(validityDays?: number): Promise<TermixIdCa> {
try {
const response = await authApi.post("/termix-id/ca/rotate", {
validityDays,
});
return response.data;
} catch (error) {
throw handleApiError(error, "rotate CA");
}
}
export async function deleteCa(): Promise<void> {
try {
await authApi.delete("/termix-id/ca");
} catch (error) {
throw handleApiError(error, "delete CA");
}
}
export async function issueCertificate(
keyId: number,
opts: { principals?: string[]; validityDays?: number } = {},
): Promise<IssuedCertificate> {
try {
const response = await authApi.post(
`/termix-id/keys/${keyId}/certificate`,
opts,
);
return response.data;
} catch (error) {
throw handleApiError(error, "issue certificate");
}
}
export async function getLinkedCredentialIds(): Promise<{
credentialIds: number[];
}> {
try {
const response = await authApi.get("/termix-id/linked-credentials");
return response.data;
} catch (error) {
throw handleApiError(error, "fetch linked credentials");
}
}
+38
View File
@@ -0,0 +1,38 @@
import { authApi, handleApiError } from "@/main-axios";
export type TlsCertificateInfo = {
subject: string;
issuer: string;
names: string[];
notBefore: string;
notAfter: string;
selfSigned: boolean;
fingerprint: string;
};
export type TlsStatus = {
enabled: boolean;
certificate: TlsCertificateInfo | null;
renewal: { pluginId: string; pluginName: string } | null;
};
export async function getTlsStatus(): Promise<TlsStatus> {
try {
const response = await authApi.get("/users/tls-certificate");
return response.data;
} catch (error) {
handleApiError(error, "fetch TLS certificate status");
}
}
export async function uploadTlsCertificate(payload: {
certificate: string;
privateKey: string;
}): Promise<TlsStatus & { success: boolean; reloadMessage?: string }> {
try {
const response = await authApi.post("/users/tls-certificate", payload);
return response.data;
} catch (error) {
handleApiError(error, "upload TLS certificate");
}
}
-173
View File
@@ -1,173 +0,0 @@
import { tmuxMonitorApi } from "@/main-axios";
export interface TmuxPane {
id: string;
index: number;
pid: number;
active: boolean;
width: number;
height: number;
command: string;
path: string;
title: string;
}
export interface TmuxWindow {
index: number;
name: string;
active: boolean;
panes: TmuxPane[];
}
export interface TmuxSessionOverview {
name: string;
created: number;
lastActivity: number;
attachedClients: number;
windows: TmuxWindow[];
tags: string[];
}
export interface TmuxOverview {
available: boolean;
sessions: TmuxSessionOverview[];
}
export interface TmuxSearchMatch {
paneId: string;
sessionName: string;
windowIndex: number;
line: number;
text: string;
}
export interface TmuxPaneMetrics {
paneId: string;
sessionName: string;
pid: number;
processCount: number;
cpuPercent: number;
memRssKb: number;
gpuMemMb: number;
topCommand: string | null;
}
export async function getTmuxOverview(hostId: number): Promise<TmuxOverview> {
const response = await tmuxMonitorApi.get(`/${hostId}/overview`);
return response.data;
}
/** Select a pane's window+pane on the server so the attached terminal
* (and any other attached client) switches to it. */
export async function focusTmuxPane(
hostId: number,
paneId: string,
): Promise<void> {
await tmuxMonitorApi.post(`/${hostId}/focus`, { paneId });
}
/** Create a new detached session on the host. Starts the tmux server if
* none is running yet. */
export async function createTmuxSession(
hostId: number,
name: string,
): Promise<void> {
await tmuxMonitorApi.post(`/${hostId}/sessions`, { name });
}
/** Create a new window in an existing session. */
export async function createTmuxWindow(
hostId: number,
sessionName: string,
): Promise<void> {
await tmuxMonitorApi.post(`/${hostId}/windows`, { sessionName });
}
/** Rename a session. Saved tags follow the session to its new name. */
export async function renameTmuxSession(
hostId: number,
sessionName: string,
newName: string,
): Promise<void> {
await tmuxMonitorApi.post(`/${hostId}/rename`, { sessionName, newName });
}
/** Kill a session (terminates all of its windows and processes). */
export async function killTmuxSession(
hostId: number,
sessionName: string,
): Promise<void> {
await tmuxMonitorApi.post(`/${hostId}/kill`, { sessionName });
}
/** Kill a window and every pane in it. */
export async function killTmuxWindow(
hostId: number,
sessionName: string,
windowIndex: number,
): Promise<void> {
await tmuxMonitorApi.post(`/${hostId}/kill-window`, {
sessionName,
windowIndex,
});
}
/** Kill a single pane (last pane of a window closes the window). */
export async function killTmuxPane(
hostId: number,
paneId: string,
): Promise<void> {
await tmuxMonitorApi.post(`/${hostId}/kill-pane`, { paneId });
}
/** Split the window containing a pane: "h" adds a pane to the right,
* "v" below (tmux -h/-v semantics). */
export async function splitTmuxPane(
hostId: number,
paneId: string,
direction: "h" | "v",
): Promise<void> {
await tmuxMonitorApi.post(`/${hostId}/split`, { paneId, direction });
}
export interface TmuxSearchResult {
matches: TmuxSearchMatch[];
/** True when a search limit was hit and the results are partial. */
truncated: boolean;
searchedLines: number;
maxPanes: number;
}
export async function searchTmux(
hostId: number,
query: string,
): Promise<TmuxSearchResult> {
const response = await tmuxMonitorApi.get(`/${hostId}/search`, {
params: { q: query },
});
return {
matches: response.data.matches ?? [],
truncated: response.data.truncated ?? false,
searchedLines: response.data.searchedLines ?? 0,
maxPanes: response.data.maxPanes ?? 0,
};
}
export async function getTmuxMetrics(
hostId: number,
): Promise<TmuxPaneMetrics[]> {
const response = await tmuxMonitorApi.get(`/${hostId}/metrics`);
return response.data.panes ?? [];
}
export async function setTmuxSessionTags(
hostId: number,
sessionName: string,
tags: string[],
): Promise<string[]> {
const response = await tmuxMonitorApi.put(`/${hostId}/tags`, {
sessionName,
tags,
});
return response.data.tags ?? [];
}
-28
View File
@@ -1,28 +0,0 @@
import { authApi, handleApiError } from "@/main-axios";
import {
normalizeTouchInputSettings,
type TouchInputSettings,
} from "@/types/touch-input-settings";
export async function getTouchInputSettings(): Promise<TouchInputSettings> {
try {
const response = await authApi.get("/users/touch-input-settings");
return normalizeTouchInputSettings(response.data);
} catch (error) {
handleApiError(error, "fetch touch input settings");
}
}
export async function updateTouchInputSettings(
settings: TouchInputSettings,
): Promise<TouchInputSettings> {
try {
const response = await authApi.patch(
"/users/touch-input-settings",
settings,
);
return normalizeTouchInputSettings(response.data);
} catch (error) {
handleApiError(error, "update touch input settings");
}
}
-255
View File
@@ -1,255 +0,0 @@
import axios from "axios";
import {
authApi,
handleApiError,
tunnelApi,
getRemoteTunnelApi,
isElectron,
} from "@/main-axios";
import type {
C2STunnelPreset,
TunnelConfig,
TunnelConnection,
TunnelStatus,
} from "@/types/index";
import { streamServerSentEvents } from "./sse-stream";
import { runAdaptivePolling } from "@/lib/adaptive-polling";
// TUNNEL MANAGEMENT
// ============================================================================
//
// Tunnel status is a process-local, in-memory view (no DB lookup) so it's
// safe to read from both the embedded backend and a connected remote server
// and merge the results. connectTunnel/disconnectTunnel/cancelTunnel are
// NOT origin-routed: they resolve the target host by numeric database id
// against whichever backend receives the request, and a synced host has a
// different numeric id in each database (only its syncId matches across
// them) -- routing those calls to a remote backend would need a
// local-id-to-remote-id resolution step that doesn't exist yet. They always
// target the embedded local backend for now.
async function isRemoteSyncConnected(): Promise<boolean> {
if (!isElectron()) return false;
try {
const config = (await window.electronAPI?.invoke?.(
"get-remote-sync-config",
)) as { serverUrl?: string } | null;
return !!config?.serverUrl;
} catch {
return false;
}
}
export async function getTunnelStatuses(): Promise<
Record<string, TunnelStatus>
> {
try {
const [localResult, remoteConnected] = await Promise.all([
tunnelApi.get("/tunnel/status"),
isRemoteSyncConnected(),
]);
const localStatuses = localResult.data || {};
if (!remoteConnected) return localStatuses;
try {
const remoteResult = await getRemoteTunnelApi().get("/tunnel/status");
return { ...(remoteResult.data || {}), ...localStatuses };
} catch {
return localStatuses;
}
} catch (error) {
handleApiError(error, "fetch tunnel statuses");
}
}
export function subscribeTunnelStatuses(
onStatuses: (statuses: Record<string, TunnelStatus>) => void,
onError?: () => void,
): () => void {
const baseURL = (tunnelApi.defaults.baseURL || "").replace(/\/$/, "");
const controller = new AbortController();
let latestLocal: Record<string, TunnelStatus> = {};
let latestRemote: Record<string, TunnelStatus> = {};
let stopRemotePolling: (() => void) | null = null;
const emitMerged = () => {
// Controls target the local backend, so its state wins name collisions.
onStatuses({ ...latestRemote, ...latestLocal });
};
const waitToReconnect = () =>
new Promise<void>((resolve) => {
const onAbort = () => {
clearTimeout(timeout);
resolve();
};
const timeout = setTimeout(() => {
controller.signal.removeEventListener("abort", onAbort);
resolve();
}, 1000);
controller.signal.addEventListener("abort", onAbort, { once: true });
});
void (async () => {
while (!controller.signal.aborted) {
const headers = new Headers({ Accept: "text/event-stream" });
if (isElectron()) {
headers.set("X-Electron-App", "true");
const jwt = localStorage.getItem("jwt");
if (jwt) headers.set("Authorization", `Bearer ${jwt}`);
}
try {
await streamServerSentEvents(
`${baseURL}/tunnel/status/stream`,
{ credentials: "include", headers, signal: controller.signal },
(event) => {
if (event.event !== "statuses") return;
try {
latestLocal = JSON.parse(event.data) as Record<
string,
TunnelStatus
>;
emitMerged();
} catch {
onError?.();
}
},
);
if (!controller.signal.aborted) onError?.();
} catch (error) {
const aborted =
controller.signal.aborted ||
(error instanceof DOMException && error.name === "AbortError");
if (!aborted) onError?.();
}
if (!controller.signal.aborted) await waitToReconnect();
}
})();
// Remote tunnel status has no SSE stream exposed to the desktop app yet,
// so poll it at a modest interval when a remote server is connected.
isRemoteSyncConnected().then((connected) => {
if (!connected || controller.signal.aborted) return;
let signature = "";
stopRemotePolling = runAdaptivePolling(
async () => {
const result = await getRemoteTunnelApi().get("/tunnel/status");
const next = result.data || {};
const nextSignature = JSON.stringify(next);
const changed = nextSignature !== signature;
signature = nextSignature;
latestRemote = next;
emitMerged();
return changed;
},
{
minIntervalMs: 5000,
maxIntervalMs: 30000,
stablePollsPerStep: 3,
},
{ enabled: () => !controller.signal.aborted },
);
});
return () => {
controller.abort();
stopRemotePolling?.();
};
}
export async function getTunnelStatusByName(
tunnelName: string,
): Promise<TunnelStatus | undefined> {
const statuses = await getTunnelStatuses();
return statuses[tunnelName];
}
export async function connectTunnel(
tunnelConfig: TunnelConfig,
): Promise<Record<string, unknown>> {
try {
const response = await tunnelApi.post("/tunnel/connect", tunnelConfig);
return response.data;
} catch (error) {
handleApiError(error, "connect tunnel");
}
}
export async function disconnectTunnel(
tunnelName: string,
): Promise<Record<string, unknown>> {
try {
const response = await tunnelApi.post("/tunnel/disconnect", { tunnelName });
return response.data;
} catch (error) {
handleApiError(error, "disconnect tunnel");
}
}
export async function cancelTunnel(
tunnelName: string,
): Promise<Record<string, unknown>> {
try {
const response = await tunnelApi.post("/tunnel/cancel", { tunnelName });
return response.data;
} catch (error) {
handleApiError(error, "cancel tunnel");
}
}
export async function getC2STunnelPresets(): Promise<C2STunnelPreset[]> {
try {
const response = await authApi.get("/c2s-tunnel-presets");
return response.data || [];
} catch (error) {
if (axios.isAxiosError(error) && error.response?.status === 404) {
return [];
}
handleApiError(error, "fetch client tunnel presets");
}
}
export async function createC2STunnelPreset(data: {
name: string;
config: TunnelConnection[];
platform?: string;
computerName?: string;
}): Promise<C2STunnelPreset> {
try {
const response = await authApi.post("/c2s-tunnel-presets", data);
return response.data;
} catch (error) {
handleApiError(error, "create client tunnel preset");
}
}
export async function updateC2STunnelPreset(
id: number,
data: Partial<{
name: string;
config: TunnelConnection[];
platform: string;
computerName: string;
}>,
): Promise<C2STunnelPreset> {
try {
const response = await authApi.put(`/c2s-tunnel-presets/${id}`, data);
return response.data;
} catch (error) {
handleApiError(error, "update client tunnel preset");
}
}
export async function deleteC2STunnelPreset(
id: number,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.delete(`/c2s-tunnel-presets/${id}`);
return response.data;
} catch (error) {
handleApiError(error, "delete client tunnel preset");
}
}
// ============================================================================
+18 -71
View File
@@ -194,19 +194,6 @@ export async function adminResetUserPassword(
return response.data;
}
export async function adminDisableUserTotp(
userId: string,
): Promise<{ message: string }> {
try {
const response = await authApi.post("/users/admin/totp/disable", {
userId,
});
return response.data;
} catch (error) {
handleApiError(error, "disable user TOTP");
}
}
export async function adminExportUserData(
userId: string,
): Promise<Record<string, unknown>> {
@@ -234,20 +221,22 @@ export async function updateRegistrationAllowed(
}
}
export async function getOidcAutoProvision(): Promise<{ enabled: boolean }> {
export async function getExternalAutoProvision(): Promise<{
enabled: boolean;
}> {
try {
const response = await authApi.get("/users/oidc-auto-provision");
const response = await authApi.get("/users/external-auto-provision");
return response.data;
} catch (error) {
handleApiError(error, "check OIDC auto-provision status");
}
}
export async function updateOidcAutoProvision(
export async function updateExternalAutoProvision(
enabled: boolean,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.patch("/users/oidc-auto-provision", {
const response = await authApi.patch("/users/external-auto-provision", {
enabled,
});
return response.data;
@@ -256,28 +245,30 @@ export async function updateOidcAutoProvision(
}
}
export async function getOidcSilentLoginDefault(): Promise<{
export async function getSecondFactorAfterExternalLogin(): Promise<{
enabled: boolean;
locked?: boolean;
}> {
try {
const response = await authApi.get("/users/oidc-silent-login-default");
const response = await authApi.get(
"/users/second-factor-after-external-login",
);
return response.data;
} catch (error) {
handleApiError(error, "get OIDC silent login default");
handleApiError(error, "check second factor after external login status");
}
}
export async function updateOidcSilentLoginDefault(
export async function updateSecondFactorAfterExternalLogin(
enabled: boolean,
): Promise<{ enabled: boolean }> {
): Promise<Record<string, unknown>> {
try {
const response = await authApi.patch("/users/oidc-silent-login-default", {
enabled,
});
const response = await authApi.patch(
"/users/second-factor-after-external-login",
{ enabled },
);
return response.data;
} catch (error) {
handleApiError(error, "update OIDC silent login default");
handleApiError(error, "update second factor after external login");
}
}
@@ -316,48 +307,4 @@ export async function updatePasswordResetAllowed(
}
}
export async function getCommandHistoryEnabled(): Promise<{
enabled: boolean;
}> {
try {
const response = await authApi.get("/users/command-history-enabled");
return response.data;
} catch (error) {
handleApiError(error, "get command history enabled");
}
}
export async function updateCommandHistoryEnabled(
enabled: boolean,
): Promise<{ enabled: boolean }> {
try {
const response = await authApi.patch("/users/command-history-enabled", {
enabled,
});
return response.data;
} catch (error) {
handleApiError(error, "update command history enabled");
}
}
export async function updateOIDCConfig(
config: Record<string, unknown>,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.post("/users/oidc-config", config);
return response.data;
} catch (error) {
handleApiError(error, "update OIDC config");
}
}
export async function disableOIDCConfig(): Promise<Record<string, unknown>> {
try {
const response = await authApi.delete("/users/oidc-config");
return response.data;
} catch (error) {
handleApiError(error, "disable OIDC config");
}
}
// ============================================================================
-60
View File
@@ -1,60 +0,0 @@
import { authApi, handleApiError } from "@/main-axios";
export interface VaultProfilePayload {
name: string;
description?: string | null;
folder?: string | null;
tags?: string[];
vaultAddr: string;
vaultNamespace?: string | null;
oidcMount?: string | null;
oidcRole?: string | null;
sshMount?: string | null;
sshRole: string;
validPrincipals?: string | null;
keyType?: string | null;
shared?: boolean;
}
export async function getVaultProfiles(): Promise<Record<string, unknown>[]> {
try {
const response = await authApi.get("/vault/profiles");
return response.data;
} catch (error) {
throw handleApiError(error, "fetch vault profiles");
}
}
export async function createVaultProfile(
payload: VaultProfilePayload,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.post("/vault/profiles", payload);
return response.data;
} catch (error) {
throw handleApiError(error, "create vault profile");
}
}
export async function updateVaultProfile(
id: number,
payload: Partial<VaultProfilePayload>,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.put(`/vault/profiles/${id}`, payload);
return response.data;
} catch (error) {
throw handleApiError(error, "update vault profile");
}
}
export async function deleteVaultProfile(
id: number,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.delete(`/vault/profiles/${id}`);
return response.data;
} catch (error) {
throw handleApiError(error, "delete vault profile");
}
}
-139
View File
@@ -1,139 +0,0 @@
import {
currentTunnelHost,
resolveWebEndpointUrl,
} from "@/lib/web-endpoint-url";
import axios from "axios";
import { handleApiError, tunnelApi } from "@/main-axios";
import { isElectron } from "@/lib/electron";
import type { WebEndpoint } from "@/types/index";
/**
* Thrown when the backend rejects a web endpoint tunnel open with a specific,
* actionable reason -- most commonly a 502 carrying the real cause: SSH auth
* rejected, host unreachable, or nothing listening on the target port.
* Preserves that reason instead of letting handleApiError collapse three
* actionable errors into "Server error occurred. Please try again later."
*/
export class WebEndpointTunnelError extends Error {
constructor(
message: string,
public readonly status?: number,
) {
super(message);
this.name = "WebEndpointTunnelError";
}
}
/**
* Opens (or reuses) the SSH forward for a tunnel-access web endpoint and
* returns the port it is listening on. The port is NOT stable across opens:
* the backend closes an idle tunnel and re-binds a fresh kernel-assigned port,
* so callers must re-resolve rather than cache it.
*/
export async function openWebEndpointTunnel(
hostId: number,
endpointId: string,
): Promise<number> {
try {
// Relative to the tunnel API base, which already includes /ssh
// (getApiUrl("/ssh", 30003)). A path beginning "/ssh" here would resolve
// to /ssh/ssh/... and 404 on every call.
const response = await tunnelApi.post("/tunnel/web-endpoint/open", {
hostId,
endpointId,
});
const port = (response?.data as { port?: number } | undefined)?.port;
if (!port) {
throw new Error("The endpoint tunnel returned no port");
}
return port;
} catch (error) {
if (axios.isAxiosError(error)) {
// Typed unknown, not string: the guard below is only meaningful if the
// type does not already promise what it checks.
const data = error.response?.data as
{ error?: unknown; message?: unknown } | undefined;
const backendMessage = data?.error ?? data?.message;
// A body shaped { error: <object> } would otherwise render as
// "[object Object]". Every response on this route sends a string today,
// but a proxy or a future handler need not.
if (typeof backendMessage === "string" && backendMessage) {
throw new WebEndpointTunnelError(
backendMessage,
error.response?.status,
);
}
}
return handleApiError(error, "open web endpoint tunnel");
}
}
/**
* Registers one exact origin as allowed to present an invalid TLS certificate.
* Only meaningful on the desktop, and only for direct endpoints -- a tunnel
* endpoint's host component is loopback, which the main process already
* exempts.
*/
export async function allowInvalidCertificateForOrigin(
origin: string,
): Promise<void> {
if (!isElectron()) return;
try {
await window.electronAPI?.invoke?.(
"allow-invalid-certificate-for-origin",
origin,
);
} catch {
// The user's build may predate the handler; the load then simply fails the
// certificate check as it would have before.
}
}
/**
* A web endpoint tunnel opens through the backend's numeric host id.
* Quick-connect hosts (ids like "quick-connect-<n>") have no row on the
* server, so there is nothing to open a tunnel through -- unlike a direct
* endpoint, which never touches the backend and works regardless.
*/
export function requireNumericHostId(id: string): number {
const numericId = Number(id);
if (!Number.isInteger(numericId) || numericId <= 0) {
throw new Error(
"This endpoint needs an SSH tunnel, which requires a saved host",
);
}
return numericId;
}
/** Desktop windows use a dedicated ephemeral session, including login popups. */
export async function openWebEndpointExternally(
host: { id: string; ip: string },
endpoint: WebEndpoint,
): Promise<void> {
if (!isElectron() || !window.electronAPI?.invoke) {
throw new Error(
"Isolated windows require the desktop app. Choose Embedded in the endpoint settings.",
);
}
const localPort =
endpoint.access === "tunnel"
? await openWebEndpointTunnel(requireNumericHostId(host.id), endpoint.id)
: undefined;
const url = resolveWebEndpointUrl({
hostAddress: host.ip,
endpoint,
localPort,
tunnelHost: currentTunnelHost(true) ?? undefined,
});
const result = await window.electronAPI.invoke("open-isolated-web-endpoint", {
url,
ignoreCert: endpoint.ignoreCert === true,
});
if (
!result ||
typeof result !== "object" ||
!("success" in result) ||
result.success !== true
)
throw new Error("Failed to open isolated web endpoint");
}
-127
View File
@@ -1,127 +0,0 @@
import type {
AuthenticationResponseJSON,
PublicKeyCredentialCreationOptionsJSON,
PublicKeyCredentialRequestOptionsJSON,
RegistrationResponseJSON,
} from "@simplewebauthn/browser";
import {
browserSupportsWebAuthn,
startAuthentication,
startRegistration,
} from "@simplewebauthn/browser";
import { authApi, handleApiError } from "@/main-axios";
export type WebAuthnUserVerification = "discouraged" | "preferred" | "required";
export type WebAuthnCredentialSummary = {
id: string;
name: string;
deviceType?: string | null;
backedUp: boolean;
transports: string[];
userVerification: WebAuthnUserVerification;
createdAt: string;
lastUsedAt?: string | null;
};
type RegistrationOptionsResponse = {
options: PublicKeyCredentialCreationOptionsJSON;
challengeId: string;
};
type AuthenticationOptionsResponse = {
options: PublicKeyCredentialRequestOptionsJSON;
challengeId: string;
};
export type PasskeyLoginResult = {
success: boolean;
requires_totp?: boolean;
temp_token?: string;
is_admin?: boolean;
username?: string;
userId?: string;
is_oidc?: boolean;
totp_enabled?: boolean;
token?: string;
};
export function isPasskeySupported(): boolean {
return browserSupportsWebAuthn();
}
export async function loginWithPasskey(
username?: string,
rememberMe = false,
): Promise<PasskeyLoginResult> {
try {
const optionsResponse = await authApi.post<AuthenticationOptionsResponse>(
"/users/webauthn/authenticate/options",
username ? { username } : {},
);
const credential = await startAuthentication({
optionsJSON: optionsResponse.data.options,
});
const verifyResponse = await authApi.post<PasskeyLoginResult>(
"/users/webauthn/authenticate/verify",
{
challengeId: optionsResponse.data.challengeId,
response: credential as AuthenticationResponseJSON,
rememberMe,
},
);
return verifyResponse.data;
} catch (error) {
throw handleApiError(error, "sign in with passkey");
}
}
export async function listWebAuthnCredentials(): Promise<{
credentials: WebAuthnCredentialSummary[];
}> {
try {
const response = await authApi.get("/users/webauthn/credentials");
return response.data;
} catch (error) {
throw handleApiError(error, "list passkeys");
}
}
export async function registerWebAuthnCredential(
name: string,
userVerification: WebAuthnUserVerification,
): Promise<{ success: boolean }> {
try {
const optionsResponse = await authApi.post<RegistrationOptionsResponse>(
"/users/webauthn/register/options",
{ userVerification },
);
const credential = await startRegistration({
optionsJSON: optionsResponse.data.options,
});
const verifyResponse = await authApi.post(
"/users/webauthn/register/verify",
{
challengeId: optionsResponse.data.challengeId,
name,
response: credential as RegistrationResponseJSON,
},
);
return verifyResponse.data;
} catch (error) {
throw handleApiError(error, "register passkey");
}
}
export async function deleteWebAuthnCredential(
credentialId: string,
): Promise<{ success: boolean }> {
try {
const response = await authApi.delete(
`/users/webauthn/credentials/${credentialId}`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "delete passkey");
}
}
-125
View File
@@ -1,125 +0,0 @@
import { authApi, handleApiError } from "@/main-axios";
import type { Workspace, WorkspacePayload } from "@/types/ui-types";
export async function listWorkspaces(): Promise<Workspace[]> {
try {
const response = await authApi.get("/workspaces");
return response.data;
} catch (error) {
throw handleApiError(error, "fetch workspaces");
}
}
export async function createWorkspace(data: {
name: string;
color?: string | null;
icon?: string | null;
payload: WorkspacePayload;
}): Promise<Workspace> {
try {
const response = await authApi.post("/workspaces", data);
return response.data;
} catch (error) {
throw handleApiError(error, "create workspace");
}
}
export async function renameWorkspace(
id: number,
data: { name?: string; color?: string | null; icon?: string | null },
): Promise<Workspace> {
try {
const response = await authApi.patch(`/workspaces/${id}`, data);
return response.data;
} catch (error) {
throw handleApiError(error, "update workspace");
}
}
export async function updateWorkspaceContent(
id: number,
payload: WorkspacePayload,
): Promise<Workspace> {
try {
const response = await authApi.put(`/workspaces/${id}/content`, {
payload,
});
return response.data;
} catch (error) {
throw handleApiError(error, "update workspace content");
}
}
export async function deleteWorkspace(
id: number,
): Promise<{ success: boolean }> {
try {
const response = await authApi.delete(`/workspaces/${id}`);
return response.data;
} catch (error) {
throw handleApiError(error, "delete workspace");
}
}
export async function duplicateWorkspace(
id: number,
name: string,
): Promise<Workspace> {
try {
const response = await authApi.post(`/workspaces/${id}/duplicate`, {
name,
});
return response.data;
} catch (error) {
throw handleApiError(error, "duplicate workspace");
}
}
export async function setDefaultWorkspace(id: number): Promise<Workspace> {
try {
const response = await authApi.post(`/workspaces/${id}/set-default`);
return response.data;
} catch (error) {
throw handleApiError(error, "set default workspace");
}
}
export async function unsetDefaultWorkspace(id: number): Promise<Workspace> {
try {
const response = await authApi.post(`/workspaces/${id}/unset-default`);
return response.data;
} catch (error) {
throw handleApiError(error, "unset default workspace");
}
}
export async function applyWorkspaceServer(id: number): Promise<Workspace> {
try {
const response = await authApi.post(`/workspaces/${id}/apply`);
return response.data;
} catch (error) {
throw handleApiError(error, "apply workspace");
}
}
export async function getLastSessionWorkspace(): Promise<Workspace | null> {
try {
const response = await authApi.get("/workspaces/last-session");
return response.data;
} catch (error) {
throw handleApiError(error, "fetch last session workspace");
}
}
export async function saveLastSessionWorkspace(
payload: WorkspacePayload,
): Promise<Workspace> {
try {
const response = await authApi.put("/workspaces/last-session", {
payload,
});
return response.data;
} catch (error) {
throw handleApiError(error, "save last session workspace");
}
}