release-2.9.0 (#1532)

* fix(macos): skip single-instance lock in Mac App Store builds (#1454)

fix(macos): skip single-instance lock in Mac App Store builds

* Fix/backend optional sharp (#1455)

Fix/backend optional sharp

* feat: issue #1218 (#1218)

https://github.com/Termix-SSH/Support/issues/1218

* feat: Add option to use saved global custom theme under Connection Defaults (#1209)

https://github.com/Termix-SSH/Support/issues/1209

* feat: Add Fleet Sharing functionality to the UI (#1228)

https://github.com/Termix-SSH/Support/issues/1228

* feat: Add a list view to the Docker management page (#1237)

https://github.com/Termix-SSH/Support/issues/1237

* feat: issue #1264 (#1264)

https://github.com/Termix-SSH/Support/issues/1264

* feat: 2FA With FortiToken (#1288)

https://github.com/Termix-SSH/Support/issues/1288

* feat: Allow to disable showing paths to folders (#1274)

https://github.com/Termix-SSH/Support/issues/1274

* feat: Sync Network Graph between Desktop and Remote Server (#1245)

https://github.com/Termix-SSH/Support/issues/1245

* feat: Version Number on offline servers (#1291)

https://github.com/Termix-SSH/Support/issues/1291

* feat: Support OrbStack Docker socket path on macOS / Fix "Docker is not installed" on mac... (#1302)

https://github.com/Termix-SSH/Support/issues/1302

* feat: Sidebar host click should focus existing tab instead of opening a new connection (#1289)

https://github.com/Termix-SSH/Support/issues/1289

* fix: Search in side bar shows overlapping hosts when grouped by tags (#1303)

https://github.com/Termix-SSH/Support/issues/1303

* fix: I can't send a file on its own. (#1304)

https://github.com/Termix-SSH/Support/issues/1304

* fix: sudo password not autofilling (#1248)

https://github.com/Termix-SSH/Support/issues/1248

* fix: node-MainThread (#1300)

https://github.com/Termix-SSH/Support/issues/1300

* fix: Tunnel authentication with shared credentials (#1295)

https://github.com/Termix-SSH/Support/issues/1295

* fix: host key updates not syncing, metrics 404 race on first connect

Host key writes never bumped updatedAt so sync never picked them up.
Also retry the first metrics fetch briefly instead of failing right away.

* chore: increment ver

* feat: add category and icon fields to plugin manifest schema

* ci(deps): bump the github-actions group with 2 updates (#1458)

Bumps the github-actions group with 2 updates: [crowdin/github-action](https://github.com/crowdin/github-action) and [actions/github-script](https://github.com/actions/github-script).


Updates `crowdin/github-action` from 2 to 3
- [Release notes](https://github.com/crowdin/github-action/releases)
- [Commits](https://github.com/crowdin/github-action/compare/v2...v3)

Updates `actions/github-script` from 7 to 9
- [Release notes](https://github.com/actions/github-script/releases)
- [Commits](https://github.com/actions/github-script/compare/v7...v9)

---
updated-dependencies:
- dependency-name: crowdin/github-action
  dependency-version: '3'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: actions/github-script
  dependency-version: '9'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps-dev): bump the dev-patch-updates group with 13 updates (#1459)

Bumps the dev-patch-updates group with 13 updates:

| Package | From | To |
| --- | --- | --- |
| [@codemirror/commands](https://github.com/codemirror/commands) | `6.11.0` | `6.11.1` |
| [@codemirror/search](https://github.com/codemirror/search) | `6.7.1` | `6.7.2` |
| [@codemirror/view](https://github.com/codemirror/view) | `6.43.9` | `6.43.12` |
| [@testing-library/dom](https://github.com/testing-library/dom-testing-library) | `10.4.1` | `10.4.2` |
| [@testing-library/react](https://github.com/testing-library/react-testing-library) | `16.3.2` | `16.3.3` |
| [@testing-library/user-event](https://github.com/testing-library/user-event) | `14.6.5` | `14.6.7` |
| [@types/ssh2](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/ssh2) | `1.15.5` | `1.15.6` |
| [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) | `6.1.0` | `6.1.1` |
| [cytoscape](https://github.com/cytoscape/cytoscape.js) | `3.34.1` | `3.34.3` |
| [eslint-plugin-react-refresh](https://github.com/ArnaudBarre/eslint-plugin-react-refresh) | `0.5.4` | `0.5.7` |
| [i18next](https://github.com/i18next/i18next) | `26.4.0` | `26.4.2` |
| [prettier](https://github.com/prettier/prettier) | `3.9.6` | `3.9.8` |
| [react-i18next](https://github.com/i18next/react-i18next) | `17.0.12` | `17.0.14` |


Updates `@codemirror/commands` from 6.11.0 to 6.11.1
- [Changelog](https://github.com/codemirror/commands/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/commands/commits)

Updates `@codemirror/search` from 6.7.1 to 6.7.2
- [Changelog](https://github.com/codemirror/search/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/search/commits)

Updates `@codemirror/view` from 6.43.9 to 6.43.12
- [Changelog](https://github.com/codemirror/view/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/view/commits)

Updates `@testing-library/dom` from 10.4.1 to 10.4.2
- [Release notes](https://github.com/testing-library/dom-testing-library/releases)
- [Changelog](https://github.com/testing-library/dom-testing-library/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/dom-testing-library/compare/v10.4.1...v10.4.2)

Updates `@testing-library/react` from 16.3.2 to 16.3.3
- [Release notes](https://github.com/testing-library/react-testing-library/releases)
- [Changelog](https://github.com/testing-library/react-testing-library/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/react-testing-library/compare/v16.3.2...v16.3.3)

Updates `@testing-library/user-event` from 14.6.5 to 14.6.7
- [Release notes](https://github.com/testing-library/user-event/releases)
- [Changelog](https://github.com/testing-library/user-event/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/user-event/compare/v14.6.5...v14.6.7)

Updates `@types/ssh2` from 1.15.5 to 1.15.6
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/ssh2)

Updates `@vitejs/plugin-react` from 6.1.0 to 6.1.1
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@6.1.1/packages/plugin-react)

Updates `cytoscape` from 3.34.1 to 3.34.3
- [Release notes](https://github.com/cytoscape/cytoscape.js/releases)
- [Commits](https://github.com/cytoscape/cytoscape.js/compare/v3.34.1...v3.34.3)

Updates `eslint-plugin-react-refresh` from 0.5.4 to 0.5.7
- [Release notes](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/releases)
- [Changelog](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/compare/v0.5.4...v0.5.7)

Updates `i18next` from 26.4.0 to 26.4.2
- [Release notes](https://github.com/i18next/i18next/releases)
- [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next/compare/v26.4.0...v26.4.2)

Updates `prettier` from 3.9.6 to 3.9.8
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.9.6...3.9.8)

Updates `react-i18next` from 17.0.12 to 17.0.14
- [Changelog](https://github.com/i18next/react-i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/react-i18next/compare/v17.0.12...v17.0.14)

---
updated-dependencies:
- dependency-name: "@codemirror/commands"
  dependency-version: 6.11.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@codemirror/search"
  dependency-version: 6.7.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@codemirror/view"
  dependency-version: 6.43.12
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@testing-library/dom"
  dependency-version: 10.4.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@testing-library/react"
  dependency-version: 16.3.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@testing-library/user-event"
  dependency-version: 14.6.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@types/ssh2"
  dependency-version: 1.15.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@vitejs/plugin-react"
  dependency-version: 6.1.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: cytoscape
  dependency-version: 3.34.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: eslint-plugin-react-refresh
  dependency-version: 0.5.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: i18next
  dependency-version: 26.4.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: prettier
  dependency-version: 3.9.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: react-i18next
  dependency-version: 17.0.14
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump the prod-patch-updates group with 6 updates (#1461)

Bumps the prod-patch-updates group with 6 updates:

| Package | From | To |
| --- | --- | --- |
| [@tanstack/react-virtual](https://github.com/TanStack/virtual/tree/HEAD/packages/react-virtual) | `3.14.10` | `3.14.13` |
| [compression](https://github.com/expressjs/compression) | `1.8.1` | `1.8.2` |
| [jose](https://github.com/panva/jose) | `6.2.9` | `6.2.12` |
| [jszip](https://github.com/Stuk/jszip) | `3.10.1` | `3.10.2` |
| [socks](https://github.com/JoshGlazebrook/socks) | `2.8.9` | `2.8.10` |
| [undici](https://github.com/nodejs/undici) | `8.10.0` | `8.10.2` |


Updates `@tanstack/react-virtual` from 3.14.10 to 3.14.13
- [Release notes](https://github.com/TanStack/virtual/releases)
- [Changelog](https://github.com/TanStack/virtual/blob/main/packages/react-virtual/CHANGELOG.md)
- [Commits](https://github.com/TanStack/virtual/commits/@tanstack/react-virtual@3.14.13/packages/react-virtual)

Updates `compression` from 1.8.1 to 1.8.2
- [Release notes](https://github.com/expressjs/compression/releases)
- [Changelog](https://github.com/expressjs/compression/blob/master/HISTORY.md)
- [Commits](https://github.com/expressjs/compression/compare/v1.8.1...v1.8.2)

Updates `jose` from 6.2.9 to 6.2.12
- [Release notes](https://github.com/panva/jose/releases)
- [Changelog](https://github.com/panva/jose/blob/main/CHANGELOG.md)
- [Commits](https://github.com/panva/jose/compare/v6.2.9...v6.2.12)

Updates `jszip` from 3.10.1 to 3.10.2
- [Changelog](https://github.com/Stuk/jszip/blob/main/CHANGES.md)
- [Commits](https://github.com/Stuk/jszip/compare/v3.10.1...v3.10.2)

Updates `socks` from 2.8.9 to 2.8.10
- [Release notes](https://github.com/JoshGlazebrook/socks/releases)
- [Commits](https://github.com/JoshGlazebrook/socks/compare/2.8.9...2.8.10)

Updates `undici` from 8.10.0 to 8.10.2
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v8.10.0...v8.10.2)

---
updated-dependencies:
- dependency-name: "@tanstack/react-virtual"
  dependency-version: 3.14.13
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: compression
  dependency-version: 1.8.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: jose
  dependency-version: 6.2.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: jszip
  dependency-version: 3.10.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: socks
  dependency-version: 2.8.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: undici
  dependency-version: 8.10.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* feat: add plugin worker protocol, bootstrap and plugin_storage table

Worker-side ctx proxy plus the message envelope it talks over, and the
per-plugin key/value table behind ctx.storage with migrations for all
three dialects. Rows cascade with the plugin so uninstalling leaves
nothing behind.

* feat: add allowlisted host view for plugins

An allowlist rather than a list of fields to strip. A blocklist rots:
the moment a new secret-bearing column lands on SSHHost it starts
leaking silently. With an allowlist a new field stays invisible to
plugins until someone adds it here on purpose.

* feat: add plugin loader with manifest validation and crash restart

Validates an unpacked plugin, spawns a worker_threads worker and manages
activate/deactivate/crash-restart with backoff, disabling after three
tries.

The crash counter clears only after a plugin stays up past a stability
window, not on activation. Clearing it on activation let a plugin that
activates cleanly and then dies restart forever, because every attempt
looked like the first.

loader.ts documents what the worker boundary does and does not buy you.
Node's permission model is process-wide, not per-worker, so it cannot be
enabled for plugin workers alone and is not claimed here.

* feat: route internal events through a shared plugin event bus

Formalises three ad-hoc publish paths that already existed:
automation-events.ts, metrics/automation-bridge.ts and the
hostSessionStatus singleton, now the host.session.status topic.

Both invariants the old code relied on are preserved. Publishing stays
fire-and-forget, so a failing subscriber cannot disturb a metrics poll or
a host delete. The automations engine subscribes to the bus at scheduler
start instead of being imported ad hoc, which keeps the edge
one-directional: automations may import repositories, hosts modules must
not import automations.

* feat: add plugin ctx broker with permission gate and audit

Every ctx call a plugin makes is checked against its granted
capabilities, performed by the main thread, then audited. A capability
must be both declared in the manifest and granted in the database, so
widening a plugin's reach always needs a new manifest the user can see,
never just a database row.

Audit attribution is set by the broker, not the plugin. The plugin
supplies only the details, so it cannot forge the actor.

fix: stop plugin workers seeing plaintext credentials in error messages

A failing ctx.ssh.connect handed the worker the raw error from deep in
the SSH stack, which embeds the connect config. The worker received, in
full:

  Authentication failed for root using password <SECRET>

where <SECRET> was the host's real plaintext password. Any plugin with
ssh.exec could read it by catching the error, defeating the whole point
of the handle-based ssh API.

Errors crossing the boundary are now sanitised. Only PluginFacingError
and PluginPermissionError, both authored here, reach a plugin verbatim;
anything else becomes a generic message and the real one goes to the
log. This uses a marker class rather than matching on message text,
because matching would start leaking again the moment an upstream error
happened to contain a familiar phrase.

Covered by credential-isolation.test.ts, which plants a sentinel in
every secret-bearing field and asserts it appears in no message the
worker ever receives.

* feat: dispatch plugin HTTP routes and wire the runtime into startup

ctx.http.route now registers a real router on the /plugin-api/:pluginId
dispatcher, replacing its stub 404. A plugin never touches the Express
req/res: it gets a plain summary and returns a plain object, so sockets
and session cookies stay on this side of the boundary. Authorization and
cookie headers are not forwarded.

Plugins load last in the start-up sequence, after the server is fully
wired, and are terminated before the database on shutdown so none can
outlive it. A plugin that fails to load cannot stop the backend.

fix: correct the nginx plugin-api location regex

The pattern was ^/plugin-api/(/.*)?$, which needs a double slash and so
never matched /plugin-api/<id>/<route>. Every other block in the file
uses ^/prefix(/.*)?$. Left alone, plugin routes would work in dev and
404 only behind Docker.

* fix: update stale SFTP transfer test for single file destination paths

The test still asserted transferToHost was called with the destination
directory "/srv". Commit 7727fa09 (#1304) deliberately changed a single
file transfer to send the full destination path including the filename,
so the correct expectation is "/srv/remote-1.txt". The code was right
and the test was left behind.

* feat: ship the ssh terminal as a first-party in-process plugin

Adds a hardcoded process:transport-owner tier so the terminal can own its
WebSocket server and ssh2 clients, which cannot cross the worker boundary.

* feat: ship docker as a first-party in-process plugin

Moves docker backend and frontend into plugins/docker/, alongside
ssh-terminal. Adds a host editor tab seam, a dashboard card registry,
and a build step to compile plugin backends into dist.

* feat: ship host metrics as a first-party in-process plugin

* fix: harden plugin auth and add per-request identity for ctx calls

Require auth on /plugin-api, gate plugin enable/grant/revoke routes
behind admin.plugins.manage, and thread the calling user through
worker ctx.hosts/ctx.ssh calls instead of always using the install
owner. Also adds the plugin permissions grant/revoke UI.

* feat: ship the AI assistant as a first-party in-process plugin

Moves src/backend/ai and its routes into plugins/ai, mounted through a
small /ai dispatcher instead of its own port. Moves the ai-enabled and
ai-private-endpoints settings routes out of user-settings-routes.ts
into the plugin too, so it has no leftover core dependents.

* fix: remove compiled plugin JS left behind in plugins/ after build

copy-bundled-plugins.cjs compiled and rewrote plugin backend TS in
place, copied it to dist/plugins, but never deleted the .js it had
just emitted next to the .ts sources. Those files shadowed the real
source for vitest/tsx and had import paths rewritten for the dist
layout, so tests failed with "Cannot find module" once a local build
had run. Affects docker, host-metrics and ai, not just this one.

* feat: add a permission-gated backend service registry for plugins

* feat: add a permission-gated UI action registry and action slots for plugins

Moves the terminal toolbar's hardwired AI button into a slot the ssh-terminal
plugin offers and the ai plugin fills, gated on ai.services.use.

* feat: add cross-plugin secret references gated by the provider's RBAC permission

* feat: move proxmox discovery/import/sync into a first-party plugin

* feat: move remote desktop (RDP/VNC/Telnet) into a first-party plugin

Relocates guacamole backend/frontend source and tests into
plugins/remote-desktop, following the ssh-terminal/docker pattern.

* feat: wire remote-desktop plugin into core and rename i18n keys

Repoints core consumers (dispatcher, collab, session-sharing, tab
utils) at the plugin, adds isTabTypeAvailable gating for rdp/vnc/
telnet, widens hostCapability to accept an array, and renames the
guacamole i18n namespace to remoteDesktop.

* feat: move fleets into a first-party plugin

* feat: move automations into a first-party plugin

* feat: remove legacy alert rules, superseded by automations

Alert rules already auto-migrate into automations on boot and the old
engine stands down after. Deletes the rule/firing tables, routes, UI,
and AI tools; keeps notification channels since automations still use
them. Also fixes automations' notify step, which still called the
deleted repository.

* feat: move network topology into a first-party plugin

* feat: move tailscale device discovery and settings into a plugin

Adds plugins/tailscale/ with a devices sidebar tab, admin settings
section, and re-auth dialog. Host Metrics tailscale manager card and
SSH re-auth banner parsing stay in core.

* feat: move workspaces into a first-party plugin

* feat: move web endpoint into a first-party plugin

* feat: collapse plugin runtime to one tier behind a plugin SDK

* feat: make each plugin an npm workspace built by a termix-plugin CLI

* feat: give plugins their own tables, migrations and sync entities

* feat: serve plugin http and websockets from one mount instead of ports

* feat: namespace plugin permissions and keep them valid when a plugin is off

* feat: give plugins their own settings and one settings screen

* feat: load plugin frontends at runtime and remove plugin ids from the shell

* fix: mock the ai status fetch in its activate test

* feat: add auth extension points and one ssh connect pipeline

* feat: rebuild workspaces as the reference plugin

* fix: gate second factors after SSO/LDAP behind an admin setting

* feat: convert network-topology to a full plugin (B1)

* feat: move snippets backend and tables into a plugin (B2)

* feat: point core frontend snippets pickers at the plugin (B2)

* feat: add the snippets plugin (B2)

* feat: wire ai, automations, fleets and ssh-terminal to snippets (B2)

* feat: finish the tailscale plugin, including SSH auth (B3)

* feat: finish the fleets plugin, add ctx.hosts and host-commands SDK (B4)

* chore: update proxmox and host-metrics locale files for B5

* chore: regenerate drizzle migration journals for B5

* feat: add ctx.hosts create/update to the SDK, host-metrics drops proxmox

* feat: move proxmox node stats and collectors into the proxmox plugin

* feat: drop proxmox host columns, use plugin settings instead

* feat: finish the fleets plugin, add ctx.hosts and host-commands SDK (B4)

* feat: convert file manager into a plugin

* feat: convert ssh tunnels into a plugin

* feat: finish the web-endpoint plugin, add ctx.desktop (B8)

* feat: move the terminal fully into the ssh-terminal plugin (B9)

* feat: convert tmux monitoring into a plugin (B10)

* feat: convert serial console into the serial plugin

* feat: move session sharing and collab rooms into a plugin (B12)

* feat: turn session recording into a plugin (B13)

* feat: finish the remote-desktop plugin (B14)

* feat: finish the host-metrics plugin, move host status into core (B16)

* feat: finish the docker plugin (B15)

* feat: finish the automations plugin (B17)

* feat: finish the ai plugin (B18)

* feat: finish the homepage plugin, move service links and canvas out of core (B19)

* feat: convert wake-on-lan into a plugin (B20)

* feat: convert secret-sources into a plugin (B20)

* feat: move totp and passkeys into plugins (C1)

* feat: move sso and ldap logins into plugins (C2)

* feat: move opkssh and warpgate into plugins (C3)

* feat: move step-ca into a plugin (C4)

* feat: move vault ssh signing into a plugin (C5)

* feat: move termix identity into a plugin (C6)

* feat: move acme certificate automation into a plugin (C7)

* fix: finish 2.9.0 plugin moves and get every check green

* chore: lock the plugin boundary and remove 2.9.0 leftovers

* fix: harden the plugin runtime after the 2.9.0 security review

* feat: add plugin packing, signing, pinned trust and tmxplug bundling

* test: prove 2.8 to 2.9.0 upgrade is lossless and fix what it caught

* fix: give bundled plugin backends a real require for CJS deps

* feat: improve UI loading time and jitter

* feat: show credential sharing inline in the sidebar like host sharing

* feat: tailscale add host and copy ip, fix outbound fetch hang and plugin ESM warning

* feat: unify alerts into one plugin, channels, rules and inbox

* feat: rebuild desktop sync as linked server accounts, fix packaged plugin bundles and UI scaling

* fix: set blacksmith builder cache-key to stop broken gha cache fallback

* fix: link plugin-sdk workspace before npm ci in docker deps stage

* fix: link every plugin workspace before npm ci in docker deps stage

* fix(host-metrics): exclude image and firmware filesystems (#1469)

* fix(host-metrics): exclude image and firmware filesystems

* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* fix(hosts): normalize imported jump host references (#1470)

* fix(hosts): normalize imported jump host references

* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* fix(ci): build plugin SDK and repair MySQL settings migration (#1471)

* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* fix(file-manager): keep inline rename consistent (#1476)

* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* fix(file-manager): keep inline rename consistent

* fix(dashboard): reuse translated host status labels (#1475)

* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* fix(dashboard): reuse translated host status labels

* docs(api): clarify local and remote version fields (#1474)

* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* docs(api): clarify local and remote version fields

* fix(file-manager): preserve selected backgrounds on hover (#1473)

* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* fix(file-manager): preserve selection feedback on hover

* test(ui): await dialog focus cleanup before teardown

* fix(file-manager): restore Ctrl/Cmd+F file search (#1472)

* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* fix(file-manager): focus file search with Ctrl or Cmd F

* fix: keep rounded-full circular in production builds

* fix(file-manager): cancel uploads and stop remote writes (#1477)

* fix(file-manager): cancel uploads and stop remote writes

* test(css): exercise the configured PostCSS plugin chain

* fix(file-manager): apply compact density to directory tree (#1478)

* fix(file-manager): apply compact density to directory tree

* test(css): exercise the configured PostCSS plugin chain

* fix(file-manager): preserve font sizes in compact mode (#1480)

* fix(file-manager): preserve font sizes in compact mode

* test(css): exercise the configured PostCSS plugin chain

* fix(file-manager): prioritize file names in narrow lists (#1483)

* fix(file-manager): prioritize file names in narrow lists

* test(css): exercise the configured PostCSS plugin chain

* test(identity): preserve full Ed25519 public keys in fixtures

* test(remote-desktop): always mutate ciphertext in tamper test

* fix(remote-desktop): probe the saved host guacd endpoint (#1481)

* fix(remote-desktop): probe the saved host guacd endpoint

* test(css): exercise the configured PostCSS plugin chain

* test(identity): preserve full Ed25519 public keys in fixtures

* fix(homepage): check allowlisted private service reachability (#1479)

* fix(homepage): check allowlisted private service reachability

* test(css): exercise the configured PostCSS plugin chain

* fix(remote-desktop): stop unsolicited clipboard read prompts (#1482)

* fix(remote-desktop): read clipboard only on explicit paste

* test(css): exercise the configured PostCSS plugin chain

* test(identity): preserve full Ed25519 public keys in fixtures

* test(remote-desktop): always mutate ciphertext in tamper test

* chore: simplify plugin readmes, changelogs and manifest descriptions

* fix: keep plugins loaded after signing in from the login screen

* fix(file-manager): accept OS file drops over existing rows (#1484)

* fix(file-manager): accept OS file drops over existing rows

* test(file-manager): target the file label in drop tests

* test(css): exercise the configured PostCSS plugin chain

* test(identity): preserve full Ed25519 public keys in fixtures

* test(remote-desktop): always mutate ciphertext in tamper test

* fix(file-manager): pipeline SFTP download reads (#1485)

* feat: add plugin sdk build to beta release

* fix: make link-store secret check immune to random base64 matches

* fix(ldap): preserve the directory-provided bind DN (#1486)

* fix: compute beta changes across dev branches and move the beta tag

* chore: untrack plugin sdk ARCHITECTURE.md

* chore: remove references to plugin sdk ARCHITECTURE.md

* feat: merge host editor terminal tabs into one owned by ssh-terminal

* feat: add descriptions to plugin host enable toggles

* fix(file-manager): show download progress in file viewers (#1487)

* fix(electron): signal backend readiness over IPC (#1488)

* fix(split-screen): allow layout creation without randomUUID (#1490)

* fix(split-screen): allow layout creation without randomUUID

* test(credentials): avoid flaky Ed25519 key generation

* fix(sidebar): remeasure visible rows after density changes (#1489)

* fix(sidebar): remeasure visible rows after density changes

* test(credentials): avoid flaky Ed25519 key generation

* feat(split-screen): redesign split screen with free-form panes and pane picker

* feat(sidebar): add host click behavior setting, default to always opening a new tab

* feat(snippets): redesign panel to match the host list

* fix(session-recording): stop session logs panel from refetching in a loop

* feat(snippets): bring back sharing, target hosts and drag reorder

* feat(ssh-terminal): add toolbar position, start state, display and fade host settings

* feat(ssh-terminal): add host setting to turn off password prompt auto-fill

* fix(ai): stop streams getting cut off and keep tool steps across messages

* feat: improve quick connect system using plugin api

* fix: proxmox guest sync, startup snippet sync, drop dead cloudflare ssh transport

* feat: move terminal settings, look and ssh tools into ssh-terminal plugin

* feat: move snippets, keybinding actions and macros out of core into plugins

* feat: store rdp, vnc and telnet logins in a generic per-protocol table

* feat: type the plugin host record, move plugin strings out of core and add lint checks

* feat: replace core homepage widget registry with generic extension points

* feat: plugins only see their own host settings in the browser

* feat: harden plugin api for separate repos, signed updates and generic external login

* fix: keep plugin css below core utilities and version plugin assets by their css

* fix: load and save plugin host settings in the editor and tighten settings row spacing

* feat: redesign user host defaults system

* fix(remote-desktop): release held keys on focus loss (#1498)

* fix(session-sharing): replace local presentation after takeover (#1500)

* fix(file-manager): refresh disk usage with directory listing (#1503)

* fix(ssh-terminal): preserve input order during local echo reconciliation (#1505)

* feat(remote-desktop): expose display zoom controls for RDP (#1514)

* fix(file-manager): restore text selection and copying in Markdown previews (#1518)

* fix(sidebar): keep compact host addresses visible (#1524)

* fix(hosts): preserve plugin settings when reopening the editor (#1499)

* fix(ssh-terminal): close owner sockets when presentations unmount (#1501)

* fix(session-sharing): synchronize shared SSH terminal dimensions (#1502)

* fix(session-sharing): show participant presence in member terminals (#1504)

* fix(ai): sync provider configuration and keys across linked devices (#1506)

* feat(ssh-terminal): add a duplicate tab action (#1510)

* feat(file-manager): allow disabling move-to-trash confirmation (#1511)

* feat(file-manager): default to the remote login directory (#1512)

* feat(homepage): synchronize canvas layouts across devices (#1515)

* fix(shortcuts): prevent accidental double-Shift palette activation (#1519)

* feat(tmux-monitor): add a unified multi-host view (#1520)

* fix(electron): allow explicit quit with active connections (#1522)

* feat: reconnect all disconnected terminal tabs (#1523)

* fix(hosts): preserve recording and sharing settings in the dev editor (#1509)

* fix(database): preserve RDP domain during startup backfill (#1497)

* feat(sidebar): add keyboard type-ahead search for hosts (#1516)

* feat(automations): add per-host maintenance mode and recurring schedules (#1521)

* feat(tmux): add a per-host mouse toggle and preserve remote configuration (#1513)

* fix(session-sharing): use the linked server for desktop meetings and links (#1507)

* fix(tunnels): support TOTP for client tunnels and jump hosts (#1508)

* fix: type errors and lint failures from merged plugin prs

* feat(installer): make the Windows desktop shortcut optional (#1517)

* fix: ship the plugin sdk and serialport in desktop builds

* fix: prefer ipv4 when resolving ssh host names (#1365)

* fix: readable terminal selection with the light theme (#1319)

* fix: build desktop session share links on the linked server (#1350)

* fix: show meeting guests to members and hide the roster from guests (#1353)

* fix: reliable file manager saves with sudo prompt and no base64 guessing (#1335)

* fix: show disk usage for the mount being browsed (#1347)

* fix: load the linux tray icon reliably and log tray failures (#1332)

* fix: cover the gnome close path in the desktop quit test (#1332)

* fix: prompt for jump host TOTP in terminal, file manager and metrics (#1312)

* feat: optional auto reconnect for dropped ssh sessions (#1321)

* feat: tighter compact host rows with inline tags (#1325)

* feat: bring back the alert feed homepage widget

* feat: pick target terminals for snippets again

* feat: share button on connection tabs again

* feat: recent servers list when linking the desktop app

* feat: list the fixed app shortcuts in keyboard shortcuts

* fix: translate hardcoded toasts and drop em dashes from ui text

* fix: translate leftover hardcoded ui text

* fix: toolbar files button opens at the shell directory again

* fix: show the remote desktop note under connection origin again

* chore: openapi docs for host metrics manager routes

* chore: openapi docs for undocumented routes and fix two broken doc blocks

* fix: declare terminal.listSessions use for snippet targets

* revert: drop the alert feed widget, the notification center replaces it

* feat: restore the alert feed homepage widget

* fix: quick connect fills the host address from plugin auth editors like tailscale

* fix: docker container logs use the base background color

* chore: fix dependabot alerts and apply safe npm upgrades

* fix: proxmox stats tab reads its switch from plugin host settings

* fix: count plugin ssh logins toward host status and stop showing reachable as a warning

* fix: hide the built-in source pill in the host editor

* fix: host status shows online when the port answers and only warns on a failed login

* fix: drop the top border on the split layout menu

* fix: host defaults tabs sit where the host editor puts them and skip plugins with nothing to default

* fix: remote desktop auth method labels and notes for vnc and telnet

* fix: add description to the web endpoint enable switch

* fix: crowdin sync and pretranslate for plugin locales, run plugin tests on release

* feat: move telemetry out of core into a usage statistics plugin

* fix: recent activity card no longer cuts off host names early

* fix: remove duplicate top border on split screen menu

* fix: redesign session log player controls, fix tooltip overlap and drop dot separators

* fix: file manager and sftp text showing raw i18n keys

* fix: onboarding rerunning for existing users after upgrading

* fix: fleet detail buttons overflowing the sidebar

* fix: session log player scrolling off screen horizontally

* fix: network graph lag when adding hosts and on every render

* fix: maintenance index name, shared host read only view and badge polling

* fix: meeting tabs, workspace tab merging, rail panels as tabs and general ui lag

* chore: update release notes

* fix: remote desktop tabs reconnecting on every tab switch

* fix: build plugin sdk before linting in release workflow

* chore: lint, format, and bump version to 2.9.0

* chore: skip crowdin pre-translate in release for now

* chore: sync Crowdin translations for 2.9.0

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Koi <38512047+datlt4@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-10-01 16:20:16 -05:00
1 parent 49fa488093
commit 3643e7af97
4405 files changed
+1341026 -289722

No files matched your search

+7 -5
View File
@@ -230,11 +230,13 @@ export function AdminApiKeysSection({
<span className="text-[10px] font-mono text-muted-foreground truncate">
{key.tokenPrefix}…
</span>
<span className="text-[10px] text-muted-foreground">
{key.createdAt.split("T")[0]} ·{" "}
{key.expiresAt
? key.expiresAt.split("T")[0]
: t("admin.apiKeyNoExpiry")}
<span className="flex gap-2.5 text-[10px] text-muted-foreground">
<span>{key.createdAt.split("T")[0]}</span>
<span>
{key.expiresAt
? key.expiresAt.split("T")[0]
: t("admin.apiKeyNoExpiry")}
</span>
</span>
</div>
<Button
+1 -2
View File
@@ -26,11 +26,10 @@ const RESOURCE_TYPES = [
"user",
"host",
"credential",
"snippet",
"plugin",
"session",
"api_key",
"setting",
"tunnel",
];
type AdminAuditLogSectionProps = {
-249
View File
@@ -1,249 +0,0 @@
import { useTranslation } from "react-i18next";
import { ImageIcon } from "lucide-react";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import type {
TerminalImageStorageMode,
TerminalImageStorageSettings,
TerminalImageStorageTestResult,
} from "@/api/settings-api";
import { AccordionSection } from "./AdminSettingsShared";
const MODE_LABEL_KEYS: Record<TerminalImageStorageMode, string> = {
auto: "admin.imageStorageModeAuto",
local: "admin.imageStorageModeLocal",
"remote-sftp": "admin.imageStorageModeRemoteSftp",
};
const SELECTED_MODE_LABEL_KEYS: Record<
TerminalImageStorageTestResult["selectedMode"],
string
> = {
local: "admin.imageStorageModeLocal",
"remote-sftp": "admin.imageStorageModeRemoteSftp",
unavailable: "admin.imageStorageSelectedModeUnavailable",
};
export function AdminImageStorageSection({
open,
onToggle,
settings,
setSettings,
localDir,
setLocalDir,
instanceId,
setInstanceId,
saving,
testing,
testResult,
onSave,
onTest,
}: {
open: boolean;
onToggle: () => void;
settings: TerminalImageStorageSettings | null;
setSettings: (settings: TerminalImageStorageSettings) => void;
localDir: string;
setLocalDir: (value: string) => void;
instanceId: string;
setInstanceId: (value: string) => void;
saving: boolean;
testing: boolean;
testResult: TerminalImageStorageTestResult | null;
onSave: () => void;
onTest: () => void;
}) {
const { t } = useTranslation();
return (
<AccordionSection
label={t("admin.sectionImageStorage")}
icon={<ImageIcon className="size-3.5" />}
open={open}
onToggle={onToggle}
>
{settings === null ? (
<div className="pt-3 text-xs text-muted-foreground">
{t("common.loading")}
</div>
) : (
<div className="@container flex flex-col gap-0 pt-2">
<div className="flex flex-col gap-2 py-3 border-b border-border">
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium leading-snug">
{t("admin.imageStorageMode")}
</span>
<span className="text-xs text-muted-foreground leading-snug">
{t("admin.imageStorageModeDesc")}
</span>
</div>
<div className="flex flex-wrap gap-1">
{(Object.keys(MODE_LABEL_KEYS) as TerminalImageStorageMode[]).map(
(mode) => (
<button
key={mode}
type="button"
aria-pressed={settings.mode === mode}
onClick={() => setSettings({ ...settings, mode })}
className={`whitespace-nowrap px-2 py-1 text-[10px] font-semibold border transition-colors ${settings.mode === mode ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:text-foreground"}`}
>
{t(MODE_LABEL_KEYS[mode])}
</button>
),
)}
</div>
</div>
<div className="grid grid-cols-1 gap-x-4 gap-y-3 py-3 @md:grid-cols-2 border-b border-border">
<label className="flex flex-col gap-1 text-xs">
<span className="font-medium text-foreground">
{t("admin.imageStorageLocalDir")}
</span>
<Input
aria-label={t("admin.imageStorageLocalDir")}
value={localDir}
placeholder={t("admin.imageStorageLocalDirPlaceholder")}
onChange={(event) => setLocalDir(event.target.value)}
className="h-8 text-xs"
/>
<span className="text-muted-foreground">
{t("admin.imageStorageLocalDirDesc")}
</span>
</label>
<label className="flex flex-col gap-1 text-xs">
<span className="font-medium text-foreground">
{t("admin.imageStorageHostPath")}
</span>
<Input
aria-label={t("admin.imageStorageHostPath")}
value={settings.hostPath}
onChange={(event) =>
setSettings({ ...settings, hostPath: event.target.value })
}
className="h-8 text-xs"
/>
<span className="text-muted-foreground">
{t("admin.imageStorageHostPathDesc")}
</span>
</label>
<label className="flex flex-col gap-1 text-xs">
<span className="font-medium text-foreground">
{t("admin.imageStorageTtl")}
</span>
<Input
aria-label={t("admin.imageStorageTtl")}
type="number"
min={0}
value={settings.ttlMs}
onChange={(event) =>
setSettings({
...settings,
ttlMs: Number(event.target.value),
})
}
className="h-8 text-xs"
/>
</label>
<label className="flex flex-col gap-1 text-xs">
<span className="font-medium text-foreground">
{t("admin.imageStorageMaxCount")}
</span>
<Input
aria-label={t("admin.imageStorageMaxCount")}
type="number"
min={1}
value={settings.maxCount}
onChange={(event) =>
setSettings({
...settings,
maxCount: Number(event.target.value),
})
}
className="h-8 text-xs"
/>
</label>
<label className="flex flex-col gap-1 text-xs">
<span className="font-medium text-foreground">
{t("admin.imageStorageMaxBytes")}
</span>
<Input
aria-label={t("admin.imageStorageMaxBytes")}
type="number"
min={1048576}
value={settings.maxBytes}
onChange={(event) =>
setSettings({
...settings,
maxBytes: Number(event.target.value),
})
}
className="h-8 text-xs"
/>
</label>
</div>
<div className="flex flex-col gap-2 py-3 border-b border-border">
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium leading-snug">
{t("admin.imageStorageInstanceId")}
</span>
<span className="text-xs text-muted-foreground leading-snug">
{t("admin.imageStorageInstanceIdDesc")}
</span>
</div>
<Input
aria-label={t("admin.imageStorageInstanceId")}
value={instanceId}
onChange={(event) => setInstanceId(event.target.value)}
className="h-8 w-full @md:w-64 text-xs"
/>
</div>
{testResult && (
<div className="py-2 text-xs text-muted-foreground border-b border-border">
<div>
{testResult.connected
? t("admin.imageStorageTestConnected")
: t("admin.imageStorageTestNotConnected")}
</div>
<div>
{t("admin.imageStorageTestSelectedMode", {
mode: t(SELECTED_MODE_LABEL_KEYS[testResult.selectedMode]),
})}
</div>
{testResult.localHostVisible !== null && (
<div>
{testResult.localHostVisible
? t("admin.imageStorageTestLocalVisible")
: t("admin.imageStorageTestLocalNotVisible")}
</div>
)}
</div>
)}
<div className="flex gap-2 pt-3">
<Button
size="sm"
variant="outline"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
disabled={saving}
onClick={onSave}
>
{saving ? t("common.saving") : t("common.save")}
</Button>
<Button
size="sm"
variant="outline"
disabled={testing}
onClick={onTest}
>
{testing
? t("admin.imageStorageTesting")
: t("admin.imageStorageTest")}
</Button>
</div>
</div>
)}
</AccordionSection>
);
}
+40 -3
View File
@@ -9,6 +9,8 @@ import {
updateRole,
} from "@/main-axios";
import type { PermissionCatalogEntry, Role } from "@/main-axios";
import { pluginKey } from "@/lib/plugin-i18n";
import { PluginIcon } from "@/lib/plugin-icon";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import {
@@ -34,7 +36,7 @@ export type AdminUser = {
isOidc: boolean;
passwordHash?: string;
dataUnlocked?: boolean;
totpEnabled?: boolean;
secondFactorEnabled?: boolean;
};
export type AdminSession = {
@@ -671,8 +673,23 @@ export function AdminRolesSection({
{catalog.map((entry) => {
const wildcard = `${entry.group}.*`;
const wildcardOn = editingPermissions.has(wildcard);
// A plugin group with enabled === false is still granted,
// it just cannot be reached right now.
const offline = entry.enabled === false;
const titleFor = (permission: string) => {
const item = entry.items?.find(
(candidate) => candidate.permission === permission,
);
if (!item) return null;
return entry.pluginId
? t(pluginKey(entry.pluginId, item.titleKey))
: t(item.titleKey);
};
return (
<div key={entry.group} className="flex flex-col gap-1">
<div
key={entry.group}
className={`flex flex-col gap-1 ${offline ? "opacity-50" : ""}`}
>
<button
onClick={() => togglePermission(wildcard)}
className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-left"
@@ -684,6 +701,12 @@ export function AdminRolesSection({
<Check className="size-2 text-background" />
)}
</span>
{entry.pluginId && (
<PluginIcon
name={entry.icon}
className="size-3 shrink-0 text-muted-foreground"
/>
)}
<span
className={
wildcardOn
@@ -691,13 +714,22 @@ export function AdminRolesSection({
: "text-muted-foreground"
}
>
{entry.group}.*
{entry.label ??
(entry.labelKey
? t(entry.labelKey)
: entry.group)}
</span>
{offline && (
<span className="px-1 border border-border text-[9px] font-normal normal-case tracking-normal text-muted-foreground">
{t("admin.rolePermissions.pluginDisabled")}
</span>
)}
</button>
<div className="flex flex-col gap-0.5 pl-4">
{entry.permissions.map((permission) => {
const checked =
wildcardOn || editingPermissions.has(permission);
const title = titleFor(permission);
return (
<button
key={permission}
@@ -712,6 +744,11 @@ export function AdminRolesSection({
<Check className="size-2 text-background" />
)}
</span>
{title && (
<span className="text-foreground">
{title}
</span>
)}
<span className="font-mono text-muted-foreground">
{permission}
</span>
@@ -0,0 +1,112 @@
import { useCallback, useEffect, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { ShieldOff } from "lucide-react";
import { Button } from "@/components/button";
import { pluginKey } from "@/lib/plugin-i18n";
import {
getUserSecondFactors,
resetUserSecondFactors,
type UserSecondFactorSummary,
} from "@/api/auth-methods-api";
/**
* Every second factor a user enrolled in, including ones whose plugin is off
* (which is what locks a user out), with a reset for all of them.
*/
export function AdminSecondFactorsSection({
userId,
username,
heading,
requestConfirm,
onReset,
}: {
userId: string;
username: string;
heading: ReactNode;
requestConfirm: (message: string, onConfirm: () => void) => void;
onReset: () => void;
}) {
const { t } = useTranslation();
const [factors, setFactors] = useState<UserSecondFactorSummary[]>([]);
const [working, setWorking] = useState(false);
const load = useCallback(() => {
getUserSecondFactors(userId)
.then(setFactors)
.catch(() => setFactors([]));
}, [userId]);
useEffect(() => {
load();
}, [load]);
async function reset() {
setWorking(true);
try {
await resetUserSecondFactors(userId);
toast.success(t("admin.secondFactorsResetSuccess"));
onReset();
load();
} catch {
toast.error(t("admin.secondFactorsResetFailed"));
} finally {
setWorking(false);
}
}
return (
<div className="flex flex-col gap-2">
{heading}
{factors.length === 0 ? (
<span className="text-xs text-muted-foreground">
{t("admin.secondFactorsNone")}
</span>
) : (
<div className="flex flex-col gap-1">
{factors.map((factor) => (
<div
key={`${factor.pluginId}:${factor.factorId}`}
className="flex items-center justify-between text-xs"
>
<span>
{factor.labelKey
? t(
factor.pluginId === "core"
? factor.labelKey
: pluginKey(factor.pluginId, factor.labelKey),
{ defaultValue: factor.factorId },
)
: factor.factorId}
</span>
{!factor.available && (
<span className="text-[10px] text-destructive">
{t("admin.secondFactorUnavailable")}
</span>
)}
</div>
))}
</div>
)}
{factors.length > 0 && (
<Button
variant="outline"
size="sm"
className="h-7 w-fit text-[10px] border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
disabled={working}
onClick={() =>
requestConfirm(
t("admin.resetSecondFactorsConfirm", { username }),
() => {
void reset();
},
)
}
>
<ShieldOff className="size-3" />
{t("admin.resetSecondFactors")}
</Button>
)}
</div>
);
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
-170
View File
@@ -1,170 +0,0 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { ChevronDown, Hand } from "lucide-react";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { SettingRow } from "@/components/section-card";
import type {
TouchInputNumericKey,
TouchInputSettings,
} from "@/types/touch-input-settings";
import { TOUCH_INPUT_NUMERIC_BOUNDS } from "@/types/touch-input-settings";
import { AccordionSection, AdminToggle } from "./AdminSettingsShared";
const fields: Array<{
key: TouchInputNumericKey;
label: string;
unit: string;
}> = [
{ key: "dragThresholdPx", label: "touchDragThreshold", unit: "px" },
{ key: "maxWheelDeltaPx", label: "touchMaxWheelDelta", unit: "px" },
{
key: "momentumSampleWindowMs",
label: "touchMomentumSampleWindow",
unit: "ms",
},
{
key: "releaseGracePeriodMs",
label: "touchReleaseGracePeriod",
unit: "ms",
},
{
key: "minimumVelocityPxPerMs",
label: "touchMinimumVelocity",
unit: "px/ms",
},
{
key: "maximumVelocityPxPerMs",
label: "touchMaximumVelocity",
unit: "px/ms",
},
{ key: "maximumDurationMs", label: "touchMaximumDuration", unit: "ms" },
{ key: "maximumTravelPx", label: "touchMaximumTravel", unit: "px" },
{ key: "decayTimeMs", label: "touchDecayTime", unit: "ms" },
{ key: "pixelsPerTick", label: "touchPixelsPerTick", unit: "px" },
{
key: "maximumFrameIntervalMs",
label: "touchMaximumFrameInterval",
unit: "ms",
},
{
key: "maximumTicksPerFrame",
label: "touchMaximumTicksPerFrame",
unit: "",
},
];
export function AdminTouchInputSection({
open,
onToggle,
settings,
setSettings,
onSave,
onReset,
}: {
open: boolean;
onToggle: () => void;
settings: TouchInputSettings;
setSettings: (settings: TouchInputSettings) => void;
onSave: () => void;
onReset: () => void;
}) {
const { t } = useTranslation();
const [advancedOpen, setAdvancedOpen] = useState(false);
return (
<AccordionSection
label={t("admin.sectionTouchInput")}
icon={<Hand className="size-3.5" />}
open={open}
onToggle={onToggle}
>
<div className="flex flex-col gap-0 pt-2">
<SettingRow
label={t("admin.touchEnabled")}
description={t("admin.touchEnabledDesc")}
>
<AdminToggle
on={settings.enabled}
onToggle={() =>
setSettings({ ...settings, enabled: !settings.enabled })
}
/>
</SettingRow>
<SettingRow
label={t("admin.touchMomentumEnabled")}
description={t("admin.touchMomentumEnabledDesc")}
>
<AdminToggle
on={settings.momentumEnabled}
onToggle={() =>
setSettings({
...settings,
momentumEnabled: !settings.momentumEnabled,
})
}
disabled={!settings.enabled}
/>
</SettingRow>
<button
type="button"
className="flex items-center gap-2 py-3 text-left text-xs font-semibold text-foreground"
onClick={() => setAdvancedOpen((value) => !value)}
>
<ChevronDown
className={`size-3.5 transition-transform ${advancedOpen ? "rotate-180" : ""}`}
/>
{t("admin.touchAdvanced")}
</button>
{advancedOpen && (
<div className="grid grid-cols-1 gap-3 pb-3 sm:grid-cols-2">
{fields.map(({ key, label, unit }) => {
const bounds = TOUCH_INPUT_NUMERIC_BOUNDS[key];
return (
<label key={key} className="flex flex-col gap-1 text-xs">
<span className="font-medium text-foreground">
{t(`admin.${label}`)}
</span>
<div className="flex items-center gap-2">
<Input
aria-label={t(`admin.${label}`)}
type="number"
min={bounds.min}
max={bounds.max}
step={bounds.step}
value={settings[key]}
onChange={(event) =>
setSettings({
...settings,
[key]: Number(event.target.value),
})
}
className="h-8 text-xs"
/>
{unit && (
<span className="w-10 text-muted-foreground">{unit}</span>
)}
</div>
</label>
);
})}
</div>
)}
<div className="flex gap-2 border-t border-border pt-3">
<Button
size="sm"
variant="outline"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={onSave}
>
{t("common.save")}
</Button>
<Button size="sm" variant="outline" onClick={onReset}>
{t("admin.touchResetDefaults")}
</Button>
</div>
</div>
</AccordionSection>
);
}
+7 -7
View File
@@ -2,9 +2,9 @@ import { useEffect, useState, type Dispatch, type SetStateAction } from "react";
import { useTranslation } from "react-i18next";
import {
assignRoleToUser,
linkOIDCToPasswordAccount,
linkExternalToPasswordAccount,
removeRoleFromUser,
unlinkOIDCFromPasswordAccount,
unlinkExternalFromPasswordAccount,
} from "@/main-axios";
import type { Role, UserRole } from "@/main-axios";
import { Button } from "@/components/button";
@@ -94,7 +94,7 @@ export function AdminCreateUserDialog({
<div className="relative">
<Input
type={showNewPassword ? "text" : "password"}
placeholder="Enter password"
placeholder={t("placeholders.enterPassword")}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleCreateUser()}
@@ -245,7 +245,7 @@ export function AdminEditUserDialog({
</span>
{editUserRolesLoading ? (
<span className="text-xs text-muted-foreground">
{t("newUi.sidebar.snippets.loading")}
{t("common.loading")}
</span>
) : (
<>
@@ -412,7 +412,7 @@ export function AdminUnlinkAccountDialog({
if (!unlinkAccountTarget) return;
setSubmitting(true);
try {
await unlinkOIDCFromPasswordAccount(unlinkAccountTarget.id);
await unlinkExternalFromPasswordAccount(unlinkAccountTarget.id);
toast.success(t("admin.unlinkAccountSuccess"));
onSuccess(unlinkAccountTarget.id);
onOpenChange(false);
@@ -498,7 +498,7 @@ export function AdminLinkAccountDialog({
setSubmitting(true);
try {
if (isOidcInitiator) {
await linkOIDCToPasswordAccount(linkAccountTarget.id, trimmed);
await linkExternalToPasswordAccount(linkAccountTarget.id, trimmed);
setUsers((prev) => prev.filter((u) => u.id !== linkAccountTarget.id));
} else {
const oidcUser = users.find(
@@ -508,7 +508,7 @@ export function AdminLinkAccountDialog({
toast.error(t("admin.linkAccountOidcNotFound"));
return;
}
await linkOIDCToPasswordAccount(
await linkExternalToPasswordAccount(
oidcUser.id,
linkAccountTarget.username,
);
+43 -304
View File
@@ -1,4 +1,5 @@
import { useEffect, useState } from "react";
import { hostProtocolFlags, type HostProtocols } from "./host-protocols";
import { getUserList } from "@/main-axios";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
@@ -10,7 +11,6 @@ import {
Pencil,
Plus,
RefreshCw,
ShieldOff,
SquareTerminal,
Trash2,
TriangleAlert,
@@ -24,12 +24,7 @@ import {
adminDeleteUserHost,
adminGetUserCredentials,
adminDeleteUserCredential,
adminGetUserSnippets,
adminCreateUserSnippet,
adminUpdateUserSnippet,
adminDeleteUserSnippet,
adminResetUserPassword,
adminDisableUserTotp,
adminExportUserData,
getSessions,
revokeSession,
@@ -51,10 +46,12 @@ import {
} from "@/main-axios";
import type { Host, Credential } from "@/types/ui-types";
import { CredentialEditorView } from "./CredentialEditorView";
import { AdminSecondFactorsSection } from "./AdminSecondFactorsSection";
import { HostEditor } from "./HostEditor";
import { mapCredentials, sshHostToHost } from "./HostManagerData";
import type { AdminSession, AdminUser } from "./AdminManagementSections";
import { makeCredentialTabs, makeHostTabs, TabStrip } from "./HostManagerTabs";
import { useActionSlot } from "@/hooks/use-action-slot";
type ApiErrorLike = {
response?: {
@@ -65,16 +62,9 @@ type ApiErrorLike = {
};
};
type ManageTabId =
"account" | "hosts" | "credentials" | "snippets" | "sessions" | "danger";
type ManagedSnippet = {
id: number;
name: string;
content: string;
description?: string | null;
folder?: string | null;
};
// Core tabs, or "plugin:<actionId>" for a tab a plugin contributes to the
// admin.userTabs slot.
type ManageTabId = string;
type EditorState =
| { kind: "host"; host: Host | null }
@@ -89,33 +79,20 @@ function apiErrorMessage(error: unknown, fallback: string) {
return (error as ApiErrorLike).response?.data?.error || fallback;
}
function mapSnippets(res: unknown): ManagedSnippet[] {
const list = Array.isArray(res)
? res
: ((res as { snippets?: unknown[] })?.snippets ?? []);
return (list as Record<string, unknown>[]).map((s) => ({
id: Number(s.id),
name: String(s.name ?? ""),
content: String(s.content ?? ""),
description: (s.description as string | null) ?? null,
folder: (s.folder as string | null) ?? null,
}));
}
export function AdminUserManagePanel({
user,
roles,
onBack,
onOpenHostTab,
onUserDeleted,
onTotpDisabled,
onSecondFactorsReset,
}: {
user: AdminUser;
roles: Role[];
onBack: () => void;
onOpenHostTab?: (host: Host) => void;
onUserDeleted: () => void;
onTotpDisabled: () => void;
onSecondFactorsReset: () => void;
}) {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<ManageTabId>("account");
@@ -138,12 +115,9 @@ export function AdminUserManagePanel({
}, [user.id]);
const [editor, setEditor] = useState<EditorState>(null);
const [editorTab, setEditorTab] = useState("general");
const [editorProtocols, setEditorProtocols] = useState({
enableSsh: true,
enableRdp: false,
enableVnc: false,
enableTelnet: false,
});
const [editorProtocols, setEditorProtocols] = useState<HostProtocols>(() =>
hostProtocolFlags(null),
);
const [confirmDialog, setConfirmDialog] = useState<{
message: string;
onConfirm: () => void;
@@ -151,27 +125,14 @@ export function AdminUserManagePanel({
const [hosts, setHosts] = useState<Host[]>([]);
const [credentials, setCredentials] = useState<Credential[]>([]);
const [snippets, setSnippets] = useState<ManagedSnippet[]>([]);
const [sessions, setSessions] = useState<AdminSession[]>([]);
const [apiKeys, setApiKeys] = useState<ApiKey[]>([]);
const [userRoles, setUserRoles] = useState<UserRole[]>([]);
const [loading, setLoading] = useState(true);
// Snippet inline editor
const [editingSnippet, setEditingSnippet] = useState<
ManagedSnippet | "new" | null
>(null);
const [snippetForm, setSnippetForm] = useState({
name: "",
content: "",
folder: "",
});
const [snippetSaving, setSnippetSaving] = useState(false);
// Account tab state
const [resetPassword, setResetPassword] = useState("");
const [resetLoading, setResetLoading] = useState(false);
const [totpEnabled, setTotpEnabled] = useState(!!user.totpEnabled);
const [exportLoading, setExportLoading] = useState(false);
const [newKeyName, setNewKeyName] = useState("");
const [newKeyLoading, setNewKeyLoading] = useState(false);
@@ -196,13 +157,6 @@ export function AdminUserManagePanel({
.catch(() => {});
};
const reloadSnippets = () => {
if (!dataUnlocked) return;
adminGetUserSnippets(user.id)
.then((res) => setSnippets(mapSnippets(res)))
.catch(() => {});
};
const reloadSessions = () => {
getSessions()
.then(({ sessions: s }) =>
@@ -216,12 +170,11 @@ export function AdminUserManagePanel({
Promise.allSettled([
dataUnlocked ? adminGetUserHosts(user.id) : Promise.resolve([]),
dataUnlocked ? adminGetUserCredentials(user.id) : Promise.resolve([]),
dataUnlocked ? adminGetUserSnippets(user.id) : Promise.resolve([]),
getSessions(),
getApiKeys(),
getUserRoles(user.id),
])
.then(([h, c, s, sess, keys, ur]) => {
.then(([h, c, sess, keys, ur]) => {
if (h.status === "fulfilled" && dataUnlocked) {
setHosts(
(h.value as SSHHostWithStatus[]).map((raw) => sshHostToHost(raw)),
@@ -230,9 +183,6 @@ export function AdminUserManagePanel({
if (c.status === "fulfilled" && dataUnlocked) {
setCredentials(mapCredentials(c.value));
}
if (s.status === "fulfilled" && dataUnlocked) {
setSnippets(mapSnippets(s.value));
}
if (sess.status === "fulfilled") {
setSessions(
sess.value.sessions.filter((session) => session.userId === user.id),
@@ -248,11 +198,21 @@ export function AdminUserManagePanel({
.finally(() => setLoading(false));
}, [user.id, dataUnlocked]);
const pluginTabs = useActionSlot("admin.userTabs").filter(
(contribution) =>
contribution.kind === "component" && contribution.component,
);
const activePluginTab = pluginTabs.find(
(contribution) => `plugin:${contribution.actionId}` === activeTab,
);
const manageTabs: { id: ManageTabId; label: string }[] = [
{ id: "account", label: t("admin.manageTabAccount") },
{ id: "hosts", label: t("admin.manageTabHosts") },
{ id: "credentials", label: t("admin.manageTabCredentials") },
{ id: "snippets", label: t("admin.manageTabSnippets") },
...pluginTabs.map((contribution) => ({
id: `plugin:${contribution.actionId}`,
label: t(contribution.titleKey),
})),
{ id: "sessions", label: t("admin.manageTabSessions") },
{ id: "danger", label: t("admin.manageTabDanger") },
];
@@ -291,17 +251,6 @@ export function AdminUserManagePanel({
}
}
async function handleDisableTotp() {
try {
await adminDisableUserTotp(user.id);
setTotpEnabled(false);
onTotpDisabled();
toast.success(t("admin.totpDisabledSuccess"));
} catch (e) {
toast.error(apiErrorMessage(e, t("admin.totpDisableFailed")));
}
}
async function handleExport() {
setExportLoading(true);
try {
@@ -361,33 +310,6 @@ export function AdminUserManagePanel({
}
}
async function handleSaveSnippet() {
if (!snippetForm.name.trim() || !snippetForm.content.trim()) {
toast.error(t("admin.snippetRequiredFields"));
return;
}
setSnippetSaving(true);
try {
const payload = {
name: snippetForm.name.trim(),
content: snippetForm.content,
folder: snippetForm.folder.trim() || null,
};
if (editingSnippet !== "new" && editingSnippet) {
await adminUpdateUserSnippet(user.id, editingSnippet.id, payload);
} else {
await adminCreateUserSnippet(user.id, payload);
}
setEditingSnippet(null);
reloadSnippets();
toast.success(t("admin.snippetSaved"));
} catch (e) {
toast.error(apiErrorMessage(e, t("admin.snippetSaveFailed")));
} finally {
setSnippetSaving(false);
}
}
const lockedNotice = (
<div className="flex items-start gap-2.5 p-3 mt-2 border border-border bg-muted/20 text-xs text-muted-foreground">
<Lock className="size-3.5 shrink-0 mt-0.5" />
@@ -405,14 +327,10 @@ export function AdminUserManagePanel({
if (editor) {
const isHost = editor.kind === "host";
const tabs = isHost
? makeHostTabs(t).filter((tab) => {
if (tab.id === "general") return true;
if (tab.id === "ssh") return editorProtocols.enableSsh;
if (tab.id === "rdp") return editorProtocols.enableRdp;
if (tab.id === "vnc") return editorProtocols.enableVnc;
if (tab.id === "telnet") return editorProtocols.enableTelnet;
return false;
})
? makeHostTabs(
t,
editorProtocols as unknown as Record<string, boolean>,
).filter((tab) => tab.id !== "ssh" || editorProtocols.enableSsh)
: makeCredentialTabs(t);
return (
@@ -559,35 +477,15 @@ export function AdminUserManagePanel({
)}
</div>
{/* TOTP */}
<div className="flex flex-col gap-2">
{sectionHeading(t("admin.totpSectionTitle"))}
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">
{totpEnabled
? t("admin.totpStatusEnabled")
: t("admin.totpStatusDisabled")}
</span>
{totpEnabled && (
<Button
variant="outline"
size="sm"
className="h-7 text-[10px] border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={() =>
setConfirmDialog({
message: t("admin.disableTotpConfirm", {
username: user.username,
}),
onConfirm: handleDisableTotp,
})
}
>
<ShieldOff className="size-3" />
{t("admin.disableTotp")}
</Button>
)}
</div>
</div>
<AdminSecondFactorsSection
userId={user.id}
username={user.username}
heading={sectionHeading(t("admin.secondFactorsSectionTitle"))}
requestConfirm={(message, onConfirm) =>
setConfirmDialog({ message, onConfirm })
}
onReset={onSecondFactorsReset}
/>
{/* Roles */}
<div className="flex flex-col gap-2">
@@ -779,12 +677,7 @@ export function AdminUserManagePanel({
size="sm"
className="h-6 text-[10px] border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={() => {
setEditorProtocols({
enableSsh: true,
enableRdp: false,
enableVnc: false,
enableTelnet: false,
});
setEditorProtocols(hostProtocolFlags(null));
setEditorTab("general");
setEditor({ kind: "host", host: null });
}}
@@ -830,12 +723,7 @@ export function AdminUserManagePanel({
size="icon"
className="size-6 text-muted-foreground hover:text-foreground"
onClick={() => {
setEditorProtocols({
enableSsh: host.enableSsh,
enableRdp: host.enableRdp,
enableVnc: host.enableVnc,
enableTelnet: host.enableTelnet,
});
setEditorProtocols(hostProtocolFlags(host));
setEditorTab("general");
setEditor({ kind: "host", host });
}}
@@ -927,8 +815,9 @@ export function AdminUserManagePanel({
<span className="text-xs font-semibold truncate max-w-[180px]">
{cred.name}
</span>
<span className="text-[10px] text-muted-foreground truncate">
{cred.username || "-"} · {cred.type}
<span className="flex gap-2.5 text-[10px] text-muted-foreground min-w-0">
<span className="truncate">{cred.username || "-"}</span>
<span className="shrink-0">{cred.type}</span>
</span>
</div>
</div>
@@ -986,161 +875,11 @@ export function AdminUserManagePanel({
</div>
))}
{activeTab === "snippets" &&
{activePluginTab &&
(!dataUnlocked ? (
lockedNotice
) : (
<div className="flex flex-col">
<div className="flex items-center justify-between py-2 border-b border-border">
<span className="text-[10px] text-muted-foreground">
{t("admin.snippetsCount", { count: snippets.length })}
</span>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="size-6 text-muted-foreground hover:text-foreground"
onClick={reloadSnippets}
>
<RefreshCw className="size-3" />
</Button>
<Button
variant="outline"
size="sm"
className="h-6 text-[10px] border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={() => {
setSnippetForm({ name: "", content: "", folder: "" });
setEditingSnippet("new");
}}
>
<Plus className="size-3" />
{t("admin.addSnippetForUser")}
</Button>
</div>
</div>
{editingSnippet && (
<div className="flex flex-col gap-2.5 py-3 border-b border-border">
<Input
className="h-8 text-xs"
placeholder={t("admin.snippetNamePlaceholder")}
value={snippetForm.name}
onChange={(e) =>
setSnippetForm((p) => ({ ...p, name: e.target.value }))
}
/>
<textarea
rows={4}
placeholder={t("admin.snippetContentPlaceholder")}
value={snippetForm.content}
onChange={(e) =>
setSnippetForm((p) => ({
...p,
content: e.target.value,
}))
}
className="w-full px-3 py-2 text-[10px] bg-background border border-border text-foreground placeholder:text-muted-foreground resize-none outline-none focus:ring-1 focus:ring-ring font-mono"
/>
<Input
className="h-8 text-xs"
placeholder={t("admin.snippetFolderPlaceholder")}
value={snippetForm.folder}
onChange={(e) =>
setSnippetForm((p) => ({
...p,
folder: e.target.value,
}))
}
/>
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="sm"
className="h-6 text-[10px]"
onClick={() => setEditingSnippet(null)}
>
{t("common.cancel")}
</Button>
<Button
variant="outline"
size="sm"
className="h-6 text-[10px] border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
disabled={snippetSaving}
onClick={handleSaveSnippet}
>
{snippetSaving ? t("hosts.savingBtn") : t("common.save")}
</Button>
</div>
</div>
)}
{!loading && snippets.length === 0 && !editingSnippet && (
<span className="text-xs text-muted-foreground py-3">
{t("admin.noSnippetsForUser")}
</span>
)}
{snippets.map((snippet) => (
<div
key={snippet.id}
className="flex items-center justify-between py-2.5 border-b border-border last:border-0"
>
<div className="flex flex-col gap-0.5 min-w-0">
<span className="text-xs font-semibold truncate max-w-[180px]">
{snippet.name}
</span>
<span className="text-[10px] font-mono text-muted-foreground truncate max-w-[220px]">
{snippet.content}
</span>
</div>
<div className="flex items-center gap-0.5 shrink-0">
<Button
variant="ghost"
size="icon"
className="size-6 text-muted-foreground hover:text-foreground"
onClick={() => {
setSnippetForm({
name: snippet.name,
content: snippet.content,
folder: snippet.folder ?? "",
});
setEditingSnippet(snippet);
}}
>
<Pencil className="size-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-6 text-muted-foreground hover:text-destructive"
onClick={() =>
setConfirmDialog({
message: t("admin.deleteSnippetConfirm", {
name: snippet.name,
username: user.username,
}),
onConfirm: async () => {
try {
await adminDeleteUserSnippet(user.id, snippet.id);
setSnippets((prev) =>
prev.filter((s) => s.id !== snippet.id),
);
toast.success(t("admin.snippetDeletedSuccess"));
} catch (e) {
toast.error(
apiErrorMessage(
e,
t("admin.snippetDeleteFailed"),
),
);
}
},
})
}
>
<Trash2 className="size-3" />
</Button>
</div>
</div>
))}
</div>
<activePluginTab.component user={user} />
))}
{activeTab === "sessions" && (
-274
View File
@@ -1,274 +0,0 @@
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/dialog";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { toast } from "sonner";
import {
createAlertRule,
updateAlertRule,
type AlertRule,
type NotificationChannel,
} from "@/api/alerts-api";
const TRIGGER_TYPES = [
{ value: "host_offline", label: "Host Offline", hasThreshold: false },
{ value: "host_online", label: "Host Online", hasThreshold: false },
{ value: "cpu_threshold", label: "CPU Usage Threshold", hasThreshold: true },
{
value: "memory_threshold",
label: "Memory Usage Threshold",
hasThreshold: true,
},
{
value: "disk_threshold",
label: "Disk Usage Threshold",
hasThreshold: true,
},
{
value: "health_check_failure",
label: "Health Check Failure",
hasThreshold: false,
},
{ value: "user_login", label: "User SSH Login", hasThreshold: false },
];
interface AlertRuleDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
rule: AlertRule | null;
channels: NotificationChannel[];
onSaved: () => void;
}
export function AlertRuleDialog({
open,
onOpenChange,
rule,
channels,
onSaved,
}: AlertRuleDialogProps) {
const { t } = useTranslation();
const [name, setName] = useState("");
const [triggerType, setTriggerType] = useState("host_offline");
const [threshold, setThreshold] = useState("80");
const [durationSeconds, setDurationSeconds] = useState("0");
const [cooldownMinutes, setCooldownMinutes] = useState("15");
const [selectedChannels, setSelectedChannels] = useState<number[]>([]);
const [saving, setSaving] = useState(false);
const triggerDef = TRIGGER_TYPES.find((tt) => tt.value === triggerType);
const hasThreshold = triggerDef?.hasThreshold ?? false;
useEffect(() => {
if (!open) return;
if (rule) {
setName(rule.name);
setTriggerType(rule.triggerType);
setThreshold(
rule.thresholdValue != null ? String(rule.thresholdValue) : "80",
);
setDurationSeconds(
rule.thresholdDurationSeconds != null
? String(rule.thresholdDurationSeconds)
: "0",
);
setCooldownMinutes(String(rule.cooldownMinutes));
setSelectedChannels(rule.channelIds ?? []);
} else {
setName("");
setTriggerType("host_offline");
setThreshold("80");
setDurationSeconds("0");
setCooldownMinutes("15");
setSelectedChannels([]);
}
}, [open, rule]);
function toggleChannel(id: number) {
setSelectedChannels((prev) =>
prev.includes(id) ? prev.filter((c) => c !== id) : [...prev, id],
);
}
async function handleSave() {
if (!name.trim()) {
toast.error(t("alerts.ruleNameRequired", "Name is required"));
return;
}
setSaving(true);
try {
const payload = {
name: name.trim(),
triggerType,
thresholdValue: hasThreshold ? Number(threshold) : null,
thresholdDurationSeconds:
hasThreshold && Number(durationSeconds) > 0
? Number(durationSeconds)
: null,
cooldownMinutes: Number(cooldownMinutes) || 15,
channels: selectedChannels,
};
if (rule) {
await updateAlertRule(rule.id, payload);
} else {
await createAlertRule(payload);
}
onSaved();
} catch {
toast.error(t("alerts.ruleSaveFailed", "Failed to save rule"));
} finally {
setSaving(false);
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{rule
? t("alerts.editRule", "Edit Alert Rule")
: t("alerts.addRule", "Add Alert Rule")}
</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-3 py-2">
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("alerts.ruleName", "Rule Name")}
</label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
className="text-sm"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("alerts.triggerType", "Trigger")}
</label>
<div className="flex flex-col gap-1">
{TRIGGER_TYPES.map((tt) => (
<button
key={tt.value}
onClick={() => setTriggerType(tt.value)}
className={`flex items-center gap-2 px-2 py-1.5 text-xs border transition-colors text-left ${triggerType === tt.value ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:text-foreground"}`}
>
<span
className={`size-2 shrink-0 rounded-full border-2 ${triggerType === tt.value ? "border-accent-brand bg-accent-brand" : "border-muted-foreground"}`}
/>
{tt.label}
</button>
))}
</div>
</div>
{hasThreshold && (
<>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("alerts.thresholdValue", "Threshold (%)")}
</label>
<div className="flex items-center gap-2">
<Input
type="number"
min={1}
max={100}
value={threshold}
onChange={(e) => setThreshold(e.target.value)}
className="w-20 text-sm"
/>
<span className="text-xs text-muted-foreground">%</span>
</div>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t(
"alerts.durationSeconds",
"Duration (seconds, 0 = fire immediately)",
)}
</label>
<Input
type="number"
min={0}
value={durationSeconds}
onChange={(e) => setDurationSeconds(e.target.value)}
className="w-24 text-sm"
/>
</div>
</>
)}
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("alerts.cooldownMinutes", "Cooldown (minutes)")}
</label>
<Input
type="number"
min={0}
value={cooldownMinutes}
onChange={(e) => setCooldownMinutes(e.target.value)}
className="w-24 text-sm"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("alerts.channels", "Notification Channels")}
</label>
{channels.length === 0 ? (
<span className="text-[10px] text-muted-foreground">
{t(
"alerts.noChannelsHint",
"Add channels in the Channels tab first",
)}
</span>
) : (
<div className="flex flex-col gap-1">
{channels.map((ch) => (
<button
key={ch.id}
onClick={() => toggleChannel(ch.id)}
className={`flex items-center gap-2 px-2 py-1.5 text-xs border transition-colors text-left ${selectedChannels.includes(ch.id) ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:text-foreground"}`}
>
<span
className={`size-2 shrink-0 rounded-sm border-2 ${selectedChannels.includes(ch.id) ? "border-accent-brand bg-accent-brand" : "border-muted-foreground"}`}
/>
{ch.name}
<span className="ml-auto text-[9px] font-mono uppercase opacity-60">
{ch.type}
</span>
</button>
))}
</div>
)}
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)}>
{t("common.cancel", "Cancel")}
</Button>
<Button
variant="outline"
onClick={handleSave}
disabled={saving}
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
>
{saving
? t("common.saving", "Saving...")
: t("common.save", "Save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
-471
View File
@@ -1,471 +0,0 @@
import { useState, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import {
Bell,
BellOff,
Check,
CheckCheck,
Trash2,
Plus,
Settings2,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/button";
import {
getAlertFirings,
acknowledgeAlertFiring,
acknowledgeAllAlertFirings,
getAlertRules,
getNotificationChannels,
deleteAlertRule,
deleteNotificationChannel,
testNotificationChannel,
type AlertFiring,
type AlertRule,
type NotificationChannel,
} from "@/api/alerts-api";
import { AlertRuleDialog } from "./AlertRuleDialog";
import { NotificationChannelDialog } from "./NotificationChannelDialog";
type PanelTab = "firings" | "rules" | "channels";
const SEVERITY_CLASS: Record<string, string> = {
info: "bg-blue-500/20 text-blue-400 border-blue-500/30",
warning: "bg-yellow-500/20 text-yellow-400 border-yellow-500/30",
critical: "bg-red-500/20 text-red-400 border-red-500/30",
};
function timeAgo(isoStr: string): string {
const ms = Date.now() - new Date(isoStr).getTime();
if (!Number.isFinite(ms)) return "";
const sec = Math.floor(ms / 1000);
if (sec < 60) return `${sec}s ago`;
const min = Math.floor(sec / 60);
if (min < 60) return `${min}m ago`;
const hr = Math.floor(min / 60);
if (hr < 24) return `${hr}h ago`;
return `${Math.floor(hr / 24)}d ago`;
}
export function AlertsPanel() {
const { t } = useTranslation();
const [tab, setTab] = useState<PanelTab>("firings");
// Firings state
const [firings, setFirings] = useState<AlertFiring[]>([]);
const [firingsLoading, setFiringsLoading] = useState(true);
const [showAcknowledged, setShowAcknowledged] = useState(false);
// Rules state
const [rules, setRules] = useState<AlertRule[]>([]);
const [rulesLoading, setRulesLoading] = useState(false);
const [ruleDialogOpen, setRuleDialogOpen] = useState(false);
const [editRule, setEditRule] = useState<AlertRule | null>(null);
// Channels state
const [channels, setChannels] = useState<NotificationChannel[]>([]);
const [channelsLoading, setChannelsLoading] = useState(false);
const [channelDialogOpen, setChannelDialogOpen] = useState(false);
const [editChannel, setEditChannel] = useState<NotificationChannel | null>(
null,
);
const loadFirings = useCallback(() => {
setFiringsLoading(true);
getAlertFirings({ limit: 100 })
.then(setFirings)
.catch(() => {})
.finally(() => setFiringsLoading(false));
}, []);
const loadRules = useCallback(() => {
setRulesLoading(true);
getAlertRules()
.then(setRules)
.catch(() => {})
.finally(() => setRulesLoading(false));
}, []);
const loadChannels = useCallback(() => {
setChannelsLoading(true);
getNotificationChannels()
.then(setChannels)
.catch(() => {})
.finally(() => setChannelsLoading(false));
}, []);
useEffect(() => {
loadFirings();
loadChannels();
}, [loadFirings, loadChannels]);
useEffect(() => {
if (tab === "rules") loadRules();
if (tab === "channels") loadChannels();
}, [tab, loadRules, loadChannels]);
const visibleFirings = firings.filter((f) =>
showAcknowledged ? true : !f.acknowledged,
);
async function handleAck(id: number) {
try {
await acknowledgeAlertFiring(id);
setFirings((prev) =>
prev.map((f) => (f.id === id ? { ...f, acknowledged: true } : f)),
);
} catch {
toast.error(t("alerts.ackFailed", "Failed to acknowledge alert"));
}
}
async function handleAckAll() {
try {
await acknowledgeAllAlertFirings();
setFirings((prev) => prev.map((f) => ({ ...f, acknowledged: true })));
toast.success(t("alerts.allAcknowledged", "All alerts acknowledged"));
} catch {
toast.error(t("alerts.ackAllFailed", "Failed to acknowledge all alerts"));
}
}
async function handleDeleteRule(id: number) {
if (!confirm(t("common.confirmDelete", "Delete this rule?"))) return;
try {
await deleteAlertRule(id);
setRules((prev) => prev.filter((r) => r.id !== id));
toast.success(t("common.deleted", "Deleted"));
} catch {
toast.error(t("common.deleteFailed", "Delete failed"));
}
}
async function handleDeleteChannel(id: number) {
if (!confirm(t("common.confirmDelete", "Delete this channel?"))) return;
try {
await deleteNotificationChannel(id);
setChannels((prev) => prev.filter((c) => c.id !== id));
toast.success(t("common.deleted", "Deleted"));
} catch {
toast.error(t("common.deleteFailed", "Delete failed"));
}
}
async function handleTestChannel(id: number) {
try {
await testNotificationChannel(id);
toast.success(t("alerts.testSent", "Test notification sent"));
} catch (err) {
const detail = err instanceof Error ? err.message : undefined;
toast.error(
detail
? t(
"alerts.testFailedWithReason",
"Test notification failed: {{reason}}",
{ reason: detail },
)
: t("alerts.testFailed", "Test notification failed"),
);
}
}
const unreadCount = firings.filter((f) => !f.acknowledged).length;
return (
<div className="flex flex-col h-full">
{/* Tab strip */}
<div className="flex shrink-0 border-b border-border">
{(["firings", "rules", "channels"] as PanelTab[]).map((t_) => (
<button
key={t_}
onClick={() => setTab(t_)}
className={`flex-1 py-2 text-xs font-semibold uppercase tracking-widest transition-colors ${
tab === t_
? "border-b-2 border-accent-brand text-accent-brand"
: "text-muted-foreground hover:text-foreground"
}`}
>
{t_ === "firings"
? t("alerts.tabFirings", "Alerts")
: t_ === "rules"
? t("alerts.tabRules", "Rules")
: t("alerts.tabChannels", "Channels")}
{t_ === "firings" && unreadCount > 0 && (
<span className="ml-1.5 rounded-full bg-destructive px-1 text-[9px] font-bold text-white">
{unreadCount}
</span>
)}
</button>
))}
</div>
{/* Firings tab */}
{tab === "firings" && (
<div className="flex flex-col flex-1 min-h-0">
<div className="flex items-center gap-2 px-3 py-2 border-b border-border shrink-0">
<button
onClick={() => setShowAcknowledged((v) => !v)}
className={`text-[10px] font-semibold px-2 py-1 border transition-colors ${showAcknowledged ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:text-foreground"}`}
>
{showAcknowledged
? t("alerts.hideAcknowledged", "Hide Acked")
: t("alerts.showAcknowledged", "Show All")}
</button>
<div className="flex-1" />
{unreadCount > 0 && (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs text-muted-foreground"
onClick={handleAckAll}
>
<CheckCheck className="size-3 mr-1" />
{t("alerts.ackAll", "Ack All")}
</Button>
)}
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-muted-foreground"
onClick={loadFirings}
title={t("common.refresh", "Refresh")}
>
<Settings2 className="size-3" />
</Button>
</div>
<div className="flex-1 min-h-0 overflow-y-auto">
{firingsLoading && (
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
{t("common.loading", "Loading...")}
</div>
)}
{!firingsLoading && visibleFirings.length === 0 && (
<div className="flex flex-col items-center justify-center py-12 gap-2 text-muted-foreground">
<BellOff className="size-8 opacity-40" />
<span className="text-xs">
{t("alerts.noFirings", "No unacknowledged alerts")}
</span>
</div>
)}
{!firingsLoading &&
visibleFirings.map((firing) => (
<div
key={firing.id}
className={`flex flex-col gap-1 px-3 py-2.5 border-b border-border transition-colors ${firing.acknowledged ? "opacity-50" : ""}`}
>
<div className="flex items-center gap-2">
<span
className={`shrink-0 text-[9px] font-bold uppercase px-1.5 py-0.5 border ${SEVERITY_CLASS[firing.severity] ?? "bg-muted text-muted-foreground border-border"}`}
>
{firing.severity}
</span>
<span className="flex-1 truncate text-xs font-semibold">
{firing.hostName}
</span>
{!firing.acknowledged && (
<button
onClick={() => handleAck(firing.id)}
className="shrink-0 text-muted-foreground hover:text-foreground transition-colors"
title={t("alerts.acknowledge", "Acknowledge")}
>
<Check className="size-3.5" />
</button>
)}
</div>
<span className="text-[11px] text-foreground/80">
{firing.message}
</span>
<div className="flex items-center gap-2 text-[10px] text-muted-foreground">
{firing.ruleName && <span>{firing.ruleName}</span>}
<span>{timeAgo(firing.firedAt)}</span>
</div>
</div>
))}
</div>
</div>
)}
{/* Rules tab */}
{tab === "rules" && (
<div className="flex flex-col flex-1 min-h-0">
<div className="flex items-center gap-2 px-3 py-2 border-b border-border shrink-0">
<span className="flex-1 text-xs text-muted-foreground">
{t("alerts.rulesDesc", "Alert rules trigger notifications")}
</span>
<Button
variant="outline"
size="sm"
className="h-6 px-2 text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={() => {
setEditRule(null);
setRuleDialogOpen(true);
}}
>
<Plus className="size-3 mr-1" />
{t("common.add", "Add")}
</Button>
</div>
<div className="flex-1 min-h-0 overflow-y-auto">
{rulesLoading && (
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
{t("common.loading", "Loading...")}
</div>
)}
{!rulesLoading && rules.length === 0 && (
<div className="flex flex-col items-center justify-center py-12 gap-2 text-muted-foreground">
<Bell className="size-8 opacity-40" />
<span className="text-xs">
{t("alerts.noRules", "No alert rules configured")}
</span>
</div>
)}
{!rulesLoading &&
rules.map((rule) => (
<div
key={rule.id}
className="flex items-center gap-2 px-3 py-2.5 border-b border-border"
>
<div className="flex-1 min-w-0">
<div className="text-xs font-semibold truncate">
{rule.name}
</div>
<div className="text-[10px] text-muted-foreground">
{(rule.triggerType || "").replace(/_/g, " ")}
</div>
</div>
<div
className={`size-2 shrink-0 rounded-full ${rule.enabled ? "bg-green-500" : "bg-muted"}`}
/>
<button
onClick={() => {
setEditRule(rule);
setRuleDialogOpen(true);
}}
className="shrink-0 text-muted-foreground hover:text-foreground"
title={t("common.edit", "Edit")}
>
<Settings2 className="size-3.5" />
</button>
<button
onClick={() => handleDeleteRule(rule.id)}
className="shrink-0 text-muted-foreground hover:text-destructive"
title={t("common.delete", "Delete")}
>
<Trash2 className="size-3.5" />
</button>
</div>
))}
</div>
</div>
)}
{/* Channels tab */}
{tab === "channels" && (
<div className="flex flex-col flex-1 min-h-0">
<div className="flex items-center gap-2 px-3 py-2 border-b border-border shrink-0">
<span className="flex-1 text-xs text-muted-foreground">
{t("alerts.channelsDesc", "Where alert notifications are sent")}
</span>
<Button
variant="outline"
size="sm"
className="h-6 px-2 text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={() => {
setEditChannel(null);
setChannelDialogOpen(true);
}}
>
<Plus className="size-3 mr-1" />
{t("common.add", "Add")}
</Button>
</div>
<div className="flex-1 min-h-0 overflow-y-auto">
{channelsLoading && (
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
{t("common.loading", "Loading...")}
</div>
)}
{!channelsLoading && channels.length === 0 && (
<div className="flex flex-col items-center justify-center py-12 gap-2 text-muted-foreground">
<Bell className="size-8 opacity-40" />
<span className="text-xs">
{t(
"alerts.noChannels",
"No notification channels configured",
)}
</span>
</div>
)}
{!channelsLoading &&
channels.map((channel) => (
<div
key={channel.id}
className="flex items-center gap-2 px-3 py-2.5 border-b border-border"
>
<div className="flex-1 min-w-0">
<div className="text-xs font-semibold truncate">
{channel.name}
</div>
<div className="text-[10px] text-muted-foreground font-mono uppercase">
{channel.type}
</div>
</div>
<div
className={`size-2 shrink-0 rounded-full ${channel.enabled ? "bg-green-500" : "bg-muted"}`}
/>
<button
onClick={() => handleTestChannel(channel.id)}
className="shrink-0 text-[10px] font-semibold text-muted-foreground hover:text-foreground border border-border px-1.5 py-0.5"
title={t("alerts.test", "Test")}
>
{t("alerts.test", "Test")}
</button>
<button
onClick={() => {
setEditChannel(channel);
setChannelDialogOpen(true);
}}
className="shrink-0 text-muted-foreground hover:text-foreground"
title={t("common.edit", "Edit")}
>
<Settings2 className="size-3.5" />
</button>
<button
onClick={() => handleDeleteChannel(channel.id)}
className="shrink-0 text-muted-foreground hover:text-destructive"
title={t("common.delete", "Delete")}
>
<Trash2 className="size-3.5" />
</button>
</div>
))}
</div>
</div>
)}
<AlertRuleDialog
open={ruleDialogOpen}
onOpenChange={setRuleDialogOpen}
rule={editRule}
channels={channels}
onSaved={() => {
setRuleDialogOpen(false);
loadRules();
}}
/>
<NotificationChannelDialog
open={channelDialogOpen}
onOpenChange={setChannelDialogOpen}
channel={editChannel}
onSaved={() => {
setChannelDialogOpen(false);
loadChannels();
}}
/>
</div>
);
}
+237 -235
View File
@@ -1,7 +1,6 @@
import { useState, useEffect } from "react";
import { useState, useEffect, type ReactElement } from "react";
import { useTranslation } from "react-i18next";
import {
Bell,
Check,
LogOut,
PanelRight,
@@ -11,37 +10,24 @@ import {
SquareArrowOutUpRight,
User,
} from "lucide-react";
import type { SplitMode, TabType, ToolsTab } from "@/types/ui-types";
import { getAlertFirings } from "@/api/alerts-api";
import { isElectron } from "@/lib/electron";
import type { TabType, ToolsTab } from "@/types/ui-types";
import { Skeleton } from "@/components/skeleton";
import { readRailPreference, setRailPreference } from "./rail-preferences";
import { visibleRailItems } from "./rail-items";
import { useAiAvailability } from "@/hooks/use-ai-availability";
import { useRailItems, type RailItemDef } from "./rail-items";
import { RailBadge } from "./RailBadge";
import { rem } from "@/lib/rem";
export type RailView =
/** Core rail views; plugins add their own ids at runtime. */
export type CoreRailView =
| "hosts"
| "credentials"
| "port-forwarding"
| "sftp"
| "termix-id"
| "quick-connect"
| "serial"
| ToolsTab
| "connections"
| "session-logs"
| "user-profile"
| "admin-settings"
| "alerts"
| "automations"
| "ai"
| "fleets"
| "workspaces"
| "collab";
| "admin-settings";
export type HideableRailView =
| Exclude<RailView, "user-profile" | "admin-settings">
| "network_graph"
| "homepage";
export type RailView = CoreRailView | (string & {});
type RailItem =
| {
@@ -49,20 +35,26 @@ type RailItem =
view: RailView;
icon: React.ReactNode;
title: string;
dot?: boolean;
promotable?: boolean;
rightDockable?: boolean;
useBadge?: () => number | null | undefined;
}
| {
kind: "tab";
tabType: TabType;
icon: React.ReactNode;
title: string;
useBadge?: () => number | null | undefined;
}
| { kind: "tab"; tabType: TabType; icon: React.ReactNode; title: string }
| { kind: "separator" };
function buildRailButtons(
splitMode: SplitMode,
items: RailItemDef[],
t: (key: string) => string,
hidden: Set<string>,
): RailItem[] {
const all: RailItem[] = [];
for (const item of visibleRailItems()) {
for (const item of items) {
const Icon = item.icon;
if (item.kind === "tab") {
all.push({
@@ -70,15 +62,16 @@ function buildRailButtons(
tabType: item.id as TabType,
icon: <Icon size={16} />,
title: t(item.labelKey),
useBadge: item.useBadge,
});
} else {
all.push({
view: item.id as RailView,
icon: <Icon size={16} />,
title: t(item.labelKey),
dot: item.id === "split-screen" ? splitMode !== "none" : undefined,
promotable: item.promotable,
rightDockable: item.rightDockable,
useBadge: item.useBadge,
});
}
if (item.separatorAfter) all.push({ kind: "separator" });
@@ -107,14 +100,14 @@ function buildRailButtons(
const btnBase =
"relative flex items-center h-7 rounded shrink-0 transition-colors gap-2.5";
const btnStyle = { margin: "0 4px", padding: "0 8px" };
const btnStyle = { margin: `0 ${rem(4)}`, padding: `0 ${rem(8)}` };
export function AppRail({
railView,
sidebarOpen,
splitMode,
username,
isAdmin,
pluginsSettled = true,
onRailClick,
onOpenTab,
onOpenInRightDock,
@@ -122,9 +115,10 @@ export function AppRail({
}: {
railView: RailView;
sidebarOpen: boolean;
splitMode: SplitMode;
username: string;
isAdmin: boolean;
/** False while plugins are still registering their rail items. */
pluginsSettled?: boolean;
onRailClick: (view: RailView) => void;
onOpenTab?: (type: TabType) => void;
onOpenInRightDock?: (view: RailView) => void;
@@ -148,49 +142,6 @@ export function AppRail({
promotable?: boolean;
rightDockable?: boolean;
} | null>(null);
const [unreadAlerts, setUnreadAlerts] = useState(0);
useEffect(() => {
let cancelled = false;
let intervalId: ReturnType<typeof setInterval> | null = null;
const poll = () => {
if (document.visibilityState === "hidden") return;
getAlertFirings({ acknowledged: false, limit: 50 })
.then((firings) => {
if (!cancelled) setUnreadAlerts(firings.length);
})
.catch(() => {});
};
const start = () => {
if (intervalId !== null) return;
intervalId = setInterval(poll, 30000);
};
const stop = () => {
if (intervalId === null) return;
clearInterval(intervalId);
intervalId = null;
};
const onVisibility = () => {
if (document.visibilityState === "hidden") {
stop();
return;
}
poll();
start();
};
poll();
if (document.visibilityState !== "hidden") start();
document.addEventListener("visibilitychange", onVisibility);
return () => {
cancelled = true;
stop();
document.removeEventListener("visibilitychange", onVisibility);
};
}, []);
const [hiddenTabs, setHiddenTabs] = useState<Set<string>>(() => {
try {
const stored = localStorage.getItem("hiddenRailTabs");
@@ -240,7 +191,8 @@ export function AppRail({
};
}, [menuPos]);
const { userEnabled: aiEnabled } = useAiAvailability();
// Plugins add and remove rail items at runtime, and permissions hide some.
const railItems = useRailItems();
useEffect(() => {
const handler = () => {
@@ -255,48 +207,15 @@ export function AppRail({
return () => window.removeEventListener("hiddenRailTabsChanged", handler);
}, []);
// Termix ID publishes SSH public keys under a claimed public handle for
// other servers to fetch -- meaningless for a standalone desktop install
// with no synced multi-device account, so it stays hidden until a remote
// server is actually connected.
const [isRemoteSyncConnected, setIsRemoteSyncConnected] = useState(
() => !isElectron(),
);
useEffect(() => {
if (!isElectron()) return;
let cancelled = false;
const refreshSyncStatus = () => {
window.electronAPI
?.invoke?.("get-remote-sync-config")
.then((config) => {
if (!cancelled) {
setIsRemoteSyncConnected(
!!(config as { serverUrl?: string } | null)?.serverUrl,
);
}
})
.catch(() => {});
};
refreshSyncStatus();
const unsubscribe = window.electronAPI?.onRemoteSyncStatusChanged?.(() =>
refreshSyncStatus(),
);
return () => {
cancelled = true;
unsubscribe?.();
};
}, []);
const railExpanded = pinned || (expandOnHover && hovered);
const effectiveHiddenTabs = new Set([
...hiddenTabs,
...(isRemoteSyncConnected ? [] : ["termix-id"]),
// The assistant is hidden until an admin has enabled it instance-wide and
// the user has said yes, so someone who declined never sees the entry.
...(aiEnabled ? [] : ["ai"]),
]);
const railButtons = buildRailButtons(splitMode, t, effectiveHiddenTabs);
const railButtons = buildRailButtons(
railItems.filter((item) => item.placement !== "footer"),
t,
hiddenTabs,
);
const footerItems = railItems.filter(
(item) => item.placement === "footer" && !hiddenTabs.has(item.id),
);
const setRailPinned = (nextPinned: boolean) => {
setPinned(nextPinned);
localStorage.setItem("pinAppRail", String(nextPinned));
@@ -316,7 +235,7 @@ export function AppRail({
return (
<div
className="hidden md:flex flex-col items-stretch bg-sidebar border-r border-border shrink-0 overflow-hidden pt-2 gap-1 transition-[width] duration-200 min-h-0"
style={{ width: railExpanded ? 160 : 40 }}
style={{ width: rem(railExpanded ? 160 : 40) }}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onContextMenu={(e) => {
@@ -334,92 +253,113 @@ export function AppRail({
}}
>
<div className="flex flex-col flex-1 gap-1 overflow-y-auto scrollbar-none min-h-0">
{railButtons.map((item, i) =>
item.kind === "separator" ? (
<div
key={`sep-${i}`}
className="mx-auto h-px bg-border my-0.5 shrink-0 transition-[width] duration-200"
style={{ width: railExpanded ? "calc(100% - 16px)" : 20 }}
/>
) : "tabType" in item ? (
<button
key={item.tabType}
onClick={() => onOpenTab?.(item.tabType)}
style={btnStyle}
className={`${btnBase} text-muted-foreground hover:text-foreground hover:bg-muted/60`}
>
<span
className="shrink-0 flex items-center justify-center"
style={{ width: 16, height: 16 }}
{!pluginsSettled
? Array.from({ length: railButtons.length || 8 }, (_, i) => (
<div
key={`rail-skeleton-${i}`}
style={btnStyle}
className="flex items-center h-7 shrink-0"
>
{item.icon}
</span>
<span
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-[opacity,width] duration-150 ${
railExpanded ? "opacity-100 delay-75" : "opacity-0 w-0"
}`}
>
{item.title}
</span>
</button>
) : (
<button
key={item.view}
onClick={(e) => {
if (item.promotable && (e.ctrlKey || e.metaKey)) {
onOpenTab?.(item.view as TabType);
return;
}
onRailClick(item.view);
}}
onAuxClick={(e) => {
if (e.button !== 1 || !item.promotable) return;
e.preventDefault();
onOpenTab?.(item.view as TabType);
}}
onContextMenu={() => {
if (item.promotable || item.rightDockable)
setMenuTarget({
view: item.view,
title: item.title,
promotable: item.promotable,
rightDockable: item.rightDockable,
});
}}
data-rail-promotable={
item.promotable || item.rightDockable ? "" : undefined
}
title={
item.promotable
? `${item.title}\n${t("nav.openAsTabHint")}`
: item.title
}
style={btnStyle}
className={`${btnBase} ${
sidebarOpen && railView === item.view
? "text-accent-brand bg-accent-brand/10"
: "text-muted-foreground hover:text-foreground hover:bg-muted/60"
}`}
>
<span
className="shrink-0 flex items-center justify-center"
style={{ width: 16, height: 16 }}
>
{item.icon}
</span>
<span
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-[opacity,width] duration-150 ${
railExpanded ? "opacity-100 delay-75" : "opacity-0 w-0"
}`}
>
{item.title}
</span>
{item.dot && (
<span className="absolute top-0.5 right-0.5 size-1.5 rounded-full bg-accent-brand" />
)}
</button>
),
)}
<Skeleton className="size-4 shrink-0" />
</div>
))
: railButtons.map((item, i) =>
item.kind === "separator" ? (
<div
key={`sep-${i}`}
className="mx-auto h-px bg-border my-0.5 shrink-0 transition-[width] duration-200"
style={{
width: railExpanded ? `calc(100% - ${rem(16)})` : rem(20),
}}
/>
) : "tabType" in item ? (
<button
key={item.tabType}
onClick={() => onOpenTab?.(item.tabType)}
style={btnStyle}
className={`${btnBase} text-muted-foreground hover:text-foreground hover:bg-muted/60`}
>
<span
className="relative shrink-0 flex items-center justify-center"
style={{ width: rem(16), height: rem(16) }}
>
{item.icon}
{item.useBadge && (
<RailBadge
useBadge={item.useBadge}
className="-top-1.5 -right-2"
/>
)}
</span>
<span
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-[opacity,width] duration-150 ${
railExpanded ? "opacity-100 delay-75" : "opacity-0 w-0"
}`}
>
{item.title}
</span>
</button>
) : (
<button
key={item.view}
onClick={(e) => {
if (item.promotable && (e.ctrlKey || e.metaKey)) {
onOpenTab?.(item.view as TabType);
return;
}
onRailClick(item.view);
}}
onAuxClick={(e) => {
if (e.button !== 1 || !item.promotable) return;
e.preventDefault();
onOpenTab?.(item.view as TabType);
}}
onContextMenu={() => {
if (item.promotable || item.rightDockable)
setMenuTarget({
view: item.view,
title: item.title,
promotable: item.promotable,
rightDockable: item.rightDockable,
});
}}
data-rail-promotable={
item.promotable || item.rightDockable ? "" : undefined
}
title={
item.promotable
? `${item.title}\n${t("nav.openAsTabHint")}`
: item.title
}
style={btnStyle}
className={`${btnBase} ${
sidebarOpen && railView === item.view
? "text-accent-brand bg-accent-brand/10"
: "text-muted-foreground hover:text-foreground hover:bg-muted/60"
}`}
>
<span
className="relative shrink-0 flex items-center justify-center"
style={{ width: rem(16), height: rem(16) }}
>
{item.icon}
{item.useBadge && (
<RailBadge
useBadge={item.useBadge}
className="-top-1.5 -right-2"
/>
)}
</span>
<span
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-[opacity,width] duration-150 ${
railExpanded ? "opacity-100 delay-75" : "opacity-0 w-0"
}`}
>
{item.title}
</span>
</button>
),
)}
</div>
<div className="shrink-0 flex flex-col gap-1 border-t border-border pt-1 pb-1">
@@ -438,7 +378,7 @@ export function AppRail({
>
<span
className="shrink-0 flex items-center justify-center"
style={{ width: 16, height: 16 }}
style={{ width: rem(16), height: rem(16) }}
>
<Pin size={16} />
</span>
@@ -454,31 +394,98 @@ export function AppRail({
{showPinButton && (
<div
className="mx-auto h-px bg-border my-0.5 shrink-0 transition-[width] duration-200"
style={{ width: railExpanded ? "calc(100% - 16px)" : 20 }}
style={{
width: railExpanded ? `calc(100% - ${rem(16)})` : rem(20),
}}
/>
)}
{[
{
view: "alerts" as RailView,
icon: <Bell size={16} />,
title: t("nav.alerts"),
promotable: true,
},
{
view: "user-profile" as RailView,
icon: <User size={16} />,
title: t("nav.userProfile"),
},
...(isAdmin
? [
{
view: "admin-settings" as RailView,
icon: <Settings size={16} />,
title: t("nav.admin"),
},
]
: []),
].map((item) => (
{footerItems.map((item) => {
const Icon = item.icon;
const title = t(item.labelKey);
return (
<button
key={item.id}
onClick={(e) => {
if (item.kind === "tab") {
onOpenTab?.(item.id as TabType);
return;
}
if (item.promotable && (e.ctrlKey || e.metaKey)) {
onOpenTab?.(item.id as TabType);
return;
}
onRailClick(item.id as RailView);
}}
onAuxClick={(e) => {
if (e.button !== 1 || !item.promotable) return;
e.preventDefault();
onOpenTab?.(item.id as TabType);
}}
onContextMenu={() => {
if (item.promotable || item.rightDockable)
setMenuTarget({
view: item.id as RailView,
title,
promotable: item.promotable,
rightDockable: item.rightDockable,
});
}}
data-rail-promotable={
item.promotable || item.rightDockable ? "" : undefined
}
title={
item.promotable ? `${title}\n${t("nav.openAsTabHint")}` : title
}
style={btnStyle}
className={`${btnBase} ${
sidebarOpen && railView === item.id
? "text-accent-brand bg-accent-brand/10"
: "text-muted-foreground hover:text-foreground hover:bg-muted/60"
}`}
>
<span
className="relative shrink-0 flex items-center justify-center"
style={{ width: rem(16), height: rem(16) }}
>
<Icon size={16} />
{item.useBadge && (
<RailBadge
useBadge={item.useBadge}
className="-top-1.5 -right-2"
/>
)}
</span>
<span
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-[opacity,width] duration-150 ${railExpanded ? "opacity-100 delay-75" : "opacity-0 w-0"}`}
>
{title}
</span>
</button>
);
})}
{(
[
{
view: "user-profile" as RailView,
icon: <User size={16} />,
title: t("nav.userProfile"),
},
...(isAdmin
? [
{
view: "admin-settings" as RailView,
icon: <Settings size={16} />,
title: t("nav.admin"),
},
]
: []),
] as {
view: RailView;
icon: ReactElement;
title: string;
promotable?: boolean;
}[]
).map((item) => (
<button
key={item.view}
onClick={(e) => {
@@ -517,14 +524,9 @@ export function AppRail({
>
<span
className="relative shrink-0 flex items-center justify-center"
style={{ width: 16, height: 16 }}
style={{ width: rem(16), height: rem(16) }}
>
{item.icon}
{item.view === "alerts" && unreadAlerts > 0 && (
<span className="absolute -top-1 -right-1 flex size-3 items-center justify-center rounded-full bg-destructive text-[8px] font-bold text-white leading-none">
{unreadAlerts > 9 ? "9+" : unreadAlerts}
</span>
)}
</span>
<span
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-[opacity,width] duration-150 ${railExpanded ? "opacity-100 delay-75" : "opacity-0 w-0"}`}
@@ -543,7 +545,7 @@ export function AppRail({
>
<span
className="shrink-0 flex items-center justify-center"
style={{ width: 16, height: 16 }}
style={{ width: rem(16), height: rem(16) }}
>
<LogOut size={16} />
</span>
@@ -565,7 +567,7 @@ export function AppRail({
>
<div
className="rounded-full bg-accent-brand/20 border border-accent-brand/30 flex items-center justify-center font-bold text-accent-brand shrink-0"
style={{ width: 24, height: 24, fontSize: 11 }}
style={{ width: rem(24), height: rem(24), fontSize: rem(11) }}
>
{username.charAt(0).toUpperCase() || "U"}
</div>
+32
View File
@@ -0,0 +1,32 @@
import { useTranslation } from "react-i18next";
import { useLoginMethods, useSecondFactors } from "@/plugin-host/auth-registry";
/**
* Enrolment sections login methods and second factors register, such as
* adding a passkey or setting up an authenticator app.
*/
export function AuthEnrollmentSections() {
const { t } = useTranslation();
const sections = [...useLoginMethods(), ...useSecondFactors()].filter(
(entry) => entry.enrollment,
);
if (sections.length === 0) return null;
return (
<div className="flex flex-col gap-4">
{sections.map((entry) => {
const Enrollment = entry.enrollment!;
return (
<div
key={`${entry.pluginId}:${entry.id}`}
className="flex flex-col gap-2"
>
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t(entry.titleKey)}
</span>
<Enrollment />
</div>
);
})}
</div>
);
}
-675
View File
@@ -1,675 +0,0 @@
import { getErrorMessage } from "../lib/error-message.js";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
ArrowLeft,
Copy,
FlaskConical,
Loader2,
Play,
Plus,
Trash2,
Workflow,
} from "lucide-react";
import { Button } from "@/components/button";
import { Badge } from "@/components/badge";
import {
createAutomation,
deleteAutomation,
listAutomationRunSteps,
listAutomationRuns,
listAutomations,
runAutomation,
updateAutomation,
type AutomationRow,
type AutomationRunRow,
type AutomationRunStepRow,
} from "@/api/automations-api";
import {
deleteNotificationChannel,
getNotificationChannels,
testNotificationChannel,
type NotificationChannel,
} from "@/api/alerts-api";
import { NotificationChannelDialog } from "./NotificationChannelDialog";
import { getSnippets } from "@/api/snippets-api";
import { getSSHHosts } from "@/api/ssh-host-management-api";
import { listFleets } from "@/api/fleets-api";
import {
AutomationEditor,
emptyDraft,
type AutomationDraft,
} from "./automations/AutomationEditor";
import {
EMPTY_EDITOR_OPTIONS,
type AutomationEditorOptions,
} from "./automations/editor-types";
type PanelTab = "automations" | "runs" | "channels";
const STATUS_CLASS: Record<string, string> = {
success: "text-green-500",
failed: "text-destructive",
timeout: "text-destructive",
running: "text-foreground",
skipped: "text-muted-foreground",
cancelled: "text-muted-foreground",
};
function timeAgo(iso: string | null): string {
if (!iso) return "";
const ms = Date.now() - new Date(iso).getTime();
if (!Number.isFinite(ms)) return "";
const sec = Math.floor(ms / 1000);
if (sec < 60) return `${sec}s ago`;
const min = Math.floor(sec / 60);
if (min < 60) return `${min}m ago`;
const hr = Math.floor(min / 60);
if (hr < 24) return `${hr}h ago`;
return `${Math.floor(hr / 24)}d ago`;
}
export function AutomationsPanel({
active = true,
onEditingChange,
}: {
active?: boolean;
onEditingChange?: (editing: boolean) => void;
}) {
const { t } = useTranslation();
const base = "newUi.sidebar.automations";
const [tab, setTab] = useState<PanelTab>("automations");
const [automations, setAutomations] = useState<AutomationRow[]>([]);
const [runs, setRuns] = useState<AutomationRunRow[]>([]);
const [loading, setLoading] = useState(false);
const [options, setOptions] =
useState<AutomationEditorOptions>(EMPTY_EDITOR_OPTIONS);
const [editorOpen, setEditorOpen] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [draft, setDraft] = useState<AutomationDraft>(emptyDraft());
const [saving, setSaving] = useState(false);
const [webhookToken, setWebhookToken] = useState<string | null>(null);
const [expandedRun, setExpandedRun] = useState<number | null>(null);
const [runSteps, setRunSteps] = useState<AutomationRunStepRow[]>([]);
const [channels, setChannels] = useState<NotificationChannel[]>([]);
const [channelDialogOpen, setChannelDialogOpen] = useState(false);
const [editingChannel, setEditingChannel] =
useState<NotificationChannel | null>(null);
const loadAutomations = useCallback(async () => {
setLoading(true);
try {
setAutomations(await listAutomations());
} catch {
// A failed load leaves the previous list on screen.
} finally {
setLoading(false);
}
}, []);
const loadRuns = useCallback(async () => {
try {
setRuns(await listAutomationRuns({ limit: 100 }));
} catch {
// Non-fatal.
}
}, []);
const loadOptions = useCallback(async () => {
const [hosts, snippets, channelsResult, fleets] = await Promise.allSettled([
getSSHHosts({ includeStatus: false }),
getSnippets(),
getNotificationChannels(),
listFleets(),
]);
if (channelsResult.status === "fulfilled") {
setChannels(channelsResult.value);
}
setOptions({
hosts:
hosts.status === "fulfilled"
? hosts.value.map((host) => ({
id: host.id,
name: host.name || host.ip,
}))
: [],
snippets:
snippets.status === "fulfilled"
? snippets.value.map((s) => ({
id: s.id,
// SnippetRow only types its id; the rest is narrowed by callers.
name: typeof s.name === "string" ? s.name : `#${s.id}`,
}))
: [],
channels:
channelsResult.status === "fulfilled"
? channelsResult.value.map((c) => ({ id: c.id, name: c.name }))
: [],
fleets:
fleets.status === "fulfilled"
? fleets.value.map((f) => ({ id: f.id, name: f.name }))
: [],
});
}, []);
useEffect(() => {
if (!active) return;
void loadAutomations();
void loadOptions();
}, [active, loadAutomations, loadOptions]);
useEffect(() => {
if (active && tab === "runs") void loadRuns();
}, [active, tab, loadRuns]);
// Widens the sidebar while the editor is open, and releases it again on the
// way out or when the user switches to another rail destination.
useEffect(() => {
if (active) onEditingChange?.(editorOpen);
}, [active, editorOpen, onEditingChange]);
useEffect(() => {
return () => onEditingChange?.(false);
}, [onEditingChange]);
function closeEditor() {
setWebhookToken(null);
setEditorOpen(false);
}
function openCreate() {
setEditingId(null);
setDraft(emptyDraft());
setWebhookToken(null);
setEditorOpen(true);
}
function openEdit(row: AutomationRow) {
setEditingId(row.id);
setDraft({
name: row.name,
description: row.description ?? "",
enabled: !!row.enabled,
concurrencyPolicy: row.concurrency_policy ?? "skip",
definition: row.definition ?? emptyDraft().definition,
});
setWebhookToken(null);
setEditorOpen(true);
}
async function save() {
if (!draft.name.trim()) {
toast.error(t(`${base}.nameRequired`));
return;
}
setSaving(true);
try {
if (editingId === null) {
const created = await createAutomation({
name: draft.name.trim(),
description: draft.description || null,
enabled: draft.enabled,
concurrencyPolicy: draft.concurrencyPolicy,
definition: draft.definition,
});
toast.success(t(`${base}.created`));
if (created.webhookToken) {
setWebhookToken(created.webhookToken);
} else {
setEditorOpen(false);
}
} else {
await updateAutomation(editingId, {
name: draft.name.trim(),
description: draft.description || null,
enabled: draft.enabled,
concurrencyPolicy: draft.concurrencyPolicy,
definition: draft.definition,
});
toast.success(t(`${base}.updated`));
setEditorOpen(false);
}
await loadAutomations();
} catch (error) {
toast.error(getErrorMessage(error, String(error)));
} finally {
setSaving(false);
}
}
async function remove(row: AutomationRow) {
if (!confirm(t(`${base}.deleteConfirm`))) return;
try {
await deleteAutomation(row.id);
toast.success(t(`${base}.deleted`));
await loadAutomations();
} catch (error) {
toast.error(getErrorMessage(error, String(error)));
}
}
async function run(row: AutomationRow, dryRun: boolean) {
try {
const outcome = await runAutomation(row.id, { dryRun });
toast.success(t(`${base}.runFinished`, { status: outcome.status }));
await loadAutomations();
if (tab === "runs") await loadRuns();
} catch (error) {
toast.error(getErrorMessage(error, String(error)));
}
}
async function testChannel(channel: NotificationChannel) {
try {
// Throws with the reason when delivery fails.
await testNotificationChannel(channel.id);
toast.success(t(`${base}.channelTestSent`));
} catch (error) {
toast.error(getErrorMessage(error, String(error)));
}
}
async function removeChannel(channel: NotificationChannel) {
if (!confirm(t(`${base}.deleteChannelConfirm`))) return;
try {
await deleteNotificationChannel(channel.id);
toast.success(t(`${base}.channelDeleted`));
await loadOptions();
} catch (error) {
toast.error(getErrorMessage(error, String(error)));
}
}
async function toggleRun(runId: number) {
if (expandedRun === runId) {
setExpandedRun(null);
return;
}
setExpandedRun(runId);
try {
setRunSteps(await listAutomationRunSteps(runId));
} catch {
setRunSteps([]);
}
}
// Editing takes over the whole panel and widens the sidebar, the same way the
// host manager does, rather than opening a dialog over the app.
if (editorOpen) {
const webhookUrl = webhookToken
? `${window.location.origin}/automations/webhook/${webhookToken}`
: "";
return (
<div className="flex flex-col h-full min-h-0">
<button
onClick={closeEditor}
className="flex items-center gap-2 px-3 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/40 transition-colors border-b border-border/50 shrink-0"
>
<ArrowLeft className="size-3.5 shrink-0" />
<span>{t(`${base}.backToAutomations`)}</span>
{editingId !== null && (
<span
className="ml-auto font-semibold text-foreground truncate max-w-[200px]"
title={draft.name}
>
{draft.name}
</span>
)}
</button>
<div className="flex-1 min-h-0 overflow-y-auto p-3">
{webhookToken ? (
<div className="space-y-2">
<p className="text-sm">{t(`${base}.webhookTokenTitle`)}</p>
<p className="text-xs text-muted-foreground">
{t(`${base}.webhookTokenDescription`)}
</p>
<div className="flex gap-2">
<code className="flex-1 p-2 bg-muted text-xs break-all">
{webhookUrl}
</code>
<Button
size="sm"
variant="outline"
className="rounded-none border-border shrink-0"
onClick={() => {
navigator.clipboard?.writeText(webhookUrl);
toast.success(t(`${base}.copied`));
}}
>
<Copy size={14} />
</Button>
</div>
</div>
) : (
<AutomationEditor
draft={draft}
options={options}
onChange={setDraft}
/>
)}
</div>
<div className="flex items-center gap-2 p-3 border-t border-border shrink-0">
{webhookToken ? (
<Button
variant="outline"
className="rounded-none ml-auto h-9 px-6 border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={closeEditor}
>
{t("common.close")}
</Button>
) : (
<>
<Button
variant="outline"
className="rounded-none border-border h-9 px-5"
onClick={closeEditor}
>
{t("common.cancel")}
</Button>
<Button
variant="outline"
className="rounded-none ml-auto h-9 px-6 border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
disabled={saving}
onClick={save}
>
{saving && (
<Loader2 size={14} className="mr-1.5 animate-spin" />
)}
{t(`${base}.save`)}
</Button>
</>
)}
</div>
</div>
);
}
return (
<div className="flex flex-col h-full">
<div className="flex items-center gap-1 p-2 border-b border-border">
{(["automations", "runs", "channels"] as PanelTab[]).map((key) => (
<Button
key={key}
variant={tab === key ? "outline" : "ghost"}
size="sm"
className={`min-w-0 flex-shrink rounded-none h-7 px-2 text-xs ${
tab === key
? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand hover:bg-accent-brand/20 hover:text-accent-brand"
: ""
}`}
onClick={() => setTab(key)}
>
<span className="truncate">
{t(
`${base}.${key === "automations" ? "tabAutomations" : key === "runs" ? "tabRuns" : "tabChannels"}`,
)}
</span>
</Button>
))}
<a
href="https://docs.termix.site/features/automations/overview"
target="_blank"
rel="noreferrer"
className="ml-auto shrink-0 text-[10px] text-accent-brand hover:underline"
>
{t("hosts.docsLink")}
</a>
</div>
<div className="flex-1 overflow-y-auto p-2">
{tab === "automations" && (
<>
<Button
size="sm"
variant="outline"
className="rounded-none w-full mb-2 border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={openCreate}
>
<Plus size={14} className="mr-1" />
{t(`${base}.createAutomation`)}
</Button>
{loading && automations.length === 0 && (
<div className="flex justify-center p-4">
<Loader2 size={16} className="animate-spin" />
</div>
)}
{!loading && automations.length === 0 && (
<p className="text-xs text-muted-foreground p-2">
{t(`${base}.empty`)}
</p>
)}
<div className="space-y-1">
{automations.map((row) => (
<div
key={row.id}
className="border border-border p-2.5 hover:bg-muted/40"
>
<div className="flex items-center gap-2">
<Workflow size={14} className="shrink-0" />
<button
className="flex-1 text-left text-sm truncate"
onClick={() => openEdit(row)}
>
{row.name}
</button>
{!row.enabled && (
<Badge variant="outline" className="text-[10px]">
off
</Badge>
)}
</div>
<div className="flex items-center gap-1 mt-1">
<span className="text-[11px] text-muted-foreground flex-1 truncate">
{row.definition
? t(
`${base}.triggerKinds.${row.definition.trigger.kind}`,
)
: ""}
{row.last_run_status
? ` · ${timeAgo(row.last_run_at)}`
: ""}
</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 rounded-none"
title={t(`${base}.testRun`)}
onClick={() => run(row, true)}
>
<FlaskConical size={12} />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 rounded-none"
title={t(`${base}.runNow`)}
onClick={() => run(row, false)}
>
<Play size={12} />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 rounded-none"
onClick={() => remove(row)}
>
<Trash2 size={12} />
</Button>
</div>
</div>
))}
</div>
</>
)}
{tab === "runs" && (
<div className="space-y-1">
{runs.length === 0 && (
<p className="text-xs text-muted-foreground p-2">
{t(`${base}.emptyRuns`)}
</p>
)}
{runs.map((entry) => (
<div key={entry.id} className="border border-border">
<button
className="w-full text-left p-2.5 hover:bg-muted/40"
onClick={() => toggleRun(entry.id)}
>
<div className="flex items-center gap-2">
<span className="text-sm flex-1 truncate">
{entry.automation_name ?? `#${entry.automation_id}`}
</span>
{entry.dry_run ? (
<Badge variant="outline" className="text-[10px]">
{t(`${base}.dryRunBadge`)}
</Badge>
) : null}
<span
className={`text-[11px] ${STATUS_CLASS[entry.status] ?? ""}`}
>
{t(`${base}.statuses.${entry.status}`)}
</span>
</div>
<div className="text-[11px] text-muted-foreground">
{timeAgo(entry.started_at)}
{entry.duration_ms !== null
? ` · ${Math.round(entry.duration_ms / 100) / 10}s`
: ""}
</div>
{entry.error && (
<div className="text-[11px] text-destructive truncate">
{entry.error}
</div>
)}
</button>
{expandedRun === entry.id && (
<div className="border-t border-border p-2 space-y-1">
{runSteps.map((step) => (
<div key={step.id} className="text-[11px]">
<div className="flex gap-2">
<span className="text-muted-foreground">
{step.step_index + 1}.
</span>
<span className="flex-1">
{t(`${base}.stepTypes.${step.step_type}`, {
defaultValue: step.step_type,
})}
</span>
<span className={STATUS_CLASS[step.status] ?? ""}>
{step.status}
</span>
</div>
{step.output && (
<pre className="mt-1 p-1 bg-muted overflow-x-auto whitespace-pre-wrap break-all text-[10px]">
{step.output}
</pre>
)}
{step.error && (
<div className="text-destructive">{step.error}</div>
)}
</div>
))}
</div>
)}
</div>
))}
</div>
)}
{tab === "channels" && (
<>
<Button
size="sm"
variant="outline"
className="rounded-none w-full mb-2 border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={() => {
setEditingChannel(null);
setChannelDialogOpen(true);
}}
>
<Plus size={14} className="mr-1" />
{t(`${base}.addChannel`)}
</Button>
{channels.length === 0 && (
<p className="text-xs text-muted-foreground p-2">
{t(`${base}.emptyChannels`)}
</p>
)}
<div className="space-y-1">
{channels.map((channel) => (
<div
key={channel.id}
className="border border-border p-2.5 hover:bg-muted/40"
>
<div className="flex items-center gap-2">
<button
className="flex-1 text-left text-sm truncate"
onClick={() => {
setEditingChannel(channel);
setChannelDialogOpen(true);
}}
>
{channel.name}
</button>
<Badge variant="outline" className="text-[10px] uppercase">
{channel.type}
</Badge>
</div>
<div className="flex items-center gap-1 mt-1">
<span className="flex-1" />
<Button
variant="ghost"
size="sm"
className="h-6 rounded-none text-[11px]"
onClick={() => testChannel(channel)}
>
{t(`${base}.testChannel`)}
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 rounded-none"
onClick={() => removeChannel(channel)}
>
<Trash2 size={12} />
</Button>
</div>
</div>
))}
</div>
</>
)}
</div>
<NotificationChannelDialog
open={channelDialogOpen}
onOpenChange={setChannelDialogOpen}
channel={editingChannel}
onSaved={() => {
setChannelDialogOpen(false);
void loadOptions();
}}
/>
</div>
);
}
export default AutomationsPanel;
-186
View File
@@ -1,186 +0,0 @@
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Loader2, Plus, Presentation, RefreshCw } from "lucide-react";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { Badge } from "@/components/badge";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/dialog";
import {
createCollabRoom,
listCollabRooms,
type CollabRoom,
} from "@/api/collab-api";
import { getErrorMessage } from "@/lib/error-message";
export function CollabPanel({
onOpenRoom,
}: {
onOpenRoom: (room: CollabRoom) => void;
}) {
const { t } = useTranslation();
const [rooms, setRooms] = useState<CollabRoom[]>([]);
const [loading, setLoading] = useState(true);
const [createOpen, setCreateOpen] = useState(false);
const [name, setName] = useState("");
const [persistent, setPersistent] = useState(false);
const [creating, setCreating] = useState(false);
const refresh = useCallback(async () => {
try {
const result = await listCollabRooms();
setRooms(result.rooms ?? []);
} catch {
/* the list stays as-is */
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void refresh();
const timer = window.setInterval(() => void refresh(), 30000);
return () => window.clearInterval(timer);
}, [refresh]);
async function handleCreate() {
if (!name.trim()) return;
setCreating(true);
try {
const { room } = await createCollabRoom(name.trim(), persistent);
toast.success(t("collab.created"));
setCreateOpen(false);
setName("");
setPersistent(false);
await refresh();
onOpenRoom(room);
} catch (error) {
toast.error(getErrorMessage(error));
} finally {
setCreating(false);
}
}
return (
<div className="flex flex-col gap-2 p-2">
<div className="flex items-center gap-1.5">
<Button
size="sm"
className="h-8 text-xs flex-1 border border-accent-brand/40 bg-accent-brand/10 text-accent-brand font-semibold hover:bg-accent-brand/20"
onClick={() => setCreateOpen(true)}
>
<Plus className="size-3.5 mr-1" />
{t("collab.createRoom")}
</Button>
<Button
size="sm"
variant="outline"
className="h-8 px-2"
onClick={() => void refresh()}
aria-label={t("common.refresh")}
title={t("common.refresh")}
>
<RefreshCw className="size-3.5" />
</Button>
</div>
{loading ? (
<div className="flex justify-center py-6">
<Loader2 className="size-4 animate-spin text-muted-foreground" />
</div>
) : rooms.length === 0 ? (
<p className="text-xs text-muted-foreground px-1 py-4 text-center">
{t("collab.noRooms")}
</p>
) : (
<div className="flex flex-col gap-1">
{rooms.map((room) => (
<button
key={room.id}
type="button"
onClick={() => onOpenRoom(room)}
className="flex items-center gap-2 px-2 py-1.5 text-left border border-border hover:bg-muted/50"
>
<Presentation className="size-3.5 shrink-0 text-muted-foreground" />
<span className="flex-1 text-xs truncate">{room.name}</span>
{room.presenterUserId && (
<span
className="size-1.5 rounded-full bg-red-500 shrink-0"
role="img"
aria-label={t("collab.presenterBadge")}
title={t("collab.presenterBadge")}
/>
)}
{room.persistent && (
<Badge variant="outline" className="text-[9px] px-1 py-0">
{t("collab.persistentRoom")}
</Badge>
)}
</button>
))}
</div>
)}
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("collab.createRoom")}</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-3">
<label htmlFor="collab-room-name" className="text-xs font-medium">
{t("collab.roomName")}
</label>
<Input
id="collab-room-name"
placeholder={t("collab.roomName")}
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void handleCreate();
}}
/>
<label className="flex items-start gap-2 text-xs cursor-pointer">
<input
type="checkbox"
className="mt-0.5"
checked={persistent}
onChange={(e) => setPersistent(e.target.checked)}
/>
<span>
<span className="font-medium">
{t("collab.persistentRoom")}
</span>
<br />
<span className="text-muted-foreground">
{t("collab.persistentRoomHint")}
</span>
</span>
</label>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setCreateOpen(false)}
disabled={creating}
>
{t("common.cancel")}
</Button>
<Button
onClick={() => void handleCreate()}
disabled={creating || !name.trim()}
>
{creating && <Loader2 className="size-3.5 mr-1 animate-spin" />}
{t("common.create")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -1,420 +0,0 @@
import { useEffect, useMemo, useState } from "react";
import type React from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Button } from "@/components/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/dialog";
import { SettingRow } from "@/components/section-card";
import { useConnectionDefaults } from "@/contexts/ConnectionDefaultsContext";
import {
CURSOR_STYLES,
TERMINAL_FONTS,
TERMINAL_THEMES,
} from "@/lib/terminal-themes";
import {
fromTriState,
toTriState,
type RemoteDesktopDefaults,
type TerminalDefaults,
type TriState,
} from "@/lib/connection-defaults";
const inputClass =
"h-8 w-full border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring";
const labelClass =
"text-[10px] font-bold uppercase tracking-widest text-muted-foreground";
const RDP_TOGGLES = [
["forceLossless", "hosts.guac.forceLossless", "hosts.guac.forceLosslessDesc"],
["enableWallpaper", "hosts.guac.wallpaper", "hosts.guac.wallpaperDesc"],
[
"enableFontSmoothing",
"hosts.guac.fontSmoothing",
"hosts.guac.fontSmoothingDesc",
],
[
"enableDesktopComposition",
"hosts.guac.desktopComposition",
"hosts.guac.desktopCompositionDesc",
],
["disableAudio", "hosts.guac.disableAudio", "hosts.guac.disableAudioDesc"],
[
"enablePrinting",
"hosts.guac.enablePrinting",
"hosts.guac.enablePrintingDesc",
],
[
"enableDrive",
"hosts.guac.enableDriveRedirection",
"hosts.guac.enableDriveRedirectionDesc",
],
["disableCopy", "hosts.guac.disableCopy", "hosts.guac.disableCopyDesc"],
["disablePaste", "hosts.guac.disablePaste", "hosts.guac.disablePasteDesc"],
] as const;
function Field({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<div className="flex flex-col gap-1.5">
<span className={labelClass}>{label}</span>
{children}
</div>
);
}
function TriStateSelect({
value,
onChange,
}: {
value?: boolean;
onChange: (value: boolean | undefined) => void;
}) {
const { t } = useTranslation();
return (
<select
className="h-7 w-28 border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
value={toTriState(value)}
onChange={(e) => onChange(fromTriState(e.target.value as TriState))}
>
<option value="inherit">
{t("newUi.sidebar.connectionDefaults.inherit")}
</option>
<option value="on">{t("newUi.sidebar.connectionDefaults.on")}</option>
<option value="off">{t("newUi.sidebar.connectionDefaults.off")}</option>
</select>
);
}
export function ConnectionDefaultsSettings() {
const { t } = useTranslation();
const defaults = useConnectionDefaults();
const [open, setOpen] = useState(false);
const [tab, setTab] = useState<"terminal" | "rdp">("terminal");
const [terminal, setTerminal] = useState<TerminalDefaults>({});
const [rdp, setRdp] = useState<RemoteDesktopDefaults>({});
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!defaults.ready) return;
setTerminal(defaults.terminal);
setRdp(defaults.rdp);
}, [defaults.ready, defaults.terminal, defaults.rdp]);
const configuredCount = useMemo(() => {
const count = (source: Record<string, unknown>) =>
Object.values(source).filter((value) => value !== undefined).length;
return count(defaults.terminal) + count(defaults.rdp);
}, [defaults.terminal, defaults.rdp]);
const updateTerminal = <K extends keyof TerminalDefaults>(
key: K,
value: TerminalDefaults[K],
) => setTerminal((current) => ({ ...current, [key]: value }));
const updateRdp = <K extends keyof RemoteDesktopDefaults>(
key: K,
value: RemoteDesktopDefaults[K],
) => setRdp((current) => ({ ...current, [key]: value }));
async function save() {
setSaving(true);
try {
await Promise.all([
defaults.saveDefaults("terminal", terminal),
defaults.saveDefaults("rdp", rdp),
]);
toast.success(t("newUi.sidebar.connectionDefaults.saved"));
setOpen(false);
} catch {
toast.error(t("newUi.sidebar.connectionDefaults.saveFailed"));
} finally {
setSaving(false);
}
}
const inheritLabel = t("newUi.sidebar.connectionDefaults.inherit");
return (
<>
<SettingRow
label={t("newUi.sidebar.connectionDefaults.title")}
description={t("newUi.sidebar.connectionDefaults.description")}
badge={configuredCount > 0 ? String(configuredCount) : undefined}
>
<Button
variant="outline"
size="sm"
disabled={!defaults.ready}
onClick={() => setOpen(true)}
>
{t("newUi.sidebar.connectionDefaults.manage")}
</Button>
</SettingRow>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle className="text-base font-bold">
{t("newUi.sidebar.connectionDefaults.title")}
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground">
{t("newUi.sidebar.connectionDefaults.dialogDescription")}{" "}
<a
href="https://docs.termix.site/features/files-and-hosts/connection-defaults"
target="_blank"
rel="noreferrer"
className="text-accent-brand hover:underline"
>
{t("hosts.docsLink")}
</a>
</DialogDescription>
</DialogHeader>
<div className="flex border-b border-border">
{(
[
["terminal", "newUi.sidebar.connectionDefaults.tabTerminal"],
["rdp", "newUi.sidebar.connectionDefaults.tabRemoteDesktop"],
] as const
).map(([id, labelKey]) => (
<button
key={id}
onClick={() => setTab(id)}
className={`px-3 py-2 text-xs font-medium border-b-2 -mb-px transition-colors ${
tab === id
? "border-accent-brand text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
{t(labelKey)}
</button>
))}
</div>
<div className="max-h-[55vh] overflow-y-auto pr-1">
{tab === "terminal" ? (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<Field label={t("hosts.fontFamilyLabel")}>
<select
className={inputClass}
value={terminal.fontFamily ?? ""}
onChange={(e) =>
updateTerminal("fontFamily", e.target.value || undefined)
}
>
<option value="">{inheritLabel}</option>
{TERMINAL_FONTS.map((font) => (
<option key={font.value} value={font.value}>
{font.label}
</option>
))}
</select>
</Field>
<Field label={t("hosts.colorTheme")}>
<select
className={inputClass}
value={terminal.theme ?? ""}
onChange={(e) =>
updateTerminal("theme", e.target.value || undefined)
}
>
<option value="">{inheritLabel}</option>
{Object.entries(TERMINAL_THEMES)
.filter(
([key]) =>
key !== "termixDark" && key !== "termixLight",
)
.map(([key, theme]) => (
<option key={key} value={key}>
{theme.name}
</option>
))}
</select>
</Field>
<Field label={t("hosts.fontSizeLabel")}>
<input
className={inputClass}
type="number"
min={8}
max={32}
value={terminal.fontSize ?? ""}
placeholder={inheritLabel}
onChange={(e) =>
updateTerminal(
"fontSize",
e.target.value ? Number(e.target.value) : undefined,
)
}
/>
</Field>
<Field label={t("hosts.lineHeightLabel")}>
<input
className={inputClass}
type="number"
min={0.8}
max={2}
step={0.05}
value={terminal.lineHeight ?? ""}
placeholder={inheritLabel}
onChange={(e) =>
updateTerminal(
"lineHeight",
e.target.value ? Number(e.target.value) : undefined,
)
}
/>
</Field>
<Field label={t("hosts.cursorStyleLabel")}>
<select
className={inputClass}
value={terminal.cursorStyle ?? ""}
onChange={(e) =>
updateTerminal(
"cursorStyle",
(e.target.value || undefined) as
TerminalDefaults["cursorStyle"] | undefined,
)
}
>
<option value="">{inheritLabel}</option>
{CURSOR_STYLES.map((style) => (
<option key={style.value} value={style.value}>
{style.label}
</option>
))}
</select>
</Field>
<Field label={t("hosts.scrollbackBufferLabel")}>
<input
className={inputClass}
type="number"
min={1000}
max={100000}
step={1000}
value={terminal.scrollback ?? ""}
placeholder={inheritLabel}
onChange={(e) =>
updateTerminal(
"scrollback",
e.target.value ? Number(e.target.value) : undefined,
)
}
/>
</Field>
<div className="sm:col-span-2">
<SettingRow
label={t("hosts.cursorBlinking")}
description={t("hosts.cursorBlinkingDesc")}
>
<TriStateSelect
value={terminal.cursorBlink}
onChange={(value) => updateTerminal("cursorBlink", value)}
/>
</SettingRow>
</div>
</div>
) : (
<div className="flex flex-col gap-3">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<Field label={t("hosts.guac.colorDepth")}>
<select
className={inputClass}
value={rdp.colorDepth ?? ""}
onChange={(e) =>
updateRdp(
"colorDepth",
e.target.value ? Number(e.target.value) : undefined,
)
}
>
<option value="">{inheritLabel}</option>
<option value="16">16-bit</option>
<option value="24">24-bit</option>
<option value="32">32-bit</option>
</select>
</Field>
<Field label={t("hosts.guac.resizeMethod")}>
<select
className={inputClass}
value={rdp.resizeMethod ?? ""}
onChange={(e) =>
updateRdp("resizeMethod", e.target.value || undefined)
}
>
<option value="">{inheritLabel}</option>
<option value="display-update">
{t(
"newUi.sidebar.connectionDefaults.resizeDisplayUpdate",
)}
</option>
<option value="reconnect">
{t("newUi.sidebar.connectionDefaults.resizeReconnect")}
</option>
</select>
</Field>
</div>
<div>
{RDP_TOGGLES.map(([key, labelKey, descriptionKey]) => (
<SettingRow
key={key}
label={t(labelKey)}
description={t(descriptionKey)}
>
<TriStateSelect
value={rdp[key] as boolean | undefined}
onChange={(value) => updateRdp(key, value)}
/>
</SettingRow>
))}
</div>
</div>
)}
</div>
<div className="flex items-center justify-between gap-2 pt-3 border-t border-border">
<Button
variant="ghost"
size="sm"
onClick={() => {
setTerminal({});
setRdp({});
}}
>
{t("newUi.sidebar.connectionDefaults.clearAll")}
</Button>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={() => setOpen(false)}>
{t("common.cancel")}
</Button>
<Button
size="sm"
disabled={saving || !defaults.ready}
onClick={save}
>
{saving ? t("common.saving") : t("common.save")}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</>
);
}
+53 -147
View File
@@ -1,3 +1,5 @@
import { getTabType, isPersistentTabType } from "@/shell/tab-registry";
import { ComponentSlot } from "@/shell/ActionSlot";
import { useState, useEffect, useRef, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { ExternalLink, Plug, Search, X, Pencil, Check } from "lucide-react";
@@ -8,9 +10,9 @@ import {
type OpenTabRecord,
} from "@/main-axios";
import { tabIcon } from "@/shell/tabUtils";
import { getSessionTimeoutMinutes } from "@/api/open-tabs-api";
import type { Tab, TabType } from "@/types/ui-types";
import { Badge } from "@/components/badge";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import {
Tooltip,
@@ -21,28 +23,16 @@ import {
import { usePageVisibleInterval } from "@/hooks/use-page-visible-interval";
import { useAdaptivePolling } from "@/hooks/use-adaptive-polling";
const CONNECTION_TAB_TYPES: TabType[] = [
"terminal",
"rdp",
"vnc",
"telnet",
"files",
"docker",
"host-metrics",
"tunnel",
];
const TYPE_LABELS: Record<string, string> = {
terminal: "SSH",
rdp: "RDP",
vnc: "VNC",
telnet: "Telnet",
/** Core badge labels; plugin tabs are labelled by their registered title. */
const CORE_TYPE_LABELS: Record<string, string> = {
files: "Files",
docker: "Docker",
"host-metrics": "Host Metrics",
tunnel: "Tunnel",
};
/** Saved connection tabs: the ones reopened after login. */
function isConnectionTabType(type: TabType): boolean {
return isPersistentTabType(type);
}
function formatDuration(ms: number): string {
const s = Math.max(0, Math.floor(ms / 1000));
if (s < 60) return `${s}s`;
@@ -66,11 +56,7 @@ function sessionsUnchanged(
a.hostName !== b.hostName ||
a.tabInstanceId !== b.tabInstanceId ||
a.isConnected !== b.isConnected ||
a.createdAt !== b.createdAt ||
a.isOwnSession !== b.isOwnSession ||
a.sharedByUsername !== b.sharedByUsername ||
a.permissionLevel !== b.permissionLevel ||
a.shareId !== b.shareId
a.createdAt !== b.createdAt
) {
return false;
}
@@ -195,14 +181,15 @@ function ConnectionRow({
variant="outline"
className="text-[9px] px-1 py-0 h-4 font-mono shrink-0 text-muted-foreground/60 border-border/60"
>
{TYPE_LABELS[tabType] ?? tabType}
{CORE_TYPE_LABELS[tabType] ??
(getTabType(tabType)?.titleKey
? t(getTabType(tabType)!.titleKey!)
: tabType)}
</Badge>
</div>
<span className="text-[10px] text-muted-foreground/60 truncate pl-3">
{hostName && hostName !== name ? (
<span className="text-muted-foreground/50">
{hostName} &middot;{" "}
</span>
<span className="text-muted-foreground/50 mr-2.5">{hostName}</span>
) : null}
{subLabel}
</span>
@@ -295,7 +282,6 @@ export function ConnectionsPanel({
onForgetBackground,
onRenameTab,
onReorderTabs,
onJoinSharedSession,
}: {
tabs: Tab[];
activeTabId: string;
@@ -310,16 +296,14 @@ export function ConnectionsPanel({
onForgetBackground: (recordId: string) => void;
onRenameTab?: (tabId: string, newLabel: string) => void;
onReorderTabs?: (tabs: Tab[]) => void;
onJoinSharedSession?: (session: ActiveSessionInfo) => void;
}) {
const { t } = useTranslation();
const [now, setNow] = useState(Date.now());
const [persistMinutes, setPersistMinutes] = useState(30);
useEffect(() => {
import("@/api/settings-api")
.then(({ getTerminalSessionSettings }) => getTerminalSessionSettings())
.then((settings) => setPersistMinutes(settings.timeoutMinutes))
getSessionTimeoutMinutes()
.then((minutes) => setPersistMinutes(minutes))
.catch(() => {});
}, []);
const [activeSessions, setActiveSessions] = useState<ActiveSessionInfo[]>([]);
@@ -334,9 +318,7 @@ export function ConnectionsPanel({
const dragStartY = useRef<number>(0);
const didDragRef = useRef(false);
const openTabs = tabs.filter((tab) =>
CONNECTION_TAB_TYPES.includes(tab.type),
);
const openTabs = tabs.filter((tab) => isConnectionTabType(tab.type));
const openInstanceIds = new Set(
tabs.map((t) => t.instanceId).filter(Boolean),
@@ -362,22 +344,6 @@ export function ConnectionsPanel({
})
: backgroundTabs;
const joinedInstanceIds = new Set(
tabs
.map((t) => (t.joinSharedSessionId ? t.instanceId : null))
.filter(Boolean),
);
const sharedWithMe = activeSessions.filter(
(s) => s.isOwnSession === false && !joinedInstanceIds.has(s.sessionId),
);
const filteredSharedWithMe = q
? sharedWithMe.filter(
(s) =>
s.hostName.toLowerCase().includes(q) ||
(s.sharedByUsername ?? "").toLowerCase().includes(q),
)
: sharedWithMe;
// Duration labels only need minute-level freshness; 1s ticks re-render the whole panel.
usePageVisibleInterval(() => setNow(Date.now()), 15_000);
@@ -437,9 +403,8 @@ export function ConnectionsPanel({
if (fromIdx !== -1 && toIdx !== -1 && fromIdx !== toIdx) {
const reordered = [...openTabs];
reordered.splice(toIdx, 0, reordered.splice(fromIdx, 1)[0]);
const connectionSet = new Set(CONNECTION_TAB_TYPES as string[]);
const nonConnectionTabs = tabs.filter(
(t) => !connectionSet.has(t.type),
(t) => !isConnectionTabType(t.type),
);
onReorderTabs([...nonConnectionTabs, ...reordered]);
}
@@ -465,26 +430,36 @@ export function ConnectionsPanel({
activeSessions.map((s) => [s.tabInstanceId, s]),
);
const hasAnything =
openTabs.length > 0 || backgroundTabs.length > 0 || sharedWithMe.length > 0;
const hasAnything = openTabs.length > 0 || backgroundTabs.length > 0;
const hasResults =
filteredOpenTabs.length > 0 ||
filteredBackgroundTabs.length > 0 ||
filteredSharedWithMe.length > 0;
filteredOpenTabs.length > 0 || filteredBackgroundTabs.length > 0;
const pluginSections = (
<ComponentSlot
slotId="connections.sections"
props={{
search: q,
openTabData: tabs.map((tab) => tab.data ?? {}),
}}
/>
);
if (!hasAnything) {
return (
<div className="flex flex-col items-center justify-center flex-1 gap-3 p-6 text-center py-16">
<div className="size-10 rounded-full bg-muted/40 flex items-center justify-center">
<Plug className="size-5 text-muted-foreground/30" />
</div>
<div className="flex flex-col gap-1">
<span className="text-sm font-semibold text-muted-foreground/60">
{t("connections.noConnections")}
</span>
<span className="text-xs text-muted-foreground/40">
{t("connections.noConnectionsDesc")}
</span>
<div className="flex flex-col flex-1">
{pluginSections}
<div className="flex flex-col items-center justify-center flex-1 gap-3 p-6 text-center py-16">
<div className="size-10 rounded-full bg-muted/40 flex items-center justify-center">
<Plug className="size-5 text-muted-foreground/30" />
</div>
<div className="flex flex-col gap-1">
<span className="text-sm font-semibold text-muted-foreground/60">
{t("connections.noConnections")}
</span>
<span className="text-xs text-muted-foreground/40">
{t("connections.noConnectionsDesc")}
</span>
</div>
</div>
</div>
);
@@ -521,10 +496,10 @@ export function ConnectionsPanel({
const liveSession = tab.instanceId
? sessionByInstanceId.get(tab.instanceId)
: undefined;
const isLive =
tab.type === "terminal"
? (liveSession?.isConnected ?? false)
: true;
const tracksSession = !!getTabType(tab.type)?.commandTarget;
const isLive = tracksSession
? (liveSession?.isConnected ?? false)
: true;
const duration = liveSession?.createdAt
? formatDuration(now - liveSession.createdAt)
: formatDuration(now - tab.openedAt);
@@ -563,7 +538,7 @@ export function ConnectionsPanel({
name={displayName}
hostName={tab.customLabel ? hostName : undefined}
subLabel={
isLive && tab.type === "terminal"
isLive && tracksSession
? t("connections.connectedFor", { duration })
: isLive
? t("connections.connected")
@@ -632,77 +607,8 @@ export function ConnectionsPanel({
</div>
)}
{filteredSharedWithMe.length > 0 && (
<div
className={`flex flex-col ${filteredOpenTabs.length > 0 || filteredBackgroundTabs.length > 0 ? "mt-2" : ""}`}
>
<SectionHeader
label={t("connections.sectionSharedWithMe")}
count={filteredSharedWithMe.length}
/>
{filteredSharedWithMe.map((session) => (
<SharedWithMeRow
key={session.sessionId}
session={session}
onJoin={() => onJoinSharedSession?.(session)}
/>
))}
</div>
)}
</div>
);
}
function SharedWithMeRow({
session,
onJoin,
}: {
session: ActiveSessionInfo;
onJoin: () => void;
}) {
const { t } = useTranslation();
const isReadWrite = session.permissionLevel === "read-write";
return (
<div className="group flex items-center gap-2.5 px-3 py-2.5 border-b border-border/40 last:border-b-0">
<div className="shrink-0 flex items-center justify-center size-7 rounded bg-muted/60 text-muted-foreground">
{tabIcon("terminal")}
</div>
<div className="flex flex-col flex-1 min-w-0 gap-0.5">
<div className="flex items-center gap-1.5 min-w-0">
<span
className={`shrink-0 size-1.5 rounded-full ${
session.isConnected ? "bg-green-500" : "bg-muted-foreground/30"
}`}
/>
<span className="text-xs font-semibold truncate flex-1 text-foreground">
{session.hostName}
</span>
<Badge
variant="outline"
className={`text-[9px] px-1 py-0 h-4 font-mono shrink-0 border-border/60 ${
isReadWrite ? "text-accent-brand" : "text-muted-foreground/60"
}`}
>
{isReadWrite
? t("sessionSharing.permissionLevel.readWrite")
: t("sessionSharing.permissionLevel.readOnly")}
</Badge>
</div>
<span className="text-[10px] text-muted-foreground/60 truncate pl-3">
{t("connections.sharedBy", {
username: session.sharedByUsername ?? "?",
})}
</span>
</div>
<Button
variant="outline"
size="sm"
className="h-6 text-[10px] px-2 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={onJoin}
>
{t("connections.join")}
</Button>
{/* Sections a plugin adds, such as sessions others shared with you. */}
{pluginSections}
</div>
);
}
+6 -7
View File
@@ -1,8 +1,5 @@
import { useRef, useState } from "react";
import {
SecretReferenceHint,
SecretSourceManager,
} from "./SecretSourceManager";
import { PluginComponent } from "@/plugin-host/component-registry";
import { useTranslation } from "react-i18next";
import { copyToClipboard } from "@/lib/clipboard";
import { Copy, Info, Lock, Upload, X } from "lucide-react";
@@ -296,12 +293,14 @@ export function CredentialEditorView({
value={credForm.password}
onChange={(e) => setCredField("password", e.target.value)}
/>
<SecretReferenceHint
<PluginComponent
id="credentials.secretHint"
onManage={() => setShowSecretSources((v) => !v)}
/>
</div>
{showSecretSources && (
<SecretSourceManager
<PluginComponent
id="credentials.secretManager"
onClose={() => setShowSecretSources(false)}
/>
)}
@@ -394,7 +393,7 @@ export function CredentialEditorView({
/>
{credForm.value === "existing_key" && (
<div className="px-3 py-2 text-[10px] border border-accent-brand/30 bg-accent-brand/5 text-accent-brand">
{t("hosts.keySaved")} — {t("hosts.keyReplaceNotice")}
{t("hosts.keySaved")}. {t("hosts.keyReplaceNotice")}
</div>
)}
<textarea
+506 -254
View File
@@ -1,17 +1,26 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Loader2, Trash2, Users, UserRound } from "lucide-react";
import {
ArrowLeft,
Check,
ListChecks,
Search,
Share2,
Shield,
User,
Users,
X,
} from "lucide-react";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/dialog";
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/dropdown-menu";
import { Select2 } from "@/components/select2";
import {
getCredentialAccess,
getRoles,
@@ -20,17 +29,41 @@ import {
type CredentialPermissionLevel,
type ShareTarget,
} from "@/api/rbac-api";
import { getUserList, type AccessRecord, type Role } from "@/main-axios";
import { getUserList, type AccessRecord } from "@/main-axios";
import type { Credential } from "@/types/ui-types";
import { getErrorMessage } from "@/lib/error-message";
const PERMISSION_LEVELS: CredentialPermissionLevel[] = ["use", "manage"];
const LEVEL_KEYS: Record<
CredentialPermissionLevel,
{ label: string; description: string }
> = {
use: {
label: "credentials.share.levelUse",
description: "credentials.share.levelUseDesc",
},
manage: {
label: "credentials.share.levelManage",
description: "credentials.share.levelManageDesc",
},
};
const EXPIRY_PRESETS = [
{ key: "never", hours: undefined },
{ key: "never", hours: null },
{ key: "oneHour", hours: 1 },
{ key: "oneDay", hours: 24 },
{ key: "sevenDays", hours: 24 * 7 },
{ key: "thirtyDays", hours: 24 * 30 },
{ key: "custom", hours: undefined },
] as const;
type ExpiryPresetKey = (typeof EXPIRY_PRESETS)[number]["key"];
function levelOf(record: AccessRecord): CredentialPermissionLevel {
return record.permissionLevel === "manage" ? "manage" : "use";
}
export function CredentialShareModal({
credential,
onClose,
@@ -39,285 +72,504 @@ export function CredentialShareModal({
onClose: () => void;
}) {
const { t } = useTranslation();
const [tab, setTab] = useState<"user" | "role">("user");
const [targetTab, setTargetTab] = useState<"user" | "role">("user");
const [search, setSearch] = useState("");
const [users, setUsers] = useState<Array<{ id: string; username: string }>>(
[],
const [shareUsers, setShareUsers] = useState<
{ id: string; username: string }[]
>([]);
const [shareRoles, setShareRoles] = useState<
{ id: number; name: string; displayName?: string }[]
>([]);
const [selectedUserIds, setSelectedUserIds] = useState<Set<string>>(
new Set(),
);
const [roles, setRoles] = useState<Role[]>([]);
const [selectedUsers, setSelectedUsers] = useState<Set<string>>(new Set());
const [selectedRoles, setSelectedRoles] = useState<Set<number>>(new Set());
const [level, setLevel] = useState<CredentialPermissionLevel>("use");
const [expiry, setExpiry] =
useState<(typeof EXPIRY_PRESETS)[number]["key"]>("never");
const [access, setAccess] = useState<AccessRecord[]>([]);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [selectedRoleIds, setSelectedRoleIds] = useState<Set<number>>(
new Set(),
);
const [permissionLevel, setPermissionLevel] =
useState<CredentialPermissionLevel>("use");
const [expiryPreset, setExpiryPreset] = useState<ExpiryPresetKey>("never");
const [customHours, setCustomHours] = useState("");
const [accessList, setAccessList] = useState<AccessRecord[]>([]);
const [loadError, setLoadError] = useState(false);
const [submitting, setSubmitting] = useState(false);
const credentialId = credential ? Number(credential.id) : null;
const loadAccess = useCallback(async () => {
const refreshAccessList = useCallback(async () => {
if (!credentialId) return;
try {
setAccess((await getCredentialAccess(credentialId)).access);
} catch {
setAccess([]);
}
setAccessList((await getCredentialAccess(credentialId)).access ?? []);
}, [credentialId]);
useEffect(() => {
if (!credential) return;
setSelectedUsers(new Set());
setSelectedRoles(new Set());
setSearch("");
setLoading(true);
Promise.all([
getUserList().catch(() => ({ users: [] })),
getRoles().catch(() => ({ roles: [] as Role[] })),
loadAccess(),
])
.then(([userResult, roleResult]) => {
setUsers(
userResult.users.map((u) => ({ id: u.userId, username: u.username })),
);
setRoles(roleResult.roles);
})
.finally(() => setLoading(false));
}, [credential, loadAccess]);
setSelectedUserIds(new Set());
setSelectedRoleIds(new Set());
setPermissionLevel("use");
setExpiryPreset("never");
setCustomHours("");
setTargetTab("user");
setAccessList([]);
setLoadError(false);
if (!credentialId) return;
const filteredUsers = useMemo(
() =>
users.filter((u) =>
u.username.toLowerCase().includes(search.toLowerCase()),
),
[users, search],
);
const filteredRoles = useMemo(
() =>
roles.filter((r) =>
(r.displayName || r.name).toLowerCase().includes(search.toLowerCase()),
),
[roles, search],
);
Promise.all([
getCredentialAccess(credentialId).catch(() => ({ access: [] })),
getUserList().catch(() => ({ users: [] })),
getRoles().catch(() => ({ roles: [] })),
])
.then(([accessRes, usersRes, rolesRes]) => {
setAccessList(accessRes.access ?? []);
setShareUsers(
(usersRes.users ?? []).map((u) => ({
id: String(u.userId),
username: u.username,
})),
);
setShareRoles(
(rolesRes.roles ?? [])
.filter((r) => !r.isSystem)
.map((r) => ({
id: Number(r.id),
name: r.name,
displayName: r.displayName,
})),
);
})
.catch(() => setLoadError(true));
}, [credentialId]);
const filteredUsers = useMemo(() => {
const q = search.trim().toLowerCase();
return q
? shareUsers.filter((u) => u.username.toLowerCase().includes(q))
: shareUsers;
}, [shareUsers, search]);
const filteredRoles = useMemo(() => {
const q = search.trim().toLowerCase();
return q
? shareRoles.filter(
(r) =>
r.name.toLowerCase().includes(q) ||
(r.displayName ?? "").toLowerCase().includes(q),
)
: shareRoles;
}, [shareRoles, search]);
const selectedCount = selectedUserIds.size + selectedRoleIds.size;
const durationHours = (() => {
if (expiryPreset === "never") return undefined;
if (expiryPreset === "custom") {
const hours = Number(customHours);
return Number.isFinite(hours) && hours > 0 ? hours : undefined;
}
return (
EXPIRY_PRESETS.find((p) => p.key === expiryPreset)?.hours ?? undefined
);
})();
async function handleShare() {
if (!credentialId) return;
if (!credentialId || selectedCount === 0) return;
const targets: ShareTarget[] = [
...Array.from(selectedUsers, (id) => ({ type: "user" as const, id })),
...Array.from(selectedRoles, (id) => ({ type: "role" as const, id })),
...Array.from(selectedUserIds, (id) => ({ type: "user" as const, id })),
...Array.from(selectedRoleIds, (id) => ({ type: "role" as const, id })),
];
if (targets.length === 0) return;
setSaving(true);
setSubmitting(true);
try {
const preset = EXPIRY_PRESETS.find((p) => p.key === expiry);
await shareCredential(credentialId, targets, level, preset?.hours);
await shareCredential(
credentialId,
targets,
permissionLevel,
durationHours,
);
await refreshAccessList();
setSelectedUserIds(new Set());
setSelectedRoleIds(new Set());
toast.success(t("credentials.share.shared"));
setSelectedUsers(new Set());
setSelectedRoles(new Set());
await loadAccess();
} catch (e) {
toast.error(getErrorMessage(e));
} finally {
setSaving(false);
setSubmitting(false);
}
}
async function handleRevoke(entry: AccessRecord) {
// No update route for credential grants, so re-share with the time left.
async function handleLevelChange(
record: AccessRecord,
level: CredentialPermissionLevel,
) {
if (!credentialId || levelOf(record) === level) return;
const target: ShareTarget | null =
record.targetType === "role" && record.roleId != null
? { type: "role", id: Number(record.roleId) }
: record.userId
? { type: "user", id: String(record.userId) }
: null;
if (!target) return;
let remainingHours: number | undefined;
if (record.expiresAt) {
remainingHours =
(new Date(record.expiresAt).getTime() - Date.now()) / 3_600_000;
if (remainingHours <= 0) {
toast.error(t("hosts.sharing.accessUpdateFailed"));
return;
}
}
try {
await shareCredential(credentialId, [target], level, remainingHours);
await refreshAccessList();
toast.success(t("hosts.sharing.accessUpdated"));
} catch {
toast.error(t("hosts.sharing.accessUpdateFailed"));
}
}
async function handleRevoke(record: AccessRecord) {
if (!credentialId) return;
try {
await revokeCredentialAccess(credentialId, entry.id);
await loadAccess();
await revokeCredentialAccess(credentialId, record.id);
setAccessList((prev) => prev.filter((entry) => entry.id !== record.id));
toast.success(t("credentials.share.accessRevoked"));
} catch (e) {
toast.error(getErrorMessage(e));
}
}
const toggle = <T,>(set: Set<T>, value: T): Set<T> => {
const next = new Set(set);
if (next.has(value)) next.delete(value);
else next.add(value);
return next;
};
if (!credential) return null;
return (
<Dialog open={!!credential} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{t("credentials.share.title", { name: credential?.name ?? "" })}
</DialogTitle>
<DialogDescription>
{t("credentials.share.description")}
</DialogDescription>
</DialogHeader>
<div className="absolute inset-0 z-20 flex flex-col bg-sidebar">
<button
onClick={onClose}
className="flex items-center gap-2 px-3 py-2 shrink-0 border-b border-border text-xs text-muted-foreground hover:text-foreground transition-colors w-full text-left"
>
<ArrowLeft className="size-3.5 shrink-0" />
<span className="truncate">
{t("credentials.share.title", { name: credential.name ?? "" })}
</span>
</button>
{loading ? (
<div className="flex justify-center py-6">
<Loader2 className="size-4 animate-spin text-muted-foreground" />
{loadError && (
<div className="flex items-start gap-2 px-3 py-2 shrink-0 border-b border-destructive/30 bg-destructive/5 text-xs text-destructive">
<Shield className="size-3.5 shrink-0 mt-0.5" />
<div>{t("hosts.sharing.loadError")}</div>
</div>
)}
<div className="flex flex-col gap-2 p-3 shrink-0 border-b border-border">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
<Users className="size-3.5" />
{t("hosts.sharing.shareWithSection")}
</div>
) : (
<div className="flex flex-col gap-3">
<div className="flex gap-1">
{(["user", "role"] as const).map((kind) => (
<button
key={kind}
type="button"
onClick={() => setTab(kind)}
className={`flex-1 flex items-center justify-center gap-1 py-1 text-[10px] font-semibold border ${
tab === kind
? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand"
: "border-border text-muted-foreground hover:text-foreground"
}`}
>
{kind === "user" ? (
<UserRound className="size-3" />
) : (
<Users className="size-3" />
)}
{t(
kind === "user"
? "credentials.share.users"
: "credentials.share.roles",
)}
</button>
))}
</div>
<Input
className="h-8 text-xs"
placeholder={t("credentials.share.searchPlaceholder")}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<div className="flex flex-col gap-1 max-h-40 overflow-y-auto">
{tab === "user"
? filteredUsers.map((u) => (
<label
key={u.id}
className="flex items-center gap-2 text-xs px-1 py-0.5 cursor-pointer"
>
<input
type="checkbox"
checked={selectedUsers.has(u.id)}
onChange={() =>
setSelectedUsers((s) => toggle(s, u.id))
}
/>
{u.username}
</label>
))
: filteredRoles.map((r) => (
<label
key={r.id}
className="flex items-center gap-2 text-xs px-1 py-0.5 cursor-pointer"
>
<input
type="checkbox"
checked={selectedRoles.has(r.id)}
onChange={() =>
setSelectedRoles((s) => toggle(s, r.id))
}
/>
{r.displayName || r.name}
</label>
))}
</div>
<div className="grid grid-cols-2 gap-2">
<div className="flex flex-col gap-1">
<span className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground">
{t("credentials.share.permission")}
</span>
<select
value={level}
onChange={(e) =>
setLevel(e.target.value as CredentialPermissionLevel)
}
className="h-8 border border-border bg-background px-2 text-xs outline-none"
>
<option value="use">{t("credentials.share.levelUse")}</option>
<option value="manage">
{t("credentials.share.levelManage")}
</option>
</select>
</div>
<div className="flex flex-col gap-1">
<span className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground">
{t("credentials.share.expires")}
</span>
<select
value={expiry}
onChange={(e) => setExpiry(e.target.value as typeof expiry)}
className="h-8 border border-border bg-background px-2 text-xs outline-none"
>
{EXPIRY_PRESETS.map((p) => (
<option key={p.key} value={p.key}>
{t(`credentials.share.expiry.${p.key}`)}
</option>
))}
</select>
</div>
</div>
<p className="text-[10px] text-muted-foreground">
{t(
level === "manage"
? "credentials.share.levelManageDesc"
: "credentials.share.levelUseDesc",
)}
</p>
{access.length > 0 && (
<div className="flex flex-col gap-1 border-t border-border pt-2">
<span className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground">
{t("credentials.share.currentAccess")}
</span>
{access.map((entry) => (
<div
key={entry.id}
className="flex items-center gap-2 text-xs px-1 py-0.5"
>
{entry.targetType === "role" ? (
<Users className="size-3 text-muted-foreground" />
) : (
<UserRound className="size-3 text-muted-foreground" />
)}
<span className="flex-1 truncate">
{entry.targetType === "role"
? entry.roleDisplayName || entry.roleName
: entry.username}
</span>
<span className="text-[10px] uppercase text-muted-foreground">
{entry.permissionLevel}
</span>
<button
type="button"
className="text-muted-foreground hover:text-destructive"
onClick={() => void handleRevoke(entry)}
title={t("credentials.share.revoke")}
>
<Trash2 className="size-3.5" />
</button>
</div>
))}
</div>
)}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={onClose}>
{t("common.close")}
</Button>
<Button
onClick={() => void handleShare()}
disabled={
saving || (selectedUsers.size === 0 && selectedRoles.size === 0)
}
<a
href="https://docs.termix.site/features/authentication/rbac"
target="_blank"
rel="noreferrer"
className="text-[10px] text-accent-brand hover:underline shrink-0"
>
{saving && <Loader2 className="size-3.5 mr-1 animate-spin" />}
{t("credentials.share.shareButton")}
{t("hosts.docsLink")}
</a>
</div>
<p className="text-[11px] text-muted-foreground leading-snug">
{t("credentials.share.description")}
</p>
<div className="flex gap-1.5">
{(["user", "role"] as const).map((tab) => (
<button
key={tab}
onClick={() => setTargetTab(tab)}
className={`flex-1 flex items-center justify-center gap-1 px-2 py-1.5 text-[10px] font-bold uppercase tracking-widest border transition-colors ${targetTab === tab ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:text-foreground"}`}
>
{tab === "user" ? (
<User className="size-3 shrink-0" />
) : (
<Shield className="size-3 shrink-0" />
)}
{tab === "user"
? t("hosts.sharing.usersTab")
: t("hosts.sharing.rolesTab")}
{tab === "user" && selectedUserIds.size > 0 && (
<span>({selectedUserIds.size})</span>
)}
{tab === "role" && selectedRoleIds.size > 0 && (
<span>({selectedRoleIds.size})</span>
)}
</button>
))}
</div>
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground/50" />
<Input
placeholder={t("hosts.sharing.searchPlaceholder")}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-8"
/>
</div>
<div className="flex flex-col border border-border h-28 overflow-y-auto">
{targetTab === "user" &&
(filteredUsers.length === 0 ? (
<div className="px-3 py-4 text-xs text-muted-foreground/50 text-center">
{t("hosts.sharing.noMatches")}
</div>
) : (
filteredUsers.map((user) => {
const isSelected = selectedUserIds.has(user.id);
return (
<button
key={user.id}
onClick={() =>
setSelectedUserIds((prev) => {
const next = new Set(prev);
if (next.has(user.id)) next.delete(user.id);
else next.add(user.id);
return next;
})
}
className={`flex items-center gap-2 px-2.5 py-1.5 text-xs text-left border-b border-border/50 last:border-0 transition-colors shrink-0 ${isSelected ? "bg-accent-brand/10 text-accent-brand" : "hover:bg-muted/40"}`}
>
<div
className={`size-3.5 border flex items-center justify-center shrink-0 transition-colors ${isSelected ? "border-accent-brand bg-accent-brand" : "border-border bg-background"}`}
>
{isSelected && (
<Check className="size-2.5 text-background" />
)}
</div>
<User className="size-3 text-muted-foreground shrink-0" />
<span className="truncate">{user.username}</span>
</button>
);
})
))}
{targetTab === "role" &&
(filteredRoles.length === 0 ? (
<div className="px-3 py-4 text-xs text-muted-foreground/50 text-center">
{t("hosts.sharing.noMatches")}
</div>
) : (
filteredRoles.map((role) => {
const isSelected = selectedRoleIds.has(role.id);
return (
<button
key={role.id}
onClick={() =>
setSelectedRoleIds((prev) => {
const next = new Set(prev);
if (next.has(role.id)) next.delete(role.id);
else next.add(role.id);
return next;
})
}
className={`flex items-center gap-2 px-2.5 py-1.5 text-xs text-left border-b border-border/50 last:border-0 transition-colors shrink-0 ${isSelected ? "bg-accent-brand/10 text-accent-brand" : "hover:bg-muted/40"}`}
>
<div
className={`size-3.5 border flex items-center justify-center shrink-0 transition-colors ${isSelected ? "border-accent-brand bg-accent-brand" : "border-border bg-background"}`}
>
{isSelected && (
<Check className="size-2.5 text-background" />
)}
</div>
<Shield className="size-3 text-muted-foreground shrink-0" />
<span className="truncate">
{role.displayName || role.name}
</span>
</button>
);
})
))}
</div>
<div className="flex items-end gap-2">
<div className="flex flex-col gap-1 flex-1 min-w-0">
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("hosts.sharing.permissionLevelLabel")}
</span>
<Select2
value={permissionLevel}
onChange={(e) =>
setPermissionLevel(e.target.value as CredentialPermissionLevel)
}
className="h-8 w-full px-2.5 text-xs border border-border bg-background hover:bg-muted/40 transition-colors"
>
{PERMISSION_LEVELS.map((level) => (
<option key={level} value={level}>
{t(LEVEL_KEYS[level].label)}
</option>
))}
</Select2>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="flex flex-col gap-1 shrink-0">
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground text-left">
{t("hosts.sharing.expiryLabel")}
</span>
<span className="h-8 flex items-center justify-center px-2.5 text-xs border border-border hover:bg-muted/40 transition-colors whitespace-nowrap">
{t(`hosts.sharing.expiry.${expiryPreset}`)}
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="text-xs">
{EXPIRY_PRESETS.map((preset) => (
<DropdownMenuItem
key={preset.key}
onClick={() => setExpiryPreset(preset.key)}
>
{expiryPreset === preset.key ? (
<Check className="size-3 mr-1.5" />
) : (
<span className="size-3 mr-1.5" />
)}
{t(`hosts.sharing.expiry.${preset.key}`)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="outline"
className="h-8 shrink-0 border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
disabled={
selectedCount === 0 ||
submitting ||
(expiryPreset === "custom" && !durationHours)
}
onClick={handleShare}
>
<Share2 className="size-3.5 mr-1.5" />
{selectedCount > 0
? t("hosts.sharing.shareWithCount", { count: selectedCount })
: t("hosts.sharing.shareButton")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
<p className="text-[11px] text-muted-foreground leading-snug">
{t(LEVEL_KEYS[permissionLevel].description)}
</p>
{expiryPreset === "custom" && (
<Input
type="number"
autoFocus
placeholder={t("hosts.sharing.customHoursPlaceholder")}
value={customHours}
onChange={(e) => setCustomHours(e.target.value)}
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
)}
</div>
<div className="flex flex-col flex-1 min-h-0">
<div className="flex items-center gap-1.5 px-3 py-2 shrink-0 border-b border-border text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
<ListChecks className="size-3.5" />
{t("hosts.sharing.currentAccess")}
{accessList.length > 0 && (
<span className="text-muted-foreground/40">
({accessList.length})
</span>
)}
</div>
<div className="flex-1 min-h-0 overflow-y-auto">
{accessList.length === 0 && (
<div className="px-3 py-6 text-xs text-muted-foreground/50 text-center">
{t("credentials.share.noAccessEntries")}
</div>
)}
{accessList.map((record) => {
const expired =
record.expiresAt && new Date(record.expiresAt) < new Date();
const recordLevel = levelOf(record);
return (
<div
key={record.id}
className="flex flex-col gap-1 px-3 py-2 border-b border-border/60 last:border-0 text-xs"
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5 min-w-0">
{record.targetType === "user" ? (
<User className="size-3 text-muted-foreground shrink-0" />
) : (
<Shield className="size-3 text-muted-foreground shrink-0" />
)}
<span className="font-semibold truncate">
{record.username ??
record.roleDisplayName ??
record.roleName ??
record.userId ??
record.roleId}
</span>
</div>
<div className="flex items-center gap-1 shrink-0">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-widest border border-accent-brand/30 bg-accent-brand/10 text-accent-brand transition-colors hover:bg-accent-brand/20">
{t(LEVEL_KEYS[recordLevel].label)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="text-xs">
{PERMISSION_LEVELS.map((level) => (
<DropdownMenuItem
key={level}
onClick={() => handleLevelChange(record, level)}
>
{recordLevel === level ? (
<Check className="size-3 mr-1.5" />
) : (
<span className="size-3 mr-1.5" />
)}
{t(LEVEL_KEYS[level].label)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="ghost"
size="sm"
className="h-6 text-[10px] px-2 text-destructive hover:bg-destructive/10"
onClick={() => void handleRevoke(record)}
>
{t("hosts.sharing.revoke")}
</Button>
</div>
</div>
<div className="flex items-center gap-3 text-[10px] text-muted-foreground pl-4">
<span>
{t("hosts.sharing.grantedBy")}:{" "}
<span className="text-foreground/70">
{record.grantedByUsername ?? "?"}
</span>
</span>
<span className={expired ? "text-destructive" : ""}>
{t("hosts.sharing.expires")}:{" "}
{expired ? (
<span className="inline-flex items-center gap-0.5 text-destructive">
<X className="size-3" />
{t("hosts.sharing.expired")}
</span>
) : record.expiresAt ? (
<span className="text-foreground/70">
{new Date(record.expiresAt).toLocaleString()}
</span>
) : (
<span className="text-foreground/70">
{t("hosts.sharing.never")}
</span>
)}
</span>
</div>
</div>
);
})}
</div>
</div>
</div>
);
}
+19 -12
View File
@@ -1,4 +1,5 @@
import { useState } from "react";
import { useSshAuthProviders } from "@/hooks/useSshAuthProviders";
import { useTranslation } from "react-i18next";
import {
ArrowUpDown,
@@ -36,6 +37,7 @@ export function CredentialsPanel({
active?: boolean;
}) {
const { t } = useTranslation();
const sshAuthProviders = useSshAuthProviders();
const { preferences: sidebarPrefs, update: updateSidebarPrefs } =
useCredentialSidebarPreferences();
const [search, setSearch] = useState("");
@@ -233,18 +235,23 @@ export function CredentialsPanel({
<DropdownMenuLabel>
{t("credentials.filterTypeGroup")}
</DropdownMenuLabel>
{(["password", "key"] as const).map((val) => (
<DropdownMenuCheckboxItem
key={val}
checked={filterState.type.includes(val)}
onCheckedChange={() => handleFilterToggle("type", val)}
onSelect={(e) => e.preventDefault()}
>
{t(
`credentials.filterType${val.charAt(0).toUpperCase() + val.slice(1)}`,
)}
</DropdownMenuCheckboxItem>
))}
{sshAuthProviders.providers
.filter((option) => option.credentialType)
.map((option) => (
<DropdownMenuCheckboxItem
key={option.type}
checked={filterState.type.includes(option.type)}
onCheckedChange={() =>
handleFilterToggle("type", option.type)
}
onSelect={(e) => e.preventDefault()}
>
{t(
`credentials.filterType${option.type.charAt(0).toUpperCase() + option.type.slice(1)}`,
{ defaultValue: t(option.labelKey) },
)}
</DropdownMenuCheckboxItem>
))}
{allTags.length > 0 && (
<>
<DropdownMenuSeparator />
+50
View File
@@ -9,6 +9,7 @@ import {
} from "@/components/dialog";
import { SectionCard, SettingRow, FakeSwitch } from "@/components/section-card";
import type {
HostClickBehavior,
HostDensity,
HostSidebarPreferences,
HostTrayTrigger,
@@ -97,6 +98,20 @@ export function CustomizeSidebarPanel({
}
/>
</SettingRow>
<SettingRow
label={t("hosts.showFolderPaths")}
description={t("hosts.showFolderPathsDesc")}
>
<FakeSwitch
checked={preferences.display.showFolderPaths}
onChange={(v) =>
update((prev) => ({
...prev,
display: { ...prev.display, showFolderPaths: v },
}))
}
/>
</SettingRow>
<div className="flex flex-col gap-1.5 py-3 border-b border-border last:border-0">
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium leading-snug">
@@ -125,12 +140,47 @@ export function CustomizeSidebarPanel({
<option value="click">{t("hosts.actionsClick")}</option>
</select>
</div>
<div className="flex flex-col gap-1.5 py-3 border-b border-border last:border-0">
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium leading-snug">
{t("hosts.hostClickBehavior")}
</span>
<span className="text-xs text-muted-foreground leading-snug">
{t("hosts.hostClickBehaviorDesc")}
</span>
</div>
<select
value={preferences.display.hostClickBehavior}
onChange={(e) =>
update((prev) => ({
...prev,
display: {
...prev.display,
hostClickBehavior: e.target.value as HostClickBehavior,
},
}))
}
className="h-8 w-full border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="newTab">{t("hosts.hostClickNewTab")}</option>
<option value="focusExisting">
{t("hosts.hostClickFocusExisting")}
</option>
<option value="focusExistingDoubleClickNew">
{t("hosts.hostClickFocusExistingDoubleNew")}
</option>
</select>
</div>
<SettingRow
label={t("hosts.openOnDoubleClick")}
description={t("hosts.openOnDoubleClickDesc")}
>
<FakeSwitch
checked={preferences.display.openOnDoubleClick}
disabled={
preferences.display.hostClickBehavior ===
"focusExistingDoubleClickNew"
}
onChange={(v) =>
update((prev) => ({
...prev,
+48
View File
@@ -0,0 +1,48 @@
import { useTranslation } from "react-i18next";
import { tabJumpHotkeyKeys } from "@/lib/tab-jump-hotkey";
/** Shortcuts the shell handles itself; they cannot be rebound. */
export function fixedShortcuts(): Array<{ keys: string[]; labelKey: string }> {
return [
{ keys: ["Ctrl", "Shift", "\\"], labelKey: "splitRight" },
{ keys: ["Ctrl", "Shift", "-"], labelKey: "splitDown" },
{ keys: ["Ctrl", "Shift", "Enter"], labelKey: "zoomPane" },
{ keys: ["Alt", "↑↓←→"], labelKey: "navigatePane" },
{ keys: ["Ctrl", "Shift", "]"], labelKey: "nextTab" },
{ keys: ["Ctrl", "Shift", "["], labelKey: "previousTab" },
{ keys: tabJumpHotkeyKeys(), labelKey: "jumpTab" },
{ keys: ["Ctrl", "Shift", "E"], labelKey: "previousView" },
{ keys: ["Ctrl", "Shift", "F"], labelKey: "fullscreen" },
];
}
export function FixedShortcutsList() {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-1.5">
<span className="text-xs font-semibold text-muted-foreground">
{t("newUi.sidebar.keybindings.fixedHeading")}
</span>
{fixedShortcuts().map(({ keys, labelKey }) => (
<div
key={labelKey}
className="flex items-center justify-between gap-2 border border-border/60 px-2.5 py-1.5"
>
<span className="text-xs text-muted-foreground flex-1 min-w-0 truncate">
{t(`newUi.sidebar.keybindings.fixed.${labelKey}`)}
</span>
<div className="flex items-center gap-0.5 shrink-0">
{keys.map((key, index) => (
<kbd
key={index}
className="inline-flex items-center px-1 py-0.5 text-[10px] font-mono bg-muted border border-border/60 text-muted-foreground leading-none"
>
{key}
</kbd>
))}
</div>
</div>
))}
</div>
);
}
-419
View File
@@ -1,419 +0,0 @@
import { getErrorMessage } from "../lib/error-message.js";
import { useEffect, useMemo, useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
ArrowDown,
ArrowUp,
ArrowUpDown,
Loader2,
RefreshCw,
Search,
Server,
} from "lucide-react";
import { Input } from "@/components/input";
import { Button } from "@/components/button";
import { cn } from "@/lib/utils";
import {
listFleets,
getFleetInventory,
refreshFleetInventory,
type FleetRow,
type FleetInventoryEntry,
} from "@/api/fleets-api";
type SortKey =
| "hostName"
| "osPrettyName"
| "kernel"
| "architecture"
| "uptimeSeconds"
| "packageManager"
| "collectedAt";
type SortDirection = "asc" | "desc";
function formatUptime(seconds: number | null): string {
if (seconds === null) return "-";
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (d > 0) return `${d}d ${h}h`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
function compareValues(
a: string | number | null,
b: string | number | null,
): number {
if (a === null && b === null) return 0;
if (a === null) return 1;
if (b === null) return -1;
if (typeof a === "number" && typeof b === "number") return a - b;
return String(a).localeCompare(String(b));
}
function SortHeader({
label,
column,
sortKey,
sortDirection,
onSort,
}: {
label: string;
column: SortKey;
sortKey: SortKey;
sortDirection: SortDirection;
onSort: (column: SortKey) => void;
}) {
const active = sortKey === column;
return (
<th className="px-3 py-2 text-left font-semibold">
<button
type="button"
onClick={() => onSort(column)}
className="flex items-center gap-1 hover:text-foreground"
>
{label}
{active ? (
sortDirection === "asc" ? (
<ArrowUp className="size-3" />
) : (
<ArrowDown className="size-3" />
)
) : (
<ArrowUpDown className="size-3 opacity-40" />
)}
</button>
</th>
);
}
export function FleetInventoryTab({
fleetId,
isVisible = true,
}: {
fleetId?: number;
isVisible?: boolean;
}) {
const { t } = useTranslation();
const [fleets, setFleets] = useState<FleetRow[]>([]);
const [selectedFleetId, setSelectedFleetId] = useState<number | undefined>(
fleetId,
);
const [entries, setEntries] = useState<FleetInventoryEntry[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [search, setSearch] = useState("");
const [sortKey, setSortKey] = useState<SortKey>("hostName");
const [sortDirection, setSortDirection] = useState<SortDirection>("asc");
useEffect(() => {
if (fleetId !== undefined) setSelectedFleetId(fleetId);
}, [fleetId]);
useEffect(() => {
listFleets()
.then(setFleets)
.catch(() => {});
}, []);
useEffect(() => {
if (!fleets.length) return;
if (selectedFleetId === undefined) {
setSelectedFleetId(fleets[0].id);
}
}, [fleets, selectedFleetId]);
const loadInventory = useCallback(async () => {
if (selectedFleetId === undefined) return;
setLoading(true);
try {
const data = await getFleetInventory(selectedFleetId);
setEntries(data);
} catch (error) {
const message = getErrorMessage(error, "");
toast.error(message || t("newUi.sidebar.fleets.inventoryLoadFailed"));
} finally {
setLoading(false);
}
}, [selectedFleetId, t]);
useEffect(() => {
if (!isVisible) return;
loadInventory();
}, [loadInventory, isVisible]);
async function handleRefresh() {
if (selectedFleetId === undefined) return;
setRefreshing(true);
try {
const { results } = await refreshFleetInventory(selectedFleetId);
const failed = results.filter((r) => !r.success).length;
if (failed > 0) {
toast.warning(
t("newUi.sidebar.fleets.commandPartialFailure", {
failed,
total: results.length,
}),
);
} else {
toast.success(t("newUi.sidebar.fleets.inventoryRefreshed"));
}
await loadInventory();
} catch (error) {
const message = getErrorMessage(error, "");
toast.error(message || t("newUi.sidebar.fleets.inventoryRefreshFailed"));
} finally {
setRefreshing(false);
}
}
function handleSort(column: SortKey) {
if (sortKey === column) {
setSortDirection((prev) => (prev === "asc" ? "desc" : "asc"));
} else {
setSortKey(column);
setSortDirection("asc");
}
}
const selectedFleet = fleets.find((f) => f.id === selectedFleetId);
const filteredSorted = useMemo(() => {
const q = search.trim().toLowerCase();
const filtered = entries.filter(
(e) =>
!q ||
e.hostName.toLowerCase().includes(q) ||
(e.inventory?.osPrettyName ?? "").toLowerCase().includes(q) ||
(e.inventory?.hostname ?? "").toLowerCase().includes(q),
);
const dir = sortDirection === "asc" ? 1 : -1;
return [...filtered].sort((a, b) => {
let cmp = 0;
switch (sortKey) {
case "hostName":
cmp = compareValues(a.hostName, b.hostName);
break;
case "osPrettyName":
cmp = compareValues(
a.inventory?.osPrettyName ?? null,
b.inventory?.osPrettyName ?? null,
);
break;
case "kernel":
cmp = compareValues(
a.inventory?.kernel ?? null,
b.inventory?.kernel ?? null,
);
break;
case "architecture":
cmp = compareValues(
a.inventory?.architecture ?? null,
b.inventory?.architecture ?? null,
);
break;
case "uptimeSeconds":
cmp = compareValues(
a.inventory?.uptimeSeconds ?? null,
b.inventory?.uptimeSeconds ?? null,
);
break;
case "packageManager":
cmp = compareValues(
a.inventory?.packageManager ?? null,
b.inventory?.packageManager ?? null,
);
break;
case "collectedAt":
cmp = compareValues(
a.inventory?.collectedAt ?? null,
b.inventory?.collectedAt ?? null,
);
break;
}
return cmp * dir;
});
}, [entries, search, sortKey, sortDirection]);
const collectedCount = entries.filter((e) => e.inventory !== null).length;
return (
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
<div className="flex shrink-0 flex-wrap items-center gap-2 border-b border-border px-3 py-2.5">
<Server className="size-4 text-muted-foreground" />
<span className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
{t("newUi.sidebar.fleets.tabInventory")}
</span>
<select
className="px-2 py-1 text-[10px] bg-background border border-border text-foreground outline-none"
value={selectedFleetId ?? ""}
onChange={(e) => setSelectedFleetId(Number(e.target.value))}
>
{fleets.map((fleet) => (
<option key={fleet.id} value={fleet.id}>
{fleet.name}
</option>
))}
</select>
{selectedFleet && (
<span className="text-[11px] text-muted-foreground">
{t("newUi.sidebar.fleets.memberCount", {
count: entries.length,
})}{" "}
&middot;{" "}
{t("newUi.sidebar.fleets.inventoryCollectedCount", {
count: collectedCount,
total: entries.length,
})}
</span>
)}
<div className="ml-auto flex items-center gap-1.5">
<div className="relative">
<Search className="pointer-events-none absolute left-2 top-1/2 size-3 -translate-y-1/2 text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("newUi.sidebar.fleets.searchHosts")}
className="h-7 w-48 pl-6 text-xs"
/>
</div>
<Button
size="sm"
variant="outline"
onClick={handleRefresh}
disabled={refreshing || selectedFleetId === undefined}
>
{refreshing ? (
<Loader2 className="size-3.5 mr-1.5 animate-spin" />
) : (
<RefreshCw className="size-3.5 mr-1.5" />
)}
{t("newUi.sidebar.fleets.refreshInventory")}
</Button>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto thin-scrollbar">
{loading ? (
<div className="flex h-full items-center justify-center py-16 text-muted-foreground">
<Loader2 className="size-5 animate-spin" />
</div>
) : fleets.length === 0 ? (
<div className="flex h-full items-center justify-center py-16 text-xs text-muted-foreground">
{t("newUi.sidebar.fleets.noFleets")}
</div>
) : filteredSorted.length === 0 ? (
<div className="flex h-full items-center justify-center py-16 text-xs text-muted-foreground">
{t("newUi.sidebar.fleets.noMembers")}
</div>
) : (
<table className="w-full border-collapse text-xs">
<thead className="sticky top-0 z-10 bg-card">
<tr className="border-b border-border text-[10px] uppercase tracking-wider text-muted-foreground">
<SortHeader
label={t("newUi.sidebar.fleets.inventoryHost")}
column="hostName"
sortKey={sortKey}
sortDirection={sortDirection}
onSort={handleSort}
/>
<SortHeader
label={t("newUi.sidebar.fleets.inventoryOs")}
column="osPrettyName"
sortKey={sortKey}
sortDirection={sortDirection}
onSort={handleSort}
/>
<SortHeader
label={t("newUi.sidebar.fleets.inventoryKernel")}
column="kernel"
sortKey={sortKey}
sortDirection={sortDirection}
onSort={handleSort}
/>
<SortHeader
label={t("newUi.sidebar.fleets.inventoryArch")}
column="architecture"
sortKey={sortKey}
sortDirection={sortDirection}
onSort={handleSort}
/>
<SortHeader
label={t("newUi.sidebar.fleets.inventoryUptime")}
column="uptimeSeconds"
sortKey={sortKey}
sortDirection={sortDirection}
onSort={handleSort}
/>
<SortHeader
label={t("newUi.sidebar.fleets.inventoryPackageManager")}
column="packageManager"
sortKey={sortKey}
sortDirection={sortDirection}
onSort={handleSort}
/>
<SortHeader
label={t("newUi.sidebar.fleets.inventoryCollectedAt")}
column="collectedAt"
sortKey={sortKey}
sortDirection={sortDirection}
onSort={handleSort}
/>
</tr>
</thead>
<tbody className="divide-y divide-border">
{filteredSorted.map((entry) => (
<tr
key={entry.hostId}
className="transition-colors hover:bg-muted/40"
>
<td className="px-3 py-2">
<div className="flex items-center gap-2">
<span
className={cn(
"size-1.5 shrink-0",
entry.inventory
? "bg-green-500"
: "bg-muted-foreground/50",
)}
/>
<span className="min-w-0 truncate font-semibold">
{entry.hostName}
</span>
</div>
</td>
<td className="px-3 py-2 text-muted-foreground">
{entry.inventory?.osPrettyName ?? "-"}
</td>
<td className="px-3 py-2 font-mono text-muted-foreground">
{entry.inventory?.kernel ?? "-"}
</td>
<td className="px-3 py-2 text-muted-foreground">
{entry.inventory?.architecture ?? "-"}
</td>
<td className="px-3 py-2 whitespace-nowrap text-muted-foreground">
{formatUptime(entry.inventory?.uptimeSeconds ?? null)}
</td>
<td className="px-3 py-2 text-muted-foreground">
{entry.inventory?.packageManager ?? "-"}
</td>
<td className="px-3 py-2 whitespace-nowrap text-muted-foreground">
{entry.inventory?.collectedAt
? new Date(entry.inventory.collectedAt).toLocaleString()
: "-"}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
}
File diff suppressed because it is too large. Load diff
+16
View File
@@ -19,12 +19,15 @@ import {
} from "@/components/folder-style";
import { normalizePath, splitPath } from "./FolderPathPicker";
import { getCredentials } from "@/main-axios";
import { useSyncStatus } from "@/hooks/use-sync-status";
import { FakeSwitch, SettingRow } from "@/components/section-card";
export type FolderMetadataValue = {
name: string;
color: string;
icon: string;
credentialId: number | null;
localOnly: boolean;
};
type CredentialOption = { id: string; name: string; username?: string };
@@ -45,6 +48,7 @@ export function FolderMetadataDialog({
color?: string;
icon?: string;
credentialId?: number | null;
localOnly?: boolean;
};
/** Every folder path that already exists, for duplicate-name validation. */
existingPaths?: string[];
@@ -58,6 +62,8 @@ export function FolderMetadataDialog({
const [color, setColor] = useState(DEFAULT_FOLDER_COLOR);
const [icon, setIcon] = useState(DEFAULT_FOLDER_ICON);
const [credentialId, setCredentialId] = useState<string>("");
const [localOnly, setLocalOnly] = useState(false);
const syncLinked = !!useSyncStatus()?.linked;
const [credentials, setCredentials] = useState<CredentialOption[]>([]);
const parentPath =
@@ -84,6 +90,7 @@ export function FolderMetadataDialog({
setCredentialId(
initial?.credentialId ? String(initial.credentialId) : "",
);
setLocalOnly(!!initial?.localOnly);
}
}, [open, initial]);
@@ -111,6 +118,7 @@ export function FolderMetadataDialog({
color,
icon,
credentialId: credentialId ? Number(credentialId) : null,
localOnly,
});
onOpenChange(false);
}
@@ -186,6 +194,14 @@ export function FolderMetadataDialog({
{t("hosts.folderCredentialHint")}
</p>
</div>
{syncLinked && (
<SettingRow
label={t("hosts.folderLocalOnly")}
description={t("hosts.folderLocalOnlyDesc")}
>
<FakeSwitch checked={localOnly} onChange={setLocalOnly} />
</SettingRow>
)}
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold">
{t("hosts.folderPreview")}
-165
View File
@@ -1,165 +0,0 @@
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { copyToClipboard } from "@/lib/clipboard";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { Copy, Search, Terminal, Trash2 } from "lucide-react";
import {
getCommandHistory,
deleteCommandFromHistory,
clearCommandHistory,
} from "@/main-axios";
import type { Tab } from "@/types/ui-types";
export function HistoryPanel({
activeTabId,
terminalTabs,
}: {
activeTabId: string;
terminalTabs: Tab[];
}) {
const { t } = useTranslation();
const [search, setSearch] = useState("");
const [commands, setCommands] = useState<string[]>([]);
const activeTab = terminalTabs.find((t) => t.id === activeTabId);
const activeIsTerminal = !!activeTab;
const hostId = activeTab?.host?.id ? parseInt(activeTab.host.id, 10) : null;
const trackingEnabled = activeTab?.host?.enableCommandHistory !== false;
useEffect(() => {
if (!hostId || !trackingEnabled) {
setCommands([]);
return;
}
getCommandHistory(hostId)
.then(setCommands)
.catch(() => setCommands([]));
}, [hostId, trackingEnabled]);
if (activeIsTerminal && !trackingEnabled) {
return (
<div className="flex flex-col items-center justify-center flex-1 gap-3 p-6 text-center">
<div className="size-10 rounded-full bg-muted/40 flex items-center justify-center">
<Terminal className="size-5 text-muted-foreground/30" />
</div>
<div className="flex flex-col gap-1">
<span className="text-sm font-semibold text-muted-foreground/60">
{t("newUi.sidebar.history.trackingDisabled")}
</span>
<span className="text-xs text-muted-foreground/40">
{t("newUi.sidebar.history.trackingDisabledHint")}
</span>
</div>
</div>
);
}
if (!activeIsTerminal) {
return (
<div className="flex flex-col items-center justify-center flex-1 gap-3 p-6 text-center">
<div className="size-10 rounded-full bg-muted/40 flex items-center justify-center">
<Terminal className="size-5 text-muted-foreground/30" />
</div>
<div className="flex flex-col gap-1">
<span className="text-sm font-semibold text-muted-foreground/60">
{t("newUi.sidebar.history.noTerminalSelected")}
</span>
<span className="text-xs text-muted-foreground/40">
{t("newUi.sidebar.history.noTerminalSelectedHint")}
</span>
</div>
</div>
);
}
const filtered = search
? commands.filter((c) => c.toLowerCase().includes(search.toLowerCase()))
: commands;
async function handleDelete(cmd: string) {
if (!hostId) return;
try {
await deleteCommandFromHistory(hostId, cmd);
setCommands((prev) => prev.filter((c) => c !== cmd));
} catch {
/* ignore */
}
}
return (
<div className="flex flex-col gap-3 p-3">
<div className="flex items-center gap-1.5 px-2.5 py-1.5 bg-muted/30 border border-border/60">
<Terminal className="size-3 shrink-0 text-accent-brand" />
<span className="text-xs font-medium truncate text-foreground">
{activeTab.label}
</span>
</div>
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
<Input
placeholder={t("newUi.sidebar.history.searchPlaceholder")}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-8"
/>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">
{filtered.length} command{filtered.length !== 1 ? "s" : ""}
</span>
<button
onClick={async () => {
if (!hostId) return;
try {
await clearCommandHistory(hostId);
} catch {
/* ignore */
}
setCommands([]);
}}
className="text-xs text-accent-brand hover:text-accent-brand/70"
>
{t("newUi.sidebar.history.clearAll")}
</button>
</div>
<div className="flex flex-col gap-1">
{filtered.length === 0 && (
<span className="text-xs text-muted-foreground/60 text-center py-8">
{t("newUi.sidebar.history.noHistoryEntries")}
</span>
)}
{filtered.map((cmd, i) => (
<div
key={i}
className="group flex flex-col gap-1 px-2.5 py-2 border border-border bg-background hover:border-muted-foreground/30 transition-colors"
>
<div className="flex items-start justify-between gap-2">
<span className="text-xs font-mono text-foreground break-all leading-relaxed">
{cmd}
</span>
<div className="flex items-center gap-0.5 shrink-0 md:opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="icon"
className="size-6 text-muted-foreground hover:text-foreground"
onClick={() => copyToClipboard(cmd)}
>
<Copy className="size-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-6 text-muted-foreground hover:text-destructive"
onClick={() => handleDelete(cmd)}
>
<Trash2 className="size-3" />
</Button>
</div>
</div>
</div>
))}
</div>
</div>
);
}
+17 -20
View File
@@ -17,9 +17,10 @@ import {
} from "@/main-axios";
import type { Credential, Host } from "@/types/ui-types";
import {
AUTH_PROTOCOL_METADATA,
SSH_AUTH_PROTOCOL,
type AuthOverrideProtocol,
} from "@/types/auth-protocols";
import { authProtocolLabel } from "./host-permissions";
import { mapCredentials } from "./HostManagerData";
import { getConnectedRemoteApi } from "@/lib/remote-server-api";
@@ -44,8 +45,10 @@ export function HostAuthOverrideModal({
const overrideState = host.authOverrides?.[protocol];
const ownerAuthShared =
overrideState?.ownerAuthShared ??
(protocol === "ssh" ? !!host.shareSshAuth : false);
const remoteShared = !!host.isShared && Number(host.id) < 0;
(protocol === SSH_AUTH_PROTOCOL ? !!host.shareSshAuth : false);
// A shared host's copy on a linked desktop: its override lives on the
// server, against the server's own credentials.
const sharedCopySyncId = host.sharedCopy ? (host.syncId ?? null) : null;
useEffect(() => {
if (!open) return;
@@ -53,18 +56,16 @@ export function HostAuthOverrideModal({
setLoading(true);
setLoadError(false);
const credentialsRequest = remoteShared
const credentialsRequest = sharedCopySyncId
? getConnectedRemoteApi().then((api) => {
if (!api) throw new Error("Remote server is not connected");
if (!api) throw new Error("The linked server is not reachable");
return api.get("/credentials").then((response) => response.data);
})
: getCredentials();
Promise.all([
credentialsRequest,
remoteShared
? getHostAuthOverride(Number(host.id), protocol, true)
: getHostAuthOverride(Number(host.id), protocol),
getHostAuthOverride(Number(host.id), protocol, sharedCopySyncId),
])
.then(([credentialResult, overrideResult]) => {
if (cancelled) return;
@@ -87,22 +88,18 @@ export function HostAuthOverrideModal({
return () => {
cancelled = true;
};
}, [host.id, open, protocol, remoteShared]);
}, [host.id, open, protocol, sharedCopySyncId]);
const handleSave = async () => {
setSaving(true);
try {
const credentialId = selectedId ? Number(selectedId) : null;
if (remoteShared) {
await setHostAuthOverride(
Number(host.id),
protocol,
credentialId,
true,
);
} else {
await setHostAuthOverride(Number(host.id), protocol, credentialId);
}
await setHostAuthOverride(
Number(host.id),
protocol,
credentialId,
sharedCopySyncId,
);
toast.success(
credentialId === null
? t(
@@ -131,7 +128,7 @@ export function HostAuthOverrideModal({
<DialogHeader>
<DialogTitle>
{t("hosts.sharing.authOverrideTitleProtocol", {
protocol: AUTH_PROTOCOL_METADATA[protocol].label,
protocol: authProtocolLabel(protocol, t),
})}
</DialogTitle>
<DialogDescription>
-458
View File
@@ -1,458 +0,0 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import {
ChevronRight,
Copy,
FolderOpen,
KeyRound,
Loader2,
Pencil,
Plus,
Trash2,
Upload,
X,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/button";
import { getCredentialDetails } from "@/main-axios";
import { copyToClipboard } from "@/lib/clipboard";
import type { Host, Credential } from "@/types/ui-types";
type CredentialWithCertificate = Credential & { certPublicKey?: string };
type ConfirmDialog = {
message: string;
onConfirm: () => void;
};
function CredentialItem({
cred,
usedByHosts,
stripeIndex,
termixIdLinked,
onDeploy,
onEdit,
onDelete,
}: {
cred: Credential;
usedByHosts: Host[];
stripeIndex: number;
termixIdLinked?: boolean;
onDeploy: () => void;
onEdit: () => void;
onDelete: () => void;
}) {
const { t } = useTranslation();
const isKey = cred.type === "key";
const visibleHosts = usedByHosts.slice(0, 2);
const remainingHosts = Math.max(0, usedByHosts.length - visibleHosts.length);
const usedByTitle = usedByHosts
.map((host) => host.name || host.ip)
.join(", ");
return (
<div
className={`group relative flex items-stretch cursor-default select-none transition-colors hover:bg-muted/40 ${stripeIndex % 2 === 1 ? "bg-muted/20" : ""}`}
>
{/* Type stripe */}
<div className="w-[3px] shrink-0 bg-transparent" />
<div className="flex flex-col flex-1 min-w-0 px-2.5 pt-2 pb-1.5 gap-1">
{/* Name row */}
<div className="flex items-center gap-1.5 min-w-0">
<span className="text-[13px] font-medium truncate text-foreground leading-none">
{cred.name}
</span>
<span
className={`text-[9px] px-1 py-px font-bold border leading-none shrink-0 ${isKey ? "border-accent-brand/30 text-accent-brand" : "border-border/60 text-muted-foreground/60"}`}
>
{isKey ? "KEY" : "PWD"}
</span>
{termixIdLinked && (
<span className="text-[9px] px-1 py-px font-bold border leading-none shrink-0 border-accent-brand/30 text-accent-brand/70">
ID
</span>
)}
</div>
{/* Username row */}
{cred.username && (
<span className="text-[11px] text-muted-foreground/45 truncate leading-none pl-3">
{cred.username}
</span>
)}
{usedByHosts.length > 0 && (
<div
className="flex min-w-0 items-center gap-1 overflow-hidden pl-3"
title={usedByTitle}
aria-label={`Used by ${usedByTitle}`}
>
{visibleHosts.map((host) => {
const label = host.name || host.ip;
return (
<span
key={host.id}
className="max-w-[92px] truncate border border-accent-brand/25 bg-accent-brand/10 px-1.5 py-px text-[9px] font-semibold leading-none text-accent-brand/80"
title={label}
>
{label}
</span>
);
})}
{remainingHosts > 0 && (
<span
className="shrink-0 border border-border/50 bg-muted/30 px-1.5 py-px text-[9px] font-semibold leading-none text-muted-foreground/70"
title={usedByTitle}
>
+{remainingHosts}
</span>
)}
</div>
)}
{/* Tag pills */}
{cred.tags && cred.tags.length > 0 && (
<div className="flex items-center gap-1 min-w-0 overflow-hidden pl-3">
{cred.tags.slice(0, 4).map((tag) => (
<span
key={tag}
className="text-[9px] px-1 py-px border border-border/50 bg-muted/30 text-muted-foreground/60 lowercase shrink-0 leading-none"
>
{tag}
</span>
))}
{cred.tags.length > 4 && (
<span className="text-[9px] text-muted-foreground/40 shrink-0">
+{cred.tags.length - 4}
</span>
)}
</div>
)}
{/* Action tray — slides open on hover */}
<div className="overflow-hidden transition-all duration-150 ease-out max-h-0 opacity-0 group-hover:max-h-[60px] group-hover:opacity-100">
<div className="flex items-center gap-1 pt-1.5 pl-2 pb-1 border-t border-border/40 mt-0.5">
{isKey && (
<>
<button
title="Deploy key to host"
onClick={onDeploy}
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
>
<Upload className="size-3.5" />
</button>
<button
title="Copy deploy command"
onClick={async () => {
const pubKey = cred.publicKey;
if (!pubKey) {
toast.error(t("credentials.noPublicKeyAvailable"));
return;
}
const cmd = `mkdir -p ~/.ssh && chmod 700 ~/.ssh && echo "${pubKey}" >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys`;
const ok = await copyToClipboard(cmd);
if (ok) toast.success(t("credentials.deployCommandCopied"));
else toast.error(t("common.copyFailed"));
}}
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
>
<Copy className="size-3.5" />
</button>
</>
)}
<button
title="Edit credential"
onClick={onEdit}
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
>
<Pencil className="size-3.5" />
</button>
<button
title="Delete credential"
onClick={onDelete}
className="flex items-center justify-center size-7 rounded text-muted-foreground/40 hover:text-destructive hover:bg-destructive/10 transition-colors"
>
<Trash2 className="size-3.5" />
</button>
</div>
</div>
</div>
</div>
);
}
function CredentialFolderItem({
folder,
creds,
allHosts,
stripeOffset,
editingFolderName,
editingFolderValue,
termixIdLinkedIds,
onEditingFolderNameChange,
onEditingFolderValueChange,
onRenameFolder,
onDeploy,
onEdit,
onDelete,
}: {
folder: string;
creds: Credential[];
allHosts: Host[];
stripeOffset: number;
editingFolderName: string | null;
editingFolderValue: string;
termixIdLinkedIds?: Set<number>;
onEditingFolderNameChange: (name: string | null) => void;
onEditingFolderValueChange: (value: string) => void;
onRenameFolder: (folder: string, newName: string) => Promise<void>;
onDeploy: (cred: Credential) => void;
onEdit: (cred: Credential) => void;
onDelete: (cred: Credential) => void;
}) {
const [open, setOpen] = useState(false);
return (
<div>
<button
onClick={() => setOpen((v) => !v)}
className={`group/folder flex items-center gap-2 w-full px-3 py-2 hover:bg-muted/50 transition-colors text-left cursor-pointer ${stripeOffset % 2 === 1 ? "bg-muted/20" : ""}`}
>
<ChevronRight
className={`size-3 shrink-0 text-muted-foreground/50 transition-transform ${open ? "rotate-90" : ""}`}
/>
<FolderOpen
className={`size-3.5 shrink-0 ${open ? "text-accent-brand" : "text-muted-foreground/60"}`}
/>
{editingFolderName === folder ? (
<>
<input
autoFocus
value={editingFolderValue}
onChange={(e) => onEditingFolderValueChange(e.target.value)}
onBlur={async () => {
const newName = editingFolderValue.trim();
onEditingFolderNameChange(null);
if (newName && newName !== folder) {
await onRenameFolder(folder, newName);
}
}}
onKeyDown={(e) => {
if (e.key === "Enter") e.currentTarget.blur();
if (e.key === "Escape") onEditingFolderNameChange(null);
}}
onClick={(e) => e.stopPropagation()}
className="text-[10px] font-semibold bg-background border border-accent-brand/60 px-1 outline-none text-foreground min-w-0 flex-1"
/>
<button
onClick={(e) => {
e.stopPropagation();
onEditingFolderNameChange(null);
}}
className="text-muted-foreground hover:text-foreground shrink-0"
>
<X className="size-3" />
</button>
</>
) : (
<>
<span className="text-[13px] font-semibold text-foreground/80 truncate flex-1">
{folder}
</span>
<span className="text-[10px] tabular-nums shrink-0 ml-1 text-muted-foreground/40">
{creds.length}
</span>
{folder !== "Uncategorized" && (
<span
className="opacity-0 group-hover/folder:opacity-100 transition-opacity ml-1 text-muted-foreground/50 hover:text-foreground"
onClick={(e) => {
e.stopPropagation();
onEditingFolderNameChange(folder);
onEditingFolderValueChange(folder);
}}
>
<Pencil className="size-2.5" />
</span>
)}
</>
)}
</button>
{open && (
<div className="border-l border-border/40 ml-[30px]">
{creds.map((cred, i) => {
const usedByHosts = allHosts.filter(
(h) => h.credentialId === cred.id,
);
return (
<CredentialItem
key={cred.id}
cred={cred}
usedByHosts={usedByHosts}
stripeIndex={stripeOffset + 1 + i}
termixIdLinked={termixIdLinkedIds?.has(Number(cred.id))}
onDeploy={() => onDeploy(cred)}
onEdit={() => onEdit(cred)}
onDelete={() => onDelete(cred)}
/>
);
})}
</div>
)}
</div>
);
}
export function HostCredentialList({
credentialFolders,
filteredCredentials,
credentialsLoading,
allHosts,
editingFolderName,
editingFolderValue,
termixIdLinkedIds,
onEditingFolderNameChange,
onEditingFolderValueChange,
onRenameFolder,
onDeployCredential,
onEditCredential,
onDeleteCredential,
onAddCredential,
onConfirmDialogChange,
}: {
credentialFolders: string[];
filteredCredentials: Credential[];
credentialsLoading: boolean;
allHosts: Host[];
editingFolderName: string | null;
editingFolderValue: string;
termixIdLinkedIds?: Set<number>;
onEditingFolderNameChange: (name: string | null) => void;
onEditingFolderValueChange: (value: string) => void;
onRenameFolder: (folder: string, newName: string) => Promise<void>;
onDeployCredential: (cred: Credential) => void;
onEditCredential: (cred: Credential) => void;
onDeleteCredential: (cred: Credential) => Promise<void>;
onAddCredential: () => void;
onConfirmDialogChange: (dialog: ConfirmDialog) => void;
}) {
const { t } = useTranslation();
async function handleDelete(cred: Credential) {
onConfirmDialogChange({
message: t("hosts.deleteCredentialConfirm", { name: cred.name }),
onConfirm: async () => {
try {
await onDeleteCredential(cred);
toast.success(t("hosts.deletedCredential", { name: cred.name }));
} catch {
toast.error(t("hosts.failedToDeleteCredential2"));
}
},
});
}
async function handleEdit(cred: Credential) {
try {
const full = await getCredentialDetails(Number(cred.id));
onEditCredential({
...cred,
value: (
full as CredentialWithCertificate & {
hasKey?: boolean;
hasKeyPassword?: boolean;
}
).hasKey
? "existing_key"
: ((
full as CredentialWithCertificate & {
password?: string;
}
).password ?? ""),
password:
(full as CredentialWithCertificate & { password?: string })
.password ?? "",
passphrase: (
full as CredentialWithCertificate & {
hasKeyPassword?: boolean;
}
).hasKeyPassword
? "existing_key_password"
: "",
publicKey:
(full as CredentialWithCertificate).certPublicKey ?? cred.publicKey,
});
} catch {
onEditCredential(cred);
}
}
let globalStripe = 0;
return (
<div className="flex-1 min-h-0 overflow-y-auto">
<div className="flex flex-col">
{credentialFolders.map((folder) => {
const creds = filteredCredentials.filter(
(c) => (c.folder || "Uncategorized") === folder,
);
if (creds.length === 0) return null;
const offset = globalStripe;
globalStripe += 1 + creds.length;
return (
<CredentialFolderItem
key={folder}
folder={folder}
creds={creds}
allHosts={allHosts}
stripeOffset={offset}
editingFolderName={editingFolderName}
editingFolderValue={editingFolderValue}
termixIdLinkedIds={termixIdLinkedIds}
onEditingFolderNameChange={onEditingFolderNameChange}
onEditingFolderValueChange={onEditingFolderValueChange}
onRenameFolder={onRenameFolder}
onDeploy={onDeployCredential}
onEdit={handleEdit}
onDelete={handleDelete}
/>
);
})}
{credentialsLoading && (
<div className="flex flex-col px-2 py-2 space-y-1.5">
{[60, 45, 55, 40].map((w, i) => (
<div key={i} className="flex items-center gap-2 px-3 py-2">
<div className="size-3 rounded-sm bg-muted/50 animate-pulse shrink-0" />
<div
className="h-3 rounded bg-muted/50 animate-pulse"
style={{ width: `${w * 2}px` }}
/>
</div>
))}
<div className="flex items-center justify-center gap-2 pt-2 text-muted-foreground/40">
<Loader2 className="size-3.5 animate-spin" />
<span className="text-xs">{t("hosts.loadingCredentials")}</span>
</div>
</div>
)}
{!credentialsLoading && filteredCredentials.length === 0 && (
<div className="flex flex-col items-center justify-center py-12 text-center px-4">
<KeyRound className="size-8 text-muted-foreground/20 mb-2" />
<span className="text-sm font-semibold text-muted-foreground/60">
{t("hosts.noCredentialsFound")}
</span>
<Button
variant="outline"
size="sm"
className="mt-3 h-7 text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10"
onClick={onAddCredential}
>
<Plus className="size-3 mr-1" />
{t("hosts.addCredentialBtn2")}
</Button>
</div>
)}
</div>
</div>
);
}
+1226 -2458
View File
File diff suppressed because it is too large. Load diff
+167 -464
View File
@@ -1,153 +1,123 @@
import { TERMINAL_THEMES } from "@/lib/terminal-themes";
import type { Host } from "@/types/ui-types";
import type { SSHHostData } from "@/types";
import type { HostDefaults } from "@/api/settings-api";
import type {
RemoteDesktopDefaults,
TerminalDefaults,
} from "@/lib/connection-defaults";
import type { HostProtocolAuthInput, SSHHostData } from "@/types";
import {
HOST_PROTOCOL_SECRET_KEPT,
type HostDraft,
type HostProtocolAuthForm,
type HostProtocolAuthSummary,
} from "@termix/plugin-sdk/frontend";
import type { DefaultOverrides } from "@/types/host-defaults";
import {
listHostProtocols,
protocolPort,
type HostProtocols,
} from "./host-protocols";
type HostSocks5ProxyNode = NonNullable<Host["socks5ProxyChain"]>[number];
export type HostProtocols = {
enableSsh: boolean;
enableRdp: boolean;
enableVnc: boolean;
enableTelnet: boolean;
};
export type { HostProtocols };
export type HostAuthType = Host["authType"];
export type HostCursorStyle = NonNullable<
Host["terminalConfig"]
>["cursorStyle"];
export type HostBellStyle = NonNullable<Host["terminalConfig"]>["bellStyle"];
export type HostBackspaceMode = NonNullable<
Host["terminalConfig"]
>["backspaceMode"];
export type HostFastScrollModifier = NonNullable<
Host["terminalConfig"]
>["fastScrollModifier"];
export const terminalAppearanceKeys = [
"theme",
"cursorBlink",
"cursorStyle",
"fontSize",
"fontFamily",
"scrollback",
"letterSpacing",
"lineHeight",
"bellStyle",
"minimumContrastRatio",
"backgroundImage",
"backgroundImageOpacity",
"customThemeColors",
] as const satisfies readonly (keyof TerminalDefaults)[];
export const remoteDesktopDefaultKeys = [
"colorDepth",
"resizeMethod",
"forceLossless",
"disableAudio",
"enableWallpaper",
"enableFontSmoothing",
"enableDesktopComposition",
"enablePrinting",
"enableDrive",
"disableCopy",
"disablePaste",
] as const satisfies readonly (keyof RemoteDesktopDefaults)[];
export interface UserConnectionDefaults {
terminal: TerminalDefaults;
rdp: RemoteDesktopDefaults;
}
function hasOwn(value: object | undefined, key: PropertyKey): boolean {
return !!value && Object.prototype.hasOwnProperty.call(value, key);
}
function stripKeys<T extends Record<string, unknown>>(
value: T,
keys: readonly string[],
): Partial<T> {
const result = { ...value };
for (const key of keys) delete result[key];
return result;
}
type SnippetListItem = {
id: number;
name?: string;
title?: string;
};
type SnippetResponse = SnippetListItem[] | { snippets?: SnippetListItem[] };
/**
* Whether the Connection Origin control is meaningful for a host.
*
* Every protocol Termix can dial from either backend belongs here. RDP, VNC
* and Telnet were added once they could originate from the desktop
* (Termix-SSH/Support#1240); before that the control was gated on SSH alone,
* so a host enabling only those protocols could never reach the setting.
* Every protocol Termix can dial from either backend belongs here, plugin
* protocols included (Termix-SSH/Support#1240); before that the control was
* gated on SSH alone, so a host enabling only remote desktop could never
* reach the setting.
*/
export function connectionOriginAppliesTo(protocols: HostProtocols): boolean {
return (
protocols.enableSsh ||
protocols.enableRdp ||
protocols.enableVnc ||
protocols.enableTelnet
);
return Object.values(protocols).some(Boolean);
}
export function mapSnippetResponse(
res: unknown,
): { id: number; name: string }[] {
const snippetRes = res as SnippetResponse;
return (
Array.isArray(snippetRes) ? snippetRes : (snippetRes.snippets ?? [])
).map((s) => ({
id: s.id,
name: s.name ?? s.title ?? `Snippet ${s.id}`,
}));
/** Overlays a plugin's draft on a new host's form. */
export function applyHostDraft(
form: HostEditorForm,
draft: HostDraft | undefined,
): HostEditorForm {
if (!draft) return form;
return {
...form,
...(draft.name !== undefined ? { name: draft.name } : {}),
...(draft.ip !== undefined ? { ip: draft.ip } : {}),
...(draft.port !== undefined ? { sshPort: draft.port } : {}),
...(draft.username !== undefined ? { username: draft.username } : {}),
...(draft.authType !== undefined
? { authType: draft.authType as HostAuthType }
: {}),
};
}
export function createHostEditorForm(
host: Host | null,
defaults?: HostDefaults,
connectionDefaults?: UserConnectionDefaults,
) {
const d = host ? undefined : defaults;
const terminalConfig = {
...(connectionDefaults?.terminal ?? {}),
...(host?.terminalConfig ?? {}),
};
const remoteDefaults = host?.enableRdp ? connectionDefaults?.rdp : undefined;
const guacamoleConfig = {
...(remoteDefaults ?? {}),
...(host?.guacamoleConfig ?? {}),
};
const rawTheme = terminalConfig.theme ?? d?.theme;
const normalizedTheme =
!rawTheme ||
["Termix Dark", "Termix Light", "termixDark", "termixLight"].includes(
rawTheme,
)
? "termix"
: rawTheme === "custom" || TERMINAL_THEMES[rawTheme]
? rawTheme
: "termix";
/** A saved login as the editor form holds it; saved secrets stay behind a placeholder. */
export function protocolAuthForm(
summaries: Record<string, HostProtocolAuthSummary> | undefined,
): Record<string, HostProtocolAuthForm> {
const form: Record<string, HostProtocolAuthForm> = {};
for (const [protocol, summary] of Object.entries(summaries ?? {})) {
const fields: Record<string, string> = { ...(summary.fields ?? {}) };
for (const key of summary.secretFieldKeys ?? []) {
fields[key] = HOST_PROTOCOL_SECRET_KEPT;
}
form[protocol] = {
authType:
summary.authType ?? (summary.credentialId ? "credential" : "direct"),
credentialId:
summary.credentialId != null ? String(summary.credentialId) : "",
username: summary.username ?? "",
password: summary.hasPassword ? HOST_PROTOCOL_SECRET_KEPT : "",
fields,
};
}
return form;
}
/**
* The logins a host save sends: one per plugin protocol the editor knows,
* removed when its switch is off. A saved secret left alone is not sent.
*/
export function protocolAuthPayload(
form: Record<string, HostProtocolAuthForm>,
protocols: HostProtocols,
): Record<string, HostProtocolAuthInput | null> {
const payload: Record<string, HostProtocolAuthInput | null> = {};
for (const protocol of listHostProtocols()) {
if (!protocols[protocol.settingKey]) {
payload[protocol.id] = null;
continue;
}
const login = form[protocol.id];
if (!login) continue;
const direct = login.authType === "direct";
payload[protocol.id] = {
authType: login.authType,
credentialId:
login.authType === "credential" && login.credentialId
? Number(login.credentialId)
: null,
username: direct ? login.username || null : null,
...(direct && login.password !== HOST_PROTOCOL_SECRET_KEPT
? { password: login.password || null }
: {}),
fields: Object.fromEntries(
Object.entries(login.fields).filter(
([, value]) => value !== HOST_PROTOCOL_SECRET_KEPT,
),
),
};
}
return payload;
}
export function createHostEditorForm(host: Host | null) {
const sshOptions = host?.sshOptions ?? {};
return {
name: host?.name ?? "",
ip: host?.ip ?? "",
username: host?.username ?? (host ? "" : "root"),
sshPort: host?.sshPort ?? host?.port ?? 22,
rdpPort: host?.rdpPort ?? 3389,
vncPort: host?.vncPort ?? 5900,
telnetPort: host?.telnetPort ?? 23,
authType: host?.authType ?? "password",
useWarpgate: host?.useWarpgate ?? false,
shareSshAuth: host?.shareSshAuth ?? false,
password: host?.hasPassword ? "existing_password" : (host?.password ?? ""),
key: host?.key ?? (host?.hasKey ? "existing_key" : ""),
@@ -156,13 +126,7 @@ export function createHostEditorForm(
: (host?.keyPassword ?? ""),
keyType: host?.keyType ?? "auto",
keySubTab: "paste" as "paste" | "upload",
credentialId:
host?.credentialId != null
? String(host.credentialId)
: d?.credentialId != null
? String(d.credentialId)
: "",
vaultProfileId: host?.vaultProfileId ?? "",
credentialId: host?.credentialId != null ? String(host.credentialId) : "",
overrideCredentialUsername: host?.overrideCredentialUsername ?? false,
folder: host?.folder ?? "",
parentHostId: host?.parentHostId ?? "",
@@ -170,178 +134,52 @@ export function createHostEditorForm(
tagInput: "",
notes: host?.notes ?? "",
pin: host?.pin ?? false,
macAddress: host?.macAddress ?? "",
wolBroadcastAddress: host?.wolBroadcastAddress ?? "",
useSocks5: host?.useSocks5 ?? d?.useSocks5 ?? false,
socks5Host: host?.socks5Host ?? d?.socks5Host ?? "",
socks5Port: host?.socks5Port ?? d?.socks5Port ?? 1080,
socks5Username: host?.socks5Username ?? d?.socks5Username ?? "",
socks5Password: host?.socks5Password ?? d?.socks5Password ?? "",
useSocks5: host?.useSocks5 ?? false,
socks5Host: host?.socks5Host ?? "",
socks5Port: host?.socks5Port ?? 1080,
socks5Username: host?.socks5Username ?? "",
socks5Password: host?.socks5Password ?? "",
socks5ProxyMode: ((host?.socks5ProxyChain ?? []).length > 0
? "chain"
: "single") as "single" | "chain",
socks5ProxyChain: (host?.socks5ProxyChain ?? []) as HostSocks5ProxyNode[],
connectionOrigin: (host?.connectionOrigin ?? null) as
"local" | "remote" | null,
enableTerminal: host?.enableTerminal ?? true,
enableSessionLogging:
host?.enableSessionLogging ?? d?.enableSessionLogging ?? true,
enableCommandHistory:
host?.enableCommandHistory ?? d?.enableCommandHistory ?? true,
enableFileManager: host?.enableFileManager ?? false,
scpLegacy: host?.scpLegacy ?? false,
enableDocker: host?.enableDocker ?? false,
enableWebUi: host?.enableWebUi ?? false,
webUiConfig: host?.webUiConfig ?? { endpoints: [] },
dockerConfig: host?.dockerConfig ?? { runtime: "docker" as const },
enableTmuxMonitor: host?.enableTmuxMonitor ?? false,
enableTerminalToolbar: host?.enableTerminalToolbar ?? true,
enableAiAssistant: host?.enableAiAssistant ?? false,
allowSessionSharing: host?.allowSessionSharing ?? true,
enableProxmox: host?.enableProxmox ?? false,
proxmoxConfig: host?.proxmoxConfig ?? {
defaultCredentialId: null as number | null,
defaultAuthType: "password" as string,
windowsPatterns: "win, windows",
dockerPatterns: "docker",
preferredPrefixes: "10., 192.168.",
autoSyncEnabled: false,
syncIntervalMinutes: 15,
markMissingGuests: true,
},
enableProxmoxStats: host?.enableProxmoxStats ?? false,
proxmoxStatsConfig: host?.proxmoxStatsConfig ?? {
pollInterval: 60,
nodeName: null as string | null,
},
enableTunnel: host?.enableTunnel ?? false,
defaultPath: host?.defaultPath ?? "/",
localOnly: host?.localOnly ?? false,
forceKeyboardInteractive: host?.forceKeyboardInteractive ?? false,
inheritTerminalAppearance:
!host ||
terminalAppearanceKeys.every((key) => !hasOwn(host.terminalConfig, key)),
inheritRemoteDesktopDefaults:
!host ||
remoteDesktopDefaultKeys.every(
(key) => !hasOwn(host.guacamoleConfig, key),
),
localEcho: host?.terminalConfig?.localEcho ?? "default",
fontSize: terminalConfig.fontSize ?? d?.fontSize ?? 14,
fontFamily:
terminalConfig.fontFamily ??
d?.fontFamily ??
"Caskaydia Cove Nerd Font Mono",
theme: normalizedTheme,
cursorStyle: (terminalConfig.cursorStyle ?? d?.cursorStyle ?? "bar") as
"block" | "underline" | "bar",
cursorBlink: terminalConfig.cursorBlink ?? d?.cursorBlink ?? true,
scrollback: terminalConfig.scrollback ?? 10000,
letterSpacing: terminalConfig.letterSpacing ?? 0,
lineHeight: terminalConfig.lineHeight ?? 1.0,
bellStyle: (terminalConfig.bellStyle ?? "none") as
"none" | "sound" | "visual" | "both",
rightClickSelectsWord: host?.terminalConfig?.rightClickSelectsWord ?? false,
macOptionIsMeta: host?.terminalConfig?.macOptionIsMeta ?? false,
fastScrollModifier: (host?.terminalConfig?.fastScrollModifier ?? "alt") as
"alt" | "ctrl" | "shift",
fastScrollSensitivity: host?.terminalConfig?.fastScrollSensitivity ?? 5,
minimumContrastRatio: terminalConfig.minimumContrastRatio ?? 1,
backspaceMode: (host?.terminalConfig?.backspaceMode ?? "normal") as
"normal" | "control-h",
startupSnippetId: host?.terminalConfig?.startupSnippetId ?? null,
moshCommand: host?.terminalConfig?.moshCommand ?? "",
agentForwarding: host?.terminalConfig?.agentForwarding ?? false,
autoMosh: host?.terminalConfig?.autoMosh ?? false,
autoTmux: host?.terminalConfig?.autoTmux ?? d?.autoTmux ?? false,
sudoPasswordAutoFill: host?.terminalConfig?.sudoPasswordAutoFill ?? false,
sudoPassword: host?.hasSudoPassword
? "existing_sudo_password"
: (host?.terminalConfig?.sudoPassword ?? ""),
keepaliveInterval: host?.terminalConfig?.keepaliveInterval ?? 60,
keepaliveCountMax: host?.terminalConfig?.keepaliveCountMax ?? 5,
backgroundImage: terminalConfig.backgroundImage ?? "",
backgroundImageOpacity: terminalConfig.backgroundImageOpacity ?? 0.15,
customThemeColors: terminalConfig.customThemeColors ?? null,
allowLegacyAlgorithms: host?.terminalConfig?.allowLegacyAlgorithms ?? true,
linkClickBehavior: (host?.terminalConfig?.linkClickBehavior ??
"default") as "default" | "confirm" | "direct",
agentSocketPath: host?.terminalConfig?.agentSocketPath ?? "",
agentIdentity: host?.terminalConfig?.agentIdentity ?? "",
useSSHTitle: host?.terminalConfig?.useSSHTitle ?? false,
syntaxHighlighting: host?.terminalConfig?.syntaxHighlighting ?? true,
syntaxHighlightingOptions: {
logLevels:
host?.terminalConfig?.syntaxHighlightingOptions?.logLevels ?? true,
paths: host?.terminalConfig?.syntaxHighlightingOptions?.paths ?? true,
timestamps:
host?.terminalConfig?.syntaxHighlightingOptions?.timestamps ?? true,
ipAddresses:
host?.terminalConfig?.syntaxHighlightingOptions?.ipAddresses ?? true,
urls: host?.terminalConfig?.syntaxHighlightingOptions?.urls ?? true,
numbers: host?.terminalConfig?.syntaxHighlightingOptions?.numbers ?? true,
},
: (host?.sudoPassword ?? ""),
keepaliveInterval: sshOptions.keepaliveInterval ?? 60,
keepaliveCountMax: sshOptions.keepaliveCountMax ?? 5,
allowLegacyAlgorithms: sshOptions.allowLegacyAlgorithms ?? true,
agentForwarding: sshOptions.agentForwarding ?? false,
agentSocketPath: sshOptions.agentSocketPath ?? "",
agentIdentity: sshOptions.agentIdentity ?? "",
environmentVariables:
host?.terminalConfig?.environmentVariables ??
sshOptions.environmentVariables ??
([] as { key: string; value: string }[]),
serverTunnels: host?.serverTunnels ?? ([] as Host["serverTunnels"]),
jumpHosts: host?.jumpHosts ?? ([] as { hostId: string }[]),
portKnockSequence:
host?.portKnockSequence ??
([] as { port: number; protocol: "tcp" | "udp"; delay: number }[]),
quickActions:
host?.quickActions ?? ([] as { name: string; snippetId: string }[]),
rdpCredentialId: host?.rdpCredentialId ?? "",
rdpUser: host?.rdpUser ?? "",
rdpPassword: host?.hasRdpPassword
? "existing_rdp_password"
: (host?.rdpPassword ?? ""),
domain: host?.domain ?? "",
security: host?.security ?? "",
ignoreCert: host?.ignoreCert ?? false,
vncCredentialId: host?.vncCredentialId ?? "",
vncPassword: host?.hasVncPassword
? "existing_vnc_password"
: (host?.vncPassword ?? ""),
vncUser: host?.vncUser ?? "",
telnetUser: host?.telnetUser ?? "",
telnetPassword: host?.hasTelnetPassword
? "existing_telnet_password"
: (host?.telnetPassword ?? ""),
telnetCredentialId:
host?.telnetCredentialId != null ? String(host.telnetCredentialId) : "",
rdpAuthType: (host?.rdpAuthType ??
(host?.rdpCredentialId ? "credential" : "direct")) as
"direct" | "credential" | "none",
vncAuthType: (host?.vncAuthType ??
(host?.vncCredentialId ? "credential" : "direct")) as
"direct" | "credential",
telnetAuthType: (host?.telnetAuthType ??
(host?.telnetCredentialId ? "credential" : "direct")) as
"direct" | "credential",
guacamoleConfig,
statsConfig: host?.statsConfig ?? {
statusCheckEnabled: d?.statusCheckEnabled ?? true,
statusCheckInterval: 60,
useGlobalStatusInterval: true,
metricsEnabled: d?.metricsEnabled ?? true,
metricsInterval: 30,
useGlobalMetricsInterval: true,
enabledWidgets: [
"cpu",
"memory",
"disk",
"network",
"uptime",
"system",
"login_stats",
"processes",
"ports",
"firewall",
"temperature",
],
excludedMounts: [] as string[],
monitoredMounts: [] as Array<{ path: string; label?: string }>,
},
// Each plugin protocol's login, edited by that plugin's host editor tab.
protocolAuth: protocolAuthForm(host?.protocolAuth),
statusCheckEnabled: host?.statusCheckEnabled ?? true,
statusCheckInterval: (host?.statusCheckInterval ?? null) as number | null,
// Host-scope plugin settings, keyed by plugin id. Loaded with the host and
// saved through the plugin's own scoped route, not in the host payload.
pluginSettings: (host?.pluginSettings ?? {}) as Record<
string,
Record<string, unknown>
>,
// Host default keys the host sets itself; the rest follow its defaults.
// Null until known, for a host saved before host defaults.
defaultOverrides: (host
? (host.defaultOverrides ?? null)
: {}) as DefaultOverrides | null,
};
}
@@ -358,23 +196,19 @@ export function omitOwnerSshAuthFromSharedEdit(
keyType: _keyType,
sudoPassword: _sudoPassword,
credentialId: _credentialId,
vaultProfileId: _vaultProfileId,
overrideCredentialUsername: _overrideCredentialUsername,
shareSshAuth: _shareSshAuth,
...editableFields
} = payload;
const terminalConfig = editableFields.terminalConfig
? { ...editableFields.terminalConfig }
const sshOptions = editableFields.sshOptions
? { ...editableFields.sshOptions }
: undefined;
if (terminalConfig) {
delete terminalConfig.sudoPassword;
delete terminalConfig.agentSocketPath;
}
if (sshOptions) delete sshOptions.agentSocketPath;
return {
...editableFields,
terminalConfig,
sshOptions,
} as SSHHostData;
}
@@ -389,80 +223,24 @@ export function buildHostEditorPayload(
const usesKey = form.authType === "key";
const usesPassword = form.authType === "password";
const usesAgent = form.authType === "agent";
const usesVault = form.authType === "vault";
const guacamoleConfig = form.inheritRemoteDesktopDefaults
? stripKeys(form.guacamoleConfig, remoteDesktopDefaultKeys)
: form.guacamoleConfig;
const terminalConfig = {
theme: form.theme,
cursorBlink: form.cursorBlink,
cursorStyle: form.cursorStyle,
fontSize: Number(form.fontSize),
fontFamily: form.fontFamily,
scrollback: Number(form.scrollback),
letterSpacing: Number(form.letterSpacing),
lineHeight: Number(form.lineHeight),
bellStyle: form.bellStyle,
rightClickSelectsWord: form.rightClickSelectsWord,
macOptionIsMeta: form.macOptionIsMeta,
fastScrollModifier: form.fastScrollModifier,
fastScrollSensitivity: Number(form.fastScrollSensitivity),
minimumContrastRatio: Number(form.minimumContrastRatio),
backspaceMode: form.backspaceMode,
startupSnippetId: form.startupSnippetId ?? null,
moshCommand: form.moshCommand || null,
agentForwarding: form.agentForwarding,
autoMosh: form.autoMosh,
autoTmux: form.autoTmux,
sudoPasswordAutoFill: form.sudoPasswordAutoFill,
sudoPassword:
form.sudoPassword === "existing_sudo_password"
? undefined
: form.sudoPassword || null,
keepaliveInterval: Number(form.keepaliveInterval),
keepaliveCountMax: Number(form.keepaliveCountMax),
environmentVariables: form.environmentVariables,
useSSHTitle: form.useSSHTitle,
syntaxHighlighting: form.syntaxHighlighting,
syntaxHighlightingOptions: form.syntaxHighlightingOptions,
backgroundImage: form.backgroundImage || null,
backgroundImageOpacity: Number(form.backgroundImageOpacity),
customThemeColors: form.theme === "custom" ? form.customThemeColors : null,
allowLegacyAlgorithms: form.allowLegacyAlgorithms,
linkClickBehavior:
form.linkClickBehavior !== "default" ? form.linkClickBehavior : undefined,
localEcho: form.localEcho !== "default" ? form.localEcho : undefined,
agentSocketPath: usesAgent ? form.agentSocketPath || null : null,
agentIdentity: usesAgent ? form.agentIdentity || null : null,
};
const terminalOverrides = form.inheritTerminalAppearance
? stripKeys(terminalConfig, terminalAppearanceKeys)
: terminalConfig;
// With SSH off, the host's primary protocol is the first plugin protocol
// switched on, and its port stands in for the host port.
const primaryProtocol = protocols.enableSsh
? undefined
: listHostProtocols().find((protocol) => protocols[protocol.settingKey]);
return {
connectionType: protocols.enableSsh
? "ssh"
: protocols.enableRdp
? "rdp"
: protocols.enableVnc
? "vnc"
: "telnet",
connectionType: primaryProtocol?.id ?? "ssh",
name: form.name,
ip: form.ip,
port: protocols.enableSsh
? Number(form.sshPort)
: protocols.enableRdp
? Number(form.rdpPort)
: protocols.enableVnc
? Number(form.vncPort)
: Number(form.telnetPort),
port: primaryProtocol
? protocolPort(form.pluginSettings, primaryProtocol)
: Number(form.sshPort),
username: form.username,
folder: form.folder,
parentHostId: form.parentHostId ? Number(form.parentHostId) : null,
tags: form.tags,
pin: form.pin,
authType: form.authType,
useWarpgate: form.useWarpgate,
shareSshAuth: form.shareSshAuth,
password:
usesPassword || usesKey || usesCredential
@@ -483,36 +261,8 @@ export function buildHostEditorPayload(
keyType: usesKey && form.keyType !== "auto" ? form.keyType : null,
credentialId:
usesCredential && form.credentialId ? Number(form.credentialId) : null,
vaultProfileId:
usesVault && form.vaultProfileId ? Number(form.vaultProfileId) : null,
overrideCredentialUsername: form.overrideCredentialUsername,
notes: form.notes,
macAddress: form.macAddress || null,
wolBroadcastAddress: form.wolBroadcastAddress || null,
enableTerminal: form.enableTerminal,
enableSessionLogging: form.enableSessionLogging,
enableCommandHistory: form.enableCommandHistory,
enableTunnel: form.enableTunnel,
enableFileManager: form.enableFileManager,
scpLegacy: form.scpLegacy,
enableDocker: form.enableDocker,
dockerConfig: form.enableDocker ? form.dockerConfig : null,
enableWebUi: form.enableWebUi,
webUiConfig: form.enableWebUi ? form.webUiConfig : null,
enableTmuxMonitor: form.enableTmuxMonitor,
enableTerminalToolbar: form.enableTerminalToolbar,
enableAiAssistant: form.enableAiAssistant,
allowSessionSharing: form.allowSessionSharing,
enableProxmox: form.enableProxmox,
proxmoxConfig:
form.enableProxmox || form.proxmoxConfig?.source
? form.proxmoxConfig
: null,
enableProxmoxStats: form.enableProxmoxStats,
proxmoxStatsConfig: form.enableProxmoxStats
? form.proxmoxStatsConfig
: null,
defaultPath: form.defaultPath || "/",
useSocks5: form.useSocks5,
socks5Host:
form.socks5ProxyMode === "single" ? form.socks5Host || null : null,
@@ -525,84 +275,37 @@ export function buildHostEditorPayload(
socks5ProxyChain:
form.socks5ProxyMode === "chain" ? form.socks5ProxyChain : null,
connectionOrigin: form.connectionOrigin,
localOnly: form.localOnly,
enableSsh: protocols.enableSsh,
enableRdp: protocols.enableRdp,
enableVnc: protocols.enableVnc,
enableTelnet: protocols.enableTelnet,
sshPort: Number(form.sshPort),
rdpPort: Number(form.rdpPort),
vncPort: Number(form.vncPort),
telnetPort: Number(form.telnetPort),
forceKeyboardInteractive: form.forceKeyboardInteractive,
rdpAuthType: protocols.enableRdp ? form.rdpAuthType : null,
rdpCredentialId:
protocols.enableRdp &&
form.rdpAuthType === "credential" &&
form.rdpCredentialId
? Number(form.rdpCredentialId)
: null,
rdpUser:
protocols.enableRdp && form.rdpAuthType === "direct"
? form.rdpUser || null
: null,
rdpPassword:
protocols.enableRdp &&
form.rdpAuthType === "direct" &&
form.rdpPassword !== "existing_rdp_password"
? form.rdpPassword || null
: null,
rdpDomain: form.domain || null,
rdpSecurity: form.security || null,
rdpIgnoreCert: form.ignoreCert,
vncAuthType: protocols.enableVnc ? form.vncAuthType : null,
vncCredentialId:
protocols.enableVnc &&
form.vncAuthType === "credential" &&
form.vncCredentialId
? Number(form.vncCredentialId)
: null,
vncPassword:
protocols.enableVnc &&
form.vncAuthType === "direct" &&
form.vncPassword !== "existing_vnc_password"
? form.vncPassword || null
: null,
vncUser:
protocols.enableVnc && form.vncAuthType === "direct"
? form.vncUser || null
: null,
telnetAuthType: protocols.enableTelnet ? form.telnetAuthType : null,
telnetCredentialId:
protocols.enableTelnet &&
form.telnetAuthType === "credential" &&
form.telnetCredentialId
? Number(form.telnetCredentialId)
: null,
telnetUser:
protocols.enableTelnet && form.telnetAuthType === "direct"
? form.telnetUser || null
: null,
telnetPassword:
protocols.enableTelnet &&
form.telnetAuthType === "direct" &&
form.telnetPassword !== "existing_telnet_password"
? form.telnetPassword || null
: null,
protocolAuth: protocolAuthPayload(form.protocolAuth, protocols),
// The editor keeps ids as strings; the API and every backend lookup take
// a number, and a string id does not compare equal on Postgres/MySQL.
jumpHosts: form.jumpHosts.map((j) => ({ hostId: Number(j.hostId) })),
portKnockSequence: form.portKnockSequence,
tunnelConnections: form.serverTunnels,
quickActions: form.quickActions.map((a) => ({
name: a.name,
snippetId: Number(a.snippetId),
})),
statsConfig: form.statsConfig,
guacamoleConfig:
(protocols.enableRdp || protocols.enableVnc || protocols.enableTelnet) &&
Object.keys(guacamoleConfig).length > 0
? guacamoleConfig
: null,
terminalConfig: protocols.enableSsh ? terminalOverrides : null,
statusCheckEnabled: form.statusCheckEnabled,
statusCheckInterval: form.statusCheckInterval,
...(form.defaultOverrides
? { defaultOverrides: form.defaultOverrides }
: {}),
// Left out with SSH off, so the stored values stay as they are.
...(protocols.enableSsh
? {
sudoPassword:
form.sudoPassword === "existing_sudo_password"
? undefined
: form.sudoPassword || "",
sshOptions: {
keepaliveInterval: Number(form.keepaliveInterval),
keepaliveCountMax: Number(form.keepaliveCountMax),
allowLegacyAlgorithms: form.allowLegacyAlgorithms,
agentForwarding: form.agentForwarding,
agentSocketPath: usesAgent ? form.agentSocketPath || null : null,
agentIdentity: usesAgent ? form.agentIdentity || null : null,
environmentVariables: form.environmentVariables,
},
}
: {}),
};
}
-480
View File
@@ -1,480 +0,0 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Box, FolderSearch, Server } from "lucide-react";
import { Input } from "@/components/input";
import { SectionCard, SettingRow, FakeSwitch } from "@/components/section-card";
import { getCredentials } from "@/main-axios";
import type { HostEditorForm, HostProtocols } from "./HostEditorData";
import { Select2 } from "@/components/select2";
import { resolveConnectionOrigin } from "@/lib/connection-origin";
import { HostEditorWebUiSection } from "./HostEditorWebUiSection";
type SetHostField = <K extends keyof HostEditorForm>(
key: K,
value: HostEditorForm[K],
) => void;
export function HostDockerTab({
form,
setField,
}: {
form: HostEditorForm;
setField: SetHostField;
}) {
const { t } = useTranslation();
const dockerConfig = form.dockerConfig ?? { runtime: "docker" as const };
const runtime =
dockerConfig.runtime === "podman"
? ("podman" as const)
: ("docker" as const);
return (
<SectionCard
title={t("hosts.dockerIntegration")}
icon={<Box className="size-3.5" />}
>
<div className="flex flex-col gap-4 py-3">
<SettingRow
label={t("hosts.enableDockerMonitor")}
description={t("hosts.enableDockerMonitorDesc")}
>
<FakeSwitch
checked={form.enableDocker}
onChange={(v) => setField("enableDocker", v)}
/>
</SettingRow>
{form.enableDocker && (
<SettingRow
label={t("hosts.containerRuntime")}
description={t("hosts.containerRuntimeDesc")}
>
<Select2
value={runtime}
onChange={(e) =>
setField("dockerConfig", {
...dockerConfig,
runtime: e.target.value as "docker" | "podman",
})
}
className="h-7 w-44 text-xs border border-border bg-background px-2 outline-none focus:ring-1 focus:ring-ring"
>
<option value="docker">
{t("hosts.containerRuntimeDocker")}
</option>
<option value="podman">
{t("hosts.containerRuntimePodman")}
</option>
</Select2>
</SettingRow>
)}
</div>
</SectionCard>
);
}
export function HostProxmoxTab({
form,
setField,
}: {
form: HostEditorForm;
setField: SetHostField;
}) {
const { t } = useTranslation();
const [credentials, setCredentials] = useState<
{ id: number; name: string; username: string | null }[]
>([]);
useEffect(() => {
getCredentials()
.then((res: unknown) => {
const raw =
(res as { credentials?: unknown })?.credentials ?? res ?? [];
const list = (Array.isArray(raw) ? raw : []).map(
(c: Record<string, unknown>) => ({
id: c.id as number,
name: c.name as string,
username: (c.username as string | null) ?? null,
}),
);
setCredentials(list);
})
.catch(() => {});
}, []);
const cfg = form.proxmoxConfig ?? {
defaultCredentialId: null,
defaultAuthType: "password",
windowsPatterns: "win, windows",
dockerPatterns: "docker",
preferredPrefixes: "10., 192.168.",
autoSyncEnabled: false,
syncIntervalMinutes: 15,
markMissingGuests: true,
};
const lastSyncResult = cfg.lastSyncResult;
const lastSyncSummary = lastSyncResult
? t("hosts.proxmoxLastSyncSummary", {
created: lastSyncResult.created,
updated: lastSyncResult.updated,
markedMissing: lastSyncResult.markedMissing,
skipped: lastSyncResult.skipped,
})
: t("hosts.proxmoxLastSyncNoResult");
const lastSyncDescription = cfg.lastSyncAt
? `${new Date(cfg.lastSyncAt).toLocaleString()} · ${lastSyncSummary}`
: t("hosts.proxmoxLastSyncNever");
return (
<SectionCard
title={t("hosts.proxmoxIntegration")}
icon={<Server className="size-3.5" />}
>
<div className="flex flex-col gap-0 py-1">
<SettingRow
label={t("hosts.enableProxmox")}
description={
<>
{t("hosts.enableProxmoxDesc")}{" "}
<a
href="https://docs.termix.site/features/files-and-hosts/proxmox-import"
target="_blank"
rel="noreferrer"
className="text-accent-brand hover:underline"
>
{t("hosts.docsLink")}
</a>
</>
}
>
<FakeSwitch
checked={form.enableProxmox}
onChange={(v) => setField("enableProxmox", v)}
/>
</SettingRow>
{form.enableProxmox && (
<>
<SettingRow
label={t("hosts.proxmoxDefaultAuthType")}
description={t("hosts.proxmoxDefaultAuthTypeDesc")}
>
<Select2
value={cfg.defaultAuthType ?? "password"}
onChange={(e) =>
setField("proxmoxConfig", {
...cfg,
defaultAuthType: e.target.value,
})
}
className="h-7 w-44 text-xs border border-border bg-background px-2 outline-none focus:ring-1 focus:ring-ring"
>
<option value="password">{t("hosts.authTypePassword")}</option>
<option value="key">{t("hosts.authTypeKey")}</option>
<option value="credential">
{t("hosts.authTypeCredential")}
</option>
<option value="opkssh">{t("hosts.authTypeOpkssh")}</option>
<option value="stepca">{t("hosts.authTypeStepca")}</option>
<option value="none">{t("hosts.authTypeNone")}</option>
</Select2>
</SettingRow>
<SettingRow
label={t("hosts.proxmoxDefaultCredential")}
description={t("hosts.proxmoxDefaultCredentialDesc")}
>
<Select2
value={cfg.defaultCredentialId ?? ""}
onChange={(e) =>
setField("proxmoxConfig", {
...cfg,
defaultCredentialId: e.target.value
? Number(e.target.value)
: null,
})
}
className="h-7 w-44 text-xs border border-border bg-background px-2 outline-none focus:ring-1 focus:ring-ring"
>
<option value="">{t("hosts.none")}</option>
{credentials.map((c) => (
<option key={c.id} value={c.id}>
{c.username ? `${c.name} (${c.username})` : c.name}
</option>
))}
</Select2>
</SettingRow>
<SettingRow
label={t("hosts.proxmoxWindowsDetection")}
description={t("hosts.proxmoxWindowsDetectionDesc")}
>
<Input
className="w-44 h-7 text-xs"
value={cfg.windowsPatterns}
onChange={(e) =>
setField("proxmoxConfig", {
...cfg,
windowsPatterns: e.target.value,
})
}
placeholder="win, windows"
/>
</SettingRow>
<SettingRow
label={t("hosts.proxmoxDockerDetection")}
description={t("hosts.proxmoxDockerDetectionDesc")}
>
<Input
className="w-44 h-7 text-xs"
value={cfg.dockerPatterns}
onChange={(e) =>
setField("proxmoxConfig", {
...cfg,
dockerPatterns: e.target.value,
})
}
placeholder="docker"
/>
</SettingRow>
<SettingRow
label={t("hosts.proxmoxPreferredRanges")}
description={t("hosts.proxmoxPreferredRangesDesc")}
>
<Input
className="w-44 h-7 text-xs"
value={cfg.preferredPrefixes}
onChange={(e) =>
setField("proxmoxConfig", {
...cfg,
preferredPrefixes: e.target.value,
})
}
placeholder="10., 192.168."
/>
</SettingRow>
<SettingRow
label={t("hosts.proxmoxAutoSync")}
description={t("hosts.proxmoxAutoSyncDesc")}
>
<FakeSwitch
checked={cfg.autoSyncEnabled === true}
onChange={(v) =>
setField("proxmoxConfig", {
...cfg,
autoSyncEnabled: v,
})
}
/>
</SettingRow>
<SettingRow
label={t("hosts.proxmoxSyncInterval")}
description={t("hosts.proxmoxSyncIntervalDesc")}
>
<Input
className="w-24 h-7 text-xs"
type="number"
min={5}
value={cfg.syncIntervalMinutes ?? 15}
onChange={(e) =>
setField("proxmoxConfig", {
...cfg,
syncIntervalMinutes: Math.max(
5,
Number.parseInt(e.target.value || "15", 10),
),
})
}
/>
</SettingRow>
<SettingRow
label={t("hosts.proxmoxMarkMissing")}
description={t("hosts.proxmoxMarkMissingDesc")}
>
<FakeSwitch
checked={cfg.markMissingGuests !== false}
onChange={(v) =>
setField("proxmoxConfig", {
...cfg,
markMissingGuests: v,
})
}
/>
</SettingRow>
<SettingRow
label={t("hosts.proxmoxLastSync")}
description={lastSyncDescription}
>
<span
className={`text-xs ${
cfg.lastSyncStatus === "error"
? "text-destructive"
: "text-muted-foreground"
}`}
title={cfg.lastSyncError ?? undefined}
>
{cfg.lastSyncStatus
? t(`hosts.proxmoxLastSyncStatus.${cfg.lastSyncStatus}`)
: t("hosts.proxmoxLastSyncStatus.pending")}
</span>
</SettingRow>
</>
)}
<SettingRow
label={t("hosts.enableProxmoxStats")}
description={t("hosts.enableProxmoxStatsDesc")}
>
<FakeSwitch
checked={form.enableProxmoxStats}
onChange={(v) => setField("enableProxmoxStats", v)}
/>
</SettingRow>
{form.enableProxmoxStats && (
<>
<SettingRow
label={t("hosts.proxmoxStatsPollInterval")}
description={t("hosts.proxmoxStatsPollIntervalDesc")}
>
<Input
className="w-24 h-7 text-xs"
type="number"
min={15}
value={form.proxmoxStatsConfig?.pollInterval ?? 60}
onChange={(e) =>
setField("proxmoxStatsConfig", {
...form.proxmoxStatsConfig,
pollInterval: Math.max(
15,
Number.parseInt(e.target.value || "60", 10),
),
})
}
/>
</SettingRow>
<SettingRow
label={t("hosts.proxmoxStatsNodeOverride")}
description={t("hosts.proxmoxStatsNodeOverrideDesc")}
>
<Input
className="w-44 h-7 text-xs"
value={form.proxmoxStatsConfig?.nodeName ?? ""}
placeholder={t("hosts.proxmoxStatsNodeOverridePlaceholder")}
onChange={(e) =>
setField("proxmoxStatsConfig", {
...form.proxmoxStatsConfig,
nodeName: e.target.value || null,
})
}
/>
</SettingRow>
</>
)}
</div>
</SectionCard>
);
}
export function HostFilesTab({
form,
setField,
}: {
form: HostEditorForm;
setField: SetHostField;
}) {
const { t } = useTranslation();
return (
<SectionCard
title={t("hosts.fileManager")}
icon={<FolderSearch className="size-3.5" />}
>
<div className="flex flex-col gap-4 py-3">
<SettingRow
label={t("hosts.enableFileManagerMonitor")}
description={t("hosts.enableFileManagerMonitorDesc")}
>
<FakeSwitch
checked={form.enableFileManager}
onChange={(v) => setField("enableFileManager", v)}
/>
</SettingRow>
<SettingRow
label={t("hosts.scpLegacyLabel")}
description={t("hosts.scpLegacyDesc")}
>
<FakeSwitch
checked={form.scpLegacy}
onChange={(v) => setField("scpLegacy", v)}
/>
</SettingRow>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.defaultPathLabel")}
</label>
<Input
placeholder="/"
value={form.defaultPath}
onChange={(e) => setField("defaultPath", e.target.value)}
/>
<span className="text-[10px] text-muted-foreground">
{t("hosts.fileManagerPathHint")}
</span>
</div>
</div>
</SectionCard>
);
}
/**
* Owns the async connection-origin resolution for the Web UI tab's tunnel
* gate, then hands the resolved boolean to the presentational section.
*
* tunnelAvailable = enableSsh && originIsLocal. Deliberately NOT gated on
* isElectron(): the forward binds wherever the backend runs, exactly as the
* server tunnels feature does, and a web deployment reaches it at the host
* serving Termix provided the endpoint opted out of a loopback bind. Gating
* this on the desktop would be stricter than the tunnels feature it mirrors.
*
* connectionType is passed as "ssh" deliberately: a tunnel endpoint always
* rides an SSH connection, and "ssh" keeps resolveConnectionOrigin out of its
* RDP/VNC/Telnet guacamole special case, which always resolves to "remote".
*
* The origin state lives here rather than in HostEditor.tsx, which is already
* ~2600 lines; this follows HostEditorGeneralTab's precedent of taking
* `protocols` as a prop and gating its own connection-origin control.
*
* Note the deliberate asymmetry with the sidebar: the sidebar entry appears on
* enableWebUi alone, because a direct endpoint needs no SSH -- but Web UI is
* an SSH sub-tab, so a host with SSH disabled cannot configure endpoints at
* all. That is the accepted behaviour, not an oversight.
*/
export function HostWebUiTab({
form,
setField,
protocols,
}: {
form: HostEditorForm;
setField: SetHostField;
protocols: HostProtocols;
}) {
const [originIsLocal, setOriginIsLocal] = useState(false);
useEffect(() => {
let cancelled = false;
void resolveConnectionOrigin({
connectionType: "ssh",
connectionOrigin: form.connectionOrigin,
}).then((origin) => {
if (!cancelled) setOriginIsLocal(origin === "local");
});
return () => {
cancelled = true;
};
}, [form.connectionOrigin]);
return (
<HostEditorWebUiSection
enableWebUi={form.enableWebUi}
webUiConfig={form.webUiConfig}
tunnelAvailable={protocols.enableSsh && originIsLocal}
setField={setField as (field: string, value: unknown) => void}
/>
);
}
+381 -381
View File
@@ -6,23 +6,20 @@ import { Input } from "@/components/input";
import { PasswordInput } from "@/components/password-input";
import { FakeSwitch, SectionCard, SettingRow } from "@/components/section-card";
import type { Host } from "@/types/ui-types";
import {
Globe,
LayoutGrid,
Monitor,
MousePointerClick,
Plus,
Tag,
Terminal,
Trash2,
X,
} from "lucide-react";
import { Activity, Globe, Plus, Tag, Terminal, Trash2, X } from "lucide-react";
import { FolderPathPicker } from "./FolderPathPicker";
import { HostParentPicker } from "./HostParentPicker";
import { getSSHFolders, isElectron } from "@/main-axios";
import { connectionOriginAppliesTo } from "./HostEditorData";
import { useSyncStatus } from "@/hooks/use-sync-status";
import type { HostEditorForm, HostProtocols } from "./HostEditorData";
import { Select2 } from "@/components/select2";
import { useHostProtocols } from "./host-protocols";
import {
DefaultsOnly,
HostDefaultBadge,
HostOnly,
} from "@/lib/host-defaults-context";
type HostEditorSetField = <K extends keyof HostEditorForm>(
key: K,
@@ -48,6 +45,8 @@ export function HostEditorGeneralTab({
simpleMode?: boolean;
}) {
const { t } = useTranslation();
const syncLinked = !!useSyncStatus()?.linked;
const pluginProtocols = useHostProtocols();
// Tracks which picker is shown, independent of whether a value is set yet
// -- switching to "parent host" mode with nothing picked shouldn't bounce
@@ -108,279 +107,242 @@ export function HostEditorGeneralTab({
return (
<>
{/* Protocols — enable/disable each connection type */}
<SectionCard
title={t("hosts.protocols")}
icon={<Globe className="size-3.5" />}
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 py-3">
{[
{
proto: "enableSsh" as const,
label: t("hosts.tabSsh"),
desc: t("hosts.secureShell"),
icon: <Terminal className="size-4" />,
portField: "sshPort" as const,
},
{
proto: "enableRdp" as const,
label: t("hosts.tabRdp"),
desc: t("hosts.remoteDesktop"),
icon: <Monitor className="size-4" />,
portField: "rdpPort" as const,
},
{
proto: "enableVnc" as const,
label: t("hosts.tabVnc"),
desc: t("hosts.virtualNetwork"),
icon: <MousePointerClick className="size-4" />,
portField: "vncPort" as const,
},
{
proto: "enableTelnet" as const,
label: t("hosts.tabTelnet"),
desc: t("hosts.unencryptedShell"),
icon: <Terminal className="size-4" />,
portField: "telnetPort" as const,
},
].map(({ proto, label, desc, icon }) => {
const enabled = protocols[proto];
return (
<div
key={proto}
className={`flex items-center gap-3 p-3 border transition-colors ${enabled ? "border-accent-brand/20 bg-accent-brand/5" : "border-border bg-muted/10"}`}
>
<HostOnly>
{/* Protocols — enable/disable each connection type */}
<SectionCard
title={t("hosts.protocols")}
icon={<Globe className="size-3.5" />}
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 py-3">
{[
{
proto: "enableSsh",
label: t("hosts.tabSsh"),
desc: t("hosts.secureShell"),
icon: <Terminal className="size-4" />,
},
...pluginProtocols.map((protocol) => {
const Icon = protocol.icon;
return {
proto: protocol.settingKey,
label: t(protocol.titleKey),
desc: protocol.descriptionKey
? t(protocol.descriptionKey)
: "",
icon: <Icon className="size-4" />,
};
}),
].map(({ proto, label, desc, icon }) => {
const enabled = !!protocols[proto];
return (
<div
className={`size-8 flex items-center justify-center shrink-0 ${enabled ? "text-accent-brand" : "text-muted-foreground/30"}`}
key={proto}
className={`flex items-center gap-3 p-3 border transition-colors ${enabled ? "border-accent-brand/20 bg-accent-brand/5" : "border-border bg-muted/10"}`}
>
{icon}
</div>
<div className="flex flex-col gap-1 flex-1 min-w-0">
<span
className={`text-xs font-bold ${enabled ? "text-foreground" : "text-muted-foreground/50"}`}
<div
className={`size-8 flex items-center justify-center shrink-0 ${enabled ? "text-accent-brand" : "text-muted-foreground/30"}`}
>
{label}
</span>
<span className="text-[10px] text-muted-foreground/50">
{desc}
</span>
{icon}
</div>
<div className="flex flex-col gap-1 flex-1 min-w-0">
<span
className={`text-xs font-bold ${enabled ? "text-foreground" : "text-muted-foreground/50"}`}
>
{label}
</span>
<span className="text-[10px] text-muted-foreground/50">
{desc}
</span>
</div>
<FakeSwitch
checked={enabled}
onChange={(v: boolean) => handleProtocolToggle(proto, v)}
/>
</div>
<FakeSwitch
checked={enabled}
onChange={(v: boolean) => handleProtocolToggle(proto, v)}
/>
</div>
);
})}
</div>
</SectionCard>
<SectionCard
title={t("hosts.connectionDetails")}
icon={<Globe className="size-3.5" />}
>
<div className="flex flex-col gap-4 py-3">
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.addressIp")}
</label>
<Input
placeholder="10.0.0.1 or example.com"
value={form.ip}
onChange={(e) => setField("ip", e.target.value)}
/>
);
})}
</div>
</SectionCard>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<SectionCard
title={t("hosts.connectionDetails")}
icon={<Globe className="size-3.5" />}
>
<div className="flex flex-col gap-4 py-3">
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.friendlyName")}
{t("hosts.addressIp")}
</label>
<Input
placeholder="e.g. Web Server Production"
value={form.name}
onChange={(e) => setField("name", e.target.value)}
placeholder="10.0.0.1 or example.com"
value={form.ip}
onChange={(e) => setField("ip", e.target.value)}
/>
</div>
{protocols.enableSsh && !simpleMode && (
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.macAddress")}
</label>
<a
href="https://docs.termix.site/features/networking/wake-on-lan"
target="_blank"
rel="noreferrer"
className="text-[10px] text-accent-brand hover:underline"
>
{t("hosts.docsLink")}
</a>
</div>
<Input
placeholder="AA:BB:CC:DD:EE:FF"
value={form.macAddress}
onChange={(e) => setField("macAddress", e.target.value)}
/>
</div>
)}
{protocols.enableSsh && !simpleMode && form.macAddress && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.wolBroadcastAddress")}
{t("hosts.friendlyName")}
</label>
<Input
placeholder="192.168.1.255"
value={form.wolBroadcastAddress}
onChange={(e) =>
setField("wolBroadcastAddress", e.target.value)
}
placeholder="e.g. Web Server Production"
value={form.name}
onChange={(e) => setField("name", e.target.value)}
/>
<p className="text-[10px] text-muted-foreground/60">
{t("hosts.wolBroadcastAddressDesc")}
</p>
</div>
)}
</div>
</div>
</div>
</SectionCard>
</SectionCard>
{!protocols.enableSsh &&
!protocols.enableRdp &&
!protocols.enableVnc &&
!protocols.enableTelnet && (
{!Object.values(protocols).some(Boolean) && (
<div className="flex items-center gap-3 p-3 border border-border bg-muted/20 text-xs text-muted-foreground">
<Globe className="size-4 shrink-0 text-muted-foreground/40" />
<span>{t("hosts.enableAtLeastOneProtocol")}</span>
</div>
)}
</HostOnly>
<SectionCard
title={t("hosts.folderAndAdvanced")}
icon={<Tag className="size-3.5" />}
className={simpleMode ? "hidden" : undefined}
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 py-3">
<div className="flex flex-col gap-1.5 col-span-2 md:col-span-1">
<div className="flex items-center justify-between">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{placementMode === "parentHost"
? t("hosts.parentHost")
: t("hosts.folder")}
</label>
<button
type="button"
onClick={() => {
if (placementMode === "parentHost") {
setPlacementMode("folder");
setField("parentHostId", "");
} else {
setPlacementMode("parentHost");
setField("folder", "");
}
}}
className="text-[10px] text-accent-brand hover:underline"
>
{placementMode === "parentHost"
? t("hosts.useFolderInstead")
: t("hosts.useParentHostInstead")}
</button>
<HostOnly>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 py-3">
<div className="flex flex-col gap-1.5 col-span-2 md:col-span-1">
<div className="flex items-center justify-between">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{placementMode === "parentHost"
? t("hosts.parentHost")
: t("hosts.folder")}
</label>
<button
type="button"
onClick={() => {
if (placementMode === "parentHost") {
setPlacementMode("folder");
setField("parentHostId", "");
} else {
setPlacementMode("parentHost");
setField("folder", "");
}
}}
className="text-[10px] text-accent-brand hover:underline"
>
{placementMode === "parentHost"
? t("hosts.useFolderInstead")
: t("hosts.useParentHostInstead")}
</button>
</div>
{placementMode === "parentHost" ? (
<HostParentPicker
value={String(form.parentHostId)}
onChange={(hostId) => setField("parentHostId", hostId)}
hosts={hosts}
excludeHostId={host?.id}
/>
) : (
<FolderPathPicker
value={form.folder}
onChange={(path) => setField("folder", path)}
folderPaths={folderPaths}
folderMeta={folderMeta}
/>
)}
</div>
{placementMode === "parentHost" ? (
<HostParentPicker
value={String(form.parentHostId)}
onChange={(hostId) => setField("parentHostId", hostId)}
hosts={hosts}
excludeHostId={host?.id}
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.tags")}
</label>
<div className="flex flex-wrap items-center gap-1 min-h-9 px-2 py-1 border border-border bg-background focus-within:ring-1 focus-within:ring-ring">
{form.tags.map((tag) => (
<span
key={tag}
className="flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] bg-muted border border-border/60 text-foreground"
>
{tag}
<button
type="button"
onClick={() =>
setField(
"tags",
form.tags.filter((tg) => tg !== tag),
)
}
className="text-muted-foreground hover:text-destructive ml-0.5"
>
<X className="size-2.5" />
</button>
</span>
))}
<input
className="flex-1 min-w-16 text-xs bg-transparent outline-none placeholder:text-muted-foreground/50"
placeholder={form.tags.length === 0 ? t("hosts.addTag") : ""}
value={form.tagInput}
onChange={(e) => setField("tagInput", e.target.value)}
onKeyDown={(e) => {
if (
(e.key === " " || e.key === "Enter") &&
form.tagInput.trim()
) {
e.preventDefault();
const tag = form.tagInput.trim();
if (!form.tags.includes(tag))
setField("tags", [...form.tags, tag]);
setField("tagInput", "");
} else if (
e.key === "Backspace" &&
!form.tagInput &&
form.tags.length > 0
) {
setField("tags", form.tags.slice(0, -1));
}
}}
/>
</div>
</div>
<div className="flex flex-col gap-1.5 col-span-2">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.privateNotes")}
</label>
<textarea
rows={3}
placeholder={t("hosts.privateNotesPlaceholder")}
className="w-full px-3 py-2 text-xs bg-background border border-border text-foreground placeholder:text-muted-foreground resize-none outline-none focus:ring-1 focus:ring-ring"
value={form.notes}
onChange={(e) => setField("notes", e.target.value)}
/>
) : (
<FolderPathPicker
value={form.folder}
onChange={(path) => setField("folder", path)}
folderPaths={folderPaths}
folderMeta={folderMeta}
</div>
<SettingRow
label={t("hosts.pinToTop")}
description={t("hosts.pinToTopDesc")}
>
<FakeSwitch
checked={form.pin}
onChange={(v) => setField("pin", v)}
/>
</SettingRow>
{syncLinked && (
<SettingRow
label={t("hosts.localOnly")}
description={t("hosts.localOnlyDesc")}
>
<FakeSwitch
checked={form.localOnly}
onChange={(v) => setField("localOnly", v)}
/>
</SettingRow>
)}
</div>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.tags")}
</label>
<div className="flex flex-wrap items-center gap-1 min-h-9 px-2 py-1 border border-border bg-background focus-within:ring-1 focus-within:ring-ring">
{form.tags.map((tag) => (
<span
key={tag}
className="flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] bg-muted border border-border/60 text-foreground"
>
{tag}
<button
type="button"
onClick={() =>
setField(
"tags",
form.tags.filter((tg) => tg !== tag),
)
}
className="text-muted-foreground hover:text-destructive ml-0.5"
>
<X className="size-2.5" />
</button>
</span>
))}
<input
className="flex-1 min-w-16 text-xs bg-transparent outline-none placeholder:text-muted-foreground/50"
placeholder={form.tags.length === 0 ? t("hosts.addTag") : ""}
value={form.tagInput}
onChange={(e) => setField("tagInput", e.target.value)}
onKeyDown={(e) => {
if (
(e.key === " " || e.key === "Enter") &&
form.tagInput.trim()
) {
e.preventDefault();
const tag = form.tagInput.trim();
if (!form.tags.includes(tag))
setField("tags", [...form.tags, tag]);
setField("tagInput", "");
} else if (
e.key === "Backspace" &&
!form.tagInput &&
form.tags.length > 0
) {
setField("tags", form.tags.slice(0, -1));
}
}}
/>
</div>
</div>
<div className="flex flex-col gap-1.5 col-span-2">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.privateNotes")}
</label>
<textarea
rows={3}
placeholder={t("hosts.privateNotesPlaceholder")}
className="w-full px-3 py-2 text-xs bg-background border border-border text-foreground placeholder:text-muted-foreground resize-none outline-none focus:ring-1 focus:ring-ring"
value={form.notes}
onChange={(e) => setField("notes", e.target.value)}
/>
</div>
<SettingRow
label={t("hosts.pinToTop")}
description={t("hosts.pinToTopDesc")}
>
<FakeSwitch
checked={form.pin}
onChange={(v) => setField("pin", v)}
/>
</SettingRow>
</div>
</HostOnly>
<div className="flex flex-col gap-3 border-t border-border pt-4 pb-0">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.portKnockingSequence")}
</span>
<HostDefaultBadge settingKey="core.portKnockSequence" />
<a
href="https://docs.termix.site/features/networking/port-knocking"
target="_blank"
@@ -495,6 +457,7 @@ export function HostEditorGeneralTab({
<SettingRow
label={t("hosts.useSocks5Proxy")}
description={t("hosts.useSocks5ProxyDesc")}
defaultKey="core.socks5"
>
<FakeSwitch
checked={form.useSocks5}
@@ -559,19 +522,21 @@ export function HostEditorGeneralTab({
}
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.proxyPassword")}
</label>
<PasswordInput
className="h-7 text-xs pr-8"
placeholder={t("hosts.optional")}
value={form.socks5Password}
onChange={(e) =>
setField("socks5Password", e.target.value)
}
/>
</div>
<HostOnly>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.proxyPassword")}
</label>
<PasswordInput
className="h-7 text-xs pr-8"
placeholder={t("hosts.optional")}
value={form.socks5Password}
onChange={(e) =>
setField("socks5Password", e.target.value)
}
/>
</div>
</HostOnly>
</div>
)}
@@ -675,24 +640,26 @@ export function HostEditorGeneralTab({
}}
/>
</div>
<div className="flex flex-col gap-1 col-span-2">
<label className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.proxyPassword")}
</label>
<PasswordInput
className="h-7 text-xs pr-8"
placeholder={t("hosts.optional")}
value={node.password}
onChange={(e) => {
const u = [...form.socks5ProxyChain];
u[ni] = {
...u[ni],
password: e.target.value,
};
setField("socks5ProxyChain", u);
}}
/>
</div>
<HostOnly>
<div className="flex flex-col gap-1 col-span-2">
<label className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.proxyPassword")}
</label>
<PasswordInput
className="h-7 text-xs pr-8"
placeholder={t("hosts.optional")}
value={node.password}
onChange={(e) => {
const u = [...form.socks5ProxyChain];
u[ni] = {
...u[ni],
password: e.target.value,
};
setField("socks5ProxyChain", u);
}}
/>
</div>
</HostOnly>
</div>
</div>
))}
@@ -767,127 +734,160 @@ export function HostEditorGeneralTab({
</div>
)}
{isElectron() && connectionOriginAppliesTo(protocols) && (
<SettingRow
label={t("hosts.connectionOrigin")}
description={
protocols.enableRdp ||
protocols.enableVnc ||
protocols.enableTelnet
? `${t("hosts.connectionOriginDesc")} ${t("hosts.connectionOriginGuacamoleNote")}`
: t("hosts.connectionOriginDesc")
}
>
<select
className="flex h-7 border border-border bg-background px-2 py-0 text-xs outline-none focus:ring-1 focus:ring-ring"
value={form.connectionOrigin ?? ""}
onChange={(e) =>
setField(
"connectionOrigin",
(e.target.value || null) as "local" | "remote" | null,
)
}
>
<option value="">{t("hosts.connectionOriginDefault")}</option>
<option value="local">
{t("hosts.connectionOriginLocal")}
</option>
<option value="remote">
{t("hosts.connectionOriginRemote")}
</option>
</select>
</SettingRow>
)}
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.jumpHostChainLabel")}
</span>
<Button
variant="outline"
size="sm"
className="h-6 text-[10px] px-2 border-accent-brand/40 text-accent-brand"
onClick={() =>
setField("jumpHosts", [...form.jumpHosts, { hostId: "" }])
}
>
<Plus className="size-3 mr-1" /> {t("hosts.addJumpBtn")}
</Button>
</div>
{form.jumpHosts.length === 0 && (
<p className="text-[10px] text-muted-foreground/50">
{t("hosts.noJumpHosts")}
</p>
)}
<div className="flex flex-col gap-2">
{form.jumpHosts.map((jh, i) => (
<div
key={i}
className="flex items-center gap-2 p-2 bg-background border border-border"
>
<span className="text-[10px] font-bold text-muted-foreground shrink-0">
{i + 1}.
</span>
<Select2
className="flex h-7 flex-1 border border-border bg-background px-2 py-0 text-xs outline-none focus:ring-1 focus:ring-ring"
value={jh.hostId}
onChange={(e) => {
const updated = [...form.jumpHosts];
updated[i] = { hostId: e.target.value };
setField("jumpHosts", updated);
}}
>
<option value="">{t("hosts.selectAServer")}</option>
{hosts
.filter((h) => (host ? h.id !== host.id : true))
.map((h) => (
<option key={h.id} value={h.id}>
{h.name || h.ip}
</option>
))}
</Select2>
<Button
variant="ghost"
size="icon"
className="size-7 text-destructive"
onClick={() =>
setField(
"jumpHosts",
form.jumpHosts.filter((_, idx) => idx !== i),
<HostOnly>
<SettingRow
label={t("hosts.connectionOrigin")}
description={[
t("hosts.connectionOriginDesc"),
...new Set(
pluginProtocols
.filter(
(protocol) =>
protocols[protocol.settingKey] &&
protocol.connectionOriginNoteKey,
)
}
.map((protocol) => t(protocol.connectionOriginNoteKey!)),
),
].join(" ")}
>
<select
className="flex h-7 border border-border bg-background px-2 py-0 text-xs outline-none focus:ring-1 focus:ring-ring"
value={form.connectionOrigin ?? ""}
onChange={(e) =>
setField(
"connectionOrigin",
(e.target.value || null) as "local" | "remote" | null,
)
}
>
<option value="">{t("hosts.connectionOriginDefault")}</option>
<option value="local">
{t("hosts.connectionOriginLocal")}
</option>
<option value="remote">
{t("hosts.connectionOriginRemote")}
</option>
</select>
</SettingRow>
</HostOnly>
)}
<DefaultsOnly settingKey="core.jumpHosts">
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<span className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.jumpHostChainLabel")}
<HostDefaultBadge settingKey="core.jumpHosts" />
</span>
<Button
variant="outline"
size="sm"
className="h-6 text-[10px] px-2 border-accent-brand/40 text-accent-brand"
onClick={() =>
setField("jumpHosts", [...form.jumpHosts, { hostId: "" }])
}
>
<Plus className="size-3 mr-1" /> {t("hosts.addJumpBtn")}
</Button>
</div>
{form.jumpHosts.length === 0 && (
<p className="text-[10px] text-muted-foreground/50">
{t("hosts.noJumpHosts")}
</p>
)}
<div className="flex flex-col gap-2">
{form.jumpHosts.map((jh, i) => (
<div
key={i}
className="flex items-center gap-2 p-2 bg-background border border-border"
>
<Trash2 className="size-3.5" />
</Button>
</div>
))}
<span className="text-[10px] font-bold text-muted-foreground shrink-0">
{i + 1}.
</span>
<Select2
className="flex h-7 flex-1 border border-border bg-background px-2 py-0 text-xs outline-none focus:ring-1 focus:ring-ring"
value={jh.hostId}
onChange={(e) => {
const updated = [...form.jumpHosts];
updated[i] = { hostId: e.target.value };
setField("jumpHosts", updated);
}}
>
<option value="">{t("hosts.selectAServer")}</option>
{hosts
.filter((h) => (host ? h.id !== host.id : true))
.map((h) => (
<option key={h.id} value={h.id}>
{h.name || h.ip}
</option>
))}
</Select2>
<Button
variant="ghost"
size="icon"
className="size-7 text-destructive"
onClick={() =>
setField(
"jumpHosts",
form.jumpHosts.filter((_, idx) => idx !== i),
)
}
>
<Trash2 className="size-3.5" />
</Button>
</div>
))}
</div>
</div>
</div>
</DefaultsOnly>
</div>
</SectionCard>
<SectionCard
title={t("hosts.connectionToolbar")}
icon={<LayoutGrid className="size-3.5" />}
title={t("hosts.statusChecksLabel")}
icon={<Activity className="size-3.5" />}
>
<div className="flex flex-col gap-4 py-3">
<div className="flex flex-col gap-0 py-1">
<SettingRow
label={t("hosts.showConnectionToolbar")}
description={t("hosts.showConnectionToolbarDesc")}
label={t("hosts.enableStatusChecks")}
description={t("hosts.enableStatusChecksDesc")}
defaultKey="core.statusCheckEnabled"
>
<FakeSwitch
checked={form.enableTerminalToolbar}
onChange={(value) => setField("enableTerminalToolbar", value)}
/>
</SettingRow>
<SettingRow
label={t("hosts.enableAiAssistant")}
description={t("hosts.enableAiAssistantDesc")}
>
<FakeSwitch
checked={form.enableAiAssistant}
onChange={(value) => setField("enableAiAssistant", value)}
checked={form.statusCheckEnabled}
onChange={(value) => setField("statusCheckEnabled", value)}
/>
</SettingRow>
{form.statusCheckEnabled && (
<SettingRow
label={t("hosts.useGlobalInterval")}
description={t("hosts.useGlobalIntervalDesc")}
defaultKey="core.statusCheckInterval"
>
<FakeSwitch
checked={form.statusCheckInterval === null}
onChange={(useGlobal) =>
setField("statusCheckInterval", useGlobal ? null : 60)
}
/>
</SettingRow>
)}
{form.statusCheckEnabled && form.statusCheckInterval !== null && (
<SettingRow
label={t("hosts.checkIntervalS")}
description={t("hosts.checkIntervalDesc")}
>
<Input
type="number"
min={5}
max={86400}
value={form.statusCheckInterval}
onChange={(e) =>
setField("statusCheckInterval", Number(e.target.value))
}
className="w-20 h-7 text-xs text-right [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
</SettingRow>
)}
</div>
</SectionCard>
</>
File diff suppressed because it is too large. Load diff
-402
View File
@@ -1,402 +0,0 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import {
Activity,
HardDrive,
LayoutDashboard,
Plus,
Server,
Trash2,
Zap,
} from "lucide-react";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { SectionCard, SettingRow, FakeSwitch } from "@/components/section-card";
import type { HostEditorForm } from "./HostEditorData";
import { Select2 } from "@/components/select2";
type SetHostField = <K extends keyof HostEditorForm>(
key: K,
value: HostEditorForm[K],
) => void;
export function HostStatsTab({
form,
setField,
snippets,
}: {
form: HostEditorForm;
setField: SetHostField;
snippets: { id: number; name: string }[];
}) {
const { t } = useTranslation();
const [newMount, setNewMount] = useState("");
const [newMonitoredPath, setNewMonitoredPath] = useState("");
const [newMonitoredLabel, setNewMonitoredLabel] = useState("");
const excludedMounts = form.statsConfig.excludedMounts ?? [];
const monitoredMounts = form.statsConfig.monitoredMounts ?? [];
const addExcludedMount = () => {
const value = newMount.trim();
if (!value || excludedMounts.includes(value)) {
setNewMount("");
return;
}
setField("statsConfig", {
...form.statsConfig,
excludedMounts: [...excludedMounts, value],
});
setNewMount("");
};
const addMonitoredMount = () => {
const path = newMonitoredPath.trim();
if (!path || monitoredMounts.some((entry) => entry.path === path)) return;
setField("statsConfig", {
...form.statsConfig,
monitoredMounts: [
...monitoredMounts,
{ path, label: newMonitoredLabel.trim() || undefined },
],
});
setNewMonitoredPath("");
setNewMonitoredLabel("");
};
return (
<>
<SectionCard
title={t("hosts.statusChecksLabel")}
icon={<Activity className="size-3.5" />}
>
<div className="flex flex-col gap-0 py-1">
<SettingRow
label={t("hosts.enableStatusChecks")}
description={t("hosts.enableStatusChecksDesc")}
>
<FakeSwitch
checked={form.statsConfig.statusCheckEnabled}
onChange={(v) =>
setField("statsConfig", {
...form.statsConfig,
statusCheckEnabled: v,
})
}
/>
</SettingRow>
{form.statsConfig.statusCheckEnabled && (
<SettingRow
label={t("hosts.useGlobalInterval")}
description={t("hosts.useGlobalIntervalDesc")}
>
<FakeSwitch
checked={form.statsConfig.useGlobalStatusInterval}
onChange={(v) =>
setField("statsConfig", {
...form.statsConfig,
useGlobalStatusInterval: v,
})
}
/>
</SettingRow>
)}
{form.statsConfig.statusCheckEnabled &&
!form.statsConfig.useGlobalStatusInterval && (
<SettingRow
label={t("hosts.checkIntervalS")}
description={t("hosts.checkIntervalDesc")}
>
<Input
type="number"
value={form.statsConfig.statusCheckInterval}
onChange={(e) =>
setField("statsConfig", {
...form.statsConfig,
statusCheckInterval: Number(e.target.value),
})
}
className="w-20 h-7 text-xs text-right [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
</SettingRow>
)}
</div>
</SectionCard>
<SectionCard
title={t("hosts.metricsCollectionLabel")}
icon={<Server className="size-3.5" />}
>
<div className="flex flex-col gap-0 py-1">
<SettingRow
label={t("hosts.enableMetricsLabel")}
description={t("hosts.enableMetricsDesc")}
>
<FakeSwitch
checked={form.statsConfig.metricsEnabled}
onChange={(v) =>
setField("statsConfig", {
...form.statsConfig,
metricsEnabled: v,
})
}
/>
</SettingRow>
{form.statsConfig.metricsEnabled && (
<SettingRow
label={t("hosts.useGlobalMetrics")}
description={t("hosts.useGlobalMetricsDesc")}
>
<FakeSwitch
checked={form.statsConfig.useGlobalMetricsInterval}
onChange={(v) =>
setField("statsConfig", {
...form.statsConfig,
useGlobalMetricsInterval: v,
})
}
/>
</SettingRow>
)}
{form.statsConfig.metricsEnabled &&
!form.statsConfig.useGlobalMetricsInterval && (
<SettingRow
label={t("hosts.metricsIntervalS")}
description={t("hosts.metricsIntervalDesc2")}
>
<Input
type="number"
value={form.statsConfig.metricsInterval}
onChange={(e) =>
setField("statsConfig", {
...form.statsConfig,
metricsInterval: Number(e.target.value),
})
}
className="w-20 h-7 text-xs text-right [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
</SettingRow>
)}
</div>
</SectionCard>
<SectionCard
title={t("hosts.monitoredMountsLabel")}
icon={<HardDrive className="size-3.5" />}
>
<div className="flex flex-col gap-3 py-3">
<p className="text-xs text-muted-foreground">
{t("hosts.monitoredMountsDesc")}
</p>
<div className="grid grid-cols-[1fr_0.7fr_auto] gap-2">
<Input
className="h-7 text-xs"
placeholder={t("hosts.monitoredMountPathPlaceholder")}
value={newMonitoredPath}
onChange={(e) => setNewMonitoredPath(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addMonitoredMount();
}
}}
/>
<Input
className="h-7 text-xs"
placeholder={t("hosts.monitoredMountLabelPlaceholder")}
value={newMonitoredLabel}
onChange={(e) => setNewMonitoredLabel(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addMonitoredMount();
}
}}
/>
<Button
variant="outline"
size="sm"
className="h-7 px-2 text-[10px]"
onClick={addMonitoredMount}
>
<Plus className="mr-1 size-3" /> {t("hosts.addMonitoredMount")}
</Button>
</div>
{monitoredMounts.map((entry) => (
<div
key={entry.path}
className="flex items-center gap-2 border border-border bg-muted/20 p-2 group"
>
<span className="min-w-0 flex-1 truncate font-mono text-xs">
{entry.path}
</span>
{entry.label && (
<span className="truncate text-xs text-muted-foreground">
{entry.label}
</span>
)}
<button
className="text-muted-foreground opacity-0 transition-opacity hover:text-destructive group-hover:opacity-100"
onClick={() =>
setField("statsConfig", {
...form.statsConfig,
monitoredMounts: monitoredMounts.filter(
(mount) => mount.path !== entry.path,
),
})
}
>
<Trash2 className="size-3.5" />
</button>
</div>
))}
</div>
</SectionCard>
<SectionCard
title={t("hosts.excludedMountsLabel")}
icon={<HardDrive className="size-3.5" />}
>
<div className="flex flex-col gap-3 py-3">
<p className="text-xs text-muted-foreground">
{t("hosts.excludedMountsDesc")}
</p>
<div className="flex items-center gap-2">
<Input
className="h-7 text-xs flex-1"
placeholder={t("hosts.excludedMountsPlaceholder")}
value={newMount}
onChange={(e) => setNewMount(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addExcludedMount();
}
}}
/>
<Button
variant="outline"
size="sm"
className="h-7 text-[10px] px-2 border-accent-brand/40 text-accent-brand"
onClick={addExcludedMount}
>
<Plus className="size-3 mr-1" /> {t("hosts.addExcludedMount")}
</Button>
</div>
{excludedMounts.length === 0 && (
<div className="flex flex-col items-center justify-center py-4 text-muted-foreground/40 gap-1.5">
<HardDrive className="size-6" />
<span className="text-xs">{t("hosts.excludedMountsEmpty")}</span>
</div>
)}
{excludedMounts.map((mount, i) => (
<div
key={`${mount}-${i}`}
className="flex items-center gap-2 p-2 bg-muted/20 border border-border group"
>
<span className="text-xs flex-1 font-mono truncate">{mount}</span>
<button
className="text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() =>
setField("statsConfig", {
...form.statsConfig,
excludedMounts: excludedMounts.filter(
(_, idx) => idx !== i,
),
})
}
>
<Trash2 className="size-3.5" />
</button>
</div>
))}
</div>
</SectionCard>
<SectionCard
title={t("hosts.visibleWidgets")}
icon={<LayoutDashboard className="size-3.5" />}
>
<div className="flex flex-col gap-2 py-3">
<p className="text-xs text-muted-foreground">
{t("hosts.widgetsMovedToHostMetrics")}
</p>
</div>
</SectionCard>
<SectionCard
title={t("hosts.quickActionsLabel")}
icon={<Zap className="size-3.5" />}
action={
<Button
variant="outline"
size="sm"
className="h-6 text-[10px] px-2 border-accent-brand/40 text-accent-brand"
onClick={() =>
setField("quickActions", [
...form.quickActions,
{ name: "", snippetId: "" },
])
}
>
<Plus className="size-3 mr-1" /> {t("hosts.addActionBtn")}
</Button>
}
>
<div className="flex flex-col gap-3 py-3">
<p className="text-xs text-muted-foreground">
{t("hosts.quickActionsToolbar")}
</p>
{form.quickActions.length === 0 && (
<div className="flex flex-col items-center justify-center py-4 text-muted-foreground/40 gap-1.5">
<Zap className="size-6" />
<span className="text-xs">{t("hosts.noQuickActions")}</span>
</div>
)}
{form.quickActions.map((a, i) => (
<div
key={i}
className="flex items-center gap-2 p-2 bg-muted/20 border border-border group"
>
<Input
className="h-7 text-xs flex-1"
placeholder={t("hosts.buttonLabel")}
value={a.name}
onChange={(e) => {
const updated = [...form.quickActions];
updated[i] = { ...updated[i], name: e.target.value };
setField("quickActions", updated);
}}
/>
<Select2
className="h-7 text-xs flex-1 border border-border bg-background px-2 outline-none focus:ring-1 focus:ring-ring"
value={a.snippetId}
onChange={(e) => {
const updated = [...form.quickActions];
updated[i] = {
...updated[i],
snippetId: e.target.value,
};
setField("quickActions", updated);
}}
>
<option value="">{t("hosts.selectSnippetPlaceholder")}</option>
{snippets.map((s) => (
<option key={s.id} value={String(s.id)}>
{s.name}
</option>
))}
</Select2>
<button
className="text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() =>
setField(
"quickActions",
form.quickActions.filter((_, idx) => idx !== i),
)
}
>
<Trash2 className="size-3.5" />
</button>
</div>
))}
</div>
</SectionCard>
</>
);
}
-457
View File
@@ -1,457 +0,0 @@
import { Globe } from "lucide-react";
import { useTranslation } from "react-i18next";
import { isElectron } from "@/lib/electron";
import {
webEndpointRefusalReason,
type WebEndpointRefusalReason,
} from "@/lib/web-endpoint-url";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { Checkbox } from "@/components/checkbox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/select";
import { SectionCard, SettingRow, FakeSwitch } from "@/components/section-card";
import {
MAX_WEB_ENDPOINTS,
MAX_WEB_ENDPOINT_LABEL_LENGTH,
type WebEndpoint,
type WebUiConfig,
} from "@/types/index";
import {
MAX_WEB_ENDPOINT_PORT,
MIN_WEB_ENDPOINT_PORT,
isWebEndpointPortValid,
webEndpointErrorKey,
webEndpointRowError,
} from "@/lib/web-endpoint-validation";
/**
* crypto.randomUUID is undefined outside a secure context, so a plain-http web
* deployment -- a first-class target for the direct+external path -- would
* throw on "Add endpoint". Matches the guarded form already used in
* MacrosPanel, PanePreview, KeybindingsDialog and AppShell.
*/
function endpointId(): string {
return typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
}
// Only the tunnel-bind reasons render here; this preview sits inside the
// tunnel-access block. The direct cookie refusal is enforced at open time in
// WebEndpointTab / openWebEndpointExternally, where the target host is known.
const BIND_HOST_REFUSAL_MESSAGES: Record<
Exclude<WebEndpointRefusalReason, "direct-shares-session-cookie">,
string
> = {
"loopback-bind-on-remote-backend": "hosts.webUiBindHostUnreachable",
"shares-session-cookie-with-termix": "hosts.webUiBindHostSharesSessionCookie",
};
/**
* True when the bind address reaches beyond the machine running the backend. A
* forward bound there serves the target's web UI to anyone who can reach the
* port, with no authentication in front of it, so the editor says so plainly
* rather than leaving it to be discovered.
*/
function isExposedBindHost(bindHost: string | undefined): boolean {
const value = (bindHost ?? "").trim();
if (!value) return false;
return !["127.0.0.1", "localhost", "::1", "[::1]"].includes(
value.toLowerCase(),
);
}
function newEndpoint(label: string): WebEndpoint {
return {
id: endpointId(),
label,
scheme: "https",
port: 443,
path: "/",
access: "direct",
render: "embedded",
};
}
export function HostEditorWebUiSection({
enableWebUi,
webUiConfig,
tunnelAvailable,
setField,
}: {
enableWebUi: boolean;
webUiConfig: WebUiConfig;
tunnelAvailable: boolean;
setField: (field: string, value: unknown) => void;
}) {
const { t } = useTranslation();
const endpoints = webUiConfig?.endpoints ?? [];
const commit = (next: WebEndpoint[]) =>
setField("webUiConfig", { endpoints: next });
const update = (index: number, patch: Partial<WebEndpoint>) =>
commit(
endpoints.map((endpoint, i) =>
i === index ? { ...endpoint, ...patch } : endpoint,
),
);
// Labels identify an endpoint in the sidebar picker, so a fresh row must not
// silently collide with one already there.
const uniqueLabel = (base: string) => {
const taken = new Set(endpoints.map((e) => e.label.trim()));
if (!taken.has(base)) return base;
for (let n = 2; ; n += 1) {
const candidate = `${base} ${n}`;
if (!taken.has(candidate)) return candidate;
}
};
const addEndpoint = () =>
commit([
...endpoints,
newEndpoint(uniqueLabel(t("hosts.webUiNewEndpointLabel"))),
]);
return (
// Two cards, as the tunnels tab does: settings first, then the list it
// governs, each with its own header.
<>
<SectionCard
title={t("hosts.webUiIntegration")}
icon={<Globe className="size-3.5" />}
>
<div className="flex flex-col gap-4 py-3">
<SettingRow
label={t("hosts.enableWebUi")}
description={t("hosts.enableWebUiDesc")}
>
<FakeSwitch
checked={enableWebUi}
onChange={(v: boolean) => setField("enableWebUi", v)}
/>
</SettingRow>
<div className="text-xs text-muted-foreground p-3 bg-muted/30 border border-border space-y-1">
<p>{t("hosts.webUiRequirementsText")}</p>
</div>
</div>
</SectionCard>
<SectionCard
title={t("hosts.webUiEndpointsSection")}
icon={<Globe className="size-3.5" />}
action={
enableWebUi ? (
// Same header control, and the same classes, as the tunnels list.
<Button
variant="outline"
size="sm"
className="h-6 text-[10px] px-2 border-accent-brand/40 text-accent-brand"
disabled={endpoints.length >= MAX_WEB_ENDPOINTS}
onClick={addEndpoint}
>
{t("hosts.webUiAddEndpoint")}
</Button>
) : undefined
}
>
<div className="flex flex-col gap-4 py-3">
{enableWebUi && (
<div className="flex flex-col gap-3">
{endpoints.map((endpoint, index) => {
const rowError = webEndpointRowError(endpoint);
return (
<div
key={endpoint.id}
className="flex flex-col gap-3 p-3 border border-border bg-muted/20 relative group"
>
<div className="flex items-center justify-between mb-1">
<span className="text-xs font-bold text-muted-foreground">
{t("hosts.webUiEndpointLabel", { number: index + 1 })}
</span>
<Button
variant="ghost"
size="sm"
aria-label={t("hosts.webUiRemoveEndpoint")}
className="h-6 text-[10px] px-2 text-destructive"
onClick={() =>
commit(endpoints.filter((_, i) => i !== index))
}
>
{t("common.delete")}
</Button>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold text-muted-foreground">
{t("hosts.webUiLabel")}
</label>
<Input
aria-label={t("hosts.webUiLabel")}
value={endpoint.label}
onChange={(e) =>
update(index, { label: e.target.value })
}
maxLength={MAX_WEB_ENDPOINT_LABEL_LENGTH}
className="h-7 text-xs"
/>
</div>
{rowError && (
<p className="text-[11px] text-destructive">
{t(webEndpointErrorKey(rowError), { row: index + 1 })}
</p>
)}
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold text-muted-foreground">
{t("hosts.webUiAddress")}
</label>
<div className="flex items-center gap-2">
<Select
value={endpoint.scheme}
onValueChange={(value) =>
update(index, {
scheme: value as WebEndpoint["scheme"],
})
}
>
<SelectTrigger
aria-label={t("hosts.webUiScheme")}
size="sm"
className="h-7 w-24 text-xs"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="https" className="text-xs">
https
</SelectItem>
<SelectItem value="http" className="text-xs">
http
</SelectItem>
</SelectContent>
</Select>
<Input
aria-label={t("hosts.webUiPort")}
type="number"
min={MIN_WEB_ENDPOINT_PORT}
max={MAX_WEB_ENDPOINT_PORT}
value={endpoint.port}
onChange={(e) => {
// Number("") is 0, which the normalizer rejects --
// so clearing the field would otherwise write an
// endpoint that is silently dropped on save.
// Commit only a value the normalizer would keep;
// anything else leaves the last good port.
const parsed = Number(e.target.value);
if (isWebEndpointPortValid(parsed)) {
update(index, { port: parsed });
}
}}
className="h-7 w-20 text-xs"
/>
<Input
aria-label={t("hosts.webUiPath")}
value={endpoint.path ?? "/"}
onChange={(e) =>
update(index, { path: e.target.value })
}
className="h-7 flex-1 text-xs"
/>
</div>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold text-muted-foreground">
{t("hosts.webUiOpening")}
</label>
<div className="flex items-center gap-2">
<Select
value={endpoint.access}
onValueChange={(value) =>
update(index, {
access: value as WebEndpoint["access"],
// Meaningless for a tunnel, whose host component
// is loopback and already exempt.
ignoreCert:
value === "direct"
? endpoint.ignoreCert
: false,
})
}
>
<SelectTrigger
aria-label={t("hosts.webUiAccess")}
size="sm"
className="h-7 flex-1 text-xs"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="direct" className="text-xs">
{t("hosts.webUiAccessDirect")}
</SelectItem>
<SelectItem
value="tunnel"
disabled={!tunnelAvailable}
>
{t("hosts.webUiAccessTunnel")}
</SelectItem>
</SelectContent>
</Select>
<Select
value={endpoint.render}
onValueChange={(value) =>
update(index, {
render: value as WebEndpoint["render"],
})
}
>
<SelectTrigger
aria-label={t("hosts.webUiRender")}
size="sm"
className="h-7 flex-1 text-xs"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem
value="external"
className="text-xs"
disabled={!isElectron()}
>
{t("hosts.webUiRenderExternal")}
</SelectItem>
<SelectItem value="embedded" className="text-xs">
{t("hosts.webUiRenderEmbedded")}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{endpoint.access === "tunnel" && (
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold text-muted-foreground">
{t("hosts.bindHost")}
</label>
<Input
aria-label={t("hosts.bindHost")}
placeholder="127.0.0.1"
value={endpoint.bindHost ?? ""}
onChange={(e) =>
update(index, { bindHost: e.target.value })
}
className="h-7 text-xs"
/>
<p className="text-[11px] opacity-70">
{t("hosts.webUiBindHostDesc")}
</p>
<label className="text-[10px] font-bold text-muted-foreground">
{t("hosts.webUiLocalPort")}
</label>
<Input
aria-label={t("hosts.webUiLocalPort")}
type="number"
min={MIN_WEB_ENDPOINT_PORT}
max={MAX_WEB_ENDPOINT_PORT}
placeholder={t("hosts.webUiLocalPortAuto")}
value={endpoint.localPort ?? ""}
onChange={(e) => {
const raw = e.target.value;
if (raw === "") {
update(index, { localPort: undefined });
return;
}
const parsed = Number(raw);
if (isWebEndpointPortValid(parsed)) {
update(index, { localPort: parsed });
}
}}
className="h-7 text-xs"
/>
<p className="text-[11px] opacity-70">
{t("hosts.webUiLocalPortDesc")}
</p>
{isExposedBindHost(endpoint.bindHost) && (
<p className="text-[11px] text-destructive">
{t("hosts.webUiBindHostExposed")}
</p>
)}
{(() => {
// Said while configuring, not only when the tab fails
// to load: a loopback bind on a backend that is not
// this machine opens fine and then leaves the browser
// with nothing to connect to, and a tunnel reached at
// Termix's own hostname would hand the tunnelled
// service this session.
const refusal = webEndpointRefusalReason(
endpoint,
isElectron(),
undefined,
);
if (!refusal) return null;
// Unreachable from this tunnel-only block; narrows
// the union to the reasons this preview renders.
if (refusal === "direct-shares-session-cookie") {
return null;
}
return (
<p className="text-[11px] text-destructive">
{t(BIND_HOST_REFUSAL_MESSAGES[refusal])}
</p>
);
})()}
</div>
)}
{endpoint.render === "embedded" && (
<p className="text-[11px] opacity-70">
{t("hosts.webUiRenderEmbeddedDesc")}
</p>
)}
{endpoint.access === "direct" && (
<div className="flex flex-col gap-1">
<label className="flex items-center gap-2 text-xs">
<Checkbox
checked={endpoint.ignoreCert === true}
onCheckedChange={(value) =>
update(index, { ignoreCert: value === true })
}
/>
{t("hosts.webUiIgnoreCert")}
</label>
<p className="text-[11px] opacity-70">
{t("hosts.webUiIgnoreCertDesc")}
</p>
</div>
)}
</div>
);
})}
{!tunnelAvailable && endpoints.length > 0 && (
// Shown whenever tunnelling is unavailable, not only once a row
// already asks for it: the Tunnel option renders disabled, and
// a disabled control with no stated reason reads as the
// dropdown being broken.
<p className="text-[11px] opacity-70">
{t("hosts.webUiAccessTunnelUnavailable")}
</p>
)}
</div>
)}
</div>
</SectionCard>
</>
);
}
+33 -16
View File
@@ -1,4 +1,5 @@
import { getErrorMessage } from "../lib/error-message.js";
import { usePluginStore } from "@/plugin-host/plugin-store";
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Download, Search } from "lucide-react";
@@ -17,6 +18,7 @@ import { exportAllSSHHosts, type SSHHostWithStatus } from "@/main-axios";
import { isFolder } from "@/sidebar/SidebarTree";
import {
buildExportPayload,
pluginSecretKeys,
hostKey,
maskSecrets,
type ExportPayload,
@@ -27,9 +29,8 @@ const GROUPS: { key: FieldGroup; label: string }[] = [
{ key: "connection", label: "groupConnection" },
{ key: "notes", label: "groupNotes" },
{ key: "tags", label: "groupTags" },
{ key: "tunnels", label: "groupTunnels" },
{ key: "proxy", label: "groupProxy" },
{ key: "jumpHosts", label: "groupJumpHosts" },
{ key: "quickActions", label: "groupQuickActions" },
{ key: "featureFlags", label: "groupFeatureFlags" },
{ key: "advanced", label: "groupAdvanced" },
];
@@ -38,9 +39,8 @@ const DEFAULT_GROUPS: FieldGroup[] = [
"connection",
"notes",
"tags",
"tunnels",
"proxy",
"jumpHosts",
"quickActions",
"featureFlags",
"advanced",
];
@@ -137,6 +137,15 @@ export function HostExportDialog({
};
}, [open, withCredentials, t]);
const { records: pluginRecords } = usePluginStore();
const pluginSecrets = useMemo(
() =>
pluginSecretKeys(
[...pluginRecords.values()].map((record) => record.summary),
),
[pluginRecords],
);
const payload = useMemo(() => {
if (!raw) return null;
return buildExportPayload(
@@ -144,12 +153,13 @@ export function HostExportDialog({
scope === "all" ? null : selectedKeys,
groups,
withCredentials,
pluginSecrets,
);
}, [raw, scope, selectedKeys, groups, withCredentials]);
}, [raw, scope, selectedKeys, groups, withCredentials, pluginSecrets]);
const preview = useMemo(() => {
if (!payload) return "";
const masked = maskSecrets(payload);
const masked = maskSecrets(payload, pluginSecrets);
if (masked.hosts.length <= PREVIEW_HOST_LIMIT) {
return JSON.stringify(masked, null, 2);
}
@@ -165,7 +175,7 @@ export function HostExportDialog({
count: payload.hosts.length - PREVIEW_HOST_LIMIT,
})
);
}, [payload, t]);
}, [payload, pluginSecrets, t]);
function toggleGroup(group: FieldGroup) {
setGroups((prev) => {
@@ -310,16 +320,23 @@ export function HostExportDialog({
</div>
<div className="shrink-0 text-xs text-muted-foreground">
{count === 0
? t("hosts.export.noneSelected")
: `${t("hosts.export.summary", {
selected: count,
total: exportableHosts.length,
})} · ${
withCredentials
{count === 0 ? (
t("hosts.export.noneSelected")
) : (
<span className="flex flex-wrap gap-x-3">
<span>
{t("hosts.export.summary", {
selected: count,
total: exportableHosts.length,
})}
</span>
<span>
{withCredentials
? t("hosts.export.credentialsIncluded")
: t("hosts.export.credentialsExcluded")
}`}
: t("hosts.export.credentialsExcluded")}
</span>
</span>
)}
</div>
<DialogFooter className="shrink-0">
+91 -62
View File
@@ -1,4 +1,9 @@
/* eslint-disable react-hooks/exhaustive-deps */
import {
enabledHostProtocols,
hostProtocolFlags,
type HostProtocols,
} from "./host-protocols";
import React, {
useState,
useEffect,
@@ -6,6 +11,13 @@ import React, {
type MutableRefObject,
} from "react";
import { useTranslation } from "react-i18next";
import type { HostDefaultsLevel } from "@/types/host-defaults";
import { getFolderDefaultsId } from "@/api/host-defaults-api";
import {
HostDefaultsEditorView,
type HostDefaultsEditorTarget,
} from "./host-defaults/HostDefaultsEditorView";
import type { HostDraft } from "@termix/plugin-sdk/frontend";
import { CredentialShareModal } from "./CredentialShareModal";
import { Button } from "@/components/button";
@@ -21,7 +33,6 @@ import {
updateCredential,
deployCredentialToHost,
renameCredentialFolder,
getLinkedCredentialIds,
} from "@/main-axios";
import type { Host, Credential } from "@/types/ui-types";
@@ -39,7 +50,8 @@ import {
makeCredentialTabs,
makeHostTabs,
makeHostSshSubTabs,
SSH_GROUP_TABS,
isSshGroupTab,
useHostEditorSections,
TabStrip,
} from "./HostManagerTabs";
import { Select2 } from "@/components/select2";
@@ -73,8 +85,19 @@ export function HostManager({
onTagsChange?: (tags: string[]) => void;
active?: boolean;
} = {}) {
// Re-render when a plugin adds or removes a host editor tab.
useHostEditorSections();
const { t } = useTranslation();
const [editingHost, setEditingHost] = useState<Host | "new" | null>(null);
// A level of host defaults open in the editor instead of a host.
const [editingDefaults, setEditingDefaults] =
useState<HostDefaultsEditorTarget | null>(null);
// Fields a plugin filled in for a new host, keyed so a second draft remounts
// the editor.
const [hostDraft, setHostDraft] = useState<{
key: number;
draft: HostDraft;
} | null>(null);
const [shareCredential, setShareCredential] = useState<Credential | null>(
null,
);
@@ -99,12 +122,9 @@ export function HostManager({
} | null>(null);
const [hostEditorDirty, setHostEditorDirty] = useState(false);
const [showUnsavedHostDialog, setShowUnsavedHostDialog] = useState(false);
const [editingProtocols, setEditingProtocols] = useState({
enableSsh: true,
enableRdp: false,
enableVnc: false,
enableTelnet: false,
});
const [editingProtocols, setEditingProtocols] = useState<HostProtocols>(() =>
hostProtocolFlags(null),
);
const simpleEditor = useUiPreference("hostEditor", "mode") === "simple";
// Expanding advanced is a per-session choice; fixing one host's SSH options
// shouldn't quietly move the whole app off the Simple preset.
@@ -122,9 +142,6 @@ export function HostManager({
const [credentialReturnHost, setCredentialReturnHost] = useState<
Host | "new" | null
>(null);
const [termixIdLinkedIds, setTermixIdLinkedIds] = useState<Set<number>>(
new Set(),
);
useEffect(() => {
onTagsChange?.([...new Set(credentials.flatMap((c) => c.tags ?? []))]);
@@ -139,12 +156,7 @@ export function HostManager({
setEditingHost(host);
setEditingCredential(null);
setActiveHostTab("general");
setEditingProtocols({
enableSsh: host.enableSsh,
enableRdp: host.enableRdp,
enableVnc: host.enableVnc,
enableTelnet: host.enableTelnet,
});
setEditingProtocols(hostProtocolFlags(host));
return true;
}
}
@@ -168,16 +180,9 @@ export function HostManager({
.finally(() => setCredentialsLoading(false));
};
const reloadLinkedIds = () => {
getLinkedCredentialIds()
.then((d) => setTermixIdLinkedIds(new Set(d.credentialIds)))
.catch(() => {});
};
useEffect(() => {
reloadHosts();
reloadCredentials();
reloadLinkedIds();
window.addEventListener("termix:hosts-changed", reloadHosts);
window.addEventListener("ssh-hosts:changed", reloadHosts);
@@ -200,13 +205,9 @@ export function HostManager({
pendingAction.current = null;
if (action === "add-host") {
setEditingHost("new");
setHostDraft(null);
setEditingCredential(null);
setEditingProtocols({
enableSsh: true,
enableRdp: false,
enableVnc: false,
enableTelnet: false,
});
setEditingProtocols(hostProtocolFlags(null));
setActiveHostTab("general");
} else if (action === "add-credential") {
setEditingCredential("new");
@@ -218,15 +219,12 @@ export function HostManager({
useEffect(() => {
if (!active) return;
const handleAddHost = () => {
const handleAddHost = (e: Event) => {
const draft = (e as CustomEvent<HostDraft | undefined>).detail;
setEditingHost("new");
setHostDraft(draft ? { key: Date.now(), draft } : null);
setEditingCredential(null);
setEditingProtocols({
enableSsh: true,
enableRdp: false,
enableVnc: false,
enableTelnet: false,
});
setEditingProtocols(hostProtocolFlags(null));
setActiveHostTab("general");
};
const handleAddCredential = () => {
@@ -234,6 +232,25 @@ export function HostManager({
setEditingHost(null);
setActiveCredentialTab("general");
};
const handleEditDefaults = (e: Event) => {
const detail = (
e as CustomEvent<{ level: HostDefaultsLevel; folderName?: string }>
).detail;
if (!detail) return;
const open = (target: HostDefaultsEditorTarget) => {
setEditingHost(null);
setEditingCredential(null);
setEditingDefaults(target);
};
if (detail.level === "folder" && detail.folderName) {
const folderName = detail.folderName;
getFolderDefaultsId(folderName)
.then((folderId) => open({ level: "folder", folderId, folderName }))
.catch(() => toast.error(t("hostDefaults.loadFailed")));
} else if (detail.level !== "folder") {
open({ level: detail.level });
}
};
const handleEditHost = (e: Event) => {
const id = (e as CustomEvent<string>).detail;
const host = hostsRef.current.find((h) => h.id === id);
@@ -241,18 +258,18 @@ export function HostManager({
setEditingHost(host);
setEditingCredential(null);
setActiveHostTab("general");
setEditingProtocols({
enableSsh: host.enableSsh,
enableRdp: host.enableRdp,
enableVnc: host.enableVnc,
enableTelnet: host.enableTelnet,
});
setEditingProtocols(hostProtocolFlags(host));
}
};
window.addEventListener("host-manager:add-host", handleAddHost);
window.addEventListener("host-manager:add-credential", handleAddCredential);
window.addEventListener("host-manager:edit-host", handleEditHost);
window.addEventListener("host-manager:edit-defaults", handleEditDefaults);
return () => {
window.removeEventListener(
"host-manager:edit-defaults",
handleEditDefaults,
);
window.removeEventListener("host-manager:add-host", handleAddHost);
window.removeEventListener(
"host-manager:add-credential",
@@ -402,6 +419,7 @@ export function HostManager({
setHostEditorDirty(false);
setShowUnsavedHostDialog(false);
setEditingHost(null);
setEditingDefaults(null);
setActiveHostTab("general");
};
@@ -414,6 +432,17 @@ export function HostManager({
};
const renderEditorView = () => {
if (editingDefaults) {
return (
<HostDefaultsEditorView
target={editingDefaults}
onClose={requestCloseHostEditor}
onDirtyChange={setHostEditorDirty}
hosts={hosts}
credentials={credentials}
/>
);
}
const isHost = !!editingHost;
// Simple mode keeps General and SSH -- between them they hold everything
// needed to reach a host (name, address, username, auth) -- and hides the
@@ -422,30 +451,31 @@ export function HostManager({
// buildHostEditorPayload still serializes all of them, so hiding a tab
// hides its inputs, not its values.
const collapseAdvanced = isHost && simpleEditor && !showAdvancedEditor;
const protocols = editingProtocols as unknown as Record<string, boolean>;
// Plugin tabs decide their own visibility from the host's protocols.
const tabs = isHost
? makeHostTabs(t).filter((tab) => {
? makeHostTabs(t, protocols).filter((tab) => {
if (tab.id === "general") return true;
if (tab.id === "ssh") return editingProtocols.enableSsh;
if (collapseAdvanced) return false;
if (tab.id === "rdp") return editingProtocols.enableRdp;
if (tab.id === "vnc") return editingProtocols.enableVnc;
if (tab.id === "telnet") return editingProtocols.enableTelnet;
return false;
return !collapseAdvanced;
})
: makeCredentialTabs(t);
// Collapsing while on a now-hidden tab would leave nothing selected. The
// SSH group collapses to its own "ssh" tab, everything else to General.
// The top-level strip only lists general/ssh/rdp/vnc/telnet -- the SSH
// The top-level strip only lists general, ssh and protocol tabs -- the SSH
// sub-tabs live in the secondary strip, so they count as visible whenever
// the SSH group is expanded.
const sshSubTabs = makeHostSshSubTabs(t, protocols);
// Registered SSH-group tabs count too: checking a fixed list here is what
// left plugin tabs unreachable.
const hostTabVisible =
tabs.some((tab) => tab.id === activeHostTab) ||
(!collapseAdvanced &&
editingProtocols.enableSsh &&
SSH_GROUP_TABS.has(activeHostTab as never));
sshSubTabs.some((tab) => tab.id === activeHostTab));
const effectiveHostTab = hostTabVisible
? activeHostTab
: collapseAdvanced && SSH_GROUP_TABS.has(activeHostTab as never)
: collapseAdvanced && isSshGroupTab(activeHostTab)
? "ssh"
: "general";
const activeTab = isHost ? effectiveHostTab : activeCredentialTab;
@@ -454,8 +484,7 @@ export function HostManager({
isHost &&
!collapseAdvanced &&
editingProtocols.enableSsh &&
SSH_GROUP_TABS.has(activeHostTab as never);
const sshSubTabs = makeHostSshSubTabs(t);
isSshGroupTab(activeHostTab);
return (
<div className="flex flex-col flex-1 min-h-0">
@@ -499,7 +528,7 @@ export function HostManager({
activeTab={activeTab}
onTabChange={(id) => {
if (isHost && id === "ssh") {
if (!SSH_GROUP_TABS.has(activeHostTab as never)) {
if (!isSshGroupTab(activeHostTab)) {
setActiveHostTab("ssh");
}
} else {
@@ -510,7 +539,7 @@ export function HostManager({
isHost
? (id) =>
id === "ssh"
? SSH_GROUP_TABS.has(activeHostTab as never)
? isSshGroupTab(activeHostTab)
: activeHostTab === id
: undefined
}
@@ -544,9 +573,12 @@ export function HostManager({
{isHost ? (
<HostEditor
key={
editingHost === "new" ? "new-host" : (editingHost as Host).id
editingHost === "new"
? `new-host-${hostDraft?.key ?? ""}`
: (editingHost as Host).id
}
host={editingHost === "new" ? null : (editingHost as Host)}
draft={editingHost === "new" ? hostDraft?.draft : undefined}
activeTab={effectiveHostTab}
simpleMode={collapseAdvanced}
onBack={requestCloseHostEditor}
@@ -670,7 +702,7 @@ export function HostManager({
);
};
const isEditing = !!editingHost || !!editingCredential;
const isEditing = !!editingHost || !!editingCredential || !!editingDefaults;
useEffect(() => {
if (active) onEditingChange?.(isEditing);
@@ -726,7 +758,6 @@ export function HostManager({
<CredentialSidebarTree
folders={credentialFolderTree}
usedByCounts={usedByCounts}
termixIdLinkedIds={termixIdLinkedIds}
query=""
loading={credentialsLoading}
arrangeLocked={externalArrangeLocked}
@@ -845,9 +876,7 @@ export function HostManager({
<option value="">{t("credentials.selectHostOption")}</option>
{allHosts
.filter(
(h) =>
h.enableSsh ||
(!h.enableRdp && !h.enableVnc && !h.enableTelnet),
(h) => h.enableSsh || enabledHostProtocols(h).length === 0,
)
.map((h) => (
<option key={h.id} value={h.id}>
+25 -85
View File
@@ -6,11 +6,7 @@ type RawSSHHost = SSHHostWithStatus & {
hasKey?: boolean;
hasKeyPassword?: boolean;
hasSudoPassword?: boolean;
hasRdpPassword?: boolean;
hasVncPassword?: boolean;
hasTelnetPassword?: boolean;
};
type HostQuickAction = Host["quickActions"][number];
type HostJumpHost = NonNullable<Host["jumpHosts"]>[number];
type RawCredential = {
isShared?: boolean;
@@ -44,8 +40,6 @@ function parseJson<T>(v: unknown): T | undefined {
export function sshHostToHost(h: SSHHostWithStatus): Host {
const host = h as RawSSHHost;
const isSshHost = h.connectionType === "ssh" || !h.connectionType;
const parsedTerminalConfig = parseJson(h.terminalConfig) as
(Host["terminalConfig"] & { sudoPassword?: string }) | undefined;
return {
id: String(h.id),
name: h.name,
@@ -74,86 +68,28 @@ export function sshHostToHost(h: SSHHostWithStatus): Host {
keyPassword: h.keyPassword,
keyType: h.keyType,
credentialId: h.credentialId != null ? String(h.credentialId) : undefined,
vaultProfileId:
(h as { vaultProfileId?: number | string | null }).vaultProfileId != null
? String((h as { vaultProfileId?: number | string }).vaultProfileId)
: undefined,
notes: h.notes,
pin: h.pin ?? false,
sortOrder: h.sortOrder ?? null,
macAddress: h.macAddress,
wolBroadcastAddress: h.wolBroadcastAddress,
connectionOrigin: h.connectionOrigin ?? null,
localOnly: !!h.localOnly,
sharedCopy: !!h.sharedCopy,
enableSsh: h.enableSsh != null ? h.enableSsh : isSshHost,
enableTerminal:
h.enableTerminal ?? (h.enableSsh != null ? h.enableSsh : isSshHost),
enableSessionLogging: h.enableSessionLogging ?? true,
enableCommandHistory: h.enableCommandHistory ?? true,
enableTunnel: h.enableTunnel ?? false,
enableFileManager: h.enableFileManager ?? true,
enableDocker: h.enableDocker ?? false,
dockerConfig: h.dockerConfig ?? null,
enableWebUi: h.enableWebUi ?? false,
webUiConfig: h.webUiConfig ?? { endpoints: [] },
enableProxmox: h.enableProxmox ?? false,
enableProxmoxStats: h.enableProxmoxStats ?? false,
enableTmuxMonitor: h.enableTmuxMonitor ?? false,
enableTerminalToolbar: h.enableTerminalToolbar ?? true,
enableAiAssistant: h.enableAiAssistant ?? false,
proxmoxConfig: h.proxmoxConfig ?? null,
proxmoxStatsConfig: h.proxmoxStatsConfig ?? null,
enableRdp: h.enableRdp != null ? h.enableRdp : h.connectionType === "rdp",
enableVnc: h.enableVnc != null ? h.enableVnc : h.connectionType === "vnc",
enableTelnet:
h.enableTelnet != null ? h.enableTelnet : h.connectionType === "telnet",
sshPort:
h.sshPort ??
(h.connectionType === "ssh" || !h.connectionType ? h.port : 22),
rdpPort: h.rdpPort ?? (h.connectionType === "rdp" ? h.port : 3389),
vncPort: h.vncPort ?? (h.connectionType === "vnc" ? h.port : 5900),
telnetPort: h.telnetPort ?? (h.connectionType === "telnet" ? h.port : 23),
rdpAuthType:
(h.rdpAuthType as "direct" | "credential") ??
(h.rdpCredentialId ? "credential" : "direct"),
rdpCredentialId:
h.rdpCredentialId != null ? String(h.rdpCredentialId) : undefined,
rdpUser: h.rdpUser,
rdpPassword: h.rdpPassword ?? "",
hasRdpPassword: !!host.hasRdpPassword || !!h.rdpPassword,
domain: h.rdpDomain,
security: h.rdpSecurity,
ignoreCert: h.rdpIgnoreCert ?? false,
vncAuthType:
(h.vncAuthType as "direct" | "credential") ??
(h.vncCredentialId ? "credential" : "direct"),
vncCredentialId:
h.vncCredentialId != null ? String(h.vncCredentialId) : undefined,
vncPassword: h.vncPassword ?? "",
hasVncPassword: !!host.hasVncPassword || !!h.vncPassword,
vncUser: h.vncUser,
telnetAuthType:
(h.telnetAuthType as "direct" | "credential") ??
(h.telnetCredentialId ? "credential" : "direct"),
telnetCredentialId:
h.telnetCredentialId != null ? String(h.telnetCredentialId) : undefined,
telnetUser: h.telnetUser,
telnetPassword: h.telnetPassword ?? "",
hasTelnetPassword: !!host.hasTelnetPassword || !!h.telnetPassword,
quickActions: (h.quickActions ?? []).map((a) => ({
name: a.name,
snippetId: String(a.snippetId),
})),
serverTunnels: parseJson(h.tunnelConnections) ?? [],
protocolAuth: h.protocolAuth ?? {},
pluginSettings: h.pluginSettings ?? {},
defaultOverrides: h.defaultOverrides ?? null,
jumpHosts: (parseJson<HostJumpHost[]>(h.jumpHosts) ?? []).map((j) => ({
hostId: String(j.hostId ?? j.hostid ?? j),
})),
portKnockSequence: parseJson(h.portKnockSequence) ?? [],
defaultPath: h.defaultPath,
terminalConfig: parsedTerminalConfig as Host["terminalConfig"],
hasSudoPassword:
!!host.hasSudoPassword || !!parsedTerminalConfig?.sudoPassword,
statsConfig: parseJson(h.statsConfig) as Host["statsConfig"],
guacamoleConfig: parseJson(h.guacamoleConfig),
terminalConfig: parseJson(h.terminalConfig) as Host["terminalConfig"],
sshOptions: parseJson(h.sshOptions) as Host["sshOptions"],
hasSudoPassword: !!host.hasSudoPassword,
statusCheckEnabled: h.statusCheckEnabled !== false,
statusCheckInterval: h.statusCheckInterval ?? null,
forceKeyboardInteractive: h.forceKeyboardInteractive ?? false,
useSocks5: h.useSocks5,
socks5Host: h.socks5Host,
@@ -165,18 +101,22 @@ export function sshHostToHost(h: SSHHostWithStatus): Host {
isShared: h.isShared ?? false,
authOverrides: h.authOverrides
? Object.fromEntries(
Object.entries(h.authOverrides).map(([protocol, state]) => [
protocol,
Object.entries(h.authOverrides).flatMap(([protocol, state]) =>
state
? {
...state,
credentialId:
state.credentialId != null
? String(state.credentialId)
: undefined,
}
: state,
]),
? [
[
protocol,
{
...state,
credentialId:
state.credentialId != null
? String(state.credentialId)
: undefined,
},
],
]
: [],
),
)
: undefined,
permissionLevel: h.permissionLevel,
+149 -110
View File
@@ -1,33 +1,11 @@
/* eslint-disable react-refresh/only-export-components */
import { useEffect, useRef, type ReactNode } from "react";
import {
Activity,
Box,
Folder,
KeyRound,
Monitor,
MousePointerClick,
Network,
Server,
Settings,
SquareTerminal,
Terminal,
Globe,
} from "lucide-react";
import { useEffect, useRef, type ComponentType, type ReactNode } from "react";
import { KeyRound, Settings, Terminal } from "lucide-react";
import { byOrderThenId, createRegistry } from "@/lib/registry";
export type HostTabId =
| "general"
| "ssh"
| "terminal"
| "tunnels"
| "docker"
| "web-ui"
| "proxmox"
| "files"
| "host-metrics"
| "rdp"
| "vnc"
| "telnet";
/** Core host editor tabs. Plugins add theirs through registerHostEditorSection. */
export type CoreHostTabId = "general" | "ssh";
export type HostTabId = CoreHostTabId | (string & {});
export type CredentialTabId = "general" | "auth";
type HostTab = {
@@ -41,90 +19,151 @@ type CredentialTab = {
icon: ReactNode;
};
export const SSH_GROUP_TABS = new Set<HostTabId>([
"ssh",
"terminal",
"tunnels",
"docker",
"web-ui",
"proxmox",
"files",
"host-metrics",
]);
export function makeHostTabs(t: (key: string) => string): HostTab[] {
return [
{
id: "general",
label: t("hosts.tabGeneral"),
icon: <Settings className="size-3" />,
},
{
id: "ssh",
label: t("hosts.tabSsh"),
icon: <Terminal className="size-3" />,
},
{
id: "rdp",
label: t("hosts.tabRdp"),
icon: <Monitor className="size-3" />,
},
{
id: "vnc",
label: t("hosts.tabVnc"),
icon: <MousePointerClick className="size-3" />,
},
{
id: "telnet",
label: t("hosts.tabTelnet"),
icon: <Terminal className="size-3" />,
},
];
export interface HostEditorSectionRenderProps {
// The editor form is a plain object. Sections read the fields they own.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
form: any;
setField: (key: string, value: unknown) => void;
/** Applies several fields at once, against the latest form. */
updateForm: (
patch: (form: Record<string, unknown>) => Record<string, unknown>,
) => void;
host?: unknown;
credentials?: unknown[];
adminTargetUserId?: string;
protocols: Record<string, boolean>;
/** "defaults" in the host defaults editor. */
mode?: "host" | "defaults";
}
export function makeHostSshSubTabs(t: (key: string) => string): HostTab[] {
return [
{
id: "ssh",
label: t("hosts.tabGeneral"),
icon: <Settings className="size-3" />,
},
{
id: "terminal",
label: t("hosts.tabTerminal"),
icon: <SquareTerminal className="size-3" />,
},
{
id: "tunnels",
label: t("hosts.tabTunnels"),
icon: <Network className="size-3" />,
},
{
id: "docker",
label: t("hosts.tabDocker"),
icon: <Box className="size-3" />,
},
{
id: "web-ui",
label: t("hosts.tabWebUi"),
icon: <Globe className="size-3" />,
},
{
id: "proxmox",
label: t("hosts.tabProxmox"),
icon: <Server className="size-3" />,
},
{
id: "files",
label: t("hosts.tabFiles"),
icon: <Folder className="size-3" />,
},
{
id: "host-metrics",
label: t("hosts.tabHostMetrics"),
icon: <Activity className="size-3" />,
},
];
/**
* A host editor tab contributed at runtime. "top" tabs sit in the main strip
* beside General and SSH; "ssh" tabs sit in the SSH group's second strip.
*/
export interface HostEditorSectionDef {
id: string;
pluginId?: string;
group: "top" | "ssh";
labelKey: string;
/** Already-translated label, used instead of labelKey when set. */
label?: string;
icon?: ComponentType<{ className?: string }>;
/** Position among the group's tabs, core ones included. */
order?: number;
/** Whether to offer the tab for these protocols. Defaults to always. */
visible?: (protocols: Record<string, boolean>) => boolean;
/** Also offered in the host defaults editor; "only" for nowhere else. */
defaults?: boolean | "only";
component: ComponentType<HostEditorSectionRenderProps>;
}
const sections = createRegistry<HostEditorSectionDef>(byOrderThenId);
export const registerHostEditorSection = sections.register;
export const unregisterHostEditorSection = sections.unregister;
export const getHostEditorSection = sections.get;
export const hostEditorSectionList = sections.list;
export const useHostEditorSections = sections.useList;
export const resetHostEditorSections = sections.reset;
const CORE_SSH_GROUP = new Set<string>(["ssh"]);
/** Whether a tab lives in the SSH group's second strip. */
export function isSshGroupTab(id: string): boolean {
return CORE_SSH_GROUP.has(id) || sections.get(id)?.group === "ssh";
}
function sectionVisible(
section: HostEditorSectionDef,
protocols: Record<string, boolean>,
): boolean {
if (!section.visible) return true;
try {
return section.visible(protocols);
} catch {
return false;
}
}
function mergeByOrder(
core: (HostTab & { order: number })[],
group: "top" | "ssh",
t: (key: string) => string,
protocols: Record<string, boolean> | undefined,
defaultsMode = false,
): HostTab[] {
const registered = sections
.list()
.filter(
(section) =>
section.group === group &&
(defaultsMode ? !!section.defaults : section.defaults !== "only") &&
(defaultsMode || !protocols || sectionVisible(section, protocols)),
)
.map((section) => {
const Icon = section.icon;
return {
id: section.id,
label: section.label ?? t(section.labelKey),
icon: Icon ? <Icon className="size-3" /> : null,
order: section.order ?? 1000,
};
});
return [...core, ...registered]
.sort((a, b) => a.order - b.order)
.map(({ order: _order, ...tab }) => tab);
}
/**
* The main strip. With `protocols`, registered tabs are filtered by their
* `visible`; without, every registered top tab is listed.
*/
export function makeHostTabs(
t: (key: string) => string,
protocols?: Record<string, boolean>,
defaultsMode = false,
): HostTab[] {
return mergeByOrder(
[
{
id: "general",
label: t("hosts.tabGeneral"),
icon: <Settings className="size-3" />,
order: 0,
},
{
id: "ssh",
label: t("hosts.tabSsh"),
icon: <Terminal className="size-3" />,
order: 10,
},
],
"top",
t,
protocols,
defaultsMode,
);
}
export function makeHostSshSubTabs(
t: (key: string) => string,
protocols?: Record<string, boolean>,
defaultsMode = false,
): HostTab[] {
return mergeByOrder(
[
{
id: "ssh",
label: t("hosts.tabGeneral"),
icon: <Settings className="size-3" />,
order: 0,
},
],
"ssh",
t,
protocols,
defaultsMode,
);
}
export function makeCredentialTabs(
+105 -131
View File
@@ -1,4 +1,6 @@
import { getErrorMessage } from "../lib/error-message.js";
import { enabledHostProtocols, useHostProtocols } from "./host-protocols";
import { useSshAuthProviders } from "@/hooks/useSshAuthProviders";
import { useEffect, useRef, useState } from "react";
import type { HostData } from "@/types/index";
import { useTranslation } from "react-i18next";
@@ -17,7 +19,7 @@ import {
Plus,
RefreshCw,
Search,
Server,
Settings2,
SlidersHorizontal,
Upload,
X,
@@ -28,7 +30,8 @@ import { HostManager } from "@/sidebar/HostManager";
import { HostShareModal } from "@/sidebar/HostShareModal";
import { HostExportDialog } from "@/sidebar/HostExportDialog";
import { CustomizeSidebarPanel } from "@/sidebar/CustomizeSidebarPanel";
import { ProxmoxDiscoverDialog } from "@/components/proxmox/ProxmoxDiscoverDialog";
import { ComponentSlot } from "@/shell/ActionSlot";
import { usePluginHostSections } from "@/settings/HostPluginSections";
import { Button } from "@/components/button";
import {
DropdownMenu,
@@ -87,9 +90,9 @@ function hostGroupNames(host: Host, key: GroupKey): string[] {
case "protocol": {
const protos: string[] = [];
if (host.enableSsh) protos.push("ssh");
if (host.enableRdp) protos.push("rdp");
if (host.enableVnc) protos.push("vnc");
if (host.enableTelnet) protos.push("telnet");
for (const protocol of enabledHostProtocols(host)) {
protos.push(protocol.id);
}
return protos.length > 0 ? protos : ["__none__"];
}
case "auth":
@@ -123,7 +126,14 @@ function groupHosts(
return { name: "root", children };
}
function hostPassesFilters(host: Host, filters: FilterState): boolean {
/** Plugin id to the host setting that switches it on. */
type HostSwitches = Record<string, string>;
function hostPassesFilters(
host: Host,
filters: FilterState,
switches: HostSwitches,
): boolean {
if (filters.status.length > 0) {
const ok =
(filters.status.includes("online") && host.online) ||
@@ -144,17 +154,17 @@ function hostPassesFilters(host: Host, filters: FilterState): boolean {
if (filters.protocol.length > 0) {
const ok =
(filters.protocol.includes("ssh") && host.enableSsh) ||
(filters.protocol.includes("rdp") && host.enableRdp) ||
(filters.protocol.includes("vnc") && host.enableVnc) ||
(filters.protocol.includes("telnet") && host.enableTelnet);
enabledHostProtocols(host).some((protocol) =>
filters.protocol.includes(protocol.id),
);
if (!ok) return false;
}
if (filters.features.length > 0) {
const ok =
(filters.features.includes("terminal") && host.enableTerminal) ||
(filters.features.includes("fileManager") && host.enableFileManager) ||
(filters.features.includes("tunnel") && host.enableTunnel) ||
(filters.features.includes("docker") && host.enableDocker);
// A saved filter for a plugin that is gone or off is ignored.
const features = filters.features.filter((id) => switches[id]);
if (features.length > 0) {
const ok = features.some(
(id) => host.pluginSettings?.[id]?.[switches[id]] === true,
);
if (!ok) return false;
}
if (filters.tags.length > 0) {
@@ -164,14 +174,18 @@ function hostPassesFilters(host: Host, filters: FilterState): boolean {
return true;
}
function applyFilters(folder: HostFolder, filters: FilterState): HostFolder {
function applyFilters(
folder: HostFolder,
filters: FilterState,
switches: HostSwitches,
): HostFolder {
const active = Object.values(filters).some((arr) => arr.length > 0);
if (!active) return folder;
const filteredChildren = folder.children
.map((child) => {
if (isFolder(child)) return applyFilters(child, filters);
return hostPassesFilters(child, filters) ? child : null;
if (isFolder(child)) return applyFilters(child, filters, switches);
return hostPassesFilters(child, filters, switches) ? child : null;
})
.filter((child): child is Host | HostFolder => {
if (child === null) return false;
@@ -192,7 +206,11 @@ export function HostsPanel({
onOpenTab: (
host: Host,
type: TabType,
options?: { endpointId?: string; label?: string },
options?: {
data?: Record<string, unknown>;
label?: string;
forceNewTab?: boolean;
},
) => void;
onEditHost: (host: Host) => void;
hostTree?: HostFolder;
@@ -201,6 +219,17 @@ export function HostsPanel({
active?: boolean;
}) {
const { t } = useTranslation();
const hostSwitchPlugins = usePluginHostSections().filter(
(plugin) => !!plugin.contributes?.settings?.host?.enableKey,
);
const hostSwitches: Record<string, string> = Object.fromEntries(
hostSwitchPlugins.map((plugin) => [
plugin.id,
plugin.contributes!.settings!.host!.enableKey!,
]),
);
const hostProtocols = useHostProtocols();
const sshAuthProviders = useSshAuthProviders();
const [hostSearch, setHostSearch] = useState("");
const [managerEditing, setManagerEditing] = useState(false);
const [customizePanelOpen, setCustomizePanelOpen] = useState(false);
@@ -212,19 +241,6 @@ export function HostsPanel({
const [exportPreselection, setExportPreselection] = useState<Set<string>>(
new Set(),
);
const [proxmoxDialogOpen, setProxmoxDialogOpen] = useState(false);
const [proxmoxHostId, setProxmoxHostId] = useState<number | undefined>(
undefined,
);
const [proxmoxDefaultCredentialId, setProxmoxDefaultCredentialId] = useState<
number | null
>(null);
const [proxmoxDefaultAuthType, setProxmoxDefaultAuthType] = useState<
string | undefined
>(undefined);
const [proxmoxDefaultUsername, setProxmoxDefaultUsername] = useState<
string | undefined
>(undefined);
const { preferences: sidebarPrefs, update: updateSidebarPrefs } =
useHostSidebarPreferences();
const sortKey = sidebarPrefs.sort.key;
@@ -276,10 +292,10 @@ export function HostsPanel({
? t("hosts.filterOnline")
: t("hosts.filterOffline");
if (key === "protocol") return group.toUpperCase();
if (key === "auth")
return t(
`hosts.filterAuth${group.charAt(0).toUpperCase() + group.slice(1)}`,
);
if (key === "auth") {
const labelKey = sshAuthProviders.find(group)?.labelKey;
return labelKey ? t(labelKey, { defaultValue: group }) : group;
}
return group;
}
@@ -359,14 +375,10 @@ export function HostsPanel({
pin: true,
notes: "Main production web server running Nginx",
enableSsh: true,
enableRdp: false,
enableVnc: false,
enableTelnet: false,
sshPort: 22,
enableTerminal: true,
enableTunnel: false,
enableFileManager: true,
enableDocker: false,
defaultPath: "/var/www",
},
{
@@ -380,14 +392,10 @@ export function HostsPanel({
folder: "Production",
tags: ["database", "production", "postgresql"],
enableSsh: true,
enableRdp: false,
enableVnc: false,
enableTelnet: false,
sshPort: 22,
enableTerminal: true,
enableTunnel: true,
enableFileManager: false,
enableDocker: false,
},
],
},
@@ -447,11 +455,11 @@ export function HostsPanel({
? parsed.credentials
: undefined;
if (!Array.isArray(hostsArray) || hostsArray.length === 0) {
toast.error("No hosts found in file");
toast.error(t("hosts.importNoHosts"));
return;
}
if (hostsArray.length > 100) {
toast.error("Cannot import more than 100 hosts at once");
toast.error(t("hosts.importTooMany", { max: 100 }));
return;
}
const normalized = hostsArray.map(
@@ -459,10 +467,6 @@ export function HostsPanel({
...h,
port: h.port ?? h.sshPort ?? 22,
enableSsh: h.enableSsh ?? h.connectionType === "ssh",
enableRdp: h.enableRdp ?? h.connectionType === "rdp",
enableVnc: h.enableVnc ?? h.connectionType === "vnc",
enableTelnet:
h.enableTelnet ?? h.connectionType === "telnet",
}),
);
const result = await bulkImportSSHHosts(
@@ -473,16 +477,15 @@ export function HostsPanel({
const hosts = await getSSHHosts();
setRawHosts(hosts);
window.dispatchEvent(new CustomEvent("termix:hosts-changed"));
const msg = [
result.success ? `${result.success} imported` : null,
result.updated ? `${result.updated} updated` : null,
result.failed ? `${result.failed} failed` : null,
]
.filter(Boolean)
.join(", ");
toast.success(`Import complete: ${msg}`);
toast.success(
t("hosts.importSummary", {
imported: result.success ?? 0,
updated: result.updated ?? 0,
failed: result.failed ?? 0,
}),
);
} catch (err: unknown) {
toast.error(getErrorMessage(err, "Failed to import hosts"));
toast.error(getErrorMessage(err, t("hosts.importHostsFailed")));
}
}}
/>
@@ -575,16 +578,7 @@ export function HostsPanel({
<Upload className="size-3.5 mr-2" />
{t("hosts.importSSHConfig")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setProxmoxHostId(undefined);
setProxmoxDialogOpen(true);
}}
disabled={!rawHosts.some((h) => h.enableProxmox)}
>
<Server className="size-3.5 mr-2" />
{t("hosts.proxmoxImportTitle")}
</DropdownMenuItem>
<ComponentSlot slotId="hosts.importMenu" />
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
@@ -769,73 +763,54 @@ export function HostsPanel({
<DropdownMenuLabel>
{t("hosts.filterAuthGroup")}
</DropdownMenuLabel>
{(
[
"password",
"key",
"credential",
"none",
"opkssh",
"stepca",
] as const
).map((val) => (
{sshAuthProviders.providers.map((option) => (
<DropdownMenuCheckboxItem
key={val}
checked={filterState.authType.includes(val)}
key={option.type}
checked={filterState.authType.includes(option.type)}
onCheckedChange={() =>
handleFilterToggle("authType", val)
handleFilterToggle("authType", option.type)
}
onSelect={(e) => e.preventDefault()}
>
{t(
`hosts.filterAuth${val.charAt(0).toUpperCase() + val.slice(1)}`,
)}
{option.editorTitleKey
? t(option.editorTitleKey)
: t(option.labelKey, { defaultValue: option.type })}
</DropdownMenuCheckboxItem>
))}
<DropdownMenuSeparator />
<DropdownMenuLabel>
{t("hosts.filterProtocolGroup")}
</DropdownMenuLabel>
{(
[
["ssh", "Ssh"],
["rdp", "Rdp"],
["vnc", "Vnc"],
["telnet", "Telnet"],
] as const
).map(([val, key]) => (
{[
{ id: "ssh", label: t("hosts.filterProtocolSsh") },
...hostProtocols.map((protocol) => ({
id: protocol.id,
label: t(protocol.titleKey),
})),
].map(({ id, label }) => (
<DropdownMenuCheckboxItem
key={val}
checked={filterState.protocol.includes(val)}
onCheckedChange={() =>
handleFilterToggle("protocol", val)
}
key={id}
checked={filterState.protocol.includes(id)}
onCheckedChange={() => handleFilterToggle("protocol", id)}
onSelect={(e) => e.preventDefault()}
>
{t(`hosts.filterProtocol${key}`)}
{label}
</DropdownMenuCheckboxItem>
))}
<DropdownMenuSeparator />
<DropdownMenuLabel>
{t("hosts.filterFeaturesGroup")}
</DropdownMenuLabel>
{(
[
["terminal", "Terminal"],
["fileManager", "FileManager"],
["tunnel", "Tunnel"],
["docker", "Docker"],
] as const
).map(([val, key]) => (
{hostSwitchPlugins.map((plugin) => (
<DropdownMenuCheckboxItem
key={val}
checked={filterState.features.includes(val)}
key={plugin.id}
checked={filterState.features.includes(plugin.id)}
onCheckedChange={() =>
handleFilterToggle("features", val)
handleFilterToggle("features", plugin.id)
}
onSelect={(e) => e.preventDefault()}
>
{t(`hosts.filterFeature${key}`)}
{plugin.name}
</DropdownMenuCheckboxItem>
))}
{allTags.length > 0 && (
@@ -952,6 +927,18 @@ export function HostsPanel({
? t("hosts.exitSelectionTitle")
: t("hosts.selectHosts")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
window.dispatchEvent(
new CustomEvent("host-manager:edit-defaults", {
detail: { level: "user" },
}),
)
}
>
<Settings2 className="size-3.5 mr-2" />
{t("hostDefaults.menuMyDefaults")}
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger className="flex items-center gap-2">
<Upload className="size-3.5 shrink-0" />
@@ -1046,6 +1033,7 @@ export function HostsPanel({
applyFilters(
sortHostTree(hostTree, sortKey, pinnedFirst),
filterState,
hostSwitches,
),
groupKey,
groupLabel,
@@ -1055,14 +1043,6 @@ export function HostsPanel({
onOpenTab={onOpenTab}
onEditHost={onEditHost}
onShareHost={(host) => setShareModalHost(host)}
onProxmoxDiscover={(host) => {
const cfg = host.proxmoxConfig;
setProxmoxHostId(Number(host.id));
setProxmoxDefaultCredentialId(cfg?.defaultCredentialId ?? null);
setProxmoxDefaultAuthType(cfg?.defaultAuthType ?? undefined);
setProxmoxDefaultUsername(undefined);
setProxmoxDialogOpen(true);
}}
query={hostSearch.trim().toLowerCase()}
selectionMode={selectionMode}
onToggleSelectionMode={toggleSelectionMode}
@@ -1076,6 +1056,8 @@ export function HostsPanel({
trayTrigger={sidebarPrefs.display.trayTrigger}
showTags={sidebarPrefs.display.showTags}
openOnDoubleClick={sidebarPrefs.display.openOnDoubleClick}
showFolderPaths={sidebarPrefs.display.showFolderPaths}
hostClickBehavior={sidebarPrefs.display.hostClickBehavior}
/>
</div>
)}
@@ -1099,18 +1081,10 @@ export function HostsPanel({
preselectedHostIds={exportPreselection}
/>
<ProxmoxDiscoverDialog
open={proxmoxDialogOpen}
onClose={() => {
setProxmoxDialogOpen(false);
setProxmoxHostId(undefined);
}}
hosts={rawHosts}
onHostsChanged={setRawHosts}
preselectedHostId={proxmoxHostId}
defaultCredentialId={proxmoxDefaultCredentialId}
defaultAuthType={proxmoxDefaultAuthType}
defaultUsername={proxmoxDefaultUsername}
{/* Plugins mount their own host-list dialogs here. */}
<ComponentSlot
slotId="hosts.panel"
props={{ hosts: rawHosts, onHostsChanged: setRawHosts }}
/>
</div>
);
+32 -13
View File
@@ -19,6 +19,7 @@ import {
} from "@/components/dialog";
import { useUiPreferencesContext } from "@/contexts/UiPreferencesContext";
import { applyPresetSideEffects } from "@/lib/apply-ui-preset";
import { usePluginStore } from "@/plugin-host/plugin-store";
import {
UI_AREA_KEYS,
type UiAreaKey,
@@ -37,9 +38,6 @@ const PRESET_ICONS = {
advanced: Wrench,
} as const;
/** Areas that have nothing worth listing as a per-area override. */
const HIDDEN_OVERRIDE_AREAS = new Set<UiAreaKey>(["homepage"]);
export function InterfacePresetSettings({
onRunSetupAgain,
}: {
@@ -57,14 +55,35 @@ export function InterfacePresetSettings({
// the customizations are listed separately below.
const activeLabel = ctx?.preferences.preset ?? "balanced";
const pluginStore = usePluginStore();
const customizedAreas = useMemo(() => {
if (!ctx) return [] as UiAreaKey[];
return UI_AREA_KEYS.filter(
(area) =>
!HIDDEN_OVERRIDE_AREAS.has(area) &&
Object.keys(ctx.preferences.overrides[area] ?? {}).length > 0,
);
}, [ctx]);
if (!ctx) return [] as Array<{ key: string; label: string }>;
const overrides = ctx.preferences.overrides as Record<
string,
Record<string, unknown> | undefined
>;
const core = UI_AREA_KEYS.filter(
(area) => Object.keys(overrides[area] ?? {}).length > 0,
).map((area) => ({
key: area as string,
label: t(`newUi.sidebar.userProfile.uiArea_${area}`),
}));
// A plugin's own area is labelled with the plugin's name.
const plugins = Object.keys(overrides)
.filter(
(area) =>
area.startsWith("plugin:") &&
Object.keys(overrides[area] ?? {}).length > 0,
)
.map((area) => {
const pluginId = area.slice("plugin:".length);
return {
key: area,
label: pluginStore.records.get(pluginId)?.summary.name ?? pluginId,
};
});
return [...core, ...plugins];
}, [ctx, pluginStore, t]);
if (!ctx) return null;
@@ -142,15 +161,15 @@ export function InterfacePresetSettings({
</span>
{customizedAreas.map((area) => (
<div
key={area}
key={area.key}
className="flex items-center justify-between gap-2 py-0.5"
>
<span className="min-w-0 flex-1 truncate text-xs">
{t(`newUi.sidebar.userProfile.uiArea_${area}`)}
{area.label}
</span>
<button
type="button"
onClick={() => ctx.clearArea(area)}
onClick={() => ctx.clearArea(area.key as UiAreaKey)}
className="shrink-0 text-[10px] text-muted-foreground underline underline-offset-2 hover:text-foreground"
>
{t("newUi.sidebar.userProfile.presetRevertArea")}
+147 -248
View File
@@ -5,12 +5,16 @@ import {
getUserPreferences,
parseCustomKeybindings,
} from "@/api/open-tabs-api";
import { saveUserPreferences, getSnippets } from "@/main-axios";
import { BUILT_IN_DEFAULTS } from "@/lib/default-keybindings";
import { saveUserPreferences } from "@/main-axios";
import {
useKeybindingActions,
useKeybindingDefaults,
type KeybindingActionDef,
} from "@/shell/keybinding-registry";
import type {
CustomKeybinding,
KeybindingAction,
KeyCombo,
KeybindingActionType,
} from "@/types/keybindings";
import {
Dialog,
@@ -22,11 +26,7 @@ import {
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { Pencil, Plus, RotateCcw, Trash2 } from "lucide-react";
interface SnippetOption {
id: number;
name: string;
}
import { FixedShortcutsList } from "./FixedShortcutsList";
function formatCombo(combo: KeyCombo): string {
const parts: string[] = [];
@@ -38,23 +38,29 @@ function formatCombo(combo: KeyCombo): string {
return parts.join(" + ");
}
const ACTION_LABEL_KEYS: Record<KeybindingActionType, string> = {
copy: "newUi.sidebar.keybindings.actionCopy",
paste: "newUi.sidebar.keybindings.actionPaste",
sendControlCode: "newUi.sidebar.keybindings.actionSendControlCode",
sendText: "newUi.sidebar.keybindings.actionSendText",
runSnippet: "newUi.sidebar.keybindings.actionRunSnippet",
nextTab: "newUi.sidebar.keybindings.actionNextTab",
previousTab: "newUi.sidebar.keybindings.actionPreviousTab",
openCommandPalette: "newUi.sidebar.keybindings.actionOpenCommandPalette",
};
function generateId(): string {
return typeof crypto !== "undefined" && "randomUUID" in crypto
? crypto.randomUUID()
: `kb-${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
/** An action without the keys an editor cleared, so it saves clean. */
function withoutEmpty(action: KeybindingAction): KeybindingAction {
const out: KeybindingAction = { type: action.type };
for (const [key, value] of Object.entries(action)) {
if (key !== "type" && value !== undefined && value !== null) {
out[key] = value;
}
}
return out;
}
/**
* Appearance > Keybindings. The actions come from the keybinding registry:
* the shell's own and whatever the running plugins register, each with its
* own parameter editor. A saved binding whose plugin is off stays in the
* list and keeps working once the plugin is back.
*/
export function KeybindingsDialog({
open,
onOpenChange,
@@ -63,9 +69,10 @@ export function KeybindingsDialog({
onOpenChange: (v: boolean) => void;
}) {
const { t } = useTranslation();
const actions = useKeybindingActions();
const defaults = useKeybindingDefaults();
const [bindings, setBindings] = useState<CustomKeybinding[]>([]);
const [loading, setLoading] = useState(false);
const [snippets, setSnippets] = useState<SnippetOption[]>([]);
const [formOpen, setFormOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [overridesDefaultId, setOverridesDefaultId] = useState<
@@ -74,32 +81,22 @@ export function KeybindingsDialog({
const [recordedCombo, setRecordedCombo] = useState<KeyCombo | null>(null);
const [recording, setRecording] = useState(false);
const [actionType, setActionType] =
useState<KeybindingActionType>("sendText");
const [text, setText] = useState("");
const [appendEnter, setAppendEnter] = useState(false);
const [controlCode, setControlCode] = useState("");
const [snippetId, setSnippetId] = useState("");
const [draft, setDraft] = useState<KeybindingAction>({ type: "" });
const actionById = useMemo(
() => new Map(actions.map((action) => [action.id, action])),
[actions],
);
const draftDef: KeybindingActionDef | undefined = actionById.get(draft.type);
useEffect(() => {
if (!open) return;
setLoading(true);
Promise.all([
getUserPreferences().catch(() => null),
getSnippets().catch(() => []),
])
.then(([prefs, snippetList]) => {
setBindings(
prefs ? parseCustomKeybindings(prefs.customKeybindings) : [],
);
const list = Array.isArray(snippetList) ? snippetList : [];
setSnippets(
list.map((s: { id: number; name: string }) => ({
id: s.id,
name: s.name,
})),
);
})
getUserPreferences()
.then((prefs) =>
setBindings(parseCustomKeybindings(prefs.customKeybindings)),
)
.catch(() => setBindings([]))
.finally(() => setLoading(false));
}, [open]);
@@ -123,16 +120,19 @@ export function KeybindingsDialog({
return null;
}, [recordedCombo, bindings, editingId, t]);
function actionLabel(type: string): string {
const def = actionById.get(type);
return def
? t(def.labelKey)
: t("newUi.sidebar.keybindings.unavailableAction", { type });
}
function resetForm() {
setEditingId(null);
setOverridesDefaultId(undefined);
setRecordedCombo(null);
setRecording(false);
setActionType("sendText");
setText("");
setAppendEnter(false);
setControlCode("");
setSnippetId("");
setDraft({ type: actions[0]?.id ?? "" });
}
function openAddForm() {
@@ -145,11 +145,7 @@ export function KeybindingsDialog({
setOverridesDefaultId(kb.overridesDefaultId);
setRecordedCombo(kb.combo);
setRecording(false);
setActionType(kb.action.type);
setText(kb.action.text ?? "");
setAppendEnter(kb.action.appendEnter ?? false);
setControlCode(kb.action.controlCode ?? "");
setSnippetId(kb.action.snippetId ?? "");
setDraft({ ...kb.action });
setFormOpen(true);
}
@@ -210,16 +206,14 @@ export function KeybindingsDialog({
toast.error(t("newUi.sidebar.keybindings.comboRequiredError"));
return;
}
if (actionType === "sendText" && !text.trim()) {
toast.error(t("newUi.sidebar.keybindings.textRequiredError"));
if (!draftDef) {
toast.error(t("newUi.sidebar.keybindings.actionRequiredError"));
return;
}
if (actionType === "sendControlCode" && !/^[a-zA-Z]$/.test(controlCode)) {
toast.error(t("newUi.sidebar.keybindings.controlCodeRequiredError"));
return;
}
if (actionType === "runSnippet" && !snippetId) {
toast.error(t("newUi.sidebar.keybindings.snippetRequiredError"));
const action = withoutEmpty(draft);
const problem = draftDef.validate?.(action);
if (problem) {
toast.error(t(problem));
return;
}
@@ -231,21 +225,7 @@ export function KeybindingsDialog({
const newBinding: CustomKeybinding = {
id: editingId ?? generateId(),
combo: recordedCombo,
action: {
type: actionType,
text: actionType === "sendText" ? text : undefined,
controlCode:
actionType === "sendControlCode"
? controlCode.toLowerCase()
: undefined,
snippetId: actionType === "runSnippet" ? snippetId : undefined,
appendEnter:
actionType === "runSnippet"
? true
: actionType === "sendText"
? appendEnter
: undefined,
},
action,
enabled: true,
overridesDefaultId,
createdAt: existing?.createdAt ?? now,
@@ -262,6 +242,7 @@ export function KeybindingsDialog({
}
const customOnlyBindings = bindings.filter((kb) => !kb.overridesDefaultId);
const Editor = draftDef?.editor;
return (
<>
@@ -277,78 +258,80 @@ export function KeybindingsDialog({
</DialogHeader>
<div className="flex flex-col gap-4 mt-1 max-h-[60vh] overflow-y-auto">
<div className="flex flex-col gap-1.5">
<span className="text-xs font-semibold text-muted-foreground">
{t("newUi.sidebar.keybindings.defaultsHeading")}
</span>
{BUILT_IN_DEFAULTS.map((def) => {
const override = bindings.find(
(kb) => kb.overridesDefaultId === def.id,
);
return (
<div
key={def.id}
className="flex items-center justify-between gap-2 border border-border bg-background px-2.5 py-2"
>
<div className="flex flex-col min-w-0">
<span className="text-xs font-mono">
{override
? formatCombo(override.combo)
: formatCombo(def.combo)}
</span>
<span className="text-xs text-muted-foreground truncate">
{def.description}
</span>
{defaults.length > 0 && (
<div className="flex flex-col gap-1.5">
<span className="text-xs font-semibold text-muted-foreground">
{t("newUi.sidebar.keybindings.defaultsHeading")}
</span>
{defaults.map((def) => {
const override = bindings.find(
(kb) => kb.overridesDefaultId === def.id,
);
return (
<div
key={def.id}
className="flex items-center justify-between gap-2 border border-border bg-background px-2.5 py-2"
>
<div className="flex flex-col min-w-0">
<span className="text-xs font-mono">
{override
? formatCombo(override.combo)
: formatCombo(def.combo)}
</span>
<span className="text-xs text-muted-foreground truncate">
{t(def.descriptionKey)}
</span>
</div>
<div className="flex items-center gap-1 shrink-0">
{override ? (
<>
<span className="text-[10px] font-bold text-accent-brand border border-accent-brand/40 px-1">
{t("newUi.sidebar.keybindings.customizedBadge")}
</span>
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={() => openEditForm(override)}
>
<Pencil className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-7 text-muted-foreground hover:text-destructive"
onClick={() => handleDelete(override.id)}
title={t(
"newUi.sidebar.keybindings.resetToDefault",
)}
>
<RotateCcw className="size-3.5" />
</Button>
</>
) : (
<>
<span className="text-[10px] text-muted-foreground border border-border px-1">
{t("newUi.sidebar.keybindings.defaultBadge")}
</span>
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={() =>
openOverrideDefaultForm(def.id, def.combo)
}
title={t("newUi.sidebar.keybindings.customize")}
>
<Pencil className="size-3.5" />
</Button>
</>
)}
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
{override ? (
<>
<span className="text-[10px] font-bold text-accent-brand border border-accent-brand/40 px-1">
{t("newUi.sidebar.keybindings.customizedBadge")}
</span>
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={() => openEditForm(override)}
>
<Pencil className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-7 text-muted-foreground hover:text-destructive"
onClick={() => handleDelete(override.id)}
title={t(
"newUi.sidebar.keybindings.resetToDefault",
)}
>
<RotateCcw className="size-3.5" />
</Button>
</>
) : (
<>
<span className="text-[10px] text-muted-foreground border border-border px-1">
{t("newUi.sidebar.keybindings.defaultBadge")}
</span>
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={() =>
openOverrideDefaultForm(def.id, def.combo)
}
title={t("newUi.sidebar.keybindings.customize")}
>
<Pencil className="size-3.5" />
</Button>
</>
)}
</div>
</div>
);
})}
</div>
);
})}
</div>
)}
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
@@ -370,9 +353,7 @@ export function KeybindingsDialog({
</span>
) : (
customOnlyBindings.map((kb) => {
const orphaned =
kb.action.type === "runSnippet" &&
!snippets.some((s) => String(s.id) === kb.action.snippetId);
const Summary = actionById.get(kb.action.type)?.summary;
return (
<div
key={kb.id}
@@ -383,16 +364,12 @@ export function KeybindingsDialog({
{formatCombo(kb.combo)}
</span>
<span className="text-xs text-muted-foreground truncate">
{t(ACTION_LABEL_KEYS[kb.action.type])}
{orphaned && (
<span className="text-destructive">
{actionLabel(kb.action.type)}
{Summary && (
<>
{" "}
(
{t(
"newUi.sidebar.keybindings.orphanedSnippetWarning",
)}
)
</span>
<Summary action={kb.action} onChange={() => {}} />
</>
)}
</span>
</div>
@@ -419,6 +396,7 @@ export function KeybindingsDialog({
})
)}
</div>
<FixedShortcutsList />
</div>
<div className="flex items-center justify-between gap-2 mt-2">
@@ -480,101 +458,22 @@ export function KeybindingsDialog({
{t("newUi.sidebar.keybindings.actionLabel")}
</label>
<select
value={actionType}
onChange={(e) =>
setActionType(e.target.value as KeybindingActionType)
}
value={draft.type}
onChange={(e) => setDraft({ type: e.target.value })}
className="h-8 border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="copy">
{t("newUi.sidebar.keybindings.actionCopy")}
</option>
<option value="paste">
{t("newUi.sidebar.keybindings.actionPaste")}
</option>
<option value="sendControlCode">
{t("newUi.sidebar.keybindings.actionSendControlCode")}
</option>
<option value="sendText">
{t("newUi.sidebar.keybindings.actionSendText")}
</option>
<option value="runSnippet">
{t("newUi.sidebar.keybindings.actionRunSnippet")}
</option>
<option value="nextTab">
{t("newUi.sidebar.keybindings.actionNextTab")}
</option>
<option value="previousTab">
{t("newUi.sidebar.keybindings.actionPreviousTab")}
</option>
<option value="openCommandPalette">
{t("newUi.sidebar.keybindings.actionOpenCommandPalette")}
</option>
{!draftDef && draft.type && (
<option value={draft.type}>{actionLabel(draft.type)}</option>
)}
{actions.map((action) => (
<option key={action.id} value={action.id}>
{t(action.labelKey)}
</option>
))}
</select>
{actionType === "paste" && (
<span className="text-xs text-muted-foreground">
{t("newUi.sidebar.keybindings.clipboardPermissionNote")}
</span>
)}
</div>
{actionType === "sendControlCode" && (
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold">
{t("newUi.sidebar.keybindings.controlCodeLabel")}
</label>
<Input
value={controlCode}
maxLength={1}
onChange={(e) => setControlCode(e.target.value)}
placeholder="w"
className="w-16 font-mono"
/>
</div>
)}
{actionType === "sendText" && (
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold">
{t("newUi.sidebar.keybindings.textLabel")}
</label>
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
className="w-full h-24 px-3 py-2 text-xs bg-background border border-border text-foreground placeholder:text-muted-foreground resize-none outline-none focus:ring-1 focus:ring-ring font-mono"
/>
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<input
type="checkbox"
checked={appendEnter}
onChange={(e) => setAppendEnter(e.target.checked)}
/>
{t("newUi.sidebar.keybindings.appendEnterLabel")}
</label>
</div>
)}
{actionType === "runSnippet" && (
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold">
{t("newUi.sidebar.keybindings.snippetLabel")}
</label>
<select
value={snippetId}
onChange={(e) => setSnippetId(e.target.value)}
className="h-8 border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="">
{t("newUi.sidebar.keybindings.selectSnippetPlaceholder")}
</option>
{snippets.map((s) => (
<option key={s.id} value={String(s.id)}>
{s.name}
</option>
))}
</select>
</div>
)}
{Editor && <Editor action={draft} onChange={setDraft} />}
</div>
<div className="flex items-center justify-end gap-2 mt-2">
<Button variant="ghost" onClick={() => setFormOpen(false)}>
-856
View File
@@ -1,856 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ArrowLeft,
Braces,
ChevronDown,
Clock,
CornerDownRight,
GitBranch,
Plus,
Repeat,
Save,
Square,
Terminal as TerminalIcon,
Trash2,
Play,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { Textarea } from "@/components/textarea";
import { Checkbox } from "@/components/checkbox";
import { EmptyState } from "@/components/empty-state";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/alert-dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/dropdown-menu";
import { getUserPreferences, saveUserPreferences } from "@/api/open-tabs-api";
import { getErrorMessage } from "../lib/error-message.js";
import {
parseTerminalMacros,
runTerminalMacro,
type MacroStep,
type TerminalMacro,
} from "@/lib/terminal-macros";
import type { Tab } from "@/types/ui-types";
const STEP_TYPES = ["send", "wait", "delay", "if", "repeat"] as const;
const STEP_ICONS = {
send: TerminalIcon,
wait: CornerDownRight,
delay: Clock,
if: GitBranch,
repeat: Repeat,
} as const;
function id(): string {
return typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
}
function createStep(type: MacroStep["type"]): MacroStep {
const stepId = id();
switch (type) {
case "send":
return { id: stepId, type, text: "", pressEnter: true };
case "delay":
return { id: stepId, type, milliseconds: 1000 };
case "wait":
return {
id: stepId,
type,
pattern: "",
isRegex: false,
timeoutMs: 10_000,
onTimeout: "stop",
};
case "if":
return {
id: stepId,
type,
pattern: "",
isRegex: false,
then: [],
else: [],
};
case "repeat":
return { id: stepId, type, count: 2, steps: [] };
}
}
function isValidRegex(pattern: string): boolean {
try {
new RegExp(pattern);
return true;
} catch {
return false;
}
}
/** Milliseconds are an implementation detail, so the editor works in seconds. */
function SecondsInput({
valueMs,
onChangeMs,
minMs,
label,
}: {
valueMs: number;
onChangeMs: (ms: number) => void;
minMs: number;
label: string;
}) {
const { t } = useTranslation();
return (
<label className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground">{label}</span>
<Input
className="h-7 w-20 rounded-none text-xs"
type="number"
min={minMs / 1000}
max={300}
step={0.5}
value={valueMs / 1000}
onChange={(event) => {
const seconds = Number(event.target.value);
onChangeMs(
Number.isFinite(seconds)
? Math.max(minMs, Math.min(300_000, Math.round(seconds * 1000)))
: minMs,
);
}}
/>
<span className="text-muted-foreground">{t("macros.seconds")}</span>
</label>
);
}
/** Shared text/regex matcher field used by both "wait" and "if". */
function MatchField({
pattern,
isRegex,
label,
placeholder,
onChange,
}: {
pattern: string;
isRegex: boolean;
label: string;
placeholder: string;
onChange: (next: { pattern?: string; isRegex?: boolean }) => void;
}) {
const { t } = useTranslation();
const badRegex = isRegex && pattern.length > 0 && !isValidRegex(pattern);
return (
<div className="space-y-1.5">
<div className="text-xs text-muted-foreground">{label}</div>
<Input
className={`h-8 rounded-none font-mono text-xs ${
badRegex ? "border-destructive" : ""
}`}
placeholder={placeholder}
value={pattern}
onChange={(event) => onChange({ pattern: event.target.value })}
/>
{badRegex && (
<div className="text-[11px] text-destructive">
{t("macros.match.invalidRegex")}
</div>
)}
<label className="flex items-center gap-2 text-[11px] text-muted-foreground">
<Checkbox
checked={isRegex}
onCheckedChange={(checked) => onChange({ isRegex: checked === true })}
/>
{t("macros.match.useRegex")}
</label>
</div>
);
}
function AddStepMenu({
onAdd,
compact,
}: {
onAdd: (type: MacroStep["type"]) => void;
compact?: boolean;
}) {
const { t } = useTranslation();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="sm"
variant="outline"
className={`rounded-none ${compact ? "h-7 text-[11px]" : "h-8 w-full text-xs"}`}
>
<Plus className="mr-1 size-3" />
{t("macros.addStep")}
<ChevronDown className="ml-1 size-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-72 rounded-none">
{STEP_TYPES.map((type) => {
const Icon = STEP_ICONS[type];
return (
<DropdownMenuItem
key={type}
className="flex-col items-start gap-0.5 rounded-none"
onSelect={() => onAdd(type)}
>
<span className="flex items-center gap-2 text-xs font-medium">
<Icon className="size-3.5" />
{t(`macros.stepTypes.${type}.label`)}
</span>
<span className="pl-5 text-[11px] leading-snug text-muted-foreground">
{t(`macros.stepTypes.${type}.help`)}
</span>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}
function StepList({
steps,
onChange,
depth = 0,
}: {
steps: MacroStep[];
onChange: (steps: MacroStep[]) => void;
depth?: number;
}) {
const { t } = useTranslation();
const update = (index: number, step: MacroStep) => {
const next = [...steps];
next[index] = step;
onChange(next);
};
return (
<div className="flex flex-col gap-2">
{steps.map((step, index) => {
const Icon = STEP_ICONS[step.type];
return (
<div key={step.id} className="border border-border bg-muted/20">
<div className="flex items-center gap-2 border-b border-border/60 px-2 py-1.5">
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
<span className="text-xs font-medium">
{t(`macros.stepTypes.${step.type}.label`)}
</span>
<Button
size="icon"
variant="ghost"
className="ml-auto size-6 rounded-none"
aria-label={t("macros.removeStep")}
onClick={() => onChange(steps.filter((_, i) => i !== index))}
>
<Trash2 className="size-3" />
</Button>
</div>
<div className="space-y-2 p-2">
{step.type === "send" && (
<div className="space-y-2">
<Textarea
className="min-h-16 rounded-none font-mono text-xs"
placeholder={t("macros.send.textPlaceholder")}
value={step.text}
onChange={(event) =>
update(index, { ...step, text: event.target.value })
}
/>
<label className="flex items-center gap-2 text-[11px] text-muted-foreground">
<Checkbox
checked={step.pressEnter}
onCheckedChange={(checked) =>
update(index, { ...step, pressEnter: checked === true })
}
/>
{t("macros.send.pressEnter")}
</label>
</div>
)}
{step.type === "delay" && (
<SecondsInput
label={t("macros.delay.duration")}
valueMs={step.milliseconds}
minMs={0}
onChangeMs={(milliseconds) =>
update(index, { ...step, milliseconds })
}
/>
)}
{step.type === "wait" && (
<div className="space-y-2">
<MatchField
pattern={step.pattern}
isRegex={step.isRegex !== false}
label={t("macros.wait.pattern")}
placeholder={t("macros.wait.patternPlaceholder")}
onChange={(next) => update(index, { ...step, ...next })}
/>
<SecondsInput
label={t("macros.wait.timeout")}
valueMs={step.timeoutMs}
minMs={100}
onChangeMs={(timeoutMs) =>
update(index, { ...step, timeoutMs })
}
/>
<div className="space-y-1">
<div className="text-xs text-muted-foreground">
{t("macros.wait.onTimeout")}
</div>
<div className="flex gap-1">
{(["stop", "continue"] as const).map((mode) => (
<Button
key={mode}
size="sm"
variant={
step.onTimeout === mode ? "default" : "outline"
}
className="h-7 flex-1 rounded-none text-[11px]"
onClick={() =>
update(index, { ...step, onTimeout: mode })
}
>
{t(`macros.wait.${mode}`)}
</Button>
))}
</div>
</div>
</div>
)}
{step.type === "if" && (
<div className="space-y-3">
<MatchField
pattern={step.pattern}
isRegex={step.isRegex !== false}
label={t("macros.ifStep.pattern")}
placeholder={t("macros.ifStep.patternPlaceholder")}
onChange={(next) => update(index, { ...step, ...next })}
/>
<div className="border-l-2 border-emerald-500/40 pl-2">
<div className="mb-1 text-[11px] font-medium text-emerald-500">
{t("macros.ifStep.then")}
</div>
<StepList
steps={step.then}
depth={depth + 1}
onChange={(then) => update(index, { ...step, then })}
/>
</div>
<div className="border-l-2 border-amber-500/40 pl-2">
<div className="mb-1 text-[11px] font-medium text-amber-500">
{t("macros.ifStep.else")}
</div>
<StepList
steps={step.else}
depth={depth + 1}
onChange={(elseSteps) =>
update(index, { ...step, else: elseSteps })
}
/>
</div>
</div>
)}
{step.type === "repeat" && (
<div className="space-y-2">
<label className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground">
{t("macros.repeat.count")}
</span>
<Input
className="h-7 w-20 rounded-none text-xs"
type="number"
min={1}
max={100}
value={step.count}
onChange={(event) =>
update(index, {
...step,
count: Math.max(
1,
Math.min(100, Number(event.target.value) || 1),
),
})
}
/>
<span className="text-muted-foreground">
{t("macros.repeat.times")}
</span>
</label>
<div className="border-l-2 border-border pl-2">
<StepList
steps={step.steps}
depth={depth + 1}
onChange={(nested) =>
update(index, { ...step, steps: nested })
}
/>
</div>
</div>
)}
</div>
</div>
);
})}
{depth < 4 && (
<AddStepMenu
compact={depth > 0}
onAdd={(type) => onChange([...steps, createStep(type)])}
/>
)}
</div>
);
}
export function MacrosPanel({
terminalTabs,
activeTabId,
storageMode,
}: {
terminalTabs: Tab[];
activeTabId: string;
storageMode: "local" | "cloud";
}) {
const { t } = useTranslation();
const [macros, setMacros] = useState<TerminalMacro[]>([]);
const [draft, setDraft] = useState<TerminalMacro | null>(null);
const [dirty, setDirty] = useState(false);
const [runningId, setRunningId] = useState<string | null>(null);
const [pendingDelete, setPendingDelete] = useState<TerminalMacro | null>(
null,
);
const [pendingRun, setPendingRun] = useState<TerminalMacro | null>(null);
const abortRef = useRef<AbortController | null>(null);
const confirmedRef = useRef<Set<string>>(new Set());
useEffect(() => {
let cancelled = false;
const load =
storageMode === "local"
? Promise.resolve(
parseTerminalMacros(localStorage.getItem("terminalMacros")),
)
: getUserPreferences().then((preferences) =>
parseTerminalMacros(preferences.terminalMacros),
);
load
.then((loaded) => {
if (!cancelled) setMacros(loaded);
})
.catch(() => {
if (!cancelled) toast.error(t("macros.loadFailed"));
});
return () => {
cancelled = true;
abortRef.current?.abort();
};
}, [storageMode, t]);
const target = useMemo(
() => terminalTabs.find((tab) => tab.id === activeTabId) ?? terminalTabs[0],
[activeTabId, terminalTabs],
);
const updateDraft = useCallback((next: TerminalMacro) => {
setDraft(next);
setDirty(true);
}, []);
const createMacro = () => {
const now = new Date().toISOString();
setDraft({
id: id(),
name: "",
description: "",
steps: [],
createdAt: now,
updatedAt: now,
});
setDirty(true);
};
const persist = async (next: TerminalMacro[]) => {
const serialized = JSON.stringify(next);
if (storageMode === "local")
localStorage.setItem("terminalMacros", serialized);
else await saveUserPreferences({ terminalMacros: serialized });
setMacros(next);
};
const save = async () => {
if (!draft) return;
if (!draft.name.trim()) {
toast.error(t("macros.nameRequired"));
return;
}
const saved: TerminalMacro = {
...draft,
name: draft.name.trim(),
updatedAt: new Date().toISOString(),
};
try {
await persist(
macros.some((macro) => macro.id === saved.id)
? macros.map((macro) => (macro.id === saved.id ? saved : macro))
: [...macros, saved],
);
setDraft(saved);
setDirty(false);
toast.success(t("macros.saved"));
} catch (error) {
toast.error(getErrorMessage(error) || t("macros.saveFailed"));
}
};
const confirmDelete = async () => {
if (!pendingDelete) return;
const next = macros.filter((macro) => macro.id !== pendingDelete.id);
try {
await persist(next);
if (draft?.id === pendingDelete.id) {
setDraft(null);
setDirty(false);
}
toast.success(t("macros.deleted"));
} catch (error) {
toast.error(getErrorMessage(error) || t("macros.saveFailed"));
} finally {
setPendingDelete(null);
}
};
const execute = useCallback(
async (macro: TerminalMacro) => {
const terminal = target?.terminalRef?.current;
if (!terminal?.sendInput || !terminal.subscribeOutput) {
toast.error(t("macros.needTerminal"));
return;
}
const controller = new AbortController();
abortRef.current = controller;
setRunningId(macro.id);
try {
await runTerminalMacro(
macro,
{ send: terminal.sendInput, subscribe: terminal.subscribeOutput },
{ signal: controller.signal },
);
toast.success(t("macros.completed"));
} catch (error) {
if (!(error instanceof DOMException && error.name === "AbortError")) {
toast.error(getErrorMessage(error) || t("macros.failed"));
}
} finally {
abortRef.current = null;
setRunningId(null);
}
},
[target, t],
);
const requestRun = (macro: TerminalMacro) => {
if (!target?.terminalRef?.current?.sendInput) {
toast.error(t("macros.needTerminal"));
return;
}
if (confirmedRef.current.has(macro.id)) {
void execute(macro);
return;
}
setPendingRun(macro);
};
const targetLabel = target?.label ?? t("macros.noTerminal");
return (
<div className="flex h-full min-h-0 flex-col">
<div className="flex items-center gap-2 border-b border-border p-3">
{draft ? (
<Button
size="icon"
variant="ghost"
className="size-7 rounded-none"
aria-label={t("macros.back")}
onClick={() => {
setDraft(null);
setDirty(false);
}}
>
<ArrowLeft className="size-4" />
</Button>
) : (
<Braces className="size-4 shrink-0" />
)}
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold">{t("macros.title")}</div>
<div className="truncate text-[11px] text-muted-foreground">
{t("macros.target")}: {targetLabel}
</div>
{!draft && (
<a
href="https://docs.termix.site/features/terminal/macros"
target="_blank"
rel="noreferrer"
className="text-[10px] text-accent-brand hover:underline"
>
{t("hosts.docsLink")}
</a>
)}
</div>
{!draft && (
<Button
size="icon"
variant="outline"
className="size-7 rounded-none"
aria-label={t("macros.create")}
onClick={createMacro}
>
<Plus className="size-3.5" />
</Button>
)}
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-3">
{!draft ? (
macros.length === 0 ? (
<EmptyState
icon={Braces}
title={t("macros.empty")}
hint={t("macros.emptyHint")}
action={
<Button
size="sm"
variant="outline"
className="mt-1 rounded-none"
onClick={createMacro}
>
<Plus className="mr-1 size-3" />
{t("macros.create")}
</Button>
}
/>
) : (
<div className="flex flex-col gap-1">
{macros.map((macro) => {
const isRunning = runningId === macro.id;
return (
<div
key={macro.id}
className="group flex items-center gap-2 border border-border bg-muted/20 px-2 py-1.5 hover:bg-muted/40"
>
<button
type="button"
className="min-w-0 flex-1 text-left"
onClick={() => {
setDraft(structuredClone(macro));
setDirty(false);
}}
>
<div className="truncate text-xs font-medium">
{macro.name}
</div>
{macro.description && (
<div className="truncate text-[11px] text-muted-foreground">
{macro.description}
</div>
)}
</button>
<Button
size="icon"
variant="ghost"
className="size-6 shrink-0 rounded-none"
aria-label={
isRunning ? t("macros.stop") : t("macros.run")
}
onClick={() =>
isRunning
? abortRef.current?.abort()
: requestRun(macro)
}
>
{isRunning ? (
<Square className="size-3" />
) : (
<Play className="size-3" />
)}
</Button>
</div>
);
})}
</div>
)
) : (
<div className="space-y-3">
<div className="space-y-1.5">
<div className="text-xs text-muted-foreground">
{t("macros.name")}
</div>
<Input
className="h-8 rounded-none text-xs"
value={draft.name}
placeholder={t("macros.namePlaceholder")}
onChange={(event) =>
updateDraft({ ...draft, name: event.target.value })
}
/>
</div>
<div className="space-y-1.5">
<div className="text-xs text-muted-foreground">
{t("macros.descriptionLabel")}
</div>
<Textarea
className="min-h-14 rounded-none text-xs"
value={draft.description ?? ""}
placeholder={t("macros.descriptionPlaceholder")}
onChange={(event) =>
updateDraft({ ...draft, description: event.target.value })
}
/>
</div>
{draft.steps.length === 0 && (
<div className="border border-dashed border-border px-3 py-4 text-center text-[11px] text-muted-foreground">
{t("macros.noSteps")}
</div>
)}
<StepList
steps={draft.steps}
onChange={(steps) => updateDraft({ ...draft, steps })}
/>
<div className="flex items-center gap-2 border-t border-border pt-3">
<Button
size="sm"
variant="destructive"
className="rounded-none"
onClick={() => setPendingDelete(draft)}
>
<Trash2 className="mr-1 size-3" />
{t("macros.delete")}
</Button>
<Button
size="sm"
variant="outline"
className="rounded-none"
onClick={save}
>
<Save className="mr-1 size-3" />
{t("macros.save")}
</Button>
<Button
size="sm"
className="ml-auto rounded-none"
onClick={() =>
runningId === draft.id
? abortRef.current?.abort()
: requestRun(draft)
}
>
{runningId === draft.id ? (
<>
<Square className="mr-1 size-3" />
{t("macros.stop")}
</>
) : (
<>
<Play className="mr-1 size-3" />
{t("macros.run")}
</>
)}
</Button>
</div>
{dirty && (
<div className="text-[11px] text-amber-500">
{t("macros.unsaved")}
</div>
)}
</div>
)}
</div>
<AlertDialog
open={pendingDelete !== null}
onOpenChange={(open) => !open && setPendingDelete(null)}
>
<AlertDialogContent className="rounded-none">
<AlertDialogHeader>
<AlertDialogTitle>{t("macros.deleteTitle")}</AlertDialogTitle>
<AlertDialogDescription>
{t("macros.deleteBody", { name: pendingDelete?.name ?? "" })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="rounded-none">
{t("macros.cancel")}
</AlertDialogCancel>
<AlertDialogAction className="rounded-none" onClick={confirmDelete}>
{t("macros.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog
open={pendingRun !== null}
onOpenChange={(open) => !open && setPendingRun(null)}
>
<AlertDialogContent className="rounded-none">
<AlertDialogHeader>
<AlertDialogTitle>{t("macros.confirmRunTitle")}</AlertDialogTitle>
<AlertDialogDescription>
{t("macros.confirmRunBody", {
name: pendingRun?.name ?? "",
host: targetLabel,
})}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="rounded-none">
{t("macros.cancel")}
</AlertDialogCancel>
<AlertDialogAction
className="rounded-none"
onClick={() => {
const macro = pendingRun;
setPendingRun(null);
if (macro) {
confirmedRef.current.add(macro.id);
void execute(macro);
}
}}
>
{t("macros.confirmRunAction")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
@@ -0,0 +1,49 @@
import { useTranslation } from "react-i18next";
import { FakeSwitch } from "@/components/section-card";
import { useRailItems } from "./rail-items";
/**
* Appearance > Sidebar > Navigation: one switch per rail destination,
* including the ones plugins register, which appear and disappear here as
* their plugin is turned on and off.
*/
export function NavigationVisibilityToggles({
hidden,
onChange,
}: {
hidden: Set<string>;
onChange: (next: Set<string>) => void;
}) {
const { t } = useTranslation();
const railItems = useRailItems();
return (
<>
{railItems
.filter((item) => item.hideable !== false)
.map((item) => (
<div
key={item.id}
className="flex items-center justify-between py-1.5"
data-testid={`nav-toggle-${item.id}`}
>
<span className="flex items-center gap-1.5 text-xs font-medium text-foreground">
<span className="text-muted-foreground">
<item.icon size={12} />
</span>
{t(item.labelKey)}
</span>
<FakeSwitch
checked={!hidden.has(item.id)}
onChange={(visible) => {
const next = new Set(hidden);
if (visible) next.delete(item.id);
else next.add(item.id);
onChange(next);
}}
/>
</div>
))}
</>
);
}
@@ -1,327 +0,0 @@
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/dialog";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { toast } from "sonner";
import {
createNotificationChannel,
updateNotificationChannel,
type NotificationChannel,
} from "@/api/alerts-api";
interface NotificationChannelDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
channel: NotificationChannel | null;
onSaved: () => void;
}
type ChannelType = "webhook" | "ntfy" | "discord";
interface WebhookConfig {
url: string;
headers?: Record<string, string>;
}
interface NtfyConfig {
url: string;
topic: string;
token?: string;
}
interface DiscordConfig {
url: string;
username?: string;
avatar_url?: string;
}
export function NotificationChannelDialog({
open,
onOpenChange,
channel,
onSaved,
}: NotificationChannelDialogProps) {
const { t } = useTranslation();
const [name, setName] = useState("");
const [type, setType] = useState<ChannelType>("webhook");
const [webhookUrl, setWebhookUrl] = useState("");
const [ntfyUrl, setNtfyUrl] = useState("https://ntfy.sh");
const [ntfyTopic, setNtfyTopic] = useState("");
const [ntfyToken, setNtfyToken] = useState("");
const [discordUsername, setDiscordUsername] = useState("");
const [discordAvatarUrl, setDiscordAvatarUrl] = useState("");
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!open) return;
if (channel) {
setName(channel.name);
setType(channel.type);
try {
const cfg = JSON.parse(channel.config);
if (channel.type === "webhook") {
setWebhookUrl((cfg as WebhookConfig).url ?? "");
} else if (channel.type === "discord") {
const dc = cfg as DiscordConfig;
setWebhookUrl(dc.url ?? "");
setDiscordUsername(dc.username ?? "");
setDiscordAvatarUrl(dc.avatar_url ?? "");
} else {
const nc = cfg as NtfyConfig;
setNtfyUrl(nc.url ?? "https://ntfy.sh");
setNtfyTopic(nc.topic ?? "");
setNtfyToken(nc.token ?? "");
}
} catch {
// ignore malformed config
}
} else {
setName("");
setType("webhook");
setWebhookUrl("");
setNtfyUrl("https://ntfy.sh");
setNtfyTopic("");
setNtfyToken("");
}
}, [open, channel]);
async function handleSave() {
if (!name.trim()) {
toast.error(t("alerts.channelNameRequired", "Name is required"));
return;
}
let config: WebhookConfig | NtfyConfig | DiscordConfig;
if (type === "webhook") {
if (!webhookUrl.trim()) {
toast.error(t("alerts.webhookUrlRequired", "Webhook URL is required"));
return;
}
config = {
url: webhookUrl.trim(),
} satisfies WebhookConfig;
} else if (type === "discord") {
const trimmedUrl = webhookUrl.trim();
if (!trimmedUrl) {
toast.error(t("alerts.webhookUrlRequired", "Webhook URL is required"));
return;
}
if (
!/^https:\/\/(?:canary\.|ptb\.)?(?:discord\.com|discordapp\.com)\/api\/webhooks\/.+/i.test(
trimmedUrl,
)
) {
toast.error(
t(
"alerts.discordUrlInvalid",
"Discord URL must be a valid Discord webhook URL",
),
);
return;
}
const dc: DiscordConfig = { url: trimmedUrl };
if (discordUsername.trim()) dc.username = discordUsername.trim();
if (discordAvatarUrl.trim()) dc.avatar_url = discordAvatarUrl.trim();
config = dc;
} else {
if (!ntfyTopic.trim()) {
toast.error(t("alerts.ntfyTopicRequired", "Topic is required"));
return;
}
const cfg: NtfyConfig = { url: ntfyUrl.trim(), topic: ntfyTopic.trim() };
if (ntfyToken.trim()) cfg.token = ntfyToken.trim();
config = cfg;
}
setSaving(true);
try {
if (channel) {
await updateNotificationChannel(channel.id, {
name: name.trim(),
type,
config,
});
} else {
await createNotificationChannel({ name: name.trim(), type, config });
}
onSaved();
} catch {
toast.error(t("alerts.channelSaveFailed", "Failed to save channel"));
} finally {
setSaving(false);
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
{channel
? t("alerts.editChannel", "Edit Channel")
: t("alerts.addChannel", "Add Channel")}
</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-3 py-2">
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("alerts.channelName", "Name")}
</label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
className="text-sm"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("alerts.channelType", "Type")}
</label>
<div className="flex gap-2">
{(["webhook", "ntfy", "discord"] as ChannelType[]).map((ct) => (
<button
key={ct}
onClick={() => setType(ct)}
className={`px-3 py-1 text-xs font-semibold border transition-colors ${type === ct ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:border-accent-brand/30"}`}
>
{ct === "webhook"
? "Webhook"
: ct === "ntfy"
? "ntfy"
: "Discord"}
</button>
))}
</div>
</div>
{type === "webhook" && (
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("alerts.webhookUrl", "URL")}
</label>
<Input
value={webhookUrl}
onChange={(e) => setWebhookUrl(e.target.value)}
placeholder="https://example.com/webhook"
className="text-sm font-mono"
/>
<span className="text-[10px] text-muted-foreground">
{t(
"alerts.webhookDesc",
"POST JSON payload to this URL on each alert firing",
)}
</span>
</div>
)}
{type === "discord" && (
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("alerts.webhookUrl", "Discord webhook URL")}
</label>
<Input
value={webhookUrl}
onChange={(e) => setWebhookUrl(e.target.value)}
placeholder="https://discord.com/api/webhooks/..."
className="text-sm font-mono"
/>
<span className="text-[10px] text-muted-foreground">
{t(
"alerts.discordWebhookDesc",
"POST a Discord embed to this webhook URL on each alert firing",
)}
</span>
<div className="flex flex-col gap-1 pt-2">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("alerts.discordUsername", "Display name (optional)")}
</label>
<Input
value={discordUsername}
onChange={(e) => setDiscordUsername(e.target.value)}
placeholder="Termix"
className="text-sm"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("alerts.discordAvatar", "Avatar URL (optional)")}
</label>
<Input
value={discordAvatarUrl}
onChange={(e) => setDiscordAvatarUrl(e.target.value)}
placeholder="https://example.com/avatar.png"
className="text-sm font-mono"
/>
</div>
</div>
)}
{type === "ntfy" && (
<>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("alerts.ntfyServer", "Server URL")}
</label>
<Input
value={ntfyUrl}
onChange={(e) => setNtfyUrl(e.target.value)}
placeholder="https://ntfy.sh"
className="text-sm font-mono"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("alerts.ntfyTopic", "Topic")}
</label>
<Input
value={ntfyTopic}
onChange={(e) => setNtfyTopic(e.target.value)}
placeholder="my-alerts"
className="text-sm font-mono"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("alerts.ntfyToken", "Access Token (optional)")}
</label>
<Input
type="password"
value={ntfyToken}
onChange={(e) => setNtfyToken(e.target.value)}
placeholder="tk_..."
className="text-sm font-mono"
/>
</div>
</>
)}
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)}>
{t("common.cancel", "Cancel")}
</Button>
<Button
variant="outline"
onClick={handleSave}
disabled={saving}
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
>
{saving
? t("common.saving", "Saving...")
: t("common.save", "Save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
-9
View File
@@ -1,9 +0,0 @@
import { C2STunnelPresetManager } from "@/user/C2STunnelPresetManager";
export function PortForwardingPanel() {
return (
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto px-2">
<C2STunnelPresetManager />
</div>
);
}
+189 -165
View File
@@ -1,43 +1,57 @@
import { useEffect, useState } from "react";
import { useEffect, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import {
Eye,
EyeOff,
FolderSearch,
Monitor,
MousePointerClick,
Terminal,
} from "lucide-react";
import { Eye, EyeOff } from "lucide-react";
import { toast } from "sonner";
import { Input } from "@/components/input";
import type { Host } from "@/types/ui-types";
import { getCredentials } from "@/api/credentials-api";
import { mapCredentials } from "./HostManagerData";
import { resolveHostDefaults } from "@/api/host-defaults-api";
import {
pluginSettingsFrom,
withDefaultPluginSettings,
} from "./host-defaults/quick-connect-defaults";
import {
createQuickConnectHost,
type QuickConnectProtocol,
quickConnectTargets,
} from "./quick-connect-host";
const DEFAULT_PORTS: Record<QuickConnectProtocol, string> = {
ssh: "22",
rdp: "3389",
vnc: "5900",
};
import { useHostProtocols } from "./host-protocols";
import { Select2 } from "@/components/select2";
import { useSshAuthProviders } from "@/hooks/useSshAuthProviders";
import { useSshAuthEditors } from "@/plugin-host/auth-registry";
import { useHostActions, type HostActionDef } from "./host-contributions";
// Core types Quick Connect draws its own fields for.
const INLINE_AUTH_TYPES = new Set(["password", "key", "credential"]);
const LABEL_CLASS =
"text-[10px] font-semibold uppercase tracking-widest text-muted-foreground";
const SELECT_CLASS =
"flex h-7 w-full border border-border bg-background px-2.5 py-1 text-xs outline-none focus:ring-1 focus:ring-ring";
interface QuickConnectPanelProps {
onConnect: (host: Host, type: "terminal" | "files" | "rdp" | "vnc") => void;
onConnect: (host: Host, type: string) => void;
}
function Field({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="flex flex-col gap-1 min-w-0">
<label className={LABEL_CLASS}>{label}</label>
{children}
</div>
);
}
export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
const { t } = useTranslation();
const [host, setHost] = useState("");
const [protocol, setProtocol] = useState<QuickConnectProtocol>("ssh");
// "ssh", or the id of a plugin protocol offered in Quick Connect.
const [protocol, setProtocol] = useState("ssh");
const [port, setPort] = useState("22");
const [domain, setDomain] = useState("");
const [username, setUsername] = useState("root");
const [authType, setAuthType] = useState<"password" | "key" | "credential">(
"password",
);
const [authType, setAuthType] = useState("password");
const [authFields, setAuthFields] = useState<Record<string, unknown>>({});
const [password, setPassword] = useState("");
const [privateKey, setPrivateKey] = useState("");
const [showPassword, setShowPassword] = useState(false);
@@ -52,88 +66,118 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
.catch(() => {});
}, []);
const isDesktop = protocol !== "ssh";
// Every registered SSH auth type that works for a host that is never saved.
const { providers } = useSshAuthProviders();
const authOptions = providers.filter(
(option) => option.available && option.quickConnect,
);
const authEditor = useSshAuthEditors().find(
(editor) => editor.id === authType,
);
const AuthEditor = INLINE_AUTH_TYPES.has(authType)
? undefined
: authEditor?.component;
const allActions = useHostActions();
const switchProtocol = (next: QuickConnectProtocol) => {
const pluginProtocols = useHostProtocols().filter(
(entry) => entry.quickConnect,
);
const selected = pluginProtocols.find((entry) => entry.id === protocol);
const isDesktop = !!selected;
const defaultPort = (id: string) =>
String(pluginProtocols.find((entry) => entry.id === id)?.defaultPort ?? 22);
const switchProtocol = (next: string) => {
// Keep a port the user typed; only swap the protocol default.
if (port === DEFAULT_PORTS[protocol]) setPort(DEFAULT_PORTS[next]);
if (port === defaultPort(protocol)) setPort(defaultPort(next));
setProtocol(next);
};
const connect = (type: "terminal" | "files" | "rdp" | "vnc") => {
if (!host) return;
// A host that is never saved still follows the user's host defaults.
const [defaultPluginSettings, setDefaultPluginSettings] = useState<
Record<string, Record<string, unknown>>
>({});
useEffect(() => {
resolveHostDefaults({ folder: null })
.then((values) => setDefaultPluginSettings(pluginSettingsFrom(values)))
.catch(() => {});
}, []);
const buildHost = () =>
withDefaultPluginSettings(
createQuickConnectHost({
ip: host.trim(),
port: parseInt(port) || parseInt(defaultPort(protocol)),
username,
authType: isDesktop ? "password" : authType,
password,
key: privateKey,
credentialId,
protocol: selected,
domain: domain || undefined,
authFields: INLINE_AUTH_TYPES.has(authType) ? undefined : authFields,
}),
defaultPluginSettings,
);
const targets = quickConnectTargets(allActions, buildHost());
const connect = (action?: HostActionDef) => {
if (!host.trim()) return;
if (!isDesktop && !username) return;
const hostConfig = createQuickConnectHost({
ip: host,
port: parseInt(port) || parseInt(DEFAULT_PORTS[protocol]),
username,
authType: isDesktop ? "password" : authType,
password,
key: privateKey,
credentialId,
protocol,
domain: domain || undefined,
});
onConnect(hostConfig, type);
const hostConfig = buildHost();
const target = action ?? quickConnectTargets(allActions, hostConfig)[0];
if (!target?.tabType) {
toast.error(t("newUi.sidebar.quickConnect.noTarget"));
return;
}
onConnect(hostConfig, target.tabType);
};
const connectDefault = () => connect(isDesktop ? protocol : "terminal");
const onEnter = (e: React.KeyboardEvent) => {
if (e.key === "Enter") connect();
};
return (
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto">
<div className="flex flex-col gap-3 p-3">
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("newUi.sidebar.quickConnect.protocolLabel")}
</label>
<div className="flex gap-1">
{(["ssh", "rdp", "vnc"] as const).map((type) => (
<button
key={type}
onClick={() => switchProtocol(type)}
className={`flex-1 py-1 text-[10px] font-semibold border transition-colors uppercase ${
protocol === type
? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand"
: "border-border text-muted-foreground hover:text-foreground"
}`}
>
{type}
</button>
))}
</div>
<div className="grid grid-cols-[1fr_5rem] gap-2">
<Field label={t("newUi.sidebar.quickConnect.protocolLabel")}>
<Select2
value={protocol}
onChange={(e) => switchProtocol(e.target.value)}
className={SELECT_CLASS}
>
<option value="ssh">
{t("newUi.sidebar.quickConnect.sshProtocol")}
</option>
{pluginProtocols.map((entry) => (
<option key={entry.id} value={entry.id}>
{t(entry.titleKey)}
</option>
))}
</Select2>
</Field>
<Field label={t("newUi.sidebar.quickConnect.portLabel")}>
<Input
placeholder={defaultPort(protocol)}
value={port}
onChange={(e) => setPort(e.target.value)}
onKeyDown={onEnter}
className="h-7 text-xs"
/>
</Field>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("newUi.sidebar.quickConnect.hostLabel")}
</label>
<Field label={t("newUi.sidebar.quickConnect.hostLabel")}>
<Input
placeholder={t("newUi.sidebar.quickConnect.hostPlaceholder")}
value={host}
onChange={(e) => setHost(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") connectDefault();
}}
onKeyDown={onEnter}
className="h-7 text-xs"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("newUi.sidebar.quickConnect.portLabel")}
</label>
<Input
placeholder={t("newUi.sidebar.quickConnect.portPlaceholder")}
value={port}
onChange={(e) => setPort(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") connectDefault();
}}
className="h-7 text-xs"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("newUi.sidebar.quickConnect.usernameLabel")}
</label>
</Field>
<Field label={t("newUi.sidebar.quickConnect.usernameLabel")}>
<Input
placeholder={t("newUi.sidebar.quickConnect.usernamePlaceholder")}
value={username}
@@ -144,39 +188,30 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
if (username === "") setUsername("root");
}}
onChange={(e) => setUsername(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") connectDefault();
}}
onKeyDown={onEnter}
className="h-7 text-xs"
/>
</div>
{!isDesktop && (
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("newUi.sidebar.quickConnect.authLabel")}
</label>
<div className="flex gap-1">
{(["password", "key", "credential"] as const).map((type) => (
<button
key={type}
onClick={() => setAuthType(type)}
className={`flex-1 py-1 text-[10px] font-semibold border transition-colors capitalize ${
authType === type
? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand"
: "border-border text-muted-foreground hover:text-foreground"
}`}
>
{type}
</button>
</Field>
{!isDesktop && authOptions.length > 0 && (
<Field label={t("newUi.sidebar.quickConnect.authLabel")}>
<Select2
value={authType}
onChange={(e) => {
setAuthType(e.target.value);
setAuthFields({});
}}
className={SELECT_CLASS}
>
{authOptions.map((option) => (
<option key={option.type} value={option.type}>
{t(option.editorTitleKey ?? option.labelKey)}
</option>
))}
</div>
</div>
</Select2>
</Field>
)}
{(isDesktop || authType === "password") && (
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("newUi.sidebar.quickConnect.passwordLabel")}
</label>
<Field label={t("newUi.sidebar.quickConnect.passwordLabel")}>
<div className="relative">
<Input
type={showPassword ? "text" : "password"}
@@ -185,12 +220,11 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
)}
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") connectDefault();
}}
onKeyDown={onEnter}
className="h-7 text-xs pr-8"
/>
<button
type="button"
onClick={() => setShowPassword((o) => !o)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
@@ -201,29 +235,21 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
)}
</button>
</div>
</div>
</Field>
)}
{isDesktop && protocol === "rdp" && (
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("newUi.sidebar.quickConnect.domainLabel")}
</label>
{selected?.quickConnect?.showDomain && (
<Field label={t("newUi.sidebar.quickConnect.domainLabel")}>
<Input
placeholder={t("newUi.sidebar.quickConnect.domainPlaceholder")}
value={domain}
onChange={(e) => setDomain(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") connectDefault();
}}
onKeyDown={onEnter}
className="h-7 text-xs"
/>
</div>
</Field>
)}
{!isDesktop && authType === "key" && (
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("newUi.sidebar.quickConnect.privateKeyLabel")}
</label>
<Field label={t("newUi.sidebar.quickConnect.privateKeyLabel")}>
<textarea
placeholder={t(
"newUi.sidebar.quickConnect.privateKeyPlaceholder",
@@ -232,13 +258,10 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
onChange={(e) => setPrivateKey(e.target.value)}
className="w-full h-24 px-2.5 py-2 text-xs bg-background border border-border text-foreground placeholder:text-muted-foreground resize-none outline-none focus:ring-1 focus:ring-ring font-mono"
/>
</div>
</Field>
)}
{!isDesktop && authType === "credential" && (
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("newUi.sidebar.quickConnect.credentialLabel")}
</label>
<Field label={t("newUi.sidebar.quickConnect.credentialLabel")}>
<Select2
value={credentialId}
onChange={(e) => {
@@ -247,7 +270,7 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
const cred = credentials.find((c) => c.id === newId);
if (cred?.username) setUsername(cred.username);
}}
className="flex h-7 w-full border border-border bg-background px-2.5 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
className={SELECT_CLASS}
>
<option value="">
{t("newUi.sidebar.quickConnect.credentialPlaceholder")}
@@ -258,42 +281,43 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
</option>
))}
</Select2>
</div>
</Field>
)}
{!isDesktop && AuthEditor && (
<AuthEditor
form={{ ...authFields, ip: host, port, username }}
setField={(key, value) => {
if (key === "ip") setHost(String(value ?? ""));
else if (key === "port") setPort(String(value ?? ""));
else if (key === "username") setUsername(String(value ?? ""));
else setAuthFields((current) => ({ ...current, [key]: value }));
}}
/>
)}
<div className="flex flex-col gap-1.5 pt-1">
{isDesktop ? (
<button
onClick={() => connect(protocol)}
className="flex items-center justify-center gap-1.5 h-7 w-full border border-accent-brand/40 bg-accent-brand/10 text-accent-brand text-xs font-semibold hover:bg-accent-brand/20 transition-colors"
>
{protocol === "rdp" ? (
<Monitor className="size-3.5" />
) : (
<MousePointerClick className="size-3.5" />
)}
{t(
protocol === "rdp"
? "newUi.sidebar.quickConnect.connectToRdp"
: "newUi.sidebar.quickConnect.connectToVnc",
)}
</button>
{targets.length === 0 ? (
<p className="text-xs text-muted-foreground">
{t("newUi.sidebar.quickConnect.noTarget")}
</p>
) : (
<>
targets.map((action, index) => (
<button
onClick={() => connect("terminal")}
className="flex items-center justify-center gap-1.5 h-7 w-full border border-accent-brand/40 bg-accent-brand/10 text-accent-brand text-xs font-semibold hover:bg-accent-brand/20 transition-colors"
key={action.id}
type="button"
disabled={!host.trim()}
onClick={() => connect(action)}
className={`flex items-center justify-center gap-1.5 h-7 w-full border text-xs font-semibold transition-colors disabled:opacity-50 disabled:pointer-events-none ${
index === 0
? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand hover:bg-accent-brand/20"
: "border-border text-foreground hover:bg-muted"
}`}
>
<Terminal className="size-3.5" />
{t("newUi.sidebar.quickConnect.connectToTerminal")}
<action.icon className="size-3.5" />
{t("newUi.sidebar.quickConnect.connectToAction", {
name: t(action.titleKey),
})}
</button>
<button
onClick={() => connect("files")}
className="flex items-center justify-center gap-1.5 h-7 w-full border border-accent-brand/40 bg-accent-brand/10 text-accent-brand text-xs font-semibold hover:bg-accent-brand/20 transition-colors"
>
<FolderSearch className="size-3.5" />
{t("newUi.sidebar.quickConnect.connectToFiles")}
</button>
</>
))
)}
</div>
</div>
+30
View File
@@ -0,0 +1,30 @@
/**
* The count a rail item shows on its icon. Each badge is its own component so
* the item's useBadge hook runs once per render of a stable element.
*/
export function RailBadge({
useBadge,
variant = "count",
className = "",
}: {
useBadge: () => number | null | undefined;
variant?: "count" | "dot";
className?: string;
}) {
const count = useBadge();
if (!count || count <= 0) return null;
if (variant === "dot") {
return (
<span
className={`absolute size-1.5 rounded-full bg-accent-brand ${className}`}
/>
);
}
return (
<span
className={`absolute min-w-[14px] h-[14px] px-[3px] rounded-full bg-accent-brand text-[9px] font-bold leading-[14px] text-center text-white tabular-nums ${className}`}
>
{count > 99 ? "99+" : count}
</span>
);
}
-726
View File
@@ -1,726 +0,0 @@
import { useState, useEffect, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/dialog";
import { toast } from "sonner";
import {
type LDAPProviderConfig,
type OIDCProviderConfig,
type SSOProvider,
type SSOProviderType,
} from "@/types/index";
import { createSSOProvider, updateSSOProvider } from "@/api/sso-provider-api";
import { Select2 } from "@/components/select2";
type ApiErrorLike = {
response?: { data?: { error?: string } };
message?: string;
};
function apiErrorMsg(error: unknown, fallback: string) {
const err = error as ApiErrorLike;
return err.response?.data?.error || err.message || fallback;
}
const PROVIDER_TYPE_OPTIONS: { value: SSOProviderType; label: string }[] = [
{ value: "oidc", label: "OIDC" },
{ value: "github", label: "GitHub" },
{ value: "google", label: "Google" },
{ value: "ldap", label: "LDAP" },
];
const githubDefaults = {
authorization_url: "https://github.com/login/oauth/authorize",
token_url: "https://github.com/login/oauth/access_token",
issuer_url: "https://token.actions.githubusercontent.com",
userinfo_url: "https://api.github.com/user",
scopes: "read:user user:email",
identifier_path: "id",
name_path: "name",
};
const googleDefaults = {
authorization_url: "https://accounts.google.com/o/oauth2/v2/auth",
token_url: "https://oauth2.googleapis.com/token",
issuer_url: "https://accounts.google.com",
userinfo_url: "https://openidconnect.googleapis.com/v1/userinfo",
scopes: "openid email profile",
identifier_path: "sub",
name_path: "name",
};
type SSOProviderDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
provider: SSOProvider | null;
onSaved: (provider: SSOProvider) => void;
};
function emptyOidc(): OIDCProviderConfig {
return {
client_id: "",
client_secret: "",
issuer_url: "",
authorization_url: "",
token_url: "",
userinfo_url: "",
identifier_path: "sub",
name_path: "name",
scopes: "openid email profile",
allowed_users: "",
admin_group: "",
group_claim: "",
ca_cert: "",
};
}
function emptyLdap(): Required<LDAPProviderConfig> {
return {
host: "",
port: 389,
useTLS: false,
bindDN: "",
bindPassword: "",
userSearchBase: "",
userSearchFilter: "(uid={{username}})",
usernameAttribute: "uid",
displayNameAttribute: "cn",
groupSearchBase: "",
adminGroup: "",
allowedUsers: "",
};
}
type OIDCFields = {
client_id: string;
client_secret: string;
issuer_url: string;
authorization_url: string;
token_url: string;
userinfo_url: string;
identifier_path: string;
name_path: string;
scopes: string;
allowed_users: string;
admin_group: string;
group_claim: string;
ca_cert: string;
};
type LDAPFields = {
host: string;
port: string;
useTLS: boolean;
bindDN: string;
bindPassword: string;
userSearchBase: string;
userSearchFilter: string;
usernameAttribute: string;
displayNameAttribute: string;
groupSearchBase: string;
adminGroup: string;
allowedUsers: string;
};
export function SSOProviderDialog({
open,
onOpenChange,
provider,
onSaved,
}: SSOProviderDialogProps) {
const { t } = useTranslation();
const isEdit = provider != null;
const [name, setName] = useState("");
const [type, setType] = useState<SSOProviderType>("oidc");
const [enabled, setEnabled] = useState(true);
const [saving, setSaving] = useState(false);
const [oidc, setOidc] = useState<OIDCFields>(
() => ({ ...emptyOidc() }) as OIDCFields,
);
const [ldap, setLdap] = useState<LDAPFields>(() => {
const d = emptyLdap();
return { ...d, port: String(d.port) };
});
useEffect(() => {
if (!open) return;
if (provider) {
setName(provider.name);
setType(provider.type);
setEnabled(provider.enabled);
let config: Record<string, unknown> = {};
if (typeof provider.config === "string") {
try {
config = JSON.parse(provider.config);
} catch {
/* */
}
} else if (provider.config && typeof provider.config === "object") {
config = provider.config as Record<string, unknown>;
}
if (provider.type === "ldap") {
const d = emptyLdap();
setLdap({
host: (config.host as string) ?? d.host,
port: String((config.port as number) ?? d.port),
useTLS: (config.useTLS as boolean) ?? d.useTLS,
bindDN: (config.bindDN as string) ?? d.bindDN,
bindPassword: (config.bindPassword as string) ?? "",
userSearchBase: (config.userSearchBase as string) ?? d.userSearchBase,
userSearchFilter:
(config.userSearchFilter as string) ?? d.userSearchFilter,
usernameAttribute:
(config.usernameAttribute as string) ?? d.usernameAttribute,
displayNameAttribute:
(config.displayNameAttribute as string) ?? d.displayNameAttribute,
groupSearchBase: (config.groupSearchBase as string) ?? "",
adminGroup: (config.adminGroup as string) ?? "",
allowedUsers: (config.allowedUsers as string) ?? "",
});
} else {
const d = emptyOidc();
setOidc({
client_id: (config.client_id as string) ?? d.client_id,
client_secret: (config.client_secret as string) ?? "",
issuer_url: (config.issuer_url as string) ?? d.issuer_url,
authorization_url:
(config.authorization_url as string) ?? d.authorization_url,
token_url: (config.token_url as string) ?? d.token_url,
userinfo_url: (config.userinfo_url as string) ?? d.userinfo_url,
identifier_path:
(config.identifier_path as string) ?? d.identifier_path,
name_path: (config.name_path as string) ?? d.name_path,
scopes: (config.scopes as string) ?? d.scopes,
allowed_users: (config.allowed_users as string) ?? d.allowed_users,
admin_group: (config.admin_group as string) ?? d.admin_group,
group_claim: (config.group_claim as string) ?? "",
ca_cert: (config.ca_cert as string) ?? "",
});
}
} else {
setName("");
setType("oidc");
setEnabled(true);
setOidc({ ...emptyOidc() } as OIDCFields);
const d = emptyLdap();
setLdap({ ...d, port: String(d.port) });
}
}, [open, provider]);
function setOidcField<K extends keyof OIDCFields>(
field: K,
value: OIDCFields[K],
) {
setOidc((prev) => ({ ...prev, [field]: value }));
}
function setLdapField<K extends keyof LDAPFields>(
field: K,
value: LDAPFields[K],
) {
setLdap((prev) => ({ ...prev, [field]: value }));
}
function buildConfig(): Record<string, unknown> {
if (type === "ldap") {
return {
host: ldap.host,
port: parseInt(ldap.port, 10) || 389,
useTLS: ldap.useTLS,
bindDN: ldap.bindDN,
bindPassword: ldap.bindPassword,
userSearchBase: ldap.userSearchBase,
userSearchFilter: ldap.userSearchFilter,
usernameAttribute: ldap.usernameAttribute || "uid",
displayNameAttribute: ldap.displayNameAttribute || "cn",
groupSearchBase: ldap.groupSearchBase || undefined,
adminGroup: ldap.adminGroup || undefined,
allowedUsers: ldap.allowedUsers || undefined,
};
}
const providerDefaults =
type === "google"
? googleDefaults
: type === "github"
? githubDefaults
: null;
return {
client_id: oidc.client_id,
client_secret: oidc.client_secret,
issuer_url: oidc.issuer_url || providerDefaults?.issuer_url || "",
authorization_url:
oidc.authorization_url || providerDefaults?.authorization_url || "",
token_url: oidc.token_url || providerDefaults?.token_url || "",
userinfo_url:
oidc.userinfo_url || providerDefaults?.userinfo_url || undefined,
identifier_path:
oidc.identifier_path || providerDefaults?.identifier_path || "sub",
name_path: oidc.name_path || providerDefaults?.name_path || "name",
scopes: oidc.scopes || providerDefaults?.scopes || "openid email profile",
allowed_users: oidc.allowed_users || undefined,
admin_group: oidc.admin_group || undefined,
group_claim: oidc.group_claim || undefined,
ca_cert: oidc.ca_cert || undefined,
};
}
async function handleSave() {
if (!name.trim()) {
toast.error(
t("admin.ssoProviderName") + " " + t("common.required").toLowerCase(),
);
return;
}
setSaving(true);
try {
const data = {
name: name.trim(),
type,
enabled,
displayOrder: 0,
config: buildConfig(),
};
let saved: SSOProvider;
if (isEdit && provider) {
saved = await updateSSOProvider(provider.id, data);
} else {
saved = await createSSOProvider(data);
}
toast.success(t("common.saved"));
onSaved(saved);
onOpenChange(false);
} catch (err) {
toast.error(apiErrorMsg(err, t("common.saveFailed")));
} finally {
setSaving(false);
}
}
const isGithubOrGoogle = type === "github" || type === "google";
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{isEdit ? t("admin.ssoEditProvider") : t("admin.ssoAddProvider")}
</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-3 pt-2">
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.ssoProviderName")}{" "}
<span className="text-accent-brand">*</span>
</label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="My SSO Provider"
className="text-xs"
/>
</div>
{!isEdit && (
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.ssoProviderType")}
</label>
<Select2
value={type}
onChange={(e) => setType(e.target.value as SSOProviderType)}
className="w-full px-2 py-1.5 text-xs bg-background border border-border text-foreground outline-none focus:ring-1 focus:ring-ring"
>
{PROVIDER_TYPE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</Select2>
</div>
)}
<div className="flex items-center justify-between">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.ssoEnabled")}
</label>
<button
type="button"
onClick={() => setEnabled((v) => !v)}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center border-2 transition-colors ${enabled ? "bg-accent-brand border-accent-brand" : "bg-muted border-border"}`}
>
<span
className={`pointer-events-none inline-block h-3 w-3 bg-background shadow-sm transition-transform ${enabled ? "translate-x-4" : "translate-x-0.5"}`}
/>
</button>
</div>
<div className="border-t border-border pt-3">
{type === "ldap" ? (
<LDAPConfigFields ldap={ldap} setLdapField={setLdapField} t={t} />
) : (
<OIDCConfigFields
oidc={oidc}
setOidcField={setOidcField}
type={type}
simplified={isGithubOrGoogle}
t={t}
/>
)}
</div>
<div className="flex gap-2 justify-end pt-2">
<Button
variant="outline"
size="sm"
className="text-xs"
onClick={() => onOpenChange(false)}
>
{t("common.cancel")}
</Button>
<Button
size="sm"
variant="outline"
className="text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={handleSave}
disabled={saving}
>
{saving ? t("admin.saving") : t("admin.ssoSaveProvider")}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
function Field({
label,
required,
children,
}: {
label: string;
required?: boolean;
children: ReactNode;
}) {
return (
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{label}
{required && <span className="text-accent-brand ml-1">*</span>}
</label>
{children}
</div>
);
}
function OIDCConfigFields({
oidc,
setOidcField,
type,
simplified,
t,
}: {
oidc: OIDCFields;
setOidcField: <K extends keyof OIDCFields>(k: K, v: OIDCFields[K]) => void;
type: SSOProviderType;
simplified: boolean;
t: (key: string) => string;
}) {
const docsHref = simplified
? "https://docs.termix.site/features/authentication/github-google"
: "https://docs.termix.site/features/authentication/oidc";
return (
<div className="flex flex-col gap-3">
<span className="text-[10px] text-muted-foreground">
<a
href={docsHref}
target="_blank"
rel="noreferrer"
className="text-accent-brand hover:underline"
>
{t("admin.ssoProviderDocsLink")}
</a>
</span>
<Field label={t("admin.oidcClientId")} required>
<Input
value={oidc.client_id}
onChange={(e) => setOidcField("client_id", e.target.value)}
placeholder="your-client-id"
className="text-xs"
/>
</Field>
<Field label={t("admin.oidcClientSecret")} required>
<Input
type="password"
value={oidc.client_secret}
onChange={(e) => setOidcField("client_secret", e.target.value)}
placeholder="your-client-secret"
className="text-xs"
/>
</Field>
{simplified ? (
<div className="flex flex-col gap-1">
<span className="text-[10px] text-muted-foreground">
{type === "github"
? `Authorization URL: ${githubDefaults.authorization_url}`
: `Authorization URL: ${googleDefaults.authorization_url}`}
</span>
</div>
) : (
<>
<Field label={t("admin.oidcIssuerUrl")} required>
<Input
value={oidc.issuer_url}
onChange={(e) => setOidcField("issuer_url", e.target.value)}
placeholder="https://provider"
className="text-xs"
/>
</Field>
<Field label={t("admin.oidcAuthUrl")} required>
<Input
value={oidc.authorization_url}
onChange={(e) =>
setOidcField("authorization_url", e.target.value)
}
placeholder="https://provider/oauth2/auth"
className="text-xs"
/>
</Field>
<Field label={t("admin.oidcTokenUrl")} required>
<Input
value={oidc.token_url}
onChange={(e) => setOidcField("token_url", e.target.value)}
placeholder="https://provider/oauth2/token"
className="text-xs"
/>
</Field>
<Field label={t("admin.oidcUserIdentifier")} required>
<Input
value={oidc.identifier_path}
onChange={(e) => setOidcField("identifier_path", e.target.value)}
placeholder="sub"
className="text-xs"
/>
</Field>
<Field label={t("admin.oidcDisplayName")} required>
<Input
value={oidc.name_path}
onChange={(e) => setOidcField("name_path", e.target.value)}
placeholder="name"
className="text-xs"
/>
</Field>
<Field label={t("admin.oidcScopes")} required>
<Input
value={oidc.scopes}
onChange={(e) => setOidcField("scopes", e.target.value)}
placeholder="openid email profile"
className="text-xs"
/>
</Field>
<Field label={t("admin.oidcUserinfoUrl")}>
<Input
value={oidc.userinfo_url}
onChange={(e) => setOidcField("userinfo_url", e.target.value)}
placeholder="https://provider/oauth2/userinfo"
className="text-xs"
/>
</Field>
<Field label={t("admin.oidcGroupClaim")}>
<Input
value={oidc.group_claim}
onChange={(e) => setOidcField("group_claim", e.target.value)}
placeholder="groups"
className="text-xs"
/>
</Field>
</>
)}
<Field label={t("admin.oidcAllowedUsers")}>
<span className="text-[10px] text-muted-foreground">
{t("admin.oidcAllowedUsersDesc")}
</span>
<textarea
value={oidc.allowed_users}
onChange={(e) => setOidcField("allowed_users", e.target.value)}
placeholder={"user@example.com\nanother@example.com"}
rows={3}
className="w-full px-2 py-1.5 text-xs bg-background border border-border text-foreground placeholder:text-muted-foreground resize-none outline-none focus:ring-1 focus:ring-ring font-mono"
/>
</Field>
<Field label={t("admin.oidcAdminGroup")}>
<span className="text-[10px] text-muted-foreground">
{t("admin.oidcAdminGroupDesc")}
</span>
<Input
value={oidc.admin_group}
onChange={(e) => setOidcField("admin_group", e.target.value)}
placeholder="admin"
className="text-xs"
/>
</Field>
<Field label={t("admin.oidcCaCert")}>
<span className="text-[10px] text-muted-foreground">
{t("admin.oidcCaCertDesc")}
</span>
<textarea
value={oidc.ca_cert}
onChange={(e) => setOidcField("ca_cert", e.target.value)}
placeholder={
"-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----"
}
rows={4}
className="w-full px-2 py-1.5 text-xs bg-background border border-border text-foreground placeholder:text-muted-foreground resize-none outline-none focus:ring-1 focus:ring-ring font-mono"
/>
</Field>
</div>
);
}
function LDAPConfigFields({
ldap,
setLdapField,
t,
}: {
ldap: LDAPFields;
setLdapField: <K extends keyof LDAPFields>(k: K, v: LDAPFields[K]) => void;
t: (key: string) => string;
}) {
return (
<div className="flex flex-col gap-3">
<span className="text-[10px] text-muted-foreground">
<a
href="https://docs.termix.site/features/authentication/ldap"
target="_blank"
rel="noreferrer"
className="text-accent-brand hover:underline"
>
{t("admin.ssoProviderDocsLink")}
</a>
</span>
<Field label={t("admin.ldapHost")} required>
<Input
value={ldap.host}
onChange={(e) => setLdapField("host", e.target.value)}
placeholder="ldap.example.com"
className="text-xs"
/>
</Field>
<div className="flex gap-2">
<div className="flex-1">
<Field label={t("admin.ldapPort")} required>
<Input
value={ldap.port}
onChange={(e) => setLdapField("port", e.target.value)}
placeholder="389"
className="text-xs"
/>
</Field>
</div>
<div className="flex items-end pb-0.5">
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setLdapField("useTLS", !ldap.useTLS)}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center border-2 transition-colors ${ldap.useTLS ? "bg-accent-brand border-accent-brand" : "bg-muted border-border"}`}
>
<span
className={`pointer-events-none inline-block h-3 w-3 bg-background shadow-sm transition-transform ${ldap.useTLS ? "translate-x-4" : "translate-x-0.5"}`}
/>
</button>
<span className="text-[10px] text-muted-foreground">
{t("admin.ldapUseTls")}
</span>
</div>
</div>
</div>
<Field label={t("admin.ldapBindDn")} required>
<Input
value={ldap.bindDN}
onChange={(e) => setLdapField("bindDN", e.target.value)}
placeholder="cn=admin,dc=example,dc=com"
className="text-xs"
/>
</Field>
<Field label={t("admin.ldapBindPassword")} required>
<Input
type="password"
value={ldap.bindPassword}
onChange={(e) => setLdapField("bindPassword", e.target.value)}
placeholder="bind password"
className="text-xs"
/>
</Field>
<Field label={t("admin.ldapUserSearchBase")} required>
<Input
value={ldap.userSearchBase}
onChange={(e) => setLdapField("userSearchBase", e.target.value)}
placeholder="ou=users,dc=example,dc=com"
className="text-xs"
/>
</Field>
<Field label={t("admin.ldapUserSearchFilter")} required>
<Input
value={ldap.userSearchFilter}
onChange={(e) => setLdapField("userSearchFilter", e.target.value)}
placeholder="(uid={{username}})"
className="text-xs"
/>
</Field>
<Field label={t("admin.ldapUsernameAttr")} required>
<Input
value={ldap.usernameAttribute}
onChange={(e) => setLdapField("usernameAttribute", e.target.value)}
placeholder="uid"
className="text-xs"
/>
</Field>
<Field label={t("admin.ldapDisplayNameAttr")} required>
<Input
value={ldap.displayNameAttribute}
onChange={(e) => setLdapField("displayNameAttribute", e.target.value)}
placeholder="cn"
className="text-xs"
/>
</Field>
<div className="border-t border-border pt-3 flex flex-col gap-3">
<span className="text-[10px] text-muted-foreground">
{t("admin.ldapGroupSearchBase")}
</span>
<Field label={t("admin.ldapGroupSearchBase")}>
<Input
value={ldap.groupSearchBase}
onChange={(e) => setLdapField("groupSearchBase", e.target.value)}
placeholder="ou=groups,dc=example,dc=com"
className="text-xs"
/>
</Field>
<Field label={t("admin.ldapAdminGroup")}>
<Input
value={ldap.adminGroup}
onChange={(e) => setLdapField("adminGroup", e.target.value)}
placeholder="cn=admins,ou=groups,dc=example,dc=com"
className="text-xs"
/>
</Field>
<Field label={t("admin.ldapAllowedUsers")}>
<Input
value={ldap.allowedUsers}
onChange={(e) => setLdapField("allowedUsers", e.target.value)}
placeholder="user1,user2,@domain.com"
className="text-xs"
/>
</Field>
</div>
</div>
);
}
-298
View File
@@ -1,298 +0,0 @@
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { KeyRound, Pencil, Plus, Trash2, X } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { PasswordInput } from "@/components/password-input";
import { getErrorMessage } from "@/lib/error-message";
import {
createSecretSource,
deleteSecretSource,
listSecretSources,
testSecretSource,
updateSecretSource,
type SecretSource,
} from "@/api/secret-sources-api";
/** One line under a secret field: references are allowed, here is where to set them up. */
export function SecretReferenceHint({ onManage }: { onManage: () => void }) {
const { t } = useTranslation();
return (
<p className="text-[10px] text-muted-foreground">
{t("hosts.secretRefHint")}{" "}
<button
type="button"
className="text-accent-brand hover:underline"
onClick={onManage}
>
{t("hosts.secretSourcesManage")}
</button>
</p>
);
}
type FormState = {
id?: string;
name: string;
baseUrl: string;
token: string;
shared: boolean;
};
const emptyForm: FormState = {
name: "",
baseUrl: "",
token: "",
shared: false,
};
export function SecretSourceManager({ onClose }: { onClose: () => void }) {
const { t } = useTranslation();
const [sources, setSources] = useState<SecretSource[]>([]);
const [form, setForm] = useState<FormState | null>(null);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState<string | null>(null);
const reload = useCallback(async () => {
try {
setSources(await listSecretSources());
} catch (e) {
toast.error(getErrorMessage(e));
}
}, []);
useEffect(() => {
void reload();
}, [reload]);
const setField = <K extends keyof FormState>(key: K, value: FormState[K]) =>
setForm((prev) => (prev ? { ...prev, [key]: value } : prev));
const handleSave = async () => {
if (!form) return;
if (
!form.name.trim() ||
!form.baseUrl.trim() ||
(!form.id && !form.token)
) {
toast.error(t("hosts.secretSourceRequired"));
return;
}
setSaving(true);
try {
if (form.id) {
await updateSecretSource(form.id, {
name: form.name,
baseUrl: form.baseUrl,
shared: form.shared,
...(form.token ? { token: form.token } : {}),
});
} else {
await createSecretSource({
name: form.name,
baseUrl: form.baseUrl,
token: form.token,
shared: form.shared,
});
}
toast.success(t("hosts.secretSourceSaved"));
setForm(null);
await reload();
} catch (e) {
toast.error(getErrorMessage(e));
} finally {
setSaving(false);
}
};
const handleDelete = async (source: SecretSource) => {
try {
await deleteSecretSource(source.id);
toast.success(t("hosts.secretSourceDeleted"));
await reload();
} catch (e) {
toast.error(getErrorMessage(e));
}
};
const handleTest = async (source: SecretSource) => {
setTesting(source.id);
try {
const result = await testSecretSource(source.id);
if (result.ok) {
toast.success(
t("hosts.secretSourceTestOk", { count: result.vaults ?? 0 }),
);
} else {
toast.error(result.error ?? t("hosts.secretSourceTestFailed"));
}
} catch (e) {
toast.error(getErrorMessage(e));
} finally {
setTesting(null);
}
};
return (
<div className="flex flex-col gap-3 col-span-2 border border-border bg-muted/20 p-3">
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.secretSourcesTitle")}
</span>
<button
type="button"
onClick={onClose}
className="text-muted-foreground hover:text-foreground"
>
<X className="size-3.5" />
</button>
</div>
<p className="text-[10px] text-muted-foreground">
{t("hosts.secretSourcesDesc")}
</p>
{!form && (
<>
{sources.map((source) => (
<div
key={source.id}
className="flex items-center gap-2 border border-border bg-background px-2 py-1.5 text-xs"
>
<KeyRound className="size-3.5 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0">
<div className="truncate">
{source.name}
{source.shared && (
<span className="ml-1 text-[9px] uppercase text-muted-foreground">
{t("hosts.secretSourceShared")}
</span>
)}
</div>
<div className="truncate text-[10px] text-muted-foreground">
{source.baseUrl}
</div>
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 text-[10px]"
disabled={testing === source.id}
onClick={() => void handleTest(source)}
>
{t("hosts.secretSourceTest")}
</Button>
{source.owned && (
<>
<button
type="button"
className="text-muted-foreground hover:text-foreground"
onClick={() =>
setForm({
id: source.id,
name: source.name,
baseUrl: source.baseUrl,
token: "",
shared: source.shared,
})
}
>
<Pencil className="size-3.5" />
</button>
<button
type="button"
className="text-muted-foreground hover:text-destructive"
onClick={() => void handleDelete(source)}
>
<Trash2 className="size-3.5" />
</button>
</>
)}
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
className="self-start border-accent-brand/40 text-accent-brand"
onClick={() => setForm(emptyForm)}
>
<Plus className="size-3 mr-1" /> {t("hosts.secretSourceNew")}
</Button>
</>
)}
{form && (
<div className="flex flex-col gap-2">
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
<div className="flex flex-col gap-1">
<label className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.friendlyNameLabel")}
</label>
<Input
className="h-8 text-xs"
placeholder="Team 1Password"
value={form.name}
onChange={(e) => setField("name", e.target.value)}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.secretSourceUrlLabel")}
</label>
<Input
className="h-8 text-xs"
placeholder="https://connect.internal:8080"
value={form.baseUrl}
onChange={(e) => setField("baseUrl", e.target.value)}
/>
</div>
</div>
<div className="flex flex-col gap-1">
<label className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.secretSourceTokenLabel")}
</label>
<PasswordInput
className="h-8 text-xs pr-8"
placeholder={
form.id ? t("hosts.secretSourceTokenKeep") : "eyJhbGciOi..."
}
value={form.token}
onChange={(e) => setField("token", e.target.value)}
/>
</div>
<label className="flex items-center gap-2 text-xs text-foreground">
<input
type="checkbox"
checked={form.shared}
onChange={(e) => setField("shared", e.target.checked)}
/>
{t("hosts.secretSourceSharedLabel")}
</label>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setForm(null)}
disabled={saving}
>
{t("hosts.cancelBtn")}
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="border-accent-brand/40 text-accent-brand"
onClick={() => void handleSave()}
disabled={saving}
>
{form.id ? t("common.save") : t("common.create")}
</Button>
</div>
</div>
)}
</div>
);
}
-335
View File
@@ -1,335 +0,0 @@
import { useState, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { RefreshCw, Usb, TriangleAlert } from "lucide-react";
import { Input } from "@/components/input";
import { isElectron } from "@/lib/electron";
import { websocketAuthProtocols } from "@/lib/ws-auth";
import type { SerialConfig } from "@/types/ui-types";
import { Select2 } from "@/components/select2";
const BAUD_RATES = [
300, 1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200, 230400, 460800,
921600,
];
interface SerialPanelProps {
onConnect: (config: SerialConfig) => void;
}
// In Chrome/Edge the Web Serial API shows its own native port picker on requestPort(),
// so getPorts() only returns previously-granted ports. We never need the user to type
// a path — the browser handles selection at connect time.
const useWebSerial = !isElectron() && "serial" in navigator;
const serialUnsupported = !isElectron() && !("serial" in navigator);
export function SerialPanel({ onConnect }: SerialPanelProps) {
const { t } = useTranslation();
const [path, setPath] = useState("");
const [baudRate, setBaudRate] = useState(115200);
const [dataBits, setDataBits] = useState<5 | 6 | 7 | 8>(8);
const [stopBits, setStopBits] = useState<1 | 2>(1);
const [parity, setParity] = useState<"none" | "even" | "odd">("none");
// Electron only: ports discovered from the backend
const [availablePorts, setAvailablePorts] = useState<string[]>([]);
const [loadingPorts, setLoadingPorts] = useState(false);
const buildWsUrl = () => {
// Serial is always local -- the device is physically attached to this
// desktop machine, so it never routes through a remote server.
return "ws://127.0.0.1:30011";
};
const refreshPorts = useCallback(() => {
if (!isElectron()) return;
setLoadingPorts(true);
const url = buildWsUrl();
if (!url) {
setLoadingPorts(false);
return;
}
const ws = new WebSocket(
url,
websocketAuthProtocols(localStorage.getItem("jwt")),
);
ws.onopen = () => ws.send(JSON.stringify({ type: "list_ports" }));
ws.onmessage = (ev) => {
try {
const msg = JSON.parse(ev.data as string) as {
type: string;
data?: { path: string }[];
};
if (msg.type === "ports_list" && Array.isArray(msg.data)) {
const paths = msg.data.map((p) => p.path);
setAvailablePorts(paths);
if (!path && paths.length > 0) setPath(paths[0]);
}
} catch {
// ignore
} finally {
ws.close();
setLoadingPorts(false);
}
};
ws.onerror = () => setLoadingPorts(false);
}, [path]);
useEffect(() => {
if (isElectron()) refreshPorts();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const connect = () => {
// For Web Serial, path is irrelevant — the browser picker handles it.
// We pass an empty string; Serial.tsx calls requestPort() itself.
if (useWebSerial) {
onConnect({ path: "", baudRate, dataBits, stopBits, parity });
return;
}
const finalPath = path.trim();
if (!finalPath) return;
onConnect({ path: finalPath, baudRate, dataBits, stopBits, parity });
};
// ── Not supported (Firefox, Safari, etc.) ─────────────────────────────
if (serialUnsupported) {
return (
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto">
<div className="flex flex-col gap-3 p-3">
<div className="flex flex-col gap-2 rounded border border-destructive/30 bg-destructive/5 p-3">
<div className="flex items-center gap-2">
<TriangleAlert className="size-3.5 text-destructive shrink-0" />
<span className="text-xs font-semibold text-destructive">
{t("serial.notSupportedTitle")}
</span>
</div>
<p className="text-[11px] text-muted-foreground leading-relaxed">
{t("serial.notSupported")}
</p>
<p className="text-[11px] text-muted-foreground leading-relaxed">
{t("serial.hideHint")}
</p>
</div>
</div>
</div>
);
}
// ── Web Serial (Chrome / Edge) ─────────────────────────────────────────
if (useWebSerial) {
return (
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto">
<div className="flex flex-col gap-3 p-3">
<p className="text-[11px] text-muted-foreground leading-relaxed">
{t("serial.browserPickerHint")}
</p>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("serial.baudRateLabel")}
</label>
<Select2
value={baudRate}
onChange={(e) => setBaudRate(Number(e.target.value))}
className="flex h-7 w-full border border-border bg-background px-2.5 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
>
{BAUD_RATES.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</Select2>
</div>
<div className="grid grid-cols-3 gap-2">
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("serial.dataBitsLabel")}
</label>
<Select2
value={dataBits}
onChange={(e) =>
setDataBits(Number(e.target.value) as 5 | 6 | 7 | 8)
}
className="flex h-7 w-full border border-border bg-background px-2.5 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
>
{([5, 6, 7, 8] as const).map((b) => (
<option key={b} value={b}>
{b}
</option>
))}
</Select2>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("serial.stopBitsLabel")}
</label>
<Select2
value={stopBits}
onChange={(e) => setStopBits(Number(e.target.value) as 1 | 2)}
className="flex h-7 w-full border border-border bg-background px-2.5 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value={1}>1</option>
<option value={2}>2</option>
</Select2>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("serial.parityLabel")}
</label>
<Select2
value={parity}
onChange={(e) =>
setParity(e.target.value as "none" | "even" | "odd")
}
className="flex h-7 w-full border border-border bg-background px-2.5 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="none">{t("serial.parityNone")}</option>
<option value="even">{t("serial.parityEven")}</option>
<option value="odd">{t("serial.parityOdd")}</option>
</Select2>
</div>
</div>
<button
onClick={connect}
className="flex items-center justify-center gap-1.5 h-7 w-full border border-accent-brand/40 bg-accent-brand/10 text-accent-brand text-xs font-semibold hover:bg-accent-brand/20 transition-colors mt-1"
>
<Usb className="size-3.5" />
{t("serial.connect")}
</button>
</div>
</div>
);
}
// ── Electron (backend serialport) ─────────────────────────────────────
return (
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto">
<div className="flex flex-col gap-3 p-3">
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("serial.portLabel")}
</label>
<button
onClick={refreshPorts}
className="text-muted-foreground hover:text-foreground transition-colors"
title={t("serial.refreshPorts")}
>
<RefreshCw
className={`size-3 ${loadingPorts ? "animate-spin" : ""}`}
/>
</button>
</div>
{availablePorts.length > 0 && (
<Select2
value={path}
onChange={(e) => setPath(e.target.value)}
className="flex h-7 w-full border border-border bg-background px-2.5 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="">{t("serial.portPlaceholder")}</option>
{availablePorts.map((p) => (
<option key={p} value={p}>
{p}
</option>
))}
</Select2>
)}
<Input
placeholder={t("serial.portPlaceholder")}
value={path}
onChange={(e) => setPath(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") connect();
}}
className="h-7 text-xs font-mono"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("serial.baudRateLabel")}
</label>
<Select2
value={baudRate}
onChange={(e) => setBaudRate(Number(e.target.value))}
className="flex h-7 w-full border border-border bg-background px-2.5 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
>
{BAUD_RATES.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</Select2>
</div>
<div className="grid grid-cols-3 gap-2">
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("serial.dataBitsLabel")}
</label>
<Select2
value={dataBits}
onChange={(e) =>
setDataBits(Number(e.target.value) as 5 | 6 | 7 | 8)
}
className="flex h-7 w-full border border-border bg-background px-2.5 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
>
{([5, 6, 7, 8] as const).map((b) => (
<option key={b} value={b}>
{b}
</option>
))}
</Select2>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("serial.stopBitsLabel")}
</label>
<Select2
value={stopBits}
onChange={(e) => setStopBits(Number(e.target.value) as 1 | 2)}
className="flex h-7 w-full border border-border bg-background px-2.5 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value={1}>1</option>
<option value={2}>2</option>
</Select2>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("serial.parityLabel")}
</label>
<Select2
value={parity}
onChange={(e) =>
setParity(e.target.value as "none" | "even" | "odd")
}
className="flex h-7 w-full border border-border bg-background px-2.5 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="none">{t("serial.parityNone")}</option>
<option value="even">{t("serial.parityEven")}</option>
<option value="odd">{t("serial.parityOdd")}</option>
</Select2>
</div>
</div>
<button
onClick={connect}
disabled={!path.trim()}
className="flex items-center justify-center gap-1.5 h-7 w-full border border-accent-brand/40 bg-accent-brand/10 text-accent-brand text-xs font-semibold hover:bg-accent-brand/20 transition-colors disabled:opacity-40 disabled:cursor-not-allowed mt-1"
>
<Usb className="size-3.5" />
{t("serial.connect")}
</button>
</div>
</div>
);
}
-603
View File
@@ -1,603 +0,0 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useTranslation } from "react-i18next";
import { copyToClipboard } from "@/lib/clipboard";
import { useAdaptivePolling } from "@/hooks/use-adaptive-polling";
import { Input } from "@/components/input";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/tooltip";
import {
ArrowLeft,
Check,
Copy,
Download,
Eye,
FileText,
Loader2,
Save,
ScrollText,
Search,
X,
} from "lucide-react";
import { toast } from "sonner";
import {
getSessionLogs,
getSessionLogContent,
getSessionLogBlob,
getSessionRecordingRetention,
setSessionRecordingRetention,
deleteSessionLog,
type SessionLogRecord,
} from "@/api/session-log-api";
import { SessionRecordingPlayer } from "@/features/session-recording/SessionRecordingPlayer";
import {
asciicastToPlainText,
parseAsciicast,
} from "@/features/session-recording/asciicast";
function formatDuration(seconds: number | null): string {
if (seconds == null) return "--";
if (seconds < 60) return `${seconds}s`;
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return s > 0 ? `${m}m ${s}s` : `${m}m`;
}
function formatBytes(bytes: number | null): string {
if (bytes == null || bytes === 0) return "--";
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function formatDate(iso: string): string {
const d = new Date(iso);
return d.toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
function buildFilename(log: SessionLogRecord): string {
const host = (log.hostName ?? log.hostIp ?? "session")
.replace(/[^a-zA-Z0-9._-]/g, "_")
.slice(0, 40);
const d = new Date(log.startedAt);
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
const h = String(d.getHours()).padStart(2, "0");
const min = String(d.getMinutes()).padStart(2, "0");
const s = String(d.getSeconds()).padStart(2, "0");
const extension =
log.format === "guacamole"
? "guac"
: log.format === "asciicast"
? "cast"
: "log";
return `${host}_${y}-${m}-${day}_${h}-${min}-${s}.${extension}`;
}
function buildTextFilename(log: SessionLogRecord): string {
return buildFilename(log).replace(/\.(cast|guac|log)$/, ".txt");
}
async function extractPlainText(
log: SessionLogRecord,
blob: Blob,
): Promise<string | null> {
if (log.format === "guacamole") return null;
const source = await blob.text();
if (log.format === "text") return source;
return asciicastToPlainText(parseAsciicast(source));
}
function SectionHeader({ label, count }: { label: string; count: number }) {
return (
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/20">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground/60 flex-1">
{label}
</span>
<span className="text-[10px] font-semibold text-muted-foreground/40 bg-muted/60 px-1.5 py-0.5">
{count}
</span>
</div>
);
}
function LogRow({
log,
onView,
onDownload,
onDownloadText,
onDelete,
}: {
log: SessionLogRecord;
onView: () => void;
onDownload: () => void;
onDownloadText: () => void;
onDelete: () => void;
}) {
const { t } = useTranslation();
const hostLabel = log.hostName ?? log.hostIp ?? `Host ${log.hostId}`;
return (
<div className="group flex items-center gap-2.5 px-3 py-2.5 border-b border-border/40 last:border-b-0 hover:bg-muted/40 transition-colors">
<div className="shrink-0 flex items-center justify-center size-7 bg-muted/60 text-muted-foreground">
<ScrollText className="size-3.5" />
</div>
<div className="flex flex-col flex-1 min-w-0 gap-0.5">
<span className="text-xs font-semibold truncate text-foreground">
{hostLabel}
</span>
<span className="text-[10px] text-muted-foreground/60 truncate">
{formatDate(log.startedAt)}
{" · "}
{(log.protocol ?? "ssh").toUpperCase()}
{log.username ? ` · ${log.username}` : ""}
{" · "}
{formatDuration(log.duration)}
{" · "}
{formatBytes(log.sizeBytes)}
</span>
</div>
<TooltipProvider>
<div className="shrink-0 flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={onView}
className="size-6 flex items-center justify-center text-muted-foreground/50 hover:text-foreground hover:bg-muted/60 transition-colors"
>
<Eye className="size-3" />
</button>
</TooltipTrigger>
<TooltipContent side="left">View log</TooltipContent>
</Tooltip>
{log.format === "asciicast" && (
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={onDownloadText}
className="size-6 flex items-center justify-center text-muted-foreground/50 hover:text-foreground hover:bg-muted/60 transition-colors"
>
<FileText className="size-3" />
</button>
</TooltipTrigger>
<TooltipContent side="left">
{t("sessionLogs.downloadAsText")}
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={onDownload}
className="size-6 flex items-center justify-center text-muted-foreground/50 hover:text-foreground hover:bg-muted/60 transition-colors"
>
<Download className="size-3" />
</button>
</TooltipTrigger>
<TooltipContent side="left">Download</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={onDelete}
className="size-6 flex items-center justify-center text-muted-foreground/50 hover:text-destructive hover:bg-destructive/10 transition-colors"
>
<X className="size-3" />
</button>
</TooltipTrigger>
<TooltipContent side="left">Delete</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
</div>
);
}
export function SessionLogsPanel() {
const { t } = useTranslation();
const [logs, setLogs] = useState<SessionLogRecord[]>([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState("");
const [viewLog, setViewLog] = useState<SessionLogRecord | null>(null);
const [viewContent, setViewContent] = useState<string>("");
const [viewBlob, setViewBlob] = useState<Blob | null>(null);
const [viewText, setViewText] = useState<string | null>(null);
const [viewLoading, setViewLoading] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<SessionLogRecord | null>(
null,
);
const [deleting, setDeleting] = useState(false);
const [copied, setCopied] = useState(false);
const [retentionDays, setRetentionDays] = useState<number | null>(null);
const [savingRetention, setSavingRetention] = useState(false);
const logsRef = useRef(logs);
logsRef.current = logs;
const load = useCallback(
async (initial = false) => {
if (initial) setLoading(true);
try {
const fresh = await getSessionLogs();
const changed =
logsRef.current.length !== fresh.length ||
logsRef.current.some((log, i) => log.id !== fresh[i]?.id);
if (changed) {
logsRef.current = fresh;
setLogs(fresh);
}
return changed;
} catch (error) {
if (initial) {
toast.error(t("sessionLogs.loadError"));
return false;
}
throw error;
} finally {
if (initial) setLoading(false);
}
},
[t],
);
useEffect(() => {
void load(true);
getSessionRecordingRetention()
.then(setRetentionDays)
.catch(() => setRetentionDays(null));
}, [load]);
useAdaptivePolling(
() => load(false),
{
minIntervalMs: 5_000,
maxIntervalMs: 30_000,
stablePollsPerStep: 3,
},
true,
{ runImmediately: false },
);
const q = filter.trim().toLowerCase();
const filtered = q
? logs.filter((l) =>
`${l.hostName ?? ""} ${l.hostIp ?? ""} ${l.username ?? ""} ${l.protocol}`
.toLowerCase()
.includes(q),
)
: logs;
const handleView = async (log: SessionLogRecord) => {
setViewLog(log);
setViewContent("");
setViewBlob(null);
setViewText(null);
setViewLoading(true);
try {
if (log.format === "text") {
const text = await getSessionLogContent(log.id);
setViewContent(text);
setViewText(text);
} else {
const blob = await getSessionLogBlob(log.id);
setViewBlob(blob);
setViewText(await extractPlainText(log, blob));
}
} catch {
toast.error(t("sessionLogs.loadError"));
} finally {
setViewLoading(false);
}
};
const downloadBlob = (blob: Blob, filename: string) => {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
};
const handleDownload = async (log: SessionLogRecord) => {
try {
downloadBlob(await getSessionLogBlob(log.id), buildFilename(log));
} catch {
toast.error(t("sessionLogs.loadError"));
}
};
const handleDownloadText = async (log: SessionLogRecord) => {
try {
const text =
log.id === viewLog?.id && viewText != null
? viewText
: await extractPlainText(log, await getSessionLogBlob(log.id));
if (text == null) {
toast.error(t("sessionLogs.loadError"));
return;
}
downloadBlob(
new Blob([text], { type: "text/plain" }),
buildTextFilename(log),
);
} catch {
toast.error(t("sessionLogs.loadError"));
}
};
const handleDelete = async () => {
if (!deleteTarget) return;
setDeleting(true);
try {
await deleteSessionLog(deleteTarget.id);
setLogs((prev) => prev.filter((l) => l.id !== deleteTarget.id));
setDeleteTarget(null);
} catch {
toast.error(t("sessionLogs.deleteError"));
} finally {
setDeleting(false);
}
};
const handleCopy = async () => {
if (!viewText) return;
const ok = await copyToClipboard(viewText);
if (ok) {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} else {
toast.error(t("common.copyFailed"));
}
};
if (loading) {
return (
<div className="flex items-center justify-center flex-1 p-8">
<Loader2 className="size-5 text-muted-foreground animate-spin" />
</div>
);
}
// Inline log viewer
if (viewLog) {
const hostLabel =
viewLog.hostName ?? viewLog.hostIp ?? `Host ${viewLog.hostId}`;
return (
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
{/* Viewer header */}
<div className="flex items-center gap-2 px-2 py-2 border-b border-border/60 bg-muted/20 shrink-0">
<button
onClick={() => setViewLog(null)}
className="size-6 flex items-center justify-center text-muted-foreground/50 hover:text-foreground hover:bg-muted/60 transition-colors"
>
<ArrowLeft className="size-3.5" />
</button>
<div className="flex flex-col flex-1 min-w-0">
<span className="text-xs font-semibold truncate text-foreground">
{hostLabel}
</span>
<span className="text-[10px] text-muted-foreground/50">
{formatDate(viewLog.startedAt)}
{` · ${viewLog.protocol.toUpperCase()}`}
{viewLog.duration != null
? ` · ${formatDuration(viewLog.duration)}`
: ""}
{" · "}
{formatBytes(viewLog.sizeBytes)}
</span>
</div>
<TooltipProvider>
<div className="flex items-center gap-0.5 shrink-0">
{viewText != null && (
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={handleCopy}
className="size-6 flex items-center justify-center text-muted-foreground/50 hover:text-foreground hover:bg-muted/60 transition-colors"
>
{copied ? (
<Check className="size-3 text-green-500" />
) : (
<Copy className="size-3" />
)}
</button>
</TooltipTrigger>
<TooltipContent side="left">
{copied
? t("sessionLogs.copied")
: t("sessionLogs.copyContent")}
</TooltipContent>
</Tooltip>
)}
{viewLog.format === "asciicast" && (
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => handleDownloadText(viewLog)}
className="size-6 flex items-center justify-center text-muted-foreground/50 hover:text-foreground hover:bg-muted/60 transition-colors"
>
<FileText className="size-3" />
</button>
</TooltipTrigger>
<TooltipContent side="left">
{t("sessionLogs.downloadAsText")}
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => handleDownload(viewLog)}
className="size-6 flex items-center justify-center text-muted-foreground/50 hover:text-foreground hover:bg-muted/60 transition-colors"
>
<Download className="size-3" />
</button>
</TooltipTrigger>
<TooltipContent side="left">
{t("sessionLogs.downloadLog")}
</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
</div>
{/* Log content */}
<div className="flex-1 min-h-0 overflow-y-auto">
{viewLoading ? (
<div className="flex items-center justify-center py-16">
<Loader2 className="size-4 animate-spin text-muted-foreground" />
</div>
) : viewBlob ? (
<SessionRecordingPlayer log={viewLog} blob={viewBlob} />
) : (
<pre className="p-3 text-[11px] font-mono whitespace-pre-wrap break-all text-foreground/80 leading-relaxed">
{viewContent || "(empty)"}
</pre>
)}
</div>
</div>
);
}
return (
<div className="flex flex-col flex-1 min-h-0">
<div className="flex-1 overflow-y-auto">
{retentionDays != null && (
<div className="flex items-center gap-2 border-b border-border/60 px-3 py-2 text-[10px] text-muted-foreground">
<span className="flex-1">Recording retention</span>
<Input
type="number"
min={1}
max={3650}
value={retentionDays}
onChange={(event) => setRetentionDays(Number(event.target.value))}
className="h-7 w-20 text-xs"
aria-label="Recording retention days"
/>
<span>days</span>
<button
type="button"
disabled={savingRetention}
onClick={async () => {
setSavingRetention(true);
try {
await setSessionRecordingRetention(retentionDays);
toast.success("Recording retention updated");
} catch {
toast.error("Failed to update recording retention");
} finally {
setSavingRetention(false);
}
}}
className="flex size-7 items-center justify-center hover:bg-muted disabled:opacity-50"
aria-label="Save recording retention"
>
{savingRetention ? (
<Loader2 className="size-3 animate-spin" />
) : (
<Save className="size-3" />
)}
</button>
</div>
)}
{logs.length === 0 ? (
<div className="flex flex-col items-center justify-center flex-1 gap-3 p-6 text-center py-16">
<div className="size-10 bg-muted/40 flex items-center justify-center">
<ScrollText className="size-5 text-muted-foreground/30" />
</div>
<div className="flex flex-col gap-1">
<span className="text-sm font-semibold text-muted-foreground/60">
{t("sessionLogs.noLogs")}
</span>
<span className="text-xs text-muted-foreground/40">
{t("sessionLogs.noLogsDesc")}
</span>
</div>
</div>
) : (
<>
<div className="relative px-3 py-2 border-b border-border/60">
<Search className="absolute left-5.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground/50 pointer-events-none" />
<Input
placeholder={t("sessionLogs.filterByHost")}
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="pl-8 h-7 text-xs"
/>
</div>
{filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-2 py-10 text-center">
<span className="text-xs text-muted-foreground/50">
No results for &quot;{filter}&quot;
</span>
</div>
) : (
<div className="flex flex-col">
<SectionHeader
label={t("sessionLogs.title")}
count={filtered.length}
/>
{filtered.map((log) => (
<LogRow
key={log.id}
log={log}
onView={() => handleView(log)}
onDownload={() => handleDownload(log)}
onDownloadText={() => handleDownloadText(log)}
onDelete={() => setDeleteTarget(log)}
/>
))}
</div>
)}
</>
)}
</div>
{/* Inline delete confirmation - positioned against the relative parent in AppShell */}
{deleteTarget && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm p-4">
<div className="bg-popover border border-border shadow-xl w-full max-w-xs flex flex-col gap-4 p-4">
<p className="text-sm text-foreground">
{t("sessionLogs.confirmDelete")}
</p>
<p className="text-xs text-muted-foreground">
{deleteTarget.hostName ??
deleteTarget.hostIp ??
`Session ${deleteTarget.id}`}
{" · "}
{formatDate(deleteTarget.startedAt)}
</p>
<div className="flex justify-end gap-2">
<button
onClick={() => setDeleteTarget(null)}
className="px-3 py-1.5 text-xs border border-border text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
>
{t("common.cancel")}
</button>
<button
onClick={handleDelete}
disabled={deleting}
className="px-3 py-1.5 text-xs bg-destructive text-destructive-foreground hover:bg-destructive/90 transition-colors flex items-center gap-1.5"
>
{deleting && <Loader2 className="size-3 animate-spin" />}
{t("sessionLogs.deleteLog")}
</button>
</div>
</div>
</div>
)}
</div>
);
}
File diff suppressed because it is too large. Load diff
-414
View File
@@ -1,414 +0,0 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/button";
import { Separator } from "@/components/separator";
import { LayoutPanelLeft, X, ChevronDown } from "lucide-react";
import { PANE_COUNTS, SPLIT_MODES } from "@/lib/theme";
import { tabJumpHotkeyKeys } from "@/lib/tab-jump-hotkey";
import { tabIcon } from "@/shell/tabUtils";
import type { Tab, SplitMode } from "@/types/ui-types";
const LAYOUT_PREVIEWS: Record<SplitMode, React.ReactNode> = {
none: <div className="size-full border-2 border-current" />,
"2-way": (
<div className="flex gap-0.5 size-full">
<div className="flex-1 border-2 border-current" />
<div className="flex-1 border-2 border-current" />
</div>
),
"2-way-horizontal": (
<div className="flex flex-col gap-0.5 size-full">
<div className="flex-1 border-2 border-current" />
<div className="flex-1 border-2 border-current" />
</div>
),
"3-way": (
<div className="flex gap-0.5 size-full">
<div className="flex-1 border-2 border-current" />
<div className="flex flex-col flex-1 gap-0.5">
<div className="flex-1 border-2 border-current" />
<div className="flex-1 border-2 border-current" />
</div>
</div>
),
"3-way-horizontal": (
<div className="flex flex-col gap-0.5 size-full">
<div className="flex gap-0.5 flex-1">
<div className="flex-1 border-2 border-current" />
<div className="flex-1 border-2 border-current" />
</div>
<div className="flex-1 border-2 border-current" />
</div>
),
"4-way": (
<div className="grid grid-cols-2 grid-rows-2 gap-0.5 size-full">
<div className="border-2 border-current" />
<div className="border-2 border-current" />
<div className="border-2 border-current" />
<div className="border-2 border-current" />
</div>
),
"5-way": (
<div className="flex flex-col gap-0.5 size-full">
<div className="flex gap-0.5 flex-1">
<div className="flex-1 border-2 border-current" />
<div className="flex-1 border-2 border-current" />
<div className="flex-1 border-2 border-current" />
</div>
<div className="flex gap-0.5 flex-1">
<div className="flex-1 border-2 border-current" />
<div className="flex-[2] border-2 border-current" />
</div>
</div>
),
"6-way": (
<div className="grid grid-cols-3 grid-rows-2 gap-0.5 size-full">
<div className="border-2 border-current" />
<div className="border-2 border-current" />
<div className="border-2 border-current" />
<div className="border-2 border-current" />
<div className="border-2 border-current" />
<div className="border-2 border-current" />
</div>
),
};
export function SplitScreenPanel({
tabs,
splitMode,
setSplitMode,
paneTabIds,
setPaneTabIds,
onAssignPane,
}: {
tabs: Tab[];
splitMode: SplitMode;
setSplitMode: (m: SplitMode) => void;
paneTabIds: (string | null)[];
setPaneTabIds: (ids: (string | null)[]) => void;
onAssignPane: (paneIndex: number, tabId: string) => void;
}) {
const { t } = useTranslation();
const paneCount = PANE_COUNTS[splitMode];
const [draggingTabId, setDraggingTabId] = useState<string | null>(null);
const [dragOverPane, setDragOverPane] = useState<number | null>(null);
const [quickAssignPane, setQuickAssignPane] = useState<number | null>(null);
function handleDrop(paneIndex: number) {
if (draggingTabId === null) return;
onAssignPane(paneIndex, draggingTabId);
setDraggingTabId(null);
setDragOverPane(null);
}
function clearPane(paneIndex: number) {
const next = [...paneTabIds];
next[paneIndex] = null;
setPaneTabIds(next);
}
function resetAll() {
setSplitMode("none");
setPaneTabIds(Array(6).fill(null));
}
const activeCount = paneTabIds
.slice(0, Math.max(paneCount, 0))
.filter(Boolean).length;
return (
<div className="flex flex-col gap-3 p-3">
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
{t("newUi.sidebar.splitScreen.layoutTitle")}
</span>
{splitMode !== "none" && (
<span className="text-xs border border-accent-brand/40 text-accent-brand px-1.5 py-0.5 leading-tight">
{splitMode}
</span>
)}
</div>
<div className="grid grid-cols-3 gap-2">
{SPLIT_MODES.map((mode) => (
<button
key={mode.id}
onClick={() => setSplitMode(mode.id)}
className={`flex flex-col items-center gap-1.5 p-2 border transition-colors ${
splitMode === mode.id
? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand"
: "border-border text-muted-foreground hover:text-foreground hover:border-muted-foreground/50"
}`}
>
<div
className={`w-10 h-7 ${splitMode === mode.id ? "text-accent-brand" : "text-muted-foreground/40"}`}
>
{LAYOUT_PREVIEWS[mode.id]}
</div>
<span className="text-xs font-semibold">{mode.label}</span>
</button>
))}
</div>
</div>
{splitMode === "none" ? (
<div className="flex flex-col items-center justify-center gap-2 py-6 text-center border border-dashed border-border">
<LayoutPanelLeft className="size-8 text-muted-foreground/30" />
<span className="text-sm text-muted-foreground">
{t("newUi.sidebar.splitScreen.selectLayoutAbove")}
</span>
<span className="text-xs text-muted-foreground/60">
{t("newUi.sidebar.splitScreen.selectLayoutHint")}
</span>
</div>
) : (
<>
<Separator />
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
{t("newUi.sidebar.splitScreen.panesTitle")}
</span>
<span className="text-xs text-muted-foreground">
{activeCount}/{paneCount} assigned
</span>
</div>
<div className="grid grid-cols-2 gap-1.5">
{Array.from({ length: paneCount }).map((_, i) => {
const assignedId = paneTabIds[i];
const assignedTab = assignedId
? tabs.find((t) => t.id === assignedId)
: null;
const isOver = dragOverPane === i;
const isQuickAssignOpen = quickAssignPane === i;
return (
<div key={i} className="flex flex-col gap-0.5">
<div
onDragOver={(e) => {
e.preventDefault();
setDragOverPane(i);
}}
onDragLeave={() => setDragOverPane(null)}
onDrop={() => handleDrop(i)}
className={`relative flex flex-col items-center justify-center gap-1 p-2 min-h-[52px] border transition-colors ${
isOver
? "border-accent-brand bg-accent-brand/10"
: assignedTab
? "border-accent-brand/30 bg-accent-brand/5"
: "border-dashed border-border/60 bg-muted/5 hover:border-border hover:bg-muted/10"
}`}
>
<span className="absolute top-1 left-1.5 text-[10px] text-muted-foreground/40 font-mono leading-none">
{i + 1}
</span>
{assignedTab ? (
<>
<div className="flex items-center gap-1 px-1 w-full justify-center">
<span className="text-accent-brand shrink-0">
{tabIcon(assignedTab.type)}
</span>
<span className="text-xs font-semibold truncate max-w-[70px] text-foreground">
{assignedTab.type === "dashboard"
? t("newUi.sidebar.splitScreen.dashboard")
: assignedTab.label}
</span>
</div>
<button
onClick={() => clearPane(i)}
className="absolute top-0.5 right-0.5 size-4 flex items-center justify-center text-muted-foreground hover:text-foreground"
>
<X className="size-2.5" />
</button>
</>
) : (
<span className="text-xs text-muted-foreground/40">
{isOver
? t("newUi.sidebar.splitScreen.dropHere")
: t("newUi.sidebar.splitScreen.emptyPane")}
</span>
)}
</div>
{/* Quick assign button */}
{!assignedTab && (
<div className="relative">
<button
onClick={() =>
setQuickAssignPane(isQuickAssignOpen ? null : i)
}
className="flex items-center justify-center gap-1 w-full py-0.5 text-[10px] text-muted-foreground/60 hover:text-accent-brand hover:bg-accent-brand/5 border border-dashed border-border/40 hover:border-accent-brand/30 transition-colors"
>
{t("newUi.sidebar.splitScreen.quickAssign")}
<ChevronDown
className={`size-2.5 transition-transform ${isQuickAssignOpen ? "rotate-180" : ""}`}
/>
</button>
{isQuickAssignOpen && (
<div className="absolute top-full left-0 right-0 z-20 bg-popover border border-border shadow-md mt-0.5 max-h-40 overflow-y-auto">
{tabs
.filter(
(tab) =>
tab.type !== "dashboard" &&
!paneTabIds.includes(tab.id),
)
.map((tab) => (
<button
key={tab.id}
onClick={() => {
onAssignPane(i, tab.id);
setQuickAssignPane(null);
}}
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs text-left hover:bg-accent hover:text-accent-foreground"
>
<span className="text-muted-foreground shrink-0">
{tabIcon(tab.type)}
</span>
<span className="truncate">{tab.label}</span>
</button>
))}
{tabs.filter(
(tab) =>
tab.type !== "dashboard" &&
!paneTabIds.includes(tab.id),
).length === 0 && (
<div className="px-2 py-2 text-xs text-muted-foreground/60">
All tabs assigned
</div>
)}
</div>
)}
</div>
)}
</div>
);
})}
</div>
</div>
<Separator />
<div className="flex flex-col gap-2">
<span className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
{t("newUi.sidebar.splitScreen.openTabsTitle")}
</span>
<span className="text-xs text-muted-foreground/60">
{t("newUi.sidebar.splitScreen.dragTabsHint")}
</span>
<div className="flex flex-col gap-1 mt-0.5">
{tabs.map((tab) => {
const paneIdx = paneTabIds.indexOf(tab.id);
const isAssigned = paneIdx !== -1;
return (
<div
key={tab.id}
draggable={!isAssigned}
onDragStart={() => !isAssigned && setDraggingTabId(tab.id)}
onDragEnd={() => {
setDraggingTabId(null);
setDragOverPane(null);
}}
className={`flex items-center gap-2 px-2.5 py-2 border select-none transition-colors ${
isAssigned
? "border-accent-brand/20 bg-accent-brand/5 opacity-60 cursor-default"
: draggingTabId === tab.id
? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand cursor-grabbing"
: "border-border hover:border-muted-foreground/40 hover:bg-muted/30 cursor-grab active:cursor-grabbing"
}`}
>
<span className="text-muted-foreground shrink-0">
{tabIcon(tab.type)}
</span>
<span className="text-xs font-medium flex-1 truncate">
{tab.type === "dashboard"
? t("newUi.sidebar.splitScreen.dashboard")
: tab.label}
</span>
{isAssigned ? (
<span className="text-[10px] text-accent-brand/60 font-mono shrink-0">
{t("newUi.sidebar.splitScreen.alreadyAssigned", {
index: paneIdx + 1,
})}
</span>
) : (
<div className="grid grid-cols-2 gap-px opacity-30 shrink-0">
<div className="size-1 bg-muted-foreground rounded-full" />
<div className="size-1 bg-muted-foreground rounded-full" />
<div className="size-1 bg-muted-foreground rounded-full" />
<div className="size-1 bg-muted-foreground rounded-full" />
</div>
)}
</div>
);
})}
</div>
</div>
<Button
variant="outline"
size="sm"
className="w-full text-xs text-muted-foreground"
onClick={resetAll}
>
<X className="size-3" />
{t("newUi.sidebar.splitScreen.clearSplitScreen")}
</Button>
</>
)}
<Separator />
<div className="flex flex-col gap-2">
<span className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
{t("newUi.sidebar.splitScreen.hotkeysTitle")}
</span>
<div className="flex flex-col gap-1">
{[
{
keys: ["Ctrl", "Shift", "\\"],
label: t("newUi.sidebar.splitScreen.hotkeysSplitRight"),
},
{
keys: ["Ctrl", "Shift", "-"],
label: t("newUi.sidebar.splitScreen.hotkeysSplitBelow"),
},
{
keys: ["Alt", "↑↓←→"],
label: t("newUi.sidebar.splitScreen.hotkeysNavigatePane"),
},
{
keys: ["Ctrl", "Shift", "]"],
label: t("newUi.sidebar.splitScreen.hotkeysNextTab"),
},
{
keys: ["Ctrl", "Shift", "["],
label: t("newUi.sidebar.splitScreen.hotkeysPrevTab"),
},
{
keys: tabJumpHotkeyKeys(),
label: t("newUi.sidebar.splitScreen.hotkeysJumpTab"),
},
].map(({ keys, label }) => (
<div
key={label}
className="flex items-center justify-between gap-2"
>
<span className="text-xs text-muted-foreground/70 flex-1 min-w-0 truncate">
{label}
</span>
<div className="flex items-center gap-0.5 shrink-0">
{keys.map((k, i) => (
<kbd
key={i}
className="inline-flex items-center px-1 py-0.5 text-[10px] font-mono bg-muted border border-border/60 text-muted-foreground leading-none"
>
{k}
</kbd>
))}
</div>
</div>
))}
</div>
</div>
</div>
);
}
+82
View File
@@ -0,0 +1,82 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import type { PluginSettingsField } from "@termix/plugin-sdk/manifest";
import { SettingsFieldRow } from "@/settings/SettingsFields";
import {
getPluginHostSettings,
updatePluginHostSettings,
PluginSettingsValidationError,
} from "@/api/plugins-api";
/**
* Fields an SSH auth type declares, for a plugin that did not register its
* own editor. They are the plugin's host settings (so the plugin reads them
* with ctx.settings.getHost), which means a host must be saved first.
*/
export function SshAuthProviderFields({
pluginId,
hostId,
fields,
}: {
pluginId: string;
hostId: number | null | undefined;
fields: PluginSettingsField[];
}) {
const { t } = useTranslation();
const [values, setValues] = useState<Record<string, unknown>>({});
const [errors, setErrors] = useState<Record<string, string>>({});
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (!hostId) return;
let cancelled = false;
getPluginHostSettings(pluginId, hostId)
.then((loaded) => {
if (!cancelled) setValues(loaded);
})
.catch(() => {});
return () => {
cancelled = true;
if (saveTimer.current) clearTimeout(saveTimer.current);
};
}, [pluginId, hostId]);
if (!hostId) {
return (
<p className="text-[10px] text-muted-foreground">
{t("hosts.authFieldsAfterSave")}
</p>
);
}
const setValue = (key: string, value: unknown) => {
const next = { ...values, [key]: value };
setValues(next);
if (saveTimer.current) clearTimeout(saveTimer.current);
saveTimer.current = setTimeout(() => {
updatePluginHostSettings(pluginId, hostId, { [key]: value })
.then(() => setErrors((prev) => ({ ...prev, [key]: "" })))
.catch((error) => {
if (error instanceof PluginSettingsValidationError) {
setErrors((prev) => ({ ...prev, ...error.errors }));
}
});
}, 400);
};
return (
<div className="flex flex-col gap-3 border-t border-border pt-3">
{fields.map((field) => (
<SettingsFieldRow
key={field.key}
pluginId={pluginId}
field={field}
values={values}
setValue={setValue}
running
error={errors[field.key] || undefined}
/>
))}
</div>
);
}
-379
View File
@@ -1,379 +0,0 @@
import { useState, useRef, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/button";
import { Separator } from "@/components/separator";
import { KeyRound, Terminal } from "lucide-react";
import type { Tab } from "@/types/ui-types";
import { getCookie, getHostPassword, setCookie } from "@/main-axios";
import { toast } from "sonner";
export function SshToolsPanel({
terminalTabs,
activeTabId,
}: {
terminalTabs: Tab[];
activeTabId: string;
}) {
const { t } = useTranslation();
const [keyRecording, setKeyRecording] = useState(false);
const [rightClickPaste, setRightClickPaste] = useState(
() => getCookie("rightClickCopyPaste") !== "false",
);
const [copyOnSelect, setCopyOnSelect] = useState(
() => getCookie("copyOnSelect") === "true",
);
const [selectedTabIds, setSelectedTabIds] = useState<Set<string>>(
() =>
new Set(
activeTabId && terminalTabs.some((t) => t.id === activeTabId)
? [activeTabId]
: [],
),
);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (keyRecording) inputRef.current?.focus();
}, [keyRecording]);
function toggleTab(id: string) {
setSelectedTabIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}
function selectAll() {
setSelectedTabIds(new Set(terminalTabs.map((t) => t.id)));
}
function deselectAll() {
setSelectedTabIds(new Set());
}
function broadcast(data: string) {
for (const tabId of selectedTabIds) {
const tab = terminalTabs.find((t) => t.id === tabId);
tab?.terminalRef?.current?.sendInput?.(data);
}
}
async function fillPassword() {
let filled = 0;
let missing = 0;
for (const tabId of selectedTabIds) {
const tab = terminalTabs.find((t) => t.id === tabId);
const hostId = tab?.host?.id ? Number(tab.host.id) : null;
const ref = tab?.terminalRef?.current;
if (!hostId || !ref?.sendInput) {
missing++;
continue;
}
const password = await getHostPassword(hostId, "password");
if (!password) {
missing++;
continue;
}
ref.sendInput(password + "\r");
filled++;
}
if (filled > 0) {
toast.success(
t("newUi.sidebar.sshTools.fillPasswordSuccess", { count: filled }),
);
}
if (missing > 0) {
toast.error(
t("newUi.sidebar.sshTools.fillPasswordMissing", { count: missing }),
);
}
}
function broadcastArrow(normalSeq: string, appSeq: string) {
for (const tabId of selectedTabIds) {
const tab = terminalTabs.find((t) => t.id === tabId);
const ref = tab?.terminalRef?.current;
if (!ref) continue;
const appMode = ref.getApplicationCursorKeysMode?.() ?? false;
ref.sendInput?.(appMode ? appSeq : normalSeq);
}
}
function handlePaste(e: React.ClipboardEvent<HTMLInputElement>) {
e.preventDefault();
e.stopPropagation();
const text = e.clipboardData.getData("text");
if (text) broadcast(text);
}
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
const ctrl = e.ctrlKey;
const { key } = e;
// Let Ctrl/Cmd+V fall through so the browser fires a paste event
// instead of being swallowed by the preventDefault() below.
if ((ctrl || e.metaKey) && key.toLowerCase() === "v") {
return;
}
e.preventDefault();
e.stopPropagation();
if (ctrl) {
const ctrlMap: Record<string, string> = {
c: "\x03",
d: "\x04",
l: "\x0C",
u: "\x15",
k: "\x0B",
a: "\x01",
e: "\x05",
w: "\x17",
z: "\x1A",
r: "\x12",
};
const seq = ctrlMap[key.toLowerCase()];
if (seq) {
broadcast(seq);
return;
}
}
if (key === "ArrowUp") {
broadcastArrow("\x1B[A", "\x1BOA");
return;
}
if (key === "ArrowDown") {
broadcastArrow("\x1B[B", "\x1BOB");
return;
}
if (key === "ArrowRight") {
broadcastArrow("\x1B[C", "\x1BOC");
return;
}
if (key === "ArrowLeft") {
broadcastArrow("\x1B[D", "\x1BOD");
return;
}
const specialMap: Record<string, string> = {
Enter: "\r",
Backspace: "\x7F",
Delete: "\x1B[3~",
Tab: "\t",
Escape: "\x1B",
Home: "\x1B[H",
End: "\x1B[F",
PageUp: "\x1B[5~",
PageDown: "\x1B[6~",
Insert: "\x1B[2~",
F1: "\x1BOP",
F2: "\x1BOQ",
F3: "\x1BOR",
F4: "\x1BOS",
F5: "\x1B[15~",
F6: "\x1B[17~",
F7: "\x1B[18~",
F8: "\x1B[19~",
F9: "\x1B[20~",
F10: "\x1B[21~",
F11: "\x1B[23~",
F12: "\x1B[24~",
};
const seq = specialMap[key];
if (seq) {
broadcast(seq);
return;
}
if (!ctrl && !e.altKey && !e.metaKey && key.length === 1) {
broadcast(key);
}
}
function toggleRecording() {
const next = !keyRecording;
if (!next) {
// clear the phantom text when stopping
if (inputRef.current) inputRef.current.value = "";
}
setKeyRecording(next);
}
return (
<div className="flex flex-col gap-3 p-3">
<div className="flex flex-col gap-2">
<span className="text-xs font-bold uppercase tracking-widest">
{t("newUi.sidebar.sshTools.keyRecordingTitle")}
</span>
{/* Terminal selector */}
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">
{t("newUi.sidebar.sshTools.recordToTerminals")}
</span>
<div className="flex items-center gap-2">
<button
onClick={selectAll}
className="text-[10px] text-accent-brand hover:text-accent-brand/70"
>
{t("newUi.sidebar.sshTools.selectAll")}
</button>
<button
onClick={deselectAll}
className="text-[10px] text-accent-brand hover:text-accent-brand/70"
>
{t("newUi.sidebar.sshTools.selectNone")}
</button>
</div>
</div>
{terminalTabs.length === 0 ? (
<div className="flex items-center gap-1.5 px-2.5 py-2 border border-dashed border-border/60 text-muted-foreground/40">
<Terminal className="size-3 shrink-0" />
<span className="text-xs">
{t("newUi.sidebar.sshTools.noTerminalTabsOpen")}
</span>
</div>
) : (
<div className="flex flex-col gap-1">
{terminalTabs.map((tab) => {
const selected = selectedTabIds.has(tab.id);
return (
<button
key={tab.id}
onClick={() => toggleTab(tab.id)}
className={`flex items-center gap-2 px-2.5 py-1.5 border text-left transition-colors ${
selected
? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand"
: "border-border text-muted-foreground hover:text-foreground hover:border-muted-foreground/40"
}`}
>
<div
className={`size-3 border-2 flex items-center justify-center shrink-0 transition-colors ${
selected
? "border-accent-brand bg-accent-brand"
: "border-border/60"
}`}
>
{selected && <div className="size-1.5 bg-background" />}
</div>
<Terminal className="size-3 shrink-0 opacity-60" />
<span className="text-xs font-medium truncate flex-1">
{tab.label}
</span>
</button>
);
})}
</div>
)}
</div>
<Button
variant="outline"
disabled={selectedTabIds.size === 0}
className={`w-full ${keyRecording ? "border-accent-brand/40 text-accent-brand bg-accent-brand/10 hover:bg-accent-brand/20 hover:text-accent-brand" : ""}`}
onClick={toggleRecording}
>
{keyRecording
? `${t("newUi.sidebar.sshTools.stopRecording")} (${selectedTabIds.size})`
: selectedTabIds.size === 0
? t("newUi.sidebar.sshTools.selectTerminalsAbove")
: `${t("newUi.sidebar.sshTools.startRecording")} (${selectedTabIds.size})`}
</Button>
{keyRecording && (
<input
ref={inputRef}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
onChange={(e) => {
// Keystrokes are broadcast directly via handleKeyDown; the
// field itself must stay empty. This also catches paste
// insertion on browsers that fire "input" before we can
// intercept it in onPaste.
e.target.value = "";
}}
placeholder={t("newUi.sidebar.sshTools.broadcastInputPlaceholder")}
className="w-full px-2.5 py-2 text-xs bg-background border border-accent-brand/40 text-foreground placeholder:text-muted-foreground/40 outline-none focus:border-accent-brand/70 caret-transparent"
/>
)}
<Button
variant="outline"
disabled={selectedTabIds.size === 0}
className="w-full"
onClick={() => {
void fillPassword();
}}
>
<KeyRound className="size-3.5 mr-2" />
{selectedTabIds.size === 0
? t("newUi.sidebar.sshTools.selectTerminalsAbove")
: `${t("newUi.sidebar.sshTools.fillPassword")} (${selectedTabIds.size})`}
</Button>
</div>
<Separator />
<div className="flex flex-col gap-2">
<span className="text-xs font-bold uppercase tracking-widest">
{t("newUi.sidebar.sshTools.settingsTitle")}
</span>
<div className="flex items-center justify-between gap-4">
<span className="text-sm text-muted-foreground">
{t("newUi.sidebar.sshTools.enableRightClickCopyPaste")}
</span>
<button
onClick={() => {
const next = !rightClickPaste;
setRightClickPaste(next);
setCookie("rightClickCopyPaste", next ? "true" : "false");
}}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center border-2 transition-colors ${
rightClickPaste
? "bg-accent-brand border-accent-brand"
: "bg-muted border-border"
}`}
>
<span
className={`pointer-events-none inline-block h-3 w-3 bg-background shadow-sm transition-transform ${rightClickPaste ? "translate-x-4" : "translate-x-0.5"}`}
/>
</button>
</div>
<div className="flex items-center justify-between gap-4">
<span className="text-sm text-muted-foreground">
{t("newUi.sidebar.sshTools.copyOnSelect")}
</span>
<button
onClick={() => {
const next = !copyOnSelect;
setCopyOnSelect(next);
setCookie("copyOnSelect", next ? "true" : "false");
}}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center border-2 transition-colors ${
copyOnSelect
? "bg-accent-brand border-accent-brand"
: "bg-muted border-border"
}`}
>
<span
className={`pointer-events-none inline-block h-3 w-3 bg-background shadow-sm transition-transform ${copyOnSelect ? "translate-x-4" : "translate-x-0.5"}`}
/>
</button>
</div>
</div>
</div>
);
}
-946
View File
@@ -1,946 +0,0 @@
import { getErrorMessage } from "../lib/error-message.js";
import { useEffect, useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
import {
Check,
Copy,
Fingerprint,
KeyRound,
Loader2,
Plus,
RefreshCw,
ScrollText,
ShieldCheck,
Sparkles,
Trash2,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { Textarea } from "@/components/textarea";
import { SectionCard, SettingRow, FakeSwitch } from "@/components/section-card";
import {
getMyTermixId,
checkTermixIdHandle,
createTermixId,
deleteTermixId,
addTermixIdKey,
generateTermixIdKey,
setTermixIdKeyEnabled,
deleteTermixIdKey,
getCredentials,
getMyCa,
createCa,
rotateCa,
deleteCa,
issueCertificate,
getLinkedCredentialIds,
type TermixIdentity,
type TermixIdentityKey,
type TermixIdCa,
} from "@/main-axios";
import { Select2 } from "@/components/select2";
const accentBtn =
"border-accent-brand/40 text-accent-brand hover:bg-accent-brand/20 hover:text-accent-brand";
function resolverUrl(handle: string): string {
const origin =
typeof window !== "undefined"
? window.location.origin
: "https://your-termix";
return `${origin}/termix-id/u/${handle}`;
}
function CopyRow({ label, value }: { label: string; value: string }) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
async function copy() {
try {
await navigator.clipboard.writeText(value);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
toast.error(t("termixId.copyFailed"));
}
}
return (
<div className="flex flex-col gap-1.5 py-2.5 border-b border-border last:border-0">
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{label}
</span>
<div className="flex items-stretch gap-2">
<code className="flex-1 min-w-0 text-[11px] font-mono bg-muted/30 border border-border px-2 py-1.5 overflow-x-auto whitespace-nowrap text-muted-foreground">
{value}
</code>
<button
onClick={copy}
className="shrink-0 w-7 flex items-center justify-center border border-border bg-background hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
>
{copied ? (
<Check className="size-3.5 text-accent-brand" />
) : (
<Copy className="size-3.5" />
)}
</button>
</div>
</div>
);
}
export function TermixIdPanel() {
const { t } = useTranslation();
const [loading, setLoading] = useState(true);
const [identity, setIdentity] = useState<TermixIdentity | null>(null);
const [keys, setKeys] = useState<TermixIdentityKey[]>([]);
const [ca, setCa] = useState<TermixIdCa | null>(null);
const [linkedCredentialIds, setLinkedCredentialIds] = useState<Set<number>>(
new Set(),
);
const refresh = useCallback(async () => {
try {
const [data, caData, linkedData] = await Promise.all([
getMyTermixId(),
getMyCa().catch(() => ({ ca: null })),
getLinkedCredentialIds().catch(() => ({ credentialIds: [] })),
]);
setIdentity(data.identity);
setKeys(data.keys);
setCa(data.identity ? caData.ca : null);
setLinkedCredentialIds(new Set(linkedData.credentialIds));
} catch {
toast.error(t("termixId.loadFailed"));
} finally {
setLoading(false);
}
}, [t]);
useEffect(() => {
refresh();
}, [refresh]);
if (loading) {
return (
<div className="flex items-center justify-center h-40 text-muted-foreground">
<Loader2 className="animate-spin size-4" />
</div>
);
}
return (
<div className="flex-1 min-h-0 overflow-y-auto scrollbar-none">
<div className="flex flex-col gap-3 p-3">
{!identity ? (
<ClaimHandle onCreated={refresh} />
) : (
<>
<IdentityCard identity={identity} onChanged={refresh} />
<AddKey
handle={identity.handle}
linkedCredentialIds={linkedCredentialIds}
onAdded={refresh}
/>
<KeyList
keys={keys}
handle={identity.handle}
caEnabled={!!ca}
onChanged={refresh}
/>
<CaCard handle={identity.handle} ca={ca} onChanged={refresh} />
</>
)}
</div>
</div>
);
}
function ClaimHandle({ onCreated }: { onCreated: () => void }) {
const { t } = useTranslation();
const [handle, setHandle] = useState("");
const [description, setDescription] = useState("");
const [checking, setChecking] = useState(false);
const [status, setStatus] = useState<
"idle" | "available" | "taken" | "invalid"
>("idle");
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
const h = handle.trim().toLowerCase();
if (!h) {
setStatus("idle");
return;
}
// Guard against an out-of-order response: if the input changed (effect
// cleanup ran) before this request resolved, ignore its result.
let cancelled = false;
setChecking(true);
const timer = setTimeout(async () => {
try {
const res = await checkTermixIdHandle(h);
if (cancelled) return;
setStatus(
!res.valid ? "invalid" : res.available ? "available" : "taken",
);
} catch {
if (!cancelled) setStatus("idle");
} finally {
if (!cancelled) setChecking(false);
}
}, 350);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [handle]);
async function submit() {
setSubmitting(true);
try {
await createTermixId(
handle.trim().toLowerCase(),
description.trim() || undefined,
);
toast.success(t("termixId.created"));
onCreated();
} catch (e) {
toast.error(getErrorMessage(e, t("termixId.createFailed")));
} finally {
setSubmitting(false);
}
}
return (
<SectionCard
title={t("termixId.title")}
icon={<Fingerprint className="size-3.5" />}
action={
<a
href="https://docs.termix.site/features/authentication/termix-id"
target="_blank"
rel="noreferrer"
className="text-[10px] text-accent-brand hover:underline"
>
{t("hosts.docsLink")}
</a>
}
>
<div className="flex flex-col gap-3 py-3">
<p className="text-xs text-muted-foreground leading-snug">
{t("termixId.claimIntro")}
</p>
<div className="flex flex-col gap-1.5">
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("termixId.handleLabel")}
</span>
<div className="relative flex items-center">
<span className="absolute left-2 text-muted-foreground text-xs pointer-events-none select-none">
@
</span>
<Input
value={handle}
placeholder={t("termixId.handlePlaceholder")}
autoCapitalize="none"
spellCheck={false}
className="h-8 text-xs pl-5"
onChange={(e) => setHandle(e.target.value)}
/>
</div>
<span className="text-[11px] h-4 leading-4">
{checking ? (
<span className="text-muted-foreground">
{t("termixId.checking")}
</span>
) : status === "available" ? (
<span className="text-accent-brand">
{t("termixId.available")}
</span>
) : status === "taken" ? (
<span className="text-destructive">{t("termixId.taken")}</span>
) : status === "invalid" ? (
<span className="text-destructive">
{t("termixId.invalidHandle")}
</span>
) : null}
</span>
</div>
<div className="flex flex-col gap-1.5">
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("termixId.descriptionLabel")}
</span>
<Input
value={description}
placeholder={t("termixId.descriptionPlaceholder")}
className="h-8 text-xs"
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<Button
variant="outline"
size="sm"
className={`self-start ${accentBtn}`}
disabled={status !== "available" || submitting}
onClick={submit}
>
{submitting ? (
<Loader2 className="animate-spin size-3.5" />
) : (
t("termixId.create")
)}
</Button>
</div>
</SectionCard>
);
}
function IdentityCard({
identity,
onChanged,
}: {
identity: TermixIdentity;
onChanged: () => void;
}) {
const { t } = useTranslation();
const [confirming, setConfirming] = useState(false);
const [busy, setBusy] = useState(false);
const url = resolverUrl(identity.handle);
const curl = `curl -fsSL ${url} >> ~/.ssh/authorized_keys`;
async function remove() {
setBusy(true);
try {
await deleteTermixId();
toast.success(t("termixId.deleted"));
onChanged();
} catch (e) {
toast.error(getErrorMessage(e, t("termixId.deleteFailed")));
} finally {
setBusy(false);
setConfirming(false);
}
}
return (
<SectionCard
title={t("termixId.title")}
icon={<Fingerprint className="size-3.5" />}
action={
confirming ? (
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
className="border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={remove}
disabled={busy}
>
{busy ? (
<Loader2 className="animate-spin size-3.5" />
) : (
t("common.confirm")
)}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setConfirming(false)}
>
{t("nav.cancel")}
</Button>
</div>
) : (
<div className="flex items-center gap-1">
<a
href="https://docs.termix.site/features/authentication/termix-id"
target="_blank"
rel="noreferrer"
className="text-[10px] text-accent-brand hover:underline px-1"
>
{t("hosts.docsLink")}
</a>
<Button
variant="ghost"
size="icon-sm"
onClick={() => setConfirming(true)}
className="hover:text-destructive"
>
<Trash2 className="size-3.5" />
</Button>
</div>
)
}
>
{confirming && (
<div className="py-2 border-b border-border">
<p className="text-xs text-muted-foreground">
{t("termixId.deleteConfirm")}
</p>
</div>
)}
<div className="flex items-center gap-2 py-2.5 border-b border-border">
<span className="text-sm font-semibold text-accent-brand">
@{identity.handle}
</span>
</div>
<CopyRow label={t("termixId.resolverUrlLabel")} value={url} />
<CopyRow label={t("termixId.provisionLabel")} value={curl} />
</SectionCard>
);
}
interface CredentialOption {
id: number;
name: string;
}
function downloadText(filename: string, text: string) {
// octet-stream so the browser/OS keeps the given extension instead of
// appending .txt to a text/plain download.
const blob = new Blob([text], { type: "application/octet-stream" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
function CredentialImportRow({
credentials,
linkedCredentialIds,
submitting,
onImport,
}: {
credentials: CredentialOption[];
linkedCredentialIds: Set<number>;
submitting: boolean;
onImport: (id: number) => void;
}) {
const { t } = useTranslation();
const [selected, setSelected] = useState<string>("");
const selectedId = selected ? Number(selected) : null;
const alreadyLinked =
selectedId !== null && linkedCredentialIds.has(selectedId);
return (
<div className="flex items-center gap-2">
<Select2
value={selected}
onChange={(e) => setSelected(e.target.value)}
disabled={submitting}
className="flex-1 h-8 min-w-0 border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring disabled:opacity-50 disabled:cursor-not-allowed"
>
<option value="" disabled>
{t("termixId.selectCredential")}
</option>
{credentials.map((c) => (
<option key={c.id} value={String(c.id)}>
{linkedCredentialIds.has(c.id) ? `✓ ${c.name}` : c.name}
</option>
))}
</Select2>
<Button
variant="outline"
size="default"
className={`h-8 shrink-0 ${alreadyLinked ? "border-accent-brand/20 text-accent-brand/50" : accentBtn}`}
disabled={!selectedId || submitting || alreadyLinked}
onClick={() => selectedId !== null && onImport(selectedId)}
>
{alreadyLinked ? <Check className="size-3.5" /> : t("termixId.import")}
</Button>
</div>
);
}
function AddKey({
handle,
linkedCredentialIds,
onAdded,
}: {
handle: string;
linkedCredentialIds: Set<number>;
onAdded: () => void;
}) {
const { t } = useTranslation();
const [publicKey, setPublicKey] = useState("");
const [label, setLabel] = useState("");
const [submitting, setSubmitting] = useState(false);
const [generating, setGenerating] = useState(false);
const [saveToVault, setSaveToVault] = useState(true);
const [credentials, setCredentials] = useState<CredentialOption[]>([]);
const loadCredentials = useCallback(async () => {
try {
const data = await getCredentials();
const list = Array.isArray(data) ? data : [];
setCredentials(
list
.filter((c) => c.authType === "key")
.map((c) => ({ id: Number(c.id), name: String(c.name) })),
);
} catch {
// non-fatal — manual paste still works
}
}, []);
useEffect(() => {
loadCredentials();
}, [loadCredentials]);
async function addManual() {
if (!publicKey.trim()) return;
setSubmitting(true);
try {
await addTermixIdKey({
publicKey: publicKey.trim(),
label: label.trim() || undefined,
});
toast.success(t("termixId.keyPublished"));
setPublicKey("");
setLabel("");
onAdded();
} catch (e) {
toast.error(getErrorMessage(e, t("termixId.addKeyFailed")));
} finally {
setSubmitting(false);
}
}
async function generate() {
setGenerating(true);
try {
const result = await generateTermixIdKey("ed25519", saveToVault);
downloadText(`termix-${handle}-ed25519.key`, result.privateKey);
toast.success(
saveToVault
? t("termixId.generatedSaved")
: t("termixId.generatedOnly"),
);
if (saveToVault) loadCredentials();
onAdded();
} catch (e) {
toast.error(getErrorMessage(e, t("termixId.generateFailed")));
} finally {
setGenerating(false);
}
}
async function importCredential(id: number) {
setSubmitting(true);
try {
await addTermixIdKey({ credentialId: id });
toast.success(t("termixId.imported"));
onAdded();
} catch (e) {
toast.error(getErrorMessage(e, t("termixId.importFailed")));
} finally {
setSubmitting(false);
}
}
return (
<SectionCard
title={t("termixId.publishTitle")}
icon={<Plus className="size-3.5" />}
action={
<Button
variant="outline"
size="sm"
className={accentBtn}
onClick={generate}
disabled={generating}
title={t("termixId.generateTooltip")}
>
{generating ? (
<Loader2 className="animate-spin size-3.5" />
) : (
<>
<Sparkles className="size-3.5" />
{t("termixId.generate")}
</>
)}
</Button>
}
>
<div className="flex flex-col gap-3 py-3">
<SettingRow label={t("termixId.saveToVault")}>
<FakeSwitch checked={saveToVault} onChange={setSaveToVault} />
</SettingRow>
<Textarea
value={publicKey}
onChange={(e) => setPublicKey(e.target.value)}
placeholder={t("termixId.keyPlaceholder")}
rows={3}
className="rounded-none font-mono text-[11px]"
spellCheck={false}
/>
<div className="flex items-center gap-2">
<Input
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder={t("termixId.labelPlaceholder")}
className="h-8 text-xs flex-1"
/>
<Button
variant="outline"
size="default"
className={`h-8 ${accentBtn}`}
onClick={addManual}
disabled={!publicKey.trim() || submitting}
>
{submitting ? (
<Loader2 className="animate-spin size-3.5" />
) : (
t("termixId.add")
)}
</Button>
</div>
{credentials.length > 0 && (
<div className="flex flex-col gap-1.5 border-t border-border pt-3">
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("termixId.importFromCredential")}
</span>
<CredentialImportRow
credentials={credentials}
linkedCredentialIds={linkedCredentialIds}
submitting={submitting}
onImport={importCredential}
/>
</div>
)}
</div>
</SectionCard>
);
}
function KeyList({
keys,
handle,
caEnabled,
onChanged,
}: {
keys: TermixIdentityKey[];
handle: string;
caEnabled: boolean;
onChanged: () => void;
}) {
const { t } = useTranslation();
const [issuingId, setIssuingId] = useState<number | null>(null);
const [principalsByKey, setPrincipalsByKey] = useState<
Record<number, string>
>({});
async function toggle(k: TermixIdentityKey) {
try {
await setTermixIdKeyEnabled(k.id, !k.enabled);
onChanged();
} catch (e) {
toast.error(getErrorMessage(e, t("termixId.updateKeyFailed")));
}
}
async function remove(k: TermixIdentityKey) {
try {
await deleteTermixIdKey(k.id);
toast.success(t("termixId.keyRemoved"));
onChanged();
} catch (e) {
toast.error(getErrorMessage(e, t("termixId.removeKeyFailed")));
}
}
async function issueCert(k: TermixIdentityKey) {
setIssuingId(k.id);
try {
const res = await issueCertificate(k.id, {
principals: (principalsByKey[k.id] ?? "")
.split(",")
.map((principal) => principal.trim())
.filter(Boolean),
});
downloadText(`termix-${handle}-${k.id}-cert.pub`, res.certificate + "\n");
toast.success(t("termixId.certIssued"));
} catch (e) {
toast.error(getErrorMessage(e, t("termixId.certIssueFailed")));
} finally {
setIssuingId(null);
}
}
return (
<div className="flex flex-col border border-border bg-card">
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-border shrink-0">
<span className="text-muted-foreground">
<KeyRound className="size-3.5" />
</span>
<span className="text-xs font-semibold uppercase tracking-widest text-muted-foreground flex-1">
{t("termixId.keysTitle")}
</span>
</div>
{keys.length === 0 ? (
<p className="text-xs text-muted-foreground px-4 py-3">
{t("termixId.noKeys")}
</p>
) : (
keys.map((k) => {
const canCert = caEnabled && k.algorithm.toUpperCase() === "ED25519";
return (
<div
key={k.id}
className="flex flex-col px-3 py-2 gap-1 border-b border-border last:border-0"
>
{/* row 1: label + badges */}
<div className="flex items-center gap-1.5 min-w-0">
<span className="text-[10px] font-bold uppercase tracking-wide text-accent-brand border border-accent-brand/40 px-1 py-px shrink-0 leading-none">
{k.algorithm}
</span>
{k.credentialId && (
<span className="text-[9px] font-semibold uppercase tracking-widest text-accent-brand/60 border border-accent-brand/20 px-1 py-px shrink-0 leading-none">
{t("termixId.fromVault")}
</span>
)}
<span className="text-xs font-medium truncate min-w-0">
{k.label || k.comment || k.keyType}
</span>
</div>
{/* row 2: public key */}
<code className="text-[10px] font-mono text-muted-foreground truncate">
{k.publicKey}
</code>
{/* row 3: actions */}
<div className="flex items-center gap-1 pt-0.5">
<FakeSwitch checked={k.enabled} onChange={() => toggle(k)} />
{canCert && (
<>
<Input
value={principalsByKey[k.id] ?? ""}
onChange={(e) =>
setPrincipalsByKey((current) => ({
...current,
[k.id]: e.target.value,
}))
}
placeholder="SSH principal(s), e.g. root"
aria-label="SSH principal(s)"
className="h-7 min-w-0 flex-1 text-xs"
spellCheck={false}
/>
<Button
variant="outline"
size="icon-sm"
onClick={() => issueCert(k)}
disabled={
issuingId === k.id ||
!(principalsByKey[k.id] ?? "").trim()
}
title={t("termixId.issueCertTooltip")}
>
{issuingId === k.id ? (
<Loader2 className="animate-spin size-3.5" />
) : (
<ScrollText className="size-3.5" />
)}
</Button>
</>
)}
<Button
variant="ghost"
size="icon-sm"
onClick={() => remove(k)}
className="hover:text-destructive"
>
<Trash2 className="size-3.5" />
</Button>
</div>
</div>
);
})
)}
</div>
);
}
function CaCard({
handle,
ca,
onChanged,
}: {
handle: string;
ca: TermixIdCa | null;
onChanged: () => void;
}) {
const { t } = useTranslation();
const [busy, setBusy] = useState(false);
const [confirmingRotate, setConfirmingRotate] = useState(false);
const [confirmingDelete, setConfirmingDelete] = useState(false);
const caUrl =
typeof window !== "undefined"
? `${window.location.origin}/termix-id/u/${handle}/ca`
: `/termix-id/u/${handle}/ca`;
const trustCmd = `curl -fsSL ${caUrl} | sudo tee /etc/ssh/${handle}-ca.pub && echo "TrustedUserCAKeys /etc/ssh/${handle}-ca.pub" | sudo tee -a /etc/ssh/sshd_config && sudo systemctl reload sshd`;
async function enable() {
setBusy(true);
try {
await createCa();
toast.success(t("termixId.caEnabled"));
onChanged();
} catch (e) {
toast.error(getErrorMessage(e, t("termixId.caCreateFailed")));
} finally {
setBusy(false);
}
}
async function rotate() {
setBusy(true);
try {
await rotateCa();
toast.success(t("termixId.caRotated"));
onChanged();
} catch (e) {
toast.error(getErrorMessage(e, t("termixId.caRotateFailed")));
} finally {
setBusy(false);
setConfirmingRotate(false);
}
}
async function remove() {
setBusy(true);
try {
await deleteCa();
toast.success(t("termixId.caDeleted"));
onChanged();
} catch (e) {
toast.error(getErrorMessage(e, t("termixId.caDeleteFailed")));
} finally {
setBusy(false);
setConfirmingDelete(false);
}
}
return (
<SectionCard
title={t("termixId.caTitle")}
icon={<ShieldCheck className="size-3.5" />}
action={
!ca ? (
<Button
variant="outline"
size="sm"
className={accentBtn}
onClick={enable}
disabled={busy}
>
{busy ? (
<Loader2 className="animate-spin size-3.5" />
) : (
t("termixId.caEnable")
)}
</Button>
) : confirmingRotate ? (
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
className="border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={rotate}
disabled={busy}
>
{busy ? (
<Loader2 className="animate-spin size-3.5" />
) : (
t("termixId.caRotate")
)}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setConfirmingRotate(false)}
>
{t("nav.cancel")}
</Button>
</div>
) : confirmingDelete ? (
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
className="border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={remove}
disabled={busy}
>
{busy ? (
<Loader2 className="animate-spin size-3.5" />
) : (
t("termixId.caDelete")
)}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setConfirmingDelete(false)}
>
{t("nav.cancel")}
</Button>
</div>
) : (
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
onClick={() => setConfirmingRotate(true)}
disabled={busy}
>
<RefreshCw className="size-3.5" />
{t("termixId.caRotate")}
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={() => setConfirmingDelete(true)}
disabled={busy}
className="hover:text-destructive"
>
<Trash2 className="size-3.5" />
</Button>
</div>
)
}
>
<div className="py-2.5 border-b border-border">
<p className="text-xs text-muted-foreground leading-snug">
{t("termixId.caIntro")}
</p>
</div>
{confirmingRotate && (
<div className="py-2.5 border-b border-border">
<p className="text-xs text-destructive/80 leading-snug">
{t("termixId.caRotateConfirm")}
</p>
</div>
)}
{confirmingDelete && (
<div className="py-2.5 border-b border-border">
<p className="text-xs text-destructive/80 leading-snug">
{t("termixId.caDeleteConfirm")}
</p>
</div>
)}
{ca && (
<>
<CopyRow label={t("termixId.caTrustLabel")} value={trustCmd} />
<CopyRow
label={t("termixId.caPublicKeyLabel")}
value={ca.publicKey}
/>
</>
)}
</SectionCard>
);
}
File diff suppressed because it is too large. Load diff
-274
View File
@@ -1,274 +0,0 @@
import { getErrorMessage } from "../lib/error-message.js";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Pencil, Plus, Trash2, X } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import {
createVaultProfile,
updateVaultProfile,
deleteVaultProfile,
type VaultProfilePayload,
} from "@/main-axios";
import type { VaultProfile } from "@/types/ui-types";
import { Select2 } from "@/components/select2";
type FormState = VaultProfilePayload & { id?: string };
const emptyForm: FormState = {
name: "",
vaultAddr: "",
vaultNamespace: "",
oidcMount: "oidc",
oidcRole: "",
sshMount: "ssh-client-signer",
sshRole: "",
validPrincipals: "",
keyType: "ssh-ed25519",
shared: false,
};
function toForm(p: VaultProfile): FormState {
return {
id: p.id,
name: p.name,
vaultAddr: p.vaultAddr,
vaultNamespace: p.vaultNamespace ?? "",
oidcMount: p.oidcMount ?? "oidc",
oidcRole: p.oidcRole ?? "",
sshMount: p.sshMount ?? "ssh-client-signer",
sshRole: p.sshRole,
validPrincipals: p.validPrincipals ?? "",
keyType: p.keyType ?? "ssh-ed25519",
shared: p.shared,
};
}
export function VaultProfileManager({
profiles,
onChanged,
onClose,
}: {
profiles: VaultProfile[];
onChanged: () => void;
onClose: () => void;
}) {
const { t } = useTranslation();
const [form, setForm] = useState<FormState | null>(null);
const [saving, setSaving] = useState(false);
const setField = <K extends keyof FormState>(k: K, v: FormState[K]) =>
setForm((p) => (p ? { ...p, [k]: v } : p));
const handleSave = async () => {
if (!form) return;
if (!form.name.trim() || !form.vaultAddr.trim() || !form.sshRole.trim()) {
toast.error(t("hosts.vaultProfileValidationError"));
return;
}
setSaving(true);
try {
const payload: VaultProfilePayload = {
name: form.name.trim(),
vaultAddr: form.vaultAddr.trim(),
vaultNamespace: form.vaultNamespace?.trim() || null,
oidcMount: form.oidcMount?.trim() || null,
oidcRole: form.oidcRole?.trim() || null,
sshMount: form.sshMount?.trim() || null,
sshRole: form.sshRole.trim(),
validPrincipals: form.validPrincipals?.trim() || null,
keyType: form.keyType?.trim() || null,
shared: !!form.shared,
};
if (form.id) {
await updateVaultProfile(Number(form.id), payload);
toast.success(t("hosts.vaultProfileSaved"));
} else {
await createVaultProfile(payload);
toast.success(t("hosts.vaultProfileCreated"));
}
setForm(null);
onChanged();
} catch (e) {
toast.error(getErrorMessage(e, t("hosts.vaultProfileSaveFailed")));
} finally {
setSaving(false);
}
};
const handleDelete = async (p: VaultProfile) => {
try {
await deleteVaultProfile(Number(p.id));
toast.success(t("hosts.vaultProfileDeleted"));
onChanged();
} catch (e) {
toast.error(getErrorMessage(e, t("hosts.vaultProfileDeleteFailed")));
}
};
const field = (label: string, key: keyof FormState, placeholder?: string) => (
<div className="flex flex-col gap-1">
<label className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground">
{label}
</label>
<Input
className="h-8 text-xs"
placeholder={placeholder}
value={(form?.[key] as string) ?? ""}
onChange={(e) => setField(key, e.target.value as FormState[typeof key])}
/>
</div>
);
return (
<div className="flex flex-col gap-3 col-span-2 border border-border bg-muted/20 p-3">
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.vaultManageProfiles")}
</span>
<button
type="button"
onClick={onClose}
className="text-muted-foreground hover:text-foreground"
>
<X className="size-3.5" />
</button>
</div>
{!form && (
<>
<div className="flex flex-col divide-y divide-border/50">
{profiles.length === 0 && (
<span className="text-[11px] text-muted-foreground py-1">
{t("hosts.vaultNoProfiles")}
</span>
)}
{profiles.map((p) => (
<div
key={p.id}
className="flex items-center justify-between py-1.5"
>
<div className="flex flex-col min-w-0">
<span className="text-xs text-foreground truncate">
{p.name}
{p.shared && (
<span className="ml-1 text-[9px] text-accent-brand">
{t("hosts.vaultSharedBadge")}
</span>
)}
</span>
<span className="text-[10px] text-muted-foreground truncate">
{p.vaultAddr}
</span>
</div>
{p.owned && (
<div className="flex items-center gap-1 shrink-0">
<button
type="button"
title="Edit"
onClick={() => setForm(toForm(p))}
className="size-7 flex items-center justify-center rounded text-muted-foreground/60 hover:text-foreground hover:bg-muted-foreground/10"
>
<Pencil className="size-3.5" />
</button>
<button
type="button"
title="Delete"
onClick={() => handleDelete(p)}
className="size-7 flex items-center justify-center rounded text-muted-foreground/50 hover:text-destructive hover:bg-destructive/10"
>
<Trash2 className="size-3.5" />
</button>
</div>
)}
</div>
))}
</div>
<Button
type="button"
variant="outline"
size="sm"
className="h-7 text-[10px] self-start"
onClick={() => setForm({ ...emptyForm })}
>
<Plus className="size-3 mr-1" /> {t("hosts.vaultNewProfile")}
</Button>
</>
)}
{form && (
<div className="flex flex-col gap-2">
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{field(t("hosts.friendlyNameLabel"), "name", "Production Vault")}
{field(
t("hosts.vaultAddrLabel"),
"vaultAddr",
"https://vault:8200",
)}
{field(t("hosts.vaultNamespaceLabel"), "vaultNamespace", "admin")}
{field(t("hosts.vaultOidcMountLabel"), "oidcMount", "oidc")}
{field(t("hosts.vaultOidcRoleLabel"), "oidcRole", "default")}
{field(
t("hosts.vaultSshMountLabel"),
"sshMount",
"ssh-client-signer",
)}
{field(t("hosts.vaultSshRoleLabel"), "sshRole", "my-role")}
{field(
t("hosts.vaultValidPrincipalsLabel"),
"validPrincipals",
"root,deploy",
)}
</div>
<div className="flex flex-col gap-1">
<label className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.vaultKeyTypeLabel")}
</label>
<Select2
value={form.keyType ?? "ssh-ed25519"}
onChange={(e) => setField("keyType", e.target.value)}
className="flex h-8 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="ssh-ed25519">Ed25519</option>
<option value="ecdsa-sha2-nistp256">ECDSA (nistp256)</option>
<option value="ssh-rsa">RSA (4096)</option>
</Select2>
</div>
<label className="flex items-center gap-2 text-xs text-foreground">
<input
type="checkbox"
checked={!!form.shared}
onChange={(e) => setField("shared", e.target.checked)}
/>
{t("hosts.vaultSharedLabel")}
</label>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setForm(null)}
disabled={saving}
>
{t("hosts.cancelBtn")}
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="border-accent-brand/40 text-accent-brand"
onClick={handleSave}
disabled={saving}
>
{form.id
? t("hosts.vaultSaveProfile")
: t("hosts.vaultCreateProfile")}
</Button>
</div>
</div>
)}
</div>
);
}
-650
View File
@@ -1,650 +0,0 @@
import { getErrorMessage } from "../lib/error-message.js";
import { useState, useEffect, useCallback, useRef } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
LayoutTemplate,
Loader2,
Plus,
Save,
Settings2,
Star,
Trash2,
X,
} from "lucide-react";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { Badge } from "@/components/badge";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/dialog";
import { FOLDER_COLORS } from "@/lib/theme";
import {
listWorkspaces,
createWorkspace,
renameWorkspace,
updateWorkspaceContent,
deleteWorkspace,
duplicateWorkspace,
setDefaultWorkspace,
unsetDefaultWorkspace,
} from "@/api/workspaces-api";
import type { Workspace, WorkspacePayload } from "@/types/ui-types";
function timeAgo(isoStr: string): string {
const ms = Date.now() - new Date(isoStr).getTime();
if (!Number.isFinite(ms)) return "";
const sec = Math.floor(ms / 1000);
if (sec < 60) return `${sec}s ago`;
const min = Math.floor(sec / 60);
if (min < 60) return `${min}m ago`;
const hr = Math.floor(min / 60);
if (hr < 24) return `${hr}h ago`;
return `${Math.floor(hr / 24)}d ago`;
}
function WorkspaceSaveDialog({
open,
onClose,
onSave,
}: {
open: boolean;
onClose: () => void;
onSave: (name: string, color: string) => Promise<void>;
}) {
const { t } = useTranslation();
const [name, setName] = useState("");
const [color, setColor] = useState<string>(FOLDER_COLORS[0]);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!open) return;
setName("");
setColor(FOLDER_COLORS[0]);
}, [open]);
async function handleSave() {
if (!name.trim()) {
toast.error(t("newUi.sidebar.workspaces.nameRequired"));
return;
}
setSaving(true);
try {
await onSave(name.trim(), color);
onClose();
} finally {
setSaving(false);
}
}
return (
<Dialog open={open} onOpenChange={(next) => !next && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
{t("newUi.sidebar.workspaces.saveCurrentTitle")}
</DialogTitle>
<DialogDescription>
{t("newUi.sidebar.workspaces.saveCurrentDescription")}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1.5">
<label className="text-xs text-muted-foreground">
{t("newUi.sidebar.workspaces.nameLabel")}
</label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t("newUi.sidebar.workspaces.namePlaceholder")}
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter") handleSave();
}}
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs text-muted-foreground">
{t("newUi.sidebar.workspaces.colorLabel")}
</label>
<div className="flex gap-1.5">
{FOLDER_COLORS.map((c) => (
<button
key={c}
type="button"
onClick={() => setColor(c)}
className={`size-6 transition-all ${
color === c
? "ring-2 ring-offset-2 ring-offset-background ring-white/50"
: "opacity-75 hover:opacity-100"
}`}
style={{ backgroundColor: c }}
aria-label={c}
/>
))}
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
{t("common.cancel")}
</Button>
<Button
variant="outline"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={handleSave}
disabled={saving}
>
{saving && <Loader2 className="size-3.5 mr-2 animate-spin" />}
{t("common.save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function WorkspaceRenameDialog({
workspace,
onClose,
onSaved,
}: {
workspace: Workspace | null;
onClose: () => void;
onSaved: () => void;
}) {
const { t } = useTranslation();
const [name, setName] = useState("");
const [color, setColor] = useState<string>(FOLDER_COLORS[0]);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!workspace) return;
setName(workspace.name);
setColor(workspace.color ?? FOLDER_COLORS[0]);
}, [workspace]);
async function handleSave() {
if (!workspace) return;
if (!name.trim()) {
toast.error(t("newUi.sidebar.workspaces.nameRequired"));
return;
}
setSaving(true);
try {
await renameWorkspace(workspace.id, { name: name.trim(), color });
toast.success(t("newUi.sidebar.workspaces.workspaceUpdated"));
onSaved();
onClose();
} catch (error) {
const message = getErrorMessage(error, "");
toast.error(message || t("newUi.sidebar.workspaces.updateFailed"));
} finally {
setSaving(false);
}
}
return (
<Dialog open={!!workspace} onOpenChange={(next) => !next && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("newUi.sidebar.workspaces.rename")}</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1.5">
<label className="text-xs text-muted-foreground">
{t("newUi.sidebar.workspaces.nameLabel")}
</label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter") handleSave();
}}
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs text-muted-foreground">
{t("newUi.sidebar.workspaces.colorLabel")}
</label>
<div className="flex gap-1.5">
{FOLDER_COLORS.map((c) => (
<button
key={c}
type="button"
onClick={() => setColor(c)}
className={`size-6 transition-all ${
color === c
? "ring-2 ring-offset-2 ring-offset-background ring-white/50"
: "opacity-75 hover:opacity-100"
}`}
style={{ backgroundColor: c }}
aria-label={c}
/>
))}
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
{t("common.cancel")}
</Button>
<Button
variant="outline"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={handleSave}
disabled={saving}
>
{saving && <Loader2 className="size-3.5 mr-2 animate-spin" />}
{t("common.save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function WorkspaceRow({
workspace,
isLastSession,
onApply,
onUpdateWithCurrent,
onRename,
onDuplicate,
onDelete,
onSetDefault,
}: {
workspace: Workspace;
isLastSession: boolean;
onApply: () => void;
onUpdateWithCurrent: () => void;
onRename: () => void;
onDuplicate: () => void;
onDelete: () => void;
onSetDefault: () => void;
}) {
const { t } = useTranslation();
return (
<div
className="flex flex-col gap-1 border border-border p-2.5 cursor-pointer hover:bg-muted/40 group"
onClick={onApply}
>
<div className="flex items-center gap-1.5 min-w-0">
<span
className="size-2.5 shrink-0"
style={{ backgroundColor: workspace.color ?? "#6b7280" }}
/>
<span
className={`text-xs font-semibold truncate min-w-0 ${isLastSession ? "italic text-muted-foreground" : ""}`}
>
{isLastSession
? t("newUi.sidebar.workspaces.lastSession")
: workspace.name}
</span>
{workspace.isDefault && (
<Star className="size-3 shrink-0 fill-accent-brand text-accent-brand" />
)}
<Badge variant="secondary" className="shrink-0 ml-auto">
{t("newUi.sidebar.workspaces.tabCount", {
count: workspace.tabCount,
})}
</Badge>
</div>
<span className="text-[11px] text-muted-foreground truncate pl-4">
{isLastSession
? t("newUi.sidebar.workspaces.lastSessionDescription")
: workspace.lastUsedAt
? t("newUi.sidebar.workspaces.lastUsed", {
time: timeAgo(workspace.lastUsedAt),
})
: t("newUi.sidebar.workspaces.neverUsed")}
</span>
{!isLastSession && (
<div className="flex items-center justify-end gap-0.5 opacity-0 group-hover:opacity-100">
<Button
variant="ghost"
size="icon-sm"
title={
workspace.isDefault
? t("newUi.sidebar.workspaces.unsetDefault")
: t("newUi.sidebar.workspaces.setDefault")
}
onClick={(e) => {
e.stopPropagation();
onSetDefault();
}}
>
<Star
className={`size-3.5 ${workspace.isDefault ? "fill-accent-brand text-accent-brand" : ""}`}
/>
</Button>
<Button
variant="ghost"
size="icon-sm"
title={t("newUi.sidebar.workspaces.updateWithCurrent")}
onClick={(e) => {
e.stopPropagation();
onUpdateWithCurrent();
}}
>
<Save className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
title={t("newUi.sidebar.workspaces.rename")}
onClick={(e) => {
e.stopPropagation();
onRename();
}}
>
<Settings2 className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
title={t("newUi.sidebar.workspaces.duplicate")}
onClick={(e) => {
e.stopPropagation();
onDuplicate();
}}
>
<Plus className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
className="hover:text-destructive"
title={t("newUi.sidebar.workspaces.deleteWorkspaceTitle")}
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
>
<Trash2 className="size-3.5" />
</Button>
</div>
)}
</div>
);
}
export function WorkspacesPanel({
active,
currentPayload,
onApplyWorkspace,
}: {
active?: boolean;
currentPayload: () => WorkspacePayload;
onApplyWorkspace: (workspace: Workspace) => void;
}) {
const { t } = useTranslation();
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
const [loading, setLoading] = useState(true);
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
const [renameTarget, setRenameTarget] = useState<Workspace | null>(null);
const [deleteTarget, setDeleteTarget] = useState<Workspace | null>(null);
const [applyTarget, setApplyTarget] = useState<Workspace | null>(null);
const hasLoadedRef = useRef(false);
const loadWorkspaces = useCallback(async () => {
try {
const data = await listWorkspaces();
setWorkspaces(data);
} catch (error) {
const message = getErrorMessage(error, "");
toast.error(message || t("newUi.sidebar.workspaces.loadFailed"));
}
}, [t]);
useEffect(() => {
if (!active || hasLoadedRef.current) return;
hasLoadedRef.current = true;
setLoading(true);
loadWorkspaces().finally(() => setLoading(false));
}, [active, loadWorkspaces]);
async function handleSaveNew(name: string, color: string) {
try {
await createWorkspace({ name, color, payload: currentPayload() });
toast.success(t("newUi.sidebar.workspaces.workspaceCreated"));
loadWorkspaces();
} catch (error) {
const message = getErrorMessage(error, "");
toast.error(message || t("newUi.sidebar.workspaces.saveFailed"));
throw error;
}
}
async function handleUpdateWithCurrent(workspace: Workspace) {
try {
await updateWorkspaceContent(workspace.id, currentPayload());
toast.success(t("newUi.sidebar.workspaces.workspaceUpdated"));
loadWorkspaces();
} catch (error) {
const message = getErrorMessage(error, "");
toast.error(message || t("newUi.sidebar.workspaces.updateFailed"));
}
}
async function handleDuplicate(workspace: Workspace) {
try {
await duplicateWorkspace(
workspace.id,
t("newUi.sidebar.workspaces.duplicateNameSuffix", {
name: workspace.name,
}),
);
toast.success(t("newUi.sidebar.workspaces.workspaceDuplicated"));
loadWorkspaces();
} catch (error) {
const message = getErrorMessage(error, "");
toast.error(message || t("newUi.sidebar.workspaces.duplicateFailed"));
}
}
async function handleToggleDefault(workspace: Workspace) {
try {
if (workspace.isDefault) {
await unsetDefaultWorkspace(workspace.id);
} else {
await setDefaultWorkspace(workspace.id);
}
loadWorkspaces();
} catch (error) {
const message = getErrorMessage(error, "");
toast.error(message || t("newUi.sidebar.workspaces.updateFailed"));
}
}
async function handleDelete() {
if (!deleteTarget) return;
try {
await deleteWorkspace(deleteTarget.id);
toast.success(t("newUi.sidebar.workspaces.workspaceDeleted"));
setDeleteTarget(null);
loadWorkspaces();
} catch (error) {
const message = getErrorMessage(error, "");
toast.error(message || t("newUi.sidebar.workspaces.deleteFailed"));
}
}
function handleApplyClick(workspace: Workspace) {
setApplyTarget(workspace);
}
function confirmApply() {
if (!applyTarget) return;
onApplyWorkspace(applyTarget);
setApplyTarget(null);
}
const manualWorkspaces = workspaces.filter((w) => w.kind === "manual");
const lastSession = workspaces.find((w) => w.kind === "last_session");
return (
<div className="flex flex-col flex-1 min-h-0">
<div className="flex items-center gap-2 p-3 border-b border-border shrink-0">
<LayoutTemplate className="size-4 text-muted-foreground" />
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm font-semibold">
{t("newUi.sidebar.workspaces.title")}
</span>
<a
href="https://docs.termix.site/features/workspaces"
target="_blank"
rel="noreferrer"
className="w-fit text-[10px] text-accent-brand hover:underline"
>
{t("hosts.docsLink")}
</a>
</div>
<Button
variant="outline"
size="sm"
className="shrink-0 border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={() => setSaveDialogOpen(true)}
>
<Plus className="size-3.5 mr-1.5" />
{t("newUi.sidebar.workspaces.saveCurrent")}
</Button>
</div>
<div className="flex-1 min-h-0 overflow-y-auto p-3">
{loading ? (
<div className="flex items-center justify-center py-8 text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
</div>
) : manualWorkspaces.length === 0 && !lastSession ? (
<div className="text-xs text-muted-foreground text-center py-8">
{t("newUi.sidebar.workspaces.noWorkspaces")}
</div>
) : (
<div className="flex flex-col gap-1.5">
{lastSession && (
<WorkspaceRow
key={lastSession.id}
workspace={lastSession}
isLastSession
onApply={() => handleApplyClick(lastSession)}
onUpdateWithCurrent={() => {}}
onRename={() => {}}
onDuplicate={() => {}}
onDelete={() => {}}
onSetDefault={() => {}}
/>
)}
{manualWorkspaces.map((workspace) => (
<WorkspaceRow
key={workspace.id}
workspace={workspace}
isLastSession={false}
onApply={() => handleApplyClick(workspace)}
onUpdateWithCurrent={() => {
if (
window.confirm(
t("newUi.sidebar.workspaces.updateWithCurrentConfirm", {
name: workspace.name,
}),
)
) {
handleUpdateWithCurrent(workspace);
}
}}
onRename={() => setRenameTarget(workspace)}
onDuplicate={() => handleDuplicate(workspace)}
onDelete={() => setDeleteTarget(workspace)}
onSetDefault={() => handleToggleDefault(workspace)}
/>
))}
</div>
)}
</div>
<WorkspaceSaveDialog
open={saveDialogOpen}
onClose={() => setSaveDialogOpen(false)}
onSave={handleSaveNew}
/>
<WorkspaceRenameDialog
workspace={renameTarget}
onClose={() => setRenameTarget(null)}
onSaved={loadWorkspaces}
/>
<Dialog
open={!!deleteTarget}
onOpenChange={(next) => !next && setDeleteTarget(null)}
>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>
{t("newUi.sidebar.workspaces.deleteWorkspaceTitle")}
</DialogTitle>
<DialogDescription>
{t("newUi.sidebar.workspaces.deleteWorkspaceDescription", {
name: deleteTarget?.name,
})}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteTarget(null)}>
{t("common.cancel")}
</Button>
<Button variant="destructive" onClick={handleDelete}>
<X className="size-3.5 mr-2" />
{t("common.delete")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={!!applyTarget}
onOpenChange={(next) => !next && setApplyTarget(null)}
>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>
{t("newUi.sidebar.workspaces.applyConfirmTitle")}
</DialogTitle>
<DialogDescription>
{t("newUi.sidebar.workspaces.applyConfirmDescription", {
name: applyTarget?.name,
})}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setApplyTarget(null)}>
{t("common.cancel")}
</Button>
<Button
variant="outline"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={confirmApply}
>
{t("newUi.sidebar.workspaces.applyConfirmButton")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -1,258 +0,0 @@
import { getErrorMessage } from "../../lib/error-message.js";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Plus } from "lucide-react";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { Label } from "@/components/label";
import { Switch } from "@/components/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/select";
import {
AUTOMATION_DEFINITION_VERSION,
type AutomationDefinition,
type ConcurrencyPolicy,
type Step,
} from "@/types/automations";
import { StepBlock } from "./StepBlock";
import { TriggerCard, defaultTrigger } from "./TriggerCard";
import { newStepId, type AutomationEditorOptions } from "./editor-types";
export interface AutomationDraft {
name: string;
description: string;
enabled: boolean;
concurrencyPolicy: ConcurrencyPolicy;
definition: AutomationDefinition;
}
export function emptyDraft(): AutomationDraft {
return {
name: "",
description: "",
enabled: true,
concurrencyPolicy: "skip",
definition: {
version: AUTOMATION_DEFINITION_VERSION,
trigger: defaultTrigger("metric_threshold"),
steps: [],
},
};
}
/**
* The automation builder: one trigger, then an ordered list of steps that can
* nest through if/otherwise. A JSON view is available for anything the form
* does not cover yet.
*/
export function AutomationEditor({
draft,
options,
onChange,
}: {
draft: AutomationDraft;
options: AutomationEditorOptions;
onChange: (next: AutomationDraft) => void;
}) {
const { t } = useTranslation();
const base = "newUi.sidebar.automations";
const [view, setView] = useState<"builder" | "json">("builder");
const [jsonText, setJsonText] = useState("");
const [jsonError, setJsonError] = useState<string | null>(null);
function setDefinition(definition: AutomationDefinition) {
onChange({ ...draft, definition });
}
function setSteps(steps: Step[]) {
setDefinition({ ...draft.definition, steps });
}
function renderSteps(
steps: Step[],
onStepsChange: (next: Step[]) => void,
depth: number,
): React.ReactNode {
return (
<div className="space-y-2">
{steps.map((step, index) => (
<StepBlock
key={step.id}
step={step}
index={index}
total={steps.length}
depth={depth}
options={options}
onChange={(next) => {
const copy = [...steps];
copy[index] = next;
onStepsChange(copy);
}}
onRemove={() => onStepsChange(steps.filter((_, i) => i !== index))}
onMove={(direction) => {
const target = index + direction;
if (target < 0 || target >= steps.length) return;
const copy = [...steps];
[copy[index], copy[target]] = [copy[target], copy[index]];
onStepsChange(copy);
}}
renderNested={renderSteps}
/>
))}
<Button
variant="outline"
size="sm"
className="rounded-none w-full border-border"
onClick={() =>
onStepsChange([
...steps,
{ id: newStepId(), type: "notify", channelIds: [] } as Step,
])
}
>
<Plus size={14} className="mr-1" />
{t(`${base}.addStep`)}
</Button>
</div>
);
}
return (
<div className="space-y-4">
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">
{t(`${base}.nameLabel`)}
</Label>
<Input
className="h-8 rounded-none"
placeholder={t(`${base}.namePlaceholder`)}
value={draft.name}
onChange={(e) => onChange({ ...draft, name: e.target.value })}
/>
</div>
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<Switch
checked={draft.enabled}
onCheckedChange={(enabled) => onChange({ ...draft, enabled })}
/>
{t(`${base}.enabledLabel`)}
</label>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">
{t(`${base}.concurrencyLabel`)}
</Label>
<Select
value={draft.concurrencyPolicy}
onValueChange={(value) =>
onChange({
...draft,
concurrencyPolicy: value as ConcurrencyPolicy,
})
}
>
<SelectTrigger
size="sm"
className="w-full rounded-none border-border"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="skip">
{t(`${base}.concurrency.skip`)}
</SelectItem>
<SelectItem value="queue">
{t(`${base}.concurrency.queue`)}
</SelectItem>
<SelectItem value="allow">
{t(`${base}.concurrency.allow`)}
</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{t(`${base}.concurrencyHint`)}
</p>
</div>
<div className="flex gap-1">
<Button
variant="outline"
size="sm"
className={`rounded-none ${
view === "builder"
? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand hover:bg-accent-brand/20 hover:text-accent-brand"
: "border-border"
}`}
onClick={() => setView("builder")}
>
{t(`${base}.viewBuilder`)}
</Button>
<Button
variant="outline"
size="sm"
className={`rounded-none ${
view === "json"
? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand hover:bg-accent-brand/20 hover:text-accent-brand"
: "border-border"
}`}
onClick={() => {
setJsonText(JSON.stringify(draft.definition, null, 2));
setJsonError(null);
setView("json");
}}
>
{t(`${base}.viewJson`)}
</Button>
</div>
{view === "builder" ? (
<>
<TriggerCard
trigger={draft.definition.trigger}
options={options}
onChange={(trigger) =>
setDefinition({ ...draft.definition, trigger })
}
/>
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">
{t(`${base}.steps`)}
</Label>
{draft.definition.steps.length === 0 && (
<p className="text-xs text-muted-foreground">
{t(`${base}.noSteps`)}
</p>
)}
{renderSteps(draft.definition.steps, setSteps, 0)}
</div>
</>
) : (
<div className="space-y-2">
<textarea
className="w-full min-h-64 bg-background border border-border p-2 font-mono text-xs"
value={jsonText}
onChange={(e) => {
setJsonText(e.target.value);
try {
const parsed = JSON.parse(e.target.value);
setDefinition(parsed as AutomationDefinition);
setJsonError(null);
} catch (error) {
setJsonError(getErrorMessage(error, String(error)));
}
}}
/>
{jsonError && <p className="text-xs text-destructive">{jsonError}</p>}
</div>
)}
</div>
);
}
@@ -1,135 +0,0 @@
import { useTranslation } from "react-i18next";
import { Label } from "@/components/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/select";
import type { HostSelector } from "@/types/automations";
import type { AutomationEditorOptions } from "./editor-types";
/**
* Picks which hosts a step acts on. "The host that triggered this" is the
* common case, so it is offered first and used as the default.
*/
export function HostSelectorField({
value,
options,
onChange,
allowTrigger = true,
}: {
value: HostSelector | undefined;
options: AutomationEditorOptions;
onChange: (next: HostSelector) => void;
allowTrigger?: boolean;
}) {
const { t } = useTranslation();
const base = "newUi.sidebar.automations";
const kind = value?.kind ?? (allowTrigger ? "trigger" : "host");
return (
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">
{t(`${base}.fields.host`)}
</Label>
<div className="flex gap-2">
<Select
value={kind}
onValueChange={(next) =>
onChange(defaultFor(next as HostSelector["kind"]))
}
>
<SelectTrigger
size="sm"
className="min-w-0 flex-1 rounded-none border-border"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{allowTrigger && (
<SelectItem value="trigger">
{t(`${base}.hostSelector.trigger`)}
</SelectItem>
)}
<SelectItem value="host">
{t(`${base}.hostSelector.host`)}
</SelectItem>
<SelectItem value="fleet">
{t(`${base}.hostSelector.fleet`)}
</SelectItem>
<SelectItem value="all">{t(`${base}.hostSelector.all`)}</SelectItem>
</SelectContent>
</Select>
{kind === "host" && (
<Select
value={
value?.kind === "host" && value.hostId ? String(value.hostId) : ""
}
onValueChange={(next) =>
onChange({ kind: "host", hostId: Number(next) })
}
>
<SelectTrigger
size="sm"
className="min-w-0 flex-1 rounded-none border-border"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{options.hosts.map((host) => (
<SelectItem key={host.id} value={String(host.id)}>
{host.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{kind === "fleet" && (
<Select
value={
value?.kind === "fleet" && value.fleetId
? String(value.fleetId)
: ""
}
onValueChange={(next) =>
onChange({ kind: "fleet", fleetId: Number(next) })
}
>
<SelectTrigger
size="sm"
className="min-w-0 flex-1 rounded-none border-border"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{options.fleets.map((fleet) => (
<SelectItem key={fleet.id} value={String(fleet.id)}>
{fleet.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
</div>
);
}
function defaultFor(kind: HostSelector["kind"]): HostSelector {
switch (kind) {
case "host":
return { kind: "host", hostId: 0 };
case "fleet":
return { kind: "fleet", fleetId: 0 };
case "hosts":
return { kind: "hosts", hostIds: [] };
case "all":
return { kind: "all" };
default:
return { kind: "trigger" };
}
}
-585
View File
@@ -1,585 +0,0 @@
import { useTranslation } from "react-i18next";
import { ChevronDown, ChevronUp, Trash2 } from "lucide-react";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { Label } from "@/components/label";
import { Textarea } from "@/components/textarea";
import { Switch } from "@/components/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/select";
import type { Step, StepType } from "@/types/automations";
import { HostSelectorField } from "./HostSelectorField";
import type { AutomationEditorOptions } from "./editor-types";
const STEP_TYPES: StepType[] = [
"notify",
"http",
"run_command",
"run_snippet",
"docker",
"tunnel",
"wol",
"wait",
"set_var",
"if",
"stop",
];
interface StepBlockProps {
step: Step;
index: number;
total: number;
depth: number;
options: AutomationEditorOptions;
onChange: (next: Step) => void;
onRemove: () => void;
onMove: (direction: -1 | 1) => void;
renderNested: (
steps: Step[],
onChange: (next: Step[]) => void,
depth: number,
) => React.ReactNode;
}
/**
* One block in the automation's step list. Each step type renders only the
* fields it actually uses, so the form stays readable as more types are added.
*/
export function StepBlock({
step,
index,
total,
depth,
options,
onChange,
onRemove,
onMove,
renderNested,
}: StepBlockProps) {
const { t } = useTranslation();
const base = "newUi.sidebar.automations";
function patch(changes: Partial<Step>) {
onChange({ ...step, ...changes } as Step);
}
return (
<div
className={`border border-border p-3 ${depth > 0 ? "border-l-2 border-l-primary/40" : ""}`}
>
<div className="flex items-center gap-2 mb-3">
<Select
value={step.type}
onValueChange={(value) =>
onChange(changeStepType(step, value as StepType))
}
>
<SelectTrigger
size="sm"
className="flex-1 rounded-none border-border"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{STEP_TYPES.map((type) => (
<SelectItem key={type} value={type}>
{t(`${base}.stepTypes.${type}`)}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-none"
disabled={index === 0}
title={t(`${base}.moveUp`)}
onClick={() => onMove(-1)}
>
<ChevronUp size={14} />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-none"
disabled={index === total - 1}
title={t(`${base}.moveDown`)}
onClick={() => onMove(1)}
>
<ChevronDown size={14} />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-none"
title={t(`${base}.removeStep`)}
onClick={onRemove}
>
<Trash2 size={14} />
</Button>
</div>
<div className="space-y-3">
{step.type === "notify" && (
<>
<Field label={t(`${base}.fields.title`)}>
<Input
className="h-8 rounded-none border-border"
value={step.title ?? ""}
onChange={(e) =>
patch({ title: e.target.value } as Partial<Step>)
}
/>
</Field>
<Field label={t(`${base}.fields.message`)}>
<Textarea
className="rounded-none border-border min-h-16"
value={step.body ?? ""}
onChange={(e) =>
patch({ body: e.target.value } as Partial<Step>)
}
/>
</Field>
<Field label={t(`${base}.fields.channels`)}>
<div className="flex flex-wrap gap-2">
{options.channels.length === 0 && (
<span className="text-xs text-muted-foreground">
{t(`${base}.noChannelsHint`)}
</span>
)}
{options.channels.map((channel) => {
const checked = step.channelIds?.includes(channel.id);
return (
<button
key={channel.id}
type="button"
onClick={() =>
patch({
channelIds: checked
? step.channelIds.filter((id) => id !== channel.id)
: [...(step.channelIds ?? []), channel.id],
} as Partial<Step>)
}
className={`px-2 py-1 text-xs border ${
checked
? "border-primary text-foreground"
: "border-border text-muted-foreground"
}`}
>
{channel.name}
</button>
);
})}
</div>
</Field>
</>
)}
{step.type === "http" && (
<>
<div className="flex gap-2">
<Select
value={step.method}
onValueChange={(value) =>
patch({ method: value } as Partial<Step>)
}
>
<SelectTrigger
size="sm"
className="w-28 rounded-none border-border"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{["GET", "POST", "PUT", "PATCH", "DELETE"].map((method) => (
<SelectItem key={method} value={method}>
{method}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
className="h-8 flex-1 rounded-none border-border"
placeholder="https://"
value={step.url ?? ""}
onChange={(e) =>
patch({ url: e.target.value } as Partial<Step>)
}
/>
</div>
<Field label={t(`${base}.fields.body`)}>
<Textarea
className="rounded-none border-border min-h-16 font-mono text-xs"
value={step.body ?? ""}
onChange={(e) =>
patch({ body: e.target.value } as Partial<Step>)
}
/>
</Field>
<label className="flex items-start gap-2 text-xs text-muted-foreground">
<Switch
checked={!!step.allowPrivateNetwork}
onCheckedChange={(checked) =>
patch({ allowPrivateNetwork: checked } as Partial<Step>)
}
/>
<span>
{t(`${base}.fields.allowPrivateNetwork`)}
<span className="block">
{t(`${base}.fields.allowPrivateNetworkHint`)}
</span>
</span>
</label>
</>
)}
{step.type === "run_command" && (
<>
<HostSelectorField
value={step.hostSelector}
options={options}
onChange={(hostSelector) =>
patch({ hostSelector } as Partial<Step>)
}
/>
<Field label={t(`${base}.fields.command`)}>
<Textarea
className="rounded-none border-border min-h-16 font-mono text-xs"
value={step.command ?? ""}
onChange={(e) =>
patch({ command: e.target.value } as Partial<Step>)
}
/>
</Field>
<ElevatedToggle
checked={!!step.elevated}
onChange={(elevated) => patch({ elevated } as Partial<Step>)}
/>
</>
)}
{step.type === "run_snippet" && (
<>
<HostSelectorField
value={step.hostSelector}
options={options}
onChange={(hostSelector) =>
patch({ hostSelector } as Partial<Step>)
}
/>
<Field label={t(`${base}.fields.snippet`)}>
<Select
value={step.snippetId ? String(step.snippetId) : ""}
onValueChange={(value) =>
patch({ snippetId: Number(value) } as Partial<Step>)
}
>
<SelectTrigger size="sm" className="rounded-none border-border">
<SelectValue />
</SelectTrigger>
<SelectContent>
{options.snippets.map((snippet) => (
<SelectItem key={snippet.id} value={String(snippet.id)}>
{snippet.name}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<ElevatedToggle
checked={!!step.elevated}
onChange={(elevated) => patch({ elevated } as Partial<Step>)}
/>
</>
)}
{step.type === "docker" && (
<>
<HostSelectorField
value={step.hostSelector}
options={options}
onChange={(hostSelector) =>
patch({ hostSelector } as Partial<Step>)
}
/>
<div className="flex gap-2">
<Select
value={step.action}
onValueChange={(value) =>
patch({ action: value } as Partial<Step>)
}
>
<SelectTrigger
size="sm"
className="min-w-0 flex-1 rounded-none border-border"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{["start", "stop", "restart"].map((action) => (
<SelectItem key={action} value={action}>
{action}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
className="h-8 flex-1 rounded-none border-border"
placeholder={t(`${base}.fields.container`)}
value={step.container ?? ""}
onChange={(e) =>
patch({ container: e.target.value } as Partial<Step>)
}
/>
</div>
</>
)}
{step.type === "tunnel" && (
<div className="flex gap-2">
<Select
value={step.action}
onValueChange={(value) =>
patch({ action: value } as Partial<Step>)
}
>
<SelectTrigger
size="sm"
className="min-w-0 flex-1 rounded-none border-border"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="connect">connect</SelectItem>
<SelectItem value="disconnect">disconnect</SelectItem>
</SelectContent>
</Select>
<Input
className="h-8 flex-1 rounded-none border-border"
value={step.tunnelName ?? ""}
onChange={(e) =>
patch({ tunnelName: e.target.value } as Partial<Step>)
}
/>
</div>
)}
{step.type === "wol" && (
<Field label={t(`${base}.fields.host`)}>
<Select
value={step.hostId ? String(step.hostId) : ""}
onValueChange={(value) =>
patch({ hostId: Number(value) } as Partial<Step>)
}
>
<SelectTrigger size="sm" className="rounded-none border-border">
<SelectValue />
</SelectTrigger>
<SelectContent>
{options.hosts.map((host) => (
<SelectItem key={host.id} value={String(host.id)}>
{host.name}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
)}
{step.type === "wait" && (
<Field label={t(`${base}.fields.seconds`)}>
<Input
type="number"
min={1}
className="h-8 w-32 rounded-none border-border"
value={step.seconds ?? 0}
onChange={(e) =>
patch({ seconds: Number(e.target.value) } as Partial<Step>)
}
/>
</Field>
)}
{step.type === "set_var" && (
<div className="flex gap-2">
<Input
className="h-8 w-40 rounded-none border-border"
placeholder={t(`${base}.fields.variableName`)}
value={step.name ?? ""}
onChange={(e) => patch({ name: e.target.value } as Partial<Step>)}
/>
<Input
className="h-8 flex-1 rounded-none border-border"
placeholder={t(`${base}.fields.variableValue`)}
value={step.value ?? ""}
onChange={(e) =>
patch({ value: e.target.value } as Partial<Step>)
}
/>
</div>
)}
{step.type === "if" && (
<div className="space-y-3">
<div className="flex gap-2">
<Input
className="h-8 flex-1 rounded-none border-border font-mono text-xs"
placeholder={t(`${base}.fields.conditionLeftPlaceholder`)}
value={step.condition?.left ?? ""}
onChange={(e) =>
patch({
condition: { ...step.condition, left: e.target.value },
} as Partial<Step>)
}
/>
<Select
value={step.condition?.operator ?? "=="}
onValueChange={(value) =>
patch({
condition: { ...step.condition, operator: value },
} as Partial<Step>)
}
>
<SelectTrigger
size="sm"
className="w-28 rounded-none border-border"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{[">", "<", ">=", "<=", "==", "!=", "contains"].map((op) => (
<SelectItem key={op} value={op}>
{op}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
className="h-8 flex-1 rounded-none border-border font-mono text-xs"
placeholder={t(`${base}.fields.conditionRightPlaceholder`)}
value={step.condition?.right ?? ""}
onChange={(e) =>
patch({
condition: { ...step.condition, right: e.target.value },
} as Partial<Step>)
}
/>
</div>
{renderNested(
step.then ?? [],
(next) => patch({ then: next } as Partial<Step>),
depth + 1,
)}
</div>
)}
{step.type !== "stop" && (
<Field label={t(`${base}.fields.onError`)}>
<Select
value={step.onError ?? "stop"}
onValueChange={(value) =>
patch({ onError: value } as Partial<Step>)
}
>
<SelectTrigger
size="sm"
className="min-w-0 w-full rounded-none border-border"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="stop">
{t(`${base}.onError.stop`)}
</SelectItem>
<SelectItem value="continue">
{t(`${base}.onError.continue`)}
</SelectItem>
</SelectContent>
</Select>
</Field>
)}
</div>
</div>
);
}
function Field({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">{label}</Label>
{children}
</div>
);
}
function ElevatedToggle({
checked,
onChange,
}: {
checked: boolean;
onChange: (value: boolean) => void;
}) {
const { t } = useTranslation();
return (
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<Switch checked={checked} onCheckedChange={onChange} />
{t("newUi.sidebar.automations.fields.elevated")}
</label>
);
}
/** Keeps the step id when the type changes, so run history stays linked. */
function changeStepType(step: Step, type: StepType): Step {
const next: Record<string, unknown> = { id: step.id, type };
switch (type) {
case "notify":
next.channelIds = [];
break;
case "http":
next.method = "POST";
next.url = "";
break;
case "run_command":
next.command = "";
next.hostSelector = { kind: "trigger" };
break;
case "run_snippet":
next.hostSelector = { kind: "trigger" };
break;
case "docker":
next.action = "restart";
next.container = "";
next.hostSelector = { kind: "trigger" };
break;
case "tunnel":
next.action = "connect";
next.tunnelName = "";
break;
case "wait":
next.seconds = 30;
break;
case "set_var":
next.name = "";
next.value = "";
break;
case "if":
next.condition = { left: "", operator: "==", right: "" };
next.then = [];
break;
default:
break;
}
return next as unknown as Step;
}
-425
View File
@@ -1,425 +0,0 @@
import { useTranslation } from "react-i18next";
import { Input } from "@/components/input";
import { Label } from "@/components/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/select";
import type { Trigger, TriggerKind } from "@/types/automations";
import { HostSelectorField } from "./HostSelectorField";
import type { AutomationEditorOptions } from "./editor-types";
const TRIGGER_KINDS: TriggerKind[] = [
"metric_threshold",
"host_status",
"health_check",
"schedule",
"docker_event",
"webhook",
];
/** Sentinel for "no zone set", which means the server's own time. */
const SERVER_TIMEZONE = "__server__";
/**
* Zones offered for a cron schedule. Uses the runtime's own list where it is
* available so the picker is complete, with a small fallback for older
* browsers that do not expose supportedValuesOf.
*/
const TIMEZONES: string[] = (() => {
try {
const supported = (
Intl as unknown as { supportedValuesOf?: (key: string) => string[] }
).supportedValuesOf?.("timeZone");
if (supported?.length) return supported;
} catch {
// Fall through to the short list.
}
return [
"UTC",
"America/New_York",
"America/Chicago",
"America/Denver",
"America/Los_Angeles",
"Europe/London",
"Europe/Berlin",
"Europe/Moscow",
"Asia/Kolkata",
"Asia/Shanghai",
"Asia/Tokyo",
"Australia/Sydney",
];
})();
const METRIC_PATHS = [
"cpu.percent",
"memory.percent",
"disk.percent",
"temperature.highestCelsius",
"cpu.load1",
"processes.total",
"network.rxRateBps",
"network.txRateBps",
];
/** The single trigger at the top of an automation. */
export function TriggerCard({
trigger,
options,
onChange,
}: {
trigger: Trigger;
options: AutomationEditorOptions;
onChange: (next: Trigger) => void;
}) {
const { t } = useTranslation();
const base = "newUi.sidebar.automations";
function patch(changes: Record<string, unknown>) {
onChange({ ...trigger, ...changes } as Trigger);
}
return (
<div className="border border-border p-3 space-y-3">
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">
{t(`${base}.trigger`)}
</Label>
<Select
value={trigger.kind}
onValueChange={(value) =>
onChange(defaultTrigger(value as TriggerKind))
}
>
<SelectTrigger size="sm" className="rounded-none border-border">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TRIGGER_KINDS.map((kind) => (
<SelectItem key={kind} value={kind}>
{t(`${base}.triggerKinds.${kind}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{trigger.kind === "metric_threshold" && (
<>
<HostSelectorField
value={trigger.hostSelector}
options={options}
allowTrigger={false}
onChange={(hostSelector) => patch({ hostSelector })}
/>
<div className="flex gap-2">
<Select
value={trigger.metric?.path ?? "cpu.percent"}
onValueChange={(value) =>
patch({ metric: { ...trigger.metric, path: value } })
}
>
<SelectTrigger
size="sm"
className="flex-1 rounded-none border-border"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{METRIC_PATHS.map((path) => (
<SelectItem key={path} value={path}>
{path}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={trigger.operator}
onValueChange={(value) => patch({ operator: value })}
>
<SelectTrigger
size="sm"
className="w-20 rounded-none border-border"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{[">", ">=", "<", "<=", "==", "!="].map((op) => (
<SelectItem key={op} value={op}>
{op}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
type="number"
className="h-8 w-24 rounded-none border-border"
value={trigger.value ?? 0}
onChange={(e) => patch({ value: Number(e.target.value) })}
/>
</div>
{trigger.metric?.path === "disk.percent" && (
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">
{t(`${base}.fields.mount`)}
</Label>
<Input
className="h-8 rounded-none border-border font-mono text-xs"
placeholder="/data"
value={trigger.metric?.mount ?? ""}
onChange={(e) =>
patch({
metric: {
...trigger.metric,
mount: e.target.value || undefined,
},
})
}
/>
</div>
)}
{trigger.metric?.path.startsWith("network.") &&
"iface" in trigger.metric && (
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">
{t(`${base}.fields.interface`)}
</Label>
<Input
className="h-8 rounded-none border-border font-mono text-xs"
placeholder="eth0"
value={trigger.metric.iface ?? ""}
onChange={(e) =>
patch({
metric: {
...trigger.metric,
iface: e.target.value || undefined,
},
})
}
/>
</div>
)}
<div className="flex gap-2">
<NumberField
label={t(`${base}.fields.forSeconds`)}
value={trigger.forSeconds ?? 0}
onChange={(value) => patch({ forSeconds: value })}
/>
<NumberField
label={t(`${base}.fields.cooldown`)}
value={trigger.cooldownMinutes ?? 15}
onChange={(value) => patch({ cooldownMinutes: value })}
/>
</div>
</>
)}
{(trigger.kind === "host_status" || trigger.kind === "health_check") && (
<>
<HostSelectorField
value={trigger.hostSelector}
options={options}
allowTrigger={false}
onChange={(hostSelector) => patch({ hostSelector })}
/>
<Select
value={trigger.to}
onValueChange={(value) => patch({ to: value })}
>
<SelectTrigger size="sm" className="rounded-none border-border">
<SelectValue />
</SelectTrigger>
<SelectContent>
{trigger.kind === "host_status" ? (
<>
<SelectItem value="offline">offline</SelectItem>
<SelectItem value="online">online</SelectItem>
</>
) : (
<>
<SelectItem value="failing">failing</SelectItem>
<SelectItem value="recovered">recovered</SelectItem>
</>
)}
</SelectContent>
</Select>
<NumberField
label={t(`${base}.fields.cooldown`)}
value={trigger.cooldownMinutes ?? 15}
onChange={(value) => patch({ cooldownMinutes: value })}
/>
</>
)}
{trigger.kind === "schedule" && (
<div className="space-y-3">
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">
{t(`${base}.fields.cron`)}
</Label>
<Input
className="h-8 rounded-none border-border font-mono text-xs"
placeholder="0 2 * * *"
value={trigger.cron ?? ""}
onChange={(e) =>
patch({ cron: e.target.value, intervalSeconds: undefined })
}
/>
</div>
<NumberField
label={t(`${base}.fields.intervalSeconds`)}
value={trigger.intervalSeconds ?? 0}
onChange={(value) =>
patch({ intervalSeconds: value || undefined, cron: undefined })
}
/>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">
{t(`${base}.fields.timezone`)}
</Label>
<Select
value={trigger.timezone ?? SERVER_TIMEZONE}
onValueChange={(value) =>
patch({
timezone: value === SERVER_TIMEZONE ? undefined : value,
})
}
>
<SelectTrigger
size="sm"
className="w-full rounded-none border-border"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={SERVER_TIMEZONE}>
{t(`${base}.fields.timezoneServer`)}
</SelectItem>
{TIMEZONES.map((zone) => (
<SelectItem key={zone} value={zone}>
{zone}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{t(`${base}.fields.timezoneHint`)}
</p>
</div>
</div>
)}
{trigger.kind === "docker_event" && (
<>
<HostSelectorField
value={trigger.hostSelector}
options={options}
allowTrigger={false}
onChange={(hostSelector) => patch({ hostSelector })}
/>
<div className="flex gap-2">
<Input
className="h-8 flex-1 rounded-none border-border"
placeholder={t(`${base}.fields.container`)}
value={trigger.container ?? ""}
onChange={(e) => patch({ container: e.target.value })}
/>
<Select
value={trigger.event}
onValueChange={(value) => patch({ event: value })}
>
<SelectTrigger
size="sm"
className="min-w-0 flex-1 rounded-none border-border"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{["exited", "started", "unhealthy", "restarting"].map((e) => (
<SelectItem key={e} value={e}>
{e}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</>
)}
{trigger.kind === "webhook" && (
<p className="text-xs text-muted-foreground">
{t(`${base}.webhookTokenDescription`)}
</p>
)}
</div>
);
}
function NumberField({
label,
value,
onChange,
}: {
label: string;
value: number;
onChange: (value: number) => void;
}) {
return (
<div className="space-y-1 flex-1">
<Label className="text-xs text-muted-foreground">{label}</Label>
<Input
type="number"
min={0}
className="h-8 rounded-none border-border"
value={value}
onChange={(e) => onChange(Number(e.target.value))}
/>
</div>
);
}
export function defaultTrigger(kind: TriggerKind): Trigger {
switch (kind) {
case "host_status":
return {
kind: "host_status",
hostSelector: { kind: "all" },
to: "offline",
cooldownMinutes: 15,
};
case "health_check":
return {
kind: "health_check",
hostSelector: { kind: "all" },
to: "failing",
cooldownMinutes: 15,
};
case "schedule":
return { kind: "schedule", cron: "0 2 * * *" };
case "docker_event":
return {
kind: "docker_event",
hostSelector: { kind: "all" },
event: "exited",
cooldownMinutes: 15,
};
case "webhook":
return { kind: "webhook", tokenHash: "" };
default:
return {
kind: "metric_threshold",
hostSelector: { kind: "all" },
metric: { path: "cpu.percent" },
operator: ">",
value: 90,
forSeconds: 300,
cooldownMinutes: 15,
};
}
}
@@ -1,19 +0,0 @@
/** Lookup data the editor needs to turn ids into names. */
export interface AutomationEditorOptions {
hosts: Array<{ id: number; name: string }>;
snippets: Array<{ id: number; name: string }>;
channels: Array<{ id: number; name: string }>;
fleets: Array<{ id: number; name: string }>;
}
export const EMPTY_EDITOR_OPTIONS: AutomationEditorOptions = {
hosts: [],
snippets: [],
channels: [],
fleets: [],
};
/** Short unique id for a new step, stable for the life of the automation. */
export function newStepId(): string {
return `s${Math.random().toString(36).slice(2, 8)}`;
}
+1 -1
View File
@@ -1,6 +1,6 @@
export const BRANDING_LOGO_MAX_BYTES = 750 * 1024;
export const BRANDING_LOGO_ACCEPT = "image/png,image/jpeg,image/webp";
export const BRANDING_LOGO_ACCEPTED_TYPES = new Set([
const BRANDING_LOGO_ACCEPTED_TYPES = new Set([
"image/png",
"image/jpeg",
"image/webp",
+2
View File
@@ -27,6 +27,7 @@ export function buildHostTree(
icon?: string;
credentialId?: number | null;
sortOrder?: number | null;
localOnly?: boolean;
}
>,
): HostFolder {
@@ -48,6 +49,7 @@ export function buildHostTree(
icon: meta?.icon,
credentialId: meta?.credentialId ?? null,
sortOrder: meta?.sortOrder ?? null,
localOnly: !!meta?.localOnly,
children: [],
};
folderMap.set(accumulated, folder);
+1 -1
View File
@@ -55,7 +55,7 @@ export function sortCredentials(
export function credentialPassesFilters(
cred: Credential,
filters: { type: ("password" | "key")[]; tags: string[] },
filters: { type: string[]; tags: string[] },
): boolean {
if (filters.type.length > 0 && !filters.type.includes(cred.type))
return false;
@@ -1,3 +1,4 @@
import { rem } from "@/lib/rem";
import { useTranslation } from "react-i18next";
import {
ChevronRight,
@@ -12,20 +13,13 @@ import {
} from "lucide-react";
import { toast } from "sonner";
import { copyToClipboard } from "@/lib/clipboard";
import { ComponentSlot } from "@/shell/ActionSlot";
import type { Credential } from "@/types/ui-types";
import type {
CredentialDensity,
CredentialTrayTrigger,
} from "@/types/credential-sidebar-preferences";
export function credentialMatchesQuery(cred: Credential, query: string) {
return (
cred.name.toLowerCase().includes(query) ||
cred.username.toLowerCase().includes(query) ||
cred.tags?.some((t) => t.toLowerCase().includes(query))
);
}
/**
* Per-density layout knobs, mirroring HOST_ITEM_DENSITY_TOKENS in
* HostItem.tsx -- one implementation with a token lookup instead of two
@@ -49,11 +43,8 @@ const CREDENTIAL_ITEM_DENSITY_TOKENS = {
export function CredentialItem({
cred,
usedByCount = 0,
termixIdLinked = false,
query = "",
stripeIndex = 0,
isMenuOpen = false,
onMenuOpenChange,
isTrayOpen = false,
onTrayOpenChange,
onDragStart,
@@ -76,7 +67,6 @@ export function CredentialItem({
}: {
cred: Credential;
usedByCount?: number;
termixIdLinked?: boolean;
query?: string;
stripeIndex?: number;
isMenuOpen?: boolean;
@@ -123,7 +113,7 @@ export function CredentialItem({
const canDrag = arrangeMode && !isTouchOnly;
const depthStyle =
depth > 0 ? ({ paddingLeft: depth * 12 } as const) : undefined;
depth > 0 ? ({ paddingLeft: rem(depth * 12) } as const) : undefined;
const trayButtonClass =
"flex items-center justify-center size-6.5 text-muted-foreground/60 hover:text-foreground hover:bg-muted transition-colors";
@@ -308,11 +298,10 @@ export function CredentialItem({
>
{isKey ? t("credentials.keyBadge") : t("credentials.passwordBadge")}
</span>
{termixIdLinked && (
<span className="text-[9px] px-1 py-px font-bold border leading-none shrink-0 border-accent-brand/30 text-accent-brand/70">
{t("credentials.idBadge")}
</span>
)}
<ComponentSlot
slotId="credentials.badges"
props={{ credentialId: Number(cred.id) }}
/>
{cred.pin && (
<Pin className="size-2.5 text-accent-brand/50 shrink-0" />
)}
@@ -346,8 +335,9 @@ export function CredentialItem({
<span className="text-[11px] text-muted-foreground/60 truncate leading-none font-mono">
{cred.username}
{usedByCount > 0 && (
<span className="text-muted-foreground/40">
{cred.username ? " · " : ""}
<span
className={`text-muted-foreground/40${cred.username ? " ml-2" : ""}`}
>
{usedByCount}h
</span>
)}
@@ -35,7 +35,6 @@ export function CredentialSidebarTree({
onDeleteCredential,
onShareCredential,
usedByCounts,
termixIdLinkedIds,
query = "",
loading = false,
arrangeLocked = true,
@@ -57,7 +56,6 @@ export function CredentialSidebarTree({
onDeleteCredential: (cred: Credential) => void;
onShareCredential?: (cred: Credential) => void;
usedByCounts?: Map<string, number>;
termixIdLinkedIds?: Set<number>;
query?: string;
loading?: boolean;
/** When true, drag-to-rearrange is off entirely. Toggled from the panel header. */
@@ -398,7 +396,6 @@ export function CredentialSidebarTree({
<CredentialItem
cred={item}
usedByCount={usedByCounts?.get(item.id) ?? 0}
termixIdLinked={termixIdLinkedIds?.has(Number(item.id))}
query={query}
stripeIndex={vItem.index}
isMenuOpen={openMenuCredentialId === item.id}
@@ -1,3 +1,4 @@
import { rem } from "@/lib/rem";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import {
@@ -64,7 +65,7 @@ export function CredentialFolderItem({
return (
<div
className="relative"
style={depth > 0 ? { paddingLeft: depth * 12 } : undefined}
style={depth > 0 ? { paddingLeft: rem(depth * 12) } : undefined}
>
<div
onDragOver={(e) => {
+1 -1
View File
@@ -1,2 +1,2 @@
export { CredentialSidebarTree } from "./CredentialSidebarTree";
export { isFolder, type CredentialFolder } from "./visible-rows";
export { type CredentialFolder } from "./visible-rows";
@@ -17,7 +17,7 @@ export function isFolder(
return "children" in item;
}
export function credentialMatchesQuery(cred: Credential, query: string) {
function credentialMatchesQuery(cred: Credential, query: string) {
return (
cred.name.toLowerCase().includes(query) ||
cred.username.toLowerCase().includes(query) ||
+124
View File
@@ -0,0 +1,124 @@
import type { ComponentType } from "react";
import type { Host } from "@/types/ui-types";
import { byOrderThenId, createRegistry } from "@/lib/registry";
import type { TabShellCallbacks } from "@/shell/tab-registry";
type Icon = ComponentType<{ className?: string; size?: number | string }>;
/**
* What a plugin adds to a host: ways to connect or open a tool, badges on its
* row, and entries in its context menu. The host list, quick connect and the
* command palette all read these instead of knowing about each protocol.
*/
export interface HostActionDef {
id: string;
pluginId?: string;
titleKey: string;
icon: Icon;
kind: "connect" | "open";
priority?: number;
tabType?: string;
when: (host: Host) => boolean;
run?: (host: Host, shell: TabShellCallbacks) => void;
tray?: boolean;
copyUrlView?: string;
/** Where the dashboard's host status list sends a click. */
overview?: boolean;
/** Offered as a button in Quick Connect, opening tabType. */
quickConnect?: boolean;
order?: number;
/** A label worked out per host, e.g. a single endpoint's name. */
label?: (host: Host) => string | undefined;
/** Several targets: two or more become a picker, one runs directly. */
items?: (host: Host) => {
id: string;
label: string;
run: (host: Host, shell: TabShellCallbacks) => void;
}[];
}
export interface HostBadgeDef {
id: string;
pluginId?: string;
when: (host: Host) => boolean;
component: ComponentType<{ host: Host }>;
order?: number;
}
export interface HostContextMenuItemDef {
id: string;
pluginId?: string;
titleKey: string;
icon?: Icon;
when: (host: Host) => boolean;
run: (host: Host, shell: TabShellCallbacks) => void;
order?: number;
}
const actions = createRegistry<HostActionDef>(byOrderThenId);
const badges = createRegistry<HostBadgeDef>(byOrderThenId);
const menuItems = createRegistry<HostContextMenuItemDef>(byOrderThenId);
export const registerHostAction = actions.register;
export const useHostActions = actions.useList;
export const listHostActions = actions.list;
export const registerHostBadge = badges.register;
export const useHostBadges = badges.useList;
export const registerHostContextMenuItem = menuItems.register;
export const useHostContextMenuItems = menuItems.useList;
/** A predicate from a plugin must never take the host list down with it. */
function safeWhen(predicate: (host: Host) => boolean, host: Host): boolean {
try {
return predicate(host);
} catch {
return false;
}
}
export function hostActionsFor(
all: HostActionDef[],
host: Host,
kind?: HostActionDef["kind"],
): HostActionDef[] {
return all.filter(
(action) => (!kind || action.kind === kind) && safeWhen(action.when, host),
);
}
/** The connect action a click on the host should run, by priority. */
export function defaultConnectAction(
all: HostActionDef[],
host: Host,
): HostActionDef | undefined {
return hostActionsFor(all, host, "connect").sort(
(a, b) => (b.priority ?? 0) - (a.priority ?? 0),
)[0];
}
export function hostBadgesFor(all: HostBadgeDef[], host: Host): HostBadgeDef[] {
return all.filter((badge) => safeWhen(badge.when, host));
}
export function hostMenuItemsFor(
all: HostContextMenuItemDef[],
host: Host,
): HostContextMenuItemDef[] {
return all.filter((item) => safeWhen(item.when, host));
}
/** Runs an action, falling back to opening its tab type. */
export function runHostAction(
action: HostActionDef,
host: Host,
shell: TabShellCallbacks,
): void {
if (action.run) action.run(host, shell);
else if (action.tabType) shell.openTab(host, action.tabType);
}
export function resetHostContributions(): void {
actions.reset();
badges.reset();
menuItems.reset();
}
@@ -0,0 +1,125 @@
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { ArrowLeft } from "lucide-react";
import type { Host } from "@/types/ui-types";
import type { HostDefaultsTarget } from "@/api/host-defaults-api";
import { HostEditor } from "../HostEditor";
import {
isSshGroupTab,
makeHostSshSubTabs,
makeHostTabs,
TabStrip,
useHostEditorSections,
} from "../HostManagerTabs";
import { listHostProtocols, type HostProtocols } from "../host-protocols";
export interface HostDefaultsEditorTarget extends HostDefaultsTarget {
/** The folder's path, shown as the title. */
folderName?: string;
}
/**
* The host editor as a form for one level of host defaults: the same tabs
* and fields as a new host, minus what only one host can have (its name,
* address, secrets, folder).
*/
export function HostDefaultsEditorView({
target,
onClose,
onDirtyChange,
hosts,
credentials,
}: {
target: HostDefaultsEditorTarget;
onClose: () => void;
onDirtyChange?: (dirty: boolean) => void;
hosts: Host[];
credentials: { id: string; name: string; username: string }[];
}) {
const { t } = useTranslation();
useHostEditorSections();
const [activeTab, setActiveTab] = useState("general");
// Every protocol counts as on, so every tab that has defaults shows.
const protocols = useMemo<HostProtocols>(() => {
const all: HostProtocols = { enableSsh: true };
for (const protocol of listHostProtocols()) all[protocol.settingKey] = true;
return all;
}, []);
const flags = protocols as unknown as Record<string, boolean>;
const tabs = makeHostTabs(t, flags, true);
const sshSubTabs = makeHostSshSubTabs(t, flags, true);
const inSshGroup = isSshGroupTab(activeTab);
const title =
target.level === "admin"
? t("hostDefaults.titleAdmin")
: target.level === "user"
? t("hostDefaults.titleUser")
: t("hostDefaults.titleFolder", { name: target.folderName ?? "" });
const description =
target.level === "admin"
? t("hostDefaults.descriptionAdmin")
: target.level === "user"
? t("hostDefaults.descriptionUser")
: t("hostDefaults.descriptionFolder");
return (
<div className="flex flex-col flex-1 min-h-0">
<div className="flex flex-col shrink-0 border-b border-border">
<button
onClick={onClose}
className="flex items-center gap-2 px-3 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/40 transition-colors border-b border-border/50"
>
<ArrowLeft className="size-3.5 shrink-0" />
<span>{t("hosts.backToHosts")}</span>
<span
className="ml-auto font-semibold text-foreground truncate max-w-[220px]"
title={title}
>
{title}
</span>
</button>
<TabStrip
tabs={tabs}
activeTab={activeTab}
onTabChange={(id) => {
if (id === "ssh") {
if (!inSshGroup) setActiveTab("ssh");
} else {
setActiveTab(id);
}
}}
isActive={(id) => (id === "ssh" ? inSshGroup : activeTab === id)}
/>
{inSshGroup && (
<TabStrip
tabs={sshSubTabs}
activeTab={activeTab}
onTabChange={setActiveTab}
variant="secondary"
/>
)}
</div>
<div className="flex-1 min-h-0 overflow-y-auto px-3 py-3 flex flex-col gap-3">
<p className="text-xs text-muted-foreground border border-border bg-muted/20 px-3 py-2">
{description}
</p>
<HostEditor
key={`${target.level}:${target.folderId ?? ""}`}
host={null}
activeTab={activeTab}
onBack={onClose}
onSave={onClose}
protocols={protocols}
onProtocolChange={() => {}}
onDirtyChange={onDirtyChange}
onTabChange={setActiveTab}
hosts={hosts}
credentials={credentials}
defaultsTarget={target}
/>
</div>
</div>
);
}
+96
View File
@@ -0,0 +1,96 @@
/**
* The keys the host editor can show a default for: core's own, and every
* running plugin's host fields that can have one.
*/
import { useEffect, useMemo, useState } from "react";
import {
CORE_HOST_DEFAULTS,
CORE_NAMESPACE,
HOST_DEFAULTS_LEVELS,
type HostDefaultsLevel,
} from "@/types/host-defaults";
import {
getPlugins,
type PluginSettingsField,
type PluginSummary,
} from "@/api/plugins-api";
export interface EditorDefaultKey {
fullKey: string;
namespace: string;
key: string;
levels: readonly HostDefaultsLevel[];
}
export function isDefaultableField(field: PluginSettingsField): boolean {
if (field.type === "secret") return false;
if (field.type === "json" && field.secretKeys?.length) return false;
return field.defaultable !== false;
}
export function pluginDefaultKeys(
plugins: PluginSummary[],
): EditorDefaultKey[] {
const keys: EditorDefaultKey[] = [];
for (const plugin of plugins) {
if (!plugin.enabled) continue;
const host = plugin.contributes?.settings?.host;
if (!host) continue;
if (host.enableKey) {
keys.push({
fullKey: `${plugin.id}.${host.enableKey}`,
namespace: plugin.id,
key: host.enableKey,
levels: HOST_DEFAULTS_LEVELS,
});
}
for (const field of host.fields) {
if (!isDefaultableField(field)) continue;
keys.push({
fullKey: `${plugin.id}.${field.key}`,
namespace: plugin.id,
key: field.key,
levels: field.defaultLevels?.length
? field.defaultLevels
: HOST_DEFAULTS_LEVELS,
});
}
}
return keys;
}
export const CORE_EDITOR_KEYS: EditorDefaultKey[] = CORE_HOST_DEFAULTS.map(
(entry) => ({
fullKey: `${CORE_NAMESPACE}.${entry.key}`,
namespace: CORE_NAMESPACE,
key: entry.key,
levels: entry.levels,
}),
);
/** Every key, by full key. Refreshed when the plugin list is. */
export function useEditorDefaultKeys(): Map<string, EditorDefaultKey> {
const [plugins, setPlugins] = useState<PluginSummary[]>([]);
useEffect(() => {
let cancelled = false;
void getPlugins()
.then((loaded) => {
if (!cancelled) setPlugins(loaded);
})
.catch(() => {});
return () => {
cancelled = true;
};
}, []);
return useMemo(
() =>
new Map(
[...CORE_EDITOR_KEYS, ...pluginDefaultKeys(plugins)].map((entry) => [
entry.fullKey,
entry,
]),
),
[plugins],
);
}
@@ -0,0 +1,294 @@
/**
* Between the host editor's form and host default keys: what value a key has
* on the form, and how to put a default's value back on it.
*
* Core keys use the API's shape ("core.auth" is an object, "core.socks5"
* too); plugin keys are the plugin's own field values.
*/
import {
CORE_NAMESPACE,
CORE_HOST_DEFAULTS,
defaultValuesEqual,
findCoreHostDefault,
splitDefaultKey,
type DefaultOverrides,
} from "@/types/host-defaults";
import type { HostEditorForm } from "../HostEditorData";
type Form = HostEditorForm;
type PluginValues = Record<string, Record<string, unknown>>;
export const CORE_DEFAULT_KEYS = CORE_HOST_DEFAULTS.map(
(entry) => `${CORE_NAMESPACE}.${entry.key}`,
);
function coreValue(form: Form, key: string): unknown {
switch (key) {
case "username":
return form.username;
case "sshPort":
return form.sshPort;
case "auth":
return {
authType: form.authType,
credentialId: form.credentialId ? Number(form.credentialId) : null,
overrideCredentialUsername: form.overrideCredentialUsername,
agentSocketPath: form.agentSocketPath || null,
agentIdentity: form.agentIdentity || null,
};
case "forceKeyboardInteractive":
case "keepaliveInterval":
case "keepaliveCountMax":
case "allowLegacyAlgorithms":
case "agentForwarding":
case "environmentVariables":
case "portKnockSequence":
case "statusCheckEnabled":
case "statusCheckInterval":
return form[key as keyof Form];
case "socks5":
return {
useSocks5: form.useSocks5,
socks5Host:
form.socks5ProxyMode === "single" ? form.socks5Host || null : null,
socks5Port:
form.socks5ProxyMode === "single" ? form.socks5Port || null : null,
socks5Username:
form.socks5ProxyMode === "single"
? form.socks5Username || null
: null,
socks5ProxyChain:
form.socks5ProxyMode === "chain" ? form.socks5ProxyChain : null,
};
case "jumpHosts":
return form.jumpHosts.map((jump) => ({ hostId: Number(jump.hostId) }));
default:
return undefined;
}
}
/** A key's value as the form holds it, normalized for core keys. */
export function formValueForKey(form: Form, fullKey: string): unknown {
const [namespace, key] = splitDefaultKey(fullKey);
if (namespace === CORE_NAMESPACE) {
const entry = findCoreHostDefault(key);
return entry ? entry.normalize(coreValue(form, key)) : undefined;
}
return (form.pluginSettings as PluginValues)?.[namespace]?.[key];
}
/** The form with one key set to a default's value. */
export function applyDefaultToForm(
form: Form,
fullKey: string,
value: unknown,
): Form {
const [namespace, key] = splitDefaultKey(fullKey);
if (namespace !== CORE_NAMESPACE) {
const all = (form.pluginSettings ?? {}) as PluginValues;
return {
...form,
pluginSettings: {
...all,
[namespace]: { ...(all[namespace] ?? {}), [key]: value },
},
};
}
const entry = findCoreHostDefault(key);
const normalized = entry ? entry.normalize(value) : value;
switch (key) {
case "username":
return { ...form, username: (normalized as string | null) ?? "" };
case "sshPort":
return { ...form, sshPort: normalized as number };
case "auth": {
const auth = normalized as {
authType: string;
credentialId: number | null;
overrideCredentialUsername: boolean;
agentSocketPath: string | null;
agentIdentity: string | null;
} | null;
if (!auth) return form;
return {
...form,
authType: auth.authType as Form["authType"],
credentialId:
auth.credentialId !== null ? String(auth.credentialId) : "",
overrideCredentialUsername: auth.overrideCredentialUsername,
agentSocketPath: auth.agentSocketPath ?? "",
agentIdentity: auth.agentIdentity ?? "",
};
}
case "socks5": {
const socks = normalized as {
useSocks5: boolean;
socks5Host: string | null;
socks5Port: number | null;
socks5Username: string | null;
socks5ProxyChain: Form["socks5ProxyChain"] | null;
};
const chain = socks.socks5ProxyChain ?? [];
return {
...form,
useSocks5: socks.useSocks5,
socks5Host: socks.socks5Host ?? "",
socks5Port: socks.socks5Port ?? 1080,
socks5Username: socks.socks5Username ?? "",
socks5ProxyMode: chain.length > 0 ? "chain" : "single",
socks5ProxyChain: chain.map((node, index) => ({
...node,
// The host's own proxy passwords stay with it.
...(form.socks5ProxyChain[index]?.password
? { password: form.socks5ProxyChain[index].password }
: {}),
})),
};
}
case "jumpHosts":
return {
...form,
jumpHosts: (normalized as Array<{ hostId: number }>).map((jump) => ({
hostId: String(jump.hostId),
})),
};
default:
return { ...form, [key]: normalized } as Form;
}
}
/** Keys whose value differs between two forms. */
export function changedKeys(
before: Form,
after: Form,
keys: Iterable<string>,
): string[] {
const changed: string[] = [];
for (const fullKey of keys) {
if (
!defaultValuesEqual(
formValueForKey(before, fullKey),
formValueForKey(after, fullKey),
)
) {
changed.push(fullKey);
}
}
return changed;
}
export function withOwnKeys(
overrides: DefaultOverrides,
keys: string[],
): DefaultOverrides {
if (keys.length === 0) return overrides;
const next: DefaultOverrides = { ...overrides };
for (const fullKey of keys) {
const [namespace, key] = splitDefaultKey(fullKey);
const own = new Set(next[namespace] ?? []);
own.add(key);
next[namespace] = [...own].sort();
}
return next;
}
export function withoutOwnKey(
overrides: DefaultOverrides,
fullKey: string,
): DefaultOverrides {
const [namespace, key] = splitDefaultKey(fullKey);
return {
...overrides,
[namespace]: (overrides[namespace] ?? []).filter((own) => own !== key),
};
}
export function isOwnDefault(
overrides: DefaultOverrides | null | undefined,
fullKey: string,
): boolean {
const [namespace, key] = splitDefaultKey(fullKey);
return !!overrides?.[namespace]?.includes(key);
}
/** Every key the overrides mark, as "namespace.key". */
export function ownDefaultKeys(
overrides: DefaultOverrides | null | undefined,
): string[] {
return Object.entries(overrides ?? {}).flatMap(([namespace, keys]) =>
keys.map((key) => `${namespace}.${key}`),
);
}
function isEmpty(value: unknown): boolean {
if (value === undefined || value === null || value === "") return true;
return Array.isArray(value) && value.length === 0;
}
/**
* Which keys a form's values make its own: those that differ from what
* resolves, and those with a value where nothing resolves.
*/
export function classifyForm(
form: Form,
resolved: Record<string, { value: unknown }>,
keys: Iterable<string>,
): DefaultOverrides {
const overrides: DefaultOverrides = { [CORE_NAMESPACE]: [] };
for (const fullKey of keys) {
const [namespace, key] = splitDefaultKey(fullKey);
overrides[namespace] ??= [];
const value = formValueForKey(form, fullKey);
const entry = resolved[fullKey];
const own = entry
? !defaultValuesEqual(value, entry.value)
: !isEmpty(value);
if (own) overrides[namespace].push(key);
}
return overrides;
}
/** Every namespace the editor knows is listed, so none is left to guess. */
export function withAllNamespaces(
overrides: DefaultOverrides,
keys: Map<string, { namespace: string }>,
): DefaultOverrides {
const next: DefaultOverrides = { [CORE_NAMESPACE]: [], ...overrides };
for (const entry of keys.values()) next[entry.namespace] ??= [];
return next;
}
/**
* The plugin values a save sends: a host's own values, and every value no
* default covers (a protocol switch). The server writes the rest.
*/
export function ownPluginValues(
values: PluginValues | undefined,
overrides: DefaultOverrides | null,
keys: Map<string, unknown>,
): PluginValues | undefined {
if (!values || overrides === null) return values;
const result: PluginValues = {};
for (const [pluginId, fields] of Object.entries(values)) {
const kept = Object.fromEntries(
Object.entries(fields ?? {}).filter(([key]) => {
const fullKey = `${pluginId}.${key}`;
return !keys.has(fullKey) || isOwnDefault(overrides, fullKey);
}),
);
if (Object.keys(kept).length > 0) result[pluginId] = kept;
}
return result;
}
/** A plugin's draft for a new host makes what it filled in the host's own. */
export function draftOwnKeys(
draft: { port?: unknown; username?: unknown; authType?: unknown } | undefined,
): DefaultOverrides {
const own: string[] = [];
if (draft?.port !== undefined) own.push("sshPort");
if (draft?.username !== undefined) own.push("username");
if (draft?.authType !== undefined) own.push("auth");
return { [CORE_NAMESPACE]: own };
}
@@ -0,0 +1,34 @@
import {
CORE_NAMESPACE,
splitDefaultKey,
type ResolvedHostDefault,
} from "@/types/host-defaults";
import type { Host } from "@/types/ui-types";
type PluginValues = Record<string, Record<string, unknown>>;
/** The plugin keys of a resolved set, in the host payload's shape. */
export function pluginSettingsFrom(
resolved: Record<string, ResolvedHostDefault>,
): PluginValues {
const result: PluginValues = {};
for (const [fullKey, entry] of Object.entries(resolved)) {
const [namespace, key] = splitDefaultKey(fullKey);
if (namespace === CORE_NAMESPACE) continue;
result[namespace] = { ...(result[namespace] ?? {}), [key]: entry.value };
}
return result;
}
/** A quick connect host with the defaults under whatever it set itself. */
export function withDefaultPluginSettings(
host: Host,
defaults: PluginValues,
): Host {
const own = host.pluginSettings ?? {};
const merged: PluginValues = { ...defaults };
for (const [pluginId, values] of Object.entries(own)) {
merged[pluginId] = { ...(defaults[pluginId] ?? {}), ...values };
}
return { ...host, pluginSettings: merged };
}
+75 -53
View File
@@ -2,9 +2,8 @@ export type FieldGroup =
| "connection"
| "notes"
| "tags"
| "tunnels"
| "proxy"
| "jumpHosts"
| "quickActions"
| "featureFlags"
| "advanced";
@@ -15,7 +14,7 @@ export interface ExportPayload {
hosts: Record<string, unknown>[];
}
export const FIELD_GROUP_KEYS: Record<FieldGroup, string[]> = {
const FIELD_GROUP_KEYS: Record<FieldGroup, string[]> = {
connection: [
"connectionType",
"name",
@@ -29,8 +28,7 @@ export const FIELD_GROUP_KEYS: Record<FieldGroup, string[]> = {
],
notes: ["notes"],
tags: ["tags", "pin"],
tunnels: [
"tunnelConnections",
proxy: [
"useSocks5",
"socks5Host",
"socks5Port",
@@ -38,33 +36,13 @@ export const FIELD_GROUP_KEYS: Record<FieldGroup, string[]> = {
"socks5ProxyChain",
],
jumpHosts: ["jumpHosts"],
quickActions: ["quickActions"],
featureFlags: [
"enableTerminal",
"enableCommandHistory",
"enableTerminalToolbar",
"enableAiAssistant",
"enableTunnel",
"enableFileManager",
"enableDocker",
"enableWebUi",
"enableProxmox",
"enableTmuxMonitor",
"showTerminalInSidebar",
"showFileManagerInSidebar",
"showTunnelInSidebar",
"showDockerInSidebar",
"showServerStatsInSidebar",
"defaultPath",
"forceKeyboardInteractive",
],
// Every plugin's host settings, enable switches included.
featureFlags: ["pluginSettings", "forceKeyboardInteractive"],
advanced: [
"statsConfig",
"dockerConfig",
"webUiConfig",
"proxmoxConfig",
"statusCheckEnabled",
"statusCheckInterval",
"terminalConfig",
"guacamoleConfig",
"sshOptions",
],
};
@@ -87,23 +65,63 @@ const CREDENTIAL_KEYS = [
const TUPLE_KEYS = ["name", "ip", "port", "username", "connectionType"];
const NESTED_SECRETS: { container: string; field: string }[] = [
{ container: "guacamoleConfig", field: "gateway-password" },
];
/**
* Keys inside a plugin's JSON host settings that hold secrets, per plugin and
* field, from each manifest's `secretKeys`.
*/
export type PluginSecretKeys = Record<string, Record<string, string[]>>;
/** Reads the secret keys the plugins declare in their host settings. */
export function pluginSecretKeys(
summaries: Array<{
id: string;
contributes?: {
settings?: {
host?: { fields?: Array<{ key: string; secretKeys?: string[] }> };
};
} | null;
}>,
): PluginSecretKeys {
const result: PluginSecretKeys = {};
for (const summary of summaries) {
for (const field of summary.contributes?.settings?.host?.fields ?? []) {
if (!field.secretKeys?.length) continue;
(result[summary.id] ??= {})[field.key] = field.secretKeys;
}
}
return result;
}
const NESTED_SECRET_ARRAYS: { container: string; field: string }[] = [
{ container: "socks5ProxyChain", field: "password" },
];
function nestedSecret(
host: Record<string, unknown>,
container: string,
field: string,
): Record<string, unknown> | null {
const blob = host[container];
if (!blob || typeof blob !== "object") return null;
const record = blob as Record<string, unknown>;
return field in record ? record : null;
/** Rewrites each declared secret inside a plugin's JSON host settings. */
function mapPluginSecrets(
pluginSettings: unknown,
secrets: PluginSecretKeys,
replace: (value: unknown) => unknown,
): unknown {
if (!pluginSettings || typeof pluginSettings !== "object") {
return pluginSettings;
}
const perPlugin: Record<string, unknown> = {};
for (const [pluginId, values] of Object.entries(
pluginSettings as Record<string, unknown>,
)) {
const copy = { ...(values as Record<string, unknown>) };
for (const [field, keys] of Object.entries(secrets[pluginId] ?? {})) {
const blob = copy[field];
if (!blob || typeof blob !== "object") continue;
const record = { ...(blob as Record<string, unknown>) };
for (const key of keys) {
if (key in record) record[key] = replace(record[key]);
}
copy[field] = record;
}
perPlugin[pluginId] = copy;
}
return perPlugin;
}
export function hostKey(host: Record<string, unknown>): string {
@@ -115,6 +133,7 @@ export function buildExportPayload(
selected: Set<string> | null,
groups: Set<FieldGroup>,
withCredentials: boolean,
pluginSecrets: PluginSecretKeys = {},
): ExportPayload {
const allowed = new Set<string>([
"exportId",
@@ -133,10 +152,11 @@ export function buildExportPayload(
if (allowed.has(key)) shaped[key] = value;
}
if (!withCredentials) {
for (const { container, field } of NESTED_SECRETS) {
const record = nestedSecret(shaped, container, field);
if (record) shaped[container] = { ...record, [field]: null };
}
shaped.pluginSettings = mapPluginSecrets(
shaped.pluginSettings,
pluginSecrets,
() => null,
);
for (const { container, field } of NESTED_SECRET_ARRAYS) {
const arr = shaped[container];
if (Array.isArray(arr)) {
@@ -165,7 +185,10 @@ export function buildExportPayload(
return result;
}
export function maskSecrets(payload: ExportPayload): ExportPayload {
export function maskSecrets(
payload: ExportPayload,
pluginSecrets: PluginSecretKeys = {},
): ExportPayload {
return {
...payload,
hosts: payload.hosts.map((host) => {
@@ -179,12 +202,11 @@ export function maskSecrets(payload: ExportPayload): ExportPayload {
masked[key] = "<included>";
}
}
for (const { container, field } of NESTED_SECRETS) {
const record = nestedSecret(masked, container, field);
if (record && record[field]) {
masked[container] = { ...record, [field]: "<included>" };
}
}
masked.pluginSettings = mapPluginSecrets(
masked.pluginSettings,
pluginSecrets,
(value) => (value ? "<included>" : value),
);
for (const { container, field } of NESTED_SECRET_ARRAYS) {
const arr = masked[container];
if (Array.isArray(arr)) {
+29 -8
View File
@@ -1,9 +1,13 @@
import type { Host, SharePermissionLevel } from "@/types/ui-types";
import {
AUTH_PROTOCOL_METADATA,
isSupportedAuthOverrideProtocol,
SSH_AUTH_PROTOCOL,
type AuthOverrideProtocol,
} from "@/types/auth-protocols";
import {
listHostProtocols,
protocolEnabled,
type HostProtocolDef,
} from "./host-protocols";
const LEVEL_RANK: Record<SharePermissionLevel, number> = {
connect: 1,
@@ -32,14 +36,31 @@ export function canDeleteHost(host: Host): boolean {
return !host.isShared;
}
/** SSH, then every protocol a running plugin registered. */
export function authOverrideProtocols(
list: HostProtocolDef[] = listHostProtocols(),
): AuthOverrideProtocol[] {
return [SSH_AUTH_PROTOCOL, ...list.map((protocol) => protocol.id)];
}
/** How a protocol is named in the override menu and dialog. */
export function authProtocolLabel(
protocol: AuthOverrideProtocol,
t: (key: string) => string,
list: HostProtocolDef[] = listHostProtocols(),
): string {
if (protocol === SSH_AUTH_PROTOCOL) return "SSH";
const def = list.find((entry) => entry.id === protocol);
return def ? t(def.titleKey) : protocol;
}
export function canOverrideHostAuth(
host: Host,
protocol: AuthOverrideProtocol,
): boolean {
const enableField = AUTH_PROTOCOL_METADATA[protocol].enableField;
return (
!!host.isShared &&
isSupportedAuthOverrideProtocol(protocol) &&
!!host[enableField]
);
if (!host.isShared) return false;
if (protocol === SSH_AUTH_PROTOCOL) return !!host.enableSsh;
// Other protocols are switched on in their plugin's host settings.
const plugin = listHostProtocols().find((entry) => entry.id === protocol);
return !!plugin && protocolEnabled(host.pluginSettings, plugin);
}
+103
View File
@@ -0,0 +1,103 @@
import type { ComponentType } from "react";
import { byOrderThenId, createRegistry } from "@/lib/registry";
type Icon = ComponentType<{ className?: string; size?: number | string }>;
/**
* A connection protocol a plugin adds next to SSH. Its on/off
* switch and port are the plugin's own host settings, so core reads them
* from host.pluginSettings[pluginId] and never knows the protocol by name.
*/
export interface HostProtocolDef {
id: string;
pluginId: string;
/** The plugin's boolean host setting that switches the protocol on. */
settingKey: string;
/** The plugin's number host setting holding the port, if it has one. */
portKey?: string;
defaultPort: number;
titleKey: string;
descriptionKey?: string;
connectionOriginNoteKey?: string;
icon: Icon;
order?: number;
/** Offer it in Quick Connect, optionally with a domain field. */
quickConnect?: { showDomain?: boolean };
}
const protocols = createRegistry<HostProtocolDef>(byOrderThenId);
export const registerHostProtocol = protocols.register;
export const useHostProtocols = protocols.useList;
export const listHostProtocols = protocols.list;
/** Which protocols are on, keyed by setting key, with SSH as enableSsh. */
export type HostProtocols = { enableSsh: boolean } & Record<string, boolean>;
type PluginSettingsBag = Record<string, Record<string, unknown>> | undefined;
function settingsOf(
pluginSettings: PluginSettingsBag,
protocol: HostProtocolDef,
): Record<string, unknown> {
return pluginSettings?.[protocol.pluginId] ?? {};
}
export function protocolEnabled(
pluginSettings: PluginSettingsBag,
protocol: HostProtocolDef,
): boolean {
return settingsOf(pluginSettings, protocol)[protocol.settingKey] === true;
}
export function protocolPort(
pluginSettings: PluginSettingsBag,
protocol: HostProtocolDef,
): number {
const raw = protocol.portKey
? Number(settingsOf(pluginSettings, protocol)[protocol.portKey])
: NaN;
return Number.isInteger(raw) && raw > 0 ? raw : protocol.defaultPort;
}
export function hostProtocolFlags(
host: { enableSsh?: boolean; pluginSettings?: PluginSettingsBag } | null,
list: HostProtocolDef[] = listHostProtocols(),
): HostProtocols {
const flags: HostProtocols = {
enableSsh: host ? host.enableSsh !== false : true,
};
for (const protocol of list) {
flags[protocol.settingKey] = host
? protocolEnabled(host.pluginSettings, protocol)
: false;
}
return flags;
}
/** The protocols a host has switched on, in registration order. */
export function enabledHostProtocols(
host: { pluginSettings?: PluginSettingsBag },
list: HostProtocolDef[] = listHostProtocols(),
): HostProtocolDef[] {
return list.filter((protocol) =>
protocolEnabled(host.pluginSettings, protocol),
);
}
/** Writes the editor's protocol switches into each owner's host settings. */
export function withProtocolSettings(
pluginSettings: Record<string, Record<string, unknown>>,
flags: HostProtocols,
list: HostProtocolDef[] = listHostProtocols(),
): Record<string, Record<string, unknown>> {
const next = { ...pluginSettings };
for (const protocol of list) {
if (!(protocol.settingKey in flags)) continue;
next[protocol.pluginId] = {
...(next[protocol.pluginId] ?? {}),
[protocol.settingKey]: flags[protocol.settingKey] === true,
};
}
return next;
}
+57 -81
View File
@@ -1,44 +1,56 @@
import type { SSHHostData } from "@/types";
import type { Host } from "@/types/ui-types";
import type { GuacamoleQuickHost } from "@/features/guacamole/GuacamoleApp";
export type QuickConnectProtocol = "ssh" | "rdp" | "vnc";
import type { HostProtocolDef } from "./host-protocols";
import { hostActionsFor, type HostActionDef } from "./host-contributions";
type QuickConnectInput = Pick<
Host,
"ip" | "port" | "username" | "authType" | "password" | "key" | "credentialId"
> & { protocol?: QuickConnectProtocol; domain?: string };
> & {
/** A plugin protocol; SSH when omitted. */
protocol?: HostProtocolDef;
domain?: string;
/** Fields a plugin's SSH auth editor filled in. */
authFields?: Record<string, unknown>;
};
export const QUICK_CONNECT_ID_PREFIX = "quick-connect-";
const QUICK_CONNECT_ID_PREFIX = "quick-connect-";
// The auth types quickConnectHostToPayload carries over. A plugin auth type
// keeps its fields elsewhere, so saving it here would lose them.
const SAVABLE_AUTH_TYPES = new Set([
"password",
"key",
"credential",
"none",
"agent",
]);
export function isQuickConnectHost(host: Pick<Host, "id">): boolean {
return host.id.startsWith(QUICK_CONNECT_ID_PREFIX);
}
export function createQuickConnectHost(input: QuickConnectInput): Host {
const protocol = input.protocol ?? "ssh";
if (protocol !== "ssh") {
const protocol = input.protocol;
if (protocol) {
return {
...createQuickConnectHost({ ...input, protocol: "ssh", port: 22 }),
...createQuickConnectHost({ ...input, protocol: undefined, port: 22 }),
// Never saved, so the login travels in plain text to the protocol's tab.
quickConnectLogin: {
protocol: protocol.id,
username: input.username,
password: input.password,
fields: input.domain ? { domain: input.domain } : {},
},
port: input.port,
enableTerminal: false,
enableCommandHistory: false,
enableFileManager: false,
enableTunnel: false,
enableDocker: false,
enableTerminalToolbar: false,
enableAiAssistant: false,
enableSsh: false,
enableRdp: protocol === "rdp",
enableVnc: protocol === "vnc",
rdpPort: protocol === "rdp" ? input.port : 3389,
vncPort: protocol === "vnc" ? input.port : 5900,
rdpAuthType: "direct",
rdpUser: protocol === "rdp" ? input.username : undefined,
rdpPassword: protocol === "rdp" ? input.password : undefined,
domain: protocol === "rdp" ? input.domain : undefined,
vncUser: protocol === "vnc" ? input.username : undefined,
vncPassword: protocol === "vnc" ? input.password : undefined,
quickConnectSavable: false,
pluginSettings: {
[protocol.pluginId]: {
[protocol.settingKey]: true,
...(protocol.portKey ? { [protocol.portKey]: input.port } : {}),
},
},
};
}
return {
@@ -58,27 +70,11 @@ export function createQuickConnectHost(input: QuickConnectInput): Host {
ram: null,
lastAccess: new Date().toISOString(),
pin: false,
defaultPath: "",
serverTunnels: [],
quickActions: [],
enableTerminal: true,
enableCommandHistory: true,
enableFileManager: true,
enableTunnel: true,
enableDocker: true,
enableProxmox: false,
enableProxmoxStats: false,
enableTmuxMonitor: false,
enableTerminalToolbar: true,
enableAiAssistant: false,
enableSsh: true,
enableRdp: false,
enableVnc: false,
enableTelnet: false,
sshPort: input.port,
rdpPort: 3389,
vncPort: 5900,
telnetPort: 23,
quickConnectSavable:
!input.authFields && SAVABLE_AUTH_TYPES.has(input.authType),
...input.authFields,
};
}
@@ -96,47 +92,27 @@ export function quickConnectHostToPayload(host: Host): SSHHostData {
: null,
folder: host.folder,
pin: host.pin,
defaultPath: host.defaultPath,
enableTerminal: true,
enableSessionLogging: true,
enableCommandHistory: host.enableCommandHistory,
enableFileManager: host.enableFileManager,
enableTunnel: host.enableTunnel,
enableDocker: host.enableDocker,
enableProxmox: host.enableProxmox,
enableTmuxMonitor: host.enableTmuxMonitor,
enableTerminalToolbar: host.enableTerminalToolbar,
enableAiAssistant: host.enableAiAssistant,
showTerminalInSidebar: true,
showFileManagerInSidebar: true,
showTunnelInSidebar: true,
showDockerInSidebar: true,
showServerStatsInSidebar: true,
connectionType: "ssh",
enableSsh: true,
enableRdp: false,
enableVnc: false,
enableTelnet: false,
sshPort: host.sshPort,
rdpPort: host.rdpPort,
vncPort: host.vncPort,
telnetPort: host.telnetPort,
};
}
/** The slice of a quick-connect host that GuacamoleApp mints a token from. */
export function quickConnectGuacHost(host: Host): GuacamoleQuickHost {
return {
name: host.name,
ip: host.ip,
connectionType: host.enableVnc ? "vnc" : "rdp",
domain: host.domain,
rdpPort: host.rdpPort,
vncPort: host.vncPort,
rdpAuthType: host.rdpAuthType,
rdpUser: host.rdpUser,
rdpPassword: host.rdpPassword,
vncUser: host.vncUser,
vncPassword: host.vncPassword,
};
/**
* Every way to reach the address: connect actions (terminal, remote desktop)
* by priority, then tools that opted into Quick Connect. Only actions that
* open a tab work here, since the host is never saved.
*/
export function quickConnectTargets(
all: HostActionDef[],
host: Host,
): HostActionDef[] {
const usable = hostActionsFor(all, host).filter((action) => action.tabType);
const connects = usable
.filter((action) => action.kind === "connect")
.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
const tools = usable.filter(
(action) => action.kind !== "connect" && action.quickConnect,
);
return [...connects, ...tools];
}
+146 -167
View File
@@ -1,29 +1,20 @@
import {
ArrowLeftRight,
Boxes,
Braces,
Clock,
Fingerprint,
Hammer,
Cloud,
KeyRound,
LayoutPanelLeft,
LayoutTemplate,
Network,
Play,
Plug,
ScrollText,
Server,
Settings,
Sparkles,
TerminalSquare,
Usb,
User,
Workflow,
Zap,
Presentation,
type LucideIcon,
} from "lucide-react";
import { useMemo, useSyncExternalStore } from "react";
import { usePermissions } from "@/hooks/use-permissions";
import { useSyncAttentionCount } from "@/hooks/use-sync-status";
import { isElectron } from "@/lib/electron";
import { createRegistry } from "@/lib/registry";
import { getPanel } from "@/shell/panel-registry";
import { getTabType } from "@/shell/tab-registry";
/**
* The one list of navigation destinations.
@@ -41,7 +32,7 @@ export interface RailItemDef {
icon: LucideIcon;
/** i18n key; every label goes through t() so nothing is hardcoded English. */
labelKey: string;
/** Tab-opening entries (network_graph) rather than sidebar panels. */
/** Tab-opening entries rather than sidebar panels. */
kind?: "tab";
/** Always-available destinations that users cannot hide. */
alwaysVisible?: boolean;
@@ -61,6 +52,24 @@ export interface RailItemDef {
rightDockable?: boolean;
/** Desktop app only. Hidden in the browser build, including its toggle. */
electronOnly?: boolean;
/** False keeps it out of the Navigation visibility toggles. */
hideable?: boolean;
/** A plugin item that stays visible in the Simple preset. */
simplePreset?: boolean;
/** Registered but not shown, e.g. while its feature is switched off. */
hidden?: boolean;
/** Places a registered item after this id instead of at the end. */
after?: string;
/** Tie-break among registered items. */
order?: number;
/** Set for items a plugin registered. */
pluginId?: string;
/** Role permission the user needs to see it at all, as a full id. */
permission?: string;
/** "footer" renders it at the bottom of the rail, above the profile. */
placement?: "main" | "footer";
/** Called as a hook by the rail; a positive number shows as a badge. */
useBadge?: () => number | null | undefined;
}
export const RAIL_ITEMS: RailItemDef[] = [
@@ -71,20 +80,6 @@ export const RAIL_ITEMS: RailItemDef[] = [
labelKey: "nav.credentials",
separatorAfter: true,
},
{
id: "port-forwarding",
icon: Network,
labelKey: "nav.portForwarding",
electronOnly: true,
separatorAfter: true,
},
{
id: "termix-id",
icon: Fingerprint,
labelKey: "nav.termixId",
separatorAfter: true,
promotable: true,
},
{
id: "connections",
icon: Plug,
@@ -92,12 +87,6 @@ export const RAIL_ITEMS: RailItemDef[] = [
separatorAfter: true,
rightDockable: true,
},
{
id: "collab",
icon: Presentation,
labelKey: "nav.collab",
separatorAfter: true,
},
{
id: "quick-connect",
icon: Zap,
@@ -105,129 +94,113 @@ export const RAIL_ITEMS: RailItemDef[] = [
separatorAfter: true,
mobilePrimary: true,
},
{ id: "serial", icon: Usb, labelKey: "nav.serial", separatorAfter: true },
{
id: "ssh-tools",
icon: Hammer,
labelKey: "nav.sshTools",
separatorAfter: true,
mobilePrimary: true,
promotable: true,
rightDockable: true,
},
{
id: "sftp",
icon: ArrowLeftRight,
labelKey: "nav.sftp",
separatorAfter: true,
},
{
id: "snippets",
icon: Play,
labelKey: "nav.snippets",
separatorAfter: true,
mobilePrimary: true,
promotable: true,
rightDockable: true,
},
{
id: "macros",
icon: Braces,
labelKey: "nav.macros",
separatorAfter: true,
promotable: true,
rightDockable: true,
},
{ id: "fleets", icon: Boxes, labelKey: "nav.fleets", separatorAfter: true },
{
id: "automations",
icon: Workflow,
labelKey: "nav.automations",
separatorAfter: true,
promotable: true,
},
{
id: "ai",
icon: Sparkles,
labelKey: "nav.ai",
separatorAfter: true,
promotable: true,
rightDockable: true,
},
{
id: "history",
icon: Clock,
labelKey: "nav.history",
separatorAfter: true,
promotable: true,
rightDockable: true,
},
{
id: "session-logs",
icon: ScrollText,
labelKey: "nav.sessionLogs",
separatorAfter: true,
promotable: true,
rightDockable: true,
},
{
id: "split-screen",
icon: LayoutPanelLeft,
labelKey: "nav.splitScreen",
separatorAfter: true,
},
{
id: "workspaces",
icon: LayoutTemplate,
labelKey: "nav.workspaces",
separatorAfter: true,
},
{
id: "local-terminal",
icon: TerminalSquare,
labelKey: "nav.localTerminal",
kind: "tab",
separatorAfter: true,
id: "sync",
icon: Cloud,
labelKey: "nav.sync",
electronOnly: true,
},
{
id: "network_graph",
icon: Network,
labelKey: "nav.networkGraph",
kind: "tab",
separatorAfter: true,
placement: "footer",
useBadge: useSyncAttentionCount,
},
];
/**
* Runtime-registered rail items, for a future plugin loader to add
* destinations that don't exist at build time. Keyed by id so a plugin can
* cleanly unregister its own entries without touching anyone else's.
* Rail items registered by plugins at runtime. Reactive, because a plugin can
* be enabled or disabled while the app is open and the rail, the mobile bar
* and the Navigation toggles all have to follow.
*/
const registeredRailItems = new Map<string, RailItemDef>();
const registeredRailItems = createRegistry<RailItemDef>();
export function registerRailItem(def: RailItemDef): void {
registeredRailItems.set(def.id, def);
export function registerRailItem(def: RailItemDef): () => void {
return registeredRailItems.register(def);
}
export function unregisterRailItem(id: string): void {
registeredRailItems.delete(id);
/** Registered items, hidden ones included. */
export const listRegisteredRailItems = registeredRailItems.list;
/**
* Core items in their fixed order, with each registered item placed after
* the item its `after` names, or at the end. Hidden items are left out.
*/
function mergedRailItems(): RailItemDef[] {
const merged = [...RAIL_ITEMS];
const plugins = [...registeredRailItems.list()]
.filter((item) => !item.hidden)
.sort(
(a, b) => (a.order ?? 0) - (b.order ?? 0) || a.id.localeCompare(b.id),
);
for (const item of plugins) {
let at = item.after
? merged.findIndex((existing) => existing.id === item.after)
: -1;
if (at < 0) {
merged.push(item);
continue;
}
// Items sharing an anchor keep their own order after it.
while (
at + 1 < merged.length &&
merged[at + 1].pluginId &&
merged[at + 1].after === item.after
) {
at++;
}
merged.splice(at + 1, 0, item);
}
return merged;
}
/**
* Rail items available in the current build. Electron-only destinations are
* dropped in the browser build so they never reach the rail, the mobile bar,
* or the visibility toggles. Runtime-registered items are appended after the
* built-in list.
* or the visibility toggles.
*/
export function visibleRailItems(): RailItemDef[] {
const electron = isElectron();
return [
...RAIL_ITEMS.filter((item) => !item.electronOnly || electron),
...[...registeredRailItems.values()].filter(
(item) => !item.electronOnly || electron,
),
];
return mergedRailItems().filter((item) => !item.electronOnly || electron);
}
let railSnapshot: RailItemDef[] | null = null;
registeredRailItems.subscribe(() => {
railSnapshot = null;
});
function railItemsSnapshot(): RailItemDef[] {
if (!railSnapshot) railSnapshot = visibleRailItems();
return railSnapshot;
}
/**
* Drops items gated on a permission the user lacks. Until permissions load,
* gated items stay hidden rather than flashing in and out.
*/
export function permittedRailItems(
items: RailItemDef[],
permissions: { has: (permission: string) => boolean; loaded: boolean },
): RailItemDef[] {
if (!items.some((item) => item.permission)) return items;
return items.filter(
(item) =>
!item.permission ||
(permissions.loaded && permissions.has(item.permission)),
);
}
/**
* visibleRailItems() as a hook, re-rendering when plugins change it, without
* the items the user's permissions hide.
*/
export function useRailItems(): RailItemDef[] {
const items = useSyncExternalStore(
registeredRailItems.subscribe,
railItemsSnapshot,
railItemsSnapshot,
);
const { has, loaded } = usePermissions();
return useMemo(
() => permittedRailItems(items, { has, loaded }),
[items, has, loaded],
);
}
/**
@@ -235,41 +208,47 @@ export function visibleRailItems(): RailItemDef[] {
* title and a mobile entry.
*/
export const RAIL_UTILITY_ITEMS: RailItemDef[] = [
{
id: "alerts",
icon: Plug,
labelKey: "nav.alerts",
promotable: true,
rightDockable: true,
},
{ id: "user-profile", icon: User, labelKey: "nav.userProfile" },
{ id: "admin-settings", icon: Settings, labelKey: "nav.admin" },
];
/** Ids that may be opened in the right dock. */
export const RIGHT_DOCKABLE_IDS = [...RAIL_ITEMS, ...RAIL_UTILITY_ITEMS]
.filter((item) => item.rightDockable)
.map((item) => item.id);
export function rightDockableIds(): string[] {
return [...mergedRailItems(), ...RAIL_UTILITY_ITEMS]
.filter((item) => item.rightDockable)
.map((item) => item.id);
}
/** Ids that may be opened as a full-width tab. */
export const PROMOTABLE_IDS = [...RAIL_ITEMS, ...RAIL_UTILITY_ITEMS]
.filter((item) => item.promotable)
.map((item) => item.id);
/** Ids that may be opened as a full-width tab: a tab type or a panel to show. */
export function promotableIds(): string[] {
return [...mergedRailItems(), ...RAIL_UTILITY_ITEMS]
.filter(
(item) => item.promotable && (getTabType(item.id) || getPanel(item.id)),
)
.map((item) => item.id);
}
/** Ids a user is allowed to hide, mirroring HideableRailView. */
export const HIDEABLE_RAIL_IDS = RAIL_ITEMS.filter(
(item) => !item.alwaysVisible,
).map((item) => item.id);
/** Ids a user is allowed to hide from Appearance > Sidebar > Navigation. */
export function hideableRailIds(): string[] {
return visibleRailItems()
.filter((item) => !item.alwaysVisible && item.hideable !== false)
.map((item) => item.id);
}
const LABEL_KEYS: Record<string, string> = Object.fromEntries(
[...RAIL_ITEMS, ...RAIL_UTILITY_ITEMS].map((item) => [
item.id,
item.labelKey,
]),
);
/** Whether a rail view is one of core's own, rather than a plugin's. */
export function isCoreRailView(id: string): boolean {
return [...RAIL_ITEMS, ...RAIL_UTILITY_ITEMS].some((item) => item.id === id);
}
/** Translated label for any rail destination, including registered ones. */
export function railItemLabel(id: string, t: (key: string) => string): string {
const key = LABEL_KEYS[id] ?? registeredRailItems.get(id)?.labelKey;
const key =
[...RAIL_ITEMS, ...RAIL_UTILITY_ITEMS].find((item) => item.id === id)
?.labelKey ?? registeredRailItems.get(id)?.labelKey;
return key ? t(key) : id;
}
/** Test seam. */
export function resetRegisteredRailItems(): void {
registeredRailItems.reset();
}
@@ -1,5 +1,5 @@
import { useTranslation } from "react-i18next";
import { FolderOpen, Pencil, Share2, Trash2 } from "lucide-react";
import { FolderOpen, Pencil, Settings2, Share2, Trash2 } from "lucide-react";
import type { HostFolder } from "@/types/ui-types";
export function FolderActions({
@@ -44,6 +44,24 @@ export function FolderActions({
<Share2 className="size-3" />
</button>
)}
<button
type="button"
title={t("hostDefaults.folderDefaultsAction")}
className={actionButtonClass}
onClick={(e) => {
e.stopPropagation();
window.dispatchEvent(
new CustomEvent("host-manager:edit-defaults", {
detail: {
level: "folder",
folderName: folder.path ?? folder.name,
},
}),
);
}}
>
<Settings2 className="size-3" />
</button>
<button
type="button"
title={t("hosts.editFolder")}
+18 -9
View File
@@ -1,10 +1,12 @@
/* eslint-disable react-refresh/only-export-components */
import { rem } from "@/lib/rem";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { ChevronRight, Check, GripVertical } from "lucide-react";
import type { Host, HostFolder, TabType } from "@/types/ui-types";
import type {
HostDensity,
HostClickBehavior,
HostTrayTrigger,
} from "@/types/host-sidebar-preferences";
import { FolderIconEl } from "@/components/folder-style";
@@ -40,7 +42,6 @@ export function FolderItem({
onShareHost,
onDeleteHost,
onDuplicateHost,
onProxmoxDiscover,
query = "",
stripeMap,
openFolders,
@@ -68,6 +69,8 @@ export function FolderItem({
trayTrigger = "hover",
showTags = true,
openOnDoubleClick = false,
showFolderPaths = true,
hostClickBehavior = "newTab",
arrangeMode = false,
isDragging = false,
onReorderDrop,
@@ -82,13 +85,16 @@ export function FolderItem({
onOpenTab: (
host: Host,
type: TabType,
options?: { endpointId?: string; label?: string },
options?: {
data?: Record<string, unknown>;
label?: string;
forceNewTab?: boolean;
},
) => void;
onEditHost?: (host: Host) => void;
onShareHost?: (host: Host) => void;
onDeleteHost: (host: Host) => void;
onDuplicateHost: (host: Host) => void;
onProxmoxDiscover?: (host: Host) => void;
query?: string;
stripeMap?: Map<Host | HostFolder, number>;
openFolders: Set<string>;
@@ -115,6 +121,9 @@ export function FolderItem({
trayTrigger?: HostTrayTrigger;
showTags?: boolean;
openOnDoubleClick?: boolean;
/** When false, nested folders hide the parent-path breadcrumb before their name. */
showFolderPaths?: boolean;
hostClickBehavior?: HostClickBehavior;
/** When true (rearranging unlocked), the header can be dragged and its
* top/bottom edges become reorder drop zones. The middle still accepts
* hosts dropped into the folder. */
@@ -152,7 +161,8 @@ export function FolderItem({
// Nested folders show their parent path as a muted breadcrumb so depth stays
// legible even when a folder is reached via search auto-expand rather than
// by manually opening every ancestor.
const pathSegments = isGroup ? [] : folderPath.split(" / ");
const pathSegments =
isGroup || !showFolderPaths ? [] : folderPath.split(" / ");
const breadcrumb =
pathSegments.length > 1 ? pathSegments.slice(0, -1).join(" / ") : null;
const folderHosts = collectAllHosts(folder.children);
@@ -168,7 +178,7 @@ export function FolderItem({
return (
<div
className="relative"
style={depth > 0 ? { paddingLeft: depth * 12 } : undefined}
style={depth > 0 ? { paddingLeft: rem(depth * 12) } : undefined}
>
<div className="relative">
<button
@@ -312,7 +322,6 @@ export function FolderItem({
onShareHost={onShareHost}
onDeleteHost={onDeleteHost}
onDuplicateHost={onDuplicateHost}
onProxmoxDiscover={onProxmoxDiscover}
query={query}
stripeMap={stripeMap}
openFolders={openFolders}
@@ -337,6 +346,8 @@ export function FolderItem({
trayTrigger={trayTrigger}
showTags={showTags}
openOnDoubleClick={openOnDoubleClick}
showFolderPaths={showFolderPaths}
hostClickBehavior={hostClickBehavior}
arrangeMode={arrangeMode}
onReorderDrop={onReorderDrop}
onFolderDragStart={onFolderDragStart}
@@ -349,9 +360,6 @@ export function FolderItem({
onOpenTab={(t, options) => onOpenTab(child, t, options)}
onEditHost={onEditHost ? () => onEditHost(child) : undefined}
onShareHost={onShareHost ? () => onShareHost(child) : undefined}
onProxmoxDiscover={
onProxmoxDiscover ? () => onProxmoxDiscover(child) : undefined
}
onDelete={() => onDeleteHost(child)}
onDuplicate={() => onDuplicateHost(child)}
query={query}
@@ -373,6 +381,7 @@ export function FolderItem({
trayTrigger={trayTrigger}
showTags={showTags}
openOnDoubleClick={openOnDoubleClick}
hostClickBehavior={hostClickBehavior}
arrangeMode={arrangeMode}
onReorderDrop={
onReorderDrop
@@ -1,122 +0,0 @@
import { describe, it, expect } from "vitest";
import type { Host } from "@/types/ui-types";
import {
buildStatusTooltip,
statusCheckEnabled,
} from "@/sidebar/tree/HostItem/HostItem";
// Minimal host factory – only the fields buildStatusTooltip reads.
function makeHost(overrides: Partial<Host> = {}): Host {
return {
id: 1,
name: "test-host",
ip: "127.0.0.1",
username: "user",
enableSsh: true,
enableRdp: false,
enableVnc: false,
enableTelnet: false,
statsConfig: { statusCheckEnabled: true },
...overrides,
} as unknown as Host;
}
// Translator that returns human-readable labels, not key paths.
// This is what the review asked for: assert the rendered labels,
// not the key paths the translator falls back to when a resource is missing.
const t = (key: string): string =>
({
"hosts.status.available": "Available",
"hosts.status.reachable": "Reachable, not authenticated",
"hosts.status.offline": "Offline",
"hosts.status.monitoringDisabled": "Monitoring disabled",
})[key] ?? key;
describe("buildStatusTooltip", () => {
it("returns the translated 'Available' label for online status", () => {
const host = makeHost();
const tooltip = buildStatusTooltip(host, "online", t);
expect(tooltip).toContain("Available");
expect(tooltip).not.toContain("hosts.status.");
});
it("returns the translated 'Reachable, not authenticated' label for reachable status", () => {
const host = makeHost();
const tooltip = buildStatusTooltip(host, "reachable", t);
expect(tooltip).toContain("Reachable, not authenticated");
expect(tooltip).not.toContain("hosts.status.");
});
it("returns the translated 'Offline' label for offline status", () => {
const host = makeHost();
const tooltip = buildStatusTooltip(host, "offline", t);
expect(tooltip).toContain("Offline");
expect(tooltip).not.toContain("hosts.status.");
});
it("returns 'Monitoring disabled' when status check is disabled", () => {
const host = makeHost({
statsConfig: {
enabledWidgets: [],
statusCheckEnabled: false,
statusCheckInterval: 30,
metricsEnabled: false,
metricsInterval: 30,
},
});
const tooltip = buildStatusTooltip(host, "online", t);
expect(tooltip).toBe("Monitoring disabled");
});
it("includes protocol names in the tooltip when protocols are enabled", () => {
const host = makeHost({
enableSsh: true,
enableRdp: true,
enableVnc: false,
enableTelnet: false,
});
const tooltip = buildStatusTooltip(host, "online", t);
expect(tooltip).toContain("SSH");
expect(tooltip).toContain("RDP");
expect(tooltip).toContain("Available");
});
it("returns just the status label when no protocols are enabled", () => {
const host = makeHost({
enableSsh: false,
enableRdp: false,
enableVnc: false,
enableTelnet: false,
});
const tooltip = buildStatusTooltip(host, "online", t);
expect(tooltip).toBe("Available");
});
it("does not render key paths when a translator is supplied", () => {
const host = makeHost();
const tooltip = buildStatusTooltip(host, "online", t);
// The tooltip must never show the raw key path – that means the
// translation resource is missing, which is the bug #1265 fixed.
expect(tooltip).not.toMatch(/hosts\.status\./);
});
});
describe("statusCheckEnabled", () => {
it("returns true when statusCheckEnabled is not set (default)", () => {
const host = makeHost({ statsConfig: undefined });
expect(statusCheckEnabled(host)).toBe(true);
});
it("returns false when statusCheckEnabled is explicitly false", () => {
const host = makeHost({
statsConfig: {
enabledWidgets: [],
statusCheckEnabled: false,
statusCheckInterval: 30,
metricsEnabled: false,
metricsInterval: 30,
},
});
expect(statusCheckEnabled(host)).toBe(false);
});
});
File diff suppressed because it is too large. Load diff
+206 -122
View File
@@ -1,3 +1,4 @@
import { useHostSpeedSearch } from "./hooks/useHostSpeedSearch";
import {
useCallback,
useEffect,
@@ -6,19 +7,20 @@ import {
useLayoutEffect,
useState,
} from "react";
import { updatePluginHostSettings } from "@/api/plugins-api";
import { resetHostToDefaults } from "@/api/host-defaults-api";
import { PluginIcon } from "@/lib/plugin-icon";
import { usePluginHostSections } from "@/settings/HostPluginSections";
import { useTranslation } from "react-i18next";
import { useVirtualizer } from "@tanstack/react-virtual";
import { useAreaPreferences } from "@/contexts/UiPreferencesContext";
import {
Box,
Boxes,
ChevronDown,
Download,
FolderOpen,
FolderSearch,
Loader2,
Network,
Plus,
RotateCcw,
Server,
Terminal,
} from "lucide-react";
@@ -46,6 +48,7 @@ import type { Host, HostFolder, TabType } from "@/types/ui-types";
import type { SSHHostData } from "@/types/index";
import type {
HostDensity,
HostClickBehavior,
HostTrayTrigger,
} from "@/types/host-sidebar-preferences";
import { resolveHostTabType } from "@/lib/host-connection-tabs";
@@ -60,6 +63,7 @@ import {
collectAllHosts,
collectAllFolderPaths,
hostExpandKey,
hostMatchesQuery,
buildReorderRows,
collectOrderableRows,
rowKey,
@@ -76,8 +80,7 @@ export function SidebarTree({
onOpenTab,
onEditHost,
onShareHost,
onProxmoxDiscover,
query = "",
query: externalQuery = "",
selectionMode,
onToggleSelectionMode,
loading = false,
@@ -87,16 +90,21 @@ export function SidebarTree({
trayTrigger = "hover",
showTags = true,
openOnDoubleClick = false,
showFolderPaths = true,
hostClickBehavior = "newTab",
}: {
children: (Host | HostFolder)[];
onOpenTab: (
host: Host,
type: TabType,
options?: { endpointId?: string; label?: string },
options?: {
data?: Record<string, unknown>;
label?: string;
forceNewTab?: boolean;
},
) => void;
onEditHost: (host: Host) => void;
onShareHost?: (host: Host) => void;
onProxmoxDiscover?: (host: Host) => void;
query?: string;
selectionMode: boolean;
onToggleSelectionMode: () => void;
@@ -108,8 +116,15 @@ export function SidebarTree({
trayTrigger?: HostTrayTrigger;
showTags?: boolean;
openOnDoubleClick?: boolean;
/** When false, nested folders hide the parent-path breadcrumb before their name. */
showFolderPaths?: boolean;
hostClickBehavior?: HostClickBehavior;
}) {
const { t } = useTranslation();
const hostSettingPlugins = usePluginHostSections();
const hostSwitchPlugins = hostSettingPlugins.filter(
(plugin) => !!plugin.contributes?.settings?.host?.enableKey,
);
// Knobs with no other owner come straight from the interface preset; the
// ones above still come from the host sidebar preferences blob.
const { showResourceBars, showStatusStripes, rowActions } =
@@ -440,7 +455,7 @@ export function SidebarTree({
const hosts = collectAllHosts(folder.children);
for (const host of hosts) {
const type = resolveHostTabType(host);
onOpenTab(host, type);
if (type) onOpenTab(host, type);
}
}
@@ -449,6 +464,7 @@ export function SidebarTree({
color: string;
icon: string;
credentialId: number | null;
localOnly: boolean;
}) {
const existing = folderDialog?.folder;
try {
@@ -466,6 +482,7 @@ export function SidebarTree({
value.color,
value.icon,
value.credentialId,
value.localOnly,
);
} else {
await updateFolderMetadata(
@@ -473,6 +490,7 @@ export function SidebarTree({
value.color,
value.icon,
value.credentialId,
value.localOnly,
);
}
window.dispatchEvent(new CustomEvent("termix:hosts-changed"));
@@ -589,7 +607,6 @@ export function SidebarTree({
tags: host.tags ?? [],
pin: host.pin ?? false,
notes: host.notes,
macAddress: host.macAddress,
// Key material is never sent to the frontend, so a cloned key-auth
// host would have authType "key" with no key — unusable. Reset to
// password so the clone is in a connectable (editable) state.
@@ -601,31 +618,19 @@ export function SidebarTree({
credentialId: host.credentialId ? Number(host.credentialId) : null,
overrideCredentialUsername: host.overrideCredentialUsername ?? false,
enableSsh: host.enableSsh,
enableRdp: host.enableRdp,
enableVnc: host.enableVnc,
enableTelnet: host.enableTelnet,
enableTerminal: host.enableTerminal,
enableTunnel: host.enableTunnel,
enableFileManager: host.enableFileManager,
enableDocker: host.enableDocker,
sshPort: host.sshPort,
rdpPort: host.rdpPort,
vncPort: host.vncPort,
telnetPort: host.telnetPort,
rdpUser: host.rdpUser ?? null,
rdpPassword: host.rdpPassword ?? null,
rdpDomain: host.domain ?? null,
rdpSecurity: host.security ?? null,
rdpIgnoreCert: host.ignoreCert ?? false,
vncAuthType: host.vncAuthType ?? null,
vncCredentialId: host.vncCredentialId
? Number(host.vncCredentialId)
: null,
vncPassword: host.vncPassword ?? null,
vncUser: host.vncUser ?? null,
telnetUser: host.telnetUser ?? null,
telnetPassword: host.telnetPassword ?? null,
defaultPath: host.defaultPath ?? "/",
// Saved passwords never reach the browser, so a copy starts without them.
protocolAuth: Object.fromEntries(
Object.entries(host.protocolAuth ?? {}).map(([protocol, login]) => [
protocol,
{
authType: login.authType,
credentialId: login.credentialId ?? null,
username: login.username ?? null,
fields: login.fields ?? {},
},
]),
),
forceKeyboardInteractive: host.forceKeyboardInteractive ?? false,
useSocks5: host.useSocks5,
socks5Host: host.socks5Host ?? null,
@@ -637,16 +642,23 @@ export function SidebarTree({
hostId: Number(j.hostId),
})),
portKnockSequence: host.portKnockSequence ?? [],
tunnelConnections: host.serverTunnels ?? [],
quickActions: (host.quickActions ?? []).map((a) => ({
name: a.name,
snippetId: Number(a.snippetId),
})),
statsConfig: host.statsConfig,
guacamoleConfig: host.guacamoleConfig ?? null,
terminalConfig: host.terminalConfig ?? null,
statusCheckEnabled: host.statusCheckEnabled,
statusCheckInterval: host.statusCheckInterval,
sshOptions: host.sshOptions ?? null,
};
await createSSHHost(duplicateHost);
const created = await createSSHHost(duplicateHost);
// Plugin host settings live outside the host row. Secrets come back
// redacted, and sending the marker back is a no-op, so they stay unset.
for (const [pluginId, fields] of Object.entries(
host.pluginSettings ?? {},
)) {
if (!fields || Object.keys(fields).length === 0) continue;
await updatePluginHostSettings(
pluginId,
Number(created.id),
fields,
).catch(() => {});
}
window.dispatchEvent(new CustomEvent("termix:hosts-changed"));
toast.success(t("hosts.duplicatedHost", { name: host.name }));
} catch {
@@ -654,6 +666,10 @@ export function SidebarTree({
}
}
const speedSearch = useHostSpeedSearch();
const query = speedSearch.open
? speedSearch.text.trim().toLowerCase()
: externalQuery;
const allHosts = collectAllHosts(children);
const allFolderPaths = collectAllFolderPaths(children);
@@ -666,6 +682,13 @@ export function SidebarTree({
closedHostParents,
);
const parentRef = useRef<HTMLDivElement>(null);
const matchingRows = visibleRows
.map((row, index) => ({ row, index }))
.filter(
({ row }) => !isFolder(row.item) && hostMatchesQuery(row.item, query),
);
const activeMatch =
matchingRows[Math.min(speedSearch.index, matchingRows.length - 1)];
const isTouchOnly =
typeof window !== "undefined" && window.matchMedia("(hover: none)").matches;
@@ -810,9 +833,13 @@ export function SidebarTree({
getItemKey: (index) => {
const row = visibleRows[index];
if (!row) return index;
// Group-by views (e.g. by tags) can list the same host under several
// group folders, so parentPath is folded in to keep each occurrence's
// key unique -- a shared key across rows made the virtualizer collide
// their measured positions and render them stacked on top of each other.
return isFolder(row.item)
? `folder:${row.item.path ?? row.item.name}`
: `host:${row.item.id}`;
: `${row.parentPath}>host:${row.item.id}`;
},
});
@@ -847,8 +874,23 @@ export function SidebarTree({
// and made the list visibly jump apart on every pointer move.
useLayoutEffect(() => {
virtualizer.measure();
// Rebuild the cleared size cache before measuring the mounted rows again.
virtualizer.getTotalSize();
// Unchanged rows will not trigger ResizeObserver after their cache is cleared.
parentRef.current
?.querySelectorAll<HTMLElement>("[data-index]")
.forEach((element) => virtualizer.measureElement(element));
}, [virtualizer, density, trayTrigger, showTags, showResourceBars]);
useEffect(() => {
if (speedSearch.open) speedSearch.inputRef.current?.focus();
}, [speedSearch.open, speedSearch.inputRef]);
const activeMatchIndex = activeMatch?.index;
useEffect(() => {
if (speedSearch.open && activeMatchIndex !== undefined)
virtualizer.scrollToIndex(activeMatchIndex, { align: "auto" });
}, [speedSearch.open, activeMatchIndex, query, virtualizer]);
if (loading) {
return (
<div className="relative flex flex-col flex-1 min-h-0">
@@ -875,9 +917,53 @@ export function SidebarTree({
}
return (
<div className="relative flex flex-col flex-1 min-h-0">
<div
className="relative flex flex-col flex-1 min-h-0"
onKeyDown={(event) => {
if (selectionMode || arrangeMode) return;
speedSearch.onKeyDown(
event,
matchingRows.length,
(index) => {
const host = matchingRows[index]?.row.item;
if (!host || isFolder(host)) return;
const type = resolveHostTabType(host);
if (type)
onOpenTab(host, type, {
forceNewTab: hostClickBehavior === "newTab",
});
},
() => parentRef.current?.focus(),
);
}}
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null))
speedSearch.close();
}}
>
{speedSearch.open && (
<div className="flex items-center gap-2 border-b border-border bg-popover px-3 py-2">
<input
ref={speedSearch.inputRef}
type="search"
aria-label={t("hosts.speedSearch")}
placeholder={t("hosts.speedSearch")}
value={speedSearch.text}
onChange={(event) => speedSearch.change(event.target.value)}
className="min-w-0 flex-1 bg-transparent text-sm outline-none"
/>
<span role="status" className="text-xs text-muted-foreground">
{matchingRows.length
? Math.min(speedSearch.index + 1, matchingRows.length)
: 0}{" "}
/ {matchingRows.length}
</span>
</div>
)}
<div
ref={parentRef}
tabIndex={0}
aria-label={t("hosts.speedSearchList")}
className={`flex-1 min-h-0 overflow-y-auto ${rootDragOver ? "ring-1 ring-inset ring-accent-brand/50" : ""}`}
// Only the container's own empty space is a root drop target. Without
// the target check this fired for every child row the pointer crossed
@@ -945,7 +1031,11 @@ export function SidebarTree({
key={vItem.key}
data-index={vItem.index}
ref={virtualizer.measureElement}
className="absolute top-0 left-0 w-full"
data-speed-search-active={
(speedSearch.open && activeMatch?.index === vItem.index) ||
undefined
}
className={`absolute top-0 left-0 w-full ${speedSearch.open && activeMatch?.index === vItem.index ? "ring-1 ring-inset ring-accent-brand bg-accent/30" : ""}`}
style={{
transform: `translateY(${vItem.start}px)`,
}}
@@ -960,7 +1050,6 @@ export function SidebarTree({
onShareHost={onShareHost}
onDeleteHost={handleDeleteHost}
onDuplicateHost={handleDuplicateHost}
onProxmoxDiscover={onProxmoxDiscover}
query={query}
openFolders={openFolders}
onToggleFolder={toggleFolder}
@@ -996,6 +1085,8 @@ export function SidebarTree({
trayTrigger={trayTrigger}
showTags={showTags}
openOnDoubleClick={openOnDoubleClick}
showFolderPaths={showFolderPaths}
hostClickBehavior={hostClickBehavior}
arrangeMode={arrangeMode}
isDragging={
draggedReorderKey === `folder:${item.path ?? item.name}`
@@ -1030,11 +1121,6 @@ export function SidebarTree({
onShareHost={
onShareHost ? () => onShareHost(item) : undefined
}
onProxmoxDiscover={
onProxmoxDiscover
? () => onProxmoxDiscover(item)
: undefined
}
onDelete={() => handleDeleteHost(item)}
onDuplicate={() => handleDuplicateHost(item)}
query={query}
@@ -1072,6 +1158,7 @@ export function SidebarTree({
trayTrigger={trayTrigger}
showTags={showTags}
openOnDoubleClick={openOnDoubleClick}
hostClickBehavior={hostClickBehavior}
showResourceBars={showResourceBars}
showStatusStripes={showStatusStripes}
rowActions={rowActions}
@@ -1134,88 +1221,86 @@ export function SidebarTree({
{t("hosts.featuresMenu")} <ChevronDown className="size-2.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="text-xs">
{hostSwitchPlugins.flatMap((plugin) =>
[true, false].map((value) => (
<DropdownMenuItem
key={`${plugin.id}:${value}`}
onClick={async () => {
const ids = Array.from(selectedHostIds).map(Number);
try {
await bulkUpdateSSHHosts(ids, {
pluginEnable: { [plugin.id]: value },
});
window.dispatchEvent(
new CustomEvent("termix:hosts-changed"),
);
toast.success(
t("hosts.updatedCount", { count: ids.length }),
);
} catch {
toast.error(t("hosts.bulkUpdateFailed"));
}
}}
>
<PluginIcon
name={plugin.icon}
className="size-3.5 mr-2"
/>
{t(
value
? "hosts.enablePluginFeature"
: "hosts.disablePluginFeature",
{ name: plugin.name },
)}
</DropdownMenuItem>
)),
)}
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="text-[10px] text-muted-foreground hover:text-foreground px-1.5 py-1 hover:bg-muted rounded transition-colors flex items-center gap-1 disabled:opacity-40"
disabled={selectedHostIds.size === 0}
>
{t("hostDefaults.resetMenu")}{" "}
<ChevronDown className="size-2.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="text-xs">
{[
{
labelKey: "hosts.enableTerminalFeature",
field: "enableTerminal",
value: true,
icon: Terminal,
},
{
labelKey: "hosts.disableTerminalFeature",
field: "enableTerminal",
value: false,
icon: Terminal,
},
{
labelKey: "hosts.enableFilesFeature",
field: "enableFileManager",
value: true,
icon: FolderSearch,
},
{
labelKey: "hosts.disableFilesFeature",
field: "enableFileManager",
value: false,
icon: FolderSearch,
},
{
labelKey: "hosts.enableTunnelsFeature",
field: "enableTunnel",
value: true,
icon: Network,
},
{
labelKey: "hosts.disableTunnelsFeature",
field: "enableTunnel",
value: false,
icon: Network,
},
{
labelKey: "hosts.enableDockerFeature",
field: "enableDocker",
value: true,
icon: Box,
},
{
labelKey: "hosts.disableDockerFeature",
field: "enableDocker",
value: false,
icon: Box,
},
{
labelKey: "hosts.enableProxmoxFeature",
field: "enableProxmox",
value: true,
icon: Boxes,
},
{
labelKey: "hosts.disableProxmoxFeature",
field: "enableProxmox",
value: false,
icon: Boxes,
},
].map(({ labelKey, field, value, icon: Icon }) => (
{ id: "all", label: t("hostDefaults.resetAll") },
{ id: "core", label: t("hostDefaults.resetCore") },
...hostSettingPlugins.map((plugin) => ({
id: plugin.id,
label: plugin.name,
})),
].map((option) => (
<DropdownMenuItem
key={labelKey}
key={option.id}
onClick={async () => {
const ids = Array.from(selectedHostIds).map(Number);
try {
await bulkUpdateSSHHosts(ids, { [field]: value });
await resetHostToDefaults(
ids,
option.id === "all"
? { all: true }
: { namespaces: [option.id] },
);
window.dispatchEvent(
new CustomEvent("termix:hosts-changed"),
);
toast.success(
t("hosts.updatedCount", { count: ids.length }),
t("hostDefaults.resetDone", { count: ids.length }),
);
} catch {
toast.error(t("hosts.bulkUpdateFailed"));
}
}}
>
<Icon className="size-3.5 mr-2" />
{t(labelKey)}
<RotateCcw className="size-3.5 mr-2" />
{option.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
@@ -1289,10 +1374,8 @@ export function SidebarTree({
selectedHostIds.has(String(h.id)),
);
for (const host of selectedHosts) {
if (host.enableSsh) onOpenTab(host, "terminal");
else if (host.enableRdp) onOpenTab(host, "rdp");
else if (host.enableVnc) onOpenTab(host, "vnc");
else if (host.enableTelnet) onOpenTab(host, "telnet");
const type = resolveHostTabType(host);
if (type) onOpenTab(host, type);
}
setSelectedHostIds(new Set());
onToggleSelectionMode();
@@ -1389,6 +1472,7 @@ export function SidebarTree({
color: folderDialog.folder.color,
icon: folderDialog.folder.icon,
credentialId: folderDialog.folder.credentialId,
localOnly: folderDialog.folder.localOnly,
}
: undefined
}
@@ -0,0 +1,63 @@
import { useRef, useState, type KeyboardEvent } from "react";
/** Keyboard search belongs to the focused host list, never to an editor or terminal. */
export function useHostSpeedSearch() {
const [open, setOpen] = useState(false);
const [text, setText] = useState("");
const [index, setIndex] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const close = () => {
setOpen(false);
setText("");
setIndex(0);
};
const change = (value: string) => {
setText(value);
setIndex(0);
};
const onKeyDown = (
event: KeyboardEvent,
count: number,
activate: (index: number) => void,
restoreFocus: () => void,
) => {
const target = event.target as HTMLElement;
const inSearch = target === inputRef.current;
if (
(!inSearch &&
target.closest("input, textarea, select, [contenteditable=true]")) ||
event.ctrlKey ||
event.metaKey ||
event.altKey ||
event.nativeEvent.isComposing ||
event.keyCode === 229
)
return;
const selected = Math.min(index, Math.max(0, count - 1));
if (open && event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
close();
restoreFocus();
} else if (open && ["ArrowDown", "ArrowUp"].includes(event.key)) {
event.preventDefault();
event.stopPropagation();
if (count)
setIndex(
(selected + (event.key === "ArrowDown" ? 1 : -1) + count) % count,
);
} else if (open && event.key === "Enter") {
event.preventDefault();
event.stopPropagation();
if (count) activate(selected);
} else if (!inSearch && event.key.length === 1 && event.key !== " ") {
event.preventDefault();
event.stopPropagation();
setOpen(true);
change(open ? text + event.key : event.key === "/" ? "" : event.key);
}
};
return { open, text, index, inputRef, change, close, onKeyDown };
}
+1 -2
View File
@@ -1,4 +1,3 @@
export { isFolder } from "./visible-rows";
export { HostItem } from "./HostItem/HostItem";
export { FolderItem } from "./FolderItem/FolderItem";
export { SidebarTree } from "./SidebarTree";
+35 -9
View File
@@ -34,7 +34,18 @@ export function folderHasMatch(folder: HostFolder, query: string): boolean {
return false;
}
export type VirtualRow = { item: Host | HostFolder; depth: number };
/** Sentinel parent for rows sitting at the tree root (no folder). */
export const ROOT_PARENT = "__root__";
// parentPath identifies which folder/group a row was collected under. Group-by
// views (e.g. group by tags) can list the same host under several synthetic
// group folders, so the host id alone isn't a unique row identity -- the
// virtualizer needs parentPath+id to tell those rows apart.
export type VirtualRow = {
item: Host | HostFolder;
depth: number;
parentPath: string;
};
function collectVisibleHostRows(
host: Host,
@@ -42,9 +53,10 @@ function collectVisibleHostRows(
closedHostParents: Set<string>,
out: VirtualRow[],
depth: number,
parentPath: string,
): void {
if (!query || hostMatchesQuery(host, query) || hostHasMatch(host, query)) {
out.push({ item: host, depth });
out.push({ item: host, depth, parentPath });
} else {
return;
}
@@ -57,7 +69,14 @@ function collectVisibleHostRows(
const isOpen = query ? true : !closedHostParents.has(hostExpandKey(host));
if (!isOpen) return;
for (const child of childHosts) {
collectVisibleHostRows(child, query, closedHostParents, out, depth + 1);
collectVisibleHostRows(
child,
query,
closedHostParents,
out,
depth + 1,
`${parentPath}>host:${host.id}`,
);
}
}
@@ -68,13 +87,15 @@ export function collectVisibleRows(
out: VirtualRow[] = [],
depth = 0,
closedHostParents: Set<string> = new Set(),
parentPath: string = ROOT_PARENT,
): VirtualRow[] {
for (const child of children) {
if (isFolder(child)) {
const visible = query ? folderHasMatch(child, query) : true;
if (!visible) continue;
out.push({ item: child, depth });
const childOpen = query ? true : openSet.has(child.path ?? child.name);
const path = child.path ?? child.name;
out.push({ item: child, depth, parentPath });
const childOpen = query ? true : openSet.has(path);
if (childOpen)
collectVisibleRows(
child.children,
@@ -83,9 +104,17 @@ export function collectVisibleRows(
out,
depth + 1,
closedHostParents,
path,
);
} else {
collectVisibleHostRows(child, query, closedHostParents, out, depth);
collectVisibleHostRows(
child,
query,
closedHostParents,
out,
depth,
parentPath,
);
}
}
return out;
@@ -123,9 +152,6 @@ export function collectAllFolderPaths(
return Array.from(paths).sort((a, b) => a.localeCompare(b));
}
/** Sentinel parent for rows sitting at the tree root (no folder). */
export const ROOT_PARENT = "__root__";
export function rowKey(item: Host | HostFolder): string {
return isFolder(item)
? `folder:${item.path ?? item.name}`