mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 07:05:24 +00:00
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:
4405 files changed
+1341026
-289722
No files matched your search
@@ -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
|
||||
|
||||
@@ -26,11 +26,10 @@ const RESOURCE_TYPES = [
|
||||
"user",
|
||||
"host",
|
||||
"credential",
|
||||
"snippet",
|
||||
"plugin",
|
||||
"session",
|
||||
"api_key",
|
||||
"setting",
|
||||
"tunnel",
|
||||
];
|
||||
|
||||
type AdminAuditLogSectionProps = {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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" && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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} ·{" "}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
})}{" "}
|
||||
·{" "}
|
||||
{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
@@ -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")}
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
+167
-464
@@ -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,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 "{filter}"
|
||||
</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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+149
-968
File diff suppressed because it is too large.
Load diff
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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) ||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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")}
|
||||
|
||||
@@ -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
@@ -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,4 +1,3 @@
|
||||
export { isFolder } from "./visible-rows";
|
||||
export { HostItem } from "./HostItem/HostItem";
|
||||
export { FolderItem } from "./FolderItem/FolderItem";
|
||||
|
||||
export { SidebarTree } from "./SidebarTree";
|
||||
@@ -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}`
|
||||
|
||||
Reference in new issue
Block a user