mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 07:05:24 +00:00
release-2.7.0 (#1264)
* feat: redesign host/credential sidebars with synced preferences and manual drag-to-reorder * chore: run format * chore(deps-dev): bump @types/pg in the dev-patch-updates group (#1162) Bumps the dev-patch-updates group with 1 update: [@types/pg](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/pg). Updates `@types/pg` from 8.20.0 to 8.20.3 - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/pg) --- updated-dependencies: - dependency-name: "@types/pg" dependency-version: 8.20.3 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-dev): bump the dev-minor-updates group with 4 updates (#1163) Bumps the dev-minor-updates group with 4 updates: [react-hook-form](https://github.com/react-hook-form/react-hook-form), [react-icons](https://github.com/react-icons/react-icons), [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) and [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite). Updates `react-hook-form` from 7.79.0 to 7.84.0 - [Release notes](https://github.com/react-hook-form/react-hook-form/releases) - [Changelog](https://github.com/react-hook-form/react-hook-form/blob/master/CHANGELOG.md) - [Commits](https://github.com/react-hook-form/react-hook-form/compare/v7.79.0...v7.84.0) Updates `react-icons` from 5.6.0 to 5.7.0 - [Release notes](https://github.com/react-icons/react-icons/releases) - [Commits](https://github.com/react-icons/react-icons/compare/v5.6.0...v5.7.0) Updates `typescript-eslint` from 8.61.1 to 8.66.0 - [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases) - [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md) - [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.66.0/packages/typescript-eslint) Updates `vite` from 8.0.16 to 8.2.0 - [Release notes](https://github.com/vitejs/vite/releases) - [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite/commits/create-vite@8.2.0/packages/vite) --- updated-dependencies: - dependency-name: react-hook-form dependency-version: 7.84.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: react-icons dependency-version: 5.7.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: typescript-eslint dependency-version: 8.66.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: vite dependency-version: 8.2.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-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 3 updates (#1164) Bumps the prod-patch-updates group with 3 updates: [jose](https://github.com/panva/jose), [js-yaml](https://github.com/nodeca/js-yaml) and [nanoid](https://github.com/ai/nanoid). Updates `jose` from 6.2.7 to 6.2.8 - [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.7...v6.2.8) Updates `js-yaml` from 5.2.2 to 5.2.3 - [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md) - [Commits](https://github.com/nodeca/js-yaml/compare/5.2.2...5.2.3) Updates `nanoid` from 6.0.0 to 6.0.1 - [Release notes](https://github.com/ai/nanoid/releases) - [Changelog](https://github.com/ai/nanoid/blob/main/CHANGELOG.md) - [Commits](https://github.com/ai/nanoid/compare/6.0.0...6.0.1) --- updated-dependencies: - dependency-name: jose dependency-version: 6.2.8 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: prod-patch-updates - dependency-name: js-yaml dependency-version: 5.2.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: prod-patch-updates - dependency-name: nanoid dependency-version: 6.0.1 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> * chore(deps): bump undici in the prod-minor-updates group (#1165) Bumps the prod-minor-updates group with 1 update: [undici](https://github.com/nodejs/undici). Updates `undici` from 8.9.0 to 8.10.0 - [Release notes](https://github.com/nodejs/undici/releases) - [Commits](https://github.com/nodejs/undici/compare/v8.9.0...v8.10.0) --- updated-dependencies: - dependency-name: undici dependency-version: 8.10.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: prod-minor-updates ... 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 major-updates group with 2 updates (#1166) Bumps the major-updates group with 2 updates: [@types/better-sqlite3](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/better-sqlite3) and [jsdom](https://github.com/jsdom/jsdom). Updates `@types/better-sqlite3` from 7.6.13 to 9.6.0 - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/better-sqlite3) Updates `jsdom` from 29.1.1 to 30.0.1 - [Release notes](https://github.com/jsdom/jsdom/releases) - [Commits](https://github.com/jsdom/jsdom/compare/v29.1.1...v30.0.1) --- updated-dependencies: - dependency-name: "@types/better-sqlite3" dependency-version: 9.6.0 dependency-type: direct:development update-type: version-update:semver-major dependency-group: major-updates - dependency-name: jsdom dependency-version: 30.0.1 dependency-type: direct:development update-type: version-update:semver-major dependency-group: major-updates ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * fix: stop resyncAutoIncrement failing on tables without an id column (#1173) The Postgres branch asked pg_get_serial_sequence(table, 'id') about every table a fixture had inserted into. That function raises 42703 when the column does not exist, rather than returning null, so any seed touching a table keyed on something else took down the fixture. host_sidebar_preferences is keyed on user_id and has no id at all, which is why the Postgres job on dev-2.7.0 fails for every pull request. Drive the lookup from information_schema so a missing id column yields no row instead of an error. A text primary key still returns a null sequence and is still skipped, as before. * chore: install the git hooks that were already configured (#1174) husky, lint-staged, commitlint and their config have been in the repo since v1.8.0 (#429): .husky/pre-commit runs lint-staged, .husky/commit-msg runs commitlint, the lint-staged globs are in package.json and the commitlint rules in .commitlintrc.json. None of it has ever run. husky only takes effect once it sets core.hooksPath, and that happens in the prepare lifecycle script, which the package did not define -- so every clone installed the tooling and left the hooks unwired. That is why formatting keeps failing in CI rather than locally: three of the four open pull requests fail lint-and-build on prettier alone, touching between one and five files each, and the check is the first place anyone finds out. prepare falls back to true so a checkout without a .git directory cannot break installation. The Docker build passes --ignore-scripts, so it never runs this at all. Also pin the Prettier extension to the repo's own copy via prettier.prettierPath, and let .vscode/settings.json out of .gitignore so it applies to everyone. The extension bundles its own prettier otherwise, which formats to a different version's rules than the one CI enforces. * fix: derive the ssh_credentials rebuild from the live schema (#1172) The startup rebuild that drops the old username NOT NULL constraint restated the table's columns as a literal and then copied rows with INSERT INTO temp SELECT <every live column>. The table has gained columns since that literal was written — cert_public_key, pin, sort_order and sync_id are all added by addColumnIfNotExists before the rebuild runs — so the destination was narrower than the source. SQLite rejected the INSERT on a column count mismatch, the error was swallowed as a warning, and the constraint survived every restart. Read the CREATE TABLE statement back from sqlite_master and rewrite just the table name and the username constraint, so the replacement table cannot fall behind the real one. Copy rows by explicit column name rather than positionally, and replay the table's indexes, which DROP TABLE would otherwise take with it along with the sync_id uniqueness. * fix: make audit_logs.user_id nullable on fresh SQLite installs (#1171) The audit trail is meant to outlive the account it belongs to: deleting a user nulls user_id and keeps username for attribution. schema.ts, the Drizzle migrations and AuditLogRepository.anonymizeByUserId were all written against that, but the runtime bootstrap still created user_id TEXT NOT NULL. A second CREATE TABLE IF NOT EXISTS further down migrateSchema() had the correct nullable column, but it can never run — the primary bootstrap has already created the table, so IF NOT EXISTS is a no-op. Every fresh install therefore got the old constraint, and user deletion failed with "NOT NULL constraint failed: audit_logs.user_id" for any account that had logged in at least once, via both the admin delete path and the OIDC account-link cleanup. Fix the primary bootstrap, and rebuild the table on existing databases using the same pattern already used for ssh_credentials.username, since SQLite cannot ALTER a column. * fix: key the sync upsert on the row it just looked up (#1175) A sync push locates the stored row twice -- once to decide insert vs update, once to write it -- and the two lookups were spelled out separately. Only the read knew about singleton entities; the write always keyed on table.id. userPreferences is the only singleton, and user_preferences is the one synced table with no id column: its primary key is user_id. table.id was therefore undefined, and drizzle emitted a comparison with nothing on its left: ( = ? and "user_preferences"."user_id" = ?) The insert branch was unaffected, so the first push of preferences succeeded and every push after it -- the steady state -- failed with SqliteError: near "=": syntax error. Preference sync never converged, and both sides ship the same handler, so the desktop's embedded backend failed identically. Extract the lookup into locateSyncRow() and use it for the read, the update and the tombstone delete, so the three cannot drift apart again. The tombstone path already handled singletons correctly; it now shares the one expression rather than keeping a third copy of it. * fix: refuse an SSH connection whose host id resolves elsewhere (#1176) A client identifies a host by the numeric row id of the database it is displaying. With the desktop connection origin set to "Remote server", that id is resolved against the sync server's ssh_data instead, and the two autoincrement sequences need not line up -- they diverge as soon as each side accumulates inserts and deletes in a different order. resolveHostById() then returns whichever row owns that id here, and the handler takes the address, the credentials, the jump hosts and the stored host key from it. The session opens on a machine the user did not pick, while the host list, host details and export all keep showing the right one. Commands run on the wrong server, a host key mismatch is reported for the wrong reason, and anything typed at the prompt goes to the wrong place. Compare the resolved address against the one the client sent, and refuse when they disagree. Checking at the point the row is loaded covers every use of it rather than each site separately. Addresses are compared with brackets stripped and casing folded, so an IPv6 literal or a hostname written differently is not treated as a different machine; when the server has no address stored, the client's own details are used as before. This stops the wrong-machine session. It does not make delegated connections work when the ids have drifted -- that needs the host to be addressed by syncId across the boundary, which the connection protocol does not currently carry. * fix: refuse SFTP and Docker console on a mismatched host id too (#1177) The wrong-machine guard added for SSH covered one of the paths that resolve a client-supplied host id against this server's ssh_data. The file manager and the Docker console take the same id from the same client and dial whatever row owns it here. The file manager then browses, edits and deletes files on that machine, and the Docker console attaches to its daemon -- both while the UI shows the host the user actually picked. Reuse hostAddressMismatch at each point the row is loaded. The two file manager sites sit inside "failed to resolve credentials, carry on" handlers, so the refusal is a distinct error type those catches rethrow; swallowing it would resume the connection this is meant to stop. The Docker console reports over its socket, as it does for every other refusal. The user-facing wording now lives next to the check instead of being written out at each site. Still uncovered, and not fixable this way: file-manager's transfer session, jump-host-chain and the proxmox routes resolve an id with no client-supplied address to compare it against. Those need the host to be addressed by syncId across the boundary. * feat: address hosts by syncId when a connection is delegated (#1178) A numeric host id belongs to the database that produced it. The desktop app lists hosts from its embedded database and names them by row id, so when a connection is delegated to a sync server that id is resolved against a different table, whose autoincrement sequence has no reason to agree. The row it lands on is a different machine, and it supplies the address, the credentials, the jump hosts and the stored host key. #1176 and #1177 made that refuse rather than connect. Refusing is right, but it leaves "Remote server" unusable once the ids have drifted, which is the state the reporter was in. syncId already names a host identically on both sides -- remote sync relies on it, ssh_data.sync_id is unique, and the API already returns it. It just never reached the backend: hostToSSHHost() builds its result field by field and dropped it. Carry it through, and resolve with it when it is present: resolveHostBySyncId(syncId, userId) // translate, then reuse -> findHostIdBySyncId(syncId) // this database's own row id -> resolveHostById(hostId, userId) // permissions, decryption, audit The translation is deliberately not scoped to a user -- sync_id is unique across the table and a shared host belongs to someone else -- so access stays with the permission check in the id-based path, which the new tests cover. An unknown syncId resolves to nothing rather than falling back to the numeric id: an unknown host is precisely where guessing picks the wrong machine. Clients that send no syncId are unchanged, address comparison included, so an older desktop keeps its safety net instead of breaking. * fix(homepage): make the System Overview update indicator able to fire (#1168) The widget's "Update available" row and orange version text were unreachable, for two independent reasons that each alone would have been enough. It called `getVersionInfo(false)`, and `checkRemote=false` makes /version return early with `{localVersion, status: "update_check_disabled"}` -- no GitHub fetch, no remote version, nothing to compare. It then read `info.updateAvailable`, a field the route does not return in either mode; the success response carries status, localVersion, version, remoteVersion, latest_release, cached and cache_age. `Boolean(undefined)` is false, always. The read type-checked only because `getVersionInfo()` is declared as `Record<string, unknown>`, so a property name that does not exist is indistinguishable from one that does. Let the endpoint do the comparison and read `status === "requires_update"`, which is what the dashboard stats bar and the profile panel badge already do. The row's label was `homepage.overviewUpdate`, whose English string is "Up to date" -- as the label of an update-available row it read "Up to date / Update available". Nobody has seen that, because the row has never rendered; fixing the indicator without the label would have shipped it. Give it its own key. That leaves `homepage.overviewUpdate` unused; it is left in place rather than removed, since it would be the natural value for an always-visible row and that is a product decision, not part of this fix. * fix: capture real client IP for SSH login alerts behind reverse proxy (#1169) * fix: capture real client IP for SSH login alerts behind reverse proxy The WebSocket terminal handler used req.socket.remoteAddress for the "user logged in" alert message, which is the immediate TCP peer (the reverse proxy) rather than the actual client IP forwarded via X-Forwarded-For. This made trust-proxy config on Traefik irrelevant since Termix never read the header for this code path. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * test: cover getClientIp forwarded-header and socket fallback paths Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> * fix: keep already-shared hosts sharing their SSH authentication (#1179) Sharing a host used to hand the owner's SSH authentication to the recipient unconditionally. 2.6.1 put that behind ssh_data.share_ssh_auth, added as NOT NULL DEFAULT 0. Existing rows took the default, so every host shared before the upgrade stopped supplying credentials the moment the column appeared. The snapshot in collectProtocolSnapshots() is guarded by host.shareSshAuth, so nothing was captured; resolveRecipientSharedHostAuthentication() then fell through to "required" and the recipient got "No valid authentication method provided" on a host that had worked the day before. Downgrading to 2.6.0 restored it, since that code has no such column to consult. Backfill the flag for hosts that already appear in host_access. That is where the previous behaviour was in effect and where the owner had already agreed to share; hosts nobody has shared keep the new default and stay off until their owner shares them. Guarded by a settings key so it runs once. Without that, an owner who turns sharing back off would have it turned on again by the next restart. * fix: let a single credential disable 2FA again (#1180) The disable dialog has one field, labelled "Enter TOTP code or password", and its caller passes that value as disableTOTP(input) -- so it arrives as `password` with `totp_code` undefined. That call has been unchanged since v2.3.0. 2.5.1 changed the route to require both: if (!totp_code || (!userRecord.isOidc && !password)) -> 400 replacing `const credential = password || totp_code`. The first check has rejected every attempt since, whatever the user typed, so nobody has been able to turn 2FA off -- the client reports the generic "Failed to disable 2FA", which hides which check failed. Take one credential again and try it as a TOTP code, a backup code, then the account password. verifyTotpReauth still refuses the password itself, so that comparison stays in the route; an OIDC user has no password hash and reaches neither. The backup-codes route has the same shape but no caller in the UI -- its codes are returned when TOTP is enabled -- so it is left alone rather than changed blind. * fix: attach user-managed CA certificates over SFTP too (#1181) opkssh-cert-auth.ts exports two helpers that end in the same _applyCertToConnection: setupOPKSSHCertAuth, and setupCACertAuth for user-managed CA-signed -cert.pub files. The file manager called the first one twice and the second one never. So a host whose key is paired with a CA-signed certificate authenticated in a terminal and failed over SFTP, while OPKSSH certificates -- going through the other helper -- worked in both. The file manager was not missing certificate support in general; it was missing one of the two paths into it. The connection also never carried the certificate to begin with: cert_public_key was not among the fields copied into resolvedCredentials, so both places that build an SFTP connection now read it and attach it where the private key is prepared -- the dedicated transfer session and the main connect route. An unusable certificate is logged and skipped rather than failing the connection. The key alone may still be accepted, which is what happened while this was not wired up at all, and turning that into a hard failure would break setups that currently work. Reported in #1160 with the call-site asymmetry already traced; the reporter noted they could not confirm the link to their failure, having moved off SSH CAs. The asymmetry is real either way and reproduces the symptom exactly. * fix: authenticate the desktop Docker console WebSocket (#1182) The console WS opted out of the query token: buildOriginWsUrl({ ..., includeLocalJwt: false }) leaving it with no credential at all on the desktop. The browser WebSocket API cannot set an Authorization header, and while Electron's main process injects a remembered JWT cookie, it requires an exact origin match -- the cookie belongs to the API origin (localhost:30001) while the console connects to 127.0.0.1:30009, so nothing is attached. The backend then closes the handshake with 1008 before it logs anything, which is why the log has no docker-console entries while stats and logs polling keep succeeding on the same host. The web build is unaffected: it connects same-origin and its cookie is sent normally. Drop the opt-out so the console carries the local JWT like the SSH terminal does -- the same token, the same query parameter, and the backend already reads it there. Guacamole passes includeLocalJwt: false too, but rdp/vnc/telnet always resolve to "remote", so that call never reaches the local branch. * fix: use getClientIp in getRequestMeta for correct audit-log IPs (#1183) * fix: capture real client IP for SSH login alerts behind reverse proxy The WebSocket terminal handler used req.socket.remoteAddress for the "user logged in" alert message, which is the immediate TCP peer (the reverse proxy) rather than the actual client IP forwarded via X-Forwarded-For. This made trust-proxy config on Traefik irrelevant since Termix never read the header for this code path. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * test: cover getClientIp forwarded-header and socket fallback paths Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix: use getClientIp in getRequestMeta for correct audit-log IPs getRequestMeta had near-duplicate, strictly worse forwarded-header logic: the array branch didn't split/trim, there was no socket-peer fallback, and it returned "" instead of "unknown". Delegate to getClientIp so the audit trail gets the same correctness as the terminal login-alert path. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> * feat: add terminal image handoff (#1170) * chore: sync Crowdin translations * fix(homepage): make the System Overview update indicator able to fire (#1168) The widget's "Update available" row and orange version text were unreachable, for two independent reasons that each alone would have been enough. It called `getVersionInfo(false)`, and `checkRemote=false` makes /version return early with `{localVersion, status: "update_check_disabled"}` -- no GitHub fetch, no remote version, nothing to compare. It then read `info.updateAvailable`, a field the route does not return in either mode; the success response carries status, localVersion, version, remoteVersion, latest_release, cached and cache_age. `Boolean(undefined)` is false, always. The read type-checked only because `getVersionInfo()` is declared as `Record<string, unknown>`, so a property name that does not exist is indistinguishable from one that does. Let the endpoint do the comparison and read `status === "requires_update"`, which is what the dashboard stats bar and the profile panel badge already do. The row's label was `homepage.overviewUpdate`, whose English string is "Up to date" -- as the label of an update-available row it read "Up to date / Update available". Nobody has seen that, because the row has never rendered; fixing the indicator without the label would have shipped it. Give it its own key. That leaves `homepage.overviewUpdate` unused; it is left in place rather than removed, since it would be the natural value for an always-visible row and that is a product decision, not part of this fix. * fix: capture real client IP for SSH login alerts behind reverse proxy (#1169) * fix: capture real client IP for SSH login alerts behind reverse proxy The WebSocket terminal handler used req.socket.remoteAddress for the "user logged in" alert message, which is the immediate TCP peer (the reverse proxy) rather than the actual client IP forwarded via X-Forwarded-For. This made trust-proxy config on Traefik irrelevant since Termix never read the header for this code path. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * test: cover getClientIp forwarded-header and socket fallback paths Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> * feat: add terminal image handoff Add authenticated browser upload and clipboard image handoff for terminal agents. Normalize images through Sharp, enforce storage and request limits, preserve host-visible paths, and provide a stable three-button terminal toolbar. * docs: document terminal image handoff deployment --------- Co-authored-by: LukeGus <bugattiguy527@gmail.com> Co-authored-by: kacperpietrzyk <105545577+kacperpietrzyk@users.noreply.github.com> Co-authored-by: Brennan Neoh <497569+brennanneoh@users.noreply.github.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> * fix(desktop): stop suppressing the update prompt, and make the version badge reachable (#1167) * fix(desktop): stop suppressing the update prompt for users who need it The startup update modal stored its dismissal under the local app version rather than the remote version being offered, and the up-to-date branch wrote that key with no user interaction at all. A user who launched while current had their own version recorded; once the next release shipped, `dismissedVersion === currentVersion` still held and the modal was skipped on every launch. It reappeared only after the user had already updated -- the inverse of what it is for. Present since v2.3.0. Key the dismissal on the offered remote version instead. The change is backward compatible: an existing key holding 2.6.0 compares unequal against a remote 2.6.1, so affected installs are prompted on their next launch. When the check itself fails there is no remote version, so nothing is recorded and no future prompt is suppressed. That left the version badge as the only remaining signal, and it was an inert span on both surfaces that render it -- the profile panel and the dashboard stats bar -- even though the `getVersionInfo()` response it is built from already carries `latest_release.html_url`. Extract the duplicated badge into `components/version-badge.tsx` and make the update case a link to the release, with an accessible name that says where it goes. The beta and stable cases stay inert. `getVersionInfo()` returned `Record<string, unknown>`, so the release URL was unreachable without a cast; give it a `VersionInfo` type that keeps an index signature, since `SystemOverviewWidget` reads `updateAvailable` off the same response. * test: cover the read that actually reaches the badge The extracted VersionBadge is unit-tested, but the line that decides whether it ever receives a URL -- pulling `latest_release.html_url` out of the version response -- was duplicated at both call sites and asserted nowhere. A wrong property there compiles (the response type keeps an index signature) and every existing test still passes. Give it a name, `releaseUrlFrom`, use it from both surfaces, and test it: the happy path, a response with no release, a release with no URL, and a missing response, since the caller's fetch can reject. Empty string is the contract the badge reads as "nothing to link to", so it stays an inert span rather than rendering a dead anchor. * docs: state the index signature's real reason The comment claimed the version endpoint carries fields beyond the typed ones, citing `updateAvailable`. It does not -- `GET /version` returns status, localVersion, version, remoteVersion, latest_release, cached and cache_age, and nothing else. SystemOverviewWidget reads `updateAvailable` off it regardless, which is why the permissive index signature has to stay, but that is a stale read rather than an undocumented field. Say so accurately. * Send alerts in Discord channels with Webhooks (#1158) * feat(utils): add discord webhook sender Add a utility to send alert embeds to Discord webhooks. * fix(utils): validate DNS and use global fetch for outbound requests Prevent private destination access and rely on global fetch after DNS validation. * chore(logger): include extra context in logs Show additional sanitized context entries for clearer diagnostics. * feat(alerts): support discord channel type in routes and engine Accept discord channels and route alerts to the Discord sender. * feat(ui): add Discord option to notification channel dialog Allow creating/editing Discord webhook channels with username/avatar. * fix(ui/api): accept structured config payload for notification channels Allow the client to pass structured config objects (or strings) when creating/updating channels. * chore: sync Crowdin translations * fix(homepage): make the System Overview update indicator able to fire (#1168) The widget's "Update available" row and orange version text were unreachable, for two independent reasons that each alone would have been enough. It called `getVersionInfo(false)`, and `checkRemote=false` makes /version return early with `{localVersion, status: "update_check_disabled"}` -- no GitHub fetch, no remote version, nothing to compare. It then read `info.updateAvailable`, a field the route does not return in either mode; the success response carries status, localVersion, version, remoteVersion, latest_release, cached and cache_age. `Boolean(undefined)` is false, always. The read type-checked only because `getVersionInfo()` is declared as `Record<string, unknown>`, so a property name that does not exist is indistinguishable from one that does. Let the endpoint do the comparison and read `status === "requires_update"`, which is what the dashboard stats bar and the profile panel badge already do. The row's label was `homepage.overviewUpdate`, whose English string is "Up to date" -- as the label of an update-available row it read "Up to date / Update available". Nobody has seen that, because the row has never rendered; fixing the indicator without the label would have shipped it. Give it its own key. That leaves `homepage.overviewUpdate` unused; it is left in place rather than removed, since it would be the natural value for an always-visible row and that is a product decision, not part of this fix. * fix: capture real client IP for SSH login alerts behind reverse proxy (#1169) * fix: capture real client IP for SSH login alerts behind reverse proxy The WebSocket terminal handler used req.socket.remoteAddress for the "user logged in" alert message, which is the immediate TCP peer (the reverse proxy) rather than the actual client IP forwarded via X-Forwarded-For. This made trust-proxy config on Traefik irrelevant since Termix never read the header for this code path. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * test: cover getClientIp forwarded-header and socket fallback paths Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> * chore: add url to SENSITIVE_FIELDS for discord url * fix: enforce SSRF protection on outbound fetches Use `undici.fetch` with the custom DNS lookup hook to ensure the validated DNS resolution is the one used for the connection. Fix DNS lookup/address validation bugs and add coverage for private, public and invalid addresses, including the resolution issue affecting Discord endpoints. * chore: prettier format * fix: validate all DNS addresses and close dispatcher * fix DNS lookup validation and callback handling * update safe outbound fetch tests * ensure created dispatcher is properly closed * chore: remode url from SENSITIVE_FIELDS for other logs --------- Co-authored-by: LukeGus <bugattiguy527@gmail.com> Co-authored-by: kacperpietrzyk <105545577+kacperpietrzyk@users.noreply.github.com> Co-authored-by: Brennan Neoh <497569+brennanneoh@users.noreply.github.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> * fix tmux UTF-8 path handling (#1157) Co-authored-by: Carl <scarlettme@qq.com> * chore: update package lock * chore: update gitnore * fix: [BUG] (#1049) https://github.com/Termix-SSH/Support/issues/1049 * fix: test commitlint path fix (#1021) * fix: SGR mouse-tracking escape codes printed as text (#1023) * fix: quote $1 in commit-msg hook so it works from git worktrees * fix: [BUG] could not connect to the database (#1057) https://github.com/Termix-SSH/Support/issues/1057 * fix: [BUG] VNC connect macOS screen sharing failed (#1063) https://github.com/Termix-SSH/Support/issues/1063 * fix: [BUG] Meta key (#1075) https://github.com/Termix-SSH/Support/issues/1075 * fix: [BUG] Remote sync doesn't work with Termix behind nginx proxy (#1085) https://github.com/Termix-SSH/Support/issues/1085 * fix: [BUG] webhook not working (#1080) https://github.com/Termix-SSH/Support/issues/1080 * fix: [BUG] First server sync doesn't refresh UI (#1084) https://github.com/Termix-SSH/Support/issues/1084 * fix: [BUG] How to enable SSL using custom certificate (#1083) https://github.com/Termix-SSH/Support/issues/1083 * fix: [BUG] Sudo Password Auto-fill Persistance (#1098) https://github.com/Termix-SSH/Support/issues/1098 * feat: [FEATURE] Expand Snippets Function (#1031) https://github.com/Termix-SSH/Support/issues/1031 * feat: [FEATURE] (#1055) https://github.com/Termix-SSH/Support/issues/1055 * feat: [FEATURE] Support for Headscale API Keys (hskey prefix) and Custom API Endpoints (#1013) https://github.com/Termix-SSH/Support/issues/1013 * feat: [FEATURE] Allow paste on non https (#1026) https://github.com/Termix-SSH/Support/issues/1026 * feat: be-azerty layout (#1073) https://github.com/Termix-SSH/Support/issues/1073 * feat: Keyboard shortcuts to move between open tabs (#1069) https://github.com/Termix-SSH/Support/issues/1069 * feat: Session Logs as a downloadable text file (#1058) https://github.com/Termix-SSH/Support/issues/1058 * fix: persist and auto-fill saved SSH and sudo passwords * fix: persist docker runtime selection and docker manager UI issues * feat: Allow excluding specific mounts from disk usage metrics (#1046) https://github.com/Termix-SSH/Support/issues/1046 * feat: Expand Snippets Function (#1031) https://github.com/Termix-SSH/Support/issues/1031 * chore: restore the prettier baseline on dev-2.7.0 (#1185) Five files on dev-2.7.0 do not match prettier, so `npx prettier --check .` fails and takes lint-and-build with it — on every pull request, whatever it changes. Formatting only, produced by `npx prettier --write` on exactly the files the check names. No logic touched: tsc passes for both configs, backend 148 files / 1106 tests and UI 71 files / 479 tests all pass. * test: keep the tmux escaping test runnable on Windows (#1184) The escaping check ran its command through /bin/sh. That binary does not exist on Windows, and Windows is a supported platform for the desktop app, so `npm test` fails there on a test about string quoting. CI is ubuntu-only and would never see it. Assert the escaped string directly, which covers the rule on every platform, and keep the round trip through a real shell as a separate case guarded by platform -- it is the stronger evidence where a shell exists. * chore: drop the unreachable table probes from migrateSchema (#1186) Eleven blocks in migrateSchema() guarded a CREATE TABLE IF NOT EXISTS behind SELECT id FROM <table> LIMIT 1, for tables the primary bootstrap had already created earlier in the same startup. The probe could not throw, so the catch never ran. Two of those unreachable copies had drifted from the definition actually in use. sessions had lost ON DELETE CASCADE, and session_recordings still carried user_id TEXT NOT NULL with ON DELETE CASCADE and no username -- the shape from before audit trails were made to outlive the account. They would have taken effect had anything ever reordered startup. Kept, because they are not the same thing: - blocks whose catch runs ALTER TABLE ADD COLUMN. CREATE TABLE IF NOT EXISTS is a no-op on a table that exists, so a database created before a column was added still needs the ALTER. Those probe a column, not a table. - blocks that are a table's only creation point. - the user_open_tabs block, which is a data migration; its SELECT is a precondition, not a probe. Deletion only, no behaviour change. * fix: repair the frontend type-check and clear the 299 errors behind it (#1189) * fix: repair frontend type-check configuration and the errors it exposed The root tsconfig.json is solution-style with "files": [], so the `npx tsc --noEmit` that CI runs compiles nothing at all. Frontend types have therefore never been checked, and 299 errors had accumulated behind that no-op. This clears just over half of them; nothing here changes runtime behaviour. Configuration: - "@/types" resolved through the "@/*" fallback to src/ui/types, which does not exist. Added an explicit mapping to src/types/index.ts. - src/vite-env.d.ts sits outside the include list, so import.meta.env and the ?url import suffix were unknown. Added. - src/ui/types/ held a single file, keybindings.ts, while every other shared type lives in src/types/. Six modules imported it as "@/types/keybindings" and silently resolved to nothing. Moved. Type definitions that had fallen behind the code: - guacamoleConfig and terminalConfig were Record<string, unknown> in ui-types while the editor read concrete fields off them. Both now use the real interfaces; GuacamoleConfig is extracted from its inline definition in guacamole-api.ts so the two cannot drift again. - customThemeColors and TerminalTheme["colors"] described the same object with different optionality. Aligned. - FileWindow declared its own SSHHost whose authType was "password" | "key", which no longer matches the eight the app supports. - connectSSH and listSSHFiles returned Record<string, unknown>, so every field the callers destructured arrived as unknown. - AxiosRequestConfig and AxiosResponse were used without being imported. Also adds asHttpError() for the handful of catch blocks that reached into an unknown binding, and narrows the Host | HostFolder comparator and the RailItem union at the points where the discriminant was not carrying. Note: dbHealthMonitor.reportDatabaseError was being called with a second argument it does not accept, so the authenticated-or-not flag was already being discarded at runtime. Dropped the argument to match the signature; whether that flag was meant to gate the report is worth a separate look. * fix: clear more of the frontend type-check baseline Continues the previous commit; 140 errors down to 70. Three of these were real defects rather than missing annotations. Defects: - DashboardTab counted active tunnels by comparing status to "CONNECTED", but CONNECTION_STATES.CONNECTED is "connected" and that is what the tunnel manager emits, so the count was always zero. Now compares against the constant. - QuickActionsCard requires isAdmin and gates a block of admin-only actions on it, but neither call site passed it — those actions never rendered. Both call sites also passed isAdmin to HostStatusCard, which does not accept it; the prop had evidently been moved and the call sites missed. - The host editor stores jump host ids as strings and sent them straight to an API typed for numbers. Backend host lookups compare against an integer column, which a string does not match on Postgres or MySQL. Converted. Types brought in line with the data: - Host and HostData were missing hasPassword, hasSudoPassword, sortOrder, instanceId, connectionOrigin, vaultProfileId, syncId, and the "vault" authType; TabContextTab was missing the "tunnel" tab, which TabContext already branched on. - statsConfig and terminalConfig used inline shapes that had drifted from StatsConfig and TerminalConfig. Both now reference the real interfaces; excludedMounts, which the editor reads, was added to StatsConfig. - downloadSSHFile, generateKeyPair and generatePublicKeyFromPrivate all returned Record<string, unknown> while callers read named fields. - The Guacamole declarations were missing Keyboard.reset, Client.onfile, InputStream.sendAck, Status.Code and BlobReader, all already in use. - NetworkTopologyNode/Edge could not be discriminated, though the graph code tells them apart by testing for source/target. ProxyNode.type is now 4 | 5 | "http" | "socks4" | "socks5". The editor writes the string spellings while proxy-helper.ts tests for "http" and casts everything else to 4|5 before handing it to the socks client, so a chained proxy reaches it as "socks5" rather than 5. Typed as what is actually stored; reconciling the two spellings needs a migration decision and is left alone here. * fix: continue clearing the frontend type-check baseline 70 errors down to 44. Dead configuration removed: - Terminal set terminal.options.bellStyle on xterm, which dropped the option in v5. The host editor still exposes the setting and stores it; it has simply had no effect on the terminal since that upgrade. Making the bell work again means handling the onBell event and is left alone. - CodeEditor passed scrollPastEnd to basicSetup, which has no such option. - FileManager passed an id to openWindow, which assigns its own and discards what it is given — the component was already being rendered under a different id than the one the caller held. Widgets that were registered but unreachable: - DockerActivityWidget and SshQuickConnectWidget register under "docker_activity" and "ssh_quick_connect", neither of which was in WidgetTypeId, and both referenced config interfaces that did not exist. Added the ids and the two interfaces, inferred from their edit forms and defaultConfig. More endpoints given their real return types: getRecentFiles, getPinnedFiles, getFolderShortcuts (arrays, not records), downloadSSHFile, copySSHItem, generateKeyPair, generatePublicKeyFromPrivate and getSnippets. parseGuacamoleConfig() handles the host row carrying guacamoleConfig either parsed or as raw JSON, which GuacamoleApp was reading fields off directly. TerminalHostConfig was missing name, which it reads for the activity log. * fix: continue clearing the frontend type-check baseline 44 errors down to 17. Host and AuditLog are now type aliases rather than interfaces. An interface has no implicit index signature, so neither could be assigned to the `[key: string]: unknown` shapes that TerminalHostConfig, HostMetricsTab's HostConfig and several helpers declare — eight errors came from that alone. More dead configuration: - i18n passed checkWhitelist to the language detector, which no longer has that option; supportedLngs already covers it. - SSHAuthDialog passed scrollPastEnd to basicSetup, same as CodeEditor. - AudioPreview's onLoadedMetadata never fired: react-h5-audio-player spells the prop onLoadedMetaData. - MarkdownRenderer destructured `inline` from code(), which react-markdown removed in v9, so the flag was always undefined and every inline span took the block branch when it happened to carry a language class. Now derived from whether a className is present at all. - SnippetsPanel put a title prop on a lucide icon, which does not forward it; changed to aria-label so the hint is actually reachable. updateHostConfig in TabContext replaced tab.hostConfig wholesale with the six-field literal it receives, dropping everything else the tab held about the host. It now merges onto the existing config. Also: getReleasesRSS, getUserAlerts and getVersionInfo have real return types (UpdateLog kept private copies of two of them, and VersionInfo was missing `version`, which the endpoint sends and the panel renders); wakeOnLan and vncCredentialId get the numeric ids they are typed for; and the tmux formatter takes i18next's TFunction instead of a hand-written signature it does not satisfy. * fix: clear the last frontend type errors and make CI actually run the check Baseline is now zero, so the check can be turned on. `npx tsc --noEmit` — what CI ran and what `npm run type-check` was — compiles nothing: the root tsconfig.json is solution-style with "files": [], and plain tsc does not follow project references. Both are now `tsc -b`, which builds tsconfig.app.json and tsconfig.node.json. Verified by planting a type error and watching the command fail. Last defects in this batch: - patchOpenTab could not carry hostId, so quick-connect's "save this host and attach the tab to it" call was passing a field excluded from the type all the way down. The column exists and updateForUser spreads whatever it receives, so the write worked; only the types disagreed. Widened front to back. - The file-comparison window opened without x, y, width or height — every other openWindow call passes them — and sent a `type` field WindowInstance does not have. - HostEditor gated a block on authType === "warpgate", which is not one of the eight authType values. Unreachable, and it held only a label and a description. Removed. - FileManager passed onLoadDirectory to a sidebar that neither declares nor reads it, and FileManagerApp passed embedded to a FileManager that has no such prop. - TunnelApp's minimal Host was missing three required flags. The remainder were assertions at boundaries that are genuinely loose: bulk host import takes rows assembled from untyped input and validates them server-side, and a vi.fn() whose body only throws infers never. * feat: add drive file browser and drag-and-drop upload for RDP (#1187) Drive redirection could already be enabled per host, but the redirected drive lived inside guacd with no way to reach it from the browser: the client never handled onfilesystem, so the mounted volume was writable from Windows and invisible from Termix. Add a file browser panel that lists the drive, downloads files, and uploads them, plus drag-and-drop onto the display which opens the panel and uploads into the directory currently shown. The disable-upload and disable-download connection settings are honoured by the UI, not just passed to guacd. A rejected upload stops the BlobWriter without firing onerror or oncomplete, so the error ack is watched explicitly; otherwise the transfer would hang forever. Directory reads carry a deadline for the same reason. Also declares Guacamole.Object, Client.onfilesystem, BlobReader and BlobWriter in the local type definitions, which previously omitted them. * fix: keep the mouse working on touch-capable devices in RDP/VNC (#1190) Reported as "mouse input broken, keyboard fine" after 2.5.1 (#1102). 2.5.1 bound Guacamole.Mouse unconditionally. 2.6.0 replaced that with a three-way branch on touchMode, and the touch branches replace the mouse binding instead of adding to it: if (touchMode === "touchscreen") new Guacamole.Mouse.Touchscreen(el) else if (touchMode === "touchpad") new Guacamole.Mouse.Touchpad(el) else new Guacamole.Mouse(el) The two do not overlap. Guacamole.Mouse listens for mousedown/mousemove/ mouseup; Touchscreen and Touchpad listen only for touchstart/touchmove/ touchend. So in a touch mode nothing is listening for the mouse at all. touchMode defaults to "touchscreen" whenever navigator.maxTouchPoints > 0, which is true of every laptop with a touchscreen — machines that are still driven by a mouse. Those users lost the pointer entirely while the keyboard kept working, because Guacamole.Keyboard is bound independently. The physical pointer is now always bound and a touch emulator is layered on top when one is selected. Extracted to bindPointerInput() so the binding is testable; the test fails against the old branch. Note the issue also carries a second, unrelated report where well-formed mouse frames do reach guacd and the VNC leg ignores them. That one is not this, and the guacd image is pinned to 1.6.0 in both 2.5.1 and 2.6.1, so it is not an upgrade either. * fix: deduplicate /api/folders requests to prevent intermittent folder disappearance (#1191) * chore: sync Crowdin translations * fix: deduplicate /api/folders requests to prevent intermittent folder disappearance getSSHFolders() had no request deduplication while getSSHHosts() used a TTL cache with in-flight dedupe. When loadHosts() fired multiple times during rapid navigation between Credentials and Hosts panels, the folder response could arrive after the hosts response, causing the sidebar tree to render without folder metadata. - Add foldersCache (10s TTL) in hosts-request-cache.ts - Wrap getSSHFolders() API call in getCachedSSHFolders() - Invalidate folders cache on renameFolder, updateFolderMetadata, deleteAllHostsInFolder, and renameCredentialFolder - Include foldersCache in invalidateHostsAndStatusCaches() Closes Termix-SSH/Support#1103 Signed-off-by: RawNuke <67506722+RawNuke@users.noreply.github.com> --------- Signed-off-by: RawNuke <67506722+RawNuke@users.noreply.github.com> Co-authored-by: LukeGus <bugattiguy527@gmail.com> * chore(deps): bump undici from 8.9.0 to 8.10.0 in the prod-minor-updates group (#1195) * chore: sync Crowdin translations * chore(deps): bump undici in the prod-minor-updates group Bumps the prod-minor-updates group with 1 update: [undici](https://github.com/nodejs/undici). Updates `undici` from 8.9.0 to 8.10.0 - [Release notes](https://github.com/nodejs/undici/releases) - [Commits](https://github.com/nodejs/undici/compare/v8.9.0...v8.10.0) --- updated-dependencies: - dependency-name: undici dependency-version: 8.10.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: prod-minor-updates ... Signed-off-by: dependabot[bot] <support@github.com> --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: LukeGus <bugattiguy527@gmail.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * feat: proxmox metrics integration * feat: add folder select to the host multi select feature * feat: implement context aware terminal toolbar with quick links, host info, image pasting, etc * feat: made toolbar open file manager at path * fix: delete folder route not invalidating host list cache * fix: match host list icons with tab bar iconfix * fix: change sidebar reset button icon to seperate against fullscreen button * feat: unify connection system and add connection logs to guacd hosts * fix: make mobile terminal scrollback match xterm wheel behavior (#1198) * fix: route mobile terminal scrolling through xterm viewport * docs: document mobile terminal touch scrolling * chore: add a note to not place files in docs * chore: remove touch imput from docs * feat: improve snippet system with variable snippets and collapse settings * feat: new fleet system with snippet, packages, files, and inventory features * fix: command pallete not loading new activity and made enter load first item * feat: add subhost from parent host organization feature * feat: add workspaces feature to save tab layout * perf: greatly improved performance across metrics polling and host management for enterprise users * feat: add a onboarding system with a new interface simplicity system * feat: finalize the multi dialect database system * fix: bind trusted MFA devices to client installs (#1202) * fix: merge OIDC group claims across sources (#1203) * fix: allow disabling SSH keepalives (#1204) * fix: distinguish reachable and available hosts (#1206) * fix: throttle session activity persistence (#1207) * fix: preserve saved RDP connection settings (#1208) * fix: authenticate tunnel status stream (#1209) * fix: select quick-created credentials (#1210) * fix: stagger initial metrics collection (#1211) * fix: stagger initial metrics collection * fix: admit reachable hosts to initial metrics * fix: prevent long host names shifting dashboard metrics (#1205) * feat: add global touch input settings (#1201) Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> * fix: keep host list row sizing stable (#1213) * fix(guacamole): correct Windows key mapping (#1216) * fix: normalize OIDC discovery issuer URLs (#1218) * fix: prompt for RDP domain credentials (#1212) * fix: route status checks by connection origin (#1214) * fix: restore desktop Tailscale configuration (#1215) * fix(docker): restore Node 24 for ssh2 native crypto (#1217) * feat: added new automations feature with events, channels, and steps * feat: allowed some tabs in the app rail to be opened as its own tab or in a new right sidebar * feat: expand onboarding process with more customization/features * feat: initial implementation of the termix ai feature * chore: run linter * fix: issue #424 (#424) https://github.com/Termix-SSH/Support/issues/424 * fix: Not working without internet connection. Missing OPKSSH binary in pre-built image. (#1133) https://github.com/Termix-SSH/Support/issues/1133 * fix: SQLite forceSave on telemetry writes causes periodic SSH terminal stalls in 2.6.x (#1109) https://github.com/Termix-SSH/Support/issues/1109 * feat: How to enable SSL using custom certificate (#1083) https://github.com/Termix-SSH/Support/issues/1083 * fix: show profile API key after creation (#1221) * feat: add trusted proxy authentication (#1222) * fix: clarify SSH agent authentication (#1224) * feat: add first-class split screen tabs (#1226) * feat: add split tab data model * feat: make split screens top-level tabs * feat: persist and manage split layouts * feat: launch native RDP on Windows desktop (#1223) * feat: launch native RDP on Windows * style: format native RDP launcher * feat: enhance custom disk and network metrics (#1220) * feat: enhance host disk and network metrics * fix: align enhanced metrics types * fix: preserve Proxmox guest identity on edit (#1219) * fix: preserve Proxmox guest identity on edit * fix: type Proxmox guest source metadata * chore: dead-code cleanup and small refactors (#1225) * chore: remove dead code and unused exports * chore: remove unused api client functions * chore: remove unused backend helpers * refactor: extract getErrorMessage helper for repeated error extraction * refactor: unify error message extraction across backend with getErrorMessage * refactor: unify error message extraction in frontend with getErrorMessage * refactor: merge duplicate imports from the same module * refactor: use Array.includes in TabBar * chore: drop biome, keep prettier as the single formatter * style: apply prettier formatting to refactored files * fix: close active tab with Ctrl+W on Windows * fix: make tray Quit terminate the desktop app * feat: verify host transfer integrity * fix: reuse transfer sessions during verification * feat: select the fastest host transfer route * feat: tune host transfers adaptively * feat: adapt background polling to activity (#1233) * feat: adapt background polling to activity * feat: extend adaptive polling coverage * feat: make polling cost and network aware (#1234) * feat: make repeat navigation feel instant (#1235) * feat: make file operations feel immediate (#1236) * feat: preload likely user actions (#1237) * feat: preload likely file previews * feat: preload likely host tools * feat: preload likely file viewers * fix: replace stale terminal input listeners * feat: add links to docs for all new features * chore: update readme * fix: warn before discarding host changes (#1229) * feat: learn local host action preferences (#1238) * feat(terminal-toolbar): add bounded movable desktop toolbar (#1239) * feat: add local adaptive decision engine (#1240) * feat: adapt speculative resource usage (#1241) * feat: persist adaptive transfer profiles (#1242) * Fix .preferred_username when using LDAP login. (#1243) * chore: sync Crowdin translations * Fix .preferred_username when using LDAP login. Strips internal LDAP prefix from username. --------- Co-authored-by: LukeGus <bugattiguy527@gmail.com> * feat: learn direct transfer routes (#1244) * feat: learn speculative preload usefulness (#1245) * fix: - Adjusting the SSH Authentication from Vault to something else fails (#1152) https://github.com/Termix-SSH/Support/issues/1152 * fix: terminal graphical display, special characters inserted, distorted - `midnight comma... (#1145) https://github.com/Termix-SSH/Support/issues/1145 * feat: single click on host in list opens session - should be only on double click (#1146) https://github.com/Termix-SSH/Support/issues/1146 * feat: Terminal: custom font/ font selection/ how-to for adding a font - `MesloLGS NF` (#1140) https://github.com/Termix-SSH/Support/issues/1140 * fix: revert host single click to open session, make double click an option (#1146) Single click opens a session again by default. The old double click behavior can be turned on in Customize Sidebar. * chore: drop prettier check from beta release workflow, run formatter * chore: patch dependabot vulnerabilities via npm overrides * fix: reset adaptive resource state between tests to stop cross-test leaks * feat: replace terminal toolbar density popover with a native select * fix: pin hardwareConcurrency in adaptive budget tests so CI cores don't change the tier * fix: allow dylib files in mac universal arch rules so mas build packages sharp * feat: add file manager trash (#1250) * feat: add inheritable connection defaults (#1246) * feat: add desktop local terminal (#1247) * feat: add interactive terminal macros (#1248) * feat: add adaptive SSH local echo (#1249) * fix: sync desktop host changes immediately (#1252) * fix: route desktop sharing through synced server (#1253) * Fix terminal image uploads and add safe diagnostics (#1254) * feat: add configurable terminal image storage backends * feat: add admin image storage settings * fix: preserve native clipboard PNG uploads * fix: quote terminal image paths safely * docs: record image storage security remediation plan * fix: close remote image SFTP channels * fix: restrict remote image SFTP permissions * fix: bound remote image SFTP writes * fix: add best effort remote image retention * fix: cap normalized image output size * fix: bound concurrent image processing * fix: fail closed on local image inspection errors * test: cover fail closed image storage and atomic settings * fix: enforce remote image quota and upload admission * fix: serialize remote quota and verify existing paths * fix: use synchronous sqlite settings transaction * fix: keep settings transactions portable across dialects * fix: bound image processing admission queue * fix: serialize remote image quota across processes * fix: recover stale remote image locks safely * fix: preserve remote storage errors during unlock * fix: fail closed when stale lock removal fails * fix: harden image upload resource and storage cleanup * fix: bound SFTP operations and lock lifetime * fix: bound SFTP acquisition and cleanup callbacks * fix: close late SFTP channels and test cleanup stalls * fix: preserve SFTP inspection client context * feat: add image upload source metadata * fix: expose image upload metadata in logs * chore: exclude internal plan from pull request * style: apply prettier formatting --------- Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> * fix: batch of security hardening fixes (#1255) * fix: bind desktop auto-session loopback check to the TCP peer address * fix: escape HTML entities in Vault OIDC callback responses * fix: route homepage ping and rss through the SSRF-safe outbound fetch * fix: scope tunnel status endpoints to hosts the caller can access * chore: update release notes * chore: update release notes to write more about the ai integration * fix: unbreak windows and macos electron builds after node-pty Install Spectre-mitigated MSVC libs on the Windows runner and cover node-pty's spawn-helper in the macOS universal arch rules. * fix: rework connection defaults ui into a dialog and add missing i18n keys * fix: rework macros panel with i18n, plain text matching, and list layout * feat: add docs links for trash, connection defaults, and local echo * fix: make image storage and trash tests pass on windows * fix: stop docs links squeezing sidebar panel headers * fix: put automations docs link back on the tabs row * fix(desktop): keep Linux credential storage working on unrecognised desktops (#1261) Chromium resolves safeStorage's backend from XDG_CURRENT_DESKTOP and falls back to the basic_text store for any desktop it has no mapping for, which covers every wlroots-style compositor (Hyprland, sway, niri, river). isEncryptionAvailable() reports false for that store, so saveRemoteSyncJwt refused every write and the OIDC sign-in it was storing appeared to succeed. The sync engine then found no JWT and reported the session as expired, which sent users looking at their OIDC provider for a fault that was never there. Name the libsecret backend explicitly on those desktops. They run an ordinary Secret Service, so that is enough to make encryption available again. KWallet desktops keep their auto-detected backend, an explicit --password-store still wins, and no stored secret can be orphaned by the switch because isEncryptionAvailable() gated every write that would have created one. Also stop discarding the {success: false} the main process returns when it cannot store a credential: on a machine with no Secret Service at all, the sign-in now says so instead of silently completing. Co-authored-by: alexandre-vl <rafaelsenchais@gmail.com> * chore: update release notes * chore: update release notes * fix(file-manager): widen trash dialog so names and paths are not cut off * fix(sidebar): stop hover action tray overlapping the row below it * fix(hosts): make real status colors toggle actually apply * feat(local-terminal): add rail button and fix hardcoded tab label * chore: update release notes * fix(ai): hide assistant everywhere when admin disables it globally * fix(automations): fix concurrency race, wire docker and internal event triggers Claim the in-flight slot in the same tick it is checked, poll container state for docker_event triggers, emit the internal events, apply the schedule time zone, and expose the concurrency policy in the editor. * fix(sidebar): rework host and credential drag-to-reorder Adds a lock toggle in the sort menu and fixes reorder positioning, cross-folder drops, and the duplicate drop indicator. * chore(sidebar): drop unused sortKey prop from host and credential trees * fix(sidebar): fix row height in click tray mode so status stripes stop overlapping * fix(onboarding): remove add-first-host step that closed onboarding mid-flow * fix(release): upload release notes so Mac App Store review submission stops failing * chore: sync Crowdin translations for 2.7.0 --------- Signed-off-by: dependabot[bot] <support@github.com> Signed-off-by: RawNuke <67506722+RawNuke@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> Co-authored-by: kacperpietrzyk <105545577+kacperpietrzyk@users.noreply.github.com> Co-authored-by: Brennan Neoh <497569+brennanneoh@users.noreply.github.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> Co-authored-by: T3rM1nAt0-R <niraj.sangani91@gmail.com> Co-authored-by: Horziox <horziox.dev@gmail.com> Co-authored-by: William Shi <184219650@qq.com> Co-authored-by: Carl <scarlettme@qq.com> Co-authored-by: Raw_Nuke <67506722+RawNuke@users.noreply.github.com> Co-authored-by: njz-cvm <njz@cvm.com> Co-authored-by: Alexandre VARGAS <alexandre.vargas.lopez@gmail.com> Co-authored-by: alexandre-vl <rafaelsenchais@gmail.com>
This commit is contained in:
837 files changed
+387941
-15831
No files matched your search
@@ -1,8 +1,7 @@
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { copyToClipboard } from "@/lib/clipboard";
|
||||
import { deleteApiKey } from "@/main-axios";
|
||||
import type { ApiKey } from "@/main-axios";
|
||||
import { deleteApiKey, type ApiKey } from "@/main-axios";
|
||||
import { Button } from "@/components/button";
|
||||
import { Input } from "@/components/input";
|
||||
import { Copy, Network, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||
@@ -72,6 +71,16 @@ export function AdminApiKeysSection({
|
||||
>
|
||||
{t("admin.apiKeysDocsLink")}
|
||||
</a>
|
||||
<br />
|
||||
{t("admin.apiKeysCliHint")}{" "}
|
||||
<a
|
||||
href="https://docs.termix.site/cli"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-accent-brand hover:underline"
|
||||
>
|
||||
{t("admin.apiKeysCliLink")}
|
||||
</a>
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ImageIcon } from "lucide-react";
|
||||
import { Button } from "@/components/button";
|
||||
import { Input } from "@/components/input";
|
||||
import { SettingRow } from "@/components/section-card";
|
||||
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="flex flex-col gap-0 pt-2">
|
||||
<SettingRow
|
||||
label={t("admin.imageStorageMode")}
|
||||
description={t("admin.imageStorageModeDesc")}
|
||||
>
|
||||
<div className="flex 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={`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>
|
||||
</SettingRow>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 py-3 sm: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>
|
||||
|
||||
<SettingRow
|
||||
label={t("admin.imageStorageInstanceId")}
|
||||
description={t("admin.imageStorageInstanceIdDesc")}
|
||||
>
|
||||
<Input
|
||||
aria-label={t("admin.imageStorageInstanceId")}
|
||||
value={instanceId}
|
||||
onChange={(event) => setInstanceId(event.target.value)}
|
||||
className="h-8 w-48 text-xs"
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -80,6 +80,12 @@ type UsersSectionProps = {
|
||||
>;
|
||||
setUnlinkAccountOpen: Dispatch<SetStateAction<boolean>>;
|
||||
onManageUser: (user: AdminUser) => void;
|
||||
search: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
onPageChange: Dispatch<SetStateAction<number>>;
|
||||
};
|
||||
|
||||
export function AdminUsersSection({
|
||||
@@ -96,8 +102,17 @@ export function AdminUsersSection({
|
||||
setUnlinkAccountTarget,
|
||||
setUnlinkAccountOpen,
|
||||
onManageUser,
|
||||
search,
|
||||
onSearchChange,
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
onPageChange,
|
||||
}: UsersSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const pageCount = Math.max(1, Math.ceil(total / pageSize));
|
||||
const hasPrev = page > 0;
|
||||
const hasNext = page + 1 < pageCount;
|
||||
|
||||
return (
|
||||
<AccordionSection
|
||||
@@ -109,7 +124,7 @@ export function AdminUsersSection({
|
||||
<div className="flex flex-col pt-2">
|
||||
<div className="flex items-center justify-between py-2 border-b border-border">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("admin.usersCount", { count: users.length })}
|
||||
{t("admin.usersCount", { count: total })}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
@@ -131,6 +146,14 @@ export function AdminUsersSection({
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="py-2 border-b border-border">
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
placeholder={t("admin.searchUsers")}
|
||||
className="h-7 text-xs rounded-none"
|
||||
/>
|
||||
</div>
|
||||
{users.map((user) => {
|
||||
const authLabel =
|
||||
user.isOidc && user.passwordHash
|
||||
@@ -245,6 +268,36 @@ export function AdminUsersSection({
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{users.length === 0 && (
|
||||
<div className="py-4 text-center text-[10px] text-muted-foreground">
|
||||
{search ? t("admin.noUsersMatch") : t("admin.noUsers")}
|
||||
</div>
|
||||
)}
|
||||
{pageCount > 1 && (
|
||||
<div className="flex items-center justify-between py-2 border-t border-border">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 text-[10px] rounded-none"
|
||||
disabled={!hasPrev}
|
||||
onClick={() => onPageChange((p) => Math.max(0, p - 1))}
|
||||
>
|
||||
{t("common.previous")}
|
||||
</Button>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("admin.pageOf", { page: page + 1, total: pageCount })}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 text-[10px] rounded-none"
|
||||
disabled={!hasNext}
|
||||
onClick={() => onPageChange((p) => p + 1)}
|
||||
>
|
||||
{t("common.next")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AccordionSection>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { notifyAiStatusChanged } from "@/hooks/use-ai-availability";
|
||||
import {
|
||||
getAiGloballyEnabled,
|
||||
getAiPrivateEndpoints,
|
||||
setAiGloballyEnabled as setAiGloballyEnabledApi,
|
||||
setAiPrivateEndpoints as setAiPrivateEndpointsApi,
|
||||
} from "@/api/ai-api";
|
||||
import {
|
||||
getUserList,
|
||||
getSessions,
|
||||
@@ -42,7 +49,12 @@ import {
|
||||
updateHostDefaults,
|
||||
getAnalyticsEnabled,
|
||||
updateAnalyticsEnabled,
|
||||
getTerminalImageStorageSettings,
|
||||
updateTerminalImageStorageSettings,
|
||||
testTerminalImageStorage,
|
||||
type HostDefaults,
|
||||
type TerminalImageStorageSettings,
|
||||
type TerminalImageStorageTestResult,
|
||||
} from "@/api/settings-api";
|
||||
import {
|
||||
getSessionSharingGloballyEnabled,
|
||||
@@ -65,9 +77,13 @@ import {
|
||||
saveMetricsHistoryRetention,
|
||||
} from "@/api/host-metrics-api";
|
||||
import type { SSOProvider } from "@/types/index";
|
||||
import type { ApiKey, CreatedApiKey, UserRole } from "@/main-axios";
|
||||
import type { AdminSection } from "@/types/ui-types";
|
||||
import type { Role } from "@/main-axios";
|
||||
import {
|
||||
type ApiKey,
|
||||
type CreatedApiKey,
|
||||
type Role,
|
||||
type UserRole,
|
||||
} from "@/main-axios";
|
||||
import { type AdminSection, type Host } from "@/types/ui-types";
|
||||
import {
|
||||
AdminRolesSection,
|
||||
AdminSessionsSection,
|
||||
@@ -94,7 +110,17 @@ import {
|
||||
AdminUnlinkAccountDialog,
|
||||
} from "./AdminUserDialogs";
|
||||
import { AdminUserManagePanel } from "./AdminUserManagePanel";
|
||||
import type { Host } from "@/types/ui-types";
|
||||
import {
|
||||
TOUCH_INPUT_DEFAULTS,
|
||||
type TouchInputSettings,
|
||||
} from "@/types/touch-input-settings";
|
||||
import {
|
||||
getTouchInputSettings,
|
||||
updateTouchInputSettings,
|
||||
} from "@/api/touch-input-settings-api";
|
||||
import { cacheTouchInputSettings } from "@/features/terminal/touch-input-settings-store";
|
||||
import { AdminTouchInputSection } from "./AdminTouchInputSection";
|
||||
import { AdminImageStorageSection } from "./AdminImageStorageSection";
|
||||
|
||||
type ApiErrorLike = {
|
||||
response?: {
|
||||
@@ -108,6 +134,8 @@ function apiErrorMessage(error: unknown, fallback: string) {
|
||||
return (error as ApiErrorLike).response?.data?.error || fallback;
|
||||
}
|
||||
|
||||
const USERS_PAGE_SIZE = 25;
|
||||
|
||||
export function AdminSettingsPanel({
|
||||
onEditingChange,
|
||||
onOpenHostTab,
|
||||
@@ -131,12 +159,28 @@ export function AdminSettingsPanel({
|
||||
const [guacUrl, setGuacUrl] = useState("guacd:4822");
|
||||
const [logLevel, setLogLevel] = useState("info");
|
||||
const [tailscaleApiKey, setTailscaleApiKey] = useState("");
|
||||
const [tailscaleApiBaseUrl, setTailscaleApiBaseUrl] = useState("");
|
||||
const [commandHistoryEnabled, setCommandHistoryEnabled] = useState(true);
|
||||
const [analyticsEnabled, setAnalyticsEnabled] = useState(true);
|
||||
const [analyticsLocked, setAnalyticsLocked] = useState(false);
|
||||
const [sessionSharingGloballyEnabled, setSessionSharingGloballyEnabled] =
|
||||
useState(true);
|
||||
const [aiGloballyEnabled, setAiGloballyEnabled] = useState(false);
|
||||
const [aiPrivateEndpoints, setAiPrivateEndpoints] = useState<string[]>([]);
|
||||
const [hostDefaults, setHostDefaults] = useState<HostDefaults>({});
|
||||
const [touchInputSettings, setTouchInputSettings] =
|
||||
useState<TouchInputSettings>({ ...TOUCH_INPUT_DEFAULTS });
|
||||
|
||||
// Terminal image storage state. localDir stays a draft: the API never
|
||||
// returns the configured backend path, so it is only sent when changed.
|
||||
const [imageStorageSettings, setImageStorageSettings] =
|
||||
useState<TerminalImageStorageSettings | null>(null);
|
||||
const [imageStorageLocalDir, setImageStorageLocalDir] = useState("");
|
||||
const [imageStorageInstanceId, setImageStorageInstanceId] = useState("");
|
||||
const [imageStorageSaving, setImageStorageSaving] = useState(false);
|
||||
const [imageStorageTesting, setImageStorageTesting] = useState(false);
|
||||
const [imageStorageTestResult, setImageStorageTestResult] =
|
||||
useState<TerminalImageStorageTestResult | null>(null);
|
||||
|
||||
// SSO / auto-provision state
|
||||
const [oidcAutoProvision, setOidcAutoProvision] = useState(false);
|
||||
@@ -215,12 +259,14 @@ export function AdminSettingsPanel({
|
||||
const [manualUploading, setManualUploading] = useState(false);
|
||||
|
||||
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||
const [userSearch, setUserSearch] = useState("");
|
||||
const [userPage, setUserPage] = useState(0);
|
||||
const [userTotal, setUserTotal] = useState(0);
|
||||
const [sessions, setSessions] = useState<AdminSession[]>([]);
|
||||
const [roles, setRoles] = useState<Role[]>([]);
|
||||
const [apiKeys, setApiKeys] = useState<ApiKey[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
loadUsers();
|
||||
loadSessions();
|
||||
loadRoles();
|
||||
loadApiKeys();
|
||||
@@ -228,6 +274,17 @@ export function AdminSettingsPanel({
|
||||
loadSSOProviders();
|
||||
}, []);
|
||||
|
||||
// Debounced so typing in the search box does not fire a request per keystroke.
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(loadUsers, userSearch ? 250 : 0);
|
||||
return () => clearTimeout(timer);
|
||||
}, [userSearch, userPage]);
|
||||
|
||||
// A new search term starts again from the first page.
|
||||
useEffect(() => {
|
||||
setUserPage(0);
|
||||
}, [userSearch]);
|
||||
|
||||
useEffect(() => {
|
||||
onEditingChange?.(manageUser !== null);
|
||||
return () => onEditingChange?.(false);
|
||||
@@ -245,8 +302,14 @@ export function AdminSettingsPanel({
|
||||
}, [editUserOpen, editUserTarget]);
|
||||
|
||||
function loadUsers() {
|
||||
getUserList()
|
||||
.then(({ users: u }) =>
|
||||
// Paged server-side so an install with thousands of accounts does not
|
||||
// ship the whole directory to render one screen of it.
|
||||
getUserList({
|
||||
search: userSearch.trim() || undefined,
|
||||
limit: USERS_PAGE_SIZE,
|
||||
offset: userPage * USERS_PAGE_SIZE,
|
||||
})
|
||||
.then(({ users: u, total }) => {
|
||||
setUsers(
|
||||
u.map((user) => ({
|
||||
id: user.userId,
|
||||
@@ -257,8 +320,9 @@ export function AdminSettingsPanel({
|
||||
dataUnlocked: user.data_unlocked,
|
||||
totpEnabled: user.totp_enabled,
|
||||
})),
|
||||
),
|
||||
)
|
||||
);
|
||||
setUserTotal(total ?? u.length);
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
@@ -296,6 +360,10 @@ export function AdminSettingsPanel({
|
||||
cmdHistory,
|
||||
analytics,
|
||||
sessionSharingEnabled,
|
||||
touchInput,
|
||||
aiEnabled,
|
||||
aiEndpoints,
|
||||
imageStorage,
|
||||
] = await Promise.allSettled([
|
||||
getRegistrationAllowed(),
|
||||
getPasswordLoginAllowed(),
|
||||
@@ -310,6 +378,10 @@ export function AdminSettingsPanel({
|
||||
getCommandHistoryEnabled(),
|
||||
getAnalyticsEnabled(),
|
||||
getSessionSharingGloballyEnabled(),
|
||||
getTouchInputSettings(),
|
||||
getAiGloballyEnabled(),
|
||||
getAiPrivateEndpoints(),
|
||||
getTerminalImageStorageSettings(),
|
||||
]);
|
||||
|
||||
if (reg.status === "fulfilled") setAllowRegistration(reg.value.allowed);
|
||||
@@ -337,6 +409,7 @@ export function AdminSettingsPanel({
|
||||
}
|
||||
if (tailscale.status === "fulfilled") {
|
||||
setTailscaleApiKey(tailscale.value.apiKey ?? "");
|
||||
setTailscaleApiBaseUrl(tailscale.value.apiBaseUrl ?? "");
|
||||
}
|
||||
if (cmdHistory.status === "fulfilled") {
|
||||
setCommandHistoryEnabled(cmdHistory.value.enabled);
|
||||
@@ -348,6 +421,19 @@ export function AdminSettingsPanel({
|
||||
if (sessionSharingEnabled.status === "fulfilled") {
|
||||
setSessionSharingGloballyEnabled(sessionSharingEnabled.value.enabled);
|
||||
}
|
||||
if (touchInput.status === "fulfilled") {
|
||||
setTouchInputSettings(touchInput.value);
|
||||
cacheTouchInputSettings(touchInput.value);
|
||||
}
|
||||
if (aiEnabled.status === "fulfilled") {
|
||||
setAiGloballyEnabled(aiEnabled.value);
|
||||
}
|
||||
if (aiEndpoints.status === "fulfilled") {
|
||||
setAiPrivateEndpoints(aiEndpoints.value);
|
||||
}
|
||||
if (imageStorage.status === "fulfilled") {
|
||||
setImageStorageSettings(imageStorage.value);
|
||||
}
|
||||
} catch {
|
||||
// non-fatal
|
||||
}
|
||||
@@ -478,6 +564,90 @@ export function AdminSettingsPanel({
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleAiGloballyEnabled() {
|
||||
const newVal = !aiGloballyEnabled;
|
||||
setAiGloballyEnabled(newVal);
|
||||
try {
|
||||
await setAiGloballyEnabledApi(newVal);
|
||||
// Every AI surface listens for this, so the admin sees the entry appear
|
||||
// or disappear right away instead of after a reload.
|
||||
notifyAiStatusChanged();
|
||||
} catch {
|
||||
setAiGloballyEnabled(!newVal);
|
||||
toast.error(t("admin.updateAiEnabledFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveAiPrivateEndpoints(hosts: string[]) {
|
||||
const previous = aiPrivateEndpoints;
|
||||
setAiPrivateEndpoints(hosts);
|
||||
try {
|
||||
setAiPrivateEndpoints(await setAiPrivateEndpointsApi(hosts));
|
||||
} catch {
|
||||
setAiPrivateEndpoints(previous);
|
||||
toast.error(t("admin.updateAiEndpointsFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
async function saveTouchInputSettings(settings = touchInputSettings) {
|
||||
try {
|
||||
const saved = await updateTouchInputSettings(settings);
|
||||
setTouchInputSettings(saved);
|
||||
cacheTouchInputSettings(saved);
|
||||
toast.success(t("admin.touchSaved"));
|
||||
} catch {
|
||||
toast.error(t("admin.touchSaveFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
function resetTouchInputSettings() {
|
||||
const defaults = { ...TOUCH_INPUT_DEFAULTS };
|
||||
setTouchInputSettings(defaults);
|
||||
void saveTouchInputSettings(defaults);
|
||||
}
|
||||
|
||||
async function handleSaveImageStorage() {
|
||||
if (!imageStorageSettings) return;
|
||||
setImageStorageSaving(true);
|
||||
try {
|
||||
const saved = await updateTerminalImageStorageSettings({
|
||||
mode: imageStorageSettings.mode,
|
||||
hostPath: imageStorageSettings.hostPath,
|
||||
ttlMs: imageStorageSettings.ttlMs,
|
||||
maxCount: imageStorageSettings.maxCount,
|
||||
maxBytes: imageStorageSettings.maxBytes,
|
||||
...(imageStorageLocalDir.trim()
|
||||
? { localDir: imageStorageLocalDir.trim() }
|
||||
: {}),
|
||||
});
|
||||
setImageStorageSettings(saved);
|
||||
setImageStorageLocalDir("");
|
||||
toast.success(t("admin.imageStorageSaved"));
|
||||
} catch (e) {
|
||||
toast.error(apiErrorMessage(e, t("admin.imageStorageSaveFailed")));
|
||||
} finally {
|
||||
setImageStorageSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTestImageStorage() {
|
||||
if (!imageStorageInstanceId.trim()) {
|
||||
toast.error(t("admin.imageStorageInstanceIdRequired"));
|
||||
return;
|
||||
}
|
||||
setImageStorageTesting(true);
|
||||
setImageStorageTestResult(null);
|
||||
try {
|
||||
setImageStorageTestResult(
|
||||
await testTerminalImageStorage(imageStorageInstanceId.trim()),
|
||||
);
|
||||
} catch (e) {
|
||||
toast.error(apiErrorMessage(e, t("admin.imageStorageTestFailed")));
|
||||
} finally {
|
||||
setImageStorageTesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveSessionTimeout() {
|
||||
const hours = parseInt(sessionTimeout, 10);
|
||||
if (isNaN(hours) || hours < 1 || hours > 720) {
|
||||
@@ -542,7 +712,7 @@ export function AdminSettingsPanel({
|
||||
|
||||
async function handleSaveTailscaleApiKey() {
|
||||
try {
|
||||
await updateTailscaleSettings(tailscaleApiKey);
|
||||
await updateTailscaleSettings(tailscaleApiKey, tailscaleApiBaseUrl);
|
||||
toast.success(t("admin.tailscaleSettingsSaved"));
|
||||
} catch {
|
||||
toast.error(t("admin.tailscaleSettingsSaveFailed"));
|
||||
@@ -632,6 +802,9 @@ export function AdminSettingsPanel({
|
||||
const result = await requestAcmeCertificate();
|
||||
setAcmeSettings(result);
|
||||
toast.success(t("admin.sslRequestCertSuccess"));
|
||||
if (result.reloadMessage) {
|
||||
toast.info(result.reloadMessage);
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(apiErrorMessage(e, t("admin.sslRequestCertFailed")));
|
||||
} finally {
|
||||
@@ -654,6 +827,9 @@ export function AdminSettingsPanel({
|
||||
setManualCertDraft("");
|
||||
setManualKeyDraft("");
|
||||
toast.success(t("admin.sslManualUploadSuccess"));
|
||||
if (result.reloadMessage) {
|
||||
toast.info(result.reloadMessage);
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(apiErrorMessage(e, t("admin.sslManualUploadFailed")));
|
||||
} finally {
|
||||
@@ -934,6 +1110,10 @@ export function AdminSettingsPanel({
|
||||
analyticsLocked={analyticsLocked}
|
||||
handleToggleAnalytics={handleToggleAnalytics}
|
||||
sessionSharingGloballyEnabled={sessionSharingGloballyEnabled}
|
||||
aiGloballyEnabled={aiGloballyEnabled}
|
||||
onToggleAiGloballyEnabled={handleToggleAiGloballyEnabled}
|
||||
aiPrivateEndpoints={aiPrivateEndpoints}
|
||||
onSaveAiPrivateEndpoints={handleSaveAiPrivateEndpoints}
|
||||
handleToggleSessionSharingGloballyEnabled={
|
||||
handleToggleSessionSharingGloballyEnabled
|
||||
}
|
||||
@@ -968,6 +1148,8 @@ export function AdminSettingsPanel({
|
||||
handleSaveLogLevel={handleSaveLogLevel}
|
||||
tailscaleApiKey={tailscaleApiKey}
|
||||
setTailscaleApiKey={setTailscaleApiKey}
|
||||
tailscaleApiBaseUrl={tailscaleApiBaseUrl}
|
||||
setTailscaleApiBaseUrl={setTailscaleApiBaseUrl}
|
||||
handleSaveTailscaleApiKey={handleSaveTailscaleApiKey}
|
||||
/>
|
||||
|
||||
@@ -1002,6 +1184,12 @@ export function AdminSettingsPanel({
|
||||
setUnlinkAccountTarget={setUnlinkAccountTarget}
|
||||
setUnlinkAccountOpen={setUnlinkAccountOpen}
|
||||
onManageUser={setManageUser}
|
||||
search={userSearch}
|
||||
onSearchChange={setUserSearch}
|
||||
page={userPage}
|
||||
pageSize={USERS_PAGE_SIZE}
|
||||
total={userTotal}
|
||||
onPageChange={setUserPage}
|
||||
/>
|
||||
|
||||
<AdminSessionsSection
|
||||
@@ -1037,6 +1225,22 @@ export function AdminSettingsPanel({
|
||||
handleSaveDefaults={handleSaveHostDefaults}
|
||||
/>
|
||||
|
||||
<AdminImageStorageSection
|
||||
open={openSections.has("image-storage")}
|
||||
onToggle={() => toggle("image-storage")}
|
||||
settings={imageStorageSettings}
|
||||
setSettings={setImageStorageSettings}
|
||||
localDir={imageStorageLocalDir}
|
||||
setLocalDir={setImageStorageLocalDir}
|
||||
instanceId={imageStorageInstanceId}
|
||||
setInstanceId={setImageStorageInstanceId}
|
||||
saving={imageStorageSaving}
|
||||
testing={imageStorageTesting}
|
||||
testResult={imageStorageTestResult}
|
||||
onSave={() => void handleSaveImageStorage()}
|
||||
onTest={() => void handleTestImageStorage()}
|
||||
/>
|
||||
|
||||
<AdminDatabaseSection
|
||||
open={openSections.has("database")}
|
||||
onToggle={() => toggle("database")}
|
||||
@@ -1093,6 +1297,15 @@ export function AdminSettingsPanel({
|
||||
users={users}
|
||||
/>
|
||||
|
||||
<AdminTouchInputSection
|
||||
open={openSections.has("touch-input")}
|
||||
onToggle={() => toggle("touch-input")}
|
||||
settings={touchInputSettings}
|
||||
setSettings={setTouchInputSettings}
|
||||
onSave={() => void saveTouchInputSettings()}
|
||||
onReset={resetTouchInputSettings}
|
||||
/>
|
||||
|
||||
<AdminCreateUserDialog
|
||||
open={createUserOpen}
|
||||
onOpenChange={setCreateUserOpen}
|
||||
|
||||
@@ -27,6 +27,10 @@ type GeneralSettingsSectionProps = {
|
||||
analyticsLocked: boolean;
|
||||
handleToggleAnalytics: () => void;
|
||||
sessionSharingGloballyEnabled: boolean;
|
||||
aiGloballyEnabled: boolean;
|
||||
onToggleAiGloballyEnabled: () => void;
|
||||
aiPrivateEndpoints: string[];
|
||||
onSaveAiPrivateEndpoints: (hosts: string[]) => void;
|
||||
handleToggleSessionSharingGloballyEnabled: () => void;
|
||||
allowRegistration: boolean;
|
||||
handleToggleRegistration: () => void;
|
||||
@@ -59,6 +63,8 @@ type GeneralSettingsSectionProps = {
|
||||
handleSaveLogLevel: (level: string) => void;
|
||||
tailscaleApiKey: string;
|
||||
setTailscaleApiKey: Dispatch<SetStateAction<string>>;
|
||||
tailscaleApiBaseUrl: string;
|
||||
setTailscaleApiBaseUrl: Dispatch<SetStateAction<string>>;
|
||||
handleSaveTailscaleApiKey: () => void;
|
||||
};
|
||||
|
||||
@@ -69,6 +75,10 @@ export function AdminGeneralSettingsSection({
|
||||
analyticsLocked,
|
||||
handleToggleAnalytics,
|
||||
sessionSharingGloballyEnabled,
|
||||
aiGloballyEnabled,
|
||||
onToggleAiGloballyEnabled,
|
||||
aiPrivateEndpoints,
|
||||
onSaveAiPrivateEndpoints,
|
||||
handleToggleSessionSharingGloballyEnabled,
|
||||
allowRegistration,
|
||||
handleToggleRegistration,
|
||||
@@ -101,6 +111,8 @@ export function AdminGeneralSettingsSection({
|
||||
handleSaveLogLevel,
|
||||
tailscaleApiKey,
|
||||
setTailscaleApiKey,
|
||||
tailscaleApiBaseUrl,
|
||||
setTailscaleApiBaseUrl,
|
||||
handleSaveTailscaleApiKey,
|
||||
}: GeneralSettingsSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -136,6 +148,40 @@ export function AdminGeneralSettingsSection({
|
||||
onToggle={handleToggleSessionSharingGloballyEnabled}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("admin.aiGloballyEnabled")}
|
||||
description={t("admin.aiGloballyEnabledDesc")}
|
||||
>
|
||||
<AdminToggle
|
||||
on={aiGloballyEnabled}
|
||||
onToggle={onToggleAiGloballyEnabled}
|
||||
/>
|
||||
</SettingRow>
|
||||
{aiGloballyEnabled && (
|
||||
// Full-width rather than a SettingRow: the panel is narrow, and an
|
||||
// inline field here squeezes the label down to a word per line.
|
||||
<div className="flex flex-col gap-1.5 py-2">
|
||||
<span className="text-xs font-medium">
|
||||
{t("admin.aiPrivateEndpoints")}
|
||||
</span>
|
||||
<span className="text-[11px] leading-snug text-muted-foreground">
|
||||
{t("admin.aiPrivateEndpointsDesc")}
|
||||
</span>
|
||||
<Input
|
||||
className="rounded-none"
|
||||
defaultValue={aiPrivateEndpoints.join(", ")}
|
||||
placeholder="localhost, 127.0.0.1"
|
||||
onBlur={(event) =>
|
||||
onSaveAiPrivateEndpoints(
|
||||
event.target.value
|
||||
.split(",")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<SettingRow
|
||||
label={t("admin.allowRegistration")}
|
||||
description={t("admin.allowRegistrationDesc")}
|
||||
@@ -362,7 +408,7 @@ export function AdminGeneralSettingsSection({
|
||||
type="password"
|
||||
value={tailscaleApiKey}
|
||||
onChange={(e) => setTailscaleApiKey(e.target.value)}
|
||||
placeholder="tskey-api-..."
|
||||
placeholder="tskey-api-... / hskey-api-..."
|
||||
className="text-sm"
|
||||
/>
|
||||
<Button
|
||||
@@ -374,6 +420,20 @@ export function AdminGeneralSettingsSection({
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 mt-1">
|
||||
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
{t("admin.tailscaleApiBaseUrl")}
|
||||
</label>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("admin.tailscaleApiBaseUrlDescription")}
|
||||
</span>
|
||||
<Input
|
||||
value={tailscaleApiBaseUrl}
|
||||
onChange={(e) => setTailscaleApiBaseUrl(e.target.value)}
|
||||
placeholder="https://api.tailscale.com/api/v2"
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 border-t border-border pt-3 mt-2">
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -41,9 +41,14 @@ import {
|
||||
assignRoleToUser,
|
||||
removeRoleFromUser,
|
||||
} from "@/main-axios";
|
||||
import type { ApiKey, CreatedApiKey, Role, UserRole } from "@/main-axios";
|
||||
import {
|
||||
type ApiKey,
|
||||
type CreatedApiKey,
|
||||
type Role,
|
||||
type SSHHostWithStatus,
|
||||
type UserRole,
|
||||
} from "@/main-axios";
|
||||
import type { Host, Credential } from "@/types/ui-types";
|
||||
import type { SSHHostWithStatus } from "@/main-axios";
|
||||
import { CredentialEditorView } from "./CredentialEditorView";
|
||||
import { HostEditor } from "./HostEditor";
|
||||
import { mapCredentials, sshHostToHost } from "./HostManagerData";
|
||||
|
||||
@@ -155,8 +155,17 @@ export function AlertsPanel() {
|
||||
try {
|
||||
await testNotificationChannel(id);
|
||||
toast.success(t("alerts.testSent", "Test notification sent"));
|
||||
} catch {
|
||||
toast.error(t("alerts.testFailed", "Test notification failed"));
|
||||
} 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"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+147
-79
@@ -3,27 +3,19 @@ import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Bell,
|
||||
Check,
|
||||
Clock,
|
||||
Fingerprint,
|
||||
Hammer,
|
||||
KeyRound,
|
||||
LayoutPanelLeft,
|
||||
LogOut,
|
||||
Network,
|
||||
Play,
|
||||
Plug,
|
||||
ScrollText,
|
||||
Server,
|
||||
PanelRight,
|
||||
Settings,
|
||||
SlidersHorizontal,
|
||||
Usb,
|
||||
SquareArrowOutUpRight,
|
||||
User,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import type { SplitMode, TabType, ToolsTab } from "@/types/ui-types";
|
||||
import { getAlertFirings } from "@/api/alerts-api";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
import { readRailPreference, setRailPreference } from "./rail-preferences";
|
||||
import { visibleRailItems } from "./rail-items";
|
||||
import { useAiAvailability } from "@/hooks/use-ai-availability";
|
||||
|
||||
export type RailView =
|
||||
| "hosts"
|
||||
@@ -36,7 +28,11 @@ export type RailView =
|
||||
| "session-logs"
|
||||
| "user-profile"
|
||||
| "admin-settings"
|
||||
| "alerts";
|
||||
| "alerts"
|
||||
| "automations"
|
||||
| "ai"
|
||||
| "fleets"
|
||||
| "workspaces";
|
||||
|
||||
export type HideableRailView =
|
||||
| Exclude<RailView, "user-profile" | "admin-settings">
|
||||
@@ -50,6 +46,8 @@ type RailItem =
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
dot?: boolean;
|
||||
promotable?: boolean;
|
||||
rightDockable?: boolean;
|
||||
}
|
||||
| { kind: "tab"; tabType: TabType; icon: React.ReactNode; title: string }
|
||||
| { kind: "separator" };
|
||||
@@ -59,70 +57,33 @@ function buildRailButtons(
|
||||
t: (key: string) => string,
|
||||
hidden: Set<string>,
|
||||
): RailItem[] {
|
||||
const all: RailItem[] = [
|
||||
{ view: "hosts", icon: <Server size={16} />, title: t("nav.hosts") },
|
||||
{
|
||||
view: "credentials",
|
||||
icon: <KeyRound size={16} />,
|
||||
title: t("nav.credentials"),
|
||||
},
|
||||
{ kind: "separator" },
|
||||
{
|
||||
view: "termix-id",
|
||||
icon: <Fingerprint size={16} />,
|
||||
title: t("nav.termixId"),
|
||||
},
|
||||
{ kind: "separator" },
|
||||
{
|
||||
view: "connections",
|
||||
icon: <Plug size={16} />,
|
||||
title: t("nav.connections"),
|
||||
},
|
||||
{ kind: "separator" },
|
||||
{
|
||||
view: "quick-connect",
|
||||
icon: <Zap size={16} />,
|
||||
title: t("nav.quickConnect"),
|
||||
},
|
||||
{ kind: "separator" },
|
||||
{
|
||||
view: "serial",
|
||||
icon: <Usb size={16} />,
|
||||
title: t("nav.serial"),
|
||||
},
|
||||
{ kind: "separator" },
|
||||
{ view: "ssh-tools", icon: <Hammer size={16} />, title: t("nav.sshTools") },
|
||||
{ kind: "separator" },
|
||||
{ view: "snippets", icon: <Play size={16} />, title: t("nav.snippets") },
|
||||
{ kind: "separator" },
|
||||
{ view: "history", icon: <Clock size={16} />, title: t("nav.history") },
|
||||
{ kind: "separator" },
|
||||
{
|
||||
view: "session-logs",
|
||||
icon: <ScrollText size={16} />,
|
||||
title: t("nav.sessionLogs"),
|
||||
},
|
||||
{ kind: "separator" },
|
||||
{
|
||||
view: "split-screen",
|
||||
icon: <LayoutPanelLeft size={16} />,
|
||||
title: t("nav.splitScreen"),
|
||||
dot: splitMode !== "none",
|
||||
},
|
||||
{ kind: "separator" },
|
||||
{
|
||||
kind: "tab",
|
||||
tabType: "network_graph" as TabType,
|
||||
icon: <Network size={16} />,
|
||||
title: t("nav.networkGraph"),
|
||||
},
|
||||
{ kind: "separator" },
|
||||
];
|
||||
const all: RailItem[] = [];
|
||||
for (const item of visibleRailItems()) {
|
||||
const Icon = item.icon;
|
||||
if (item.kind === "tab") {
|
||||
all.push({
|
||||
kind: "tab",
|
||||
tabType: item.id as TabType,
|
||||
icon: <Icon size={16} />,
|
||||
title: t(item.labelKey),
|
||||
});
|
||||
} 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,
|
||||
});
|
||||
}
|
||||
if (item.separatorAfter) all.push({ kind: "separator" });
|
||||
}
|
||||
|
||||
// Filter out hidden items, then collapse consecutive/leading/trailing separators
|
||||
const filtered = all.filter((item) => {
|
||||
if (item.kind === "separator") return true;
|
||||
if (item.kind === "tab") return !hidden.has(item.tabType);
|
||||
if ("tabType" in item) return !hidden.has(item.tabType);
|
||||
return !hidden.has(item.view);
|
||||
});
|
||||
|
||||
@@ -152,6 +113,7 @@ export function AppRail({
|
||||
isAdmin,
|
||||
onRailClick,
|
||||
onOpenTab,
|
||||
onOpenInRightDock,
|
||||
onLogout,
|
||||
}: {
|
||||
railView: RailView;
|
||||
@@ -161,6 +123,7 @@ export function AppRail({
|
||||
isAdmin: boolean;
|
||||
onRailClick: (view: RailView) => void;
|
||||
onOpenTab?: (type: TabType) => void;
|
||||
onOpenInRightDock?: (view: RailView) => void;
|
||||
onLogout: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
@@ -170,6 +133,14 @@ export function AppRail({
|
||||
readRailPreference("expandAppRailOnHover"),
|
||||
);
|
||||
const [menuPos, setMenuPos] = useState<{ x: number; y: number } | null>(null);
|
||||
// Which promotable item was right-clicked, so the menu can offer to open it
|
||||
// as a tab. Null when the right-click landed on empty rail space.
|
||||
const [menuTarget, setMenuTarget] = useState<{
|
||||
view: RailView;
|
||||
title: string;
|
||||
promotable?: boolean;
|
||||
rightDockable?: boolean;
|
||||
} | null>(null);
|
||||
const [unreadAlerts, setUnreadAlerts] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -252,6 +223,8 @@ export function AppRail({
|
||||
};
|
||||
}, [menuPos]);
|
||||
|
||||
const { userEnabled: aiEnabled } = useAiAvailability();
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
try {
|
||||
@@ -299,9 +272,13 @@ export function AppRail({
|
||||
}, []);
|
||||
|
||||
const railExpanded = pinned || (expandOnHover && hovered);
|
||||
const effectiveHiddenTabs = isRemoteSyncConnected
|
||||
? hiddenTabs
|
||||
: new Set([...hiddenTabs, "termix-id"]);
|
||||
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 togglePinned = () => {
|
||||
@@ -329,6 +306,9 @@ export function AppRail({
|
||||
x: Math.min(e.clientX, window.innerWidth - MENU_W - 8),
|
||||
y: Math.min(e.clientY, window.innerHeight - MENU_H - 8),
|
||||
});
|
||||
if (!(e.target as HTMLElement).closest("[data-rail-promotable]")) {
|
||||
setMenuTarget(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col flex-1 gap-1 overflow-y-auto scrollbar-none min-h-0">
|
||||
@@ -339,7 +319,7 @@ export function AppRail({
|
||||
className="mx-auto h-px bg-border my-0.5 shrink-0 transition-[width] duration-200"
|
||||
style={{ width: railExpanded ? "calc(100% - 16px)" : 20 }}
|
||||
/>
|
||||
) : item.kind === "tab" ? (
|
||||
) : "tabType" in item ? (
|
||||
<button
|
||||
key={item.tabType}
|
||||
onClick={() => onOpenTab?.(item.tabType)}
|
||||
@@ -363,7 +343,35 @@ export function AppRail({
|
||||
) : (
|
||||
<button
|
||||
key={item.view}
|
||||
onClick={() => onRailClick(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
|
||||
@@ -398,6 +406,7 @@ export function AppRail({
|
||||
view: "alerts" as RailView,
|
||||
icon: <Bell size={16} />,
|
||||
title: t("nav.alerts"),
|
||||
promotable: true,
|
||||
},
|
||||
{
|
||||
view: "user-profile" as RailView,
|
||||
@@ -416,7 +425,33 @@ export function AppRail({
|
||||
].map((item) => (
|
||||
<button
|
||||
key={item.view}
|
||||
onClick={() => onRailClick(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)
|
||||
setMenuTarget({
|
||||
view: item.view,
|
||||
title: item.title,
|
||||
promotable: item.promotable,
|
||||
rightDockable: true,
|
||||
});
|
||||
}}
|
||||
data-rail-promotable={item.promotable ? "" : undefined}
|
||||
title={
|
||||
item.promotable
|
||||
? `${item.title}\n${t("nav.openAsTabHint")}`
|
||||
: item.title
|
||||
}
|
||||
style={btnStyle}
|
||||
className={`${btnBase} ${
|
||||
sidebarOpen && railView === item.view
|
||||
@@ -494,6 +529,39 @@ export function AppRail({
|
||||
style={{ position: "fixed", left: menuPos.x, top: menuPos.y }}
|
||||
className="z-[10000] bg-popover border border-border shadow-lg py-1 min-w-[190px]"
|
||||
>
|
||||
{menuTarget && (
|
||||
<>
|
||||
{menuTarget.promotable && (
|
||||
<button
|
||||
onClick={() => {
|
||||
onOpenTab?.(menuTarget.view as TabType);
|
||||
setMenuPos(null);
|
||||
}}
|
||||
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<span className="shrink-0 w-3 flex items-center justify-center">
|
||||
<SquareArrowOutUpRight className="size-3" />
|
||||
</span>
|
||||
{t("nav.openAsTab")}
|
||||
</button>
|
||||
)}
|
||||
{menuTarget.rightDockable && (
|
||||
<button
|
||||
onClick={() => {
|
||||
onOpenInRightDock?.(menuTarget.view);
|
||||
setMenuPos(null);
|
||||
}}
|
||||
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<span className="shrink-0 w-3 flex items-center justify-center">
|
||||
<PanelRight className="size-3" />
|
||||
</span>
|
||||
{t("nav.openInRightDock")}
|
||||
</button>
|
||||
)}
|
||||
<div className="h-px bg-border my-1" />
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={togglePinned}
|
||||
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left hover:bg-accent hover:text-accent-foreground"
|
||||
|
||||
@@ -0,0 +1,675 @@
|
||||
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;
|
||||
@@ -0,0 +1,420 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/tooltip";
|
||||
import { usePageVisibleInterval } from "@/hooks/use-page-visible-interval";
|
||||
import { useAdaptivePolling } from "@/hooks/use-adaptive-polling";
|
||||
|
||||
const CONNECTION_TAB_TYPES: TabType[] = [
|
||||
"terminal",
|
||||
@@ -314,6 +315,8 @@ export function ConnectionsPanel({
|
||||
const { t } = useTranslation();
|
||||
const [now, setNow] = useState(Date.now());
|
||||
const [activeSessions, setActiveSessions] = useState<ActiveSessionInfo[]>([]);
|
||||
const activeSessionsRef = useRef(activeSessions);
|
||||
activeSessionsRef.current = activeSessions;
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
// Drag-to-reorder state
|
||||
@@ -371,22 +374,21 @@ export function ConnectionsPanel({
|
||||
usePageVisibleInterval(() => setNow(Date.now()), 15_000);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const sessions = await getActiveSessions();
|
||||
setActiveSessions((prev) => {
|
||||
const next = Array.isArray(sessions) ? sessions : [];
|
||||
if (sessionsUnchanged(prev, next)) return prev;
|
||||
return next;
|
||||
});
|
||||
} catch {
|
||||
// silently ignore
|
||||
const sessions = await getActiveSessions();
|
||||
const next = Array.isArray(sessions) ? sessions : [];
|
||||
const changed = !sessionsUnchanged(activeSessionsRef.current, next);
|
||||
if (changed) {
|
||||
activeSessionsRef.current = next;
|
||||
setActiveSessions(next);
|
||||
}
|
||||
return changed;
|
||||
}, []);
|
||||
|
||||
// Initial fetch + visibility-aware poll (hook fires once on mount).
|
||||
usePageVisibleInterval(() => {
|
||||
void refresh();
|
||||
}, 5_000);
|
||||
useAdaptivePolling(refresh, {
|
||||
minIntervalMs: 5_000,
|
||||
maxIntervalMs: 30_000,
|
||||
stablePollsPerStep: 3,
|
||||
});
|
||||
|
||||
// Global pointer listeners for drag reorder
|
||||
useEffect(() => {
|
||||
|
||||
@@ -19,8 +19,6 @@ import {
|
||||
import type { Credential } from "@/types/ui-types";
|
||||
import { FolderPathPicker } from "./FolderPathPicker";
|
||||
|
||||
type CredentialWithCertificate = Credential & { certPublicKey?: string };
|
||||
|
||||
export function CredentialEditorView({
|
||||
credential,
|
||||
activeTab,
|
||||
@@ -51,8 +49,7 @@ export function CredentialEditorView({
|
||||
: (credential?.password ?? ""),
|
||||
publicKey: credential?.publicKey ?? "",
|
||||
passphrase: credential?.passphrase ?? "",
|
||||
certPublicKey:
|
||||
(credential as CredentialWithCertificate | null)?.certPublicKey ?? "",
|
||||
certPublicKey: credential?.certPublicKey ?? "",
|
||||
}));
|
||||
const { t } = useTranslation();
|
||||
const [generatingKey, setGeneratingKey] = useState(false);
|
||||
|
||||
+223
-169
@@ -5,11 +5,17 @@ import {
|
||||
Check,
|
||||
ExternalLink,
|
||||
Filter,
|
||||
GripVertical,
|
||||
Plus,
|
||||
Search,
|
||||
SlidersHorizontal,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { HostManager } from "@/sidebar/HostManager";
|
||||
import { useCredentialSidebarPreferences } from "@/sidebar/credential-tree/hooks/useCredentialSidebarPreferences";
|
||||
import { useArrangeLock } from "@/sidebar/use-arrange-lock";
|
||||
import { CustomizeCredentialsSidebarPanel } from "@/sidebar/CustomizeCredentialsSidebarPanel";
|
||||
import { Button } from "@/components/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -20,16 +26,7 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/dropdown-menu";
|
||||
|
||||
export type CredentialSortKey =
|
||||
"default" | "name-asc" | "name-desc" | "username-asc" | "username-desc";
|
||||
|
||||
export type CredentialFilterState = {
|
||||
type: ("password" | "key")[];
|
||||
tags: string[];
|
||||
};
|
||||
|
||||
const DEFAULT_FILTERS: CredentialFilterState = { type: [], tags: [] };
|
||||
import type { CredentialSortKey } from "@/types/credential-sidebar-preferences";
|
||||
|
||||
export function CredentialsPanel({
|
||||
onEditingChange,
|
||||
@@ -39,52 +36,60 @@ export function CredentialsPanel({
|
||||
active?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { preferences: sidebarPrefs, update: updateSidebarPrefs } =
|
||||
useCredentialSidebarPreferences();
|
||||
const [search, setSearch] = useState("");
|
||||
const [managerEditing, setManagerEditing] = useState(false);
|
||||
const [allTags, setAllTags] = useState<string[]>([]);
|
||||
const [sortKey, setSortKey] = useState<CredentialSortKey>(
|
||||
() =>
|
||||
(localStorage.getItem("credentialSortKey") as CredentialSortKey) ??
|
||||
"default",
|
||||
const [customizePanelOpen, setCustomizePanelOpen] = useState(false);
|
||||
|
||||
const sortKey = sidebarPrefs.sort.key;
|
||||
const { arrangeLocked, toggleArrangeLock } = useArrangeLock(
|
||||
"credentialSidebarArrangeLocked",
|
||||
);
|
||||
const [filterState, setFilterState] = useState<CredentialFilterState>(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem("credentialFilterState");
|
||||
return saved
|
||||
? (JSON.parse(saved) as CredentialFilterState)
|
||||
: DEFAULT_FILTERS;
|
||||
} catch {
|
||||
return DEFAULT_FILTERS;
|
||||
}
|
||||
});
|
||||
const filterActive = Object.values(filterState).some((arr) => arr.length > 0);
|
||||
const filterState = sidebarPrefs.filters;
|
||||
const filterActive =
|
||||
filterState.type.length > 0 || filterState.tags.length > 0;
|
||||
|
||||
function handleSortChange(key: CredentialSortKey) {
|
||||
setSortKey(key);
|
||||
localStorage.setItem("credentialSortKey", key);
|
||||
updateSidebarPrefs((prev) => ({
|
||||
...prev,
|
||||
sort: { ...prev.sort, key },
|
||||
}));
|
||||
}
|
||||
|
||||
function handleFilterToggle<K extends keyof CredentialFilterState>(
|
||||
function handleArrangeLockToggle() {
|
||||
const unlocking = arrangeLocked;
|
||||
toggleArrangeLock();
|
||||
// Same reasoning as the hosts panel: reordering only sticks visually
|
||||
// under manual sort, so unlocking switches to it.
|
||||
if (unlocking && sortKey !== "manual") {
|
||||
handleSortChange("manual");
|
||||
toast.info(t("credentials.arrangeUnlockedSwitchedToManual"));
|
||||
}
|
||||
}
|
||||
|
||||
function handleFilterToggle<K extends keyof typeof filterState>(
|
||||
group: K,
|
||||
value: CredentialFilterState[K][number],
|
||||
value: (typeof filterState)[K][number],
|
||||
) {
|
||||
setFilterState((prev) => {
|
||||
const arr = prev[group] as string[];
|
||||
updateSidebarPrefs((prev) => {
|
||||
const arr = prev.filters[group] as string[];
|
||||
const next = arr.includes(value as string)
|
||||
? arr.filter((v) => v !== value)
|
||||
: [...arr, value as string];
|
||||
const updated = { ...prev, [group]: next };
|
||||
localStorage.setItem("credentialFilterState", JSON.stringify(updated));
|
||||
return updated as CredentialFilterState;
|
||||
return {
|
||||
...prev,
|
||||
filters: { ...prev.filters, [group]: next },
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function handleFilterClear() {
|
||||
setFilterState(DEFAULT_FILTERS);
|
||||
localStorage.setItem(
|
||||
"credentialFilterState",
|
||||
JSON.stringify(DEFAULT_FILTERS),
|
||||
);
|
||||
updateSidebarPrefs((prev) => ({
|
||||
...prev,
|
||||
filters: { type: [], tags: [] },
|
||||
}));
|
||||
}
|
||||
|
||||
function handleEditingChange(editing: boolean) {
|
||||
@@ -114,152 +119,190 @@ export function CredentialsPanel({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={`size-7 ${sortKey !== "default" ? "text-accent-brand" : "text-muted-foreground hover:text-foreground"}`}
|
||||
title={t("credentials.sortCredentials")}
|
||||
<div className="flex items-center gap-1.5 overflow-x-auto overflow-y-hidden toolbar-scrollbar">
|
||||
<div className="flex items-center border border-border shrink-0">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={`size-7 ${sortKey !== "default" || !arrangeLocked ? "text-accent-brand" : "text-muted-foreground hover:text-foreground"}`}
|
||||
title={t("credentials.sortCredentials")}
|
||||
>
|
||||
<ArrowUpDown className="size-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className="text-xs min-w-[160px]"
|
||||
>
|
||||
<ArrowUpDown className="size-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className="text-xs min-w-[160px]"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleSortChange("default")}
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
{sortKey === "default" ? (
|
||||
<Check className="size-3 shrink-0 text-accent-brand" />
|
||||
) : (
|
||||
<span className="size-3 shrink-0 inline-block" />
|
||||
)}
|
||||
{t("credentials.sortDefault")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{(["name-asc", "name-desc"] as const).map((key) => (
|
||||
<DropdownMenuItem
|
||||
key={key}
|
||||
onClick={() => handleSortChange(key)}
|
||||
onClick={() => handleSortChange("default")}
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
{sortKey === key ? (
|
||||
{sortKey === "default" ? (
|
||||
<Check className="size-3 shrink-0 text-accent-brand" />
|
||||
) : (
|
||||
<span className="size-3 shrink-0 inline-block" />
|
||||
)}
|
||||
{t(
|
||||
`credentials.sort${key === "name-asc" ? "NameAsc" : "NameDesc"}`,
|
||||
)}
|
||||
{t("credentials.sortDefault")}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
{(["username-asc", "username-desc"] as const).map((key) => (
|
||||
<DropdownMenuItem
|
||||
key={key}
|
||||
onClick={() => handleSortChange(key)}
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
{sortKey === key ? (
|
||||
<Check className="size-3 shrink-0 text-accent-brand" />
|
||||
) : (
|
||||
<span className="size-3 shrink-0 inline-block" />
|
||||
)}
|
||||
{t(
|
||||
`credentials.sort${key === "username-asc" ? "UsernameAsc" : "UsernameDesc"}`,
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={`size-7 ${filterActive ? "text-accent-brand" : "text-muted-foreground hover:text-foreground"}`}
|
||||
title={t("credentials.filterCredentials")}
|
||||
>
|
||||
<Filter className="size-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className="text-xs min-w-[180px]"
|
||||
>
|
||||
{filterActive && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
{(["name-asc", "name-desc"] as const).map((key) => (
|
||||
<DropdownMenuItem
|
||||
onClick={handleFilterClear}
|
||||
className="flex items-center gap-1.5 text-accent-brand"
|
||||
key={key}
|
||||
onClick={() => handleSortChange(key)}
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
<X className="size-3 shrink-0" />
|
||||
{t("credentials.filterClearAll")}
|
||||
{sortKey === key ? (
|
||||
<Check className="size-3 shrink-0 text-accent-brand" />
|
||||
) : (
|
||||
<span className="size-3 shrink-0 inline-block" />
|
||||
)}
|
||||
{t(
|
||||
`credentials.sort${key === "name-asc" ? "NameAsc" : "NameDesc"}`,
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<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()}
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
{(["username-asc", "username-desc"] as const).map((key) => (
|
||||
<DropdownMenuItem
|
||||
key={key}
|
||||
onClick={() => handleSortChange(key)}
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
{sortKey === key ? (
|
||||
<Check className="size-3 shrink-0 text-accent-brand" />
|
||||
) : (
|
||||
<span className="size-3 shrink-0 inline-block" />
|
||||
)}
|
||||
{t(
|
||||
`credentials.sort${key === "username-asc" ? "UsernameAsc" : "UsernameDesc"}`,
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleSortChange("manual")}
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
{t(
|
||||
`credentials.filterType${val.charAt(0).toUpperCase() + val.slice(1)}`,
|
||||
{sortKey === "manual" ? (
|
||||
<Check className="size-3 shrink-0 text-accent-brand" />
|
||||
) : (
|
||||
<GripVertical className="size-3 shrink-0 text-muted-foreground/40" />
|
||||
)}
|
||||
{t("credentials.sortManual")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={!arrangeLocked}
|
||||
onCheckedChange={handleArrangeLockToggle}
|
||||
onSelect={(event) => event.preventDefault()}
|
||||
>
|
||||
{t("credentials.arrangeUnlocked")}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
{allTags.length > 0 && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel>
|
||||
{t("credentials.filterTagsGroup")}
|
||||
</DropdownMenuLabel>
|
||||
{allTags.map((tag) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={tag}
|
||||
checked={filterState.tags.includes(tag)}
|
||||
onCheckedChange={() => handleFilterToggle("tags", tag)}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<div className="w-px self-stretch bg-border" />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={`size-7 ${filterActive ? "text-accent-brand" : "text-muted-foreground hover:text-foreground"}`}
|
||||
title={t("credentials.filterCredentials")}
|
||||
>
|
||||
<Filter className="size-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className="text-xs min-w-[180px]"
|
||||
>
|
||||
{filterActive && (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
onClick={handleFilterClear}
|
||||
className="flex items-center gap-1.5 text-accent-brand"
|
||||
>
|
||||
{tag}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<a
|
||||
href="https://docs.termix.site/features/files-and-hosts/credentials"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title={t("hosts.docsLink")}
|
||||
className="flex items-center justify-center size-7 text-muted-foreground hover:text-foreground shrink-0 transition-colors ml-auto"
|
||||
>
|
||||
<ExternalLink className="size-3.5" />
|
||||
</a>
|
||||
<button
|
||||
onClick={() =>
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("host-manager:add-credential"),
|
||||
)
|
||||
}
|
||||
title={t("credentials.addCredential")}
|
||||
className="flex items-center gap-1 h-7 px-2 text-[10px] font-medium text-accent-brand hover:bg-accent-brand/10 border border-accent-brand/30 rounded-sm shrink-0 transition-colors"
|
||||
>
|
||||
<Plus className="size-3 shrink-0" />
|
||||
{t("credentials.addCredential")}
|
||||
</button>
|
||||
<X className="size-3 shrink-0" />
|
||||
{t("credentials.filterClearAll")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<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>
|
||||
))}
|
||||
{allTags.length > 0 && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel>
|
||||
{t("credentials.filterTagsGroup")}
|
||||
</DropdownMenuLabel>
|
||||
{allTags.map((tag) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={tag}
|
||||
checked={filterState.tags.includes(tag)}
|
||||
onCheckedChange={() =>
|
||||
handleFilterToggle("tags", tag)
|
||||
}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
{tag}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<div className="w-px self-stretch bg-border" />
|
||||
<a
|
||||
href="https://docs.termix.site/features/files-and-hosts/credentials"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title={t("hosts.docsLink")}
|
||||
className="flex items-center justify-center size-7 text-muted-foreground hover:text-foreground shrink-0 transition-colors"
|
||||
>
|
||||
<ExternalLink className="size-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex items-center border border-border shrink-0">
|
||||
<button
|
||||
onClick={() => setCustomizePanelOpen(true)}
|
||||
title={t("credentials.customizeSidebar")}
|
||||
className="flex items-center justify-center size-7 text-muted-foreground hover:text-foreground hover:bg-muted transition-colors shrink-0"
|
||||
>
|
||||
<SlidersHorizontal className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center border border-accent-brand/30 ml-auto shrink-0">
|
||||
<button
|
||||
onClick={() =>
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("host-manager:add-credential"),
|
||||
)
|
||||
}
|
||||
title={t("credentials.addCredential")}
|
||||
className="flex items-center justify-center gap-1 h-7 px-2 text-[10px] font-medium text-accent-brand hover:bg-accent-brand/10 transition-colors"
|
||||
>
|
||||
<Plus className="size-3 shrink-0" />
|
||||
<span className="hidden min-[280px]:inline">
|
||||
{t("credentials.addCredential")}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -269,12 +312,23 @@ export function CredentialsPanel({
|
||||
hideListHeader
|
||||
externalSearch={managerEditing ? undefined : search}
|
||||
externalSort={sortKey}
|
||||
externalArrangeLocked={arrangeLocked}
|
||||
externalFilter={filterState}
|
||||
density={sidebarPrefs.display.density}
|
||||
trayTrigger={sidebarPrefs.display.trayTrigger}
|
||||
showTags={sidebarPrefs.display.showTags}
|
||||
onTagsChange={setAllTags}
|
||||
onEditingChange={handleEditingChange}
|
||||
active={active}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CustomizeCredentialsSidebarPanel
|
||||
open={customizePanelOpen}
|
||||
onOpenChange={setCustomizePanelOpen}
|
||||
preferences={sidebarPrefs}
|
||||
update={updateSidebarPrefs}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Rows3, SquareStack } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/dialog";
|
||||
import { SectionCard, SettingRow, FakeSwitch } from "@/components/section-card";
|
||||
import type {
|
||||
CredentialDensity,
|
||||
CredentialSidebarPreferences,
|
||||
CredentialTrayTrigger,
|
||||
} from "@/types/credential-sidebar-preferences";
|
||||
|
||||
export function CustomizeCredentialsSidebarPanel({
|
||||
open,
|
||||
onOpenChange,
|
||||
preferences,
|
||||
update,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
preferences: CredentialSidebarPreferences;
|
||||
update: (
|
||||
patch:
|
||||
| Partial<CredentialSidebarPreferences>
|
||||
| ((prev: CredentialSidebarPreferences) => CredentialSidebarPreferences),
|
||||
) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-lg font-bold">
|
||||
{t("credentials.customizeSidebar")}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs text-muted-foreground">
|
||||
{t("credentials.customizeSidebarDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-4 mt-1">
|
||||
<SectionCard
|
||||
title={t("credentials.customizeDensityTitle")}
|
||||
icon={<Rows3 className="size-3.5" />}
|
||||
>
|
||||
<SettingRow
|
||||
label={t("credentials.customizeDensityTitle")}
|
||||
description={
|
||||
preferences.display.density === "comfortable"
|
||||
? t("credentials.densityComfortableDesc")
|
||||
: t("credentials.densityCompactDesc")
|
||||
}
|
||||
>
|
||||
<select
|
||||
value={preferences.display.density}
|
||||
onChange={(e) =>
|
||||
update((prev) => ({
|
||||
...prev,
|
||||
display: {
|
||||
...prev.display,
|
||||
density: e.target.value as CredentialDensity,
|
||||
},
|
||||
}))
|
||||
}
|
||||
className="h-8 border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
<option value="comfortable">
|
||||
{t("credentials.displayDensityComfortable")}
|
||||
</option>
|
||||
<option value="compact">
|
||||
{t("credentials.displayDensityCompact")}
|
||||
</option>
|
||||
</select>
|
||||
</SettingRow>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
title={t("credentials.customizeBehaviorTitle")}
|
||||
icon={<SquareStack className="size-3.5" />}
|
||||
>
|
||||
<SettingRow
|
||||
label={t("credentials.showCredentialTags")}
|
||||
description={t("credentials.showCredentialTagsDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={preferences.display.showTags}
|
||||
onChange={(v) =>
|
||||
update((prev) => ({
|
||||
...prev,
|
||||
display: { ...prev.display, showTags: 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">
|
||||
{t("credentials.actionsVisibility")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground leading-snug">
|
||||
{t("credentials.actionsVisibilityDesc")}
|
||||
</span>
|
||||
</div>
|
||||
<select
|
||||
value={preferences.display.trayTrigger}
|
||||
onChange={(e) =>
|
||||
update((prev) => ({
|
||||
...prev,
|
||||
display: {
|
||||
...prev.display,
|
||||
trayTrigger: e.target.value as CredentialTrayTrigger,
|
||||
},
|
||||
}))
|
||||
}
|
||||
className="h-8 w-full border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
<option value="always">{t("credentials.actionsAlways")}</option>
|
||||
<option value="actionsOnly">
|
||||
{t("credentials.actionsOnly")}
|
||||
</option>
|
||||
<option value="hover">{t("credentials.actionsHover")}</option>
|
||||
<option value="click">{t("credentials.actionsClick")}</option>
|
||||
</select>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<p className="text-[11px] text-muted-foreground/70 leading-snug">
|
||||
{t("credentials.customizeSortHint")}
|
||||
</p>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Rows3, SquareStack } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/dialog";
|
||||
import { SectionCard, SettingRow, FakeSwitch } from "@/components/section-card";
|
||||
import type {
|
||||
HostDensity,
|
||||
HostSidebarPreferences,
|
||||
HostTrayTrigger,
|
||||
} from "@/types/host-sidebar-preferences";
|
||||
|
||||
export function CustomizeSidebarPanel({
|
||||
open,
|
||||
onOpenChange,
|
||||
preferences,
|
||||
update,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
preferences: HostSidebarPreferences;
|
||||
update: (
|
||||
patch:
|
||||
| Partial<HostSidebarPreferences>
|
||||
| ((prev: HostSidebarPreferences) => HostSidebarPreferences),
|
||||
) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-lg font-bold">
|
||||
{t("hosts.customizeSidebar")}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs text-muted-foreground">
|
||||
{t("hosts.customizeSidebarDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-4 mt-1">
|
||||
<SectionCard
|
||||
title={t("hosts.customizeDensityTitle")}
|
||||
icon={<Rows3 className="size-3.5" />}
|
||||
>
|
||||
<SettingRow
|
||||
label={t("hosts.customizeDensityTitle")}
|
||||
description={
|
||||
preferences.display.density === "comfortable"
|
||||
? t("hosts.densityComfortableDesc")
|
||||
: t("hosts.densityCompactDesc")
|
||||
}
|
||||
>
|
||||
<select
|
||||
value={preferences.display.density}
|
||||
onChange={(e) =>
|
||||
update((prev) => ({
|
||||
...prev,
|
||||
display: {
|
||||
...prev.display,
|
||||
density: e.target.value as HostDensity,
|
||||
},
|
||||
}))
|
||||
}
|
||||
className="h-8 border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
<option value="comfortable">
|
||||
{t("hosts.displayDensityComfortable")}
|
||||
</option>
|
||||
<option value="compact">
|
||||
{t("hosts.displayDensityCompact")}
|
||||
</option>
|
||||
</select>
|
||||
</SettingRow>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
title={t("hosts.customizeBehaviorTitle")}
|
||||
icon={<SquareStack className="size-3.5" />}
|
||||
>
|
||||
<SettingRow
|
||||
label={t("newUi.sidebar.userProfile.showHostTags")}
|
||||
description={t("newUi.sidebar.userProfile.showHostTagsDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={preferences.display.showTags}
|
||||
onChange={(v) =>
|
||||
update((prev) => ({
|
||||
...prev,
|
||||
display: { ...prev.display, showTags: 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">
|
||||
{t("hosts.actionsVisibility")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground leading-snug">
|
||||
{t("hosts.actionsVisibilityDesc")}
|
||||
</span>
|
||||
</div>
|
||||
<select
|
||||
value={preferences.display.trayTrigger}
|
||||
onChange={(e) =>
|
||||
update((prev) => ({
|
||||
...prev,
|
||||
display: {
|
||||
...prev.display,
|
||||
trayTrigger: e.target.value as HostTrayTrigger,
|
||||
},
|
||||
}))
|
||||
}
|
||||
className="h-8 w-full border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
<option value="always">{t("hosts.actionsAlways")}</option>
|
||||
<option value="actionsOnly">{t("hosts.actionsOnly")}</option>
|
||||
<option value="hover">{t("hosts.actionsHover")}</option>
|
||||
<option value="click">{t("hosts.actionsClick")}</option>
|
||||
</select>
|
||||
</div>
|
||||
<SettingRow
|
||||
label={t("hosts.openOnDoubleClick")}
|
||||
description={t("hosts.openOnDoubleClickDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={preferences.display.openOnDoubleClick}
|
||||
onChange={(v) =>
|
||||
update((prev) => ({
|
||||
...prev,
|
||||
display: { ...prev.display, openOnDoubleClick: v },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("newUi.sidebar.userProfile.statusColors")}
|
||||
description={t("newUi.sidebar.userProfile.statusColorsDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={preferences.display.statusColorScheme === "status"}
|
||||
onChange={(v) =>
|
||||
update((prev) => ({
|
||||
...prev,
|
||||
display: {
|
||||
...prev.display,
|
||||
statusColorScheme: v ? "status" : "accent",
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</SettingRow>
|
||||
</SectionCard>
|
||||
|
||||
<p className="text-[11px] text-muted-foreground/70 leading-snug">
|
||||
{t("hosts.customizeSortHint")}
|
||||
</p>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
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
@@ -33,6 +33,8 @@ export function FolderMetadataDialog({
|
||||
open,
|
||||
mode,
|
||||
initial,
|
||||
existingPaths = [],
|
||||
currentPath,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
}: {
|
||||
@@ -44,6 +46,10 @@ export function FolderMetadataDialog({
|
||||
icon?: string;
|
||||
credentialId?: number | null;
|
||||
};
|
||||
/** Every folder path that already exists, for duplicate-name validation. */
|
||||
existingPaths?: string[];
|
||||
/** The folder's own current full path, in edit mode -- excluded from the duplicate check. */
|
||||
currentPath?: string;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
onSubmit: (value: FolderMetadataValue) => void;
|
||||
}) {
|
||||
@@ -54,6 +60,22 @@ export function FolderMetadataDialog({
|
||||
const [credentialId, setCredentialId] = useState<string>("");
|
||||
const [credentials, setCredentials] = useState<CredentialOption[]>([]);
|
||||
|
||||
const parentPath =
|
||||
mode === "edit" && currentPath?.includes(" / ")
|
||||
? currentPath.slice(0, currentPath.lastIndexOf(" / "))
|
||||
: "";
|
||||
const resultingPath = name
|
||||
? mode === "edit"
|
||||
? parentPath
|
||||
? `${parentPath} / ${normalizePath(name)}`
|
||||
: normalizePath(name)
|
||||
: normalizePath(name)
|
||||
: "";
|
||||
const isDuplicate =
|
||||
!!resultingPath &&
|
||||
resultingPath !== currentPath &&
|
||||
existingPaths.some((p) => p.toLowerCase() === resultingPath.toLowerCase());
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName(initial?.name ?? "");
|
||||
@@ -83,7 +105,7 @@ export function FolderMetadataDialog({
|
||||
|
||||
function handleSubmit() {
|
||||
const normalized = normalizePath(name);
|
||||
if (!normalized) return;
|
||||
if (!normalized || isDuplicate) return;
|
||||
onSubmit({
|
||||
name: normalized,
|
||||
color,
|
||||
@@ -117,12 +139,20 @@ export function FolderMetadataDialog({
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleSubmit();
|
||||
if (e.key === "Enter" && !isDuplicate) handleSubmit();
|
||||
}}
|
||||
aria-invalid={isDuplicate}
|
||||
className={isDuplicate ? "border-destructive" : undefined}
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{t("hosts.folderNestingHint")}
|
||||
</p>
|
||||
{isDuplicate ? (
|
||||
<p className="text-[10px] text-destructive">
|
||||
{t("hosts.folderNameDuplicate")}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{t("hosts.folderNestingHint")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-xs font-semibold">
|
||||
@@ -189,6 +219,7 @@ export function FolderMetadataDialog({
|
||||
variant="outline"
|
||||
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
onClick={handleSubmit}
|
||||
disabled={!name.trim() || isDuplicate}
|
||||
>
|
||||
{mode === "create"
|
||||
? t("hosts.createFolderButton")
|
||||
|
||||
@@ -121,7 +121,7 @@ export function FolderPathPicker({
|
||||
align="start"
|
||||
sideOffset={4}
|
||||
collisionPadding={8}
|
||||
className="w-max min-w-(--radix-popover-trigger-width) max-w-72 max-h-(--radix-popover-content-available-height) p-0 rounded-none border-0 ring-1 ring-border shadow-md flex flex-col overflow-hidden"
|
||||
className="w-max min-w-(--radix-popover-trigger-width) max-w-96 max-h-(--radix-popover-content-available-height) p-0 rounded-none border-0 ring-1 ring-border shadow-md flex flex-col overflow-hidden"
|
||||
>
|
||||
<div className="flex items-center gap-2 border-b border-border px-2.5 h-8 shrink-0">
|
||||
<Search className="size-3 shrink-0 text-muted-foreground/60" />
|
||||
|
||||
@@ -179,6 +179,8 @@ export function HostAuthOverrideModal({
|
||||
{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={
|
||||
loading || loadError || saving || selectedId === initialId
|
||||
|
||||
@@ -1,429 +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,
|
||||
usedByCount,
|
||||
stripeIndex,
|
||||
termixIdLinked,
|
||||
onDeploy,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
cred: Credential;
|
||||
usedByCount: number;
|
||||
stripeIndex: number;
|
||||
termixIdLinked?: boolean;
|
||||
onDeploy: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const isKey = cred.type === "key";
|
||||
|
||||
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 || usedByCount > 0) && (
|
||||
<span className="text-[11px] text-muted-foreground/45 truncate leading-none pl-3">
|
||||
{cred.username}
|
||||
{usedByCount > 0 && (
|
||||
<span className="text-muted-foreground/30">
|
||||
{cred.username ? " · " : ""}
|
||||
{usedByCount}h
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* 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 usedByCount = allHosts.filter(
|
||||
(h) => h.credentialId === cred.id,
|
||||
).length;
|
||||
return (
|
||||
<CredentialItem
|
||||
key={cred.id}
|
||||
cred={cred}
|
||||
usedByCount={usedByCount}
|
||||
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>
|
||||
);
|
||||
}
|
||||
+489
-218
@@ -11,16 +11,25 @@ import { Button } from "@/components/button";
|
||||
import { Input } from "@/components/input";
|
||||
import { PasswordInput } from "@/components/password-input";
|
||||
import { Slider } from "@/components/slider";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/tooltip";
|
||||
import {
|
||||
TERMINAL_FONT_ZOOM_MIN,
|
||||
TERMINAL_FONT_ZOOM_MAX,
|
||||
} from "@/features/terminal/terminal-font-zoom";
|
||||
import {
|
||||
Globe,
|
||||
Info,
|
||||
Layers, // --- tmux-monitor ---
|
||||
LayoutGrid,
|
||||
Network,
|
||||
Palette,
|
||||
Plus,
|
||||
Settings,
|
||||
Shield,
|
||||
Upload,
|
||||
X,
|
||||
@@ -61,6 +70,7 @@ import {
|
||||
createHostEditorForm,
|
||||
mapSnippetResponse,
|
||||
omitOwnerSshAuthFromSharedEdit,
|
||||
terminalAppearanceKeys,
|
||||
type HostAuthType,
|
||||
type HostBellStyle,
|
||||
type HostBackspaceMode,
|
||||
@@ -68,6 +78,7 @@ import {
|
||||
type HostFastScrollModifier,
|
||||
type HostProtocols,
|
||||
} from "./HostEditorData";
|
||||
import { useConnectionDefaults } from "@/contexts/ConnectionDefaultsContext";
|
||||
import {
|
||||
HostDockerTab,
|
||||
HostProxmoxTab,
|
||||
@@ -83,6 +94,12 @@ import {
|
||||
import { HostStatsTab } from "./HostEditorStatsTab";
|
||||
import { VaultProfileManager } from "./VaultProfileManager";
|
||||
import { findHostByTunnelEndpoint } from "@/features/tunnel/tunnel-endpoints";
|
||||
import {
|
||||
toCredentialOption,
|
||||
type CredentialOption,
|
||||
} from "./quick-created-credential";
|
||||
|
||||
const CUSTOM_FONT_OPTION = "__custom__";
|
||||
|
||||
export function HostEditor({
|
||||
host,
|
||||
@@ -91,17 +108,22 @@ export function HostEditor({
|
||||
onSave,
|
||||
protocols,
|
||||
onProtocolChange,
|
||||
onDirtyChange,
|
||||
onTabChange,
|
||||
hosts,
|
||||
credentials,
|
||||
adminTargetUserId,
|
||||
simpleMode = false,
|
||||
}: {
|
||||
host: Host | null;
|
||||
activeTab: string;
|
||||
/** Collapses the General tab to the fields needed to reach a host. */
|
||||
simpleMode?: boolean;
|
||||
onBack: () => void;
|
||||
onSave: (saved: SSHHost) => void;
|
||||
protocols: HostProtocols;
|
||||
onProtocolChange: (p: Partial<typeof protocols>) => void;
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
onTabChange: (tab: string) => void;
|
||||
hosts: Host[];
|
||||
credentials: { id: string; name: string; username: string }[];
|
||||
@@ -111,13 +133,35 @@ export function HostEditor({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { setPreviewTerminalTheme } = useTabsSafe();
|
||||
const [form, setForm] = useState(() => createHostEditorForm(host));
|
||||
const connectionDefaults = useConnectionDefaults();
|
||||
const [form, setForm] = useState(() =>
|
||||
createHostEditorForm(host, undefined, connectionDefaults),
|
||||
);
|
||||
const [isCustomFont, setIsCustomFont] = useState(
|
||||
() => !TERMINAL_FONTS.some((f) => f.value === form.fontFamily),
|
||||
);
|
||||
|
||||
const setField = <K extends keyof typeof form>(k: K, v: (typeof form)[K]) =>
|
||||
setForm((p) => ({ ...p, [k]: v }));
|
||||
const setField = <K extends keyof typeof form>(k: K, v: (typeof form)[K]) => {
|
||||
onDirtyChange?.(true);
|
||||
setForm((p) => ({
|
||||
...p,
|
||||
[k]: v,
|
||||
...(terminalAppearanceKeys.includes(
|
||||
k as (typeof terminalAppearanceKeys)[number],
|
||||
)
|
||||
? { inheritTerminalAppearance: false }
|
||||
: {}),
|
||||
}));
|
||||
};
|
||||
|
||||
const setGuacField = (key: string, value: unknown) =>
|
||||
setField("guacamoleConfig", { ...form.guacamoleConfig, [key]: value });
|
||||
const setGuacField = (key: string, value: unknown) => {
|
||||
onDirtyChange?.(true);
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
inheritRemoteDesktopDefaults: false,
|
||||
guacamoleConfig: { ...current.guacamoleConfig, [key]: value },
|
||||
}));
|
||||
};
|
||||
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [snippets, setSnippets] = useState<{ id: number; name: string }[]>([]);
|
||||
@@ -136,6 +180,7 @@ export function HostEditor({
|
||||
>([]);
|
||||
const [tailscaleHasApiKey, setTailscaleHasApiKey] = useState(false);
|
||||
const [tailscaleLoading, setTailscaleLoading] = useState(false);
|
||||
const [tailscaleDeviceError, setTailscaleDeviceError] = useState(false);
|
||||
const [connectingTunnel, setConnectingTunnel] = useState<number | null>(null);
|
||||
const [isOidcUser, setIsOidcUser] = useState(false);
|
||||
const [vaultProfiles, setVaultProfiles] = useState<VaultProfile[]>([]);
|
||||
@@ -144,6 +189,8 @@ export function HostEditor({
|
||||
const [creatingQuickCredential, setCreatingQuickCredential] = useState(false);
|
||||
const [showQuickCredentialDialog, setShowQuickCredentialDialog] =
|
||||
useState(false);
|
||||
const [quickCreatedCredential, setQuickCreatedCredential] =
|
||||
useState<CredentialOption | null>(null);
|
||||
const [savedThemes, setSavedThemes] = useState<SavedCustomTheme[]>([]);
|
||||
const [savingTheme, setSavingTheme] = useState(false);
|
||||
|
||||
@@ -223,11 +270,15 @@ export function HostEditor({
|
||||
}, [adminTargetUserId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (host) return;
|
||||
if (!connectionDefaults.ready) return;
|
||||
if (host) {
|
||||
setForm(createHostEditorForm(host, undefined, connectionDefaults));
|
||||
return;
|
||||
}
|
||||
getHostDefaults()
|
||||
.then((d) => setForm(createHostEditorForm(null, d)))
|
||||
.then((d) => setForm(createHostEditorForm(null, d, connectionDefaults)))
|
||||
.catch(() => {});
|
||||
}, [host]);
|
||||
}, [host, connectionDefaults]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!host?.id || form.vncAuthType !== "direct" || form.vncPassword) return;
|
||||
@@ -257,12 +308,14 @@ export function HostEditor({
|
||||
useEffect(() => {
|
||||
if (form.authType !== "tailscale") return;
|
||||
setTailscaleLoading(true);
|
||||
setTailscaleDeviceError(false);
|
||||
getTailscaleDevices()
|
||||
.then((res) => {
|
||||
setTailscaleDevices(res?.devices ?? []);
|
||||
setTailscaleHasApiKey(res?.hasApiKey ?? false);
|
||||
setTailscaleDeviceError(!!res?.error);
|
||||
})
|
||||
.catch(() => {})
|
||||
.catch(() => setTailscaleDeviceError(true))
|
||||
.finally(() => setTailscaleLoading(false));
|
||||
}, [form.authType]);
|
||||
|
||||
@@ -294,7 +347,14 @@ export function HostEditor({
|
||||
};
|
||||
|
||||
const authMethod = form.authType;
|
||||
const selectedCredential = credentials.find(
|
||||
const availableCredentials =
|
||||
quickCreatedCredential &&
|
||||
!credentials.some(
|
||||
(credential) => credential.id === quickCreatedCredential.id,
|
||||
)
|
||||
? [...credentials, quickCreatedCredential]
|
||||
: credentials;
|
||||
const selectedCredential = availableCredentials.find(
|
||||
(c) => c.id === form.credentialId,
|
||||
);
|
||||
|
||||
@@ -330,8 +390,11 @@ export function HostEditor({
|
||||
if (authMethod === "password") {
|
||||
data.authType = "password";
|
||||
data.password =
|
||||
form.password ||
|
||||
(host?.hasPassword ? await fetchField("password") : null);
|
||||
form.password && form.password !== "existing_password"
|
||||
? form.password
|
||||
: host?.hasPassword
|
||||
? await fetchField("password")
|
||||
: null;
|
||||
} else {
|
||||
const key =
|
||||
form.key && form.key !== "existing_key"
|
||||
@@ -348,14 +411,24 @@ export function HostEditor({
|
||||
data.authType = "key";
|
||||
data.key = key;
|
||||
data.keyPassword = keyPassword;
|
||||
data.password = form.password || null;
|
||||
data.password =
|
||||
form.password && form.password !== "existing_password"
|
||||
? form.password
|
||||
: null;
|
||||
}
|
||||
|
||||
if (adminTargetUserId) {
|
||||
await adminCreateUserCredential(adminTargetUserId, data);
|
||||
} else {
|
||||
await createCredential(data);
|
||||
}
|
||||
const created = adminTargetUserId
|
||||
? await adminCreateUserCredential(adminTargetUserId, data)
|
||||
: await createCredential(data);
|
||||
const credential = toCredentialOption(created);
|
||||
if (!credential) throw new Error(t("hosts.failedToSaveCredential"));
|
||||
|
||||
setQuickCreatedCredential(credential);
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
authType: "credential",
|
||||
credentialId: credential.id,
|
||||
}));
|
||||
toast.success(t("hosts.credentialCreated"));
|
||||
if (!adminTargetUserId) {
|
||||
window.dispatchEvent(new CustomEvent("termix:credentials-changed"));
|
||||
@@ -380,6 +453,7 @@ export function HostEditor({
|
||||
proto: keyof typeof protocols,
|
||||
value: boolean,
|
||||
) => {
|
||||
onDirtyChange?.(true);
|
||||
onProtocolChange({ [proto]: value });
|
||||
const tabForProto: Record<string, string> = {
|
||||
enableSsh: "ssh",
|
||||
@@ -434,6 +508,7 @@ export function HostEditor({
|
||||
handleProtocolToggle={handleProtocolToggle}
|
||||
hosts={hosts}
|
||||
host={host}
|
||||
simpleMode={simpleMode}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -493,9 +568,16 @@ export function HostEditor({
|
||||
>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.authMethod")}
|
||||
{t("hosts.authenticationMethod")}
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{t("hosts.authenticationMethodDesc")}
|
||||
</p>
|
||||
<div
|
||||
className="flex flex-wrap gap-2"
|
||||
role="radiogroup"
|
||||
aria-label={t("hosts.authenticationMethod")}
|
||||
>
|
||||
{[
|
||||
"password",
|
||||
"key",
|
||||
@@ -508,6 +590,9 @@ export function HostEditor({
|
||||
].map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={authMethod === m}
|
||||
disabled={lockAuthReferences}
|
||||
title={
|
||||
lockAuthReferences
|
||||
@@ -570,8 +655,20 @@ export function HostEditor({
|
||||
</label>
|
||||
<PasswordInput
|
||||
className="h-8 text-xs pr-8"
|
||||
placeholder="••••••••"
|
||||
value={form.password}
|
||||
placeholder={
|
||||
form.password === "existing_password"
|
||||
? t("hosts.passwordSaved")
|
||||
: "••••••••"
|
||||
}
|
||||
value={
|
||||
form.password === "existing_password"
|
||||
? ""
|
||||
: form.password
|
||||
}
|
||||
onFocus={() => {
|
||||
if (form.password === "existing_password")
|
||||
setField("password", "");
|
||||
}}
|
||||
onChange={(e) => setField("password", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
@@ -689,8 +786,20 @@ export function HostEditor({
|
||||
</label>
|
||||
<PasswordInput
|
||||
className="h-8 text-xs pr-8"
|
||||
placeholder="••••••••"
|
||||
value={form.password}
|
||||
placeholder={
|
||||
form.password === "existing_password"
|
||||
? t("hosts.passwordSaved")
|
||||
: "••••••••"
|
||||
}
|
||||
value={
|
||||
form.password === "existing_password"
|
||||
? ""
|
||||
: form.password
|
||||
}
|
||||
onFocus={() => {
|
||||
if (form.password === "existing_password")
|
||||
setField("password", "");
|
||||
}}
|
||||
onChange={(e) =>
|
||||
setField("password", e.target.value)
|
||||
}
|
||||
@@ -802,7 +911,7 @@ export function HostEditor({
|
||||
const newId = e.target.value;
|
||||
setField("credentialId", newId);
|
||||
if (!form.overrideCredentialUsername) {
|
||||
const cred = credentials.find(
|
||||
const cred = availableCredentials.find(
|
||||
(c) => c.id === newId,
|
||||
);
|
||||
if (cred?.username)
|
||||
@@ -814,7 +923,7 @@ export function HostEditor({
|
||||
<option value="">
|
||||
{t("hosts.selectACredential")}
|
||||
</option>
|
||||
{credentials.map((c) => (
|
||||
{availableCredentials.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.username
|
||||
? `${c.name} (${c.username})`
|
||||
@@ -853,8 +962,20 @@ export function HostEditor({
|
||||
</label>
|
||||
<PasswordInput
|
||||
className="h-8 text-xs pr-8"
|
||||
placeholder="••••••••"
|
||||
value={form.password}
|
||||
placeholder={
|
||||
form.password === "existing_password"
|
||||
? t("hosts.passwordSaved")
|
||||
: "••••••••"
|
||||
}
|
||||
value={
|
||||
form.password === "existing_password"
|
||||
? ""
|
||||
: form.password
|
||||
}
|
||||
onFocus={() => {
|
||||
if (form.password === "existing_password")
|
||||
setField("password", "");
|
||||
}}
|
||||
onChange={(e) =>
|
||||
setField("password", e.target.value)
|
||||
}
|
||||
@@ -898,14 +1019,18 @@ export function HostEditor({
|
||||
{t("hosts.tailscaleDocsLink")}
|
||||
</a>
|
||||
</div>
|
||||
{!tailscaleHasApiKey && !tailscaleLoading ? (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{t("hosts.tailscaleNoApiKey")}
|
||||
</p>
|
||||
) : tailscaleLoading ? (
|
||||
{tailscaleLoading ? (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{t("hosts.tailscaleLoadingDevices")}
|
||||
</p>
|
||||
) : tailscaleDeviceError ? (
|
||||
<p className="text-[10px] text-destructive">
|
||||
{t("hosts.tailscaleDeviceLoadFailed")}
|
||||
</p>
|
||||
) : !tailscaleHasApiKey ? (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{t("hosts.tailscaleNoApiKey")}
|
||||
</p>
|
||||
) : tailscaleDevices.length === 0 ? (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{t("hosts.tailscaleNoDevices")}
|
||||
@@ -956,25 +1081,13 @@ export function HostEditor({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{authMethod === "warpgate" && (
|
||||
<div className="flex flex-col gap-2 border-t border-border pt-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.warpgateLabel")}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{t("hosts.warpgateDesc")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{authMethod === "agent" && (
|
||||
<div className="flex flex-col gap-2 border-t border-border pt-3">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.agentLabel")}
|
||||
</span>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{t("hosts.agentDesc")}
|
||||
{t("hosts.agentAuthenticationDesc")}
|
||||
</p>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
@@ -1041,8 +1154,20 @@ export function HostEditor({
|
||||
</label>
|
||||
<PasswordInput
|
||||
className="h-8 text-xs pr-8"
|
||||
placeholder="••••••••"
|
||||
value={form.sudoPassword}
|
||||
placeholder={
|
||||
form.sudoPassword === "existing_sudo_password"
|
||||
? t("hosts.sudoPasswordSaved")
|
||||
: "••••••••"
|
||||
}
|
||||
value={
|
||||
form.sudoPassword === "existing_sudo_password"
|
||||
? ""
|
||||
: form.sudoPassword
|
||||
}
|
||||
onFocus={() => {
|
||||
if (form.sudoPassword === "existing_sudo_password")
|
||||
setField("sudoPassword", "");
|
||||
}}
|
||||
onChange={(e) =>
|
||||
setField("sudoPassword", e.target.value)
|
||||
}
|
||||
@@ -1061,180 +1186,243 @@ export function HostEditor({
|
||||
icon={<Palette className="size-3.5" />}
|
||||
>
|
||||
<div className="flex flex-col gap-4 py-3">
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.themePreview")}
|
||||
</label>
|
||||
<TerminalPreview
|
||||
theme={form.theme}
|
||||
fontSize={form.fontSize}
|
||||
fontFamily={form.fontFamily}
|
||||
cursorStyle={form.cursorStyle}
|
||||
cursorBlink={form.cursorBlink}
|
||||
letterSpacing={form.letterSpacing}
|
||||
lineHeight={form.lineHeight}
|
||||
customThemeColors={form.customThemeColors ?? undefined}
|
||||
<SettingRow
|
||||
label={t("hosts.useUserDefaults", {
|
||||
defaultValue: "Use user defaults",
|
||||
})}
|
||||
description={t("hosts.useUserTerminalDefaultsDesc", {
|
||||
defaultValue:
|
||||
"Keep this host synchronized with Terminal defaults from User Profile.",
|
||||
})}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={form.inheritTerminalAppearance}
|
||||
onChange={(value) =>
|
||||
setField("inheritTerminalAppearance", value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
</SettingRow>
|
||||
<fieldset
|
||||
disabled={form.inheritTerminalAppearance}
|
||||
className={
|
||||
form.inheritTerminalAppearance
|
||||
? "contents opacity-60"
|
||||
: "contents"
|
||||
}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.colorTheme")}
|
||||
{t("hosts.themePreview")}
|
||||
</label>
|
||||
<select
|
||||
value={form.theme}
|
||||
onChange={(e) => {
|
||||
const newTheme = e.target.value;
|
||||
setField("theme", newTheme);
|
||||
setPreviewTerminalTheme(newTheme);
|
||||
if (
|
||||
newTheme === "custom" &&
|
||||
!form.customThemeColors
|
||||
) {
|
||||
setField("customThemeColors", {
|
||||
...TERMINAL_THEMES.termixDark.colors,
|
||||
});
|
||||
<TerminalPreview
|
||||
theme={form.theme}
|
||||
fontSize={form.fontSize}
|
||||
fontFamily={form.fontFamily}
|
||||
cursorStyle={form.cursorStyle}
|
||||
cursorBlink={form.cursorBlink}
|
||||
letterSpacing={form.letterSpacing}
|
||||
lineHeight={form.lineHeight}
|
||||
customThemeColors={form.customThemeColors ?? undefined}
|
||||
/>
|
||||
</div>
|
||||
<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.colorTheme")}
|
||||
</label>
|
||||
<select
|
||||
value={form.theme}
|
||||
onChange={(e) => {
|
||||
const newTheme = e.target.value;
|
||||
setField("theme", newTheme);
|
||||
setPreviewTerminalTheme(newTheme);
|
||||
if (
|
||||
newTheme === "custom" &&
|
||||
!form.customThemeColors
|
||||
) {
|
||||
setField("customThemeColors", {
|
||||
...TERMINAL_THEMES.termixDark.colors,
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
{Object.entries(TERMINAL_THEMES)
|
||||
.filter(
|
||||
([key]) =>
|
||||
key !== "termixDark" && key !== "termixLight",
|
||||
)
|
||||
.map(([key, theme]) => (
|
||||
<option key={key} value={key}>
|
||||
{theme.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center gap-1">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.fontFamilyLabel")}
|
||||
</label>
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="size-3 text-muted-foreground" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{t("hosts.fontFamilyCustomHint")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<select
|
||||
value={
|
||||
isCustomFont ? CUSTOM_FONT_OPTION : form.fontFamily
|
||||
}
|
||||
}}
|
||||
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
{Object.entries(TERMINAL_THEMES)
|
||||
.filter(
|
||||
([key]) =>
|
||||
key !== "termixDark" && key !== "termixLight",
|
||||
)
|
||||
.map(([key, theme]) => (
|
||||
<option key={key} value={key}>
|
||||
{theme.name}
|
||||
onChange={(e) => {
|
||||
if (e.target.value === CUSTOM_FONT_OPTION) {
|
||||
setIsCustomFont(true);
|
||||
setField("fontFamily", "");
|
||||
} else {
|
||||
setIsCustomFont(false);
|
||||
setField("fontFamily", e.target.value);
|
||||
}
|
||||
}}
|
||||
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring font-mono"
|
||||
>
|
||||
{TERMINAL_FONTS.map((f) => (
|
||||
<option key={f.value} value={f.value}>
|
||||
{f.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.fontFamilyLabel")}
|
||||
</label>
|
||||
<select
|
||||
value={form.fontFamily}
|
||||
onChange={(e) => setField("fontFamily", e.target.value)}
|
||||
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring font-mono"
|
||||
>
|
||||
{TERMINAL_FONTS.map((f) => (
|
||||
<option key={f.value} value={f.value}>
|
||||
{f.label}
|
||||
<option value={CUSTOM_FONT_OPTION}>
|
||||
{t("hosts.fontFamilyCustomOption")}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<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.fontSizeLabel")}
|
||||
</label>
|
||||
<span className="text-[10px] text-muted-foreground tabular-nums">
|
||||
{form.fontSize}px
|
||||
</span>
|
||||
</select>
|
||||
{isCustomFont && (
|
||||
<Input
|
||||
value={form.fontFamily}
|
||||
onChange={(e) =>
|
||||
setField("fontFamily", e.target.value)
|
||||
}
|
||||
placeholder={t("hosts.fontFamilyCustomPlaceholder")}
|
||||
className="h-9 text-xs font-mono"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Slider
|
||||
min={TERMINAL_FONT_ZOOM_MIN}
|
||||
max={TERMINAL_FONT_ZOOM_MAX}
|
||||
step={1}
|
||||
value={[form.fontSize]}
|
||||
onValueChange={([v]) => setField("fontSize", v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.cursorStyleLabel")}
|
||||
</label>
|
||||
<select
|
||||
value={form.cursorStyle}
|
||||
onChange={(e) =>
|
||||
setField(
|
||||
"cursorStyle",
|
||||
e.target.value as HostCursorStyle,
|
||||
)
|
||||
}
|
||||
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
{CURSOR_STYLES.map((s) => (
|
||||
<option key={s.value} value={s.value}>
|
||||
{s.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<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.letterSpacingPx")}
|
||||
</label>
|
||||
<span className="text-[10px] text-muted-foreground tabular-nums">
|
||||
{form.letterSpacing}px
|
||||
</span>
|
||||
<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.fontSizeLabel")}
|
||||
</label>
|
||||
<span className="text-[10px] text-muted-foreground tabular-nums">
|
||||
{form.fontSize}px
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
min={TERMINAL_FONT_ZOOM_MIN}
|
||||
max={TERMINAL_FONT_ZOOM_MAX}
|
||||
step={1}
|
||||
value={[form.fontSize]}
|
||||
onValueChange={([v]) => setField("fontSize", v)}
|
||||
/>
|
||||
</div>
|
||||
<Slider
|
||||
min={-2}
|
||||
max={10}
|
||||
step={0.5}
|
||||
value={[form.letterSpacing]}
|
||||
onValueChange={([v]) => setField("letterSpacing", v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.lineHeightLabel")}
|
||||
{t("hosts.cursorStyleLabel")}
|
||||
</label>
|
||||
<span className="text-[10px] text-muted-foreground tabular-nums">
|
||||
{form.lineHeight.toFixed(1)}
|
||||
</span>
|
||||
<select
|
||||
value={form.cursorStyle}
|
||||
onChange={(e) =>
|
||||
setField(
|
||||
"cursorStyle",
|
||||
e.target.value as HostCursorStyle,
|
||||
)
|
||||
}
|
||||
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
{CURSOR_STYLES.map((s) => (
|
||||
<option key={s.value} value={s.value}>
|
||||
{s.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<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.letterSpacingPx")}
|
||||
</label>
|
||||
<span className="text-[10px] text-muted-foreground tabular-nums">
|
||||
{form.letterSpacing}px
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
min={-2}
|
||||
max={10}
|
||||
step={0.5}
|
||||
value={[form.letterSpacing]}
|
||||
onValueChange={([v]) => setField("letterSpacing", v)}
|
||||
/>
|
||||
</div>
|
||||
<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.lineHeightLabel")}
|
||||
</label>
|
||||
<span className="text-[10px] text-muted-foreground tabular-nums">
|
||||
{form.lineHeight.toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
min={1.0}
|
||||
max={2.0}
|
||||
step={0.1}
|
||||
value={[form.lineHeight]}
|
||||
onValueChange={([v]) => setField("lineHeight", v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.bellStyleLabel")}
|
||||
</label>
|
||||
<select
|
||||
value={form.bellStyle}
|
||||
onChange={(e) =>
|
||||
setField(
|
||||
"bellStyle",
|
||||
e.target.value as HostBellStyle,
|
||||
)
|
||||
}
|
||||
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
{BELL_STYLES.map((b) => (
|
||||
<option key={b.value} value={b.value}>
|
||||
{b.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.backspaceModeLabel")}
|
||||
</label>
|
||||
<select
|
||||
value={form.backspaceMode}
|
||||
onChange={(e) =>
|
||||
setField(
|
||||
"backspaceMode",
|
||||
e.target.value as HostBackspaceMode,
|
||||
)
|
||||
}
|
||||
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
<option value="normal">Normal (DEL)</option>
|
||||
<option value="control-h">Control-H (BS)</option>
|
||||
</select>
|
||||
</div>
|
||||
<Slider
|
||||
min={1.0}
|
||||
max={2.0}
|
||||
step={0.1}
|
||||
value={[form.lineHeight]}
|
||||
onValueChange={([v]) => setField("lineHeight", v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.bellStyleLabel")}
|
||||
</label>
|
||||
<select
|
||||
value={form.bellStyle}
|
||||
onChange={(e) =>
|
||||
setField("bellStyle", e.target.value as HostBellStyle)
|
||||
}
|
||||
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
{BELL_STYLES.map((b) => (
|
||||
<option key={b.value} value={b.value}>
|
||||
{b.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.backspaceModeLabel")}
|
||||
</label>
|
||||
<select
|
||||
value={form.backspaceMode}
|
||||
onChange={(e) =>
|
||||
setField(
|
||||
"backspaceMode",
|
||||
e.target.value as HostBackspaceMode,
|
||||
)
|
||||
}
|
||||
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
<option value="normal">Normal (DEL)</option>
|
||||
<option value="control-h">Control-H (BS)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
{form.theme === "custom" && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
@@ -1426,6 +1614,15 @@ export function HostEditor({
|
||||
onChange={(v) => setField("rightClickSelectsWord", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("hosts.macOptionIsMetaLabel")}
|
||||
description={t("hosts.macOptionIsMetaShortDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={form.macOptionIsMeta}
|
||||
onChange={(v) => setField("macOptionIsMeta", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("hosts.syntaxHighlightingLabel")}
|
||||
description={t("hosts.syntaxHighlightingDesc")}
|
||||
@@ -1654,6 +1851,39 @@ export function HostEditor({
|
||||
onChange={(v) => setField("enableCommandHistory", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.localEchoLabel")}
|
||||
</label>
|
||||
<select
|
||||
value={form.localEcho}
|
||||
onChange={(e) =>
|
||||
setField(
|
||||
"localEcho",
|
||||
e.target.value as "default" | "off" | "auto" | "on",
|
||||
)
|
||||
}
|
||||
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
<option value="default">
|
||||
{t("hosts.localEchoDefault")}
|
||||
</option>
|
||||
<option value="off">{t("hosts.localEchoOff")}</option>
|
||||
<option value="auto">{t("hosts.localEchoAuto")}</option>
|
||||
<option value="on">{t("hosts.localEchoOn")}</option>
|
||||
</select>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{t("hosts.localEchoDesc")}{" "}
|
||||
<a
|
||||
href="https://docs.termix.site/features/terminal/appearance"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-accent-brand hover:underline"
|
||||
>
|
||||
{t("hosts.docsLink")}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.linkClickBehaviorLabel")}
|
||||
@@ -1892,6 +2122,22 @@ export function HostEditor({
|
||||
</SettingRow>
|
||||
</div>
|
||||
</SectionCard>
|
||||
<SectionCard
|
||||
title={t("terminalToolbar.title")}
|
||||
icon={<LayoutGrid className="size-3.5" />}
|
||||
>
|
||||
<div className="flex flex-col gap-4 py-3">
|
||||
<SettingRow
|
||||
label={t("hosts.enableTerminalToolbar")}
|
||||
description={t("hosts.enableTerminalToolbarDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={form.enableTerminalToolbar}
|
||||
onChange={(v) => setField("enableTerminalToolbar", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
</SectionCard>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -2298,13 +2544,38 @@ export function HostEditor({
|
||||
)}
|
||||
|
||||
{activeTab === "rdp" && (
|
||||
<HostEditorRdpTab
|
||||
form={form}
|
||||
setField={setField}
|
||||
setGuacField={setGuacField}
|
||||
host={host}
|
||||
credentials={credentials}
|
||||
/>
|
||||
<>
|
||||
<SectionCard
|
||||
title={t("hosts.rdpDefaults", {
|
||||
defaultValue: "RDP defaults",
|
||||
})}
|
||||
icon={<Settings className="size-3.5" />}
|
||||
>
|
||||
<SettingRow
|
||||
label={t("hosts.useUserDefaults", {
|
||||
defaultValue: "Use user defaults",
|
||||
})}
|
||||
description={t("hosts.useUserRdpDefaultsDesc", {
|
||||
defaultValue:
|
||||
"Inherit performance, redirection, and clipboard settings from User Profile.",
|
||||
})}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={form.inheritRemoteDesktopDefaults}
|
||||
onChange={(value) =>
|
||||
setField("inheritRemoteDesktopDefaults", value)
|
||||
}
|
||||
/>
|
||||
</SettingRow>
|
||||
</SectionCard>
|
||||
<HostEditorRdpTab
|
||||
form={form}
|
||||
setField={setField}
|
||||
setGuacField={setGuacField}
|
||||
host={host}
|
||||
credentials={availableCredentials}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === "vnc" && (
|
||||
@@ -2313,7 +2584,7 @@ export function HostEditor({
|
||||
setField={setField}
|
||||
setGuacField={setGuacField}
|
||||
host={host}
|
||||
credentials={credentials}
|
||||
credentials={availableCredentials}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -2323,7 +2594,7 @@ export function HostEditor({
|
||||
setField={setField}
|
||||
setGuacField={setGuacField}
|
||||
host={host}
|
||||
credentials={credentials}
|
||||
credentials={availableCredentials}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,10 @@ 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";
|
||||
|
||||
type HostSocks5ProxyNode = NonNullable<Host["socks5ProxyChain"]>[number];
|
||||
|
||||
@@ -24,6 +28,54 @@ 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;
|
||||
@@ -46,9 +98,19 @@ export function mapSnippetResponse(
|
||||
export function createHostEditorForm(
|
||||
host: Host | null,
|
||||
defaults?: HostDefaults,
|
||||
connectionDefaults?: UserConnectionDefaults,
|
||||
) {
|
||||
const d = host ? undefined : defaults;
|
||||
const rawTheme = host?.terminalConfig?.theme ?? d?.theme;
|
||||
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(
|
||||
@@ -70,7 +132,7 @@ export function createHostEditorForm(
|
||||
authType: host?.authType ?? "password",
|
||||
useWarpgate: host?.useWarpgate ?? false,
|
||||
shareSshAuth: host?.shareSshAuth ?? false,
|
||||
password: host?.password ?? "",
|
||||
password: host?.hasPassword ? "existing_password" : (host?.password ?? ""),
|
||||
key: host?.key ?? (host?.hasKey ? "existing_key" : ""),
|
||||
keyPassword: host?.hasKeyPassword
|
||||
? "existing_key_password"
|
||||
@@ -83,6 +145,7 @@ export function createHostEditorForm(
|
||||
vaultProfileId: host?.vaultProfileId ?? "",
|
||||
overrideCredentialUsername: host?.overrideCredentialUsername ?? false,
|
||||
folder: host?.folder ?? "",
|
||||
parentHostId: host?.parentHostId ?? "",
|
||||
tags: host?.tags ?? ([] as string[]),
|
||||
tagInput: "",
|
||||
notes: host?.notes ?? "",
|
||||
@@ -110,6 +173,7 @@ export function createHostEditorForm(
|
||||
enableDocker: host?.enableDocker ?? false,
|
||||
dockerConfig: host?.dockerConfig ?? { runtime: "docker" as const },
|
||||
enableTmuxMonitor: host?.enableTmuxMonitor ?? false,
|
||||
enableTerminalToolbar: host?.enableTerminalToolbar ?? true,
|
||||
allowSessionSharing: host?.allowSessionSharing ?? true,
|
||||
enableProxmox: host?.enableProxmox ?? false,
|
||||
proxmoxConfig: host?.proxmoxConfig ?? {
|
||||
@@ -122,29 +186,43 @@ export function createHostEditorForm(
|
||||
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 ?? "/",
|
||||
forceKeyboardInteractive: host?.forceKeyboardInteractive ?? false,
|
||||
fontSize: host?.terminalConfig?.fontSize ?? d?.fontSize ?? 14,
|
||||
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:
|
||||
host?.terminalConfig?.fontFamily ??
|
||||
terminalConfig.fontFamily ??
|
||||
d?.fontFamily ??
|
||||
"Caskaydia Cove Nerd Font Mono",
|
||||
theme: normalizedTheme,
|
||||
cursorStyle: (host?.terminalConfig?.cursorStyle ??
|
||||
d?.cursorStyle ??
|
||||
"bar") as "block" | "underline" | "bar",
|
||||
cursorBlink: host?.terminalConfig?.cursorBlink ?? d?.cursorBlink ?? true,
|
||||
scrollback: host?.terminalConfig?.scrollback ?? 10000,
|
||||
letterSpacing: host?.terminalConfig?.letterSpacing ?? 0,
|
||||
lineHeight: host?.terminalConfig?.lineHeight ?? 1.0,
|
||||
bellStyle: (host?.terminalConfig?.bellStyle ?? "none") as
|
||||
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 ?? true,
|
||||
fastScrollModifier: (host?.terminalConfig?.fastScrollModifier ?? "alt") as
|
||||
"alt" | "ctrl" | "shift",
|
||||
fastScrollSensitivity: host?.terminalConfig?.fastScrollSensitivity ?? 5,
|
||||
minimumContrastRatio: host?.terminalConfig?.minimumContrastRatio ?? 1,
|
||||
minimumContrastRatio: terminalConfig.minimumContrastRatio ?? 1,
|
||||
backspaceMode: (host?.terminalConfig?.backspaceMode ?? "normal") as
|
||||
"normal" | "control-h",
|
||||
startupSnippetId: host?.terminalConfig?.startupSnippetId ?? null,
|
||||
@@ -153,13 +231,14 @@ export function createHostEditorForm(
|
||||
autoMosh: host?.terminalConfig?.autoMosh ?? false,
|
||||
autoTmux: host?.terminalConfig?.autoTmux ?? false,
|
||||
sudoPasswordAutoFill: host?.terminalConfig?.sudoPasswordAutoFill ?? false,
|
||||
sudoPassword: host?.terminalConfig?.sudoPassword ?? "",
|
||||
sudoPassword: host?.hasSudoPassword
|
||||
? "existing_sudo_password"
|
||||
: (host?.terminalConfig?.sudoPassword ?? ""),
|
||||
keepaliveInterval: host?.terminalConfig?.keepaliveInterval ?? 60,
|
||||
keepaliveCountMax: host?.terminalConfig?.keepaliveCountMax ?? 5,
|
||||
backgroundImage: host?.terminalConfig?.backgroundImage ?? "",
|
||||
backgroundImageOpacity:
|
||||
host?.terminalConfig?.backgroundImageOpacity ?? 0.15,
|
||||
customThemeColors: host?.terminalConfig?.customThemeColors ?? null,
|
||||
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",
|
||||
@@ -215,7 +294,7 @@ export function createHostEditorForm(
|
||||
telnetAuthType: (host?.telnetAuthType ??
|
||||
(host?.telnetCredentialId ? "credential" : "direct")) as
|
||||
"direct" | "credential",
|
||||
guacamoleConfig: host?.guacamoleConfig ?? {},
|
||||
guacamoleConfig,
|
||||
statsConfig: host?.statsConfig ?? {
|
||||
statusCheckEnabled: d?.statusCheckEnabled ?? true,
|
||||
statusCheckInterval: 60,
|
||||
@@ -236,6 +315,8 @@ export function createHostEditorForm(
|
||||
"firewall",
|
||||
"temperature",
|
||||
],
|
||||
excludedMounts: [] as string[],
|
||||
monitoredMounts: [] as Array<{ path: string; label?: string }>,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -284,6 +365,54 @@ 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,
|
||||
};
|
||||
const terminalOverrides = form.inheritTerminalAppearance
|
||||
? stripKeys(terminalConfig, terminalAppearanceKeys)
|
||||
: terminalConfig;
|
||||
|
||||
return {
|
||||
connectionType: protocols.enableSsh
|
||||
@@ -304,13 +433,18 @@ export function buildHostEditorPayload(
|
||||
: Number(form.telnetPort),
|
||||
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 ? form.password || null : null,
|
||||
usesPassword || usesKey || usesCredential
|
||||
? form.password === "existing_password"
|
||||
? undefined
|
||||
: form.password || null
|
||||
: null,
|
||||
key: usesKey
|
||||
? form.key === "existing_key"
|
||||
? undefined
|
||||
@@ -324,7 +458,8 @@ export function buildHostEditorPayload(
|
||||
keyType: usesKey && form.keyType !== "auto" ? form.keyType : null,
|
||||
credentialId:
|
||||
usesCredential && form.credentialId ? Number(form.credentialId) : null,
|
||||
vaultProfileId: form.vaultProfileId ? Number(form.vaultProfileId) : null,
|
||||
vaultProfileId:
|
||||
usesVault && form.vaultProfileId ? Number(form.vaultProfileId) : null,
|
||||
overrideCredentialUsername: form.overrideCredentialUsername,
|
||||
notes: form.notes,
|
||||
macAddress: form.macAddress || null,
|
||||
@@ -338,9 +473,17 @@ export function buildHostEditorPayload(
|
||||
enableDocker: form.enableDocker,
|
||||
dockerConfig: form.enableDocker ? form.dockerConfig : null,
|
||||
enableTmuxMonitor: form.enableTmuxMonitor,
|
||||
enableTerminalToolbar: form.enableTerminalToolbar,
|
||||
allowSessionSharing: form.allowSessionSharing,
|
||||
enableProxmox: form.enableProxmox,
|
||||
proxmoxConfig: form.enableProxmox ? form.proxmoxConfig : null,
|
||||
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:
|
||||
@@ -417,7 +560,9 @@ export function buildHostEditorPayload(
|
||||
form.telnetPassword !== "existing_telnet_password"
|
||||
? form.telnetPassword || null
|
||||
: null,
|
||||
jumpHosts: form.jumpHosts,
|
||||
// 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) => ({
|
||||
@@ -427,49 +572,9 @@ export function buildHostEditorPayload(
|
||||
statsConfig: form.statsConfig,
|
||||
guacamoleConfig:
|
||||
(protocols.enableRdp || protocols.enableVnc || protocols.enableTelnet) &&
|
||||
Object.keys(form.guacamoleConfig).length > 0
|
||||
? form.guacamoleConfig
|
||||
Object.keys(guacamoleConfig).length > 0
|
||||
? guacamoleConfig
|
||||
: null,
|
||||
terminalConfig: protocols.enableSsh
|
||||
? {
|
||||
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,
|
||||
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 || 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,
|
||||
agentSocketPath: usesAgent ? form.agentSocketPath || null : null,
|
||||
}
|
||||
: null,
|
||||
terminalConfig: protocols.enableSsh ? terminalOverrides : null,
|
||||
};
|
||||
}
|
||||
@@ -313,6 +313,55 @@ export function HostProxmoxTab({
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from "react";
|
||||
import type { ProxyNode } from "@/types/index";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/button";
|
||||
import { Input } from "@/components/input";
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { FolderPathPicker } from "./FolderPathPicker";
|
||||
import { HostParentPicker } from "./HostParentPicker";
|
||||
import { getSSHFolders, isElectron } from "@/main-axios";
|
||||
import type { HostEditorForm, HostProtocols } from "./HostEditorData";
|
||||
|
||||
@@ -31,6 +33,7 @@ export function HostEditorGeneralTab({
|
||||
handleProtocolToggle,
|
||||
hosts,
|
||||
host,
|
||||
simpleMode = false,
|
||||
}: {
|
||||
form: HostEditorForm;
|
||||
setField: HostEditorSetField;
|
||||
@@ -38,9 +41,18 @@ export function HostEditorGeneralTab({
|
||||
handleProtocolToggle: (proto: keyof HostProtocols, value: boolean) => void;
|
||||
hosts: Host[];
|
||||
host: Host | null;
|
||||
/** Hides organizational/advanced fields; their values still save unchanged. */
|
||||
simpleMode?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Tracks which picker is shown, independent of whether a value is set yet
|
||||
// -- switching to "parent host" mode with nothing picked shouldn't bounce
|
||||
// back to the folder picker just because parentHostId is still empty.
|
||||
const [placementMode, setPlacementMode] = React.useState<
|
||||
"folder" | "parentHost"
|
||||
>(form.parentHostId ? "parentHost" : "folder");
|
||||
|
||||
const [folderMeta, setFolderMeta] = React.useState<
|
||||
Map<string, { color?: string; icon?: string }>
|
||||
>(new Map());
|
||||
@@ -187,7 +199,7 @@ export function HostEditorGeneralTab({
|
||||
onChange={(e) => setField("name", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{protocols.enableSsh && (
|
||||
{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">
|
||||
@@ -209,7 +221,7 @@ export function HostEditorGeneralTab({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{protocols.enableSsh && form.macAddress && (
|
||||
{protocols.enableSsh && !simpleMode && form.macAddress && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.wolBroadcastAddress")}
|
||||
@@ -243,18 +255,49 @@ export function HostEditorGeneralTab({
|
||||
<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">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.folder")}
|
||||
</label>
|
||||
<FolderPathPicker
|
||||
value={form.folder}
|
||||
onChange={(path) => setField("folder", path)}
|
||||
folderPaths={folderPaths}
|
||||
folderMeta={folderMeta}
|
||||
/>
|
||||
<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>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
@@ -599,7 +642,10 @@ export function HostEditorGeneralTab({
|
||||
value={node.type}
|
||||
onChange={(e) => {
|
||||
const u = [...form.socks5ProxyChain];
|
||||
u[ni] = { ...u[ni], type: e.target.value };
|
||||
u[ni] = {
|
||||
...u[ni],
|
||||
type: e.target.value as ProxyNode["type"],
|
||||
};
|
||||
setField("socks5ProxyChain", u);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -678,6 +678,7 @@ export function HostEditorRdpTab({
|
||||
<option>de-de-qwertz</option>
|
||||
<option>de-ch-qwertz</option>
|
||||
<option>fr-fr-azerty</option>
|
||||
<option>fr-be-azerty</option>
|
||||
<option>it-it-qwerty</option>
|
||||
<option>sv-se-qwerty</option>
|
||||
<option>ja-jp-qwerty</option>
|
||||
@@ -1315,6 +1316,7 @@ export function HostEditorVncTab({
|
||||
<option>de-de-qwertz</option>
|
||||
<option>de-ch-qwertz</option>
|
||||
<option>fr-fr-azerty</option>
|
||||
<option>fr-be-azerty</option>
|
||||
<option>it-it-qwerty</option>
|
||||
<option>sv-se-qwerty</option>
|
||||
<option>ja-jp-qwerty</option>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Activity,
|
||||
HardDrive,
|
||||
LayoutDashboard,
|
||||
Plus,
|
||||
Server,
|
||||
@@ -28,6 +30,38 @@ export function HostStatsTab({
|
||||
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 (
|
||||
<>
|
||||
@@ -143,6 +177,137 @@ export function HostStatsTab({
|
||||
)}
|
||||
</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" />}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getErrorMessage } from "../lib/error-message.js";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Download, Search } from "lucide-react";
|
||||
@@ -12,8 +13,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/dialog";
|
||||
import { exportAllSSHHosts } from "@/main-axios";
|
||||
import type { SSHHostWithStatus } from "@/main-axios";
|
||||
import { exportAllSSHHosts, type SSHHostWithStatus } from "@/main-axios";
|
||||
import { isFolder } from "@/sidebar/SidebarTree";
|
||||
import {
|
||||
buildExportPayload,
|
||||
@@ -126,7 +126,7 @@ export function HostExportDialog({
|
||||
.catch((err: unknown) => {
|
||||
if (cancelled) return;
|
||||
setRaw(null);
|
||||
const message = err instanceof Error ? err.message : "";
|
||||
const message = getErrorMessage(err, "");
|
||||
toast.error(message || t("hosts.export.fetchFailed"));
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -326,7 +326,12 @@ export function HostExportDialog({
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
{t("hosts.export.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleExport} disabled={!canExport}>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
onClick={handleExport}
|
||||
disabled={!canExport}
|
||||
>
|
||||
<Download className="size-3.5 mr-2" />
|
||||
{t("hosts.export.confirm")}
|
||||
</Button>
|
||||
|
||||
+267
-102
@@ -8,26 +8,31 @@ import React, {
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/button";
|
||||
import { ArrowLeft, Search, X } from "lucide-react";
|
||||
import { ArrowLeft, ChevronDown, Search, X } from "lucide-react";
|
||||
import { useUiPreference } from "@/contexts/UiPreferencesContext";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
getSSHHosts,
|
||||
getCredentials,
|
||||
getCredentialDetails,
|
||||
deleteCredential,
|
||||
updateCredential,
|
||||
deployCredentialToHost,
|
||||
renameCredentialFolder,
|
||||
getLinkedCredentialIds,
|
||||
} from "@/main-axios";
|
||||
|
||||
import type { Host, Credential } from "@/types/ui-types";
|
||||
import type { SSHHostWithStatus } from "@/main-axios";
|
||||
import type {
|
||||
CredentialSidebarFilterState,
|
||||
CredentialSortKey,
|
||||
} from "@/types/credential-sidebar-preferences";
|
||||
import { CredentialEditorView } from "./CredentialEditorView";
|
||||
import { HostEditor } from "./HostEditor";
|
||||
import { mapCredentials, sshHostToHost } from "./HostManagerData";
|
||||
import { HostCredentialList } from "./HostCredentialList";
|
||||
import type {
|
||||
CredentialFilterState,
|
||||
CredentialSortKey,
|
||||
} from "./CredentialsPanel";
|
||||
import { CredentialSidebarTree } from "./credential-tree";
|
||||
import { sortCredentials, credentialPassesFilters } from "./credential-sort";
|
||||
import {
|
||||
makeCredentialTabs,
|
||||
makeHostTabs,
|
||||
@@ -36,43 +41,6 @@ import {
|
||||
TabStrip,
|
||||
} from "./HostManagerTabs";
|
||||
|
||||
function sortCredentials(
|
||||
creds: Credential[],
|
||||
key: CredentialSortKey,
|
||||
): Credential[] {
|
||||
if (key === "default") return creds;
|
||||
const sorted = [...creds];
|
||||
switch (key) {
|
||||
case "name-asc":
|
||||
sorted.sort((a, b) => a.name.localeCompare(b.name));
|
||||
break;
|
||||
case "name-desc":
|
||||
sorted.sort((a, b) => b.name.localeCompare(a.name));
|
||||
break;
|
||||
case "username-asc":
|
||||
sorted.sort((a, b) => a.username.localeCompare(b.username));
|
||||
break;
|
||||
case "username-desc":
|
||||
sorted.sort((a, b) => b.username.localeCompare(a.username));
|
||||
break;
|
||||
}
|
||||
return sorted;
|
||||
}
|
||||
|
||||
function credentialPassesFilters(
|
||||
cred: Credential,
|
||||
filters: CredentialFilterState,
|
||||
): boolean {
|
||||
if (filters.type.length > 0 && !filters.type.includes(cred.type))
|
||||
return false;
|
||||
if (
|
||||
filters.tags.length > 0 &&
|
||||
!filters.tags.some((tag) => cred.tags?.includes(tag))
|
||||
)
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function HostManager({
|
||||
pendingEditId,
|
||||
pendingAction,
|
||||
@@ -80,7 +48,11 @@ export function HostManager({
|
||||
hideListHeader,
|
||||
externalSearch,
|
||||
externalSort,
|
||||
externalArrangeLocked = true,
|
||||
externalFilter,
|
||||
density = "comfortable",
|
||||
trayTrigger = "hover",
|
||||
showTags = true,
|
||||
onTagsChange,
|
||||
active = true,
|
||||
}: {
|
||||
@@ -90,7 +62,11 @@ export function HostManager({
|
||||
hideListHeader?: boolean;
|
||||
externalSearch?: string;
|
||||
externalSort?: CredentialSortKey;
|
||||
externalFilter?: CredentialFilterState;
|
||||
externalArrangeLocked?: boolean;
|
||||
externalFilter?: CredentialSidebarFilterState;
|
||||
density?: "comfortable" | "compact";
|
||||
trayTrigger?: "always" | "hover" | "click" | "actionsOnly";
|
||||
showTags?: boolean;
|
||||
onTagsChange?: (tags: string[]) => void;
|
||||
active?: boolean;
|
||||
} = {}) {
|
||||
@@ -115,12 +91,18 @@ export function HostManager({
|
||||
message: string;
|
||||
onConfirm: () => void;
|
||||
} | 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 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.
|
||||
const [showAdvancedEditor, setShowAdvancedEditor] = useState(false);
|
||||
const hostsRef = useRef<Host[]>([]);
|
||||
useEffect(() => {
|
||||
hostsRef.current = hosts;
|
||||
@@ -280,9 +262,22 @@ export function HostManager({
|
||||
externalSort ?? "default",
|
||||
);
|
||||
|
||||
const credentialFolders = Array.from(
|
||||
const credentialFolderNames = Array.from(
|
||||
new Set(filteredCredentials.map((c) => c.folder || "Uncategorized")),
|
||||
).sort();
|
||||
const credentialFolderTree = credentialFolderNames.map((name) => ({
|
||||
name,
|
||||
children: filteredCredentials.filter(
|
||||
(c) => (c.folder || "Uncategorized") === name,
|
||||
),
|
||||
}));
|
||||
const usedByCounts = new Map<string, number>();
|
||||
for (const cred of filteredCredentials) {
|
||||
usedByCounts.set(
|
||||
cred.id,
|
||||
allHosts.filter((h) => h.credentialId === cred.id).length,
|
||||
);
|
||||
}
|
||||
|
||||
const handleRenameCredentialFolder = async (
|
||||
folder: string,
|
||||
@@ -292,9 +287,28 @@ export function HostManager({
|
||||
await renameCredentialFolder(folder, newName);
|
||||
const res = await getCredentials();
|
||||
setCredentials(mapCredentials(res));
|
||||
toast.success(t("hosts.folderRenamedTo", { name: newName }));
|
||||
toast.success(t("credentials.folderRenamedTo", { name: newName }));
|
||||
} catch {
|
||||
toast.error(t("hosts.failedToRenameFolder"));
|
||||
toast.error(t("credentials.failedToRenameFolder"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleMoveCredentialToFolder = async (
|
||||
credentialId: string,
|
||||
targetFolder: string,
|
||||
) => {
|
||||
const cred = credentials.find((c) => c.id === credentialId);
|
||||
if (!cred || (cred.folder || "Uncategorized") === targetFolder) return;
|
||||
const folderValue = targetFolder === "Uncategorized" ? "" : targetFolder;
|
||||
try {
|
||||
await updateCredential(Number(credentialId), { folder: folderValue });
|
||||
setCredentials((prev) =>
|
||||
prev.map((c) =>
|
||||
c.id === credentialId ? { ...c, folder: folderValue } : c,
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
toast.error(t("credentials.failedToMoveCredential"));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -303,23 +317,100 @@ export function HostManager({
|
||||
setCredentials((prev) => prev.filter((c) => c.id !== cred.id));
|
||||
};
|
||||
|
||||
function handleConfirmDeleteCredential(cred: Credential) {
|
||||
setConfirmDialog({
|
||||
message: t("credentials.deleteCredentialConfirm", { name: cred.name }),
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await handleDeleteCredential(cred);
|
||||
toast.success(
|
||||
t("credentials.deletedCredential", { name: cred.name }),
|
||||
);
|
||||
} catch {
|
||||
toast.error(t("credentials.failedToDeleteCredential"));
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function handleEditCredential(cred: Credential) {
|
||||
try {
|
||||
const full = (await getCredentialDetails(Number(cred.id))) as {
|
||||
hasKey?: boolean;
|
||||
hasKeyPassword?: boolean;
|
||||
password?: string;
|
||||
certPublicKey?: string;
|
||||
};
|
||||
setEditingCredential({
|
||||
...cred,
|
||||
value: full.hasKey ? "existing_key" : (full.password ?? ""),
|
||||
password: full.password ?? "",
|
||||
passphrase: full.hasKeyPassword ? "existing_key_password" : "",
|
||||
publicKey: full.certPublicKey ?? cred.publicKey,
|
||||
});
|
||||
setActiveCredentialTab("general");
|
||||
} catch {
|
||||
setEditingCredential(cred);
|
||||
setActiveCredentialTab("general");
|
||||
}
|
||||
}
|
||||
|
||||
// Editor view: full-width with top tab bar instead of side nav
|
||||
const closeHostEditor = () => {
|
||||
setHostEditorDirty(false);
|
||||
setShowUnsavedHostDialog(false);
|
||||
setEditingHost(null);
|
||||
setActiveHostTab("general");
|
||||
};
|
||||
|
||||
const requestCloseHostEditor = () => {
|
||||
if (hostEditorDirty) {
|
||||
setShowUnsavedHostDialog(true);
|
||||
return;
|
||||
}
|
||||
closeHostEditor();
|
||||
};
|
||||
|
||||
const renderEditorView = () => {
|
||||
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
|
||||
// other protocol tabs plus the seven SSH sub-tabs. Nothing is lost:
|
||||
// createHostEditorForm still materializes every field with its default and
|
||||
// buildHostEditorPayload still serializes all of them, so hiding a tab
|
||||
// hides its inputs, not its values.
|
||||
const collapseAdvanced = isHost && simpleEditor && !showAdvancedEditor;
|
||||
const tabs = isHost
|
||||
? makeHostTabs(t).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;
|
||||
})
|
||||
: makeCredentialTabs(t);
|
||||
const activeTab = isHost ? activeHostTab : activeCredentialTab;
|
||||
// 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
|
||||
// sub-tabs live in the secondary strip, so they count as visible whenever
|
||||
// the SSH group is expanded.
|
||||
const hostTabVisible =
|
||||
tabs.some((tab) => tab.id === activeHostTab) ||
|
||||
(!collapseAdvanced &&
|
||||
editingProtocols.enableSsh &&
|
||||
SSH_GROUP_TABS.has(activeHostTab as never));
|
||||
const effectiveHostTab = hostTabVisible
|
||||
? activeHostTab
|
||||
: collapseAdvanced && SSH_GROUP_TABS.has(activeHostTab as never)
|
||||
? "ssh"
|
||||
: "general";
|
||||
const activeTab = isHost ? effectiveHostTab : activeCredentialTab;
|
||||
const setActiveTab = isHost ? setActiveHostTab : setActiveCredentialTab;
|
||||
const showSshSubTabs =
|
||||
isHost &&
|
||||
!collapseAdvanced &&
|
||||
editingProtocols.enableSsh &&
|
||||
SSH_GROUP_TABS.has(activeHostTab as never);
|
||||
const sshSubTabs = makeHostSshSubTabs(t);
|
||||
@@ -331,8 +422,7 @@ export function HostManager({
|
||||
<button
|
||||
onClick={() => {
|
||||
if (isHost) {
|
||||
setEditingHost(null);
|
||||
setActiveHostTab("general");
|
||||
requestCloseHostEditor();
|
||||
} else {
|
||||
setEditingCredential(null);
|
||||
setActiveCredentialTab("general");
|
||||
@@ -342,7 +432,9 @@ export function HostManager({
|
||||
>
|
||||
<ArrowLeft className="size-3.5 shrink-0" />
|
||||
<span>
|
||||
{isHost ? t("hosts.backToHosts") : t("hosts.backToCredentials")}
|
||||
{isHost
|
||||
? t("hosts.backToHosts")
|
||||
: t("credentials.backToCredentials")}
|
||||
</span>
|
||||
{isHost && editingHost !== "new" && (
|
||||
<span
|
||||
@@ -382,6 +474,22 @@ export function HostManager({
|
||||
variant="secondary"
|
||||
/>
|
||||
)}
|
||||
{isHost && simpleEditor && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (showAdvancedEditor) setActiveHostTab("general");
|
||||
setShowAdvancedEditor(!showAdvancedEditor);
|
||||
}}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-[11px] text-muted-foreground hover:text-foreground hover:bg-muted/40 transition-colors border-t border-border/50"
|
||||
>
|
||||
<ChevronDown
|
||||
className={`size-3 transition-transform ${showAdvancedEditor ? "rotate-180" : ""}`}
|
||||
/>
|
||||
{showAdvancedEditor
|
||||
? t("hosts.hideAdvancedSettings")
|
||||
: t("hosts.showAdvancedSettings")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-3 py-3 flex flex-col gap-3">
|
||||
{isHost ? (
|
||||
@@ -390,13 +498,13 @@ export function HostManager({
|
||||
editingHost === "new" ? "new-host" : (editingHost as Host).id
|
||||
}
|
||||
host={editingHost === "new" ? null : (editingHost as Host)}
|
||||
activeTab={activeHostTab}
|
||||
onBack={() => {
|
||||
setEditingHost(null);
|
||||
setActiveHostTab("general");
|
||||
}}
|
||||
activeTab={effectiveHostTab}
|
||||
simpleMode={collapseAdvanced}
|
||||
onBack={requestCloseHostEditor}
|
||||
onSave={(saved) => {
|
||||
const updated = sshHostToHost(saved);
|
||||
const updated = sshHostToHost(
|
||||
saved as unknown as SSHHostWithStatus,
|
||||
);
|
||||
setHosts((prev) => {
|
||||
const idx = prev.findIndex((h) => h.id === updated.id);
|
||||
if (idx >= 0) {
|
||||
@@ -407,13 +515,13 @@ export function HostManager({
|
||||
return [...prev, updated];
|
||||
});
|
||||
window.dispatchEvent(new CustomEvent("termix:hosts-changed"));
|
||||
setEditingHost(null);
|
||||
setActiveHostTab("general");
|
||||
closeHostEditor();
|
||||
}}
|
||||
protocols={editingProtocols}
|
||||
onProtocolChange={(p) =>
|
||||
setEditingProtocols((prev) => ({ ...prev, ...p }))
|
||||
}
|
||||
onDirtyChange={setHostEditorDirty}
|
||||
onTabChange={setActiveHostTab}
|
||||
hosts={hosts}
|
||||
credentials={credentials}
|
||||
@@ -443,20 +551,26 @@ export function HostManager({
|
||||
setActiveCredentialTab("general");
|
||||
}}
|
||||
onSave={(saved) => {
|
||||
// The save endpoints are typed as an untyped record; these are
|
||||
// the fields this view reads back off the response.
|
||||
const result = saved as Partial<Credential> & {
|
||||
id: number | string;
|
||||
authType?: string;
|
||||
};
|
||||
setCredentials((prev) => {
|
||||
const idx = prev.findIndex((c) => c.id === String(saved.id));
|
||||
const idx = prev.findIndex((c) => c.id === String(result.id));
|
||||
const updated: Credential = {
|
||||
id: String(saved.id),
|
||||
name: saved.name,
|
||||
username: saved.username ?? "",
|
||||
type: saved.authType === "key" ? "key" : "password",
|
||||
value: saved.value,
|
||||
password: saved.password,
|
||||
publicKey: saved.publicKey,
|
||||
passphrase: saved.passphrase,
|
||||
description: saved.description,
|
||||
folder: saved.folder ?? "",
|
||||
tags: saved.tags ?? [],
|
||||
id: String(result.id),
|
||||
name: result.name ?? "",
|
||||
username: result.username ?? "",
|
||||
type: result.authType === "key" ? "key" : "password",
|
||||
value: result.value,
|
||||
password: result.password,
|
||||
publicKey: result.publicKey,
|
||||
passphrase: result.passphrase,
|
||||
description: result.description,
|
||||
folder: result.folder ?? "",
|
||||
tags: result.tags ?? [],
|
||||
};
|
||||
if (idx >= 0) {
|
||||
const next = [...prev];
|
||||
@@ -495,7 +609,7 @@ export function HostManager({
|
||||
<input
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={t("hosts.searchCredentialsPlaceholder")}
|
||||
placeholder={t("credentials.searchCredentialsPlaceholder")}
|
||||
className="flex-1 text-xs bg-transparent outline-none placeholder:text-muted-foreground/50 text-foreground min-w-0"
|
||||
/>
|
||||
{searchQuery && (
|
||||
@@ -510,29 +624,47 @@ export function HostManager({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<HostCredentialList
|
||||
credentialFolders={credentialFolders}
|
||||
filteredCredentials={filteredCredentials}
|
||||
credentialsLoading={credentialsLoading}
|
||||
allHosts={allHosts}
|
||||
editingFolderName={editingCredFolderName}
|
||||
editingFolderValue={editingCredFolderValue}
|
||||
termixIdLinkedIds={termixIdLinkedIds}
|
||||
onEditingFolderNameChange={setEditingCredFolderName}
|
||||
onEditingFolderValueChange={setEditingCredFolderValue}
|
||||
onRenameFolder={handleRenameCredentialFolder}
|
||||
onDeployCredential={(cred) => setDeployDialog({ cred, hostId: "" })}
|
||||
onEditCredential={(cred) => {
|
||||
setEditingCredential(cred);
|
||||
setActiveCredentialTab("general");
|
||||
}}
|
||||
onDeleteCredential={handleDeleteCredential}
|
||||
onAddCredential={() => {
|
||||
setEditingCredential("new");
|
||||
setActiveCredentialTab("general");
|
||||
}}
|
||||
onConfirmDialogChange={setConfirmDialog}
|
||||
/>
|
||||
{!credentialsLoading && filteredCredentials.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center px-4">
|
||||
<span className="text-sm font-semibold text-muted-foreground/60">
|
||||
{t("credentials.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={() => {
|
||||
setEditingCredential("new");
|
||||
setActiveCredentialTab("general");
|
||||
}}
|
||||
>
|
||||
{t("credentials.addCredentialBtn")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<CredentialSidebarTree
|
||||
folders={credentialFolderTree}
|
||||
usedByCounts={usedByCounts}
|
||||
termixIdLinkedIds={termixIdLinkedIds}
|
||||
query=""
|
||||
loading={credentialsLoading}
|
||||
arrangeLocked={externalArrangeLocked}
|
||||
density={density}
|
||||
trayTrigger={trayTrigger}
|
||||
showTags={showTags}
|
||||
editingFolderName={editingCredFolderName}
|
||||
editingFolderValue={editingCredFolderValue}
|
||||
onEditingFolderNameChange={setEditingCredFolderName}
|
||||
onEditingFolderValueChange={setEditingCredFolderValue}
|
||||
onRenameFolder={handleRenameCredentialFolder}
|
||||
onMoveCredentialToFolder={handleMoveCredentialToFolder}
|
||||
onDeployCredential={(cred) =>
|
||||
setDeployDialog({ cred, hostId: "" })
|
||||
}
|
||||
onEditCredential={handleEditCredential}
|
||||
onDeleteCredential={handleConfirmDeleteCredential}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -562,13 +694,42 @@ export function HostManager({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showUnsavedHostDialog && (
|
||||
<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">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm font-semibold text-foreground">
|
||||
{t("common.unsavedChanges")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("hosts.unsavedChangesDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => setShowUnsavedHostDialog(false)}
|
||||
className="px-3 py-1.5 text-xs border border-border text-muted-foreground hover:text-foreground hover:bg-muted rounded transition-colors"
|
||||
>
|
||||
{t("hosts.keepEditing")}
|
||||
</button>
|
||||
<button
|
||||
onClick={closeHostEditor}
|
||||
className="px-3 py-1.5 text-xs bg-destructive text-destructive-foreground hover:bg-destructive/90 rounded transition-colors"
|
||||
>
|
||||
{t("hosts.discardChanges")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Deploy credential dialog */}
|
||||
{deployDialog && (
|
||||
<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-sm flex flex-col gap-4 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-bold">
|
||||
{t("hosts.deploySSHKeyTitle")}
|
||||
{t("credentials.deployDialogTitle")}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setDeployDialog(null)}
|
||||
@@ -578,11 +739,13 @@ export function HostManager({
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("hosts.deployDialogDesc", { name: deployDialog.cred.name })}
|
||||
{t("credentials.deployDialogDesc", {
|
||||
name: deployDialog.cred.name,
|
||||
})}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.targetHostLabel")}
|
||||
{t("credentials.targetHostLabel")}
|
||||
</label>
|
||||
<select
|
||||
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
@@ -591,7 +754,7 @@ export function HostManager({
|
||||
setDeployDialog({ ...deployDialog, hostId: e.target.value })
|
||||
}
|
||||
>
|
||||
<option value="">{t("hosts.selectHostOption")}</option>
|
||||
<option value="">{t("credentials.selectHostOption")}</option>
|
||||
{allHosts
|
||||
.filter(
|
||||
(h) =>
|
||||
@@ -626,16 +789,18 @@ export function HostManager({
|
||||
Number(deployDialog.cred.id),
|
||||
Number(deployDialog.hostId),
|
||||
);
|
||||
toast.success(t("hosts.keyDeployedSuccess"));
|
||||
toast.success(t("credentials.keyDeployedSuccess"));
|
||||
setDeployDialog(null);
|
||||
} catch {
|
||||
toast.error(t("hosts.failedToDeployKey2"));
|
||||
toast.error(t("credentials.failedToDeployKey"));
|
||||
} finally {
|
||||
setDeploying(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{deploying ? t("hosts.deployingBtn") : t("hosts.deployBtn")}
|
||||
{deploying
|
||||
? t("credentials.deployingBtn")
|
||||
: t("credentials.deployBtn")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,8 +2,10 @@ import type { SSHHostWithStatus } from "@/main-axios";
|
||||
import type { Host, Credential } from "@/types/ui-types";
|
||||
|
||||
type RawSSHHost = SSHHostWithStatus & {
|
||||
hasPassword?: boolean;
|
||||
hasKey?: boolean;
|
||||
hasKeyPassword?: boolean;
|
||||
hasSudoPassword?: boolean;
|
||||
hasRdpPassword?: boolean;
|
||||
hasVncPassword?: boolean;
|
||||
hasTelnetPassword?: boolean;
|
||||
@@ -19,6 +21,9 @@ type RawCredential = {
|
||||
folder?: string | null;
|
||||
tags?: string[];
|
||||
publicKey?: string | null;
|
||||
pin?: boolean | null;
|
||||
sortOrder?: number | null;
|
||||
certPublicKey?: string | null;
|
||||
};
|
||||
|
||||
function parseJson<T>(v: unknown): T | undefined {
|
||||
@@ -36,6 +41,8 @@ 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,
|
||||
@@ -43,14 +50,21 @@ export function sshHostToHost(h: SSHHostWithStatus): Host {
|
||||
ip: h.ip,
|
||||
port: h.port,
|
||||
folder: h.folder ?? "",
|
||||
parentHostId:
|
||||
(h as { parentHostId?: number | string | null }).parentHostId != null
|
||||
? String((h as { parentHostId?: number | string }).parentHostId)
|
||||
: null,
|
||||
online: h.status === "online",
|
||||
status: h.status,
|
||||
cpu: null,
|
||||
ram: null,
|
||||
lastAccess: "",
|
||||
tags: h.tags ?? [],
|
||||
syncId: h.syncId ?? null,
|
||||
authType: h.authType,
|
||||
shareSshAuth: h.shareSshAuth ?? false,
|
||||
password: h.password,
|
||||
hasPassword: !!host.hasPassword || !!h.password,
|
||||
hasKey: !!host.hasKey || !!(typeof h.key === "string" && h.key),
|
||||
hasKeyPassword: !!host.hasKeyPassword || !!h.keyPassword,
|
||||
key: typeof h.key === "string" ? h.key : undefined,
|
||||
@@ -63,6 +77,7 @@ export function sshHostToHost(h: SSHHostWithStatus): Host {
|
||||
: undefined,
|
||||
notes: h.notes,
|
||||
pin: h.pin ?? false,
|
||||
sortOrder: h.sortOrder ?? null,
|
||||
macAddress: h.macAddress,
|
||||
wolBroadcastAddress: h.wolBroadcastAddress,
|
||||
enableSsh: h.enableSsh != null ? h.enableSsh : isSshHost,
|
||||
@@ -73,9 +88,13 @@ export function sshHostToHost(h: SSHHostWithStatus): Host {
|
||||
enableTunnel: h.enableTunnel ?? false,
|
||||
enableFileManager: h.enableFileManager ?? true,
|
||||
enableDocker: h.enableDocker ?? false,
|
||||
dockerConfig: h.dockerConfig ?? null,
|
||||
enableProxmox: h.enableProxmox ?? false,
|
||||
enableProxmoxStats: h.enableProxmoxStats ?? false,
|
||||
enableTmuxMonitor: h.enableTmuxMonitor ?? false,
|
||||
enableTerminalToolbar: h.enableTerminalToolbar ?? true,
|
||||
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:
|
||||
@@ -113,7 +132,7 @@ export function sshHostToHost(h: SSHHostWithStatus): Host {
|
||||
telnetUser: h.telnetUser,
|
||||
telnetPassword: h.telnetPassword ?? "",
|
||||
hasTelnetPassword: !!host.hasTelnetPassword || !!h.telnetPassword,
|
||||
quickActions: (h.quickActions ?? []).map((a: HostQuickAction) => ({
|
||||
quickActions: (h.quickActions ?? []).map((a) => ({
|
||||
name: a.name,
|
||||
snippetId: String(a.snippetId),
|
||||
})),
|
||||
@@ -123,7 +142,9 @@ export function sshHostToHost(h: SSHHostWithStatus): Host {
|
||||
})),
|
||||
portKnockSequence: parseJson(h.portKnockSequence) ?? [],
|
||||
defaultPath: h.defaultPath,
|
||||
terminalConfig: parseJson(h.terminalConfig) as Host["terminalConfig"],
|
||||
terminalConfig: parsedTerminalConfig as Host["terminalConfig"],
|
||||
hasSudoPassword:
|
||||
!!host.hasSudoPassword || !!parsedTerminalConfig?.sudoPassword,
|
||||
statsConfig: parseJson(h.statsConfig) as Host["statsConfig"],
|
||||
guacamoleConfig: parseJson(h.guacamoleConfig),
|
||||
forceKeyboardInteractive: h.forceKeyboardInteractive ?? false,
|
||||
@@ -168,5 +189,8 @@ export function mapCredentials(res: unknown): Credential[] {
|
||||
folder: c.folder ?? "",
|
||||
tags: c.tags ?? [],
|
||||
publicKey: c.publicKey ?? undefined,
|
||||
pin: c.pin ?? false,
|
||||
sortOrder: c.sortOrder ?? null,
|
||||
certPublicKey: c.certPublicKey ?? undefined,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronsUpDown, Search, Server, X } from "lucide-react";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/popover";
|
||||
import type { Host } from "@/types/ui-types";
|
||||
|
||||
/**
|
||||
* Walks parentHostId to collect every descendant id of `hostId`, so a host
|
||||
* can never be assigned under its own descendant (which would form a cycle).
|
||||
* Mirrors the ancestor-walk cycle guard used server-side and in buildHostTree.
|
||||
*/
|
||||
export function collectDescendantIds(
|
||||
hostId: string,
|
||||
hosts: Host[],
|
||||
): Set<string> {
|
||||
const childrenByParent = new Map<string, string[]>();
|
||||
for (const h of hosts) {
|
||||
if (!h.parentHostId) continue;
|
||||
const list = childrenByParent.get(h.parentHostId) ?? [];
|
||||
list.push(h.id);
|
||||
childrenByParent.set(h.parentHostId, list);
|
||||
}
|
||||
|
||||
const out = new Set<string>();
|
||||
const stack = [...(childrenByParent.get(hostId) ?? [])];
|
||||
while (stack.length > 0) {
|
||||
const id = stack.pop()!;
|
||||
if (out.has(id)) continue;
|
||||
out.add(id);
|
||||
stack.push(...(childrenByParent.get(id) ?? []));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function HostParentPicker({
|
||||
value,
|
||||
onChange,
|
||||
hosts,
|
||||
excludeHostId,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (hostId: string) => void;
|
||||
hosts: Host[];
|
||||
/** The host being edited, if any -- excluded from the list along with its descendants. */
|
||||
excludeHostId?: string | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const excludedIds = useMemo(() => {
|
||||
if (!excludeHostId) return new Set<string>();
|
||||
const descendants = collectDescendantIds(excludeHostId, hosts);
|
||||
descendants.add(excludeHostId);
|
||||
return descendants;
|
||||
}, [excludeHostId, hosts]);
|
||||
|
||||
const candidates = useMemo(
|
||||
() =>
|
||||
hosts
|
||||
.filter((h) => !excludedIds.has(h.id))
|
||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
[hosts, excludedIds],
|
||||
);
|
||||
|
||||
const query = search.trim().toLowerCase();
|
||||
const filtered = query
|
||||
? candidates.filter(
|
||||
(h) =>
|
||||
h.name.toLowerCase().includes(query) ||
|
||||
h.ip.toLowerCase().includes(query),
|
||||
)
|
||||
: candidates;
|
||||
|
||||
const selected = value ? hosts.find((h) => h.id === value) : undefined;
|
||||
|
||||
function commit(hostId: string) {
|
||||
onChange(hostId);
|
||||
setSearch("");
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1.5 h-8 w-full min-w-0 rounded-none border border-input bg-transparent px-2.5 text-xs transition-colors hover:border-ring/60 focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring/50 outline-none"
|
||||
>
|
||||
{selected ? (
|
||||
<span className="flex items-center gap-1.5 min-w-0 flex-1">
|
||||
<Server className="size-3.5 shrink-0 text-muted-foreground/70" />
|
||||
<span className="truncate">{selected.name || selected.ip}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex-1 text-left text-muted-foreground">
|
||||
{t("hosts.parentHostPickerPlaceholder")}
|
||||
</span>
|
||||
)}
|
||||
{value && (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onChange("");
|
||||
}}
|
||||
className="shrink-0 text-muted-foreground/50 hover:text-foreground"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</span>
|
||||
)}
|
||||
<ChevronsUpDown className="size-3 shrink-0 text-muted-foreground/50" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
sideOffset={4}
|
||||
collisionPadding={8}
|
||||
className="w-max min-w-(--radix-popover-trigger-width) max-w-96 max-h-(--radix-popover-content-available-height) p-0 rounded-none border-0 ring-1 ring-border shadow-md flex flex-col overflow-hidden"
|
||||
>
|
||||
<div className="flex items-center gap-2 border-b border-border px-2.5 h-8 shrink-0">
|
||||
<Search className="size-3 shrink-0 text-muted-foreground/60" />
|
||||
<input
|
||||
autoFocus
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && filtered.length > 0) {
|
||||
e.preventDefault();
|
||||
commit(filtered[0].id);
|
||||
}
|
||||
}}
|
||||
placeholder={t("hosts.parentHostPickerSearch")}
|
||||
className="flex-1 text-xs bg-transparent outline-none placeholder:text-muted-foreground/50 text-foreground min-w-0"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto py-1">
|
||||
{value && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => commit("")}
|
||||
className="flex items-center gap-2 w-full px-2.5 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors"
|
||||
>
|
||||
<X className="size-3.5 shrink-0" />
|
||||
{t("hosts.parentHostPickerNone")}
|
||||
</button>
|
||||
)}
|
||||
{filtered.map((h) => (
|
||||
<button
|
||||
key={h.id}
|
||||
type="button"
|
||||
onClick={() => commit(h.id)}
|
||||
className={`flex items-center gap-2 w-full px-2.5 py-1.5 text-xs hover:bg-accent hover:text-accent-foreground transition-colors ${
|
||||
h.id === value
|
||||
? "bg-accent/60 text-foreground"
|
||||
: "text-foreground/80"
|
||||
}`}
|
||||
>
|
||||
<Server className="size-3.5 shrink-0 text-muted-foreground/70" />
|
||||
<span className="truncate flex-1 min-w-0">{h.name || h.ip}</span>
|
||||
<span className="text-[10px] text-muted-foreground/50 truncate shrink-0">
|
||||
{h.ip}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<div className="py-4 text-center text-xs text-muted-foreground">
|
||||
{t("hosts.parentHostPickerEmpty")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -98,7 +98,9 @@ export function HostShareModal({
|
||||
setSharingLoaded(true);
|
||||
Promise.all([
|
||||
host
|
||||
? getHostAccess(Number(host.id)).catch(() => ({ accessList: [] }))
|
||||
? getHostAccess(Number(host.id), host.syncId).catch(() => ({
|
||||
accessList: [],
|
||||
}))
|
||||
: Promise.resolve({ accessList: [] }),
|
||||
getUserList().catch(() => ({ users: [] })),
|
||||
getRoles().catch(() => ({ roles: [] })),
|
||||
@@ -171,7 +173,7 @@ export function HostShareModal({
|
||||
|
||||
async function refreshAccessList() {
|
||||
if (!host) return;
|
||||
const res = await getHostAccess(Number(host.id));
|
||||
const res = await getHostAccess(Number(host.id), host.syncId);
|
||||
setAccessList(res.accessList ?? []);
|
||||
}
|
||||
|
||||
@@ -206,11 +208,15 @@ export function HostShareModal({
|
||||
}),
|
||||
);
|
||||
} else if (host) {
|
||||
await shareHost(Number(host.id), {
|
||||
targets,
|
||||
permissionLevel,
|
||||
...(durationHours ? { durationHours } : {}),
|
||||
});
|
||||
await shareHost(
|
||||
Number(host.id),
|
||||
{
|
||||
targets,
|
||||
permissionLevel,
|
||||
...(durationHours ? { durationHours } : {}),
|
||||
},
|
||||
host.syncId,
|
||||
);
|
||||
await refreshAccessList();
|
||||
setSelectedUserIds(new Set());
|
||||
setSelectedRoleIds(new Set());
|
||||
@@ -233,9 +239,14 @@ export function HostShareModal({
|
||||
) {
|
||||
if (!host || record.permissionLevel === level) return;
|
||||
try {
|
||||
await updateHostAccess(Number(host.id), record.id, {
|
||||
permissionLevel: level,
|
||||
});
|
||||
await updateHostAccess(
|
||||
Number(host.id),
|
||||
record.id,
|
||||
{
|
||||
permissionLevel: level,
|
||||
},
|
||||
host.syncId,
|
||||
);
|
||||
setAccessList((prev) =>
|
||||
prev.map((entry) =>
|
||||
entry.id === record.id ? { ...entry, permissionLevel: level } : entry,
|
||||
@@ -560,7 +571,11 @@ export function HostShareModal({
|
||||
className="h-6 text-[10px] px-2 text-destructive hover:bg-destructive/10"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await revokeHostAccess(Number(host!.id), record.id);
|
||||
await revokeHostAccess(
|
||||
Number(host!.id),
|
||||
record.id,
|
||||
host!.syncId,
|
||||
);
|
||||
setAccessList((prev) =>
|
||||
prev.filter((entry) => entry.id !== record.id),
|
||||
);
|
||||
|
||||
+540
-484
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,227 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Check,
|
||||
Gauge,
|
||||
Layers,
|
||||
RotateCcw,
|
||||
Sparkles,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/dialog";
|
||||
import { useUiPreferencesContext } from "@/contexts/UiPreferencesContext";
|
||||
import { applyPresetSideEffects } from "@/lib/apply-ui-preset";
|
||||
import {
|
||||
UI_AREA_KEYS,
|
||||
type UiAreaKey,
|
||||
type UiPreset,
|
||||
} from "@/types/ui-preferences";
|
||||
|
||||
const SELECTABLE_PRESETS: Exclude<UiPreset, "custom">[] = [
|
||||
"simple",
|
||||
"balanced",
|
||||
"advanced",
|
||||
];
|
||||
|
||||
const PRESET_ICONS = {
|
||||
simple: Sparkles,
|
||||
balanced: Gauge,
|
||||
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,
|
||||
}: {
|
||||
onRunSetupAgain?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const ctx = useUiPreferencesContext();
|
||||
const [pendingPreset, setPendingPreset] = useState<Exclude<
|
||||
UiPreset,
|
||||
"custom"
|
||||
> | null>(null);
|
||||
const [applying, setApplying] = useState(false);
|
||||
|
||||
// The chosen preset stays highlighted even once knobs diverge from it --
|
||||
// the customizations are listed separately below.
|
||||
const activeLabel = ctx?.preferences.preset ?? "balanced";
|
||||
|
||||
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 null;
|
||||
|
||||
async function confirmPreset() {
|
||||
if (!pendingPreset) return;
|
||||
setApplying(true);
|
||||
try {
|
||||
ctx.setPreset(pendingPreset);
|
||||
// Picking a preset is a deliberate reset, so any knob the user had
|
||||
// pinned goes back to following the preset.
|
||||
ctx.clearAllOverrides();
|
||||
await applyPresetSideEffects(pendingPreset);
|
||||
} finally {
|
||||
setApplying(false);
|
||||
setPendingPreset(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{t("newUi.sidebar.userProfile.interfacePresetDesc")}
|
||||
</p>
|
||||
|
||||
{/* Stacked rather than a grid: this panel lives in the sidebar, which
|
||||
can be as narrow as 160px, so side-by-side cards would wrap. */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{SELECTABLE_PRESETS.map((preset) => {
|
||||
const Icon = PRESET_ICONS[preset];
|
||||
const active = activeLabel === preset;
|
||||
return (
|
||||
<button
|
||||
key={preset}
|
||||
type="button"
|
||||
onClick={() => setPendingPreset(preset)}
|
||||
aria-pressed={active}
|
||||
className={`flex w-full items-start gap-2 border p-2 text-left transition-colors ${
|
||||
active
|
||||
? "border-accent-brand bg-accent-brand/10"
|
||||
: "border-border bg-card hover:bg-muted/40"
|
||||
}`}
|
||||
>
|
||||
<Icon
|
||||
size={13}
|
||||
className={`mt-0.5 shrink-0 ${
|
||||
active ? "text-accent-brand" : "text-muted-foreground"
|
||||
}`}
|
||||
/>
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span className="text-xs font-semibold">
|
||||
{t(`newUi.sidebar.userProfile.preset_${preset}`)}
|
||||
</span>
|
||||
<span className="text-[10px] leading-snug text-muted-foreground">
|
||||
{t(`newUi.sidebar.userProfile.preset_${preset}_desc`)}
|
||||
</span>
|
||||
</span>
|
||||
{active && (
|
||||
<Check
|
||||
size={12}
|
||||
className="mt-0.5 shrink-0 text-accent-brand"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{customizedAreas.length > 0 && (
|
||||
<div className="flex flex-col gap-1 border border-border bg-card p-2">
|
||||
<span className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
<Layers size={11} className="shrink-0" />
|
||||
<span className="min-w-0 truncate">
|
||||
{t("newUi.sidebar.userProfile.presetOverridesTitle")}
|
||||
</span>
|
||||
</span>
|
||||
{customizedAreas.map((area) => (
|
||||
<div
|
||||
key={area}
|
||||
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}`)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => ctx.clearArea(area)}
|
||||
className="shrink-0 text-[10px] text-muted-foreground underline underline-offset-2 hover:text-foreground"
|
||||
>
|
||||
{t("newUi.sidebar.userProfile.presetRevertArea")}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-1 h-7 w-full text-[10px]"
|
||||
onClick={() => ctx.clearAllOverrides()}
|
||||
>
|
||||
<RotateCcw size={11} />
|
||||
{t("newUi.sidebar.userProfile.presetClearOverrides")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{onRunSetupAgain && (
|
||||
<div className="flex flex-col gap-1.5 border-t border-border pt-3">
|
||||
<span className="text-[10px] leading-snug text-muted-foreground">
|
||||
{t("newUi.sidebar.userProfile.runSetupAgainDesc")}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 w-full text-[10px]"
|
||||
onClick={onRunSetupAgain}
|
||||
>
|
||||
{t("newUi.sidebar.userProfile.runSetupAgainButton")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog
|
||||
open={!!pendingPreset}
|
||||
onOpenChange={(open) => !open && setPendingPreset(null)}
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base font-bold">
|
||||
{t("newUi.sidebar.userProfile.presetConfirmTitle", {
|
||||
preset: pendingPreset
|
||||
? t(`newUi.sidebar.userProfile.preset_${pendingPreset}`)
|
||||
: "",
|
||||
})}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs text-muted-foreground">
|
||||
{t("newUi.sidebar.userProfile.presetConfirmDesc")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPendingPreset(null)}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
disabled={applying}
|
||||
onClick={confirmPreset}
|
||||
>
|
||||
{t("newUi.sidebar.userProfile.presetConfirmApply")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,856 @@
|
||||
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,93 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PanelRight, SquareArrowOutUpRight, X } from "lucide-react";
|
||||
|
||||
const STORAGE_KEY = "termix_multiPanelHintDismissed";
|
||||
|
||||
function multiPanelHintDismissed(): boolean {
|
||||
return localStorage.getItem(STORAGE_KEY) === "true";
|
||||
}
|
||||
|
||||
/**
|
||||
* One-time nudge telling users a panel can leave the sidebar. Both actions are
|
||||
* otherwise only reachable through modifier clicks and the rail context menu,
|
||||
* which nobody finds on their own.
|
||||
*/
|
||||
export function MultiPanelHint({
|
||||
canPromote,
|
||||
canRightDock,
|
||||
onOpenAsTab,
|
||||
onOpenInRightDock,
|
||||
}: {
|
||||
canPromote: boolean;
|
||||
canRightDock: boolean;
|
||||
onOpenAsTab: () => void;
|
||||
onOpenInRightDock: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [dismissed, setDismissed] = useState(multiPanelHintDismissed);
|
||||
|
||||
if (dismissed || (!canPromote && !canRightDock)) return null;
|
||||
|
||||
function dismiss() {
|
||||
localStorage.setItem(STORAGE_KEY, "true");
|
||||
setDismissed(true);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative shrink-0 border-b border-border bg-accent-brand/5 px-3 py-2.5">
|
||||
<button
|
||||
onClick={dismiss}
|
||||
title={t("common.dismiss")}
|
||||
aria-label={t("common.dismiss")}
|
||||
className="absolute right-1.5 top-1.5 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
<p className="pr-5 text-xs font-semibold text-foreground">
|
||||
{t("nav.multiPanelHintTitle")}
|
||||
</p>
|
||||
<p className="mt-0.5 pr-5 text-[11px] leading-snug text-muted-foreground">
|
||||
{t("nav.multiPanelHintBody")}
|
||||
</p>
|
||||
<div className="mt-2 flex flex-col gap-1.5">
|
||||
{canPromote && (
|
||||
<div className="flex items-start gap-1.5 text-[11px] leading-snug text-muted-foreground">
|
||||
<SquareArrowOutUpRight className="mt-0.5 size-3 shrink-0 text-accent-brand" />
|
||||
<span>
|
||||
<button
|
||||
onClick={() => {
|
||||
dismiss();
|
||||
onOpenAsTab();
|
||||
}}
|
||||
className="font-medium text-foreground underline underline-offset-2 hover:text-accent-brand"
|
||||
>
|
||||
{t("nav.openAsTab")}
|
||||
</button>
|
||||
{": "}
|
||||
{t("nav.multiPanelHintTabHow")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{canRightDock && (
|
||||
<div className="flex items-start gap-1.5 text-[11px] leading-snug text-muted-foreground">
|
||||
<PanelRight className="mt-0.5 size-3 shrink-0 text-accent-brand" />
|
||||
<span>
|
||||
<button
|
||||
onClick={() => {
|
||||
dismiss();
|
||||
onOpenInRightDock();
|
||||
}}
|
||||
className="font-medium text-foreground underline underline-offset-2 hover:text-accent-brand"
|
||||
>
|
||||
{t("nav.openInRightDock")}
|
||||
</button>
|
||||
{": "}
|
||||
{t("nav.multiPanelHintDockHow")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -23,7 +23,7 @@ interface NotificationChannelDialogProps {
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
type ChannelType = "webhook" | "ntfy";
|
||||
type ChannelType = "webhook" | "ntfy" | "discord";
|
||||
|
||||
interface WebhookConfig {
|
||||
url: string;
|
||||
@@ -36,6 +36,12 @@ interface NtfyConfig {
|
||||
token?: string;
|
||||
}
|
||||
|
||||
interface DiscordConfig {
|
||||
url: string;
|
||||
username?: string;
|
||||
avatar_url?: string;
|
||||
}
|
||||
|
||||
export function NotificationChannelDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
@@ -49,6 +55,8 @@ export function NotificationChannelDialog({
|
||||
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(() => {
|
||||
@@ -60,6 +68,11 @@ export function NotificationChannelDialog({
|
||||
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");
|
||||
@@ -84,7 +97,7 @@ export function NotificationChannelDialog({
|
||||
toast.error(t("alerts.channelNameRequired", "Name is required"));
|
||||
return;
|
||||
}
|
||||
let config: WebhookConfig | NtfyConfig;
|
||||
let config: WebhookConfig | NtfyConfig | DiscordConfig;
|
||||
if (type === "webhook") {
|
||||
if (!webhookUrl.trim()) {
|
||||
toast.error(t("alerts.webhookUrlRequired", "Webhook URL is required"));
|
||||
@@ -93,6 +106,29 @@ export function NotificationChannelDialog({
|
||||
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"));
|
||||
@@ -150,13 +186,17 @@ export function NotificationChannelDialog({
|
||||
{t("alerts.channelType", "Type")}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
{(["webhook", "ntfy"] as ChannelType[]).map((ct) => (
|
||||
{(["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" : "ntfy"}
|
||||
{ct === "webhook"
|
||||
? "Webhook"
|
||||
: ct === "ntfy"
|
||||
? "ntfy"
|
||||
: "Discord"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -182,6 +222,50 @@ export function NotificationChannelDialog({
|
||||
</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">
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Drop indicator for sidebar drag-to-reorder.
|
||||
*
|
||||
* Drawn once by the tree at an absolute offset from the virtualizer's own
|
||||
* slot geometry, never by the rows themselves. Rows can't place this
|
||||
* correctly: in hover mode the hovered row expands its action tray while the
|
||||
* row below stays collapsed, so a bar at the hovered row's bottom edge and
|
||||
* one at the next row's top edge land in two different places. That produced
|
||||
* two candidate lines whose visibility flickered with the pointer.
|
||||
*/
|
||||
export function ReorderIndicator({ top }: { top: number }) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute inset-x-0 h-0.5 bg-accent-brand pointer-events-none z-30"
|
||||
style={{ top: top - 1 }}
|
||||
>
|
||||
<span className="absolute -left-px -top-[3px] size-2 bg-accent-brand" />
|
||||
<span className="absolute -right-px -top-[3px] size-2 bg-accent-brand" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,8 +9,12 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/dialog";
|
||||
import { toast } from "sonner";
|
||||
import type { SSOProvider, SSOProviderType } from "@/types/index";
|
||||
import type { OIDCProviderConfig, LDAPProviderConfig } from "@/types/index";
|
||||
import {
|
||||
type LDAPProviderConfig,
|
||||
type OIDCProviderConfig,
|
||||
type SSOProvider,
|
||||
type SSOProviderType,
|
||||
} from "@/types/index";
|
||||
import { createSSOProvider, updateSSOProvider } from "@/api/sso-provider-api";
|
||||
|
||||
type ApiErrorLike = {
|
||||
@@ -75,7 +79,7 @@ function emptyOidc(): OIDCProviderConfig {
|
||||
};
|
||||
}
|
||||
|
||||
function emptyLdap(): LDAPProviderConfig {
|
||||
function emptyLdap(): Required<LDAPProviderConfig> {
|
||||
return {
|
||||
host: "",
|
||||
port: 389,
|
||||
@@ -382,7 +386,8 @@ export function SSOProviderDialog({
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="text-xs bg-accent-brand text-white hover:bg-accent-brand/90"
|
||||
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}
|
||||
>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { copyToClipboard } from "@/lib/clipboard";
|
||||
import { usePageVisibleInterval } from "@/hooks/use-page-visible-interval";
|
||||
import { useAdaptivePolling } from "@/hooks/use-adaptive-polling";
|
||||
import { Input } from "@/components/input";
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
Copy,
|
||||
Download,
|
||||
Eye,
|
||||
FileText,
|
||||
Loader2,
|
||||
Save,
|
||||
ScrollText,
|
||||
@@ -32,6 +33,10 @@ import {
|
||||
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 "--";
|
||||
@@ -78,6 +83,20 @@ function buildFilename(log: SessionLogRecord): string {
|
||||
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">
|
||||
@@ -95,13 +114,16 @@ 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 (
|
||||
@@ -139,6 +161,21 @@ function LogRow({
|
||||
</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
|
||||
@@ -175,6 +212,7 @@ export function SessionLogsPanel() {
|
||||
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,
|
||||
@@ -183,46 +221,51 @@ export function SessionLogsPanel() {
|
||||
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(
|
||||
(initial = false) => {
|
||||
async (initial = false) => {
|
||||
if (initial) setLoading(true);
|
||||
getSessionLogs()
|
||||
.then((fresh) => {
|
||||
setLogs((prev) => {
|
||||
if (
|
||||
prev.length === fresh.length &&
|
||||
prev.every((log, i) => log.id === fresh[i]?.id)
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
return fresh;
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
if (initial) toast.error(t("sessionLogs.loadError"));
|
||||
})
|
||||
.finally(() => {
|
||||
if (initial) setLoading(false);
|
||||
});
|
||||
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(() => {
|
||||
load(true);
|
||||
void load(true);
|
||||
getSessionRecordingRetention()
|
||||
.then(setRetentionDays)
|
||||
.catch(() => setRetentionDays(null));
|
||||
}, [load]);
|
||||
|
||||
usePageVisibleInterval(
|
||||
() => {
|
||||
void load(false);
|
||||
useAdaptivePolling(
|
||||
() => load(false),
|
||||
{
|
||||
minIntervalMs: 5_000,
|
||||
maxIntervalMs: 30_000,
|
||||
stablePollsPerStep: 3,
|
||||
},
|
||||
5_000,
|
||||
true,
|
||||
{ runOnMount: false },
|
||||
{ runImmediately: false },
|
||||
);
|
||||
|
||||
const q = filter.trim().toLowerCase();
|
||||
@@ -238,12 +281,17 @@ export function SessionLogsPanel() {
|
||||
setViewLog(log);
|
||||
setViewContent("");
|
||||
setViewBlob(null);
|
||||
setViewText(null);
|
||||
setViewLoading(true);
|
||||
try {
|
||||
if (log.format === "text") {
|
||||
setViewContent(await getSessionLogContent(log.id));
|
||||
const text = await getSessionLogContent(log.id);
|
||||
setViewContent(text);
|
||||
setViewText(text);
|
||||
} else {
|
||||
setViewBlob(await getSessionLogBlob(log.id));
|
||||
const blob = await getSessionLogBlob(log.id);
|
||||
setViewBlob(blob);
|
||||
setViewText(await extractPlainText(log, blob));
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("sessionLogs.loadError"));
|
||||
@@ -252,15 +300,37 @@ export function SessionLogsPanel() {
|
||||
}
|
||||
};
|
||||
|
||||
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 {
|
||||
const blob = await getSessionLogBlob(log.id);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = buildFilename(log);
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
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"));
|
||||
}
|
||||
@@ -281,8 +351,8 @@ export function SessionLogsPanel() {
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!viewContent) return;
|
||||
const ok = await copyToClipboard(viewContent);
|
||||
if (!viewText) return;
|
||||
const ok = await copyToClipboard(viewText);
|
||||
if (ok) {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
@@ -329,7 +399,7 @@ export function SessionLogsPanel() {
|
||||
</div>
|
||||
<TooltipProvider>
|
||||
<div className="flex items-center gap-0.5 shrink-0">
|
||||
{viewLog.format === "text" && (
|
||||
{viewText != null && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
@@ -350,6 +420,21 @@ export function SessionLogsPanel() {
|
||||
</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
|
||||
@@ -470,6 +555,7 @@ export function SessionLogsPanel() {
|
||||
log={log}
|
||||
onView={() => handleView(log)}
|
||||
onDownload={() => handleDownload(log)}
|
||||
onDownloadText={() => handleDownloadText(log)}
|
||||
onDelete={() => setDeleteTarget(log)}
|
||||
/>
|
||||
))}
|
||||
|
||||
+1
-2683
File diff suppressed because it is too large.
Load diff
+444
-156
@@ -1,7 +1,8 @@
|
||||
import { getErrorMessage } from "../lib/error-message.js";
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { copyToClipboard } from "@/lib/clipboard";
|
||||
import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
||||
import { useSnippetRunner } from "@/hooks/use-snippet-runner.tsx";
|
||||
import {
|
||||
getSnippets,
|
||||
createSnippet as apiCreateSnippet,
|
||||
@@ -18,7 +19,13 @@ import {
|
||||
getUserList,
|
||||
getRoles,
|
||||
reorderSnippets,
|
||||
saveUserPreferences,
|
||||
} from "@/main-axios";
|
||||
import {
|
||||
hasSnippetInputs,
|
||||
type SnippetHostContext,
|
||||
} from "@/lib/snippet-variables";
|
||||
import { SnippetVariablesDialog } from "@/components/SnippetVariablesDialog";
|
||||
import {
|
||||
exportSnippets,
|
||||
importSnippets,
|
||||
@@ -41,6 +48,9 @@ import {
|
||||
import {
|
||||
Box,
|
||||
ChevronDown,
|
||||
ChevronsDownUp,
|
||||
ChevronsUpDown,
|
||||
Clipboard,
|
||||
Copy,
|
||||
Cpu,
|
||||
Database,
|
||||
@@ -70,6 +80,7 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/dropdown-menu";
|
||||
import { SettingRow, FakeSwitch } from "@/components/section-card";
|
||||
import { toast } from "sonner";
|
||||
import { FOLDER_COLORS } from "@/lib/theme";
|
||||
import { FOLDER_ICONS } from "@/types/ui-types";
|
||||
@@ -147,6 +158,7 @@ function SnippetFormDialog({
|
||||
const [description, setDescription] = useState("");
|
||||
const [folder, setFolder] = useState<string | null>(null);
|
||||
const [content, setContent] = useState("");
|
||||
const [isNote, setIsNote] = useState(false);
|
||||
const [selectedHostIds, setSelectedHostIds] = useState<Set<number>>(
|
||||
new Set(),
|
||||
);
|
||||
@@ -165,6 +177,7 @@ function SnippetFormDialog({
|
||||
setDescription(snippet?.description ?? "");
|
||||
setFolder(snippet?.folder ?? null);
|
||||
setContent(snippet?.content ?? "");
|
||||
setIsNote(snippet?.isNote ?? false);
|
||||
setSelectedHostIds(new Set(snippet?.hostIds ?? []));
|
||||
}
|
||||
}, [open, snippet]);
|
||||
@@ -189,8 +202,11 @@ function SnippetFormDialog({
|
||||
description: description.trim() || undefined,
|
||||
content: content.trim(),
|
||||
folder,
|
||||
isNote,
|
||||
hostIds:
|
||||
selectedHostIds.size > 0 ? Array.from(selectedHostIds) : undefined,
|
||||
!isNote && selectedHostIds.size > 0
|
||||
? Array.from(selectedHostIds)
|
||||
: undefined,
|
||||
},
|
||||
snippet?.id,
|
||||
);
|
||||
@@ -254,79 +270,113 @@ function SnippetFormDialog({
|
||||
folderMeta={folderMeta}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold text-muted-foreground">
|
||||
{t("newUi.sidebar.snippets.typeLabel")}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsNote(false)}
|
||||
className={`flex-1 py-1.5 text-xs border transition-colors ${!isNote ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:text-foreground"}`}
|
||||
>
|
||||
{t("newUi.sidebar.snippets.typeCommand")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsNote(true)}
|
||||
className={`flex-1 py-1.5 text-xs border transition-colors ${isNote ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:text-foreground"}`}
|
||||
>
|
||||
{t("newUi.sidebar.snippets.typeNote")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold">
|
||||
{t("newUi.sidebar.snippets.commandLabel")}{" "}
|
||||
{isNote
|
||||
? t("newUi.sidebar.snippets.noteLabel")
|
||||
: t("newUi.sidebar.snippets.commandLabel")}{" "}
|
||||
<span className="text-accent-brand">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
placeholder={t("newUi.sidebar.snippets.commandPlaceholder")}
|
||||
placeholder={
|
||||
isNote
|
||||
? t("newUi.sidebar.snippets.notePlaceholder")
|
||||
: t("newUi.sidebar.snippets.commandPlaceholder")
|
||||
}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
className="w-full h-36 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"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="flex items-center gap-2 text-xs font-semibold text-muted-foreground">
|
||||
<Server className="size-3.5" />
|
||||
{t("newUi.sidebar.snippets.targetHostsLabel")}{" "}
|
||||
<span className="font-normal">
|
||||
({t("newUi.sidebar.snippets.optional")})
|
||||
</span>
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground/70">
|
||||
{t("newUi.sidebar.snippets.targetHostsHint")}
|
||||
<p className="text-[10px] text-muted-foreground/70 font-mono">
|
||||
{t("newUi.sidebar.snippets.variablesHint")}
|
||||
</p>
|
||||
{availableHosts.length === 0 ? (
|
||||
<span className="text-xs text-muted-foreground/50">
|
||||
{t("newUi.sidebar.snippets.noHostsAvailable")}
|
||||
</span>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1 max-h-32 overflow-y-auto border border-border p-1.5">
|
||||
{availableHosts.map((host) => {
|
||||
const selected = selectedHostIds.has(host.id);
|
||||
return (
|
||||
<button
|
||||
key={host.id}
|
||||
type="button"
|
||||
onClick={() => toggleHost(host.id)}
|
||||
className={`flex items-center gap-2 px-2 py-1.5 text-left transition-colors ${
|
||||
selected
|
||||
? "bg-accent-brand/10 text-accent-brand"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`size-3 border-2 flex items-center justify-center shrink-0 transition-colors ${
|
||||
</div>
|
||||
{!isNote && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="flex items-center gap-2 text-xs font-semibold text-muted-foreground">
|
||||
<Server className="size-3.5" />
|
||||
{t("newUi.sidebar.snippets.targetHostsLabel")}{" "}
|
||||
<span className="font-normal">
|
||||
({t("newUi.sidebar.snippets.optional")})
|
||||
</span>
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground/70">
|
||||
{t("newUi.sidebar.snippets.targetHostsHint")}
|
||||
</p>
|
||||
{availableHosts.length === 0 ? (
|
||||
<span className="text-xs text-muted-foreground/50">
|
||||
{t("newUi.sidebar.snippets.noHostsAvailable")}
|
||||
</span>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1 max-h-32 overflow-y-auto border border-border p-1.5">
|
||||
{availableHosts.map((host) => {
|
||||
const selected = selectedHostIds.has(host.id);
|
||||
return (
|
||||
<button
|
||||
key={host.id}
|
||||
type="button"
|
||||
onClick={() => toggleHost(host.id)}
|
||||
className={`flex items-center gap-2 px-2 py-1.5 text-left transition-colors ${
|
||||
selected
|
||||
? "border-accent-brand bg-accent-brand"
|
||||
: "border-border/60"
|
||||
? "bg-accent-brand/10 text-accent-brand"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{selected && <div className="size-1.5 bg-background" />}
|
||||
</div>
|
||||
<Server className="size-3 shrink-0 opacity-60" />
|
||||
<span className="text-xs font-medium truncate flex-1">
|
||||
{host.name || host.ip}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground/60 shrink-0">
|
||||
{host.ip}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{selectedHostIds.size > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedHostIds(new Set())}
|
||||
className="text-xs text-muted-foreground hover:text-foreground self-start"
|
||||
>
|
||||
{t("newUi.sidebar.snippets.clearTargetHosts")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<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>
|
||||
<Server className="size-3 shrink-0 opacity-60" />
|
||||
<span className="text-xs font-medium truncate flex-1">
|
||||
{host.name || host.ip}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground/60 shrink-0">
|
||||
{host.ip}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{selectedHostIds.size > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedHostIds(new Set())}
|
||||
className="text-xs text-muted-foreground hover:text-foreground self-start"
|
||||
>
|
||||
{t("newUi.sidebar.snippets.clearTargetHosts")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 mt-2">
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
@@ -624,6 +674,7 @@ function mapRawSnippet(s: RawSnippet): Snippet {
|
||||
folder: s.folder ?? null,
|
||||
order: s.order ?? 0,
|
||||
hostIds: parseHostFilter(s.hostFilter),
|
||||
isNote: s.isNote ?? false,
|
||||
};
|
||||
}
|
||||
type RawSnippetFolder = {
|
||||
@@ -817,7 +868,7 @@ function SnippetCard({
|
||||
onDelete,
|
||||
onEdit,
|
||||
onShare,
|
||||
onConfirmRun,
|
||||
onRunSnippet,
|
||||
onDirectExecute,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
@@ -834,7 +885,7 @@ function SnippetCard({
|
||||
onDelete: (id: number) => void;
|
||||
onEdit: (snippet: Snippet) => void;
|
||||
onShare: (snippet: Snippet) => void;
|
||||
onConfirmRun: (snippet: Snippet, execute: () => void) => void;
|
||||
onRunSnippet: (snippet: Snippet, targets: Tab[]) => void;
|
||||
onDirectExecute: (snippet: Snippet) => void;
|
||||
onDragStart: () => void;
|
||||
onDragEnd: () => void;
|
||||
@@ -844,34 +895,20 @@ function SnippetCard({
|
||||
availableHosts: SSHHost[];
|
||||
t: (key: string, opts?: Record<string, unknown>) => string;
|
||||
}) {
|
||||
const hasTargetHosts = (snippet.hostIds?.length ?? 0) > 0;
|
||||
const hasTargetHosts = !snippet.isNote && (snippet.hostIds?.length ?? 0) > 0;
|
||||
|
||||
function executeRun() {
|
||||
function handleRun() {
|
||||
if (hasTargetHosts) {
|
||||
onDirectExecute(snippet);
|
||||
return;
|
||||
}
|
||||
const targets = terminalTabs.filter((tab) => selectedTabIds.has(tab.id));
|
||||
if (targets.length > 0) {
|
||||
targets.forEach((tab) => {
|
||||
tab.terminalRef?.current?.sendInput?.(snippet.content + "\r");
|
||||
});
|
||||
toast.success(
|
||||
t("newUi.sidebar.snippets.runSuccess", {
|
||||
name: snippet.name,
|
||||
count: targets.length,
|
||||
}),
|
||||
);
|
||||
onRunSnippet(snippet, targets);
|
||||
} else if (terminalTabs.length > 0) {
|
||||
const activeTab =
|
||||
terminalTabs.find((tab) => tab.id === activeTabId) ?? terminalTabs[0];
|
||||
activeTab.terminalRef?.current?.sendInput?.(snippet.content + "\r");
|
||||
toast.success(
|
||||
t("newUi.sidebar.snippets.runSuccess", {
|
||||
name: snippet.name,
|
||||
count: 1,
|
||||
}),
|
||||
);
|
||||
onRunSnippet(snippet, [activeTab]);
|
||||
} else {
|
||||
toast.error(t("newUi.sidebar.snippets.noTerminalTabsOpen"));
|
||||
}
|
||||
@@ -880,10 +917,6 @@ function SnippetCard({
|
||||
}
|
||||
}
|
||||
|
||||
function handleRun() {
|
||||
onConfirmRun(snippet, executeRun);
|
||||
}
|
||||
|
||||
function handleCopy() {
|
||||
copyToClipboard(snippet.content);
|
||||
toast.success(
|
||||
@@ -921,10 +954,15 @@ function SnippetCard({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{snippet.isNote && (
|
||||
<span className="shrink-0 mt-0.5 text-[10px] px-1.5 py-0.5 bg-muted/40 text-muted-foreground border border-border">
|
||||
{t("newUi.sidebar.snippets.typeNote")}
|
||||
</span>
|
||||
)}
|
||||
{hasTargetHosts && (
|
||||
<Zap
|
||||
className="size-3 shrink-0 mt-0.5 text-accent-brand/70"
|
||||
title={t("newUi.sidebar.snippets.hasTargetHosts")}
|
||||
aria-label={t("newUi.sidebar.snippets.hasTargetHosts")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -953,12 +991,16 @@ function SnippetCard({
|
||||
>
|
||||
{hasTargetHosts ? (
|
||||
<Zap className="size-3" />
|
||||
) : snippet.isNote ? (
|
||||
<Clipboard className="size-3" />
|
||||
) : (
|
||||
<Play className="size-3" />
|
||||
)}
|
||||
{hasTargetHosts
|
||||
? t("newUi.sidebar.snippets.runOnTargets")
|
||||
: t("newUi.sidebar.snippets.run")}
|
||||
: snippet.isNote
|
||||
? t("newUi.sidebar.snippets.pasteToTerminal")
|
||||
: t("newUi.sidebar.snippets.run")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -1211,7 +1253,7 @@ function VirtualFolderGroup({
|
||||
onDelete,
|
||||
onEdit,
|
||||
onShare,
|
||||
onConfirmRun,
|
||||
onRunSnippet,
|
||||
onDirectExecute,
|
||||
draggedSnippet,
|
||||
dropTarget,
|
||||
@@ -1221,7 +1263,8 @@ function VirtualFolderGroup({
|
||||
onDrop,
|
||||
availableHosts,
|
||||
t,
|
||||
defaultOpen,
|
||||
open,
|
||||
onToggleOpen,
|
||||
}: {
|
||||
folderName: string;
|
||||
snippets: Snippet[];
|
||||
@@ -1231,7 +1274,7 @@ function VirtualFolderGroup({
|
||||
onDelete: (id: number) => void;
|
||||
onEdit: (snippet: Snippet) => void;
|
||||
onShare: (snippet: Snippet) => void;
|
||||
onConfirmRun: (snippet: Snippet, execute: () => void) => void;
|
||||
onRunSnippet: (snippet: Snippet, targets: Tab[]) => void;
|
||||
onDirectExecute: (snippet: Snippet) => void;
|
||||
draggedSnippet: Snippet | null;
|
||||
dropTarget: { id: number; position: "above" | "below" } | null;
|
||||
@@ -1241,14 +1284,13 @@ function VirtualFolderGroup({
|
||||
onDrop: (folder: string | null) => void;
|
||||
availableHosts: SSHHost[];
|
||||
t: (key: string, opts?: Record<string, unknown>) => string;
|
||||
defaultOpen: boolean;
|
||||
open: boolean;
|
||||
onToggleOpen: () => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
onClick={onToggleOpen}
|
||||
className="flex items-center gap-1.5 w-full text-left"
|
||||
>
|
||||
<ChevronDown
|
||||
@@ -1278,7 +1320,7 @@ function VirtualFolderGroup({
|
||||
onDelete={onDelete}
|
||||
onEdit={onEdit}
|
||||
onShare={onShare}
|
||||
onConfirmRun={onConfirmRun}
|
||||
onRunSnippet={onRunSnippet}
|
||||
onDirectExecute={onDirectExecute}
|
||||
onDragStart={() => onDragStart(snippet)}
|
||||
onDragEnd={onDragEnd}
|
||||
@@ -1297,15 +1339,28 @@ function VirtualFolderGroup({
|
||||
);
|
||||
}
|
||||
|
||||
const UNCATEGORIZED_KEY = "__uncategorized__";
|
||||
|
||||
function readOpenFolders(): Set<string> {
|
||||
try {
|
||||
const saved = localStorage.getItem("snippetOpenFolders");
|
||||
if (saved) return new Set(JSON.parse(saved));
|
||||
} catch {
|
||||
// ignore malformed storage
|
||||
}
|
||||
return new Set();
|
||||
}
|
||||
|
||||
export function SnippetsPanel({
|
||||
terminalTabs,
|
||||
activeTabId,
|
||||
storageMode = "local",
|
||||
}: {
|
||||
terminalTabs: Tab[];
|
||||
activeTabId: string;
|
||||
storageMode?: "local" | "cloud";
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { confirmWithToast } = useConfirmation();
|
||||
const [snippetSearch, setSnippetSearch] = useState("");
|
||||
const [folders, setFolders] = useState<SnippetFolder[]>([]);
|
||||
const [snippets, setSnippets] = useState<Snippet[]>([]);
|
||||
@@ -1323,6 +1378,12 @@ export function SnippetsPanel({
|
||||
[],
|
||||
);
|
||||
const [executionSnippetName, setExecutionSnippetName] = useState("");
|
||||
const [foldersCollapsedDefault, setFoldersCollapsedDefault] = useState(
|
||||
() => localStorage.getItem("defaultSnippetFoldersCollapsed") !== "false",
|
||||
);
|
||||
const [confirmExecution, setConfirmExecution] = useState(
|
||||
() => localStorage.getItem("confirmSnippetExecution") === "true",
|
||||
);
|
||||
const [selectedTabIds, setSelectedTabIds] = useState<Set<string>>(
|
||||
() =>
|
||||
new Set(
|
||||
@@ -1331,6 +1392,22 @@ export function SnippetsPanel({
|
||||
: [],
|
||||
),
|
||||
);
|
||||
// Path B (direct SSH execute) needs its own variables-dialog state since it
|
||||
// resolves against the first target host and posts inputValues to the
|
||||
// backend, unlike Path A (terminal send) which the shared hook covers.
|
||||
const [directRunningSnippet, setDirectRunningSnippet] = useState<{
|
||||
snippet: Snippet;
|
||||
host: SnippetHostContext | null;
|
||||
onConfirm: (
|
||||
resolvedContent: string,
|
||||
inputValues: Record<string, string>,
|
||||
) => void;
|
||||
} | null>(null);
|
||||
const {
|
||||
runSnippet: handleRunSnippet,
|
||||
handleConfirmRun,
|
||||
dialog: runSnippetDialog,
|
||||
} = useSnippetRunner();
|
||||
|
||||
function updateSnippets(next: Snippet[] | ((prev: Snippet[]) => Snippet[])) {
|
||||
setSnippets((prev) => {
|
||||
@@ -1343,23 +1420,69 @@ export function SnippetsPanel({
|
||||
const getFoldersCollapsed = () =>
|
||||
localStorage.getItem("defaultSnippetFoldersCollapsed") !== "false";
|
||||
|
||||
const [uncategorizedOpen, setUncategorizedOpen] = useState(
|
||||
() => !getFoldersCollapsed(),
|
||||
const [openFolders, setOpenFolders] = useState<Set<string>>(() =>
|
||||
readOpenFolders(),
|
||||
);
|
||||
|
||||
function persistOpenFolders(next: Set<string>) {
|
||||
try {
|
||||
localStorage.setItem("snippetOpenFolders", JSON.stringify([...next]));
|
||||
} catch {
|
||||
// ignore quota/serialization failures
|
||||
}
|
||||
}
|
||||
|
||||
function toggleOpenFolder(key: string) {
|
||||
setOpenFolders((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) {
|
||||
next.delete(key);
|
||||
} else {
|
||||
next.add(key);
|
||||
}
|
||||
persistOpenFolders(next);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
const allFolderKeys = useMemo(() => {
|
||||
const keys = new Set<string>([UNCATEGORIZED_KEY]);
|
||||
for (const f of folders) keys.add(f.name);
|
||||
for (const s of snippets) {
|
||||
if (s.folder) keys.add(s.folder);
|
||||
}
|
||||
return keys;
|
||||
}, [folders, snippets]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
const collapsed = getFoldersCollapsed();
|
||||
setUncategorizedOpen(!collapsed);
|
||||
setFolders((prev) => prev.map((f) => ({ ...f, open: !collapsed })));
|
||||
const next = collapsed ? new Set<string>() : new Set(allFolderKeys);
|
||||
persistOpenFolders(next);
|
||||
setOpenFolders(next);
|
||||
};
|
||||
const expandAll = () => {
|
||||
const next = new Set(allFolderKeys);
|
||||
persistOpenFolders(next);
|
||||
setOpenFolders(next);
|
||||
};
|
||||
const collapseAll = () => {
|
||||
const next = new Set<string>();
|
||||
persistOpenFolders(next);
|
||||
setOpenFolders(next);
|
||||
};
|
||||
window.addEventListener("defaultSnippetFoldersCollapsedChanged", handler);
|
||||
return () =>
|
||||
window.addEventListener("snippets:expand-all", expandAll);
|
||||
window.addEventListener("snippets:collapse-all", collapseAll);
|
||||
return () => {
|
||||
window.removeEventListener(
|
||||
"defaultSnippetFoldersCollapsedChanged",
|
||||
handler,
|
||||
);
|
||||
}, []);
|
||||
window.removeEventListener("snippets:expand-all", expandAll);
|
||||
window.removeEventListener("snippets:collapse-all", collapseAll);
|
||||
};
|
||||
}, [allFolderKeys]);
|
||||
|
||||
const [draggedSnippet, setDraggedSnippet] = useState<Snippet | null>(null);
|
||||
const [dropTarget, setDropTarget] = useState<{
|
||||
@@ -1426,32 +1549,43 @@ export function SnippetsPanel({
|
||||
setDropTarget(null);
|
||||
}
|
||||
|
||||
const handleConfirmRun = useCallback(
|
||||
(snippet: Snippet, execute: () => void) => {
|
||||
const shouldConfirm =
|
||||
localStorage.getItem("confirmSnippetExecution") === "true";
|
||||
if (!shouldConfirm) {
|
||||
execute();
|
||||
return;
|
||||
// For folder keys the user has never explicitly toggled (not present in the
|
||||
// persisted set), fall back to the default-collapsed setting instead of
|
||||
// always treating them as closed.
|
||||
function seedNewFolderKeys(keys: string[]) {
|
||||
if (keys.length === 0) return;
|
||||
const collapsed = getFoldersCollapsed();
|
||||
if (collapsed) return;
|
||||
setOpenFolders((prev) => {
|
||||
const hadAny = localStorage.getItem("snippetOpenFolders") !== null;
|
||||
const next = new Set(prev);
|
||||
let changed = false;
|
||||
for (const key of keys) {
|
||||
if (!hadAny || !next.has(key)) {
|
||||
if (!next.has(key)) {
|
||||
next.add(key);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
confirmWithToast(
|
||||
t("newUi.sidebar.snippets.confirmRunMessage", { name: snippet.name }),
|
||||
execute,
|
||||
t("newUi.sidebar.snippets.confirmRunButton"),
|
||||
t("newUi.sidebar.snippets.cancel"),
|
||||
{ confirmOnEnter: true, duration: 6000 },
|
||||
);
|
||||
},
|
||||
[confirmWithToast, t],
|
||||
);
|
||||
if (!changed) return prev;
|
||||
persistOpenFolders(next);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const collapsed = getFoldersCollapsed();
|
||||
|
||||
getSnippets()
|
||||
.then((data) => {
|
||||
const arr: RawSnippet[] = Array.isArray(data) ? data : [];
|
||||
updateSnippets(arr.map(mapRawSnippet));
|
||||
const arr = (Array.isArray(data)
|
||||
? data
|
||||
: []) as unknown as RawSnippet[];
|
||||
const mapped = arr.map(mapRawSnippet);
|
||||
updateSnippets(mapped);
|
||||
const folderKeys = Array.from(
|
||||
new Set(mapped.map((s) => s.folder ?? UNCATEGORIZED_KEY)),
|
||||
);
|
||||
seedNewFolderKeys(folderKeys);
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
@@ -1463,9 +1597,10 @@ export function SnippetsPanel({
|
||||
name: f.name,
|
||||
color: f.color ?? FOLDER_COLORS[0],
|
||||
icon: (f.icon as FolderIconId) ?? "folder",
|
||||
open: !collapsed,
|
||||
open: true,
|
||||
}));
|
||||
setFolders(mapped);
|
||||
seedNewFolderKeys(mapped.map((f) => f.name));
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
@@ -1535,18 +1670,18 @@ export function SnippetsPanel({
|
||||
const id =
|
||||
typeof created.id === "number" ? created.id : Number(created.id);
|
||||
setFolders((prev) => [...prev, { ...f, id, open: true }]);
|
||||
setOpenFolders((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(f.name);
|
||||
persistOpenFolders(next);
|
||||
return next;
|
||||
});
|
||||
toast.success(t("newUi.sidebar.snippets.folderCreateSuccess"));
|
||||
} catch {
|
||||
toast.error(t("newUi.sidebar.snippets.folderCreateFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
function toggleFolder(id: number) {
|
||||
setFolders((prev) =>
|
||||
prev.map((f) => (f.id === id ? { ...f, open: !f.open } : f)),
|
||||
);
|
||||
}
|
||||
|
||||
async function handleDeleteFolder(folder: SnippetFolder) {
|
||||
try {
|
||||
await apiDeleteSnippetFolder(folder.name);
|
||||
@@ -1556,6 +1691,13 @@ export function SnippetsPanel({
|
||||
s.folder === folder.name ? { ...s, folder: null } : s,
|
||||
),
|
||||
);
|
||||
setOpenFolders((prev) => {
|
||||
if (!prev.has(folder.name)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.delete(folder.name);
|
||||
persistOpenFolders(next);
|
||||
return next;
|
||||
});
|
||||
toast.success(
|
||||
t("newUi.sidebar.snippets.folderDeleteSuccess", { name: folder.name }),
|
||||
);
|
||||
@@ -1577,6 +1719,15 @@ export function SnippetsPanel({
|
||||
s.folder === oldName ? { ...s, folder: data.name } : s,
|
||||
),
|
||||
);
|
||||
setOpenFolders((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(oldName)) {
|
||||
next.delete(oldName);
|
||||
next.add(data.name);
|
||||
}
|
||||
persistOpenFolders(next);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
await apiUpdateSnippetFolderMetadata(nameChanged ? data.name : oldName, {
|
||||
color: data.color,
|
||||
@@ -1614,11 +1765,16 @@ export function SnippetsPanel({
|
||||
}
|
||||
|
||||
function reloadData() {
|
||||
const collapsed = getFoldersCollapsed();
|
||||
getSnippets()
|
||||
.then((data) => {
|
||||
const arr: RawSnippet[] = Array.isArray(data) ? data : [];
|
||||
updateSnippets(arr.map(mapRawSnippet));
|
||||
const arr = (Array.isArray(data)
|
||||
? data
|
||||
: []) as unknown as RawSnippet[];
|
||||
const mapped = arr.map(mapRawSnippet);
|
||||
updateSnippets(mapped);
|
||||
seedNewFolderKeys(
|
||||
Array.from(new Set(mapped.map((s) => s.folder ?? UNCATEGORIZED_KEY))),
|
||||
);
|
||||
})
|
||||
.catch(() => {});
|
||||
getSnippetFolders()
|
||||
@@ -1629,9 +1785,10 @@ export function SnippetsPanel({
|
||||
name: f.name,
|
||||
color: f.color ?? FOLDER_COLORS[0],
|
||||
icon: (f.icon as FolderIconId) ?? "folder",
|
||||
open: !collapsed,
|
||||
open: true,
|
||||
}));
|
||||
setFolders(mapped);
|
||||
seedNewFolderKeys(mapped.map((f) => f.name));
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
@@ -1650,7 +1807,10 @@ export function SnippetsPanel({
|
||||
setSnippetFormOpen(true);
|
||||
}
|
||||
|
||||
async function handleDirectExecute(snippet: Snippet) {
|
||||
async function executeDirectRun(
|
||||
snippet: Snippet,
|
||||
inputValues: Record<string, string>,
|
||||
) {
|
||||
const hostIds = snippet.hostIds ?? [];
|
||||
if (hostIds.length === 0) return;
|
||||
|
||||
@@ -1663,7 +1823,11 @@ export function SnippetsPanel({
|
||||
const host = availableHosts.find((h) => h.id === hostId);
|
||||
const hostLabel = host ? host.name || host.ip : String(hostId);
|
||||
try {
|
||||
const result = await apiExecuteSnippet(snippet.id, hostId);
|
||||
const result = await apiExecuteSnippet(
|
||||
snippet.id,
|
||||
hostId,
|
||||
Object.keys(inputValues).length > 0 ? inputValues : undefined,
|
||||
);
|
||||
return {
|
||||
hostLabel,
|
||||
success: result.success,
|
||||
@@ -1675,7 +1839,7 @@ export function SnippetsPanel({
|
||||
hostLabel,
|
||||
success: false,
|
||||
output: "",
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
error: getErrorMessage(err, String(err)),
|
||||
};
|
||||
}
|
||||
}),
|
||||
@@ -1700,6 +1864,40 @@ export function SnippetsPanel({
|
||||
}
|
||||
}
|
||||
|
||||
const handleDirectExecute = useCallback(
|
||||
(snippet: Snippet) => {
|
||||
const runWithInputs = (inputValues: Record<string, string>) => {
|
||||
handleConfirmRun(snippet, () => {
|
||||
void executeDirectRun(snippet, inputValues);
|
||||
});
|
||||
};
|
||||
|
||||
if (hasSnippetInputs(snippet.content)) {
|
||||
const firstHost = snippet.hostIds?.length
|
||||
? availableHosts.find((h) => h.id === snippet.hostIds![0])
|
||||
: undefined;
|
||||
setDirectRunningSnippet({
|
||||
snippet,
|
||||
host: firstHost
|
||||
? {
|
||||
ip: firstHost.ip,
|
||||
username: firstHost.username,
|
||||
port: firstHost.port,
|
||||
name: firstHost.name,
|
||||
}
|
||||
: null,
|
||||
onConfirm: (_resolvedContent, inputValues) => {
|
||||
setDirectRunningSnippet(null);
|
||||
runWithInputs(inputValues);
|
||||
},
|
||||
});
|
||||
} else {
|
||||
runWithInputs({});
|
||||
}
|
||||
},
|
||||
[availableHosts, handleConfirmRun],
|
||||
);
|
||||
|
||||
const filtered = snippetSearch
|
||||
? snippets.filter(
|
||||
(s) =>
|
||||
@@ -1726,14 +1924,91 @@ export function SnippetsPanel({
|
||||
<span className="text-xs font-semibold">
|
||||
{t("newUi.sidebar.snippets.title")}
|
||||
</span>
|
||||
<a
|
||||
href="https://docs.termix.site/features/terminal/snippets"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-[10px] text-accent-brand hover:underline"
|
||||
>
|
||||
{t("hosts.docsLink")}
|
||||
</a>
|
||||
<div className="flex items-center gap-2">
|
||||
<a
|
||||
href="https://docs.termix.site/features/terminal/snippets"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-[10px] text-accent-brand hover:underline"
|
||||
>
|
||||
{t("hosts.docsLink")}
|
||||
</a>
|
||||
<button
|
||||
onClick={() =>
|
||||
window.dispatchEvent(new CustomEvent("snippets:expand-all"))
|
||||
}
|
||||
title={t("newUi.sidebar.snippets.expandAll")}
|
||||
className="size-6 flex items-center justify-center text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ChevronsUpDown className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
window.dispatchEvent(new CustomEvent("snippets:collapse-all"))
|
||||
}
|
||||
title={t("newUi.sidebar.snippets.collapseAll")}
|
||||
className="size-6 flex items-center justify-center text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ChevronsDownUp className="size-3.5" />
|
||||
</button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
title={t("newUi.sidebar.snippets.settings")}
|
||||
className="size-6 flex items-center justify-center text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Settings className="size-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="text-xs w-64 p-2">
|
||||
<SettingRow
|
||||
label={t("newUi.sidebar.userProfile.foldersCollapsed")}
|
||||
description={t(
|
||||
"newUi.sidebar.userProfile.foldersCollapsedDesc",
|
||||
)}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={foldersCollapsedDefault}
|
||||
onChange={(v) => {
|
||||
setFoldersCollapsedDefault(v);
|
||||
localStorage.setItem(
|
||||
"defaultSnippetFoldersCollapsed",
|
||||
v.toString(),
|
||||
);
|
||||
window.dispatchEvent(
|
||||
new Event("defaultSnippetFoldersCollapsedChanged"),
|
||||
);
|
||||
if (storageMode === "cloud")
|
||||
saveUserPreferences({ foldersCollapsed: v }).catch(
|
||||
() => {},
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("newUi.sidebar.userProfile.confirmExecution")}
|
||||
description={t(
|
||||
"newUi.sidebar.userProfile.confirmExecutionDesc",
|
||||
)}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={confirmExecution}
|
||||
onChange={(v) => {
|
||||
setConfirmExecution(v);
|
||||
localStorage.setItem(
|
||||
"confirmSnippetExecution",
|
||||
v.toString(),
|
||||
);
|
||||
if (storageMode === "cloud")
|
||||
saveUserPreferences({
|
||||
confirmSnippetExecution: v,
|
||||
}).catch(() => {});
|
||||
}}
|
||||
/>
|
||||
</SettingRow>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -1857,11 +2132,11 @@ export function SnippetsPanel({
|
||||
{(!snippetSearch || uncategorizedSnippets.length > 0) && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
onClick={() => setUncategorizedOpen((v) => !v)}
|
||||
onClick={() => toggleOpenFolder(UNCATEGORIZED_KEY)}
|
||||
className="flex items-center gap-1.5 w-full text-left"
|
||||
>
|
||||
<ChevronDown
|
||||
className={`size-3 text-muted-foreground shrink-0 transition-transform ${uncategorizedOpen ? "" : "-rotate-90"}`}
|
||||
className={`size-3 text-muted-foreground shrink-0 transition-transform ${openFolders.has(UNCATEGORIZED_KEY) ? "" : "-rotate-90"}`}
|
||||
/>
|
||||
<Folder className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="text-xs font-semibold flex-1 truncate text-muted-foreground">
|
||||
@@ -1871,7 +2146,7 @@ export function SnippetsPanel({
|
||||
{uncategorizedSnippets.length}
|
||||
</span>
|
||||
</button>
|
||||
{uncategorizedOpen && (
|
||||
{openFolders.has(UNCATEGORIZED_KEY) && (
|
||||
<div
|
||||
className="flex flex-col gap-2 ml-1"
|
||||
onDrop={() => handleDrop(null)}
|
||||
@@ -1887,7 +2162,7 @@ export function SnippetsPanel({
|
||||
onDelete={handleDeleteSnippet}
|
||||
onEdit={handleEditSnippet}
|
||||
onShare={setShareSnippet}
|
||||
onConfirmRun={handleConfirmRun}
|
||||
onRunSnippet={handleRunSnippet}
|
||||
onDirectExecute={handleDirectExecute}
|
||||
onDragStart={() => handleDragStart(snippet)}
|
||||
onDragEnd={handleDragEnd}
|
||||
@@ -1916,15 +2191,16 @@ export function SnippetsPanel({
|
||||
(s) => s.folder === folder.name,
|
||||
);
|
||||
if (folderSnippets.length === 0 && snippetSearch) return null;
|
||||
const isOpen = openFolders.has(folder.name);
|
||||
return (
|
||||
<div key={folder.id} className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-1.5 w-full group">
|
||||
<button
|
||||
onClick={() => toggleFolder(folder.id)}
|
||||
onClick={() => toggleOpenFolder(folder.name)}
|
||||
className="flex items-center gap-1.5 flex-1 min-w-0 text-left"
|
||||
>
|
||||
<ChevronDown
|
||||
className={`size-3 text-muted-foreground shrink-0 transition-transform ${folder.open ? "" : "-rotate-90"}`}
|
||||
className={`size-3 text-muted-foreground shrink-0 transition-transform ${isOpen ? "" : "-rotate-90"}`}
|
||||
/>
|
||||
<FolderIconEl
|
||||
icon={folder.icon}
|
||||
@@ -1967,7 +2243,7 @@ export function SnippetsPanel({
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
{folder.open && (
|
||||
{isOpen && (
|
||||
<div
|
||||
className="flex flex-col gap-2 ml-1"
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
@@ -1983,7 +2259,7 @@ export function SnippetsPanel({
|
||||
onDelete={handleDeleteSnippet}
|
||||
onEdit={handleEditSnippet}
|
||||
onShare={setShareSnippet}
|
||||
onConfirmRun={handleConfirmRun}
|
||||
onRunSnippet={handleRunSnippet}
|
||||
onDirectExecute={handleDirectExecute}
|
||||
onDragStart={() => handleDragStart(snippet)}
|
||||
onDragEnd={handleDragEnd}
|
||||
@@ -2024,7 +2300,7 @@ export function SnippetsPanel({
|
||||
onDelete={handleDeleteSnippet}
|
||||
onEdit={handleEditSnippet}
|
||||
onShare={setShareSnippet}
|
||||
onConfirmRun={handleConfirmRun}
|
||||
onRunSnippet={handleRunSnippet}
|
||||
onDirectExecute={handleDirectExecute}
|
||||
draggedSnippet={draggedSnippet}
|
||||
dropTarget={dropTarget}
|
||||
@@ -2034,13 +2310,25 @@ export function SnippetsPanel({
|
||||
onDrop={handleDrop}
|
||||
availableHosts={availableHosts}
|
||||
t={t}
|
||||
defaultOpen={!getFoldersCollapsed()}
|
||||
open={openFolders.has(folderName)}
|
||||
onToggleOpen={() => toggleOpenFolder(folderName)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{runSnippetDialog}
|
||||
|
||||
{directRunningSnippet && (
|
||||
<SnippetVariablesDialog
|
||||
snippet={directRunningSnippet.snippet}
|
||||
host={directRunningSnippet.host}
|
||||
onCancel={() => setDirectRunningSnippet(null)}
|
||||
onConfirm={directRunningSnippet.onConfirm}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SnippetFormDialog
|
||||
open={snippetFormOpen}
|
||||
onOpenChange={(v) => {
|
||||
|
||||
@@ -15,6 +15,12 @@ const LAYOUT_PREVIEWS: Record<SplitMode, React.ReactNode> = {
|
||||
<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" />
|
||||
@@ -376,6 +382,10 @@ export function SplitScreenPanel({
|
||||
keys: ["Ctrl", "Shift", "["],
|
||||
label: t("newUi.sidebar.splitScreen.hotkeysPrevTab"),
|
||||
},
|
||||
{
|
||||
keys: ["Alt", "1-9"],
|
||||
label: t("newUi.sidebar.splitScreen.hotkeysJumpTab"),
|
||||
},
|
||||
].map(({ keys, label }) => (
|
||||
<div
|
||||
key={label}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getErrorMessage } from "../lib/error-message.js";
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
@@ -202,7 +203,7 @@ function ClaimHandle({ onCreated }: { onCreated: () => void }) {
|
||||
toast.success(t("termixId.created"));
|
||||
onCreated();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : t("termixId.createFailed"));
|
||||
toast.error(getErrorMessage(e, t("termixId.createFailed")));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -311,7 +312,7 @@ function IdentityCard({
|
||||
toast.success(t("termixId.deleted"));
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : t("termixId.deleteFailed"));
|
||||
toast.error(getErrorMessage(e, t("termixId.deleteFailed")));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
setConfirming(false);
|
||||
@@ -500,7 +501,7 @@ function AddKey({
|
||||
setLabel("");
|
||||
onAdded();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : t("termixId.addKeyFailed"));
|
||||
toast.error(getErrorMessage(e, t("termixId.addKeyFailed")));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -519,9 +520,7 @@ function AddKey({
|
||||
if (saveToVault) loadCredentials();
|
||||
onAdded();
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t("termixId.generateFailed"),
|
||||
);
|
||||
toast.error(getErrorMessage(e, t("termixId.generateFailed")));
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
@@ -534,7 +533,7 @@ function AddKey({
|
||||
toast.success(t("termixId.imported"));
|
||||
onAdded();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : t("termixId.importFailed"));
|
||||
toast.error(getErrorMessage(e, t("termixId.importFailed")));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -636,9 +635,7 @@ function KeyList({
|
||||
await setTermixIdKeyEnabled(k.id, !k.enabled);
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t("termixId.updateKeyFailed"),
|
||||
);
|
||||
toast.error(getErrorMessage(e, t("termixId.updateKeyFailed")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -648,9 +645,7 @@ function KeyList({
|
||||
toast.success(t("termixId.keyRemoved"));
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t("termixId.removeKeyFailed"),
|
||||
);
|
||||
toast.error(getErrorMessage(e, t("termixId.removeKeyFailed")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -661,9 +656,7 @@ function KeyList({
|
||||
downloadText(`termix-${handle}-${k.id}-cert.pub`, res.certificate + "\n");
|
||||
toast.success(t("termixId.certIssued"));
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t("termixId.certIssueFailed"),
|
||||
);
|
||||
toast.error(getErrorMessage(e, t("termixId.certIssueFailed")));
|
||||
} finally {
|
||||
setIssuingId(null);
|
||||
}
|
||||
@@ -771,9 +764,7 @@ function CaCard({
|
||||
toast.success(t("termixId.caEnabled"));
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t("termixId.caCreateFailed"),
|
||||
);
|
||||
toast.error(getErrorMessage(e, t("termixId.caCreateFailed")));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -786,9 +777,7 @@ function CaCard({
|
||||
toast.success(t("termixId.caRotated"));
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t("termixId.caRotateFailed"),
|
||||
);
|
||||
toast.error(getErrorMessage(e, t("termixId.caRotateFailed")));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
setConfirmingRotate(false);
|
||||
@@ -802,9 +791,7 @@ function CaCard({
|
||||
toast.success(t("termixId.caDeleted"));
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t("termixId.caDeleteFailed"),
|
||||
);
|
||||
toast.error(getErrorMessage(e, t("termixId.caDeleteFailed")));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
setConfirmingDelete(false);
|
||||
|
||||
+336
-335
@@ -14,12 +14,14 @@ import {
|
||||
enableTOTP,
|
||||
disableTOTP,
|
||||
getVersionInfo,
|
||||
releaseUrlFrom,
|
||||
getUserRoles,
|
||||
saveUserPreferences,
|
||||
getUserPreferences,
|
||||
} from "@/main-axios";
|
||||
import { getDatabaseTransferUrl } from "@/lib/database-transfer-url";
|
||||
import { readRailPreference, setRailPreference } from "./rail-preferences";
|
||||
import { useHostSidebarPreferences } from "./tree/hooks/useHostSidebarPreferences";
|
||||
import {
|
||||
deleteWebAuthnCredential,
|
||||
listWebAuthnCredentials,
|
||||
@@ -35,6 +37,7 @@ import { shouldForceLocalPreferenceStorage } from "@/settings/remote-sync-state"
|
||||
import { C2STunnelPresetManager } from "@/user/C2STunnelPresetManager";
|
||||
import { Button } from "@/components/button";
|
||||
import { Input } from "@/components/input";
|
||||
import { VersionBadge } from "@/components/version-badge";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -48,33 +51,27 @@ import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
Clock,
|
||||
Copy,
|
||||
Database,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Fingerprint,
|
||||
Hammer,
|
||||
KeyRound,
|
||||
LayoutPanelLeft,
|
||||
LayoutTemplate,
|
||||
Network,
|
||||
Palette,
|
||||
Play,
|
||||
Plug,
|
||||
Plus,
|
||||
RotateCcw,
|
||||
ScrollText,
|
||||
Server,
|
||||
Shield,
|
||||
ShieldCheck,
|
||||
Trash2,
|
||||
Type,
|
||||
Usb,
|
||||
User,
|
||||
X,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { SettingRow, FakeSwitch } from "@/components/section-card";
|
||||
import { visibleRailItems } from "./rail-items";
|
||||
import { InterfacePresetSettings } from "./InterfacePresetSettings";
|
||||
import { useUiPreferencesContext } from "@/contexts/UiPreferencesContext";
|
||||
import { KeybindingsDialog } from "./KeybindingsDialog";
|
||||
import {
|
||||
ACCENT_PRESET_COLORS,
|
||||
@@ -87,9 +84,17 @@ import { useTheme } from "@/components/theme-provider";
|
||||
import type { FontSizeId, ThemeId } from "@/types/ui-types";
|
||||
import { toast } from "sonner";
|
||||
import { changeAppLanguage, normalizeLanguageCode } from "@/i18n/i18n";
|
||||
import { clearLocalAdaptivePreferences } from "@/lib/local-adaptive-preferences";
|
||||
import { ConnectionDefaultsSettings } from "./ConnectionDefaultsSettings";
|
||||
|
||||
type UserProfileSection =
|
||||
"account" | "appearance" | "security" | "api-keys" | "data" | "c2s-tunnels";
|
||||
| "account"
|
||||
| "interface"
|
||||
| "appearance"
|
||||
| "security"
|
||||
| "api-keys"
|
||||
| "data"
|
||||
| "c2s-tunnels";
|
||||
|
||||
const THEMES: { id: ThemeId; preview: string }[] = [
|
||||
{ id: "system", preview: "auto" },
|
||||
@@ -207,7 +212,7 @@ type CreatedProfileApiKey = {
|
||||
expiresAt?: string | null;
|
||||
};
|
||||
|
||||
function NewApiKeyDialog({
|
||||
export function NewApiKeyDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onAdd,
|
||||
@@ -221,12 +226,23 @@ function NewApiKeyDialog({
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = useState("");
|
||||
const [expiry, setExpiry] = useState("");
|
||||
const [createdToken, setCreatedToken] = useState<string | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const close = () => {
|
||||
setName("");
|
||||
setExpiry("");
|
||||
setCreatedToken(null);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!name.trim()) {
|
||||
toast.error(t("newUi.sidebar.userProfile.apiKeyNameRequired"));
|
||||
return;
|
||||
}
|
||||
if (creating) return;
|
||||
setCreating(true);
|
||||
try {
|
||||
const created = await createApiKey(
|
||||
name.trim(),
|
||||
@@ -234,17 +250,22 @@ function NewApiKeyDialog({
|
||||
expiry ? new Date(expiry).toISOString() : undefined,
|
||||
);
|
||||
onAdd(created);
|
||||
onOpenChange(false);
|
||||
setName("");
|
||||
setExpiry("");
|
||||
setCreatedToken(created.token);
|
||||
toast.success(t("newUi.sidebar.userProfile.apiKeyCreated", { name }));
|
||||
} catch {
|
||||
toast.error(t("newUi.sidebar.userProfile.apiKeyCreateFailed"));
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (nextOpen || !createdToken) onOpenChange(nextOpen);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-md rounded-none border-border bg-card p-0 gap-0 overflow-hidden">
|
||||
<DialogHeader className="px-5 pt-5 pb-4 border-b border-border">
|
||||
<div className="flex items-center gap-2.5">
|
||||
@@ -262,53 +283,93 @@ function NewApiKeyDialog({
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-4 px-5 py-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("newUi.sidebar.userProfile.apiKeyNameLabel")}
|
||||
</label>
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder={t("newUi.sidebar.userProfile.apiKeyNamePlaceholder")}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
|
||||
className="rounded-none bg-muted/50 border-border text-sm h-9"
|
||||
/>
|
||||
{createdToken ? (
|
||||
<div className="flex flex-col gap-3 px-5 py-4">
|
||||
<span className="text-xs font-semibold text-accent-brand">
|
||||
{t("newUi.sidebar.userProfile.apiKeyCreatedWarning")}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 border border-border bg-muted/30 px-2 py-2">
|
||||
<code className="min-w-0 flex-1 break-all text-xs text-accent-brand">
|
||||
{createdToken}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 shrink-0"
|
||||
aria-label={t("newUi.sidebar.userProfile.copyApiKey")}
|
||||
onClick={() => {
|
||||
copyToClipboard(createdToken);
|
||||
toast.info(t("newUi.sidebar.userProfile.copiedToClipboard"));
|
||||
}}
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4 px-5 py-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("newUi.sidebar.userProfile.apiKeyNameLabel")}
|
||||
</label>
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder={t(
|
||||
"newUi.sidebar.userProfile.apiKeyNamePlaceholder",
|
||||
)}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
|
||||
className="rounded-none bg-muted/50 border-border text-sm h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("newUi.sidebar.userProfile.expiryDateLabel")}{" "}
|
||||
<span className="text-muted-foreground/50 normal-case font-medium">
|
||||
({t("newUi.sidebar.userProfile.optional")})
|
||||
</span>
|
||||
</label>
|
||||
<Input
|
||||
type="date"
|
||||
value={expiry}
|
||||
onChange={(e) => setExpiry(e.target.value)}
|
||||
className="rounded-none bg-muted/50 border-border text-sm h-9"
|
||||
/>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("newUi.sidebar.userProfile.expiryDateLabel")}{" "}
|
||||
<span className="text-muted-foreground/50 normal-case font-medium">
|
||||
({t("newUi.sidebar.userProfile.optional")})
|
||||
</span>
|
||||
</label>
|
||||
<Input
|
||||
type="date"
|
||||
value={expiry}
|
||||
onChange={(e) => setExpiry(e.target.value)}
|
||||
className="rounded-none bg-muted/50 border-border text-sm h-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className="px-5 py-3 border-t border-border bg-muted/20">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="rounded-none text-[10px] font-bold uppercase tracking-widest"
|
||||
>
|
||||
{t("newUi.sidebar.userProfile.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 rounded-none text-[10px] font-bold uppercase tracking-widest gap-1.5"
|
||||
onClick={handleCreate}
|
||||
>
|
||||
<KeyRound className="size-3" />{" "}
|
||||
{t("newUi.sidebar.userProfile.createKey")}
|
||||
</Button>
|
||||
{createdToken ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="rounded-none border-accent-brand/40 text-[10px] font-bold uppercase tracking-widest text-accent-brand"
|
||||
onClick={close}
|
||||
>
|
||||
{t("newUi.sidebar.userProfile.done")}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={close}
|
||||
className="rounded-none text-[10px] font-bold uppercase tracking-widest"
|
||||
>
|
||||
{t("newUi.sidebar.userProfile.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 rounded-none text-[10px] font-bold uppercase tracking-widest gap-1.5"
|
||||
onClick={handleCreate}
|
||||
disabled={creating}
|
||||
>
|
||||
<KeyRound className="size-3" />{" "}
|
||||
{t("newUi.sidebar.userProfile.createKey")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -441,6 +502,8 @@ export function UserProfilePanel({
|
||||
storageMode?: string | null;
|
||||
commandAutocomplete?: boolean | null;
|
||||
commandPaletteEnabled?: boolean | null;
|
||||
aiAssistantEnabled?: boolean | null;
|
||||
aiReadOnlyCommands?: boolean | null;
|
||||
showHostTags?: boolean | null;
|
||||
hostTrayOnClick?: boolean | null;
|
||||
compactHostView?: boolean | null;
|
||||
@@ -485,6 +548,7 @@ export function UserProfilePanel({
|
||||
const [versionStatus, setVersionStatus] = useState<
|
||||
"up_to_date" | "requires_update" | "beta"
|
||||
>("up_to_date");
|
||||
const [releaseUrl, setReleaseUrl] = useState("");
|
||||
const [isOidc, setIsOidc] = useState(false);
|
||||
const [isDualAuth, setIsDualAuth] = useState(false);
|
||||
|
||||
@@ -604,29 +668,72 @@ export function UserProfilePanel({
|
||||
return v !== null ? v === "true" : true;
|
||||
});
|
||||
const [keybindingsDialogOpen, setKeybindingsDialogOpen] = useState(false);
|
||||
const [showHostTags, setShowHostTags] = useState(() => {
|
||||
const v = localStorage.getItem("showHostTags");
|
||||
return v !== null ? v === "true" : true;
|
||||
});
|
||||
const [hostTrayOnClick, setHostTrayOnClick] = useState(
|
||||
() => localStorage.getItem("hostTrayOnClick") !== "false",
|
||||
);
|
||||
const [compactHostView, setCompactHostView] = useState(
|
||||
() => localStorage.getItem("compactHostView") === "true",
|
||||
);
|
||||
const [statusColorScheme, setStatusColorScheme] = useState(
|
||||
() => localStorage.getItem("statusColorScheme") ?? "accent",
|
||||
const [aiAssistantEnabled, setAiAssistantEnabled] = useState(false);
|
||||
const [aiReadOnlyCommands, setAiReadOnlyCommands] = useState(false);
|
||||
// Null until the status call answers; the whole section stays hidden while
|
||||
// unknown so a user who cannot have the feature never sees it mentioned.
|
||||
const [aiGloballyEnabled, setAiGloballyEnabled] = useState<boolean | null>(
|
||||
null,
|
||||
);
|
||||
// Sidebar display customization (density, tags, tray trigger, status
|
||||
// colors) now lives in the dedicated Customize Sidebar panel opened from
|
||||
// the Hosts toolbar, not here -- resetToDefaults still needs write access.
|
||||
const { update: updateSidebarPrefs } = useHostSidebarPreferences();
|
||||
const uiPrefs = useUiPreferencesContext();
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
import("@/api/ai-api")
|
||||
.then(({ getAiStatus }) => getAiStatus())
|
||||
.then((status) => {
|
||||
if (cancelled) return;
|
||||
setAiGloballyEnabled(status.globallyEnabled);
|
||||
setAiAssistantEnabled(status.enabled);
|
||||
setAiReadOnlyCommands(status.allowReadOnlyCommands);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setAiGloballyEnabled(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Turning the assistant off also hides its rail tab, so declining removes
|
||||
* the feature from view entirely rather than leaving an inert entry behind.
|
||||
*/
|
||||
const applyAiEnabled = (enabled: boolean) => {
|
||||
setAiAssistantEnabled(enabled);
|
||||
|
||||
const hidden = new Set<string>(
|
||||
JSON.parse(localStorage.getItem("hiddenRailTabs") ?? "[]"),
|
||||
);
|
||||
if (enabled) hidden.delete("ai");
|
||||
else hidden.add("ai");
|
||||
|
||||
const serialized = JSON.stringify([...hidden]);
|
||||
localStorage.setItem("hiddenRailTabs", serialized);
|
||||
setHiddenRailTabs(hidden);
|
||||
window.dispatchEvent(new Event("hiddenRailTabsChanged"));
|
||||
|
||||
if (storageMode === "cloud") {
|
||||
saveToCloud({ aiAssistantEnabled: enabled, hiddenRailTabs: serialized });
|
||||
}
|
||||
};
|
||||
const [pinAppRail, setPinAppRail] = useState(() =>
|
||||
readRailPreference("pinAppRail"),
|
||||
);
|
||||
const [expandAppRailOnHover, setExpandAppRailOnHover] = useState(() =>
|
||||
readRailPreference("expandAppRailOnHover"),
|
||||
);
|
||||
const [foldersCollapsed, setFoldersCollapsed] = useState(
|
||||
// Read values are unused now that the Snippets settings UI lives in
|
||||
// SnippetsPanel.tsx; the setters still back the cloud-sync/reset/snapshot
|
||||
// machinery for these two localStorage-backed prefs below.
|
||||
const [_foldersCollapsed, setFoldersCollapsed] = useState(
|
||||
() => localStorage.getItem("defaultSnippetFoldersCollapsed") !== "false",
|
||||
);
|
||||
const [confirmSnippetExecution, setConfirmSnippetExecution] = useState(
|
||||
const [_confirmSnippetExecution, setConfirmSnippetExecution] = useState(
|
||||
() => localStorage.getItem("confirmSnippetExecution") === "true",
|
||||
);
|
||||
const [disableUpdateCheck, setDisableUpdateCheck] = useState(
|
||||
@@ -692,6 +799,7 @@ export function UserProfilePanel({
|
||||
.then((info) => {
|
||||
setVersion(info.localVersion);
|
||||
setVersionStatus(info.status ?? "up_to_date");
|
||||
setReleaseUrl(releaseUrlFrom(info));
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [t]);
|
||||
@@ -737,6 +845,13 @@ export function UserProfilePanel({
|
||||
"confirmTabClose",
|
||||
"hiddenRailTabs",
|
||||
"statusColorScheme",
|
||||
"uiPreferences",
|
||||
// Layout/view keys that were never snapshotted, so switching to cloud
|
||||
// and back silently reset them.
|
||||
"dashboardTab.slots",
|
||||
"dashboardTab.mainWidthPct",
|
||||
"termix-terminal-toolbar-density",
|
||||
"fileManagerViewMode",
|
||||
];
|
||||
const snap: Record<string, string | null> = { __theme: theme };
|
||||
for (const key of SNAPSHOT_KEYS) snap[key] = localStorage.getItem(key);
|
||||
@@ -774,26 +889,15 @@ export function UserProfilePanel({
|
||||
String(prefs.commandPaletteEnabled),
|
||||
);
|
||||
}
|
||||
if (prefs.showHostTags != null) {
|
||||
setShowHostTags(prefs.showHostTags);
|
||||
localStorage.setItem("showHostTags", String(prefs.showHostTags));
|
||||
window.dispatchEvent(new CustomEvent("showHostTagsChanged"));
|
||||
if (prefs.aiAssistantEnabled != null) {
|
||||
setAiAssistantEnabled(prefs.aiAssistantEnabled);
|
||||
}
|
||||
if (prefs.hostTrayOnClick != null) {
|
||||
setHostTrayOnClick(prefs.hostTrayOnClick);
|
||||
localStorage.setItem(
|
||||
"hostTrayOnClick",
|
||||
String(prefs.hostTrayOnClick),
|
||||
);
|
||||
}
|
||||
if (prefs.compactHostView != null) {
|
||||
setCompactHostView(prefs.compactHostView);
|
||||
localStorage.setItem(
|
||||
"compactHostView",
|
||||
String(prefs.compactHostView),
|
||||
);
|
||||
window.dispatchEvent(new CustomEvent("compactHostViewChanged"));
|
||||
if (prefs.aiReadOnlyCommands != null) {
|
||||
setAiReadOnlyCommands(prefs.aiReadOnlyCommands);
|
||||
}
|
||||
// showHostTags/hostTrayOnClick/compactHostView/statusColorScheme are
|
||||
// no longer restored here -- useHostSidebarPreferences independently
|
||||
// fetches its own authoritative copy from /host-sidebar/preferences.
|
||||
if (prefs.pinAppRail != null) {
|
||||
setPinAppRail(prefs.pinAppRail);
|
||||
localStorage.setItem("pinAppRail", String(prefs.pinAppRail));
|
||||
@@ -841,11 +945,6 @@ export function UserProfilePanel({
|
||||
localStorage.setItem("hiddenRailTabs", prefs.hiddenRailTabs);
|
||||
window.dispatchEvent(new CustomEvent("hiddenRailTabsChanged"));
|
||||
}
|
||||
if (prefs.statusColorScheme != null) {
|
||||
setStatusColorScheme(prefs.statusColorScheme);
|
||||
localStorage.setItem("statusColorScheme", prefs.statusColorScheme);
|
||||
window.dispatchEvent(new CustomEvent("statusColorSchemeChanged"));
|
||||
}
|
||||
} catch {
|
||||
// leave UI as-is on error
|
||||
}
|
||||
@@ -857,6 +956,7 @@ export function UserProfilePanel({
|
||||
}
|
||||
|
||||
function resetToDefaults() {
|
||||
clearLocalAdaptivePreferences();
|
||||
const DEFAULT_ACCENT = "#f59145";
|
||||
setTheme("system");
|
||||
setFontSize("md");
|
||||
@@ -871,14 +971,16 @@ export function UserProfilePanel({
|
||||
localStorage.setItem("commandAutocomplete", "false");
|
||||
setCommandPaletteEnabled(true);
|
||||
localStorage.setItem("commandPaletteShortcutEnabled", "true");
|
||||
setShowHostTags(true);
|
||||
localStorage.setItem("showHostTags", "true");
|
||||
window.dispatchEvent(new CustomEvent("showHostTagsChanged"));
|
||||
setHostTrayOnClick(true);
|
||||
localStorage.setItem("hostTrayOnClick", "true");
|
||||
setCompactHostView(false);
|
||||
localStorage.setItem("compactHostView", "false");
|
||||
window.dispatchEvent(new CustomEvent("compactHostViewChanged"));
|
||||
updateSidebarPrefs((prev) => ({
|
||||
...prev,
|
||||
display: {
|
||||
...prev.display,
|
||||
showTags: true,
|
||||
trayTrigger: "hover",
|
||||
density: "comfortable",
|
||||
statusColorScheme: "accent",
|
||||
},
|
||||
}));
|
||||
setPinAppRail(false);
|
||||
localStorage.setItem("pinAppRail", "false");
|
||||
window.dispatchEvent(new Event("pinAppRailChanged"));
|
||||
@@ -896,9 +998,9 @@ export function UserProfilePanel({
|
||||
setHiddenRailTabs(new Set());
|
||||
localStorage.removeItem("hiddenRailTabs");
|
||||
window.dispatchEvent(new CustomEvent("hiddenRailTabsChanged"));
|
||||
setStatusColorScheme("accent");
|
||||
localStorage.setItem("statusColorScheme", "accent");
|
||||
window.dispatchEvent(new CustomEvent("statusColorSchemeChanged"));
|
||||
// Back to the preset that matches the pre-preset UI, with nothing pinned.
|
||||
uiPrefs?.setPreset("balanced");
|
||||
uiPrefs?.clearAllOverrides();
|
||||
if (storageMode === "cloud") {
|
||||
saveToCloud({
|
||||
theme: "system",
|
||||
@@ -907,9 +1009,6 @@ export function UserProfilePanel({
|
||||
language: "en",
|
||||
commandAutocomplete: false,
|
||||
commandPaletteEnabled: true,
|
||||
showHostTags: true,
|
||||
hostTrayOnClick: true,
|
||||
compactHostView: false,
|
||||
pinAppRail: false,
|
||||
expandAppRailOnHover: true,
|
||||
foldersCollapsed: true,
|
||||
@@ -917,7 +1016,6 @@ export function UserProfilePanel({
|
||||
disableUpdateCheck: false,
|
||||
confirmTabClose: false,
|
||||
hiddenRailTabs: "[]",
|
||||
statusColorScheme: "accent",
|
||||
});
|
||||
}
|
||||
localSnapshot.current = {};
|
||||
@@ -972,20 +1070,9 @@ export function UserProfilePanel({
|
||||
String(restoredPalette),
|
||||
);
|
||||
|
||||
const restoredHostTags = restore("showHostTags", "true") !== "false";
|
||||
setShowHostTags(restoredHostTags);
|
||||
localStorage.setItem("showHostTags", String(restoredHostTags));
|
||||
window.dispatchEvent(new CustomEvent("showHostTagsChanged"));
|
||||
|
||||
const restoredTrayOnClick = restore("hostTrayOnClick", "true") !== "false";
|
||||
setHostTrayOnClick(restoredTrayOnClick);
|
||||
localStorage.setItem("hostTrayOnClick", String(restoredTrayOnClick));
|
||||
|
||||
const restoredCompactHostView =
|
||||
restore("compactHostView", "false") === "true";
|
||||
setCompactHostView(restoredCompactHostView);
|
||||
localStorage.setItem("compactHostView", String(restoredCompactHostView));
|
||||
window.dispatchEvent(new CustomEvent("compactHostViewChanged"));
|
||||
// showHostTags/hostTrayOnClick/compactHostView/statusColorScheme are no
|
||||
// longer part of this snapshot -- useHostSidebarPreferences keeps its own
|
||||
// localStorage cache independent of storageMode switches.
|
||||
|
||||
const restoredPinRail = restore("pinAppRail", "false") === "true";
|
||||
setPinAppRail(restoredPinRail);
|
||||
@@ -1046,12 +1133,6 @@ export function UserProfilePanel({
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent("hiddenRailTabsChanged"));
|
||||
|
||||
const restoredStatusScheme =
|
||||
restore("statusColorScheme", "accent") ?? "accent";
|
||||
setStatusColorScheme(restoredStatusScheme);
|
||||
localStorage.setItem("statusColorScheme", restoredStatusScheme);
|
||||
window.dispatchEvent(new CustomEvent("statusColorSchemeChanged"));
|
||||
|
||||
localStorage.removeItem("termix-local-snapshot");
|
||||
localSnapshot.current = {};
|
||||
}
|
||||
@@ -1330,7 +1411,7 @@ export function UserProfilePanel({
|
||||
<NewApiKeyDialog
|
||||
open={newKeyOpen}
|
||||
onOpenChange={setNewKeyOpen}
|
||||
onAdd={(key) => setApiKeys((prev) => [key, ...prev])}
|
||||
onAdd={(key) => setApiKeys((prev) => [key as ApiKey, ...prev])}
|
||||
userId={userId}
|
||||
/>
|
||||
|
||||
@@ -1483,21 +1564,7 @@ export function UserProfilePanel({
|
||||
<span className="text-sm font-bold text-accent-brand">
|
||||
{version ? `v${version}` : "—"}
|
||||
</span>
|
||||
<span
|
||||
className={`text-[10px] px-1.5 py-0.5 font-semibold leading-none ${
|
||||
versionStatus === "beta"
|
||||
? "bg-blue-500/20 text-blue-400"
|
||||
: versionStatus === "requires_update"
|
||||
? "bg-yellow-500/20 text-yellow-400"
|
||||
: "bg-accent-brand/20 text-accent-brand"
|
||||
}`}
|
||||
>
|
||||
{versionStatus === "beta"
|
||||
? t("dashboard.beta").toUpperCase()
|
||||
: versionStatus === "requires_update"
|
||||
? t("dashboard.updateAvailable").toUpperCase()
|
||||
: t("dashboardTab.stable")}
|
||||
</span>
|
||||
<VersionBadge status={versionStatus} releaseUrl={releaseUrl} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1530,6 +1597,27 @@ export function UserProfilePanel({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border pt-3 mt-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs font-medium">
|
||||
{t("newUi.sidebar.userProfile.cliTitle")}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("newUi.sidebar.userProfile.cliDescription")}
|
||||
</span>
|
||||
</div>
|
||||
<a
|
||||
href="https://docs.termix.site/cli"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="shrink-0 ml-3 flex items-center gap-1 text-[10px] font-bold uppercase tracking-widest border border-border px-2 py-1.5 hover:bg-muted/40 transition-colors"
|
||||
>
|
||||
{t("newUi.sidebar.userProfile.cliLearnMore")}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isElectron() && (
|
||||
<div className="border-t border-border pt-3 mt-3">
|
||||
<RemoteSyncPanel initialServerUrl={remoteSyncInitialServerUrl} />
|
||||
@@ -1561,6 +1649,23 @@ export function UserProfilePanel({
|
||||
</div>
|
||||
</AccordionSection>
|
||||
|
||||
{/* Interface */}
|
||||
<AccordionSection
|
||||
id="interface"
|
||||
label={t("newUi.sidebar.userProfile.sectionInterface")}
|
||||
icon={<LayoutTemplate size={13} />}
|
||||
open={openSections.has("interface")}
|
||||
onToggle={() => toggle("interface")}
|
||||
>
|
||||
<div className="flex flex-col gap-4 pt-3">
|
||||
<InterfacePresetSettings
|
||||
onRunSetupAgain={() =>
|
||||
window.dispatchEvent(new Event("termix:open-onboarding"))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</AccordionSection>
|
||||
|
||||
{/* Appearance */}
|
||||
<AccordionSection
|
||||
id="appearance"
|
||||
@@ -1711,6 +1816,7 @@ export function UserProfilePanel({
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1">
|
||||
{t("newUi.sidebar.userProfile.settingsTerminal")}
|
||||
</span>
|
||||
<ConnectionDefaultsSettings />
|
||||
<SettingRow
|
||||
label={t("newUi.sidebar.userProfile.commandAutocomplete")}
|
||||
description={t(
|
||||
@@ -1727,6 +1833,29 @@ export function UserProfilePanel({
|
||||
}}
|
||||
/>
|
||||
</SettingRow>
|
||||
<div className="flex flex-col gap-1.5 py-3 border-b border-border">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-sm font-medium leading-snug">
|
||||
{t("newUi.sidebar.userProfile.localEcho")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground leading-snug">
|
||||
{t("newUi.sidebar.userProfile.localEchoDesc")}
|
||||
</span>
|
||||
</div>
|
||||
<select
|
||||
defaultValue={
|
||||
localStorage.getItem("terminalLocalEchoMode") ?? "auto"
|
||||
}
|
||||
onChange={(e) =>
|
||||
localStorage.setItem("terminalLocalEchoMode", e.target.value)
|
||||
}
|
||||
className="h-7 border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
<option value="off">{t("hosts.localEchoOff")}</option>
|
||||
<option value="auto">{t("hosts.localEchoAuto")}</option>
|
||||
<option value="on">{t("hosts.localEchoOn")}</option>
|
||||
</select>
|
||||
</div>
|
||||
<SettingRow
|
||||
label={t("newUi.sidebar.userProfile.keyboardShortcuts")}
|
||||
description={t(
|
||||
@@ -1789,6 +1918,34 @@ export function UserProfilePanel({
|
||||
}}
|
||||
/>
|
||||
</SettingRow>
|
||||
{aiGloballyEnabled && (
|
||||
<>
|
||||
<SettingRow
|
||||
label={t("ai.enableTitle")}
|
||||
description={t("ai.enableDescription")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={aiAssistantEnabled}
|
||||
onChange={applyAiEnabled}
|
||||
/>
|
||||
</SettingRow>
|
||||
{aiAssistantEnabled && (
|
||||
<SettingRow
|
||||
label={t("ai.readOnlyCommands")}
|
||||
description={t("ai.readOnlyCommandsDescription")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={aiReadOnlyCommands}
|
||||
onChange={(v) => {
|
||||
setAiReadOnlyCommands(v);
|
||||
if (storageMode === "cloud")
|
||||
saveToCloud({ aiReadOnlyCommands: v });
|
||||
}}
|
||||
/>
|
||||
</SettingRow>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<SettingRow
|
||||
label={t("newUi.sidebar.userProfile.reopenTabsOnLogin")}
|
||||
description={t("newUi.sidebar.userProfile.reopenTabsOnLoginDesc")}
|
||||
@@ -1823,68 +1980,11 @@ export function UserProfilePanel({
|
||||
|
||||
<div className="flex flex-col gap-1 border-t border-border pt-3">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1">
|
||||
{t("newUi.sidebar.userProfile.settingsSidebar")}
|
||||
{t("newUi.sidebar.userProfile.settingsAppRail")}
|
||||
</span>
|
||||
<SettingRow
|
||||
label={t("newUi.sidebar.userProfile.showHostTags")}
|
||||
description={t("newUi.sidebar.userProfile.showHostTagsDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={showHostTags}
|
||||
onChange={(v) => {
|
||||
setShowHostTags(v);
|
||||
localStorage.setItem("showHostTags", v.toString());
|
||||
window.dispatchEvent(new Event("showHostTagsChanged"));
|
||||
if (storageMode === "cloud") saveToCloud({ showHostTags: v });
|
||||
}}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("newUi.sidebar.userProfile.hostTrayOnClick")}
|
||||
description={t("newUi.sidebar.userProfile.hostTrayOnClickDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={hostTrayOnClick}
|
||||
onChange={(v) => {
|
||||
setHostTrayOnClick(v);
|
||||
localStorage.setItem("hostTrayOnClick", v.toString());
|
||||
window.dispatchEvent(new Event("hostTrayOnClickChanged"));
|
||||
if (storageMode === "cloud")
|
||||
saveToCloud({ hostTrayOnClick: v });
|
||||
}}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("newUi.sidebar.userProfile.compactHostView")}
|
||||
description={t("newUi.sidebar.userProfile.compactHostViewDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={compactHostView}
|
||||
onChange={(v) => {
|
||||
setCompactHostView(v);
|
||||
localStorage.setItem("compactHostView", v.toString());
|
||||
window.dispatchEvent(new Event("compactHostViewChanged"));
|
||||
if (storageMode === "cloud")
|
||||
saveToCloud({ compactHostView: v });
|
||||
}}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("newUi.sidebar.userProfile.statusColors")}
|
||||
description={t("newUi.sidebar.userProfile.statusColorsDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={statusColorScheme === "status"}
|
||||
onChange={(v) => {
|
||||
const scheme = v ? "status" : "accent";
|
||||
setStatusColorScheme(scheme);
|
||||
localStorage.setItem("statusColorScheme", scheme);
|
||||
window.dispatchEvent(new Event("statusColorSchemeChanged"));
|
||||
if (storageMode === "cloud")
|
||||
saveToCloud({ statusColorScheme: scheme });
|
||||
}}
|
||||
/>
|
||||
</SettingRow>
|
||||
<p className="text-[10px] text-muted-foreground mb-2">
|
||||
{t("newUi.sidebar.userProfile.sidebarSettingsMoved")}
|
||||
</p>
|
||||
<SettingRow
|
||||
label={t("newUi.sidebar.userProfile.pinAppRail")}
|
||||
description={t("newUi.sidebar.userProfile.pinAppRailDesc")}
|
||||
@@ -1920,134 +2020,35 @@ export function UserProfilePanel({
|
||||
<p className="text-[10px] text-muted-foreground mb-2">
|
||||
{t("newUi.sidebar.userProfile.navigationTabsDesc")}
|
||||
</p>
|
||||
{(
|
||||
[
|
||||
{
|
||||
id: "hosts",
|
||||
icon: <Server size={12} />,
|
||||
label: t("nav.hosts"),
|
||||
},
|
||||
{
|
||||
id: "credentials",
|
||||
icon: <KeyRound size={12} />,
|
||||
label: t("nav.credentials"),
|
||||
},
|
||||
{
|
||||
id: "termix-id",
|
||||
icon: <Fingerprint size={12} />,
|
||||
label: t("nav.termixId"),
|
||||
},
|
||||
{
|
||||
id: "connections",
|
||||
icon: <Plug size={12} />,
|
||||
label: t("nav.connections"),
|
||||
},
|
||||
{
|
||||
id: "quick-connect",
|
||||
icon: <Zap size={12} />,
|
||||
label: t("nav.quickConnect"),
|
||||
},
|
||||
{
|
||||
id: "serial",
|
||||
icon: <Usb size={12} />,
|
||||
label: t("nav.serial"),
|
||||
},
|
||||
{
|
||||
id: "ssh-tools",
|
||||
icon: <Hammer size={12} />,
|
||||
label: t("nav.sshTools"),
|
||||
},
|
||||
{
|
||||
id: "snippets",
|
||||
icon: <Play size={12} />,
|
||||
label: t("nav.snippets"),
|
||||
},
|
||||
{
|
||||
id: "history",
|
||||
icon: <Clock size={12} />,
|
||||
label: t("nav.history"),
|
||||
},
|
||||
{
|
||||
id: "session-logs",
|
||||
icon: <ScrollText size={12} />,
|
||||
label: t("nav.sessionLogs"),
|
||||
},
|
||||
{
|
||||
id: "split-screen",
|
||||
icon: <LayoutPanelLeft size={12} />,
|
||||
label: t("nav.splitScreen"),
|
||||
},
|
||||
{
|
||||
id: "network_graph",
|
||||
icon: <Network size={12} />,
|
||||
label: t("nav.networkGraph"),
|
||||
},
|
||||
] as { id: string; icon: React.ReactNode; label: string }[]
|
||||
).map((tab) => (
|
||||
<div
|
||||
key={tab.id}
|
||||
className="flex items-center justify-between py-1.5"
|
||||
>
|
||||
<span className="flex items-center gap-1.5 text-xs font-medium text-foreground">
|
||||
<span className="text-muted-foreground">{tab.icon}</span>
|
||||
{tab.label}
|
||||
</span>
|
||||
<FakeSwitch
|
||||
checked={!hiddenRailTabs.has(tab.id)}
|
||||
onChange={(visible) => {
|
||||
const next = new Set(hiddenRailTabs);
|
||||
if (visible) next.delete(tab.id);
|
||||
else next.add(tab.id);
|
||||
setHiddenRailTabs(next);
|
||||
const serialized = JSON.stringify([...next]);
|
||||
localStorage.setItem("hiddenRailTabs", serialized);
|
||||
window.dispatchEvent(new Event("hiddenRailTabsChanged"));
|
||||
if (storageMode === "cloud")
|
||||
saveToCloud({ hiddenRailTabs: serialized });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1 border-t border-border pt-3">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1">
|
||||
{t("newUi.sidebar.userProfile.settingsSnippets")}
|
||||
</span>
|
||||
<SettingRow
|
||||
label={t("newUi.sidebar.userProfile.foldersCollapsed")}
|
||||
description={t("newUi.sidebar.userProfile.foldersCollapsedDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={foldersCollapsed}
|
||||
onChange={(v) => {
|
||||
setFoldersCollapsed(v);
|
||||
localStorage.setItem(
|
||||
"defaultSnippetFoldersCollapsed",
|
||||
v.toString(),
|
||||
);
|
||||
window.dispatchEvent(
|
||||
new Event("defaultSnippetFoldersCollapsedChanged"),
|
||||
);
|
||||
if (storageMode === "cloud")
|
||||
saveToCloud({ foldersCollapsed: v });
|
||||
}}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("newUi.sidebar.userProfile.confirmExecution")}
|
||||
description={t("newUi.sidebar.userProfile.confirmExecutionDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={confirmSnippetExecution}
|
||||
onChange={(v) => {
|
||||
setConfirmSnippetExecution(v);
|
||||
localStorage.setItem("confirmSnippetExecution", v.toString());
|
||||
if (storageMode === "cloud")
|
||||
saveToCloud({ confirmSnippetExecution: v });
|
||||
}}
|
||||
/>
|
||||
</SettingRow>
|
||||
{visibleRailItems()
|
||||
.filter((tab) => tab.id !== "ai" || aiGloballyEnabled)
|
||||
.map((tab) => (
|
||||
<div
|
||||
key={tab.id}
|
||||
className="flex items-center justify-between py-1.5"
|
||||
>
|
||||
<span className="flex items-center gap-1.5 text-xs font-medium text-foreground">
|
||||
<span className="text-muted-foreground">
|
||||
<tab.icon size={12} />
|
||||
</span>
|
||||
{t(tab.labelKey)}
|
||||
</span>
|
||||
<FakeSwitch
|
||||
checked={!hiddenRailTabs.has(tab.id)}
|
||||
onChange={(visible) => {
|
||||
const next = new Set(hiddenRailTabs);
|
||||
if (visible) next.delete(tab.id);
|
||||
else next.add(tab.id);
|
||||
setHiddenRailTabs(next);
|
||||
const serialized = JSON.stringify([...next]);
|
||||
localStorage.setItem("hiddenRailTabs", serialized);
|
||||
window.dispatchEvent(new Event("hiddenRailTabsChanged"));
|
||||
if (storageMode === "cloud")
|
||||
saveToCloud({ hiddenRailTabs: serialized });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1 border-t border-border pt-3">
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getErrorMessage } from "../lib/error-message.js";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pencil, Plus, Trash2, X } from "lucide-react";
|
||||
@@ -90,9 +91,7 @@ export function VaultProfileManager({
|
||||
setForm(null);
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t("hosts.vaultProfileSaveFailed"),
|
||||
);
|
||||
toast.error(getErrorMessage(e, t("hosts.vaultProfileSaveFailed")));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -104,9 +103,7 @@ export function VaultProfileManager({
|
||||
toast.success(t("hosts.vaultProfileDeleted"));
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t("hosts.vaultProfileDeleteFailed"),
|
||||
);
|
||||
toast.error(getErrorMessage(e, t("hosts.vaultProfileDeleteFailed")));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,650 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
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" };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,585 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/** 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)}`;
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { SSHHostWithStatus } from "@/main-axios";
|
||||
import type { HostFolder } from "@/types/ui-types";
|
||||
import { sshHostToHost } from "./HostManagerData";
|
||||
|
||||
/**
|
||||
* Builds the sidebar's host tree from the flat host list returned by the
|
||||
* API, nesting via two independent mechanisms:
|
||||
*
|
||||
* - folder: a " / "-separated string path, split into synthetic HostFolder
|
||||
* nodes (folders are virtual -- they only exist as this derived tree).
|
||||
* - parentHostId: sub-host nesting, where a real host acts as an
|
||||
* organizational parent for other real hosts. A host with children stays
|
||||
* a normal, connectable Host -- it is never wrapped in a synthetic node,
|
||||
* just given a childHosts array the tree/virtualizer walk into.
|
||||
*
|
||||
* The backend already enforces that folder and parentHostId are mutually
|
||||
* exclusive on write and rejects cycles, but this still defends against
|
||||
* stale/imported/synced data: a missing, inaccessible, or cyclic parent
|
||||
* falls back to folder/root placement rather than being dropped or looping.
|
||||
*/
|
||||
export function buildHostTree(
|
||||
hosts: SSHHostWithStatus[],
|
||||
folderMeta?: Map<
|
||||
string,
|
||||
{
|
||||
color?: string;
|
||||
icon?: string;
|
||||
credentialId?: number | null;
|
||||
sortOrder?: number | null;
|
||||
}
|
||||
>,
|
||||
): HostFolder {
|
||||
const root: HostFolder = { name: "root", children: [] };
|
||||
const folderMap = new Map<string, HostFolder>();
|
||||
const getOrCreateFolder = (path: string): HostFolder => {
|
||||
if (folderMap.has(path)) return folderMap.get(path)!;
|
||||
const parts = path.split(" / ");
|
||||
let current = root;
|
||||
let accumulated = "";
|
||||
for (const part of parts) {
|
||||
accumulated = accumulated ? `${accumulated} / ${part}` : part;
|
||||
if (!folderMap.has(accumulated)) {
|
||||
const meta = folderMeta?.get(accumulated);
|
||||
const folder: HostFolder = {
|
||||
name: part,
|
||||
path: accumulated,
|
||||
color: meta?.color,
|
||||
icon: meta?.icon,
|
||||
credentialId: meta?.credentialId ?? null,
|
||||
sortOrder: meta?.sortOrder ?? null,
|
||||
children: [],
|
||||
};
|
||||
folderMap.set(accumulated, folder);
|
||||
current.children.push(folder);
|
||||
}
|
||||
current = folderMap.get(accumulated)!;
|
||||
}
|
||||
return current;
|
||||
};
|
||||
// Surface empty folders (created but with no hosts yet) so they stay visible.
|
||||
if (folderMeta) {
|
||||
for (const path of folderMeta.keys()) getOrCreateFolder(path);
|
||||
}
|
||||
|
||||
const mappedHosts = hosts.map((h) => sshHostToHost(h));
|
||||
const hostsById = new Map(mappedHosts.map((h) => [h.id, h]));
|
||||
|
||||
// The backend rejects cycles on write, but stale/imported/synced data could
|
||||
// still contain one -- walking a candidate parent's own ancestor chain
|
||||
// before attaching guards the tree build against an infinite loop.
|
||||
function isDescendantOf(candidateId: string, ancestorId: string): boolean {
|
||||
let current: string | null | undefined = candidateId;
|
||||
const visited = new Set<string>();
|
||||
while (current) {
|
||||
if (current === ancestorId) return true;
|
||||
if (visited.has(current)) return false;
|
||||
visited.add(current);
|
||||
current = hostsById.get(current)?.parentHostId;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Sub-host nesting (parentHostId) takes priority over folder placement --
|
||||
// the backend already clears folder when parentHostId is set and vice
|
||||
// versa, but a host is placed by whichever it currently has.
|
||||
const nested = new Set<string>();
|
||||
for (const host of mappedHosts) {
|
||||
if (!host.parentHostId) continue;
|
||||
const parent = hostsById.get(host.parentHostId);
|
||||
// A parent that's missing (deleted), not visible to this user (e.g. a
|
||||
// shared host whose parent link was stripped server-side), or would form
|
||||
// a cycle falls back to folder/root placement below, same as any other
|
||||
// orphaned host.
|
||||
if (!parent) continue;
|
||||
if (isDescendantOf(host.parentHostId, host.id)) continue;
|
||||
parent.childHosts = parent.childHosts ?? [];
|
||||
parent.childHosts.push(host);
|
||||
nested.add(host.id);
|
||||
}
|
||||
|
||||
for (const host of mappedHosts) {
|
||||
if (nested.has(host.id)) continue;
|
||||
if (host.folder) {
|
||||
getOrCreateFolder(host.folder).children.push(host);
|
||||
} else {
|
||||
root.children.push(host);
|
||||
}
|
||||
}
|
||||
return root;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { Credential } from "@/types/ui-types";
|
||||
import type { CredentialSortKey } from "@/types/credential-sidebar-preferences";
|
||||
|
||||
/**
|
||||
* Sorts a flat credential list. No tree recursion needed -- unlike hosts,
|
||||
* credential folders are explicitly flat, so this is a plain array sort.
|
||||
*/
|
||||
export function sortCredentials(
|
||||
creds: Credential[],
|
||||
key: CredentialSortKey,
|
||||
pinnedFirst = false,
|
||||
): Credential[] {
|
||||
const sorted = [...creds];
|
||||
|
||||
switch (key) {
|
||||
case "name-asc":
|
||||
sorted.sort((a, b) => a.name.localeCompare(b.name));
|
||||
break;
|
||||
case "name-desc":
|
||||
sorted.sort((a, b) => b.name.localeCompare(a.name));
|
||||
break;
|
||||
case "username-asc":
|
||||
sorted.sort((a, b) => a.username.localeCompare(b.username));
|
||||
break;
|
||||
case "username-desc":
|
||||
sorted.sort((a, b) => b.username.localeCompare(a.username));
|
||||
break;
|
||||
case "manual":
|
||||
sorted.sort((a, b) => {
|
||||
const aOrder = a.sortOrder ?? null;
|
||||
const bOrder = b.sortOrder ?? null;
|
||||
if (aOrder == null && bOrder == null)
|
||||
return a.name.localeCompare(b.name);
|
||||
if (aOrder == null) return 1;
|
||||
if (bOrder == null) return -1;
|
||||
if (aOrder !== bOrder) return aOrder - bOrder;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
break;
|
||||
case "default":
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
if (pinnedFirst) {
|
||||
sorted.sort((a, b) => {
|
||||
const aPinned = a.pin ? 1 : 0;
|
||||
const bPinned = b.pin ? 1 : 0;
|
||||
return bPinned - aPinned;
|
||||
});
|
||||
}
|
||||
|
||||
return sorted;
|
||||
}
|
||||
|
||||
export function credentialPassesFilters(
|
||||
cred: Credential,
|
||||
filters: { type: ("password" | "key")[]; tags: string[] },
|
||||
): boolean {
|
||||
if (filters.type.length > 0 && !filters.type.includes(cred.type))
|
||||
return false;
|
||||
if (
|
||||
filters.tags.length > 0 &&
|
||||
!filters.tags.some((tag) => cred.tags?.includes(tag))
|
||||
)
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ChevronRight,
|
||||
Copy,
|
||||
GripVertical,
|
||||
Pencil,
|
||||
Pin,
|
||||
Trash2,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { copyToClipboard } from "@/lib/clipboard";
|
||||
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
|
||||
* parallel JSX trees, so a future tweak only has to be made once.
|
||||
*/
|
||||
const CREDENTIAL_ITEM_DENSITY_TOKENS = {
|
||||
comfortable: {
|
||||
rowPadding: "pl-2.5 pr-2 py-2",
|
||||
nameTextSize: "text-[13px]",
|
||||
showUsernameRow: true,
|
||||
showTagsRow: true,
|
||||
},
|
||||
compact: {
|
||||
rowPadding: "pl-2 pr-1.5 py-[5px]",
|
||||
nameTextSize: "text-[12px]",
|
||||
showUsernameRow: false,
|
||||
showTagsRow: false,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export function CredentialItem({
|
||||
cred,
|
||||
usedByCount = 0,
|
||||
termixIdLinked = false,
|
||||
query = "",
|
||||
stripeIndex = 0,
|
||||
isMenuOpen = false,
|
||||
onMenuOpenChange,
|
||||
isTrayOpen = false,
|
||||
onTrayOpenChange,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
depth = 0,
|
||||
density = "comfortable",
|
||||
trayTrigger = "hover",
|
||||
showTags = true,
|
||||
arrangeMode = false,
|
||||
isDragging = false,
|
||||
onReorderDrop,
|
||||
isReorderHovered = false,
|
||||
reorderHoverEdge = null,
|
||||
onReorderHoverChange,
|
||||
onDeploy,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
cred: Credential;
|
||||
usedByCount?: number;
|
||||
termixIdLinked?: boolean;
|
||||
query?: string;
|
||||
stripeIndex?: number;
|
||||
isMenuOpen?: boolean;
|
||||
onMenuOpenChange?: (open: boolean) => void;
|
||||
isTrayOpen?: boolean;
|
||||
onTrayOpenChange?: (open: boolean) => void;
|
||||
onDragStart?: () => void;
|
||||
onDragEnd?: () => void;
|
||||
/** Nesting level when rendered in a flattened virtual list. */
|
||||
depth?: number;
|
||||
density?: CredentialDensity;
|
||||
trayTrigger?: CredentialTrayTrigger;
|
||||
showTags?: boolean;
|
||||
/** When true (rearranging unlocked), the row can be dragged to reorder or
|
||||
* onto a folder header to move. */
|
||||
arrangeMode?: boolean;
|
||||
/** True while this row is the one being dragged. */
|
||||
isDragging?: boolean;
|
||||
onReorderDrop?: (position: "before" | "after") => void;
|
||||
/** Whether THIS row is the current reorder drop target -- lifted to the
|
||||
* parent tree so only one row can ever show the drop-indicator bar at a
|
||||
* time. See HostItem's identical prop for the full rationale. */
|
||||
isReorderHovered?: boolean;
|
||||
reorderHoverEdge?: "before" | "after" | null;
|
||||
onReorderHoverChange?: (edge: "before" | "after" | null) => void;
|
||||
onDeploy: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const reorderEdge = isReorderHovered ? reorderHoverEdge : null;
|
||||
const isKey = cred.type === "key";
|
||||
const tokens = CREDENTIAL_ITEM_DENSITY_TOKENS[density];
|
||||
const isCompact = density === "compact";
|
||||
const isTouchOnly =
|
||||
typeof window !== "undefined" && window.matchMedia("(hover: none)").matches;
|
||||
|
||||
const alwaysShowTray = trayTrigger === "always";
|
||||
const actionsOnly = trayTrigger === "actionsOnly";
|
||||
const shouldUseClickTray =
|
||||
!alwaysShowTray && !actionsOnly && (trayTrigger === "click" || isTouchOnly);
|
||||
const canDrag = arrangeMode && !isTouchOnly;
|
||||
|
||||
const depthStyle =
|
||||
depth > 0 ? ({ paddingLeft: 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";
|
||||
|
||||
const connectionButtons = isKey ? (
|
||||
<>
|
||||
<button
|
||||
title={t("credentials.deploySSHKeyTitle")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeploy();
|
||||
}}
|
||||
className={trayButtonClass}
|
||||
>
|
||||
<Upload className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
title={t("credentials.copyDeployCommand")}
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
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={trayButtonClass}
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
</button>
|
||||
</>
|
||||
) : null;
|
||||
|
||||
const managementButtons = (
|
||||
<>
|
||||
<button
|
||||
title={t("credentials.editCredentialAction")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit();
|
||||
}}
|
||||
className={trayButtonClass}
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
title={t("credentials.deleteCredentialAction")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
className={`${trayButtonClass} hover:text-destructive`}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
|
||||
const trayOpenState = isTrayOpen || isMenuOpen;
|
||||
const trayVisibilityClass =
|
||||
alwaysShowTray || actionsOnly
|
||||
? `overflow-hidden transition-all duration-150 ease-out ${trayOpenState || alwaysShowTray ? "max-h-[60px] opacity-100" : "max-h-0 opacity-0"}`
|
||||
: shouldUseClickTray
|
||||
? `overflow-hidden transition-all duration-150 ease-out ${trayOpenState ? "max-h-[60px] opacity-100" : "max-h-0 opacity-0"}`
|
||||
: `overflow-hidden transition-all duration-150 ease-out max-h-0 opacity-0 group-hover:max-h-[60px] group-hover:opacity-100 ${isMenuOpen ? "!max-h-[60px] !opacity-100" : ""}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
draggable={canDrag}
|
||||
onDragStart={(e) => {
|
||||
if (!canDrag) return;
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
// Drag the name row, not the whole row with its open tray, so the
|
||||
// ghost stays a compact label instead of a tall overlapping block.
|
||||
const label =
|
||||
e.currentTarget.querySelector<HTMLElement>("[data-drag-label]");
|
||||
if (label) {
|
||||
const rect = label.getBoundingClientRect();
|
||||
e.dataTransfer.setDragImage(
|
||||
label,
|
||||
Math.min(e.clientX - rect.left, rect.width),
|
||||
rect.height / 2,
|
||||
);
|
||||
}
|
||||
onDragStart?.();
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
onReorderHoverChange?.(null);
|
||||
onDragEnd?.();
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
if (!arrangeMode || !onReorderDrop) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
onReorderHoverChange?.(
|
||||
e.clientY - rect.top < rect.height / 2 ? "before" : "after",
|
||||
);
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
if (!arrangeMode || !onReorderDrop || !reorderEdge) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onReorderDrop(reorderEdge);
|
||||
onReorderHoverChange?.(null);
|
||||
}}
|
||||
style={depthStyle}
|
||||
className={`group relative flex items-stretch select-none transition-colors hover:bg-muted/50 ${
|
||||
canDrag ? "cursor-grab active:cursor-grabbing" : "cursor-default"
|
||||
} ${stripeIndex % 2 === 1 ? "bg-muted/15" : ""} ${isMenuOpen ? "bg-muted/50" : ""} ${isDragging ? "opacity-40" : ""}`}
|
||||
>
|
||||
{canDrag && (
|
||||
<div className="flex items-center justify-center w-4 shrink-0 text-muted-foreground/35 group-hover:text-muted-foreground/70 transition-colors">
|
||||
<GripVertical className="size-3" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`flex flex-col flex-1 min-w-0 ${tokens.rowPadding} ${isCompact ? "" : "gap-1"}`}
|
||||
>
|
||||
{/* Name row */}
|
||||
<div data-drag-label className="flex items-center gap-1.5 min-w-0">
|
||||
<span
|
||||
className={`${tokens.nameTextSize} font-semibold truncate text-foreground leading-none tracking-tight`}
|
||||
>
|
||||
{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 ? 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>
|
||||
)}
|
||||
{cred.pin && (
|
||||
<Pin className="size-2.5 text-accent-brand/50 shrink-0" />
|
||||
)}
|
||||
{!shouldUseClickTray && !actionsOnly && (
|
||||
<span className="text-[11px] text-muted-foreground/45 truncate leading-none ml-auto shrink-0 group-hover:hidden">
|
||||
{usedByCount > 0 ? `${usedByCount}h` : ""}
|
||||
</span>
|
||||
)}
|
||||
{(shouldUseClickTray || actionsOnly) && (
|
||||
<button
|
||||
title={
|
||||
isTrayOpen
|
||||
? t("credentials.collapseActions")
|
||||
: t("credentials.expandActions")
|
||||
}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onTrayOpenChange?.(!isTrayOpen);
|
||||
}}
|
||||
className="ml-auto flex items-center justify-center size-5 rounded text-muted-foreground/30 hover:text-muted-foreground hover:bg-muted-foreground/10 transition-colors shrink-0"
|
||||
>
|
||||
<ChevronRight
|
||||
className={`size-3 transition-transform duration-150 ${isTrayOpen ? "rotate-90" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Username row */}
|
||||
{tokens.showUsernameRow && (cred.username || usedByCount > 0) && (
|
||||
<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 ? " · " : ""}
|
||||
{usedByCount}h
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Tag pills */}
|
||||
{showTags && cred.tags && cred.tags.length > 0 && (
|
||||
<div
|
||||
className={`flex items-center gap-1 min-w-0 overflow-hidden ${tokens.showTagsRow ? "" : "-mt-0.5"}`}
|
||||
>
|
||||
{cred.tags.slice(0, isCompact ? 2 : 4).map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="text-[9px] px-1.5 py-[1px] bg-muted/60 text-muted-foreground/70 lowercase shrink-0 leading-[1.4]"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
{cred.tags.length > (isCompact ? 2 : 4) && (
|
||||
<span className="text-[9px] text-muted-foreground/40 shrink-0">
|
||||
+{cred.tags.length - (isCompact ? 2 : 4)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Connection buttons: permanent in "always"/"actionsOnly" modes, or shown once the chevron opens the tray in click mode */}
|
||||
{connectionButtons &&
|
||||
(alwaysShowTray ||
|
||||
actionsOnly ||
|
||||
(shouldUseClickTray && isTrayOpen)) && (
|
||||
<div className="flex items-center flex-wrap gap-1">
|
||||
{connectionButtons}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action tray */}
|
||||
<div className={trayVisibilityClass}>
|
||||
<div
|
||||
className={`flex flex-col gap-0.5 ${alwaysShowTray || actionsOnly || shouldUseClickTray ? "" : "pt-1.5"}`}
|
||||
>
|
||||
{connectionButtons &&
|
||||
!alwaysShowTray &&
|
||||
!actionsOnly &&
|
||||
!shouldUseClickTray && (
|
||||
<div className="flex items-center flex-wrap gap-0.5">
|
||||
{connectionButtons}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`flex items-center gap-0.5 border-t border-border/30 ${alwaysShowTray || actionsOnly || shouldUseClickTray ? "pt-1.5" : "pt-1 mt-0.5"}`}
|
||||
>
|
||||
{managementButtons}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { KeyRound, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { reorderCredentials } from "@/main-axios";
|
||||
import { planReorder, renumberSiblings } from "@/sidebar/reorder-utils";
|
||||
import { ReorderIndicator } from "@/sidebar/ReorderIndicator";
|
||||
import type { Credential } from "@/types/ui-types";
|
||||
import type {
|
||||
CredentialDensity,
|
||||
CredentialTrayTrigger,
|
||||
} from "@/types/credential-sidebar-preferences";
|
||||
import { CredentialItem } from "./CredentialItem/CredentialItem";
|
||||
import { CredentialFolderItem } from "./FolderItem/CredentialFolderItem";
|
||||
import {
|
||||
isFolder,
|
||||
collectVisibleRows,
|
||||
collectAllFolderNames,
|
||||
type CredentialFolder,
|
||||
} from "./visible-rows";
|
||||
|
||||
export function CredentialSidebarTree({
|
||||
folders,
|
||||
onDeployCredential,
|
||||
onEditCredential,
|
||||
onDeleteCredential,
|
||||
usedByCounts,
|
||||
termixIdLinkedIds,
|
||||
query = "",
|
||||
loading = false,
|
||||
arrangeLocked = true,
|
||||
density = "comfortable",
|
||||
trayTrigger = "hover",
|
||||
showTags = true,
|
||||
editingFolderName,
|
||||
editingFolderValue,
|
||||
onEditingFolderNameChange,
|
||||
onEditingFolderValueChange,
|
||||
onRenameFolder,
|
||||
onDeleteFolder,
|
||||
onMoveCredentialToFolder,
|
||||
}: {
|
||||
folders: CredentialFolder[];
|
||||
onDeployCredential: (cred: Credential) => void;
|
||||
onEditCredential: (cred: Credential) => void;
|
||||
onDeleteCredential: (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. */
|
||||
arrangeLocked?: boolean;
|
||||
density?: CredentialDensity;
|
||||
trayTrigger?: CredentialTrayTrigger;
|
||||
showTags?: boolean;
|
||||
editingFolderName: string | null;
|
||||
editingFolderValue: string;
|
||||
onEditingFolderNameChange: (name: string | null) => void;
|
||||
onEditingFolderValueChange: (value: string) => void;
|
||||
onRenameFolder: (folder: string, newName: string) => Promise<void>;
|
||||
onDeleteFolder?: (folder: string) => void;
|
||||
/** Drop a credential on a folder header to reassign it. */
|
||||
onMoveCredentialToFolder?: (
|
||||
credentialId: string,
|
||||
targetFolder: string,
|
||||
) => void | Promise<void>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
const [openFolders, setOpenFolders] = useState<Set<string>>(
|
||||
() => new Set(collectAllFolderNames(folders)),
|
||||
);
|
||||
const seenFolderNames = useRef<Set<string>>(
|
||||
new Set(collectAllFolderNames(folders)),
|
||||
);
|
||||
// Folders default to open, but the initial render often happens before
|
||||
// credentials finish loading (folders === []), so the useState initializer
|
||||
// above freezes on an empty set. Newly-appearing folder names (including
|
||||
// the very first load) are added to the open set as they show up; a
|
||||
// folder the user has explicitly closed stays closed on future renders
|
||||
// since it's already tracked in seenFolderNames.
|
||||
useEffect(() => {
|
||||
const currentNames = collectAllFolderNames(folders);
|
||||
const unseen = currentNames.filter((n) => !seenFolderNames.current.has(n));
|
||||
if (unseen.length === 0) return;
|
||||
for (const name of unseen) seenFolderNames.current.add(name);
|
||||
setOpenFolders((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const name of unseen) next.add(name);
|
||||
return next;
|
||||
});
|
||||
}, [folders]);
|
||||
const [openTrayCredentialId, setOpenTrayCredentialId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [openMenuCredentialId, setOpenMenuCredentialId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [draggedReorderKey, setDraggedReorderKey] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
// Tracks the single credential being dragged for folder reassignment,
|
||||
// separate from draggedReorderKey (which drives manual-sort-mode
|
||||
// position reordering). Only active outside manual sort mode.
|
||||
const [draggedCredentialId, setDraggedCredentialId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
// Tracks which single row is currently the manual-reorder drop target,
|
||||
// lifted here rather than local per-row state so only one row can ever
|
||||
// show the drop-indicator bar at a time. See HostItem/SidebarTree's
|
||||
// identical fix for the full rationale.
|
||||
const [reorderHoverKey, setReorderHoverKey] = useState<string | null>(null);
|
||||
const [reorderHoverEdge, setReorderHoverEdge] = useState<
|
||||
"before" | "after" | null
|
||||
>(null);
|
||||
const arrangeMode = !arrangeLocked;
|
||||
|
||||
const isTouchOnly =
|
||||
typeof window !== "undefined" && window.matchMedia("(hover: none)").matches;
|
||||
const alwaysShowActions = trayTrigger === "always";
|
||||
const actionsOnly = trayTrigger === "actionsOnly";
|
||||
const clickTrayActive =
|
||||
!alwaysShowActions &&
|
||||
!actionsOnly &&
|
||||
(trayTrigger === "click" || isTouchOnly);
|
||||
const isCompactDensity = density === "compact";
|
||||
|
||||
const visibleRows = collectVisibleRows(folders, query, openFolders);
|
||||
|
||||
// Fixed, exactly-computed row heights rather than estimate-then-measure,
|
||||
// matching SidebarTree.tsx's approach -- estimate-then-measure caused
|
||||
// visible gaps between rows there. All constants below were measured live
|
||||
// against the running app (Playwright getBoundingClientRect) for every
|
||||
// density x trayTrigger x open/closed-tray x credential-type combination.
|
||||
//
|
||||
// Unlike hosts, credential row height genuinely depends on TYPE, not just
|
||||
// density/trayTrigger: only "key" credentials render a connection-buttons
|
||||
// row (deploy/copy-command) -- "password" credentials have no connection
|
||||
// buttons at all, so a password row is shorter than a key row whenever
|
||||
// that row is showing (actionsOnly closed, or always mode).
|
||||
const FOLDER_ROW_HEIGHT = 31.5;
|
||||
// Base closed height with no persistent extras (hover/click modes,
|
||||
// collapsed) -- identical for both credential types since neither the
|
||||
// connection row nor the management row is rendered. Re-measured live
|
||||
// after fixing the comfortable-density row's top/bottom padding asymmetry
|
||||
// (was pt-2.5 pb-2, now the symmetric py-2, 2px shorter).
|
||||
const BASE_ROW_HEIGHT = isCompactDensity ? 23 : 45;
|
||||
// Fully opened (management row revealed, plus connection row for key
|
||||
// creds) -- differs by type since only key creds have a connection row;
|
||||
// measured identical for click-mode-open and actionsOnly-mode-open.
|
||||
const OPEN_KEY_ROW_HEIGHT = isCompactDensity ? 79.25 : 104.75;
|
||||
const OPEN_PASSWORD_ROW_HEIGHT = isCompactDensity ? 56.5 : 78.5;
|
||||
// actionsOnly closed (connection row shown permanently, management row
|
||||
// still collapsed) -- differs by type since only key creds have a
|
||||
// connection row to show.
|
||||
const ACTIONS_ONLY_KEY_ROW_HEIGHT = isCompactDensity ? 50.25 : 75.75;
|
||||
const ACTIONS_ONLY_PASSWORD_ROW_HEIGHT = isCompactDensity ? 27.5 : 49.5;
|
||||
// always mode (both rows permanently shown) -- measured slightly shorter
|
||||
// than a click-opened row since the chevron toggle button isn't rendered.
|
||||
const ALWAYS_KEY_ROW_HEIGHT = isCompactDensity ? 74.75 : 100.25;
|
||||
const ALWAYS_PASSWORD_ROW_HEIGHT = isCompactDensity ? 52 : 74;
|
||||
|
||||
const rowHeight = useCallback(
|
||||
(index: number) => {
|
||||
const row = visibleRows[index];
|
||||
if (!row) return FOLDER_ROW_HEIGHT;
|
||||
if (isFolder(row.item)) return FOLDER_ROW_HEIGHT;
|
||||
const isKey = row.item.type === "key";
|
||||
|
||||
if (alwaysShowActions) {
|
||||
return isKey ? ALWAYS_KEY_ROW_HEIGHT : ALWAYS_PASSWORD_ROW_HEIGHT;
|
||||
}
|
||||
|
||||
const isOpen =
|
||||
(openTrayCredentialId === row.item.id ||
|
||||
openMenuCredentialId === row.item.id) &&
|
||||
(clickTrayActive || actionsOnly);
|
||||
|
||||
if (actionsOnly) {
|
||||
if (isOpen)
|
||||
return isKey ? OPEN_KEY_ROW_HEIGHT : OPEN_PASSWORD_ROW_HEIGHT;
|
||||
return isKey
|
||||
? ACTIONS_ONLY_KEY_ROW_HEIGHT
|
||||
: ACTIONS_ONLY_PASSWORD_ROW_HEIGHT;
|
||||
}
|
||||
|
||||
if (isOpen) return isKey ? OPEN_KEY_ROW_HEIGHT : OPEN_PASSWORD_ROW_HEIGHT;
|
||||
return BASE_ROW_HEIGHT;
|
||||
},
|
||||
[
|
||||
visibleRows,
|
||||
openTrayCredentialId,
|
||||
openMenuCredentialId,
|
||||
clickTrayActive,
|
||||
alwaysShowActions,
|
||||
actionsOnly,
|
||||
BASE_ROW_HEIGHT,
|
||||
OPEN_KEY_ROW_HEIGHT,
|
||||
OPEN_PASSWORD_ROW_HEIGHT,
|
||||
ACTIONS_ONLY_KEY_ROW_HEIGHT,
|
||||
ACTIONS_ONLY_PASSWORD_ROW_HEIGHT,
|
||||
ALWAYS_KEY_ROW_HEIGHT,
|
||||
ALWAYS_PASSWORD_ROW_HEIGHT,
|
||||
],
|
||||
);
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: visibleRows.length,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: rowHeight,
|
||||
overscan: 12,
|
||||
getItemKey: (index) => {
|
||||
const row = visibleRows[index];
|
||||
if (!row) return index;
|
||||
return isFolder(row.item)
|
||||
? `folder:${row.item.name}`
|
||||
: `cred:${row.item.id}`;
|
||||
},
|
||||
});
|
||||
|
||||
// Single tree-level indicator off the virtualizer's slot geometry -- see
|
||||
// SidebarTree for why rows can't place this themselves.
|
||||
const reorderIndicatorTop = (() => {
|
||||
if (!arrangeMode || !reorderHoverKey || !reorderHoverEdge) return null;
|
||||
const index = visibleRows.findIndex((row) => {
|
||||
const key = isFolder(row.item)
|
||||
? `folder:${row.item.name}`
|
||||
: `cred:${row.item.id}`;
|
||||
return key === reorderHoverKey;
|
||||
});
|
||||
if (index === -1) return null;
|
||||
const slot = virtualizer
|
||||
.getVirtualItems()
|
||||
.find((vItem) => vItem.index === index);
|
||||
if (!slot) return null;
|
||||
return reorderHoverEdge === "before" ? slot.start : slot.start + slot.size;
|
||||
})();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
virtualizer.measure();
|
||||
}, [
|
||||
virtualizer,
|
||||
openFolders,
|
||||
openTrayCredentialId,
|
||||
openMenuCredentialId,
|
||||
query,
|
||||
visibleRows.length,
|
||||
density,
|
||||
trayTrigger,
|
||||
]);
|
||||
|
||||
function toggleFolder(name: string) {
|
||||
setOpenFolders((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(name)) next.delete(name);
|
||||
else next.add(name);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a credential drop into its new position, scoped to the target
|
||||
* credential's own folder, and moves it there when the drop crossed
|
||||
* folders. Credential folders themselves are always alphabetical -- they
|
||||
* have no stored order, so only credentials are draggable.
|
||||
*/
|
||||
/**
|
||||
* Credential dropped on a folder header: lands at the end of that folder.
|
||||
* It needs an explicit sortOrder for the same reason hosts do -- a null
|
||||
* one sorts last under manual sort and then falls back to alphabetical,
|
||||
* throwing the placement away.
|
||||
*/
|
||||
async function handleDropIntoFolder(credentialId: string, folder: string) {
|
||||
const destination = folders
|
||||
.find((f) => f.name === folder)
|
||||
?.children.filter((c) => c.id !== credentialId);
|
||||
try {
|
||||
await onMoveCredentialToFolder?.(credentialId, folder);
|
||||
if (destination) {
|
||||
const ordered = [
|
||||
...destination.filter((c) => c.sortOrder != null),
|
||||
...destination.filter((c) => c.sortOrder == null),
|
||||
];
|
||||
await reorderCredentials(
|
||||
renumberSiblings([...ordered, { id: credentialId }]).map((r) => ({
|
||||
id: Number(r.id),
|
||||
sortOrder: r.sortOrder,
|
||||
})),
|
||||
);
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent("termix:credentials-changed"));
|
||||
} catch {
|
||||
toast.error(t("credentials.failedToReorder"));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReorderDrop(
|
||||
targetKey: string,
|
||||
position: "before" | "after",
|
||||
) {
|
||||
const draggedKey = draggedReorderKey;
|
||||
setDraggedReorderKey(null);
|
||||
setReorderHoverKey(null);
|
||||
setReorderHoverEdge(null);
|
||||
if (!draggedKey) return;
|
||||
if (!draggedKey.startsWith("cred:") || !targetKey.startsWith("cred:"))
|
||||
return;
|
||||
|
||||
// From every folder's children, not visibleRows: a collapsed folder
|
||||
// contributes no visible rows, so planning off the rendered list
|
||||
// compared against a partial sibling group.
|
||||
const rows = folders.flatMap((folder) =>
|
||||
folder.children.map((cred) => ({
|
||||
key: `cred:${cred.id}`,
|
||||
parentKey: `folder:${cred.folder || "Uncategorized"}`,
|
||||
sortOrder: cred.sortOrder,
|
||||
})),
|
||||
);
|
||||
|
||||
const plan = planReorder(rows, draggedKey, targetKey, position);
|
||||
if (!plan) return;
|
||||
|
||||
const draggedId = draggedKey.slice("cred:".length);
|
||||
|
||||
try {
|
||||
if (plan.movedTo !== null) {
|
||||
const folder = plan.movedTo.slice("folder:".length);
|
||||
await onMoveCredentialToFolder?.(draggedId, folder);
|
||||
}
|
||||
await reorderCredentials(
|
||||
plan.positions.map((pos) => ({
|
||||
id: Number(pos.key.slice("cred:".length)),
|
||||
sortOrder: pos.sortOrder,
|
||||
})),
|
||||
);
|
||||
window.dispatchEvent(new CustomEvent("termix:credentials-changed"));
|
||||
} catch {
|
||||
toast.error(t("credentials.failedToReorder"));
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="relative flex flex-col flex-1 min-h-0">
|
||||
<div className="flex-1 min-h-0 overflow-y-auto 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 bg-muted/50 animate-pulse shrink-0" />
|
||||
<div
|
||||
className="h-3 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("credentials.loadingCredentials")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col flex-1 min-h-0">
|
||||
<div ref={parentRef} className="flex-1 min-h-0 overflow-y-auto">
|
||||
{visibleRows.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">
|
||||
{query
|
||||
? t("credentials.noCredentialsMatchSearch")
|
||||
: t("credentials.noCredentialsFound")}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="relative w-full"
|
||||
style={{ height: virtualizer.getTotalSize() }}
|
||||
>
|
||||
{reorderIndicatorTop !== null && (
|
||||
<ReorderIndicator top={reorderIndicatorTop} />
|
||||
)}
|
||||
{virtualizer.getVirtualItems().map((vItem) => {
|
||||
const row = visibleRows[vItem.index];
|
||||
if (!row) return null;
|
||||
const { item, depth } = row;
|
||||
return (
|
||||
<div
|
||||
key={vItem.key}
|
||||
data-index={vItem.index}
|
||||
ref={virtualizer.measureElement}
|
||||
className="absolute top-0 left-0 w-full"
|
||||
style={{ transform: `translateY(${vItem.start}px)` }}
|
||||
>
|
||||
{isFolder(item) ? (
|
||||
<CredentialFolderItem
|
||||
folder={item}
|
||||
isOpen={openFolders.has(item.name)}
|
||||
stripeIndex={vItem.index}
|
||||
query={query}
|
||||
editingFolderName={editingFolderName}
|
||||
editingFolderValue={editingFolderValue}
|
||||
onToggleFolder={toggleFolder}
|
||||
onEditingFolderNameChange={onEditingFolderNameChange}
|
||||
onEditingFolderValueChange={onEditingFolderValueChange}
|
||||
onRenameFolder={onRenameFolder}
|
||||
onDeleteFolder={onDeleteFolder}
|
||||
depth={depth}
|
||||
arrangeMode={arrangeMode}
|
||||
draggedCredentialId={draggedCredentialId}
|
||||
onDropCredential={(credentialId) => {
|
||||
setDraggedCredentialId(null);
|
||||
void handleDropIntoFolder(credentialId, item.name);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<CredentialItem
|
||||
cred={item}
|
||||
usedByCount={usedByCounts?.get(item.id) ?? 0}
|
||||
termixIdLinked={termixIdLinkedIds?.has(Number(item.id))}
|
||||
query={query}
|
||||
stripeIndex={vItem.index}
|
||||
isMenuOpen={openMenuCredentialId === item.id}
|
||||
onMenuOpenChange={(open) =>
|
||||
setOpenMenuCredentialId(open ? item.id : null)
|
||||
}
|
||||
isTrayOpen={openTrayCredentialId === item.id}
|
||||
onTrayOpenChange={(open) =>
|
||||
setOpenTrayCredentialId(open ? item.id : null)
|
||||
}
|
||||
onDragStart={() => {
|
||||
// Both arm together: the drop target picks whether
|
||||
// this ends up a reorder or a folder move.
|
||||
setDraggedReorderKey(`cred:${item.id}`);
|
||||
setDraggedCredentialId(item.id);
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
setDraggedReorderKey(null);
|
||||
setDraggedCredentialId(null);
|
||||
setReorderHoverKey(null);
|
||||
setReorderHoverEdge(null);
|
||||
}}
|
||||
depth={depth}
|
||||
density={density}
|
||||
trayTrigger={trayTrigger}
|
||||
showTags={showTags}
|
||||
arrangeMode={arrangeMode}
|
||||
isDragging={draggedReorderKey === `cred:${item.id}`}
|
||||
onReorderDrop={(position) =>
|
||||
handleReorderDrop(`cred:${item.id}`, position)
|
||||
}
|
||||
isReorderHovered={reorderHoverKey === `cred:${item.id}`}
|
||||
reorderHoverEdge={reorderHoverEdge}
|
||||
onReorderHoverChange={(edge) => {
|
||||
setReorderHoverKey(edge ? `cred:${item.id}` : null);
|
||||
setReorderHoverEdge(edge);
|
||||
}}
|
||||
onDeploy={() => onDeployCredential(item)}
|
||||
onEdit={() => onEditCredential(item)}
|
||||
onDelete={() => onDeleteCredential(item)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ChevronRight,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
Pencil,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import type { CredentialFolder } from "../visible-rows";
|
||||
|
||||
const UNCATEGORIZED = "Uncategorized";
|
||||
|
||||
export function CredentialFolderItem({
|
||||
folder,
|
||||
isOpen,
|
||||
stripeIndex,
|
||||
query = "",
|
||||
editingFolderName,
|
||||
editingFolderValue,
|
||||
onToggleFolder,
|
||||
onEditingFolderNameChange,
|
||||
onEditingFolderValueChange,
|
||||
onRenameFolder,
|
||||
onDeleteFolder,
|
||||
depth = 0,
|
||||
arrangeMode = false,
|
||||
draggedCredentialId,
|
||||
onDropCredential,
|
||||
}: {
|
||||
folder: CredentialFolder;
|
||||
isOpen: boolean;
|
||||
stripeIndex?: number;
|
||||
query?: string;
|
||||
editingFolderName: string | null;
|
||||
editingFolderValue: string;
|
||||
onToggleFolder: (name: string) => void;
|
||||
onEditingFolderNameChange: (name: string | null) => void;
|
||||
onEditingFolderValueChange: (value: string) => void;
|
||||
onRenameFolder: (folder: string, newName: string) => Promise<void>;
|
||||
onDeleteFolder?: (folder: string) => void;
|
||||
depth?: number;
|
||||
/** When true (rearranging unlocked), the header accepts dropped
|
||||
* credentials. Credential folders themselves have no stored order, so
|
||||
* they are never draggable. */
|
||||
arrangeMode?: boolean;
|
||||
/** Id of the credential currently being dragged (outside manual sort
|
||||
* mode), if any -- drives the drop-target highlight below. */
|
||||
draggedCredentialId?: string | null;
|
||||
/** Fires when a dragged credential is dropped on this folder header to
|
||||
* reassign it, outside manual sort mode. */
|
||||
onDropCredential?: (credentialId: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const isRenaming = editingFolderName === folder.name;
|
||||
const canManage = folder.name !== UNCATEGORIZED;
|
||||
const actionButtonClass =
|
||||
"flex items-center justify-center size-6 text-muted-foreground/60 hover:text-foreground hover:bg-background/80 transition-colors";
|
||||
|
||||
const acceptsCredentialDrop = arrangeMode && !!draggedCredentialId;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative"
|
||||
style={depth > 0 ? { paddingLeft: depth * 12 } : undefined}
|
||||
>
|
||||
<div
|
||||
onDragOver={(e) => {
|
||||
if (!acceptsCredentialDrop) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragOver(true);
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
if (e.currentTarget === e.target) setDragOver(false);
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
if (!acceptsCredentialDrop || !draggedCredentialId) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragOver(false);
|
||||
onDropCredential?.(draggedCredentialId);
|
||||
}}
|
||||
onClick={() => !query && !isRenaming && onToggleFolder(folder.name)}
|
||||
className={`group/folder flex items-center gap-2 w-full pl-2.5 pr-2 py-1.5 transition-colors text-left cursor-pointer ${
|
||||
isOpen ? "bg-muted/40" : "hover:bg-muted/30"
|
||||
} ${(stripeIndex ?? 0) % 2 === 1 && !isOpen ? "bg-muted/[0.08]" : ""} ${dragOver ? "ring-1 ring-inset ring-accent-brand bg-accent-brand/10" : ""}`}
|
||||
>
|
||||
<ChevronRight
|
||||
className={`size-3.5 shrink-0 text-muted-foreground/60 transition-transform ${isOpen ? "rotate-90" : ""}`}
|
||||
/>
|
||||
{isOpen ? (
|
||||
<FolderOpen className="size-4 shrink-0 text-accent-brand" />
|
||||
) : (
|
||||
<Folder className="size-4 shrink-0 text-muted-foreground/70" />
|
||||
)}
|
||||
{isRenaming ? (
|
||||
<>
|
||||
<input
|
||||
autoFocus
|
||||
value={editingFolderValue}
|
||||
onChange={(e) => onEditingFolderValueChange(e.target.value)}
|
||||
onBlur={async () => {
|
||||
const newName = editingFolderValue.trim();
|
||||
onEditingFolderNameChange(null);
|
||||
if (newName && newName !== folder.name) {
|
||||
await onRenameFolder(folder.name, newName);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") e.currentTarget.blur();
|
||||
if (e.key === "Escape") onEditingFolderNameChange(null);
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-[13px] font-bold 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="min-w-0 flex-1 truncate">
|
||||
<span className="text-[13px] font-bold text-foreground tracking-tight">
|
||||
{folder.name}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-[10px] tabular-nums shrink-0 ml-1 px-1.5 py-[1px] bg-muted/70 text-muted-foreground/50">
|
||||
{folder.children.length}
|
||||
</span>
|
||||
{canManage && (
|
||||
<span className="flex items-center gap-0.5 ml-0.5 opacity-0 group-hover/folder:opacity-100 transition-opacity">
|
||||
<button
|
||||
type="button"
|
||||
title={t("credentials.renameFolder")}
|
||||
className={actionButtonClass}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEditingFolderNameChange(folder.name);
|
||||
onEditingFolderValueChange(folder.name);
|
||||
}}
|
||||
>
|
||||
<Pencil className="size-3" />
|
||||
</button>
|
||||
{onDeleteFolder && (
|
||||
<button
|
||||
type="button"
|
||||
title={t("credentials.deleteFolder")}
|
||||
className={`${actionButtonClass} hover:text-destructive`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeleteFolder(folder.name);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
getCredentialSidebarPreferences,
|
||||
saveCredentialSidebarPreferences,
|
||||
getUserPreferences,
|
||||
} from "@/main-axios";
|
||||
import {
|
||||
defaultCredentialSidebarPreferences,
|
||||
sanitizeCredentialSidebarPreferences,
|
||||
type CredentialSidebarPreferences,
|
||||
} from "@/types/credential-sidebar-preferences";
|
||||
|
||||
const SAVE_DEBOUNCE_MS = 500;
|
||||
const LS_KEY = "credentialSidebarPreferences";
|
||||
const SYNC_EVENT = "credentialSidebarPreferencesChanged";
|
||||
|
||||
function readCache(): CredentialSidebarPreferences | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_KEY);
|
||||
if (!raw) return null;
|
||||
return sanitizeCredentialSidebarPreferences(JSON.parse(raw));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeCache(preferences: CredentialSidebarPreferences) {
|
||||
try {
|
||||
localStorage.setItem(LS_KEY, JSON.stringify(preferences));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified credential sidebar preferences (sort, filters, open folders,
|
||||
* density, tag visibility, tray trigger), cached in localStorage for instant
|
||||
* paint and synced to the backend so they follow the user across devices
|
||||
* when storageMode is "cloud" -- same cache-then-fetch-then-debounced-save
|
||||
* shape as useHostSidebarPreferences, but a fully independent parallel
|
||||
* system (separate table, separate localStorage key, separate sync event),
|
||||
* not a shared blob with hosts.
|
||||
*
|
||||
* Multiple components (CredentialsPanel, CustomizeCredentialsSidebarPanel)
|
||||
* can call this hook at the same time. Each holds its own React state, so a
|
||||
* write in one instance broadcasts a same-tab custom event that every other
|
||||
* instance listens for to stay in sync.
|
||||
*/
|
||||
export function useCredentialSidebarPreferences() {
|
||||
const [preferences, setPreferences] = useState<CredentialSidebarPreferences>(
|
||||
() => readCache() ?? defaultCredentialSidebarPreferences(),
|
||||
);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
getCredentialSidebarPreferences()
|
||||
.then((remote) => {
|
||||
if (cancelled) return;
|
||||
setPreferences(remote);
|
||||
writeCache(remote);
|
||||
})
|
||||
.catch(() => {
|
||||
/* keep cache/default */
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoaded(true);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
const cached = readCache();
|
||||
if (cached) setPreferences(cached);
|
||||
};
|
||||
window.addEventListener(SYNC_EVENT, handler);
|
||||
return () => window.removeEventListener(SYNC_EVENT, handler);
|
||||
}, []);
|
||||
|
||||
const update = useCallback(
|
||||
(
|
||||
patch:
|
||||
| Partial<CredentialSidebarPreferences>
|
||||
| ((
|
||||
prev: CredentialSidebarPreferences,
|
||||
) => CredentialSidebarPreferences),
|
||||
) => {
|
||||
setPreferences((prev) => {
|
||||
const next = sanitizeCredentialSidebarPreferences(
|
||||
typeof patch === "function" ? patch(prev) : { ...prev, ...patch },
|
||||
);
|
||||
writeCache(next);
|
||||
window.dispatchEvent(new Event(SYNC_EVENT));
|
||||
|
||||
if (saveTimer.current) clearTimeout(saveTimer.current);
|
||||
saveTimer.current = setTimeout(() => {
|
||||
getUserPreferences()
|
||||
.then((prefs) => {
|
||||
if (prefs.storageMode !== "cloud") return;
|
||||
return saveCredentialSidebarPreferences(next);
|
||||
})
|
||||
.catch(() => {
|
||||
/* best-effort; cache already holds it */
|
||||
});
|
||||
}, SAVE_DEBOUNCE_MS);
|
||||
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (saveTimer.current) clearTimeout(saveTimer.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { preferences, update, loaded };
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { CredentialSidebarTree } from "./CredentialSidebarTree";
|
||||
export { isFolder, type CredentialFolder } from "./visible-rows";
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { Credential } from "@/types/ui-types";
|
||||
|
||||
/**
|
||||
* Credential folders are explicitly flat (no nesting), unlike HostFolder --
|
||||
* so this has no depth/path-string tree logic, just a name and a list of
|
||||
* credentials. `children` matches HostFolder's shape for isFolder()
|
||||
* structural parity, but never contains another CredentialFolder.
|
||||
*/
|
||||
export interface CredentialFolder {
|
||||
name: string;
|
||||
children: Credential[];
|
||||
}
|
||||
|
||||
export function isFolder(
|
||||
item: Credential | CredentialFolder,
|
||||
): item is CredentialFolder {
|
||||
return "children" in item;
|
||||
}
|
||||
|
||||
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))
|
||||
);
|
||||
}
|
||||
|
||||
export type VirtualRow = {
|
||||
item: Credential | CredentialFolder;
|
||||
depth: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the flattened, currently-visible row list for the virtualizer.
|
||||
* Folders are always depth 0, credentials always depth 1 -- there is no
|
||||
* deeper nesting to walk, unlike hosts' recursive collectVisibleRows.
|
||||
*/
|
||||
export function collectVisibleRows(
|
||||
folders: CredentialFolder[],
|
||||
query: string,
|
||||
openSet: Set<string>,
|
||||
): VirtualRow[] {
|
||||
const out: VirtualRow[] = [];
|
||||
const q = query.trim().toLowerCase();
|
||||
|
||||
for (const folder of folders) {
|
||||
const matchingCreds = q
|
||||
? folder.children.filter((c) => credentialMatchesQuery(c, q))
|
||||
: folder.children;
|
||||
if (matchingCreds.length === 0) continue;
|
||||
|
||||
out.push({ item: folder, depth: 0 });
|
||||
const isOpen = q ? true : openSet.has(folder.name);
|
||||
if (isOpen) {
|
||||
for (const cred of matchingCreds) {
|
||||
out.push({ item: cred, depth: 1 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
export function collectAllFolderNames(folders: CredentialFolder[]): string[] {
|
||||
return folders.map((f) => f.name);
|
||||
}
|
||||
+36
-16
@@ -1,13 +1,7 @@
|
||||
import type { Host, HostFolder } from "@/types/ui-types";
|
||||
import type { SortKey } from "@/types/host-sidebar-preferences";
|
||||
|
||||
export type SortKey =
|
||||
| "default"
|
||||
| "name-asc"
|
||||
| "name-desc"
|
||||
| "ip-asc"
|
||||
| "ip-desc"
|
||||
| "status-online"
|
||||
| "status-offline";
|
||||
export type { SortKey };
|
||||
|
||||
const SORT_KEYS = new Set<SortKey>([
|
||||
"default",
|
||||
@@ -17,6 +11,7 @@ const SORT_KEYS = new Set<SortKey>([
|
||||
"ip-desc",
|
||||
"status-online",
|
||||
"status-offline",
|
||||
"manual",
|
||||
]);
|
||||
|
||||
export function resolveHostSortPreferences(
|
||||
@@ -49,23 +44,48 @@ export function sortHostTree(
|
||||
const bIsFolder = isFolder(b);
|
||||
if (aIsFolder && !bIsFolder) return -1;
|
||||
if (!aIsFolder && bIsFolder) return 1;
|
||||
if (aIsFolder && bIsFolder) return a.name.localeCompare(b.name);
|
||||
if (aIsFolder && bIsFolder) {
|
||||
if (key === "manual") {
|
||||
const aOrder = a.sortOrder;
|
||||
const bOrder = b.sortOrder;
|
||||
if (aOrder == null && bOrder == null)
|
||||
return a.name.localeCompare(b.name);
|
||||
if (aOrder == null) return 1;
|
||||
if (bOrder == null) return -1;
|
||||
return aOrder - bOrder;
|
||||
}
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
|
||||
if (pinnedFirst && !!a.pin !== !!b.pin) return b.pin ? 1 : -1;
|
||||
// The three branches above return for every combination involving a
|
||||
// folder, so both sides are hosts from here on.
|
||||
const hostA = a as Host;
|
||||
const hostB = b as Host;
|
||||
|
||||
if (pinnedFirst && !!hostA.pin !== !!hostB.pin) return hostB.pin ? 1 : -1;
|
||||
|
||||
switch (key) {
|
||||
case "name-asc":
|
||||
return a.name.localeCompare(b.name);
|
||||
return hostA.name.localeCompare(hostB.name);
|
||||
case "name-desc":
|
||||
return b.name.localeCompare(a.name);
|
||||
return hostB.name.localeCompare(hostA.name);
|
||||
case "ip-asc":
|
||||
return a.ip.localeCompare(b.ip);
|
||||
return hostA.ip.localeCompare(hostB.ip);
|
||||
case "ip-desc":
|
||||
return b.ip.localeCompare(a.ip);
|
||||
return hostB.ip.localeCompare(hostA.ip);
|
||||
case "status-online":
|
||||
return Number(b.online) - Number(a.online);
|
||||
return Number(hostB.online) - Number(hostA.online);
|
||||
case "status-offline":
|
||||
return Number(a.online) - Number(b.online);
|
||||
return Number(hostA.online) - Number(hostB.online);
|
||||
case "manual": {
|
||||
const aOrder = hostA.sortOrder;
|
||||
const bOrder = hostB.sortOrder;
|
||||
if (aOrder == null && bOrder == null)
|
||||
return hostA.name.localeCompare(hostB.name);
|
||||
if (aOrder == null) return 1;
|
||||
if (bOrder == null) return -1;
|
||||
return aOrder - bOrder;
|
||||
}
|
||||
case "default":
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -33,7 +33,9 @@ export function createQuickConnectHost(input: QuickConnectInput): Host {
|
||||
enableTunnel: true,
|
||||
enableDocker: true,
|
||||
enableProxmox: false,
|
||||
enableProxmoxStats: false,
|
||||
enableTmuxMonitor: false,
|
||||
enableTerminalToolbar: true,
|
||||
enableSsh: true,
|
||||
enableRdp: false,
|
||||
enableVnc: false,
|
||||
@@ -68,6 +70,7 @@ export function quickConnectHostToPayload(host: Host): SSHHostData {
|
||||
enableDocker: host.enableDocker,
|
||||
enableProxmox: host.enableProxmox,
|
||||
enableTmuxMonitor: host.enableTmuxMonitor,
|
||||
enableTerminalToolbar: host.enableTerminalToolbar,
|
||||
showTerminalInSidebar: true,
|
||||
showFileManagerInSidebar: true,
|
||||
showTunnelInSidebar: true,
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
export interface CredentialOption {
|
||||
id: string;
|
||||
name: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
export function toCredentialOption(
|
||||
credential: Record<string, unknown>,
|
||||
): CredentialOption | null {
|
||||
if (
|
||||
(typeof credential.id !== "number" && typeof credential.id !== "string") ||
|
||||
typeof credential.name !== "string"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: String(credential.id),
|
||||
name: credential.name,
|
||||
username:
|
||||
typeof credential.username === "string" ? credential.username : "",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import {
|
||||
Boxes,
|
||||
Braces,
|
||||
Clock,
|
||||
Fingerprint,
|
||||
Hammer,
|
||||
KeyRound,
|
||||
LayoutPanelLeft,
|
||||
LayoutTemplate,
|
||||
Network,
|
||||
Play,
|
||||
Plug,
|
||||
ScrollText,
|
||||
Server,
|
||||
Settings,
|
||||
Sparkles,
|
||||
TerminalSquare,
|
||||
Usb,
|
||||
User,
|
||||
Workflow,
|
||||
Zap,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
|
||||
/**
|
||||
* The one list of navigation destinations.
|
||||
*
|
||||
* This used to be duplicated in four places -- AppRail's button array, the
|
||||
* visibility toggles in UserProfilePanel, AppShell's sidebar title map, and
|
||||
* MobileBottomBar's own primary/more lists -- which drifted: half the sidebar
|
||||
* titles were hardcoded English, the alerts entry had no visibility toggle,
|
||||
* and the mobile bar ignored hidden tabs entirely. Everything now derives from
|
||||
* here, so adding a destination is a single edit.
|
||||
*/
|
||||
export interface RailItemDef {
|
||||
/** Matches RailView, or a TabType for entries that open a tab instead. */
|
||||
id: string;
|
||||
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. */
|
||||
kind?: "tab";
|
||||
/** Always-available destinations that users cannot hide. */
|
||||
alwaysVisible?: boolean;
|
||||
/** Renders a separator after this item in the rail. */
|
||||
separatorAfter?: boolean;
|
||||
/** Shown on the mobile bottom bar's primary row rather than its More menu. */
|
||||
mobilePrimary?: boolean;
|
||||
/**
|
||||
* Can also open as a full-width tab in the main area, via ctrl/middle-click
|
||||
* or the rail context menu. The id doubles as the TabType.
|
||||
*/
|
||||
promotable?: boolean;
|
||||
/**
|
||||
* Can be opened in the right dock. Reference panels only -- editors stay in
|
||||
* the left sidebar, which is the only dock that widens for them.
|
||||
*/
|
||||
rightDockable?: boolean;
|
||||
/** Desktop app only. Hidden in the browser build, including its toggle. */
|
||||
electronOnly?: boolean;
|
||||
}
|
||||
|
||||
export const RAIL_ITEMS: RailItemDef[] = [
|
||||
{ id: "hosts", icon: Server, labelKey: "nav.hosts", mobilePrimary: true },
|
||||
{
|
||||
id: "credentials",
|
||||
icon: KeyRound,
|
||||
labelKey: "nav.credentials",
|
||||
separatorAfter: true,
|
||||
},
|
||||
{
|
||||
id: "termix-id",
|
||||
icon: Fingerprint,
|
||||
labelKey: "nav.termixId",
|
||||
separatorAfter: true,
|
||||
promotable: true,
|
||||
},
|
||||
{
|
||||
id: "connections",
|
||||
icon: Plug,
|
||||
labelKey: "nav.connections",
|
||||
separatorAfter: true,
|
||||
rightDockable: true,
|
||||
},
|
||||
{
|
||||
id: "quick-connect",
|
||||
icon: Zap,
|
||||
labelKey: "nav.quickConnect",
|
||||
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: "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,
|
||||
electronOnly: true,
|
||||
},
|
||||
{
|
||||
id: "network_graph",
|
||||
icon: Network,
|
||||
labelKey: "nav.networkGraph",
|
||||
kind: "tab",
|
||||
separatorAfter: true,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function visibleRailItems(): RailItemDef[] {
|
||||
const electron = isElectron();
|
||||
return RAIL_ITEMS.filter((item) => !item.electronOnly || electron);
|
||||
}
|
||||
|
||||
/**
|
||||
* Destinations that live outside the rail's hideable list but still need a
|
||||
* 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);
|
||||
|
||||
/** 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 a user is allowed to hide, mirroring HideableRailView. */
|
||||
export const HIDEABLE_RAIL_IDS = RAIL_ITEMS.filter(
|
||||
(item) => !item.alwaysVisible,
|
||||
).map((item) => item.id);
|
||||
|
||||
const LABEL_KEYS: Record<string, string> = Object.fromEntries(
|
||||
[...RAIL_ITEMS, ...RAIL_UTILITY_ITEMS].map((item) => [
|
||||
item.id,
|
||||
item.labelKey,
|
||||
]),
|
||||
);
|
||||
|
||||
/** Translated label for any rail destination. */
|
||||
export function railItemLabel(id: string, t: (key: string) => string): string {
|
||||
const key = LABEL_KEYS[id];
|
||||
return key ? t(key) : id;
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Manual drag-to-reorder position math, shared by host and folder reordering.
|
||||
* Sparse integers (gaps of 1000) mean most drops only rewrite the moved
|
||||
* item's own sortOrder -- inserting between two neighbors is just the
|
||||
* midpoint between their values. Renumbering the whole sibling group only
|
||||
* happens when a gap runs out (two adjacent siblings differ by 1).
|
||||
*/
|
||||
|
||||
const GAP = 1000;
|
||||
|
||||
export interface Orderable {
|
||||
sortOrder?: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the sortOrder for an item dropped between `before` and `after`
|
||||
* (either may be absent for a drop at the start/end of the list). Returns
|
||||
* null when the gap between neighbors has been exhausted and the caller
|
||||
* should renumber the whole sibling group instead via renumberSiblings.
|
||||
*/
|
||||
export function computeDropSortOrder(
|
||||
before: Orderable | null,
|
||||
after: Orderable | null,
|
||||
): number | null {
|
||||
const beforeOrder = before?.sortOrder ?? null;
|
||||
const afterOrder = after?.sortOrder ?? null;
|
||||
|
||||
if (beforeOrder == null && afterOrder == null) return GAP;
|
||||
if (beforeOrder == null) return (afterOrder as number) - GAP;
|
||||
if (afterOrder == null) return beforeOrder + GAP;
|
||||
|
||||
const mid = Math.floor((beforeOrder + afterOrder) / 2);
|
||||
if (mid <= beforeOrder || mid >= afterOrder) return null;
|
||||
return mid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assigns fresh, evenly-spaced sortOrder values (0, GAP, 2*GAP, ...) to an
|
||||
* ordered list of sibling ids, for when computeDropSortOrder signals the
|
||||
* available gap is exhausted.
|
||||
*/
|
||||
export function renumberSiblings<T extends { id: string | number }>(
|
||||
orderedIds: T[],
|
||||
): { id: string | number; sortOrder: number }[] {
|
||||
return orderedIds.map((item, index) => ({
|
||||
id: item.id,
|
||||
sortOrder: index * GAP,
|
||||
}));
|
||||
}
|
||||
|
||||
export type ReorderKind = "host" | "folder" | "cred";
|
||||
|
||||
export interface ReorderRow {
|
||||
/** Stable key, e.g. "host:12" or "folder:Prod / Web". */
|
||||
key: string;
|
||||
/** Identifies the row's parent so siblings can be scoped to one group. */
|
||||
parentKey: string;
|
||||
sortOrder?: number | null;
|
||||
}
|
||||
|
||||
export interface ReorderPlan {
|
||||
/** Rows to write, already carrying their new sortOrder. */
|
||||
positions: { key: string; sortOrder: number }[];
|
||||
/** New parent for the dragged row, when the drop crossed groups. */
|
||||
movedTo: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a drop into the exact writes it implies.
|
||||
*
|
||||
* Siblings are scoped to the drop target's parent, not to every row of the
|
||||
* same type -- dropping onto a row in another folder has to compare against
|
||||
* that folder's own neighbors, and reports the parent change so the caller
|
||||
* can persist the move alongside the new position.
|
||||
*
|
||||
* Returns null when the drop is a no-op (unknown rows, or dropping an item
|
||||
* back onto the slot it already occupies).
|
||||
*/
|
||||
export function planReorder(
|
||||
rows: ReorderRow[],
|
||||
draggedKey: string,
|
||||
targetKey: string,
|
||||
position: "before" | "after",
|
||||
): ReorderPlan | null {
|
||||
if (draggedKey === targetKey) return null;
|
||||
|
||||
const dragged = rows.find((r) => r.key === draggedKey);
|
||||
const target = rows.find((r) => r.key === targetKey);
|
||||
if (!dragged || !target) return null;
|
||||
|
||||
const siblings = rows.filter((r) => r.parentKey === target.parentKey);
|
||||
const targetIndex = siblings.findIndex((r) => r.key === targetKey);
|
||||
if (targetIndex === -1) return null;
|
||||
|
||||
const before =
|
||||
position === "before" ? (siblings[targetIndex - 1] ?? null) : target;
|
||||
const after =
|
||||
position === "before" ? target : (siblings[targetIndex + 1] ?? null);
|
||||
|
||||
// Already sitting exactly where the drop would put it.
|
||||
if (before?.key === draggedKey || after?.key === draggedKey) return null;
|
||||
|
||||
const movedTo =
|
||||
dragged.parentKey === target.parentKey ? null : target.parentKey;
|
||||
|
||||
// Hosts start life with sortOrder null, and null sorts last in manual
|
||||
// mode. Writing a single value for the moved row against null neighbours
|
||||
// therefore says nothing about where it landed -- the group has to be
|
||||
// numbered as a whole before any position is meaningful. A cross-folder
|
||||
// drop renumbers for the same reason: the dragged row arrives carrying a
|
||||
// sortOrder from its old folder, which means nothing in this one.
|
||||
const needsFullRenumber =
|
||||
movedTo !== null ||
|
||||
siblings.some((r) => r.key !== draggedKey && r.sortOrder == null);
|
||||
|
||||
const sortOrder = needsFullRenumber
|
||||
? null
|
||||
: computeDropSortOrder(
|
||||
before && before.key !== draggedKey ? before : null,
|
||||
after && after.key !== draggedKey ? after : null,
|
||||
);
|
||||
|
||||
if (sortOrder !== null) {
|
||||
return { positions: [{ key: draggedKey, sortOrder }], movedTo };
|
||||
}
|
||||
|
||||
// Gap exhausted: renumber the whole destination group with the dragged row
|
||||
// spliced into its new slot.
|
||||
const ordered = siblings.filter((r) => r.key !== draggedKey);
|
||||
const targetPos = ordered.findIndex((r) => r.key === targetKey);
|
||||
const insertAt = position === "before" ? targetPos : targetPos + 1;
|
||||
ordered.splice(insertAt, 0, dragged);
|
||||
|
||||
return {
|
||||
positions: renumberSiblings(ordered.map((r) => ({ id: r.key }))).map(
|
||||
(r) => ({ key: String(r.id), sortOrder: r.sortOrder }),
|
||||
),
|
||||
movedTo,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FolderOpen, Pencil, Share2, Trash2 } from "lucide-react";
|
||||
import type { HostFolder } from "@/types/ui-types";
|
||||
|
||||
export function FolderActions({
|
||||
folder,
|
||||
onOpenAllSessions,
|
||||
onShareFolder,
|
||||
onManageFolder,
|
||||
onDeleteFolder,
|
||||
}: {
|
||||
folder: HostFolder;
|
||||
onOpenAllSessions: (folder: HostFolder) => void;
|
||||
onShareFolder?: (folder: HostFolder) => void;
|
||||
onManageFolder: (folder: HostFolder) => void;
|
||||
onDeleteFolder: (folder: HostFolder) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const actionButtonClass =
|
||||
"flex items-center justify-center size-6 text-muted-foreground/60 hover:text-foreground hover:bg-background/80 transition-colors";
|
||||
return (
|
||||
<span className="flex items-center gap-0.5 ml-0.5 opacity-0 group-hover/folder:opacity-100 transition-opacity">
|
||||
<button
|
||||
type="button"
|
||||
title={t("hosts.openAllSessions")}
|
||||
className={actionButtonClass}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenAllSessions(folder);
|
||||
}}
|
||||
>
|
||||
<FolderOpen className="size-3" />
|
||||
</button>
|
||||
{onShareFolder && (
|
||||
<button
|
||||
type="button"
|
||||
title={t("hosts.shareFolder")}
|
||||
className={actionButtonClass}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onShareFolder(folder);
|
||||
}}
|
||||
>
|
||||
<Share2 className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
title={t("hosts.editFolder")}
|
||||
className={actionButtonClass}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onManageFolder(folder);
|
||||
}}
|
||||
>
|
||||
<Pencil className="size-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title={t("hosts.deleteFolder")}
|
||||
className={`${actionButtonClass} hover:text-destructive`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeleteFolder(folder);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
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,
|
||||
HostTrayTrigger,
|
||||
} from "@/types/host-sidebar-preferences";
|
||||
import { FolderIconEl } from "@/components/folder-style";
|
||||
import { useServerStatus } from "@/lib/ServerStatusContext";
|
||||
import { HostItem, statusCheckEnabled } from "../HostItem/HostItem";
|
||||
import { isFolder, folderHasMatch, collectAllHosts } from "../visible-rows";
|
||||
import { FolderActions } from "./FolderActions";
|
||||
|
||||
export function folderHostCount(folder: HostFolder): {
|
||||
total: number;
|
||||
online: number;
|
||||
} {
|
||||
let total = 0,
|
||||
online = 0;
|
||||
for (const child of folder.children) {
|
||||
if (isFolder(child)) {
|
||||
const c = folderHostCount(child);
|
||||
total += c.total;
|
||||
online += c.online;
|
||||
} else {
|
||||
total++;
|
||||
if (child.online) online++;
|
||||
}
|
||||
}
|
||||
return { total, online };
|
||||
}
|
||||
|
||||
export function FolderItem({
|
||||
folder,
|
||||
depth = 0,
|
||||
onOpenTab,
|
||||
onEditHost,
|
||||
onShareHost,
|
||||
onDeleteHost,
|
||||
onDuplicateHost,
|
||||
onProxmoxDiscover,
|
||||
query = "",
|
||||
stripeMap,
|
||||
openFolders,
|
||||
onToggleFolder,
|
||||
selectionMode,
|
||||
selectedHostIds,
|
||||
onToggleSelect,
|
||||
onToggleSelectFolder,
|
||||
openMenuHostId,
|
||||
onMenuOpenChange,
|
||||
openTrayHostId,
|
||||
onTrayOpenChange,
|
||||
onManageFolder,
|
||||
onDeleteFolder,
|
||||
onOpenAllSessions,
|
||||
onShareFolder,
|
||||
onMoveHostsToFolder,
|
||||
draggedHostIds,
|
||||
onDragHostStart,
|
||||
onDragEnd,
|
||||
/** When true, only render the folder header (children come from the virtual list). */
|
||||
flat = false,
|
||||
stripeIndex: stripeIndexProp,
|
||||
density = "comfortable",
|
||||
trayTrigger = "hover",
|
||||
showTags = true,
|
||||
openOnDoubleClick = false,
|
||||
arrangeMode = false,
|
||||
isDragging = false,
|
||||
onReorderDrop,
|
||||
onFolderDragStart,
|
||||
onFolderDragEnd,
|
||||
isReorderHovered = false,
|
||||
reorderHoverEdge = null,
|
||||
onReorderHoverChange,
|
||||
}: {
|
||||
folder: HostFolder;
|
||||
depth?: number;
|
||||
onOpenTab: (host: Host, type: TabType) => 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>;
|
||||
onToggleFolder: (name: string) => void;
|
||||
selectionMode: boolean;
|
||||
selectedHostIds: Set<string>;
|
||||
onToggleSelect: (id: string) => void;
|
||||
onToggleSelectFolder: (folder: HostFolder) => void;
|
||||
openMenuHostId: string | null;
|
||||
onMenuOpenChange: (hostId: string | null) => void;
|
||||
openTrayHostId: string | null;
|
||||
onTrayOpenChange: (hostId: string | null) => void;
|
||||
onManageFolder: (folder: HostFolder) => void;
|
||||
onDeleteFolder: (folder: HostFolder) => void;
|
||||
onOpenAllSessions: (folder: HostFolder) => void;
|
||||
onShareFolder?: (folder: HostFolder) => void;
|
||||
onMoveHostsToFolder: (hostIds: string[], targetPath: string) => void;
|
||||
draggedHostIds: string[] | null;
|
||||
onDragHostStart: (hostId: string) => void;
|
||||
onDragEnd: () => void;
|
||||
flat?: boolean;
|
||||
stripeIndex?: number;
|
||||
density?: HostDensity;
|
||||
trayTrigger?: HostTrayTrigger;
|
||||
showTags?: boolean;
|
||||
openOnDoubleClick?: boolean;
|
||||
/** 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. */
|
||||
arrangeMode?: boolean;
|
||||
/** True while this folder is the one being dragged. */
|
||||
isDragging?: boolean;
|
||||
onReorderDrop?: (targetKey: string, position: "before" | "after") => void;
|
||||
onFolderDragStart?: (folderPath: string) => void;
|
||||
onFolderDragEnd?: () => void;
|
||||
/** Whether THIS folder header is the current reorder drop target -- see
|
||||
* HostItem's identical prop for why this is lifted rather than local. */
|
||||
isReorderHovered?: boolean;
|
||||
reorderHoverEdge?: "before" | "after" | null;
|
||||
onReorderHoverChange?: (edge: "before" | "after" | null) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { getStatus, initialLoadComplete } = useServerStatus();
|
||||
const { total } = folderHostCount(folder);
|
||||
const online = initialLoadComplete
|
||||
? collectAllHosts(folder.children).filter(
|
||||
(h) => statusCheckEnabled(h) && getStatus(Number(h.id)) === "online",
|
||||
).length
|
||||
: folderHostCount(folder).online;
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const reorderEdge = isReorderHovered ? reorderHoverEdge : null;
|
||||
|
||||
if (query && !folderHasMatch(folder, query)) return null;
|
||||
|
||||
const folderPath = folder.path ?? folder.name;
|
||||
const isOpen = query ? true : openFolders.has(folderPath);
|
||||
const stripeIndex = stripeIndexProp ?? stripeMap?.get(folder) ?? 0;
|
||||
// Synthetic group headers (group-by tag/status/etc.) are not real folders, so
|
||||
// they can't be edited, deleted, or used as drop targets.
|
||||
const isGroup = folderPath.startsWith("__group__:");
|
||||
// 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 breadcrumb =
|
||||
pathSegments.length > 1 ? pathSegments.slice(0, -1).join(" / ") : null;
|
||||
const folderHosts = collectAllHosts(folder.children);
|
||||
const folderSelected =
|
||||
folderHosts.length > 0 &&
|
||||
folderHosts.every((h) => selectedHostIds.has(h.id));
|
||||
|
||||
const canDragFolder = arrangeMode && !isGroup;
|
||||
// Hosts land IN the folder; the header's own top/bottom edges reorder it
|
||||
// among its siblings.
|
||||
const acceptsHostDrop = !!draggedHostIds && !isGroup;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative"
|
||||
style={depth > 0 ? { paddingLeft: depth * 12 } : undefined}
|
||||
>
|
||||
<div className="relative">
|
||||
<button
|
||||
draggable={canDragFolder}
|
||||
onDragStart={(e) => {
|
||||
if (!canDragFolder) return;
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
onFolderDragStart?.(folderPath);
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
setDragOver(false);
|
||||
onReorderHoverChange?.(null);
|
||||
onFolderDragEnd?.();
|
||||
}}
|
||||
// The drop zones live on the header, not on the wrapper around the
|
||||
// folder's whole subtree -- measuring the edges against the full
|
||||
// expanded subtree made the before/after split land hundreds of
|
||||
// pixels away from the header the user was actually pointing at.
|
||||
onDragOver={(e) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const offset = e.clientY - rect.top;
|
||||
const band = Math.min(8, rect.height * 0.3);
|
||||
const wantsInto =
|
||||
acceptsHostDrop && offset > band && offset < rect.height - band;
|
||||
|
||||
if (wantsInto) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onReorderHoverChange?.(null);
|
||||
setDragOver(true);
|
||||
return;
|
||||
}
|
||||
if (arrangeMode && onReorderDrop && !isGroup) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragOver(false);
|
||||
onReorderHoverChange?.(
|
||||
offset < rect.height / 2 ? "before" : "after",
|
||||
);
|
||||
}
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
if (e.currentTarget === e.target) setDragOver(false);
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
if (dragOver && acceptsHostDrop && draggedHostIds) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragOver(false);
|
||||
onMoveHostsToFolder(draggedHostIds, folderPath);
|
||||
return;
|
||||
}
|
||||
if (arrangeMode && onReorderDrop && !isGroup && reorderEdge) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onReorderDrop(`folder:${folderPath}`, reorderEdge);
|
||||
onReorderHoverChange?.(null);
|
||||
}
|
||||
}}
|
||||
onClick={() => !query && onToggleFolder(folderPath)}
|
||||
className={`group/folder flex items-center gap-2 w-full pl-2.5 pr-2 py-1.5 transition-colors text-left ${
|
||||
canDragFolder
|
||||
? "cursor-grab active:cursor-grabbing"
|
||||
: "cursor-pointer"
|
||||
} ${
|
||||
isOpen ? "bg-muted/40" : "hover:bg-muted/30"
|
||||
} ${stripeIndex % 2 === 1 && !isOpen ? "bg-muted/[0.08]" : ""} ${dragOver ? "ring-1 ring-inset ring-accent-brand bg-accent-brand/10" : ""} ${isDragging ? "opacity-40" : ""}`}
|
||||
>
|
||||
{canDragFolder && (
|
||||
<GripVertical className="size-3 shrink-0 text-muted-foreground/35 group-hover/folder:text-muted-foreground/70 transition-colors" />
|
||||
)}
|
||||
<ChevronRight
|
||||
className={`size-3.5 shrink-0 text-muted-foreground/60 transition-transform ${isOpen ? "rotate-90" : ""}`}
|
||||
/>
|
||||
{selectionMode && !isGroup && folderHosts.length > 0 && (
|
||||
<div
|
||||
role="checkbox"
|
||||
aria-checked={folderSelected}
|
||||
title={
|
||||
folderSelected
|
||||
? t("hosts.deselectFolder")
|
||||
: t("hosts.selectFolder")
|
||||
}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleSelectFolder(folder);
|
||||
}}
|
||||
className={`size-3.5 border-2 flex items-center justify-center shrink-0 transition-colors ${folderSelected ? "border-accent-brand bg-accent-brand" : "border-border bg-background"}`}
|
||||
>
|
||||
{folderSelected && <Check className="size-2 text-background" />}
|
||||
</div>
|
||||
)}
|
||||
<FolderIconEl
|
||||
icon={folder.icon ?? "folder"}
|
||||
className={`size-4 shrink-0 ${folder.color ? "" : isOpen ? "text-accent-brand" : "text-muted-foreground/70"}`}
|
||||
style={folder.color ? { color: folder.color } : undefined}
|
||||
/>
|
||||
{
|
||||
<>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{breadcrumb && (
|
||||
<span className="text-[10px] text-muted-foreground/40 truncate mr-1">
|
||||
{breadcrumb} /
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[13px] font-bold text-foreground tracking-tight">
|
||||
{folder.name}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-[10px] tabular-nums shrink-0 ml-1 px-1.5 py-[1px] bg-muted/70">
|
||||
{online > 0 && (
|
||||
<span className="text-accent-brand font-semibold">
|
||||
{online}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-muted-foreground/50">/{total}</span>
|
||||
</span>
|
||||
{!isGroup && (
|
||||
<FolderActions
|
||||
folder={folder}
|
||||
onOpenAllSessions={onOpenAllSessions}
|
||||
onShareFolder={onShareFolder}
|
||||
onManageFolder={onManageFolder}
|
||||
onDeleteFolder={onDeleteFolder}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
{!flat && isOpen && (
|
||||
<div className="border-l border-border/50 ml-[27px]">
|
||||
{folder.children.map((child, i) =>
|
||||
isFolder(child) ? (
|
||||
<FolderItem
|
||||
key={i}
|
||||
folder={child}
|
||||
depth={depth + 1}
|
||||
onOpenTab={onOpenTab}
|
||||
onEditHost={onEditHost}
|
||||
onShareHost={onShareHost}
|
||||
onDeleteHost={onDeleteHost}
|
||||
onDuplicateHost={onDuplicateHost}
|
||||
onProxmoxDiscover={onProxmoxDiscover}
|
||||
query={query}
|
||||
stripeMap={stripeMap}
|
||||
openFolders={openFolders}
|
||||
onToggleFolder={onToggleFolder}
|
||||
selectionMode={selectionMode}
|
||||
selectedHostIds={selectedHostIds}
|
||||
onToggleSelect={onToggleSelect}
|
||||
onToggleSelectFolder={onToggleSelectFolder}
|
||||
openMenuHostId={openMenuHostId}
|
||||
onMenuOpenChange={onMenuOpenChange}
|
||||
openTrayHostId={openTrayHostId}
|
||||
onTrayOpenChange={onTrayOpenChange}
|
||||
onManageFolder={onManageFolder}
|
||||
onDeleteFolder={onDeleteFolder}
|
||||
onOpenAllSessions={onOpenAllSessions}
|
||||
onShareFolder={onShareFolder}
|
||||
onMoveHostsToFolder={onMoveHostsToFolder}
|
||||
draggedHostIds={draggedHostIds}
|
||||
onDragHostStart={onDragHostStart}
|
||||
onDragEnd={onDragEnd}
|
||||
density={density}
|
||||
trayTrigger={trayTrigger}
|
||||
showTags={showTags}
|
||||
openOnDoubleClick={openOnDoubleClick}
|
||||
arrangeMode={arrangeMode}
|
||||
onReorderDrop={onReorderDrop}
|
||||
onFolderDragStart={onFolderDragStart}
|
||||
onFolderDragEnd={onFolderDragEnd}
|
||||
/>
|
||||
) : (
|
||||
<HostItem
|
||||
key={i}
|
||||
host={child}
|
||||
onOpenTab={(t) => onOpenTab(child, t)}
|
||||
onEditHost={onEditHost ? () => onEditHost(child) : undefined}
|
||||
onShareHost={onShareHost ? () => onShareHost(child) : undefined}
|
||||
onProxmoxDiscover={
|
||||
onProxmoxDiscover ? () => onProxmoxDiscover(child) : undefined
|
||||
}
|
||||
onDelete={() => onDeleteHost(child)}
|
||||
onDuplicate={() => onDuplicateHost(child)}
|
||||
query={query}
|
||||
stripeIndex={stripeMap?.get(child) ?? 0}
|
||||
selectionMode={selectionMode}
|
||||
selected={selectedHostIds.has(child.id)}
|
||||
onToggleSelect={() => onToggleSelect(child.id)}
|
||||
isMenuOpen={openMenuHostId === child.id}
|
||||
onMenuOpenChange={(open) =>
|
||||
onMenuOpenChange(open ? child.id : null)
|
||||
}
|
||||
isTrayOpen={openTrayHostId === child.id}
|
||||
onTrayOpenChange={(open) =>
|
||||
onTrayOpenChange(open ? child.id : null)
|
||||
}
|
||||
onDragStart={() => onDragHostStart(child.id)}
|
||||
onDragEnd={onDragEnd}
|
||||
density={density}
|
||||
trayTrigger={trayTrigger}
|
||||
showTags={showTags}
|
||||
openOnDoubleClick={openOnDoubleClick}
|
||||
arrangeMode={arrangeMode}
|
||||
onReorderDrop={
|
||||
onReorderDrop
|
||||
? (position) => onReorderDrop(`host:${child.id}`, position)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,125 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
getHostSidebarPreferences,
|
||||
saveHostSidebarPreferences,
|
||||
getUserPreferences,
|
||||
} from "@/main-axios";
|
||||
import {
|
||||
defaultHostSidebarPreferences,
|
||||
sanitizeHostSidebarPreferences,
|
||||
type HostSidebarPreferences,
|
||||
} from "@/types/host-sidebar-preferences";
|
||||
|
||||
const SAVE_DEBOUNCE_MS = 500;
|
||||
const LS_KEY = "hostSidebarPreferences";
|
||||
const SYNC_EVENT = "hostSidebarPreferencesChanged";
|
||||
|
||||
function readCache(): HostSidebarPreferences | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_KEY);
|
||||
if (!raw) return null;
|
||||
return sanitizeHostSidebarPreferences(JSON.parse(raw));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeCache(preferences: HostSidebarPreferences) {
|
||||
try {
|
||||
localStorage.setItem(LS_KEY, JSON.stringify(preferences));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified host sidebar preferences (sort, group, filters, open folders,
|
||||
* density, tag visibility, tray trigger, status color scheme), cached in
|
||||
* localStorage for instant paint and synced to the backend so they follow
|
||||
* the user across devices when storageMode is "cloud" -- same
|
||||
* cache-then-fetch-then-debounced-save shape as useHostMetricsPreferences,
|
||||
* but per-user rather than per-host, and gated on storageMode the way the
|
||||
* older per-preference localStorage toggles were.
|
||||
*
|
||||
* Multiple components (HostsPanel, CustomizeSidebarPanel, UserProfilePanel's
|
||||
* resetToDefaults) can call this hook at the same time. Each holds its own
|
||||
* React state, so a write in one instance broadcasts a same-tab custom event
|
||||
* (mirroring the showHostTagsChanged-style events the older per-preference
|
||||
* toggles used) that every other instance listens for to stay in sync.
|
||||
*/
|
||||
export function useHostSidebarPreferences() {
|
||||
const [preferences, setPreferences] = useState<HostSidebarPreferences>(
|
||||
() => readCache() ?? defaultHostSidebarPreferences(),
|
||||
);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
getHostSidebarPreferences()
|
||||
.then((remote) => {
|
||||
if (cancelled) return;
|
||||
setPreferences(remote);
|
||||
writeCache(remote);
|
||||
})
|
||||
.catch(() => {
|
||||
/* keep cache/default */
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoaded(true);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
const cached = readCache();
|
||||
if (cached) setPreferences(cached);
|
||||
};
|
||||
window.addEventListener(SYNC_EVENT, handler);
|
||||
return () => window.removeEventListener(SYNC_EVENT, handler);
|
||||
}, []);
|
||||
|
||||
const update = useCallback(
|
||||
(
|
||||
patch:
|
||||
| Partial<HostSidebarPreferences>
|
||||
| ((prev: HostSidebarPreferences) => HostSidebarPreferences),
|
||||
) => {
|
||||
setPreferences((prev) => {
|
||||
const next = sanitizeHostSidebarPreferences(
|
||||
typeof patch === "function" ? patch(prev) : { ...prev, ...patch },
|
||||
);
|
||||
writeCache(next);
|
||||
window.dispatchEvent(new Event(SYNC_EVENT));
|
||||
|
||||
if (saveTimer.current) clearTimeout(saveTimer.current);
|
||||
saveTimer.current = setTimeout(() => {
|
||||
getUserPreferences()
|
||||
.then((prefs) => {
|
||||
if (prefs.storageMode !== "cloud") return;
|
||||
return saveHostSidebarPreferences(next);
|
||||
})
|
||||
.catch(() => {
|
||||
/* best-effort; cache already holds it */
|
||||
});
|
||||
}, SAVE_DEBOUNCE_MS);
|
||||
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (saveTimer.current) clearTimeout(saveTimer.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { preferences, update, loaded };
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useState } from "react";
|
||||
|
||||
/** Drag-in-progress host ids and root drop-zone hover state for the tree. */
|
||||
export function useSidebarDragState() {
|
||||
const [draggedHostIds, setDraggedHostIds] = useState<string[] | null>(null);
|
||||
const [rootDragOver, setRootDragOver] = useState(false);
|
||||
|
||||
return {
|
||||
draggedHostIds,
|
||||
setDraggedHostIds,
|
||||
rootDragOver,
|
||||
setRootDragOver,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useState } from "react";
|
||||
|
||||
/** Selected-hosts and open-menu/tray id state shared across the tree's rows. */
|
||||
export function useSidebarSelection() {
|
||||
const [selectedHostIds, setSelectedHostIds] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const [openMenuHostId, setOpenMenuHostId] = useState<string | null>(null);
|
||||
const [openTrayHostId, setOpenTrayHostId] = useState<string | null>(null);
|
||||
// Hover-mode tray expansion has to live in state, not just CSS: rows are
|
||||
// absolutely positioned at virtualizer-computed heights, so a row that grows
|
||||
// on hover would overlap the row below unless the height is reserved too.
|
||||
const [hoveredHostId, setHoveredHostId] = useState<string | null>(null);
|
||||
|
||||
function toggleSelect(id: string) {
|
||||
setSelectedHostIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
/** Selects every host id, or deselects them if they're all already selected. */
|
||||
function toggleSelectMany(ids: string[]) {
|
||||
setSelectedHostIds((prev) => {
|
||||
const allSelected = ids.every((id) => prev.has(id));
|
||||
const next = new Set(prev);
|
||||
if (allSelected) {
|
||||
for (const id of ids) next.delete(id);
|
||||
} else {
|
||||
for (const id of ids) next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
selectedHostIds,
|
||||
setSelectedHostIds,
|
||||
openMenuHostId,
|
||||
setOpenMenuHostId,
|
||||
openTrayHostId,
|
||||
setOpenTrayHostId,
|
||||
hoveredHostId,
|
||||
setHoveredHostId,
|
||||
toggleSelect,
|
||||
toggleSelectMany,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { isFolder } from "./visible-rows";
|
||||
export { HostItem } from "./HostItem/HostItem";
|
||||
export { FolderItem } from "./FolderItem/FolderItem";
|
||||
export { SidebarTree } from "./SidebarTree";
|
||||
@@ -0,0 +1,192 @@
|
||||
import type { Host, HostFolder } from "@/types/ui-types";
|
||||
|
||||
export function isFolder(item: Host | HostFolder): item is HostFolder {
|
||||
return "children" in item;
|
||||
}
|
||||
|
||||
export function hostMatchesQuery(host: Host, query: string) {
|
||||
return (
|
||||
host.name.toLowerCase().includes(query) ||
|
||||
host.ip.toLowerCase().includes(query) ||
|
||||
host.username.toLowerCase().includes(query) ||
|
||||
host.tags?.some((t) => t.toLowerCase().includes(query))
|
||||
);
|
||||
}
|
||||
|
||||
/** Key used in the openFolders/openSet expand-state Set for a host's own children row. */
|
||||
export function hostExpandKey(host: Host): string {
|
||||
return `host:${host.id}`;
|
||||
}
|
||||
|
||||
function hostHasMatch(host: Host, query: string): boolean {
|
||||
if (hostMatchesQuery(host, query)) return true;
|
||||
return (host.childHosts ?? []).some((child) => hostHasMatch(child, query));
|
||||
}
|
||||
|
||||
export function folderHasMatch(folder: HostFolder, query: string): boolean {
|
||||
for (const child of folder.children) {
|
||||
if (isFolder(child)) {
|
||||
if (folderHasMatch(child, query)) return true;
|
||||
} else {
|
||||
if (hostHasMatch(child, query)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export type VirtualRow = { item: Host | HostFolder; depth: number };
|
||||
|
||||
function collectVisibleHostRows(
|
||||
host: Host,
|
||||
query: string,
|
||||
closedHostParents: Set<string>,
|
||||
out: VirtualRow[],
|
||||
depth: number,
|
||||
): void {
|
||||
if (!query || hostMatchesQuery(host, query) || hostHasMatch(host, query)) {
|
||||
out.push({ item: host, depth });
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
const childHosts = host.childHosts ?? [];
|
||||
if (childHosts.length === 0) return;
|
||||
// Sub-host parents default to expanded (opposite of folders): a host that
|
||||
// just got reparented shouldn't seem to disappear because its new parent
|
||||
// row starts closed. closedHostParents tracks the opposite of openFolders
|
||||
// -- parents the user has explicitly collapsed.
|
||||
const isOpen = query ? true : !closedHostParents.has(hostExpandKey(host));
|
||||
if (!isOpen) return;
|
||||
for (const child of childHosts) {
|
||||
collectVisibleHostRows(child, query, closedHostParents, out, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
export function collectVisibleRows(
|
||||
children: (Host | HostFolder)[],
|
||||
query: string,
|
||||
openSet: Set<string>,
|
||||
out: VirtualRow[] = [],
|
||||
depth = 0,
|
||||
closedHostParents: Set<string> = new Set(),
|
||||
): 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);
|
||||
if (childOpen)
|
||||
collectVisibleRows(
|
||||
child.children,
|
||||
query,
|
||||
openSet,
|
||||
out,
|
||||
depth + 1,
|
||||
closedHostParents,
|
||||
);
|
||||
} else {
|
||||
collectVisibleHostRows(child, query, closedHostParents, out, depth);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function collectAllHosts(children: (Host | HostFolder)[]): Host[] {
|
||||
const out: Host[] = [];
|
||||
for (const child of children) {
|
||||
if (isFolder(child)) {
|
||||
out.push(...collectAllHosts(child.children));
|
||||
} else {
|
||||
out.push(child);
|
||||
if (child.childHosts && child.childHosts.length > 0) {
|
||||
out.push(...collectAllHosts(child.childHosts));
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Open/close state and folder assignment are both keyed by the full " / " path,
|
||||
// so two folders that share a leaf name don't collapse together. Synthetic group
|
||||
// headers (group-by views) are excluded from the assignable-folder list.
|
||||
export function collectAllFolderPaths(
|
||||
children: (Host | HostFolder)[],
|
||||
): string[] {
|
||||
const paths = new Set<string>();
|
||||
for (const child of children) {
|
||||
if (isFolder(child)) {
|
||||
const path = child.path ?? child.name;
|
||||
if (!path.startsWith("__group__:")) paths.add(path);
|
||||
for (const p of collectAllFolderPaths(child.children)) paths.add(p);
|
||||
}
|
||||
}
|
||||
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}`
|
||||
: `host:${item.id}`;
|
||||
}
|
||||
|
||||
export function rowKind(key: string): string {
|
||||
return key.slice(0, key.indexOf(":"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Flattens the visible rows into the parent-scoped shape planReorder needs.
|
||||
*
|
||||
* Parent is derived from the row's own data rather than from its position in
|
||||
* the flattened list: a folder's parent is its path minus the last segment,
|
||||
* a host's is its folder (or its parent host when nested as a sub-host).
|
||||
* Scoping by parent is what keeps a drop comparing against the neighbours it
|
||||
* was actually dropped between.
|
||||
*/
|
||||
export function buildReorderRows(
|
||||
rows: { item: Host | HostFolder }[],
|
||||
): { key: string; parentKey: string; sortOrder?: number | null }[] {
|
||||
return rows.map(({ item }) => {
|
||||
if (isFolder(item)) {
|
||||
const path = item.path ?? item.name;
|
||||
const cut = path.lastIndexOf(" / ");
|
||||
return {
|
||||
key: rowKey(item),
|
||||
parentKey: cut === -1 ? ROOT_PARENT : `folder:${path.slice(0, cut)}`,
|
||||
sortOrder: item.sortOrder,
|
||||
};
|
||||
}
|
||||
const parentKey = item.parentHostId
|
||||
? `host:${item.parentHostId}`
|
||||
: item.folder
|
||||
? `folder:${item.folder}`
|
||||
: ROOT_PARENT;
|
||||
return { key: rowKey(item), parentKey, sortOrder: item.sortOrder };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Every orderable row in the tree, visible or not, in tree order.
|
||||
*
|
||||
* Reorder math must run against the complete sibling set: visibleRows omits
|
||||
* anything inside a collapsed folder, so planning a drop off it compared the
|
||||
* dragged row against a partial group and produced a position that only
|
||||
* settled correctly after a second drop.
|
||||
*/
|
||||
export function collectOrderableRows(
|
||||
children: (Host | HostFolder)[],
|
||||
out: { item: Host | HostFolder }[] = [],
|
||||
): { item: Host | HostFolder }[] {
|
||||
for (const child of children) {
|
||||
if (isFolder(child)) {
|
||||
out.push({ item: child });
|
||||
collectOrderableRows(child.children, out);
|
||||
} else {
|
||||
out.push({ item: child });
|
||||
if (child.childHosts?.length) collectOrderableRows(child.childHosts, out);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
/**
|
||||
* Drag-to-rearrange lock for a sidebar tree, kept per-device in
|
||||
* localStorage rather than in the synced sidebar-preferences blob.
|
||||
*
|
||||
* It deliberately does NOT live in HostSidebarPreferences /
|
||||
* CredentialSidebarPreferences: those are fetched from the server on every
|
||||
* mount and the response replaces local state wholesale, so a value the
|
||||
* server has never stored (and, outside "cloud" storage mode, never will)
|
||||
* gets clobbered back to its default the moment the fetch resolves. That
|
||||
* made the toggle appear to do nothing at all.
|
||||
*
|
||||
* Locked is the default, so a fresh device never drags by accident.
|
||||
*/
|
||||
export function useArrangeLock(storageKey: string) {
|
||||
const syncEvent = `${storageKey}:changed`;
|
||||
|
||||
const read = useCallback(() => {
|
||||
try {
|
||||
return localStorage.getItem(storageKey) === "false";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, [storageKey]);
|
||||
|
||||
const [unlocked, setUnlocked] = useState(read);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => setUnlocked(read());
|
||||
window.addEventListener(syncEvent, handler);
|
||||
window.addEventListener("storage", handler);
|
||||
return () => {
|
||||
window.removeEventListener(syncEvent, handler);
|
||||
window.removeEventListener("storage", handler);
|
||||
};
|
||||
}, [syncEvent, read]);
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
const next = !read();
|
||||
try {
|
||||
localStorage.setItem(storageKey, next ? "false" : "true");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
setUnlocked(next);
|
||||
window.dispatchEvent(new Event(syncEvent));
|
||||
return next;
|
||||
}, [storageKey, syncEvent, read]);
|
||||
|
||||
return { arrangeLocked: !unlocked, toggleArrangeLock: toggle };
|
||||
}
|
||||
Reference in new issue
Block a user