mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-30 18:58:22 +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:
co-authored by
dependabot[bot]
ZacharyZcR
kacperpietrzyk
Brennan Neoh
Claude Sonnet 5
T3rM1nAt0-R
Horziox
William Shi
Carl
Raw_Nuke
njz-cvm
Alexandre VARGAS
alexandre-vl
parent
5021ccf3e2
commit
7ae1648c25
@@ -13,11 +13,16 @@ import { Auth } from "@/auth/Auth.tsx";
|
||||
import { Toaster } from "@/components/sonner.tsx";
|
||||
import { dbHealthMonitor } from "@/lib/db-health-monitor.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { ConnectionScreen } from "@/components/connection/ConnectionScreen.tsx";
|
||||
|
||||
export type FullScreenAppPhase = "loading" | "not-found" | "ready";
|
||||
|
||||
interface FullScreenAppWrapperProps {
|
||||
hostId?: string;
|
||||
children: (hostConfig: SSHHost | null, loading: boolean) => React.ReactNode;
|
||||
children: (
|
||||
hostConfig: SSHHost | null,
|
||||
phase: FullScreenAppPhase,
|
||||
) => React.ReactNode;
|
||||
}
|
||||
|
||||
export const FullScreenAppWrapper: React.FC<FullScreenAppWrapperProps> = ({
|
||||
@@ -146,17 +151,8 @@ export const FullScreenAppWrapper: React.FC<FullScreenAppWrapperProps> = ({
|
||||
|
||||
if (authLoading) {
|
||||
return (
|
||||
<div
|
||||
className="w-full h-screen overflow-hidden flex flex-col items-center justify-center gap-4"
|
||||
style={{ backgroundColor: "var(--bg-base)" }}
|
||||
>
|
||||
<RefreshCw
|
||||
className="size-8 animate-spin"
|
||||
style={{ color: "var(--foreground)" }}
|
||||
/>
|
||||
<p className="text-sm" style={{ color: "var(--foreground-secondary)" }}>
|
||||
{t("common.loading")}
|
||||
</p>
|
||||
<div className="relative w-full h-screen overflow-hidden">
|
||||
<ConnectionScreen status="connecting" message={t("common.loading")} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -185,6 +181,12 @@ export const FullScreenAppWrapper: React.FC<FullScreenAppWrapperProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
const phase: FullScreenAppPhase = loading
|
||||
? "loading"
|
||||
: !hostConfig && hostId
|
||||
? "not-found"
|
||||
: "ready";
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<TabProvider>
|
||||
@@ -193,7 +195,7 @@ export const FullScreenAppWrapper: React.FC<FullScreenAppWrapperProps> = ({
|
||||
className="w-full h-screen overflow-hidden"
|
||||
style={{ backgroundColor: "var(--bg-base)" }}
|
||||
>
|
||||
{children(hostConfig, loading)}
|
||||
{children(hostConfig, phase)}
|
||||
</div>
|
||||
</CommandHistoryProvider>
|
||||
</TabProvider>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
interface AiMessageProps {
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
}
|
||||
|
||||
export function AiMessage({ role, content }: AiMessageProps) {
|
||||
if (role === "user") {
|
||||
return (
|
||||
<div className="rounded-none border border-border bg-muted px-3 py-2 text-sm whitespace-pre-wrap break-words">
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="text-sm leading-relaxed [&_a]:underline [&_code]:font-mono [&_code]:text-xs [&_li]:my-0.5 [&_ol]:my-2 [&_ol]:list-decimal [&_ol]:pl-5 [&_p]:my-2 [&_pre]:overflow-x-auto [&_pre]:rounded-none [&_pre]:border [&_pre]:border-border [&_pre]:bg-muted [&_pre]:p-2 [&_ul]:my-2 [&_ul]:list-disc [&_ul]:pl-5">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,533 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Loader2, Send, Settings2, Sparkles, Square } from "lucide-react";
|
||||
import { Button } from "@/components/button";
|
||||
import { Textarea } from "@/components/textarea";
|
||||
import { saveUserPreferences } from "@/main-axios";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/select";
|
||||
import {
|
||||
getAiConversation,
|
||||
getAiProviders,
|
||||
getAiStatus,
|
||||
type AiProposal,
|
||||
type AiProvider,
|
||||
} from "@/api/ai-api";
|
||||
import { AiMessage } from "./AiMessage";
|
||||
import { AiProviderSettings } from "./AiProviderSettings";
|
||||
import { AiToolCall } from "./AiToolCall";
|
||||
import { ProposalCard } from "./ProposalCard";
|
||||
import { useAiStream, type ToolActivity } from "./use-ai-stream";
|
||||
import {
|
||||
activeMentionQuery,
|
||||
useMentions,
|
||||
type MentionItem,
|
||||
} from "./useMentions";
|
||||
import { mentionLabel } from "./labels";
|
||||
|
||||
interface HistoryEntry {
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
}
|
||||
|
||||
/** One entry in the rendered conversation, in the order it happened. */
|
||||
type TimelineItem =
|
||||
| {
|
||||
kind: "message";
|
||||
key: string;
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
}
|
||||
| { kind: "tool"; key: string; tool: ToolActivity }
|
||||
| { kind: "proposal"; key: string; proposal: AiProposal };
|
||||
|
||||
export function AiPanel({ activeTab }: { activeTab?: string | null }) {
|
||||
const { t } = useTranslation();
|
||||
const { state, send, stop } = useAiStream();
|
||||
|
||||
const [providers, setProviders] = useState<AiProvider[]>([]);
|
||||
const [providerId, setProviderId] = useState<number | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [input, setInput] = useState("");
|
||||
const [history, setHistory] = useState<HistoryEntry[]>([]);
|
||||
const [resolvedProposals, setResolvedProposals] = useState<
|
||||
Record<number, { status: "applied" | "rejected"; resultSummary?: string }>
|
||||
>({});
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const [mention, setMention] = useState<{
|
||||
query: string;
|
||||
start: number;
|
||||
} | null>(null);
|
||||
const [mentionIndex, setMentionIndex] = useState(0);
|
||||
const { search: searchMentions } = useMentions(Boolean(providerId));
|
||||
|
||||
const mentionMatches = mention ? searchMentions(mention.query) : [];
|
||||
|
||||
const updateMentionQuery = (value: string, caret: number) => {
|
||||
const next = activeMentionQuery(value, caret);
|
||||
setMention(next);
|
||||
setMentionIndex(0);
|
||||
};
|
||||
|
||||
/**
|
||||
* Replaces the "@partial" under the caret with the chosen name. The mention
|
||||
* is plain text: the assistant still has to call a read tool to look the
|
||||
* item up, so nothing is attached to the prompt behind the user's back.
|
||||
*/
|
||||
const insertMention = (item: MentionItem) => {
|
||||
if (!mention) return;
|
||||
const caret = textareaRef.current?.selectionStart ?? input.length;
|
||||
const before = input.slice(0, mention.start);
|
||||
const after = input.slice(caret);
|
||||
const inserted = `@${item.label} `;
|
||||
|
||||
setInput(`${before}${inserted}${after}`);
|
||||
setMention(null);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const position = before.length + inserted.length;
|
||||
textareaRef.current?.focus();
|
||||
textareaRef.current?.setSelectionRange(position, position);
|
||||
});
|
||||
};
|
||||
|
||||
const loadProviders = useCallback(async (selectId?: number) => {
|
||||
try {
|
||||
const list = await getAiProviders();
|
||||
setProviders(list);
|
||||
setProviderId((current) => {
|
||||
// A provider that was just added becomes the active one, so there is
|
||||
// no second step between creating it and being able to use it.
|
||||
if (selectId && list.some((entry) => entry.id === selectId)) {
|
||||
return selectId;
|
||||
}
|
||||
// Fall back to the first if the current selection has gone away.
|
||||
if (current && list.some((entry) => entry.id === current)) {
|
||||
return current;
|
||||
}
|
||||
return list[0]?.id ?? null;
|
||||
});
|
||||
return list;
|
||||
} catch {
|
||||
setProviders([]);
|
||||
return [];
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const status = await getAiStatus();
|
||||
if (cancelled) return;
|
||||
|
||||
// Opening the panel is consent to use it. Without this every write
|
||||
// 403s through the gate, which surfaces as a bare permission error
|
||||
// rather than anything the user can act on.
|
||||
if (status.globallyEnabled && !status.enabled) {
|
||||
await saveUserPreferences({ aiAssistantEnabled: true });
|
||||
}
|
||||
if (!status.globallyEnabled) return;
|
||||
|
||||
const list = await loadProviders();
|
||||
// With nothing configured there is nothing to chat with, so go
|
||||
// straight to the form instead of showing a card that just vanishes.
|
||||
if (!cancelled && list.length === 0) setShowSettings(true);
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [loadProviders]);
|
||||
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollTo({
|
||||
top: scrollRef.current.scrollHeight,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}, [state.assistantText, state.tools.length, history.length]);
|
||||
|
||||
const reloadHistory = useCallback(
|
||||
async (conversationId: number | null, reply: string) => {
|
||||
// Reload from the server so the transcript is the stored one rather
|
||||
// than a client-side reconstruction.
|
||||
if (conversationId) {
|
||||
try {
|
||||
const loaded = await getAiConversation(conversationId);
|
||||
setHistory(
|
||||
loaded.messages
|
||||
.filter((entry) => entry.role !== "tool")
|
||||
.map((entry) => ({
|
||||
role: entry.role as "user" | "assistant",
|
||||
content: entry.content,
|
||||
})),
|
||||
);
|
||||
return;
|
||||
} catch {
|
||||
// Fall through to keeping the streamed copy.
|
||||
}
|
||||
}
|
||||
|
||||
// No stored copy to fall back on, so the streamed reply is appended
|
||||
// locally. Without this it would vanish when the next turn resets the
|
||||
// stream state.
|
||||
if (reply) {
|
||||
setHistory((prev) => [...prev, { role: "assistant", content: reply }]);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
async function handleSend() {
|
||||
const message = input.trim();
|
||||
if (!message || !providerId || state.streaming) return;
|
||||
|
||||
setInput("");
|
||||
setHistory((prev) => [...prev, { role: "user", content: message }]);
|
||||
|
||||
await send({
|
||||
message,
|
||||
providerId,
|
||||
conversationId: state.conversationId,
|
||||
activeTab,
|
||||
onComplete: reloadHistory,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the outcome on the card and tells the assistant what happened.
|
||||
*
|
||||
* Without the follow-up the run just stops: the assistant proposed a
|
||||
* command, the user approved it, and the model never learns it produced
|
||||
* output, so it sits there as if the tool were still running.
|
||||
*/
|
||||
function handleProposalResolved(
|
||||
id: number,
|
||||
status: "applied" | "rejected",
|
||||
resultSummary?: string,
|
||||
) {
|
||||
setResolvedProposals((prev) => ({
|
||||
...prev,
|
||||
[id]: { status, resultSummary },
|
||||
}));
|
||||
|
||||
if (!providerId || state.streaming) return;
|
||||
|
||||
const proposal = state.proposals.find((entry) => entry.id === id);
|
||||
const label = proposal?.summary ?? proposal?.kind ?? `proposal ${id}`;
|
||||
|
||||
const followUp =
|
||||
status === "applied"
|
||||
? resultSummary
|
||||
? `I approved "${label}". Result:\n\n${resultSummary}`
|
||||
: `I approved "${label}". It completed.`
|
||||
: `I rejected "${label}". Do not retry it unless I ask.`;
|
||||
|
||||
setHistory((prev) => [...prev, { role: "user", content: followUp }]);
|
||||
|
||||
void send({
|
||||
message: followUp,
|
||||
providerId,
|
||||
conversationId: state.conversationId,
|
||||
activeTab,
|
||||
onComplete: reloadHistory,
|
||||
});
|
||||
}
|
||||
|
||||
const proposals: AiProposal[] = state.proposals.map((proposal) => {
|
||||
const resolved = resolvedProposals[proposal.id];
|
||||
if (!resolved) return proposal;
|
||||
return {
|
||||
...proposal,
|
||||
status: resolved.status,
|
||||
resultSummary: resolved.resultSummary ?? proposal.resultSummary,
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* The reply exists in two places once a turn finishes: the streamed text in
|
||||
* `state`, and the stored copy that `onComplete` reloads into `history`.
|
||||
* Whichever arrives is shown exactly once, and the stored copy wins because
|
||||
* it sits in the right place in the timeline.
|
||||
*/
|
||||
const last = history[history.length - 1];
|
||||
const historyEndsWithThisReply =
|
||||
state.assistantText.length > 0 &&
|
||||
last?.role === "assistant" &&
|
||||
last.content === state.assistantText;
|
||||
|
||||
const streamingReply = historyEndsWithThisReply ? "" : state.assistantText;
|
||||
|
||||
/**
|
||||
* One ordered list rather than three stacked blocks.
|
||||
*
|
||||
* Tool calls happen before the reply that describes them, but once a turn
|
||||
* ends its reply moves into `history`, which used to render above a separate
|
||||
* tools block. The tool call appeared to jump below the answer it produced.
|
||||
* Splicing the current turn's activity in ahead of its own reply keeps the
|
||||
* order stable across that handover.
|
||||
*/
|
||||
const timeline: TimelineItem[] = [];
|
||||
|
||||
const trailingReply = historyEndsWithThisReply ? history.length - 1 : -1;
|
||||
|
||||
history.forEach((entry, index) => {
|
||||
if (index === trailingReply) return;
|
||||
timeline.push({
|
||||
kind: "message",
|
||||
key: `history-${index}`,
|
||||
role: entry.role,
|
||||
content: entry.content,
|
||||
});
|
||||
});
|
||||
|
||||
for (const tool of state.tools) {
|
||||
timeline.push({ kind: "tool", key: tool.id, tool });
|
||||
}
|
||||
|
||||
if (trailingReply >= 0) {
|
||||
timeline.push({
|
||||
kind: "message",
|
||||
key: `history-${trailingReply}`,
|
||||
role: "assistant",
|
||||
content: history[trailingReply].content,
|
||||
});
|
||||
} else if (streamingReply) {
|
||||
timeline.push({
|
||||
kind: "message",
|
||||
key: "streaming",
|
||||
role: "assistant",
|
||||
content: streamingReply,
|
||||
});
|
||||
}
|
||||
|
||||
for (const proposal of proposals) {
|
||||
timeline.push({
|
||||
kind: "proposal",
|
||||
key: `proposal-${proposal.id}`,
|
||||
proposal,
|
||||
});
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Loader2 className="animate-spin text-muted-foreground" size={20} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/*
|
||||
The panel also lives in the narrow sidebar, so the title and the
|
||||
provider picker get their own rows rather than competing for one line.
|
||||
*/}
|
||||
<div className="border-b border-border">
|
||||
<div className="flex items-center gap-2 px-3 py-2">
|
||||
<Sparkles size={16} className="shrink-0" />
|
||||
<span className="truncate text-sm font-medium">{t("ai.title")}</span>
|
||||
<a
|
||||
href="https://docs.termix.site/features/ai/overview"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="ml-auto shrink-0 text-[10px] text-accent-brand hover:underline"
|
||||
>
|
||||
{t("hosts.docsLink")}
|
||||
</a>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="shrink-0"
|
||||
onClick={() => setShowSettings((value) => !value)}
|
||||
aria-label={t("ai.providerSettings")}
|
||||
>
|
||||
<Settings2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{providers.length > 0 && (
|
||||
<div className="flex items-center gap-2 px-3 pb-2">
|
||||
<Select
|
||||
value={providerId ? String(providerId) : undefined}
|
||||
onValueChange={(value) => setProviderId(Number(value))}
|
||||
>
|
||||
<SelectTrigger className="h-7 min-w-0 flex-1 rounded-none text-xs">
|
||||
<SelectValue placeholder={t("ai.selectProvider")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{providers.map((provider) => (
|
||||
<SelectItem key={provider.id} value={String(provider.id)}>
|
||||
{provider.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showSettings && (
|
||||
<div className="border-b border-border p-3">
|
||||
<AiProviderSettings
|
||||
providers={providers}
|
||||
onChanged={loadProviders}
|
||||
// Adding the first provider is the reason the form opened, so it
|
||||
// closes once that is done and the chat becomes usable.
|
||||
onAdded={() => setShowSettings(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={scrollRef} className="flex-1 space-y-3 overflow-y-auto p-3">
|
||||
{timeline.map((item) => {
|
||||
if (item.kind === "message") {
|
||||
return (
|
||||
<AiMessage
|
||||
key={item.key}
|
||||
role={item.role}
|
||||
content={item.content}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (item.kind === "tool") {
|
||||
return (
|
||||
<AiToolCall
|
||||
key={item.key}
|
||||
tool={item.tool}
|
||||
streaming={state.streaming}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ProposalCard
|
||||
key={item.key}
|
||||
proposal={item.proposal}
|
||||
onResolved={handleProposalResolved}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{state.error && (
|
||||
<div className="rounded-none border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{state.error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative border-t border-border p-3">
|
||||
{mentionMatches.length > 0 && (
|
||||
<div className="absolute bottom-full left-3 right-3 z-10 max-h-56 overflow-y-auto border border-border bg-popover shadow-md">
|
||||
{mentionMatches.map((item, index) => (
|
||||
<button
|
||||
key={`${item.kind}-${item.id}`}
|
||||
type="button"
|
||||
className={`flex w-full items-center gap-2 px-2 py-1.5 text-left text-xs ${
|
||||
index === mentionIndex ? "bg-muted" : ""
|
||||
}`}
|
||||
onMouseDown={(event) => {
|
||||
// mousedown, so the textarea does not lose the caret first.
|
||||
event.preventDefault();
|
||||
insertMention(item);
|
||||
}}
|
||||
>
|
||||
<span className="shrink-0 text-[10px] uppercase text-muted-foreground">
|
||||
{mentionLabel(item.kind)}
|
||||
</span>
|
||||
<span className="truncate font-medium">{item.label}</span>
|
||||
{item.detail && (
|
||||
<span className="ml-auto shrink-0 truncate text-[10px] text-muted-foreground">
|
||||
{item.detail}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
className="min-h-[64px] resize-none rounded-none text-sm"
|
||||
value={input}
|
||||
onChange={(event) => {
|
||||
setInput(event.target.value);
|
||||
updateMentionQuery(
|
||||
event.target.value,
|
||||
event.target.selectionStart ?? 0,
|
||||
);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (mentionMatches.length > 0) {
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
setMentionIndex((i) => (i + 1) % mentionMatches.length);
|
||||
return;
|
||||
}
|
||||
if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
setMentionIndex(
|
||||
(i) =>
|
||||
(i - 1 + mentionMatches.length) % mentionMatches.length,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (event.key === "Enter" || event.key === "Tab") {
|
||||
event.preventDefault();
|
||||
insertMention(mentionMatches[mentionIndex]);
|
||||
return;
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
setMention(null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
void handleSend();
|
||||
}
|
||||
}}
|
||||
placeholder={t("ai.inputPlaceholder")}
|
||||
disabled={!providerId}
|
||||
/>
|
||||
{/*
|
||||
The hint gets its own line and wraps: sharing a row with the send
|
||||
button left it truncated to an ellipsis in the sidebar, where the
|
||||
panel is at its narrowest.
|
||||
*/}
|
||||
<p className="mt-2 text-[11px] leading-snug text-muted-foreground">
|
||||
{t("ai.attachHint")}
|
||||
</p>
|
||||
<div className="mt-1.5 flex items-center justify-end gap-2">
|
||||
{state.streaming ? (
|
||||
<Button size="sm" variant="outline" onClick={stop}>
|
||||
<Square size={14} />
|
||||
{t("ai.stop")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
disabled={!input.trim() || !providerId}
|
||||
onClick={() => void handleSend()}
|
||||
>
|
||||
<Send size={14} />
|
||||
{t("ai.send")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
import { getErrorMessage } from "../../lib/error-message.js";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { Loader2, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/button";
|
||||
import { Input } from "@/components/input";
|
||||
import { Label } from "@/components/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/select";
|
||||
import {
|
||||
createAiProvider,
|
||||
deleteAiProvider,
|
||||
probeAiModels,
|
||||
type AiProvider,
|
||||
type AiProviderType,
|
||||
} from "@/api/ai-api";
|
||||
|
||||
const PROVIDER_TYPES: Array<{
|
||||
value: AiProviderType;
|
||||
labelKey: string;
|
||||
needsBaseUrl: boolean;
|
||||
needsApiKey: boolean;
|
||||
defaultBaseUrl?: string;
|
||||
}> = [
|
||||
{
|
||||
value: "ollama",
|
||||
labelKey: "ai.providerOllama",
|
||||
needsBaseUrl: true,
|
||||
needsApiKey: false,
|
||||
defaultBaseUrl: "http://localhost:11434",
|
||||
},
|
||||
{
|
||||
value: "anthropic",
|
||||
labelKey: "ai.providerAnthropic",
|
||||
needsBaseUrl: false,
|
||||
needsApiKey: true,
|
||||
},
|
||||
{
|
||||
value: "openai",
|
||||
labelKey: "ai.providerOpenai",
|
||||
needsBaseUrl: false,
|
||||
needsApiKey: true,
|
||||
},
|
||||
{
|
||||
value: "gemini",
|
||||
labelKey: "ai.providerGemini",
|
||||
needsBaseUrl: false,
|
||||
needsApiKey: true,
|
||||
},
|
||||
{
|
||||
value: "openai_compatible",
|
||||
labelKey: "ai.providerOpenaiCompatible",
|
||||
needsBaseUrl: true,
|
||||
needsApiKey: false,
|
||||
},
|
||||
];
|
||||
|
||||
interface AiProviderSettingsProps {
|
||||
providers: AiProvider[];
|
||||
/** selectId names a provider that should become the active one. */
|
||||
onChanged: (selectId?: number) => void;
|
||||
onAdded?: () => void;
|
||||
}
|
||||
|
||||
export function AiProviderSettings({
|
||||
providers,
|
||||
onChanged,
|
||||
onAdded,
|
||||
}: AiProviderSettingsProps) {
|
||||
const { t } = useTranslation();
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [providerType, setProviderType] = useState<AiProviderType>("ollama");
|
||||
const [label, setLabel] = useState("");
|
||||
const [baseUrl, setBaseUrl] = useState("http://localhost:11434");
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [defaultModel, setDefaultModel] = useState("");
|
||||
|
||||
const [models, setModels] = useState<string[]>([]);
|
||||
const [detecting, setDetecting] = useState(false);
|
||||
const [detectWarning, setDetectWarning] = useState<string | null>(null);
|
||||
const [customModel, setCustomModel] = useState(false);
|
||||
|
||||
const spec = PROVIDER_TYPES.find((entry) => entry.value === providerType)!;
|
||||
|
||||
useEffect(() => {
|
||||
setBaseUrl(spec.defaultBaseUrl ?? "");
|
||||
setApiKey("");
|
||||
setModels([]);
|
||||
setDefaultModel("");
|
||||
setCustomModel(false);
|
||||
setDetectWarning(null);
|
||||
}, [providerType, spec.defaultBaseUrl]);
|
||||
|
||||
/**
|
||||
* Fetches the provider's own model list so nobody has to go and look model
|
||||
* names up. Falls back to a curated list when the endpoint is unreachable,
|
||||
* and a free-text field is always available for anything not listed.
|
||||
*/
|
||||
const detectModels = useCallback(async () => {
|
||||
setDetecting(true);
|
||||
setDetectWarning(null);
|
||||
try {
|
||||
const result = await probeAiModels({
|
||||
providerType,
|
||||
baseUrl: baseUrl.trim() || null,
|
||||
apiKey: apiKey.trim() || null,
|
||||
});
|
||||
setModels(result.models);
|
||||
if (result.source === "fallback") {
|
||||
setDetectWarning(t("ai.modelDetectFailed"));
|
||||
}
|
||||
// Pick the first suggestion so the field is never left empty.
|
||||
setDefaultModel((current) => current || result.models[0] || "");
|
||||
} catch {
|
||||
setDetectWarning(t("ai.modelDetectFailed"));
|
||||
} finally {
|
||||
setDetecting(false);
|
||||
}
|
||||
}, [providerType, baseUrl, apiKey, t]);
|
||||
|
||||
// Detect as soon as the provider has enough detail to be reachable.
|
||||
useEffect(() => {
|
||||
if (!adding) return;
|
||||
const ready = spec.needsApiKey ? apiKey.trim().length > 0 : true;
|
||||
if (!ready) return;
|
||||
const timer = setTimeout(() => void detectModels(), 400);
|
||||
return () => clearTimeout(timer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [adding, providerType, baseUrl, apiKey]);
|
||||
|
||||
async function handleAdd() {
|
||||
if (!label.trim()) {
|
||||
toast.error(t("ai.labelRequired"));
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const created = await createAiProvider({
|
||||
providerType,
|
||||
label: label.trim(),
|
||||
baseUrl: baseUrl.trim() || null,
|
||||
apiKey: apiKey.trim() || null,
|
||||
defaultModel: defaultModel.trim() || null,
|
||||
});
|
||||
setAdding(false);
|
||||
setLabel("");
|
||||
setApiKey("");
|
||||
setDefaultModel("");
|
||||
onChanged(created.id);
|
||||
onAdded?.();
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, t("ai.providerSaveFailed")));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
try {
|
||||
await deleteAiProvider(id);
|
||||
onChanged();
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, t("ai.providerDeleteFailed")));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{providers.map((provider) => (
|
||||
<div
|
||||
key={provider.id}
|
||||
className="flex items-center justify-between gap-2 rounded-none border border-border bg-muted px-3 py-2"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium">{provider.label}</div>
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{provider.providerType}
|
||||
{provider.baseUrl ? ` · ${provider.baseUrl}` : ""}
|
||||
{provider.apiKeyPrefix ? ` · ${provider.apiKeyPrefix}…` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleDelete(provider.id)}
|
||||
aria-label={t("ai.removeProvider")}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{!adding && (
|
||||
<Button size="sm" variant="outline" onClick={() => setAdding(true)}>
|
||||
<Plus size={14} />
|
||||
{t("ai.addProvider")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{adding && (
|
||||
<div className="space-y-3 rounded-none border border-border p-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("ai.providerType")}</Label>
|
||||
<Select
|
||||
value={providerType}
|
||||
onValueChange={(value) =>
|
||||
setProviderType(value as AiProviderType)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="rounded-none">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PROVIDER_TYPES.map((entry) => (
|
||||
<SelectItem key={entry.value} value={entry.value}>
|
||||
{t(entry.labelKey)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("ai.providerLabel")}</Label>
|
||||
<Input
|
||||
className="rounded-none"
|
||||
value={label}
|
||||
onChange={(event) => setLabel(event.target.value)}
|
||||
placeholder={t("ai.providerLabelPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{spec.needsBaseUrl && (
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("ai.baseUrl")}</Label>
|
||||
<Input
|
||||
className="rounded-none"
|
||||
value={baseUrl}
|
||||
onChange={(event) => setBaseUrl(event.target.value)}
|
||||
placeholder="http://localhost:11434"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("ai.privateEndpointHint")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(spec.needsApiKey || providerType === "openai_compatible") && (
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("ai.apiKey")}</Label>
|
||||
<Input
|
||||
className="rounded-none"
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(event) => setApiKey(event.target.value)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="min-w-0 flex-1">{t("ai.defaultModel")}</Label>
|
||||
<button
|
||||
type="button"
|
||||
className="flex shrink-0 items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground"
|
||||
onClick={() => void detectModels()}
|
||||
disabled={detecting}
|
||||
>
|
||||
{detecting ? (
|
||||
<Loader2 size={11} className="animate-spin" />
|
||||
) : (
|
||||
<RefreshCw size={11} />
|
||||
)}
|
||||
{t("ai.modelRefresh")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{models.length > 0 && !customModel ? (
|
||||
<Select
|
||||
value={defaultModel || undefined}
|
||||
onValueChange={(value) => {
|
||||
if (value === "__custom__") {
|
||||
setCustomModel(true);
|
||||
setDefaultModel("");
|
||||
return;
|
||||
}
|
||||
setDefaultModel(value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="rounded-none">
|
||||
<SelectValue placeholder={t("ai.modelPlaceholder")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{models.map((model) => (
|
||||
<SelectItem key={model} value={model}>
|
||||
{model}
|
||||
</SelectItem>
|
||||
))}
|
||||
{/* Anything the provider did not list is still reachable. */}
|
||||
<SelectItem value="__custom__">
|
||||
{t("ai.modelCustom")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
className="rounded-none"
|
||||
value={defaultModel}
|
||||
onChange={(event) => setDefaultModel(event.target.value)}
|
||||
placeholder={t("ai.defaultModelPlaceholder")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{detectWarning && (
|
||||
<p className="text-[11px] leading-snug text-muted-foreground">
|
||||
{detectWarning}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<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={handleAdd}
|
||||
>
|
||||
{saving && <Loader2 size={14} className="animate-spin" />}
|
||||
{t("ai.save")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={saving}
|
||||
onClick={() => setAdding(false)}
|
||||
>
|
||||
{t("ai.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronDown, ChevronRight, Loader2, Wrench } from "lucide-react";
|
||||
import type { ToolActivity } from "./use-ai-stream";
|
||||
import { toolLabel } from "./labels";
|
||||
|
||||
/**
|
||||
* Every tool call is shown, so the user can always see what the assistant
|
||||
* looked at rather than having to trust the summary.
|
||||
*/
|
||||
export function AiToolCall({
|
||||
tool,
|
||||
streaming = false,
|
||||
}: {
|
||||
tool: ToolActivity;
|
||||
/** False once the turn has ended, whatever ended it. */
|
||||
streaming?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
// A tool can only still be running while the turn is. An aborted or failed
|
||||
// stream leaves calls without results, and treating those as pending left a
|
||||
// spinner running forever after the conversation had finished.
|
||||
const pending = !("result" in tool) && streaming;
|
||||
|
||||
return (
|
||||
<div className="rounded-none border border-border bg-muted/50 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 px-2 py-1.5 text-left"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
>
|
||||
{open ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||
{pending ? (
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
) : (
|
||||
<Wrench size={12} />
|
||||
)}
|
||||
<span className="truncate">{toolLabel(tool.name)}</span>
|
||||
{pending && (
|
||||
<span className="text-muted-foreground">{t("ai.running")}</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="space-y-2 border-t border-border px-2 py-2">
|
||||
{/* The header reads as an action, so the actual tool id lives here. */}
|
||||
<div className="font-mono text-[10px] text-muted-foreground">
|
||||
{tool.name}
|
||||
</div>
|
||||
{Object.keys(tool.arguments).length > 0 && (
|
||||
<div>
|
||||
<div className="mb-1 text-muted-foreground">
|
||||
{t("ai.toolArguments")}
|
||||
</div>
|
||||
<pre className="overflow-x-auto whitespace-pre-wrap break-words font-mono">
|
||||
{JSON.stringify(tool.arguments, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{/*
|
||||
Gated on the result existing, not on !pending: an interrupted turn
|
||||
leaves a call with no result at all, which would otherwise print
|
||||
"undefined" under a Result heading.
|
||||
*/}
|
||||
{"result" in tool && (
|
||||
<div>
|
||||
<div className="mb-1 text-muted-foreground">
|
||||
{t("ai.toolResult")}
|
||||
</div>
|
||||
<pre className="max-h-64 overflow-auto whitespace-pre-wrap break-words font-mono">
|
||||
{JSON.stringify(tool.result, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import { getErrorMessage } from "../../lib/error-message.js";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { Check, Loader2, TriangleAlert, X } from "lucide-react";
|
||||
import { Button } from "@/components/button";
|
||||
import {
|
||||
applyAiProposal,
|
||||
rejectAiProposal,
|
||||
type AiProposal,
|
||||
} from "@/api/ai-api";
|
||||
import { fieldLabel, toolLabel } from "./labels";
|
||||
|
||||
interface ProposalCardProps {
|
||||
proposal: AiProposal;
|
||||
onResolved: (
|
||||
id: number,
|
||||
status: "applied" | "rejected",
|
||||
resultSummary?: string,
|
||||
) => void;
|
||||
}
|
||||
|
||||
function isDestructive(kind: string): boolean {
|
||||
return kind.includes("delete");
|
||||
}
|
||||
|
||||
/**
|
||||
* Fields shown in their own block rather than the generic row list, so the
|
||||
* card does not print the same command twice.
|
||||
*/
|
||||
const PROMOTED_FIELDS = new Set(["command", "explanation", "reason"]);
|
||||
|
||||
/** Renders the payload as readable field rows rather than raw JSON. */
|
||||
function fieldRows(payload: Record<string, unknown>): Array<[string, string]> {
|
||||
const rows: Array<[string, string]> = [];
|
||||
|
||||
const push = (key: string, value: unknown) => {
|
||||
if (value === null || value === undefined || value === "") return;
|
||||
if (PROMOTED_FIELDS.has(key)) return;
|
||||
const text =
|
||||
typeof value === "object" ? JSON.stringify(value) : String(value);
|
||||
rows.push([key, text]);
|
||||
};
|
||||
|
||||
for (const [key, value] of Object.entries(payload)) {
|
||||
if (key === "changes" && value && typeof value === "object") {
|
||||
for (const [childKey, childValue] of Object.entries(
|
||||
value as Record<string, unknown>,
|
||||
)) {
|
||||
push(childKey, childValue);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
push(key, value);
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
function asText(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
export function ProposalCard({ proposal, onResolved }: ProposalCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const [busy, setBusy] = useState<"apply" | "reject" | null>(null);
|
||||
|
||||
let payload: Record<string, unknown> = {};
|
||||
try {
|
||||
payload = JSON.parse(proposal.payload);
|
||||
} catch {
|
||||
payload = {};
|
||||
}
|
||||
|
||||
const rows = fieldRows(payload);
|
||||
const destructive = isDestructive(proposal.kind);
|
||||
const resolved = proposal.status !== "pending";
|
||||
|
||||
const command = asText(payload.command);
|
||||
const explanation = asText(payload.explanation) ?? asText(payload.reason);
|
||||
|
||||
// The stored summary repeats the command for run_command proposals, so a
|
||||
// short action label is used instead of printing it twice.
|
||||
const title =
|
||||
command !== null
|
||||
? t("ai.runCommandTitle")
|
||||
: (proposal.summary ?? toolLabel(proposal.kind));
|
||||
|
||||
async function handleApply() {
|
||||
setBusy("apply");
|
||||
try {
|
||||
const result = await applyAiProposal(proposal.id);
|
||||
// The result goes into the card, not a toast: command output can be
|
||||
// hundreds of lines, which covered the screen.
|
||||
onResolved(proposal.id, "applied", result.summary);
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, t("ai.proposalApplyFailed")));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReject() {
|
||||
setBusy("reject");
|
||||
try {
|
||||
await rejectAiProposal(proposal.id);
|
||||
onResolved(proposal.id, "rejected");
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, t("ai.proposalRejectFailed")));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-none border border-border bg-muted p-2.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{destructive && (
|
||||
<TriangleAlert size={13} className="shrink-0 text-destructive" />
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate text-xs font-medium">
|
||||
{title}
|
||||
</span>
|
||||
{resolved && (
|
||||
<span className="shrink-0 border border-border px-1.5 py-0.5 text-[10px] text-muted-foreground">
|
||||
{proposal.status === "applied"
|
||||
? t("ai.statusApplied")
|
||||
: t("ai.statusRejected")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/*
|
||||
A command is the payload, not a field of it, so it gets its own block
|
||||
rather than a label column. In the sidebar a fixed label gutter left
|
||||
barely any room for the command itself.
|
||||
*/}
|
||||
{command && (
|
||||
<pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap break-all border border-border bg-background px-2 py-1.5 font-mono text-[11px] leading-snug">
|
||||
{command}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
{explanation && (
|
||||
<p className="mt-1.5 text-[11px] leading-snug text-muted-foreground">
|
||||
{explanation}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{rows.length > 0 && (
|
||||
<div className="mt-2 space-y-1.5 border-t border-border pt-2">
|
||||
{rows.map(([key, value]) => (
|
||||
// Stacked, not a two-column row: the value is what matters and it
|
||||
// needs the full width to stay readable.
|
||||
<div key={key} className="min-w-0">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{fieldLabel(key)}
|
||||
</span>
|
||||
<div className="break-all font-mono text-[11px] leading-snug">
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{proposal.resultSummary && (
|
||||
// Command output comes back here, so it keeps the monospace treatment.
|
||||
<pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap break-all border border-border bg-background px-2 py-1.5 font-mono text-[11px] leading-snug text-muted-foreground">
|
||||
{proposal.resultSummary}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
{!resolved && (
|
||||
<div className="mt-2 flex gap-1.5">
|
||||
<Button
|
||||
size="sm"
|
||||
className={`h-7 flex-1 text-xs ${
|
||||
destructive
|
||||
? ""
|
||||
: "border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
}`}
|
||||
variant={destructive ? "destructive" : "outline"}
|
||||
disabled={busy !== null}
|
||||
onClick={handleApply}
|
||||
>
|
||||
{busy === "apply" ? (
|
||||
<Loader2 size={13} className="animate-spin" />
|
||||
) : (
|
||||
<Check size={13} />
|
||||
)}
|
||||
{t("ai.approve")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 flex-1 text-xs"
|
||||
variant="outline"
|
||||
disabled={busy !== null}
|
||||
onClick={handleReject}
|
||||
>
|
||||
{busy === "reject" ? (
|
||||
<Loader2 size={13} className="animate-spin" />
|
||||
) : (
|
||||
<X size={13} />
|
||||
)}
|
||||
{t("ai.reject")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Turns the internal identifiers the assistant works in into text a user can
|
||||
* read. Tool names, proposal kinds and payload keys all surface in the panel,
|
||||
* and raw ids like "propose_create_alert_rule" or "thresholddurationseconds"
|
||||
* are not something to show anyone.
|
||||
*/
|
||||
|
||||
/** What each tool is doing, phrased as an action rather than a function name. */
|
||||
const TOOL_LABELS: Record<string, string> = {
|
||||
list_hosts: "Reading hosts",
|
||||
get_host: "Reading host",
|
||||
list_fleets: "Reading fleets",
|
||||
list_snippets: "Reading snippets",
|
||||
list_automations: "Reading automations",
|
||||
get_automation: "Reading automation",
|
||||
list_workspaces: "Reading workspaces",
|
||||
list_alert_rules: "Reading alert rules",
|
||||
list_notification_channels: "Reading notification channels",
|
||||
get_alert_firings: "Reading recent alerts",
|
||||
list_homepage_items: "Reading homepage items",
|
||||
get_command_history: "Reading command history",
|
||||
get_network_topology: "Reading network topology",
|
||||
propose_create_host: "Add host",
|
||||
propose_update_host: "Update host",
|
||||
propose_delete_host: "Delete host",
|
||||
propose_create_snippet: "Create snippet",
|
||||
propose_update_snippet: "Update snippet",
|
||||
propose_delete_snippet: "Delete snippet",
|
||||
propose_create_automation: "Create automation",
|
||||
propose_create_fleet: "Create fleet",
|
||||
propose_create_alert_rule: "Create alert rule",
|
||||
propose_run_command: "Run command",
|
||||
};
|
||||
|
||||
/** Readable names for the payload keys the propose tools emit. */
|
||||
const FIELD_LABELS: Record<string, string> = {
|
||||
hostId: "Host",
|
||||
hostIds: "Hosts",
|
||||
snippetId: "Snippet",
|
||||
automationId: "Automation",
|
||||
name: "Name",
|
||||
ip: "Address",
|
||||
port: "Port",
|
||||
username: "Username",
|
||||
folder: "Folder",
|
||||
tags: "Tags",
|
||||
content: "Command",
|
||||
description: "Description",
|
||||
definition: "Definition",
|
||||
enabled: "Enabled",
|
||||
triggerType: "Trigger",
|
||||
thresholdValue: "Threshold",
|
||||
thresholdDurationSeconds: "Duration (seconds)",
|
||||
cooldownMinutes: "Cooldown (minutes)",
|
||||
};
|
||||
|
||||
const MENTION_LABELS: Record<string, string> = {
|
||||
host: "Host",
|
||||
snippet: "Snippet",
|
||||
automation: "Automation",
|
||||
};
|
||||
|
||||
/**
|
||||
* Splits snake_case and camelCase into words and capitalises the first.
|
||||
* Used whenever an identifier has no explicit label, so something added later
|
||||
* still reads as words rather than one run-on token.
|
||||
*/
|
||||
export function humanize(value: string): string {
|
||||
const spaced = value
|
||||
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
||||
.replace(/[_-]+/g, " ")
|
||||
.trim();
|
||||
if (!spaced) return value;
|
||||
return spaced.charAt(0).toUpperCase() + spaced.slice(1).toLowerCase();
|
||||
}
|
||||
|
||||
export function toolLabel(name: string): string {
|
||||
return TOOL_LABELS[name] ?? humanize(name);
|
||||
}
|
||||
|
||||
export function fieldLabel(key: string): string {
|
||||
return FIELD_LABELS[key] ?? humanize(key);
|
||||
}
|
||||
|
||||
export function mentionLabel(kind: string): string {
|
||||
return MENTION_LABELS[kind] ?? humanize(kind);
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import { getErrorMessage } from "../../lib/error-message.js";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { authApi } from "@/main-axios";
|
||||
import type { AiProposal } from "@/api/ai-api";
|
||||
|
||||
/**
|
||||
* Reuses whatever base authApi resolved to, so the stream follows the same
|
||||
* dev proxy, Electron localhost and reverse-proxy base path rules as every
|
||||
* other call instead of hardcoding an origin.
|
||||
*/
|
||||
function streamUrl(): string {
|
||||
const base = (authApi.defaults.baseURL ?? "").replace(/\/+$/, "");
|
||||
return `${base}/ai/chat/stream`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives the chat stream.
|
||||
*
|
||||
* EventSource cannot POST, and the request carries a message body, so this
|
||||
* reads the SSE frames off a fetch response by hand.
|
||||
*/
|
||||
|
||||
export interface ToolActivity {
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: Record<string, unknown>;
|
||||
result?: unknown;
|
||||
}
|
||||
|
||||
export interface StreamState {
|
||||
streaming: boolean;
|
||||
assistantText: string;
|
||||
tools: ToolActivity[];
|
||||
proposals: AiProposal[];
|
||||
error: string | null;
|
||||
conversationId: number | null;
|
||||
}
|
||||
|
||||
const INITIAL: StreamState = {
|
||||
streaming: false,
|
||||
assistantText: "",
|
||||
tools: [],
|
||||
proposals: [],
|
||||
error: null,
|
||||
conversationId: null,
|
||||
};
|
||||
|
||||
export function useAiStream() {
|
||||
const [state, setState] = useState<StreamState>(INITIAL);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setState(INITIAL);
|
||||
}, []);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
abortRef.current?.abort();
|
||||
abortRef.current = null;
|
||||
setState((prev) => ({ ...prev, streaming: false }));
|
||||
}, []);
|
||||
|
||||
const send = useCallback(
|
||||
async (input: {
|
||||
message: string;
|
||||
providerId: number;
|
||||
model?: string;
|
||||
conversationId?: number | null;
|
||||
activeTab?: string | null;
|
||||
onComplete?: (conversationId: number | null, reply: string) => void;
|
||||
}) => {
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
setState({
|
||||
streaming: true,
|
||||
assistantText: "",
|
||||
tools: [],
|
||||
proposals: [],
|
||||
error: null,
|
||||
conversationId: input.conversationId ?? null,
|
||||
});
|
||||
|
||||
let conversationId = input.conversationId ?? null;
|
||||
let replyText = "";
|
||||
let toolSequence = 0;
|
||||
|
||||
try {
|
||||
const response = await fetch(streamUrl(), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
signal: controller.signal,
|
||||
body: JSON.stringify({
|
||||
message: input.message,
|
||||
providerId: input.providerId,
|
||||
model: input.model,
|
||||
conversationId: input.conversationId ?? undefined,
|
||||
activeTab: input.activeTab ?? undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let message = "The assistant could not be reached";
|
||||
try {
|
||||
message = (await response.json()).error ?? message;
|
||||
} catch {
|
||||
// Keep the generic message.
|
||||
}
|
||||
setState((prev) => ({ ...prev, streaming: false, error: message }));
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
streaming: false,
|
||||
error: "The assistant returned an empty response",
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
let newlineIndex = buffer.indexOf("\n");
|
||||
while (newlineIndex !== -1) {
|
||||
const line = buffer.slice(0, newlineIndex).trim();
|
||||
buffer = buffer.slice(newlineIndex + 1);
|
||||
newlineIndex = buffer.indexOf("\n");
|
||||
|
||||
if (!line.startsWith("data:")) continue;
|
||||
|
||||
let event: any;
|
||||
try {
|
||||
event = JSON.parse(line.slice(5).trim());
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (event.type === "conversation") {
|
||||
conversationId = event.conversationId;
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
conversationId: event.conversationId,
|
||||
}));
|
||||
} else if (event.type === "token") {
|
||||
replyText += event.text;
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
assistantText: prev.assistantText + event.text,
|
||||
}));
|
||||
} else if (event.type === "tool_call") {
|
||||
// The id is assigned here rather than inside the updater: React
|
||||
// may run an updater more than once, and deriving the id from
|
||||
// prev.tools.length appended a duplicate entry that no result
|
||||
// could ever match, leaving it stuck on "running".
|
||||
const activityId = `tool-${toolSequence++}`;
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
tools: [
|
||||
...prev.tools,
|
||||
{
|
||||
id: activityId,
|
||||
name: event.name,
|
||||
arguments: event.arguments ?? {},
|
||||
},
|
||||
],
|
||||
}));
|
||||
} else if (event.type === "tool_result") {
|
||||
setState((prev) => {
|
||||
const tools = [...prev.tools];
|
||||
// Attach to the most recent call of this tool awaiting a result.
|
||||
for (let i = tools.length - 1; i >= 0; i -= 1) {
|
||||
if (tools[i].name === event.name && !("result" in tools[i])) {
|
||||
tools[i] = { ...tools[i], result: event.result };
|
||||
break;
|
||||
}
|
||||
}
|
||||
return { ...prev, tools };
|
||||
});
|
||||
} else if (event.type === "proposal") {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
proposals: [...prev.proposals, event.proposal],
|
||||
}));
|
||||
} else if (event.type === "error") {
|
||||
setState((prev) => ({ ...prev, error: event.message }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setState((prev) => ({ ...prev, streaming: false }));
|
||||
input.onComplete?.(conversationId, replyText);
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) {
|
||||
setState((prev) => ({ ...prev, streaming: false }));
|
||||
return;
|
||||
}
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
streaming: false,
|
||||
error: getErrorMessage(error, "The assistant stopped unexpectedly"),
|
||||
}));
|
||||
} finally {
|
||||
abortRef.current = null;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return { state, send, stop, reset, setState };
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { getSSHHosts } from "@/api/ssh-host-management-api";
|
||||
import { getSnippets } from "@/api/snippets-api";
|
||||
import { listAutomations } from "@/api/automations-api";
|
||||
|
||||
/**
|
||||
* Backs the @-mention picker in the composer.
|
||||
*
|
||||
* A mention is only ever a name the assistant reads in the message text; it
|
||||
* does not grant access to anything. The assistant still has to call a read
|
||||
* tool to see the referenced item, so every access stays visible in the
|
||||
* transcript rather than being silently attached to the prompt.
|
||||
*/
|
||||
|
||||
export type MentionKind = "host" | "snippet" | "automation";
|
||||
|
||||
export interface MentionItem {
|
||||
kind: MentionKind;
|
||||
id: number;
|
||||
label: string;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
/** Extracts the "@partial" the caret currently sits in, if any. */
|
||||
export function activeMentionQuery(
|
||||
value: string,
|
||||
caret: number,
|
||||
): { query: string; start: number } | null {
|
||||
const before = value.slice(0, caret);
|
||||
const at = before.lastIndexOf("@");
|
||||
if (at === -1) return null;
|
||||
|
||||
// Only trigger at a word boundary, so an email address does not open it.
|
||||
if (at > 0 && !/\s/.test(before[at - 1])) return null;
|
||||
|
||||
const query = before.slice(at + 1);
|
||||
if (/\s/.test(query)) return null;
|
||||
|
||||
return { query, start: at };
|
||||
}
|
||||
|
||||
export function useMentions(enabled: boolean) {
|
||||
const [items, setItems] = useState<MentionItem[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
const [hosts, snippets, automations] = await Promise.allSettled([
|
||||
getSSHHosts(),
|
||||
getSnippets(),
|
||||
listAutomations(),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
|
||||
const collected: MentionItem[] = [];
|
||||
|
||||
if (hosts.status === "fulfilled") {
|
||||
for (const host of (hosts.value ?? []) as any[]) {
|
||||
collected.push({
|
||||
kind: "host",
|
||||
id: host.id,
|
||||
label: host.name || host.ip,
|
||||
detail: host.ip,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (snippets.status === "fulfilled") {
|
||||
for (const snippet of (snippets.value ?? []) as any[]) {
|
||||
collected.push({
|
||||
kind: "snippet",
|
||||
id: snippet.id,
|
||||
label: snippet.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (automations.status === "fulfilled") {
|
||||
for (const automation of (automations.value ?? []) as any[]) {
|
||||
collected.push({
|
||||
kind: "automation",
|
||||
id: automation.id,
|
||||
label: automation.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setItems(collected);
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [enabled]);
|
||||
|
||||
const search = useCallback(
|
||||
(query: string): MentionItem[] => {
|
||||
const needle = query.trim().toLowerCase();
|
||||
const matches = needle
|
||||
? items.filter((item) => item.label.toLowerCase().includes(needle))
|
||||
: items;
|
||||
return matches.slice(0, 8);
|
||||
},
|
||||
[items],
|
||||
);
|
||||
|
||||
return useMemo(() => ({ items, search }), [items, search]);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DockerManager } from "@/features/docker/DockerManager.tsx";
|
||||
import { FullScreenAppWrapper } from "@/features/FullScreenAppWrapper.tsx";
|
||||
import { ConnectionScreen } from "@/components/connection/ConnectionScreen.tsx";
|
||||
|
||||
interface DockerAppProps {
|
||||
hostId?: string;
|
||||
@@ -11,26 +12,25 @@ const DockerApp: React.FC<DockerAppProps> = ({ hostId }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<FullScreenAppWrapper hostId={hostId}>
|
||||
{(hostConfig, loading) => {
|
||||
if (loading) {
|
||||
{(hostConfig, phase) => {
|
||||
if (phase === "loading") {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto mb-2"></div>
|
||||
<p className="text-muted-foreground">
|
||||
{t("hosts.loadingHost")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="relative h-full w-full">
|
||||
<ConnectionScreen
|
||||
status="connecting"
|
||||
message={t("hosts.loadingHost")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hostConfig) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<p className="text-red-500 mb-4">{t("hosts.hostNotFound")}</p>
|
||||
</div>
|
||||
<div className="relative h-full w-full">
|
||||
<ConnectionScreen
|
||||
status="disconnected"
|
||||
message={t("hosts.hostNotFound")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,18 +26,20 @@ import {
|
||||
logActivity,
|
||||
getSSHHosts,
|
||||
} from "@/main-axios.ts";
|
||||
import { SimpleLoader } from "@/lib/SimpleLoader.tsx";
|
||||
import { ContainerList } from "./components/ContainerList.tsx";
|
||||
import { ContainerDetail } from "./components/ContainerDetail.tsx";
|
||||
import { TOTPDialog } from "@/ssh/dialogs/TOTPDialog.tsx";
|
||||
import { SSHAuthDialog } from "@/ssh/dialogs/SSHAuthDialog.tsx";
|
||||
import { WarpgateDialog } from "@/ssh/dialogs/WarpgateDialog.tsx";
|
||||
import { useTabsSafe } from "@/shell/TabContext.tsx";
|
||||
import { useAreaPreferences } from "@/contexts/UiPreferencesContext";
|
||||
import {
|
||||
ConnectionLogProvider,
|
||||
useConnectionLog,
|
||||
} from "@/ssh/connection-log/ConnectionLogContext.tsx";
|
||||
import { ConnectionLog } from "@/ssh/connection-log/ConnectionLog.tsx";
|
||||
import { ConnectionScreen } from "@/components/connection/ConnectionScreen.tsx";
|
||||
import { useConnectionRetry } from "@/lib/useConnectionRetry.ts";
|
||||
import { useAdaptivePolling } from "@/hooks/use-adaptive-polling.ts";
|
||||
import type { LogEntry } from "@/types/connection-log.ts";
|
||||
|
||||
interface DockerManagerProps {
|
||||
@@ -65,16 +67,13 @@ function DockerManagerInner({
|
||||
onClose,
|
||||
}: DockerManagerProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
addLog,
|
||||
setLogs,
|
||||
clearLogs,
|
||||
isExpanded: isConnectionLogExpanded,
|
||||
} = useConnectionLog();
|
||||
const { addLog, setLogs, clearLogs } = useConnectionLog();
|
||||
const { currentTab, removeTab } = useTabsSafe();
|
||||
const dockerPrefs = useAreaPreferences("docker");
|
||||
const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig);
|
||||
const [sessionId, setSessionId] = React.useState<string | null>(null);
|
||||
const [containers, setContainers] = React.useState<DockerContainer[]>([]);
|
||||
const containersSignatureRef = React.useRef("");
|
||||
const [selectedContainer, setSelectedContainer] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
@@ -82,8 +81,14 @@ function DockerManagerInner({
|
||||
const [dockerValidation, setDockerValidation] =
|
||||
React.useState<DockerValidation | null>(null);
|
||||
const [isValidating, setIsValidating] = React.useState(false);
|
||||
const [viewMode, setViewMode] = React.useState<"list" | "detail">("list");
|
||||
// Initial view follows the interface preset; switching it afterwards is a
|
||||
// per-session choice, same as before.
|
||||
const [viewMode, setViewMode] = React.useState<"list" | "detail">(
|
||||
dockerPrefs.viewMode,
|
||||
);
|
||||
const [isLoadingContainers, setIsLoadingContainers] = React.useState(false);
|
||||
const [hasLoadedContainersOnce, setHasLoadedContainersOnce] =
|
||||
React.useState(false);
|
||||
const [totpRequired, setTotpRequired] = React.useState(false);
|
||||
const [totpSessionId, setTotpSessionId] = React.useState<string | null>(null);
|
||||
const [totpPrompt, setTotpPrompt] = React.useState<string>("");
|
||||
@@ -98,7 +103,6 @@ function DockerManagerInner({
|
||||
const [authReason, setAuthReason] = React.useState<
|
||||
"no_keyboard" | "auth_failed" | "timeout"
|
||||
>("no_keyboard");
|
||||
const [hasConnectionError, setHasConnectionError] = React.useState(false);
|
||||
const [search, setSearch] = React.useState("");
|
||||
const [statusFilter, setStatusFilter] = React.useState("all");
|
||||
const [retryCount, setRetryCount] = React.useState(0);
|
||||
@@ -195,7 +199,6 @@ function DockerManagerInner({
|
||||
}
|
||||
|
||||
setIsConnecting(true);
|
||||
setHasConnectionError(false);
|
||||
clearLogs();
|
||||
const sid = `docker-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
@@ -243,7 +246,6 @@ function DockerManagerInner({
|
||||
setIsValidating(false);
|
||||
|
||||
if (!validation.available) {
|
||||
setHasConnectionError(true);
|
||||
addLog({
|
||||
type: "error",
|
||||
stage: "validation",
|
||||
@@ -252,15 +254,16 @@ function DockerManagerInner({
|
||||
? `Error code: ${validation.code}`
|
||||
: undefined,
|
||||
});
|
||||
dockerConnectRetryRef.current.markFailed();
|
||||
} else {
|
||||
logDockerActivity();
|
||||
setTimeout(() => clearLogs(), 1000);
|
||||
dockerConnectRetryRef.current.markConnected();
|
||||
}
|
||||
} catch (error) {
|
||||
const dockerError = error as DockerConnectionError;
|
||||
setIsConnecting(false);
|
||||
setIsValidating(false);
|
||||
setHasConnectionError(true);
|
||||
|
||||
if (Array.isArray(dockerError.connectionLogs)) {
|
||||
setLogs(dockerError.connectionLogs);
|
||||
@@ -271,6 +274,7 @@ function DockerManagerInner({
|
||||
message: dockerError.message || t("docker.connectionFailed"),
|
||||
});
|
||||
}
|
||||
dockerConnectRetryRef.current.markFailed();
|
||||
} finally {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
@@ -314,37 +318,37 @@ function DockerManagerInner({
|
||||
}, [sessionId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!sessionId || !isVisible || !dockerValidation?.available) return;
|
||||
setHasLoadedContainersOnce(false);
|
||||
}, [sessionId]);
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const pollContainers = async () => {
|
||||
try {
|
||||
setIsLoadingContainers(true);
|
||||
const data = await listDockerContainers(sessionId, true);
|
||||
if (!cancelled) {
|
||||
setContainers(data);
|
||||
}
|
||||
} catch {
|
||||
// Silently handle polling errors
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setIsLoadingContainers(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
pollContainers();
|
||||
const interval = setInterval(() => {
|
||||
if (document.visibilityState === "hidden") return;
|
||||
void pollContainers();
|
||||
}, 5000);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [sessionId, isVisible, dockerValidation?.available]);
|
||||
useAdaptivePolling(
|
||||
async () => {
|
||||
if (!sessionId) return;
|
||||
setIsLoadingContainers((loading) =>
|
||||
hasLoadedContainersOnce ? loading : true,
|
||||
);
|
||||
const data = await listDockerContainers(sessionId, true);
|
||||
const signature = JSON.stringify(data);
|
||||
const changed = signature !== containersSignatureRef.current;
|
||||
containersSignatureRef.current = signature;
|
||||
setContainers(data);
|
||||
setIsLoadingContainers(false);
|
||||
setHasLoadedContainersOnce(true);
|
||||
return changed;
|
||||
},
|
||||
{
|
||||
minIntervalMs: 5000,
|
||||
maxIntervalMs: 30000,
|
||||
stablePollsPerStep: 3,
|
||||
},
|
||||
!!sessionId && isVisible && !!dockerValidation?.available,
|
||||
{
|
||||
onError: () => {
|
||||
setIsLoadingContainers(false);
|
||||
setHasLoadedContainersOnce(true);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const handleBack = React.useCallback(() => {
|
||||
setViewMode("list");
|
||||
@@ -370,7 +374,6 @@ function DockerManagerInner({
|
||||
setIsValidating(false);
|
||||
|
||||
if (!validation.available) {
|
||||
setHasConnectionError(true);
|
||||
addLog({
|
||||
type: "error",
|
||||
stage: "validation",
|
||||
@@ -379,18 +382,20 @@ function DockerManagerInner({
|
||||
? `Error code: ${validation.code}`
|
||||
: undefined,
|
||||
});
|
||||
dockerConnectRetryRef.current.markFailed();
|
||||
} else {
|
||||
logDockerActivity();
|
||||
dockerConnectRetryRef.current.markConnected();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("TOTP verification failed:", error);
|
||||
setHasConnectionError(true);
|
||||
addLog({
|
||||
type: "error",
|
||||
stage: "auth",
|
||||
message: t("docker.totpVerificationFailed"),
|
||||
});
|
||||
dockerConnectRetryRef.current.markFailed();
|
||||
} finally {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
@@ -426,7 +431,6 @@ function DockerManagerInner({
|
||||
setIsValidating(false);
|
||||
|
||||
if (!validation.available) {
|
||||
setHasConnectionError(true);
|
||||
addLog({
|
||||
type: "error",
|
||||
stage: "validation",
|
||||
@@ -435,18 +439,20 @@ function DockerManagerInner({
|
||||
? `Error code: ${validation.code}`
|
||||
: undefined,
|
||||
});
|
||||
dockerConnectRetryRef.current.markFailed();
|
||||
} else {
|
||||
logDockerActivity();
|
||||
dockerConnectRetryRef.current.markConnected();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Warpgate verification failed:", error);
|
||||
setHasConnectionError(true);
|
||||
addLog({
|
||||
type: "error",
|
||||
stage: "auth",
|
||||
message: t("docker.warpgateVerificationFailed"),
|
||||
});
|
||||
dockerConnectRetryRef.current.markFailed();
|
||||
} finally {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
@@ -526,19 +532,20 @@ function DockerManagerInner({
|
||||
setIsValidating(false);
|
||||
|
||||
if (!validation.available) {
|
||||
setHasConnectionError(true);
|
||||
dockerConnectRetryRef.current.markFailed();
|
||||
} else {
|
||||
logDockerActivity();
|
||||
dockerConnectRetryRef.current.markConnected();
|
||||
}
|
||||
} catch (error) {
|
||||
setIsConnecting(false);
|
||||
setIsValidating(false);
|
||||
setHasConnectionError(true);
|
||||
addLog({
|
||||
type: "error",
|
||||
stage: "connection",
|
||||
message: error?.message || t("docker.connectionFailed"),
|
||||
});
|
||||
dockerConnectRetryRef.current.markFailed();
|
||||
} finally {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
@@ -553,12 +560,25 @@ function DockerManagerInner({
|
||||
const handleRetry = () => {
|
||||
initializingRef.current = false;
|
||||
setSessionId(null);
|
||||
setHasConnectionError(false);
|
||||
setDockerValidation(null);
|
||||
clearLogs();
|
||||
setRetryCount((c) => c + 1);
|
||||
};
|
||||
|
||||
const dockerConnectRetry = useConnectionRetry({
|
||||
connect: () => {
|
||||
handleRetry();
|
||||
},
|
||||
enabled:
|
||||
!!currentHostConfig?.enableDocker &&
|
||||
!totpRequired &&
|
||||
!warpgateRequired &&
|
||||
!showAuthDialog,
|
||||
autoStart: false,
|
||||
});
|
||||
const dockerConnectRetryRef = React.useRef(dockerConnectRetry);
|
||||
dockerConnectRetryRef.current = dockerConnectRetry;
|
||||
|
||||
const topMarginPx = isTopbarOpen ? 74 : 16;
|
||||
const leftMarginPx = 8;
|
||||
const bottomMarginPx = 8;
|
||||
@@ -609,53 +629,32 @@ function DockerManagerInner({
|
||||
if (isConnecting || isValidating) {
|
||||
return (
|
||||
<div style={wrapperStyle} className={`${containerClass} relative`}>
|
||||
<div className="h-full w-full flex flex-col">
|
||||
<div className="flex-1 overflow-hidden min-h-0 relative">
|
||||
<SimpleLoader
|
||||
visible={!isConnectionLogExpanded}
|
||||
message={
|
||||
isValidating ? t("docker.validating") : t("docker.connecting")
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ConnectionLog
|
||||
isConnecting={isConnecting}
|
||||
isConnected={!!sessionId && !!dockerValidation?.available}
|
||||
hasConnectionError={hasConnectionError}
|
||||
position={hasConnectionError ? "top" : "bottom"}
|
||||
<ConnectionScreen
|
||||
status={dockerConnectRetry.status}
|
||||
message={
|
||||
isValidating ? t("docker.validating") : t("docker.connecting")
|
||||
}
|
||||
attempt={dockerConnectRetry.attempt}
|
||||
maxAttempts={dockerConnectRetry.maxAttempts}
|
||||
nextRetryInMs={dockerConnectRetry.nextRetryInMs}
|
||||
onManualRetry={dockerConnectRetry.retryNow}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (dockerValidation && !dockerValidation.available) {
|
||||
const isError =
|
||||
hasConnectionError || (!!dockerValidation && !dockerValidation.available);
|
||||
return (
|
||||
<div style={wrapperStyle} className={`${containerClass} relative`}>
|
||||
{!isConnectionLogExpanded && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 z-10">
|
||||
<Box className="size-12 opacity-20" />
|
||||
<p className="text-sm text-muted-foreground opacity-60">
|
||||
{t("docker.connectionFailed")}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
onClick={handleRetry}
|
||||
className="gap-2 font-semibold"
|
||||
>
|
||||
<RefreshCw className="size-3.5" />
|
||||
{t("terminal.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<ConnectionLog
|
||||
isConnecting={isConnecting}
|
||||
isConnected={!!sessionId && !!dockerValidation?.available}
|
||||
hasConnectionError={isError}
|
||||
position={isError ? "top" : "bottom"}
|
||||
<ConnectionScreen
|
||||
status={dockerConnectRetry.status}
|
||||
message={t("docker.connectionFailed")}
|
||||
attempt={dockerConnectRetry.attempt}
|
||||
maxAttempts={dockerConnectRetry.maxAttempts}
|
||||
nextRetryInMs={dockerConnectRetry.nextRetryInMs}
|
||||
onManualRetry={dockerConnectRetry.retryNow}
|
||||
retryLabel={t("terminal.retry")}
|
||||
logPosition="top"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -663,17 +662,7 @@ function DockerManagerInner({
|
||||
|
||||
return (
|
||||
<div style={wrapperStyle} className={`${containerClass} relative`}>
|
||||
<div
|
||||
className="h-full w-full flex flex-col flex-1 min-h-0 overflow-hidden"
|
||||
style={{
|
||||
visibility:
|
||||
(hasConnectionError ||
|
||||
(!!dockerValidation && !dockerValidation.available)) &&
|
||||
isConnectionLogExpanded
|
||||
? "hidden"
|
||||
: "visible",
|
||||
}}
|
||||
>
|
||||
<div className="h-full w-full flex flex-col flex-1 min-h-0 overflow-hidden">
|
||||
{viewMode === "detail" &&
|
||||
sessionId &&
|
||||
selectedContainer &&
|
||||
@@ -699,6 +688,10 @@ function DockerManagerInner({
|
||||
<span className="text-xs text-muted-foreground uppercase tracking-widest font-semibold">
|
||||
{dockerValidation?.version
|
||||
? t("docker.version", {
|
||||
runtime:
|
||||
dockerValidation.runtime === "podman"
|
||||
? "Podman"
|
||||
: "Docker",
|
||||
version: dockerValidation.version,
|
||||
})
|
||||
: t("docker.manager")}
|
||||
@@ -753,7 +746,7 @@ function DockerManagerInner({
|
||||
</Card>
|
||||
|
||||
{sessionId ? (
|
||||
isLoadingContainers && containers.length === 0 ? (
|
||||
!hasLoadedContainersOnce ? (
|
||||
<div className="flex flex-col items-center justify-center h-full opacity-40 py-20">
|
||||
<RefreshCw className="size-8 animate-spin mb-4" />
|
||||
<span className="text-sm font-semibold">
|
||||
@@ -806,40 +799,15 @@ function DockerManagerInner({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<SimpleLoader
|
||||
visible={isConnecting && !isConnectionLogExpanded}
|
||||
<ConnectionScreen
|
||||
status={dockerConnectRetry.status}
|
||||
message={t("docker.connecting")}
|
||||
/>
|
||||
{hasConnectionError && !isConnectionLogExpanded && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 z-10">
|
||||
<Box className="size-12 opacity-20" />
|
||||
<p className="text-sm text-muted-foreground opacity-60">
|
||||
{t("docker.connectionFailed")}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
onClick={handleRetry}
|
||||
className="gap-2 font-semibold"
|
||||
>
|
||||
<RefreshCw className="size-3.5" />
|
||||
{t("terminal.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<ConnectionLog
|
||||
isConnecting={isConnecting}
|
||||
isConnected={!!sessionId && !!dockerValidation?.available}
|
||||
hasConnectionError={
|
||||
hasConnectionError ||
|
||||
(!!dockerValidation && !dockerValidation.available)
|
||||
}
|
||||
position={
|
||||
hasConnectionError ||
|
||||
(!!dockerValidation && !dockerValidation.available)
|
||||
? "top"
|
||||
: "bottom"
|
||||
}
|
||||
attempt={dockerConnectRetry.attempt}
|
||||
maxAttempts={dockerConnectRetry.maxAttempts}
|
||||
nextRetryInMs={dockerConnectRetry.nextRetryInMs}
|
||||
onManualRetry={dockerConnectRetry.retryNow}
|
||||
retryLabel={t("terminal.retry")}
|
||||
logPosition="top"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { getErrorMessage } from "../../../lib/error-message.js";
|
||||
import React from "react";
|
||||
import { useXTerm } from "react-xtermjs";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { ClipboardAddon } from "@xterm/addon-clipboard";
|
||||
import { RobustClipboardProvider } from "@/lib/clipboard-provider";
|
||||
import { copyToClipboard } from "@/lib/clipboard";
|
||||
import { copyToClipboard, readFromClipboard } from "@/lib/clipboard";
|
||||
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import {
|
||||
@@ -23,12 +24,17 @@ import { Terminal as TerminalIcon, Power, PowerOff } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import type { SSHHost } from "@/types";
|
||||
import { isElectron } from "@/main-axios.ts";
|
||||
import { SimpleLoader } from "@/lib/SimpleLoader.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { resolveTermixThemeColors } from "@/features/terminal/terminal-theme";
|
||||
import { DEFAULT_TERMINAL_CONFIG, TERMINAL_FONTS } from "@/lib/terminal-themes";
|
||||
import { ensureTerminalFontsLoaded } from "@/features/terminal/terminal-global-styles";
|
||||
import { useTheme } from "@/components/theme-provider";
|
||||
import { ConnectionScreen } from "@/components/connection/ConnectionScreen.tsx";
|
||||
import {
|
||||
ConnectionLogProvider,
|
||||
useConnectionLog,
|
||||
} from "@/ssh/connection-log/ConnectionLogContext.tsx";
|
||||
import { useConnectionRetry } from "@/lib/useConnectionRetry.ts";
|
||||
|
||||
interface ConsoleTerminalProps {
|
||||
containerId: string;
|
||||
@@ -37,7 +43,17 @@ interface ConsoleTerminalProps {
|
||||
hostConfig: SSHHost;
|
||||
}
|
||||
|
||||
export function ConsoleTerminal({
|
||||
export function ConsoleTerminal(
|
||||
props: ConsoleTerminalProps,
|
||||
): React.ReactElement {
|
||||
return (
|
||||
<ConnectionLogProvider>
|
||||
<ConsoleTerminalInner {...props} />
|
||||
</ConnectionLogProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function ConsoleTerminalInner({
|
||||
containerId,
|
||||
containerName,
|
||||
containerState,
|
||||
@@ -46,6 +62,7 @@ export function ConsoleTerminal({
|
||||
const { t } = useTranslation();
|
||||
const { theme: appTheme } = useTheme();
|
||||
const { instance: terminal, ref: xtermRef } = useXTerm();
|
||||
const { addLog, clearLogs } = useConnectionLog();
|
||||
|
||||
const terminalConfig = React.useMemo(
|
||||
() => ({ ...DEFAULT_TERMINAL_CONFIG, ...hostConfig.terminalConfig }),
|
||||
@@ -93,10 +110,7 @@ export function ConsoleTerminal({
|
||||
terminal.options.lineHeight = terminalConfig.lineHeight;
|
||||
|
||||
const readTextFromClipboard = async (): Promise<string> => {
|
||||
if (window.electronClipboard) {
|
||||
return window.electronClipboard.readText();
|
||||
}
|
||||
return (await navigator.clipboard?.readText?.()) ?? "";
|
||||
return readFromClipboard();
|
||||
};
|
||||
|
||||
const writeTextToClipboard = async (text: string): Promise<void> => {
|
||||
@@ -260,6 +274,8 @@ export function ConsoleTerminal({
|
||||
wsRef.current = null;
|
||||
}
|
||||
setIsConnected(false);
|
||||
retryRef.current.reset();
|
||||
clearLogs();
|
||||
if (terminal) {
|
||||
try {
|
||||
terminal.clear();
|
||||
@@ -267,7 +283,7 @@ export function ConsoleTerminal({
|
||||
// Terminal clear can fail after disposal.
|
||||
}
|
||||
}
|
||||
}, [terminal]);
|
||||
}, [terminal, clearLogs]);
|
||||
|
||||
const connect = React.useCallback(async () => {
|
||||
if (!terminal || containerState !== "running") {
|
||||
@@ -276,6 +292,11 @@ export function ConsoleTerminal({
|
||||
}
|
||||
|
||||
setIsConnecting(true);
|
||||
addLog({
|
||||
type: "info",
|
||||
stage: "docker_connecting",
|
||||
message: t("docker.connectingTo", { containerName }),
|
||||
});
|
||||
|
||||
try {
|
||||
if (fitAddonRef.current) {
|
||||
@@ -304,7 +325,6 @@ export function ConsoleTerminal({
|
||||
localPort: 30009,
|
||||
localPath: "/docker/console/",
|
||||
remotePath: "/docker/console/",
|
||||
includeLocalJwt: false,
|
||||
});
|
||||
if (!resolvedUrl) {
|
||||
setIsConnecting(false);
|
||||
@@ -348,6 +368,7 @@ export function ConsoleTerminal({
|
||||
case "connected":
|
||||
setIsConnected(true);
|
||||
setIsConnecting(false);
|
||||
retryRef.current.markConnected();
|
||||
|
||||
if (msg.data?.shellChanged) {
|
||||
toast.warning(
|
||||
@@ -376,6 +397,7 @@ export function ConsoleTerminal({
|
||||
case "disconnected":
|
||||
setIsConnected(false);
|
||||
setIsConnecting(false);
|
||||
retryRef.current.reset();
|
||||
terminal.write(
|
||||
`\r\n\x1b[1;33m${msg.message || t("docker.disconnected")}\x1b[0m\r\n`,
|
||||
);
|
||||
@@ -391,6 +413,12 @@ export function ConsoleTerminal({
|
||||
terminal.write(
|
||||
`\r\n\x1b[1;31m${t("docker.errorMessage", { message: msg.message })}\x1b[0m\r\n`,
|
||||
);
|
||||
addLog({
|
||||
type: "error",
|
||||
stage: "error",
|
||||
message: msg.message || t("docker.consoleError"),
|
||||
});
|
||||
retryRef.current.markFailed();
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -403,6 +431,12 @@ export function ConsoleTerminal({
|
||||
setIsConnecting(false);
|
||||
setIsConnected(false);
|
||||
toast.error(t("docker.failedToConnect"));
|
||||
addLog({
|
||||
type: "error",
|
||||
stage: "error",
|
||||
message: t("docker.failedToConnect"),
|
||||
});
|
||||
retryRef.current.markFailed();
|
||||
};
|
||||
|
||||
if (pingIntervalRef.current) {
|
||||
@@ -440,9 +474,10 @@ export function ConsoleTerminal({
|
||||
});
|
||||
} catch (error) {
|
||||
setIsConnecting(false);
|
||||
toast.error(
|
||||
`Failed to connect: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
const message = `Failed to connect: ${getErrorMessage(error)}`;
|
||||
toast.error(message);
|
||||
addLog({ type: "error", stage: "error", message });
|
||||
retryRef.current.markFailed();
|
||||
}
|
||||
}, [
|
||||
terminal,
|
||||
@@ -452,8 +487,16 @@ export function ConsoleTerminal({
|
||||
selectedShell,
|
||||
containerName,
|
||||
t,
|
||||
addLog,
|
||||
]);
|
||||
|
||||
const retry = useConnectionRetry({
|
||||
connect,
|
||||
autoStart: false,
|
||||
});
|
||||
const retryRef = React.useRef(retry);
|
||||
retryRef.current = retry;
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (pingIntervalRef.current) {
|
||||
@@ -559,30 +602,40 @@ export function ConsoleTerminal({
|
||||
style={{ display: isConnected ? "block" : "none" }}
|
||||
/>
|
||||
|
||||
{!isConnected && !isConnecting && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-center space-y-2">
|
||||
<TerminalIcon className="h-12 w-12 text-muted-foreground/50 mx-auto" />
|
||||
<p className="text-muted-foreground">
|
||||
{t("docker.notConnected")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("docker.clickToConnect")}
|
||||
</p>
|
||||
{!isConnected &&
|
||||
!isConnecting &&
|
||||
retry.status !== "error" &&
|
||||
retry.status !== "disconnected" && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-center space-y-2">
|
||||
<TerminalIcon className="h-12 w-12 text-muted-foreground/50 mx-auto" />
|
||||
<p className="text-muted-foreground">
|
||||
{t("docker.notConnected")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("docker.clickToConnect")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{isConnecting && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<SimpleLoader size="lg" />
|
||||
<p className="text-muted-foreground mt-4">
|
||||
{t("docker.connectingTo", { containerName })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isConnected &&
|
||||
(isConnecting ||
|
||||
retry.status === "error" ||
|
||||
retry.status === "disconnected") && (
|
||||
<ConnectionScreen
|
||||
status={isConnecting ? "connecting" : retry.status}
|
||||
message={t("docker.connectingTo", { containerName })}
|
||||
attempt={retry.attempt}
|
||||
maxAttempts={retry.maxAttempts}
|
||||
nextRetryInMs={retry.nextRetryInMs}
|
||||
onManualRetry={() => {
|
||||
clearLogs();
|
||||
retry.retryNow();
|
||||
}}
|
||||
retryLabel={t("docker.connect")}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getErrorMessage } from "../../../lib/error-message.js";
|
||||
import React from "react";
|
||||
import { Card } from "@/components/card.tsx";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
@@ -87,7 +88,7 @@ export function ContainerCard({
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
t("docker.failedToStartContainer", {
|
||||
error: err instanceof Error ? err.message : "Unknown error",
|
||||
error: getErrorMessage(err),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
@@ -105,7 +106,7 @@ export function ContainerCard({
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
t("docker.failedToStopContainer", {
|
||||
error: err instanceof Error ? err.message : "Unknown error",
|
||||
error: getErrorMessage(err),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
@@ -123,7 +124,7 @@ export function ContainerCard({
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
t("docker.failedToRestartContainer", {
|
||||
error: err instanceof Error ? err.message : "Unknown error",
|
||||
error: getErrorMessage(err),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
@@ -147,7 +148,7 @@ export function ContainerCard({
|
||||
toast.error(
|
||||
t("docker.failedToTogglePauseContainer", {
|
||||
action: container.state === "paused" ? "unpause" : "pause",
|
||||
error: err instanceof Error ? err.message : "Unknown error",
|
||||
error: getErrorMessage(err),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
@@ -175,7 +176,7 @@ export function ContainerCard({
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
t("docker.failedToRemoveContainer", {
|
||||
error: err instanceof Error ? err.message : "Unknown error",
|
||||
error: getErrorMessage(err),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getErrorMessage } from "../../../lib/error-message.js";
|
||||
import React from "react";
|
||||
import {
|
||||
Activity,
|
||||
@@ -13,6 +14,7 @@ import { getContainerStats } from "@/main-axios.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SectionCard } from "@/components/section-card";
|
||||
import { DockerBadge } from "./ContainerCard.tsx";
|
||||
import { useAdaptivePolling } from "@/hooks/use-adaptive-polling.ts";
|
||||
|
||||
interface ContainerStatsProps {
|
||||
sessionId: string;
|
||||
@@ -31,31 +33,43 @@ export function ContainerStats({
|
||||
const [stats, setStats] = React.useState<DockerStats | null>(null);
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const statsRef = React.useRef(stats);
|
||||
statsRef.current = stats;
|
||||
|
||||
const fetchStats = React.useCallback(async () => {
|
||||
if (containerState !== "running") return;
|
||||
if (containerState !== "running") return false;
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await getContainerStats(sessionId, containerId);
|
||||
const previous = statsRef.current;
|
||||
const changed =
|
||||
!previous ||
|
||||
Math.abs(parseFloat(data.cpu) - parseFloat(previous.cpu)) >= 1.5 ||
|
||||
Math.abs(
|
||||
parseFloat(data.memoryPercent) - parseFloat(previous.memoryPercent),
|
||||
) >= 1 ||
|
||||
data.pids !== previous.pids;
|
||||
statsRef.current = data;
|
||||
setStats(data);
|
||||
return changed;
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : t("docker.failedToFetchStats"),
|
||||
);
|
||||
setError(getErrorMessage(err, t("docker.failedToFetchStats")));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [sessionId, containerId, containerState, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
fetchStats();
|
||||
const interval = setInterval(() => {
|
||||
if (document.visibilityState === "hidden") return;
|
||||
void fetchStats();
|
||||
}, 2000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchStats]);
|
||||
useAdaptivePolling(
|
||||
fetchStats,
|
||||
{
|
||||
minIntervalMs: 2_000,
|
||||
maxIntervalMs: 12_000,
|
||||
stablePollsPerStep: 2,
|
||||
maxRequestDutyCycle: 0.2,
|
||||
},
|
||||
containerState === "running",
|
||||
);
|
||||
|
||||
if (containerState !== "running") {
|
||||
return (
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getErrorMessage } from "../../../lib/error-message.js";
|
||||
import React from "react";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import { Input } from "@/components/input.tsx";
|
||||
@@ -7,6 +8,7 @@ import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { DockerLogOptions } from "@/types";
|
||||
import { getContainerLogs, downloadContainerLogs } from "@/main-axios.ts";
|
||||
import { useAdaptivePolling } from "@/hooks/use-adaptive-polling.ts";
|
||||
|
||||
interface LogViewerProps {
|
||||
sessionId: string;
|
||||
@@ -65,6 +67,8 @@ export function LogViewer({
|
||||
const [autoRefresh, setAutoRefresh] = React.useState(false);
|
||||
const [logSearch, setLogSearch] = React.useState("");
|
||||
const logsEndRef = React.useRef<HTMLDivElement>(null);
|
||||
const rawLogsRef = React.useRef(rawLogs);
|
||||
rawLogsRef.current = rawLogs;
|
||||
|
||||
const fetchLogs = React.useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
@@ -74,11 +78,17 @@ export function LogViewer({
|
||||
timestamps: showTimestamps,
|
||||
};
|
||||
const data = await getContainerLogs(sessionId, containerId, options);
|
||||
setRawLogs(data.logs.split("\n").filter(Boolean));
|
||||
const next = data.logs.split("\n").filter(Boolean);
|
||||
const changed =
|
||||
next.length !== rawLogsRef.current.length ||
|
||||
next.some((line, index) => line !== rawLogsRef.current[index]);
|
||||
if (changed) {
|
||||
rawLogsRef.current = next;
|
||||
setRawLogs(next);
|
||||
}
|
||||
return changed;
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
`Failed to fetch logs: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
toast.error(`Failed to fetch logs: ${getErrorMessage(error)}`);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -88,14 +98,17 @@ export function LogViewer({
|
||||
fetchLogs();
|
||||
}, [fetchLogs]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!autoRefresh) return;
|
||||
const interval = setInterval(() => {
|
||||
if (document.visibilityState === "hidden") return;
|
||||
void fetchLogs();
|
||||
}, 3000);
|
||||
return () => clearInterval(interval);
|
||||
}, [autoRefresh, fetchLogs]);
|
||||
useAdaptivePolling(
|
||||
fetchLogs,
|
||||
{
|
||||
minIntervalMs: 3_000,
|
||||
maxIntervalMs: 18_000,
|
||||
stablePollsPerStep: 2,
|
||||
maxRequestDutyCycle: 0.2,
|
||||
},
|
||||
autoRefresh,
|
||||
{ runImmediately: false },
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (autoRefresh && logsEndRef.current) {
|
||||
@@ -119,9 +132,7 @@ export function LogViewer({
|
||||
document.body.removeChild(a);
|
||||
toast.success(t("docker.logsDownloaded"));
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
`Failed to download logs: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
toast.error(`Failed to download logs: ${getErrorMessage(error)}`);
|
||||
} finally {
|
||||
setIsDownloading(false);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getErrorMessage } from "../../lib/error-message.js";
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import React, {
|
||||
useState,
|
||||
@@ -6,9 +7,10 @@ import React, {
|
||||
useCallback,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import { asHttpError } from "@/lib/http-error";
|
||||
import { cn } from "@/lib/utils.ts";
|
||||
import { FileManagerGrid } from "./FileManagerGrid.tsx";
|
||||
import { FileManagerSidebar } from "./FileManagerSidebar.tsx";
|
||||
import { FileManagerSidebar, type SidebarItem } from "./FileManagerSidebar.tsx";
|
||||
import { FileManagerContextMenu } from "./FileManagerContextMenu.tsx";
|
||||
import { useFileSelection } from "./hooks/useFileSelection.ts";
|
||||
import { useDragAndDrop } from "./hooks/useDragAndDrop.ts";
|
||||
@@ -27,17 +29,20 @@ import { FileManagerDialogs } from "./FileManagerDialogs.tsx";
|
||||
import { PassphraseDialog } from "@/ssh/dialogs/PassphraseDialog.tsx";
|
||||
import { FileManagerToolbar } from "./FileManagerToolbar.tsx";
|
||||
import { TransferToHostDialog } from "./components/TransferToHostDialog.tsx";
|
||||
import { FileManagerTrashDialog } from "./FileManagerTrashDialog.tsx";
|
||||
import { TerminalWindow } from "./components/TerminalWindow.tsx";
|
||||
import type { SSHHost, FileItem } from "@/types/index";
|
||||
import {
|
||||
ConnectionLogProvider,
|
||||
useConnectionLog,
|
||||
} from "@/ssh/connection-log/ConnectionLogContext.tsx";
|
||||
import { ConnectionLog } from "@/ssh/connection-log/ConnectionLog.tsx";
|
||||
import { SimpleLoader } from "@/lib/SimpleLoader.tsx";
|
||||
import { ConnectionScreen } from "@/components/connection/ConnectionScreen.tsx";
|
||||
import { ConnectionLogPanel } from "@/components/connection/ConnectionLogPanel.tsx";
|
||||
import { useConnectionRetry } from "@/lib/useConnectionRetry.ts";
|
||||
import { copyToClipboard } from "@/lib/clipboard.ts";
|
||||
import {
|
||||
listSSHFiles,
|
||||
readSSHFile,
|
||||
resolveSSHPath,
|
||||
uploadSSHFile,
|
||||
createSSHFile,
|
||||
@@ -69,9 +74,18 @@ import {
|
||||
type TransferMethodPreference,
|
||||
type DiskFilesystem,
|
||||
} from "@/main-axios.ts";
|
||||
import {
|
||||
getAdaptiveResourceBudget,
|
||||
markAdaptiveResourceUsed,
|
||||
runAdaptiveBackgroundTask,
|
||||
} from "@/lib/adaptive-resource-budget";
|
||||
import { shouldPrefetchFileContent } from "@/lib/file-content-request-cache";
|
||||
import {
|
||||
markFilePreviewUsed,
|
||||
preloadFilePreview,
|
||||
} from "./file-preview-preload";
|
||||
import { beginTransferProgressMonitoring } from "./transferProgressMonitor.tsx";
|
||||
import { createFormatTransferMetrics } from "./transferMetricsFormat.ts";
|
||||
import type { SidebarItem } from "./FileManagerSidebar.tsx";
|
||||
import type {
|
||||
CreateIntent,
|
||||
FileManagerProps,
|
||||
@@ -79,6 +93,18 @@ import type {
|
||||
SSHConnectionError,
|
||||
} from "./file-manager-types.ts";
|
||||
import { formatFileSize } from "./file-manager-utils.ts";
|
||||
import {
|
||||
invalidateCachedFileList,
|
||||
peekCachedFileList,
|
||||
updateCachedFileList,
|
||||
} from "@/lib/file-list-request-cache";
|
||||
import {
|
||||
addOptimisticItem,
|
||||
childPath,
|
||||
removePaths,
|
||||
renameOptimisticItem,
|
||||
restoreItems,
|
||||
} from "./optimistic-file-list";
|
||||
|
||||
const LARGE_FILE_WARNING_SIZE = 50 * 1024 * 1024;
|
||||
|
||||
@@ -97,16 +123,14 @@ function FileManagerContent({
|
||||
[t],
|
||||
);
|
||||
const { confirmWithToast } = useConfirmation();
|
||||
const {
|
||||
addLog,
|
||||
clearLogs,
|
||||
isExpanded: isConnectionLogExpanded,
|
||||
} = useConnectionLog();
|
||||
const { addLog, clearLogs } = useConnectionLog();
|
||||
|
||||
const [currentHost] = useState<SSHHost | null>(initialHost || null);
|
||||
const [currentPath, setCurrentPath] = useState(
|
||||
initialPath || initialHost?.defaultPath || "/",
|
||||
);
|
||||
const currentPathRef = useRef(currentPath);
|
||||
currentPathRef.current = currentPath;
|
||||
const lastSuccessfulPathRef = useRef(
|
||||
initialPath || initialHost?.defaultPath || "/",
|
||||
);
|
||||
@@ -115,8 +139,13 @@ function FileManagerContent({
|
||||
]);
|
||||
const [navIndex, setNavIndex] = useState(0);
|
||||
const [files, setFiles] = useState<FileItem[]>([]);
|
||||
const filesRef = useRef(files);
|
||||
filesRef.current = files;
|
||||
const directoryRequestRef = useRef(0);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [sshSessionId, setSshSessionId] = useState<string | null>(null);
|
||||
const sshSessionIdRef = useRef(sshSessionId);
|
||||
sshSessionIdRef.current = sshSessionId;
|
||||
const [isReconnecting, setIsReconnecting] = useState<boolean>(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [lastRefreshTime, setLastRefreshTime] = useState<number>(0);
|
||||
@@ -124,6 +153,16 @@ function FileManagerContent({
|
||||
const saved = localStorage.getItem("fileManagerViewMode");
|
||||
return saved === "grid" || saved === "list" ? saved : "grid";
|
||||
});
|
||||
// Picking an interface preset seeds this key from another part of the app.
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
const saved = localStorage.getItem("fileManagerViewMode");
|
||||
if (saved === "grid" || saved === "list") setViewMode(saved);
|
||||
};
|
||||
window.addEventListener("fileManagerViewModeChanged", handler);
|
||||
return () =>
|
||||
window.removeEventListener("fileManagerViewModeChanged", handler);
|
||||
}, []);
|
||||
const [sortBy, setSortBy] = useState<"name" | "modified" | "size">(() => {
|
||||
const saved = localStorage.getItem("fileManagerSortBy");
|
||||
return saved === "name" || saved === "modified" || saved === "size"
|
||||
@@ -150,6 +189,7 @@ function FileManagerContent({
|
||||
const [showPassphraseDialog, setShowPassphraseDialog] = useState(false);
|
||||
const [pinnedFiles, setPinnedFiles] = useState<Set<string>>(new Set());
|
||||
const [sidebarRefreshTrigger, setSidebarRefreshTrigger] = useState(0);
|
||||
const [trashOpen, setTrashOpen] = useState(false);
|
||||
const [hasConnectionError, setHasConnectionError] = useState<boolean>(false);
|
||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||||
const [diskInfo, setDiskInfo] = useState<{
|
||||
@@ -214,6 +254,32 @@ function FileManagerContent({
|
||||
|
||||
const { selectedFiles, clearSelection, setSelection } = useFileSelection();
|
||||
|
||||
const commitFilesForPath = useCallback(
|
||||
(sessionId: string, path: string, next: FileItem[]) => {
|
||||
updateCachedFileList(sessionId, path, next);
|
||||
if (
|
||||
sshSessionIdRef.current !== sessionId ||
|
||||
currentPathRef.current !== path
|
||||
) {
|
||||
return;
|
||||
}
|
||||
directoryRequestRef.current += 1;
|
||||
filesRef.current = next;
|
||||
setFiles(next);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const filesForPath = useCallback((sessionId: string, path: string) => {
|
||||
if (
|
||||
sshSessionIdRef.current === sessionId &&
|
||||
currentPathRef.current === path
|
||||
) {
|
||||
return filesRef.current;
|
||||
}
|
||||
return peekCachedFileList(sessionId, path)?.files ?? [];
|
||||
}, []);
|
||||
|
||||
const { dragHandlers } = useDragAndDrop({
|
||||
onFilesDropped: handleFilesDropped,
|
||||
onItemsDropped: handleItemsDropped,
|
||||
@@ -264,13 +330,31 @@ function FileManagerContent({
|
||||
stage: "connection",
|
||||
message: errorMessage,
|
||||
});
|
||||
connectRetryRef.current?.markFailed();
|
||||
},
|
||||
[addLog],
|
||||
);
|
||||
|
||||
const connectRetry = useConnectionRetry({
|
||||
connect: () => {
|
||||
void initializeSSHConnection();
|
||||
},
|
||||
enabled:
|
||||
!!currentHost &&
|
||||
!sshSessionId &&
|
||||
!totpRequired &&
|
||||
!warpgateRequired &&
|
||||
!showAuthDialog &&
|
||||
!showPassphraseDialog,
|
||||
autoStart: false,
|
||||
});
|
||||
const connectRetryRef = useRef(connectRetry);
|
||||
connectRetryRef.current = connectRetry;
|
||||
|
||||
useEffect(() => {
|
||||
if (currentHost) {
|
||||
initializeSSHConnection();
|
||||
connectRetryRef.current.reset();
|
||||
connectRetryRef.current.retryNow();
|
||||
}
|
||||
}, [currentHost]);
|
||||
|
||||
@@ -427,8 +511,8 @@ function FileManagerContent({
|
||||
setHasConnectionError(true);
|
||||
addLog({
|
||||
type: "error",
|
||||
stage: "error",
|
||||
message: t("fileManager.sshRequiredForFileManager"),
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
setIsLoading(false);
|
||||
return;
|
||||
@@ -496,6 +580,7 @@ function FileManagerContent({
|
||||
}
|
||||
|
||||
setSshSessionId(sessionId);
|
||||
connectRetryRef.current.markConnected();
|
||||
|
||||
try {
|
||||
const response = await listSSHFiles(sessionId, currentPath);
|
||||
@@ -563,7 +648,7 @@ function FileManagerContent({
|
||||
handleCloseWithError(
|
||||
t("fileManager.failedToConnect") +
|
||||
": " +
|
||||
(error instanceof Error ? error.message : String(error)),
|
||||
getErrorMessage(error, String(error)),
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -577,6 +662,7 @@ function FileManagerContent({
|
||||
console.error("Cannot load directory: no SSH session ID");
|
||||
return false;
|
||||
}
|
||||
const requestId = ++directoryRequestRef.current;
|
||||
|
||||
let resolvedPath = path;
|
||||
if (path.includes("$") || path.startsWith("~")) {
|
||||
@@ -588,12 +674,19 @@ function FileManagerContent({
|
||||
}
|
||||
|
||||
currentLoadingPathRef.current = resolvedPath;
|
||||
setIsLoading(true);
|
||||
const cached = peekCachedFileList(sshSessionId, resolvedPath);
|
||||
if (cached) {
|
||||
setFiles(cached.files);
|
||||
clearSelection();
|
||||
}
|
||||
setIsLoading(!cached);
|
||||
|
||||
try {
|
||||
const response = await listSSHFiles(sshSessionId, resolvedPath);
|
||||
const response = await listSSHFiles(sshSessionId, resolvedPath, {
|
||||
force: cached !== null,
|
||||
});
|
||||
|
||||
if (currentLoadingPathRef.current !== resolvedPath) {
|
||||
if (directoryRequestRef.current !== requestId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -606,7 +699,7 @@ function FileManagerContent({
|
||||
clearSelection();
|
||||
return true;
|
||||
} catch (error: unknown) {
|
||||
if (currentLoadingPathRef.current === resolvedPath) {
|
||||
if (directoryRequestRef.current === requestId) {
|
||||
// ApiError has .status directly; raw axios errors have .response.status
|
||||
const apiError = error as {
|
||||
status?: number;
|
||||
@@ -717,7 +810,7 @@ function FileManagerContent({
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
if (currentLoadingPathRef.current === resolvedPath) {
|
||||
if (directoryRequestRef.current === requestId) {
|
||||
setIsLoading(false);
|
||||
currentLoadingPathRef.current = "";
|
||||
}
|
||||
@@ -744,7 +837,12 @@ function FileManagerContent({
|
||||
|
||||
const navigateTo = useCallback(
|
||||
(path: string) => {
|
||||
if (sshSessionId) setIsLoading(true);
|
||||
directoryRequestRef.current += 1;
|
||||
const cached = sshSessionId
|
||||
? peekCachedFileList(sshSessionId, path)
|
||||
: null;
|
||||
if (cached) setFiles(cached.files);
|
||||
setIsLoading(!!sshSessionId && !cached);
|
||||
setCurrentPath(path);
|
||||
setNavHistory((prev) => {
|
||||
const next = [...prev.slice(0, navIndex + 1), path];
|
||||
@@ -757,19 +855,31 @@ function FileManagerContent({
|
||||
|
||||
const goBack = useCallback(() => {
|
||||
if (navIndex > 0) {
|
||||
if (sshSessionId) setIsLoading(true);
|
||||
directoryRequestRef.current += 1;
|
||||
const newIndex = navIndex - 1;
|
||||
const path = navHistory[newIndex];
|
||||
const cached = sshSessionId
|
||||
? peekCachedFileList(sshSessionId, path)
|
||||
: null;
|
||||
if (cached) setFiles(cached.files);
|
||||
setIsLoading(!!sshSessionId && !cached);
|
||||
setNavIndex(newIndex);
|
||||
setCurrentPath(navHistory[newIndex]);
|
||||
setCurrentPath(path);
|
||||
}
|
||||
}, [navIndex, navHistory, sshSessionId]);
|
||||
|
||||
const goForward = useCallback(() => {
|
||||
if (navIndex < navHistory.length - 1) {
|
||||
if (sshSessionId) setIsLoading(true);
|
||||
directoryRequestRef.current += 1;
|
||||
const newIndex = navIndex + 1;
|
||||
const path = navHistory[newIndex];
|
||||
const cached = sshSessionId
|
||||
? peekCachedFileList(sshSessionId, path)
|
||||
: null;
|
||||
if (cached) setFiles(cached.files);
|
||||
setIsLoading(!!sshSessionId && !cached);
|
||||
setNavIndex(newIndex);
|
||||
setCurrentPath(navHistory[newIndex]);
|
||||
setCurrentPath(path);
|
||||
}
|
||||
}, [navIndex, navHistory, sshSessionId]);
|
||||
|
||||
@@ -807,11 +917,12 @@ function FileManagerContent({
|
||||
}
|
||||
|
||||
setLastRefreshTime(now);
|
||||
if (sshSessionId) invalidateCachedFileList(sshSessionId, currentPath);
|
||||
// Force reset loading state to ensure refresh is not blocked
|
||||
setIsLoading(false);
|
||||
currentLoadingPathRef.current = "";
|
||||
loadDirectory(currentPath);
|
||||
}, [currentPath, lastRefreshTime, loadDirectory]);
|
||||
}, [currentPath, lastRefreshTime, loadDirectory, sshSessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (event: Event) => {
|
||||
@@ -850,7 +961,7 @@ function FileManagerContent({
|
||||
activeElement &&
|
||||
(activeElement.tagName === "INPUT" ||
|
||||
activeElement.tagName === "TEXTAREA" ||
|
||||
activeElement.contentEditable === "true")
|
||||
(activeElement as HTMLElement).contentEditable === "true")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -1110,11 +1221,24 @@ function FileManagerContent({
|
||||
});
|
||||
}
|
||||
|
||||
const fullMessage = `${confirmMessage}\n\n${t("fileManager.permanentDeleteWarning")}`;
|
||||
const fullMessage = `${confirmMessage}\n\n${t("fileManager.moveToTrashWarning")}`;
|
||||
|
||||
confirmWithToast(
|
||||
fullMessage,
|
||||
async () => {
|
||||
const operationPath = currentPath;
|
||||
const operationSession = sshSessionId;
|
||||
const deletedPaths = new Set(files.map((file) => file.path));
|
||||
commitFilesForPath(
|
||||
operationSession,
|
||||
operationPath,
|
||||
removePaths(
|
||||
filesForPath(operationSession, operationPath),
|
||||
deletedPaths,
|
||||
),
|
||||
);
|
||||
clearSelection();
|
||||
let completed = 0;
|
||||
try {
|
||||
await ensureSSHConnection();
|
||||
|
||||
@@ -1126,31 +1250,29 @@ function FileManagerContent({
|
||||
currentHost?.id,
|
||||
currentHost?.userId?.toString(),
|
||||
);
|
||||
completed += 1;
|
||||
}
|
||||
|
||||
const deletedFiles = files.map((file) => ({
|
||||
path: file.path,
|
||||
name: file.name,
|
||||
}));
|
||||
|
||||
const undoAction: UndoAction = {
|
||||
type: "delete",
|
||||
description: t("fileManager.deletedItems", { count: files.length }),
|
||||
data: {
|
||||
operation: "cut",
|
||||
deletedFiles,
|
||||
targetDirectory: currentPath,
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
setUndoHistory((prev) => [...prev.slice(-9), undoAction]);
|
||||
|
||||
toast.success(
|
||||
t("fileManager.itemsDeletedSuccessfully", { count: files.length }),
|
||||
t("fileManager.itemsMovedToTrash", { count: files.length }),
|
||||
);
|
||||
handleRefreshDirectory();
|
||||
clearSelection();
|
||||
if (
|
||||
sshSessionIdRef.current === operationSession &&
|
||||
currentPathRef.current === operationPath
|
||||
) {
|
||||
handleRefreshDirectory();
|
||||
} else {
|
||||
invalidateCachedFileList(operationSession, operationPath);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
commitFilesForPath(
|
||||
operationSession,
|
||||
operationPath,
|
||||
restoreItems(
|
||||
filesForPath(operationSession, operationPath),
|
||||
files.slice(completed),
|
||||
),
|
||||
);
|
||||
const axiosError = error as {
|
||||
response?: {
|
||||
data?: { needsSudo?: boolean; error?: string };
|
||||
@@ -1159,10 +1281,36 @@ function FileManagerContent({
|
||||
message?: string;
|
||||
};
|
||||
if (axiosError.response?.data?.needsSudo) {
|
||||
setPendingSudoOperation({ type: "delete", files });
|
||||
setPendingSudoOperation({
|
||||
type: "delete",
|
||||
files: files.slice(completed),
|
||||
});
|
||||
setSudoDialogOpen(true);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
(axiosError.response?.data as { trashUnavailable?: boolean })
|
||||
?.trashUnavailable &&
|
||||
window.confirm(t("fileManager.trashUnavailableConfirm"))
|
||||
) {
|
||||
for (const file of files.slice(completed)) {
|
||||
await deleteSSHItem(
|
||||
sshSessionId,
|
||||
file.path,
|
||||
file.type === "directory",
|
||||
currentHost?.id,
|
||||
currentHost?.userId?.toString(),
|
||||
true,
|
||||
);
|
||||
}
|
||||
toast.success(
|
||||
t("fileManager.itemsDeletedSuccessfully", {
|
||||
count: files.length - completed,
|
||||
}),
|
||||
);
|
||||
handleRefreshDirectory();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
axiosError.response?.status === 403 ||
|
||||
axiosError.response?.data?.error
|
||||
@@ -1339,9 +1487,10 @@ function FileManagerContent({
|
||||
});
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const httpError = asHttpError(error);
|
||||
toast.error(
|
||||
error?.response?.data?.error ||
|
||||
error?.message ||
|
||||
httpError.response?.data?.error ||
|
||||
httpError.message ||
|
||||
t("fileManager.failedToResolveSymlink"),
|
||||
);
|
||||
}
|
||||
@@ -1403,7 +1552,13 @@ function FileManagerContent({
|
||||
|
||||
async function handleFileOpen(file: FileItem) {
|
||||
if (file.type === "directory") {
|
||||
if (sshSessionId) setIsLoading(true);
|
||||
markAdaptiveResourceUsed("network", "directory-list");
|
||||
directoryRequestRef.current += 1;
|
||||
const cached = sshSessionId
|
||||
? peekCachedFileList(sshSessionId, file.path)
|
||||
: null;
|
||||
if (cached) setFiles(cached.files);
|
||||
setIsLoading(!!sshSessionId && !cached);
|
||||
setCurrentPath(file.path);
|
||||
return;
|
||||
}
|
||||
@@ -1420,10 +1575,42 @@ function FileManagerContent({
|
||||
|
||||
if (!(await confirmLargeFileOpen(file))) return;
|
||||
|
||||
markFilePreviewUsed(file.name);
|
||||
markAdaptiveResourceUsed(
|
||||
"network",
|
||||
file.size && file.size > 128 * 1024
|
||||
? "file-content:medium"
|
||||
: "file-content:small",
|
||||
);
|
||||
await recordRecentFile(file);
|
||||
openFileWindow(file, sshSessionId);
|
||||
}
|
||||
|
||||
const prefetchFileIntent = useCallback(
|
||||
(file: FileItem) => {
|
||||
if (!sshSessionId) return;
|
||||
if (file.type === "directory") {
|
||||
runAdaptiveBackgroundTask("network", "directory-list", () =>
|
||||
listSSHFiles(sshSessionId, file.path),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const budget = getAdaptiveResourceBudget("network");
|
||||
if (shouldPrefetchFileContent(file, budget.maxPrefetchBytes)) {
|
||||
preloadFilePreview(file.name);
|
||||
runAdaptiveBackgroundTask(
|
||||
"network",
|
||||
file.size && file.size > 128 * 1024
|
||||
? "file-content:medium"
|
||||
: "file-content:small",
|
||||
() => readSSHFile(sshSessionId, file.path),
|
||||
{ estimatedBytes: file.size },
|
||||
);
|
||||
}
|
||||
},
|
||||
[sshSessionId],
|
||||
);
|
||||
|
||||
function handleContextMenu(event: React.MouseEvent, file?: FileItem) {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -1637,7 +1824,7 @@ function FileManagerContent({
|
||||
? t("fileManager.copy")
|
||||
: t("fileManager.move"),
|
||||
name: file.name,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
error: getErrorMessage(error, String(error)),
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -1731,8 +1918,7 @@ function FileManagerContent({
|
||||
setClipboard(null);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
const errorMessage = getErrorMessage(error, String(error));
|
||||
toast.error(`${t("fileManager.pasteFailed")}: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
@@ -1756,11 +1942,9 @@ function FileManagerContent({
|
||||
toast.success(
|
||||
t("fileManager.archiveExtractedSuccessfully", { name: file.name }),
|
||||
);
|
||||
|
||||
handleRefreshDirectory();
|
||||
} catch (error: unknown) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
const errorMessage = getErrorMessage(error, String(error));
|
||||
toast.error(`${t("fileManager.extractFailed")}: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
@@ -1799,12 +1983,10 @@ function FileManagerContent({
|
||||
name: archiveName,
|
||||
}),
|
||||
);
|
||||
|
||||
handleRefreshDirectory();
|
||||
clearSelection();
|
||||
} catch (error: unknown) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
const errorMessage = getErrorMessage(error, String(error));
|
||||
toast.error(`${t("fileManager.compressFailed")}: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
@@ -1821,7 +2003,7 @@ function FileManagerContent({
|
||||
destPath: string,
|
||||
destPathLabel: string,
|
||||
methodPreference: TransferMethodPreference,
|
||||
parallelSegmentCount: number,
|
||||
parallelSegmentCount?: number,
|
||||
) {
|
||||
if (!sshSessionId || !currentHost?.id || transferFiles.length === 0) return;
|
||||
|
||||
@@ -1915,8 +2097,7 @@ function FileManagerContent({
|
||||
toast.error(
|
||||
t("fileManager.deleteCopiedFileFailed", {
|
||||
name: copiedFile.targetName,
|
||||
error:
|
||||
error instanceof Error ? error.message : String(error),
|
||||
error: getErrorMessage(error, String(error)),
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -1958,8 +2139,7 @@ function FileManagerContent({
|
||||
toast.error(
|
||||
t("fileManager.moveBackFileFailed", {
|
||||
name: movedFile.targetName,
|
||||
error:
|
||||
error instanceof Error ? error.message : String(error),
|
||||
error: getErrorMessage(error, String(error)),
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -1992,8 +2172,7 @@ function FileManagerContent({
|
||||
|
||||
handleRefreshDirectory();
|
||||
} catch (error: unknown) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
const errorMessage = getErrorMessage(error, String(error));
|
||||
toast.error(`${t("fileManager.undoOperationFailed")}: ${errorMessage}`);
|
||||
console.error("Undo failed:", error);
|
||||
}
|
||||
@@ -2062,10 +2241,25 @@ function FileManagerContent({
|
||||
async function handleConfirmCreate(name: string) {
|
||||
if (!createIntent || !sshSessionId) return;
|
||||
|
||||
const operationPath = currentPath;
|
||||
const operationSession = sshSessionId;
|
||||
const optimisticPath = childPath(operationPath, name);
|
||||
const previousFiles = filesForPath(operationSession, operationPath);
|
||||
const optimisticFiles = addOptimisticItem(
|
||||
previousFiles,
|
||||
operationPath,
|
||||
name,
|
||||
createIntent.type,
|
||||
);
|
||||
const didAddOptimistically = optimisticFiles !== previousFiles;
|
||||
commitFilesForPath(operationSession, operationPath, optimisticFiles);
|
||||
const createdType = createIntent.type;
|
||||
setCreateIntent(null);
|
||||
|
||||
try {
|
||||
await ensureSSHConnection();
|
||||
|
||||
if (createIntent.type === "file") {
|
||||
if (createdType === "file") {
|
||||
await createSSHFile(
|
||||
sshSessionId,
|
||||
currentPath,
|
||||
@@ -2086,9 +2280,25 @@ function FileManagerContent({
|
||||
toast.success(t("fileManager.folderCreatedSuccessfully", { name }));
|
||||
}
|
||||
|
||||
setCreateIntent(null);
|
||||
handleRefreshDirectory();
|
||||
if (
|
||||
sshSessionIdRef.current === operationSession &&
|
||||
currentPathRef.current === operationPath
|
||||
) {
|
||||
handleRefreshDirectory();
|
||||
} else {
|
||||
invalidateCachedFileList(operationSession, operationPath);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (didAddOptimistically) {
|
||||
commitFilesForPath(
|
||||
operationSession,
|
||||
operationPath,
|
||||
removePaths(
|
||||
filesForPath(operationSession, operationPath),
|
||||
new Set([optimisticPath]),
|
||||
),
|
||||
);
|
||||
}
|
||||
const axiosError = error as {
|
||||
response?: { status?: number; data?: { error?: string } };
|
||||
};
|
||||
@@ -2113,6 +2323,25 @@ function FileManagerContent({
|
||||
async function handleRenameConfirm(file: FileItem, newName: string) {
|
||||
if (!sshSessionId) return;
|
||||
|
||||
const operationPath = currentPath;
|
||||
const operationSession = sshSessionId;
|
||||
const renamedPath = childPath(
|
||||
file.path.slice(0, Math.max(1, file.path.lastIndexOf("/"))),
|
||||
newName,
|
||||
);
|
||||
const operationFiles = filesForPath(operationSession, operationPath);
|
||||
const didRenameOptimistically = !operationFiles.some(
|
||||
(entry) => entry.path === renamedPath && entry.path !== file.path,
|
||||
);
|
||||
if (didRenameOptimistically) {
|
||||
commitFilesForPath(
|
||||
operationSession,
|
||||
operationPath,
|
||||
renameOptimisticItem(operationFiles, file.path, newName),
|
||||
);
|
||||
}
|
||||
setEditingFile(null);
|
||||
|
||||
try {
|
||||
await ensureSSHConnection();
|
||||
|
||||
@@ -2127,9 +2356,26 @@ function FileManagerContent({
|
||||
toast.success(
|
||||
t("fileManager.itemRenamedSuccessfully", { name: newName }),
|
||||
);
|
||||
setEditingFile(null);
|
||||
handleRefreshDirectory();
|
||||
if (
|
||||
sshSessionIdRef.current === operationSession &&
|
||||
currentPathRef.current === operationPath
|
||||
) {
|
||||
handleRefreshDirectory();
|
||||
} else {
|
||||
invalidateCachedFileList(operationSession, operationPath);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (didRenameOptimistically) {
|
||||
commitFilesForPath(
|
||||
operationSession,
|
||||
operationPath,
|
||||
renameOptimisticItem(
|
||||
filesForPath(operationSession, operationPath),
|
||||
renamedPath,
|
||||
file.name,
|
||||
),
|
||||
);
|
||||
}
|
||||
const axiosError = error as {
|
||||
response?: { status?: number; data?: { error?: string } };
|
||||
};
|
||||
@@ -2167,6 +2413,7 @@ function FileManagerContent({
|
||||
setTotpPrompt("");
|
||||
setSshSessionId(totpSessionId);
|
||||
setTotpSessionId(null);
|
||||
connectRetryRef.current.markConnected();
|
||||
|
||||
try {
|
||||
const response = await listSSHFiles(totpSessionId, currentPath);
|
||||
@@ -2211,6 +2458,7 @@ function FileManagerContent({
|
||||
setWarpgateSecurityKey("");
|
||||
setSshSessionId(warpgateSessionId);
|
||||
setWarpgateSessionId(null);
|
||||
connectRetryRef.current.markConnected();
|
||||
|
||||
try {
|
||||
const response = await listSSHFiles(warpgateSessionId, currentPath);
|
||||
@@ -2308,6 +2556,7 @@ function FileManagerContent({
|
||||
}
|
||||
|
||||
setSshSessionId(sessionId);
|
||||
connectRetryRef.current.markConnected();
|
||||
|
||||
try {
|
||||
const response = await listSSHFiles(sessionId, currentPath);
|
||||
@@ -2329,7 +2578,7 @@ function FileManagerContent({
|
||||
toast.error(
|
||||
t("fileManager.failedToConnect") +
|
||||
": " +
|
||||
(error instanceof Error ? error.message : String(error)),
|
||||
getErrorMessage(error, String(error)),
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -2392,6 +2641,7 @@ function FileManagerContent({
|
||||
}
|
||||
|
||||
setSshSessionId(sessionId);
|
||||
connectRetryRef.current.markConnected();
|
||||
|
||||
try {
|
||||
const response = await listSSHFiles(sessionId, currentPath);
|
||||
@@ -2477,7 +2727,7 @@ function FileManagerContent({
|
||||
toast.error(
|
||||
t("fileManager.moveFileFailed", { name: file.name }) +
|
||||
": " +
|
||||
(error instanceof Error ? error.message : String(error)),
|
||||
getErrorMessage(error, String(error)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2523,7 +2773,7 @@ function FileManagerContent({
|
||||
toast.error(
|
||||
t("fileManager.moveOperationFailed") +
|
||||
": " +
|
||||
(error instanceof Error ? error.message : String(error)),
|
||||
getErrorMessage(error, String(error)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2556,15 +2806,17 @@ function FileManagerContent({
|
||||
);
|
||||
|
||||
openWindow({
|
||||
id: windowId,
|
||||
type: "diff",
|
||||
title: t("fileManager.fileComparison", {
|
||||
file1: file1.name,
|
||||
file2: file2.name,
|
||||
}),
|
||||
x: offsetX,
|
||||
y: offsetY,
|
||||
width: 800,
|
||||
height: 600,
|
||||
isMaximized: false,
|
||||
isMinimized: false,
|
||||
component: createWindowComponent,
|
||||
zIndex: Date.now(),
|
||||
});
|
||||
|
||||
toast.success(
|
||||
@@ -2598,7 +2850,7 @@ function FileManagerContent({
|
||||
toast.error(
|
||||
t("fileManager.dragFailed") +
|
||||
": " +
|
||||
(error instanceof Error ? error.message : String(error)),
|
||||
getErrorMessage(error, String(error)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2690,9 +2942,7 @@ function FileManagerContent({
|
||||
|
||||
try {
|
||||
const pinnedData = await getPinnedFiles(currentHost.id);
|
||||
const pinnedPaths = new Set(
|
||||
pinnedData.map((item: Record<string, unknown>) => item.path),
|
||||
);
|
||||
const pinnedPaths = new Set(pinnedData.map((item) => item.path));
|
||||
setPinnedFiles(pinnedPaths);
|
||||
} catch (error) {
|
||||
console.error("Failed to load pinned files:", error);
|
||||
@@ -2871,17 +3121,14 @@ function FileManagerContent({
|
||||
if ((isLoading || isReconnecting) && !sshSessionId) {
|
||||
return (
|
||||
<div className="h-full w-full flex flex-col bg-background relative">
|
||||
<div className="flex-1 overflow-hidden min-h-0 relative">
|
||||
<SimpleLoader
|
||||
visible={!isConnectionLogExpanded}
|
||||
message={t("fileManager.connecting")}
|
||||
/>
|
||||
</div>
|
||||
<ConnectionLog
|
||||
isConnecting={isLoading || isReconnecting}
|
||||
isConnected={false}
|
||||
hasConnectionError={hasConnectionError}
|
||||
position={hasConnectionError ? "top" : "bottom"}
|
||||
<ConnectionScreen
|
||||
status={isReconnecting ? "connecting" : connectRetry.status}
|
||||
message={t("fileManager.connecting")}
|
||||
attempt={connectRetry.attempt}
|
||||
maxAttempts={connectRetry.maxAttempts}
|
||||
nextRetryInMs={connectRetry.nextRetryInMs}
|
||||
onManualRetry={connectRetry.retryNow}
|
||||
logPosition={hasConnectionError ? "top" : "bottom"}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -2889,12 +3136,7 @@ function FileManagerContent({
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-background relative overflow-hidden isolate">
|
||||
<div
|
||||
className="h-full w-full flex flex-col min-h-0"
|
||||
style={{
|
||||
visibility: isConnectionLogExpanded ? "hidden" : "visible",
|
||||
}}
|
||||
>
|
||||
<div className="h-full w-full flex flex-col min-h-0">
|
||||
<FileManagerToolbar
|
||||
t={t}
|
||||
currentPath={currentPath}
|
||||
@@ -2951,11 +3193,11 @@ function FileManagerContent({
|
||||
currentHost={currentHost}
|
||||
currentPath={currentPath}
|
||||
onPathChange={navigateTo}
|
||||
onLoadDirectory={loadDirectory}
|
||||
onFileOpen={handleSidebarFileOpen}
|
||||
onItemContextMenu={handleSidebarItemContextMenu}
|
||||
sshSessionId={sshSessionId}
|
||||
refreshTrigger={sidebarRefreshTrigger}
|
||||
onOpenTrash={() => setTrashOpen(true)}
|
||||
diskInfo={diskInfo ?? undefined}
|
||||
/>
|
||||
</div>
|
||||
@@ -2967,6 +3209,7 @@ function FileManagerContent({
|
||||
files={filteredFiles}
|
||||
selectedFiles={selectedFiles}
|
||||
onFileOpen={handleFileOpen}
|
||||
onFileIntent={prefetchFileIntent}
|
||||
onSelectionChange={setSelection}
|
||||
onRefresh={handleRefreshDirectory}
|
||||
onUpload={handleFilesDropped}
|
||||
@@ -3059,6 +3302,17 @@ function FileManagerContent({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FileManagerTrashDialog
|
||||
open={trashOpen}
|
||||
onOpenChange={setTrashOpen}
|
||||
sessionId={sshSessionId}
|
||||
onChanged={() => {
|
||||
if (sshSessionId)
|
||||
invalidateCachedFileList(sshSessionId, currentPath);
|
||||
void handleRefreshDirectory();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{currentHost && (
|
||||
@@ -3115,9 +3369,9 @@ function FileManagerContent({
|
||||
setPendingSudoOperation={setPendingSudoOperation}
|
||||
handleSudoPasswordSubmit={handleSudoPasswordSubmit}
|
||||
/>
|
||||
<ConnectionLog
|
||||
<ConnectionLogPanel
|
||||
isConnecting={isReconnecting || isLoading}
|
||||
isConnected={!!sshSessionId}
|
||||
isConnected={!!sshSessionId && !isReconnecting}
|
||||
hasConnectionError={hasConnectionError}
|
||||
position={hasConnectionError ? "top" : "bottom"}
|
||||
/>
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FileManager } from "@/features/file-manager/FileManager.tsx";
|
||||
import { FullScreenAppWrapper } from "@/features/FullScreenAppWrapper.tsx";
|
||||
import { ConnectionScreen } from "@/components/connection/ConnectionScreen.tsx";
|
||||
|
||||
interface FileManagerAppProps {
|
||||
hostId?: string;
|
||||
@@ -15,33 +16,31 @@ const FileManagerApp: React.FC<FileManagerAppProps> = ({
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<FullScreenAppWrapper hostId={hostId}>
|
||||
{(hostConfig, loading) => {
|
||||
if (loading) {
|
||||
{(hostConfig, phase) => {
|
||||
if (phase === "loading") {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto mb-2"></div>
|
||||
<p className="text-muted-foreground">
|
||||
{t("hosts.loadingHost")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="relative h-full w-full">
|
||||
<ConnectionScreen
|
||||
status="connecting"
|
||||
message={t("hosts.loadingHost")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hostConfig) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<p className="text-red-500 mb-4">{t("hosts.hostNotFound")}</p>
|
||||
</div>
|
||||
<div className="relative h-full w-full">
|
||||
<ConnectionScreen
|
||||
status="disconnected"
|
||||
message={t("hosts.hostNotFound")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FileManager
|
||||
embedded={true}
|
||||
initialHost={hostConfig}
|
||||
initialPath={initialPath}
|
||||
onClose={() => {}}
|
||||
|
||||
@@ -32,10 +32,7 @@ type FileManagerDialogsProps = {
|
||||
handleAuthDialogCancel: () => void;
|
||||
permissionsDialogFile: FileItem | null;
|
||||
setPermissionsDialogFile: (file: FileItem | null) => void;
|
||||
handleSavePermissions: (
|
||||
file: FileItem,
|
||||
permissions: string,
|
||||
) => void | Promise<void>;
|
||||
handleSavePermissions: (file: FileItem, permissions: string) => Promise<void>;
|
||||
sudoDialogOpen: boolean;
|
||||
setSudoDialogOpen: (open: boolean) => void;
|
||||
setPendingSudoOperation: (operation: PendingSudoOperation | null) => void;
|
||||
|
||||
@@ -46,6 +46,7 @@ interface FileManagerGridProps {
|
||||
files: FileItem[];
|
||||
selectedFiles: FileItem[];
|
||||
onFileOpen: (file: FileItem) => void;
|
||||
onFileIntent?: (file: FileItem) => void;
|
||||
onSelectionChange: (files: FileItem[]) => void;
|
||||
onRefresh: () => void;
|
||||
onUpload?: (files: FileList) => void;
|
||||
@@ -162,6 +163,7 @@ export function FileManagerGrid({
|
||||
files,
|
||||
selectedFiles,
|
||||
onFileOpen,
|
||||
onFileIntent,
|
||||
onSelectionChange,
|
||||
onRefresh,
|
||||
onUpload,
|
||||
@@ -715,6 +717,8 @@ export function FileManagerGrid({
|
||||
const handleFileClick = (file: FileItem, event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
|
||||
onFileIntent?.(file);
|
||||
|
||||
if (gridRef.current && !createIntent) {
|
||||
gridRef.current.focus();
|
||||
}
|
||||
@@ -782,7 +786,7 @@ export function FileManagerGrid({
|
||||
activeElement &&
|
||||
(activeElement.tagName === "INPUT" ||
|
||||
activeElement.tagName === "TEXTAREA" ||
|
||||
activeElement.contentEditable === "true")
|
||||
(activeElement as HTMLElement).contentEditable === "true")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Bookmark,
|
||||
File,
|
||||
Folder,
|
||||
Trash2,
|
||||
ChevronDown,
|
||||
Check,
|
||||
} from "lucide-react";
|
||||
@@ -86,6 +87,7 @@ interface FileManagerSidebarProps {
|
||||
onItemContextMenu?: (event: React.MouseEvent, item: SidebarItem) => void;
|
||||
sshSessionId?: string;
|
||||
refreshTrigger?: number;
|
||||
onOpenTrash?: () => void;
|
||||
diskInfo?: {
|
||||
usedHuman: string;
|
||||
totalHuman: string;
|
||||
@@ -184,6 +186,7 @@ export function FileManagerSidebar({
|
||||
onItemContextMenu,
|
||||
sshSessionId,
|
||||
refreshTrigger,
|
||||
onOpenTrash,
|
||||
diskInfo,
|
||||
}: FileManagerSidebarProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -227,7 +230,7 @@ export function FileManagerSidebar({
|
||||
|
||||
try {
|
||||
const recentData = await getRecentFiles(currentHost.id);
|
||||
const recentItems = (recentData as RecentFileData[])
|
||||
const recentItems = (recentData as unknown as RecentFileData[])
|
||||
.slice(0, 5)
|
||||
.map((item: RecentFileData) => ({
|
||||
id: `recent-${item.id}`,
|
||||
@@ -239,7 +242,7 @@ export function FileManagerSidebar({
|
||||
setRecentItems(recentItems);
|
||||
|
||||
const pinnedData = await getPinnedFiles(currentHost.id);
|
||||
const pinnedItems = (pinnedData as PinnedFileData[]).map(
|
||||
const pinnedItems = (pinnedData as unknown as PinnedFileData[]).map(
|
||||
(item: PinnedFileData) => ({
|
||||
id: `pinned-${item.id}`,
|
||||
name: item.name,
|
||||
@@ -250,7 +253,7 @@ export function FileManagerSidebar({
|
||||
setPinnedItems(pinnedItems);
|
||||
|
||||
const shortcutData = await getFolderShortcuts(currentHost.id);
|
||||
const shortcutItems = (shortcutData as ShortcutData[]).map(
|
||||
const shortcutItems = (shortcutData as unknown as ShortcutData[]).map(
|
||||
(item: ShortcutData) => ({
|
||||
id: `shortcut-${item.id}`,
|
||||
name: item.name,
|
||||
@@ -717,6 +720,15 @@ export function FileManagerSidebar({
|
||||
<>
|
||||
<div className="h-full flex flex-col bg-card overflow-hidden">
|
||||
<div className="flex-1 overflow-y-auto thin-scrollbar">
|
||||
{onOpenTrash && (
|
||||
<button
|
||||
className="w-full flex items-center gap-2.5 px-3 py-2 text-[11px] font-bold uppercase tracking-wider text-muted-foreground hover:text-foreground hover:bg-muted border-l-2 border-transparent"
|
||||
onClick={onOpenTrash}
|
||||
>
|
||||
<Trash2 className="size-3.5 shrink-0" />
|
||||
<span>{t("fileManager.trash")}</span>
|
||||
</button>
|
||||
)}
|
||||
{/* ── Recent files ──────────────────────────────────────── */}
|
||||
{renderSection(t("fileManager.recent"), recentItems, (item) =>
|
||||
renderQuickAccessItem(
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { RotateCcw, Trash2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/dialog";
|
||||
import {
|
||||
emptySSHTrash,
|
||||
getSSHTrash,
|
||||
permanentlyDeleteSSHTrashItem,
|
||||
restoreSSHTrashItem,
|
||||
updateSSHTrashRetention,
|
||||
type TrashItem,
|
||||
} from "@/api/ssh-file-operations-api";
|
||||
|
||||
export function FileManagerTrashDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
sessionId,
|
||||
onChanged,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
sessionId: string | null;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [items, setItems] = useState<TrashItem[]>([]);
|
||||
const [retentionDays, setRetentionDays] = useState(7);
|
||||
const [canManageRetention, setCanManageRetention] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!sessionId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await getSSHTrash(sessionId);
|
||||
setItems(result.items);
|
||||
setRetentionDays(result.retentionDays);
|
||||
setCanManageRetention(result.canManageRetention);
|
||||
} catch {
|
||||
toast.error(t("fileManager.trashLoadFailed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [sessionId, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) void load();
|
||||
}, [load, open]);
|
||||
|
||||
async function restore(item: TrashItem) {
|
||||
if (!sessionId) return;
|
||||
try {
|
||||
await restoreSSHTrashItem(sessionId, item.id);
|
||||
setItems((current) => current.filter((entry) => entry.id !== item.id));
|
||||
onChanged();
|
||||
toast.success(t("fileManager.trashRestored", { name: item.name }));
|
||||
} catch {
|
||||
toast.error(t("fileManager.trashRestoreFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(item: TrashItem) {
|
||||
if (!sessionId) return;
|
||||
if (
|
||||
!window.confirm(t("fileManager.trashDeleteConfirm", { name: item.name }))
|
||||
)
|
||||
return;
|
||||
try {
|
||||
await permanentlyDeleteSSHTrashItem(sessionId, item.id);
|
||||
setItems((current) => current.filter((entry) => entry.id !== item.id));
|
||||
toast.success(
|
||||
t("fileManager.trashPermanentlyDeleted", { name: item.name }),
|
||||
);
|
||||
} catch {
|
||||
toast.error(t("fileManager.trashDeleteFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
async function empty() {
|
||||
if (!sessionId || items.length === 0) return;
|
||||
if (!window.confirm(t("fileManager.trashEmptyConfirm"))) return;
|
||||
try {
|
||||
await emptySSHTrash(sessionId);
|
||||
setItems([]);
|
||||
toast.success(t("fileManager.trashEmptied"));
|
||||
} catch {
|
||||
toast.error(t("fileManager.trashDeleteFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="flex max-h-[85dvh] w-[calc(100vw-2rem)] flex-col sm:max-w-3xl">
|
||||
<DialogHeader className="shrink-0 pr-8">
|
||||
<DialogTitle>{t("fileManager.trash")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("fileManager.trashDescription", { days: retentionDays })}{" "}
|
||||
<a
|
||||
href="https://docs.termix.site/features/files-and-hosts/trash"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-accent-brand hover:underline"
|
||||
>
|
||||
{t("hosts.docsLink")}
|
||||
</a>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{canManageRetention && (
|
||||
<div className="flex shrink-0 items-center gap-2 border border-border p-2 text-xs">
|
||||
<span className="flex-1">{t("fileManager.trashRetention")}</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={3650}
|
||||
value={retentionDays}
|
||||
onChange={(event) => setRetentionDays(Number(event.target.value))}
|
||||
className="h-8 w-20 border border-border bg-background px-2"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={async () => {
|
||||
if (!sessionId || retentionDays < 1 || retentionDays > 3650)
|
||||
return;
|
||||
try {
|
||||
await updateSSHTrashRetention(sessionId, retentionDays);
|
||||
toast.success(t("fileManager.trashRetentionSaved"));
|
||||
} catch {
|
||||
toast.error(t("fileManager.trashRetentionFailed"));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto border border-border">
|
||||
{loading ? (
|
||||
<div className="p-6 text-center text-sm text-muted-foreground">
|
||||
{t("common.loading")}
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="p-6 text-center text-sm text-muted-foreground">
|
||||
{t("fileManager.trashEmpty")}
|
||||
</div>
|
||||
) : (
|
||||
items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex flex-col gap-2 border-b border-border p-3 last:border-b-0 sm:flex-row sm:items-center sm:gap-3"
|
||||
>
|
||||
<Trash2 className="size-4 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div
|
||||
className="truncate text-sm font-medium"
|
||||
title={item.name}
|
||||
>
|
||||
{item.name}
|
||||
</div>
|
||||
<div
|
||||
className="truncate text-xs text-muted-foreground"
|
||||
title={item.originalPath}
|
||||
>
|
||||
{item.originalPath}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{new Date(item.deletedAt).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => void restore(item)}
|
||||
>
|
||||
<RotateCcw className="mr-1 size-3.5" />
|
||||
{t("fileManager.trashRestore")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => void remove(item)}
|
||||
>
|
||||
{t("fileManager.trashDeletePermanently")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 justify-end">
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={items.length === 0}
|
||||
onClick={() => void empty()}
|
||||
>
|
||||
{t("fileManager.trashEmptyAction")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
getPendingTransferIds,
|
||||
isTransferNotified,
|
||||
} from "./transferNotificationStore.ts";
|
||||
import { runAdaptivePolling } from "@/lib/adaptive-polling.ts";
|
||||
|
||||
const POLL_INTERVAL_MS = 2000;
|
||||
|
||||
@@ -24,8 +25,10 @@ export function TransferMonitor() {
|
||||
|
||||
useEffect(() => {
|
||||
const reconcileTransfers = async () => {
|
||||
let hasWork = false;
|
||||
try {
|
||||
const { transfers } = await listActiveTransfers();
|
||||
hasWork = transfers.length > 0;
|
||||
for (const transfer of transfers) {
|
||||
if (isTransferBeingMonitored(transfer.transferId)) continue;
|
||||
beginTransferProgressMonitoring(transfer.transferId, t, {
|
||||
@@ -38,13 +41,15 @@ export function TransferMonitor() {
|
||||
// Non-fatal: file-manager service may be unavailable briefly
|
||||
}
|
||||
|
||||
for (const transferId of getPendingTransferIds()) {
|
||||
const pendingTransferIds = getPendingTransferIds();
|
||||
for (const transferId of pendingTransferIds) {
|
||||
if (
|
||||
isTransferBeingMonitored(transferId) ||
|
||||
isTransferNotified(transferId)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
hasWork = true;
|
||||
|
||||
try {
|
||||
const status = await getTransferStatus(transferId);
|
||||
@@ -69,36 +74,14 @@ export function TransferMonitor() {
|
||||
clearStalePendingTransfer(transferId);
|
||||
}
|
||||
}
|
||||
return hasWork;
|
||||
};
|
||||
|
||||
let intervalId: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const start = () => {
|
||||
if (intervalId !== null) return;
|
||||
intervalId = setInterval(reconcileTransfers, POLL_INTERVAL_MS);
|
||||
};
|
||||
const stop = () => {
|
||||
if (intervalId === null) return;
|
||||
clearInterval(intervalId);
|
||||
intervalId = null;
|
||||
};
|
||||
const onVisibility = () => {
|
||||
if (document.visibilityState === "hidden") {
|
||||
stop();
|
||||
return;
|
||||
}
|
||||
void reconcileTransfers();
|
||||
start();
|
||||
};
|
||||
|
||||
void reconcileTransfers();
|
||||
if (document.visibilityState !== "hidden") start();
|
||||
document.addEventListener("visibilitychange", onVisibility);
|
||||
|
||||
return () => {
|
||||
stop();
|
||||
document.removeEventListener("visibilitychange", onVisibility);
|
||||
};
|
||||
return runAdaptivePolling(reconcileTransfers, {
|
||||
minIntervalMs: POLL_INTERVAL_MS,
|
||||
maxIntervalMs: 30_000,
|
||||
stablePollsPerStep: 1,
|
||||
});
|
||||
}, [t, formatTransferMetrics]);
|
||||
|
||||
return null;
|
||||
|
||||
@@ -85,7 +85,7 @@ export function AudioPreview({
|
||||
<div className="rounded-lg overflow-hidden">
|
||||
<AudioPlayer
|
||||
src={audioUrl}
|
||||
onLoadedMetadata={() => {
|
||||
onLoadedMetaData={() => {
|
||||
onMediaDimensionsChange?.({
|
||||
width: 600,
|
||||
height: 400,
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import React, { forwardRef, useImperativeHandle, useMemo, useRef } from "react";
|
||||
import CodeMirror from "@uiw/react-codemirror";
|
||||
import { oneDark } from "@codemirror/theme-one-dark";
|
||||
import { loadLanguage } from "@uiw/codemirror-extensions-langs";
|
||||
import {
|
||||
loadLanguage,
|
||||
type LanguageName,
|
||||
} from "@uiw/codemirror-extensions-langs";
|
||||
import { EditorView, keymap } from "@codemirror/view";
|
||||
import { searchKeymap, search, openSearchPanel } from "@codemirror/search";
|
||||
import {
|
||||
@@ -31,7 +34,7 @@ function getLanguageExtension(filename: string) {
|
||||
const baseName = filename.toLowerCase();
|
||||
|
||||
if (["dockerfile", "makefile", "rakefile", "gemfile"].includes(baseName)) {
|
||||
return loadLanguage(baseName);
|
||||
return loadLanguage(baseName as LanguageName);
|
||||
}
|
||||
|
||||
const langMap: Record<string, string> = {
|
||||
@@ -69,7 +72,7 @@ function getLanguageExtension(filename: string) {
|
||||
};
|
||||
|
||||
const language = langMap[ext];
|
||||
return language ? loadLanguage(language) : null;
|
||||
return language ? loadLanguage(language as LanguageName) : null;
|
||||
}
|
||||
|
||||
export const CodeEditor = forwardRef<CodeEditorHandle, CodeEditorProps>(
|
||||
@@ -154,7 +157,6 @@ export const CodeEditor = forwardRef<CodeEditorHandle, CodeEditorProps>(
|
||||
closeBrackets: true,
|
||||
autocompletion: true,
|
||||
highlightSelectionMatches: false,
|
||||
scrollPastEnd: false,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -45,32 +45,20 @@ import {
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import { Kbd, KbdKey } from "@/components/kbd.tsx";
|
||||
import type { CodeEditorHandle } from "./CodeEditor.tsx";
|
||||
import {
|
||||
loadAudioPreview,
|
||||
loadCodeEditor,
|
||||
loadImagePreview,
|
||||
loadMarkdownRenderer,
|
||||
loadPdfPreview,
|
||||
resolveFilePreviewKind,
|
||||
} from "../file-preview-preload";
|
||||
|
||||
const CodeEditor = lazy(() =>
|
||||
import("./CodeEditor.tsx").then((module) => ({
|
||||
default: module.CodeEditor,
|
||||
})),
|
||||
);
|
||||
const ImagePreview = lazy(() =>
|
||||
import("./ImagePreview.tsx").then((module) => ({
|
||||
default: module.ImagePreview,
|
||||
})),
|
||||
);
|
||||
const MarkdownRenderer = lazy(() =>
|
||||
import("./MarkdownRenderer.tsx").then((module) => ({
|
||||
default: module.MarkdownRenderer,
|
||||
})),
|
||||
);
|
||||
const PdfPreview = lazy(() =>
|
||||
import("./PdfPreview.tsx").then((module) => ({
|
||||
default: module.PdfPreview,
|
||||
})),
|
||||
);
|
||||
const AudioPreview = lazy(() =>
|
||||
import("./AudioPreview.tsx").then((module) => ({
|
||||
default: module.AudioPreview,
|
||||
})),
|
||||
);
|
||||
const CodeEditor = lazy(loadCodeEditor);
|
||||
const ImagePreview = lazy(loadImagePreview);
|
||||
const MarkdownRenderer = lazy(loadMarkdownRenderer);
|
||||
const PdfPreview = lazy(loadPdfPreview);
|
||||
const AudioPreview = lazy(loadAudioPreview);
|
||||
|
||||
interface FileItem {
|
||||
name: string;
|
||||
@@ -157,84 +145,45 @@ function getFileType(filename: string): {
|
||||
icon: React.ReactNode;
|
||||
color: string;
|
||||
} {
|
||||
const ext = filename.split(".").pop()?.toLowerCase() || "";
|
||||
const kind = resolveFilePreviewKind(filename);
|
||||
|
||||
const imageExts = ["png", "jpg", "jpeg", "gif", "bmp", "svg", "webp"];
|
||||
const videoExts = ["mp4", "avi", "mkv", "mov", "wmv", "flv", "webm"];
|
||||
const audioExts = ["mp3", "wav", "flac", "ogg", "aac", "m4a"];
|
||||
const textExts = ["txt", "readme"];
|
||||
const markdownExts = ["md", "markdown", "mdown", "mkdn", "mdx"];
|
||||
const pdfExts = ["pdf"];
|
||||
const codeExts = [
|
||||
"js",
|
||||
"ts",
|
||||
"jsx",
|
||||
"tsx",
|
||||
"py",
|
||||
"java",
|
||||
"cpp",
|
||||
"c",
|
||||
"cs",
|
||||
"php",
|
||||
"rb",
|
||||
"go",
|
||||
"rs",
|
||||
"html",
|
||||
"css",
|
||||
"scss",
|
||||
"less",
|
||||
"json",
|
||||
"xml",
|
||||
"yaml",
|
||||
"yml",
|
||||
"toml",
|
||||
"ini",
|
||||
"conf",
|
||||
"sh",
|
||||
"bash",
|
||||
"zsh",
|
||||
"sql",
|
||||
"vue",
|
||||
"svelte",
|
||||
];
|
||||
|
||||
if (imageExts.includes(ext)) {
|
||||
if (kind === "image") {
|
||||
return {
|
||||
type: "image",
|
||||
icon: <ImageIcon className="w-6 h-6" />,
|
||||
color: "text-green-500",
|
||||
};
|
||||
} else if (videoExts.includes(ext)) {
|
||||
} else if (kind === "video") {
|
||||
return {
|
||||
type: "video",
|
||||
icon: <Film className="w-6 h-6" />,
|
||||
color: "text-purple-500",
|
||||
};
|
||||
} else if (audioExts.includes(ext)) {
|
||||
} else if (kind === "audio") {
|
||||
return {
|
||||
type: "audio",
|
||||
icon: <Music className="w-6 h-6" />,
|
||||
color: "text-pink-500",
|
||||
};
|
||||
} else if (markdownExts.includes(ext)) {
|
||||
} else if (kind === "markdown") {
|
||||
return {
|
||||
type: "markdown",
|
||||
icon: <FileText className="w-6 h-6" />,
|
||||
color: "text-blue-600",
|
||||
};
|
||||
} else if (pdfExts.includes(ext)) {
|
||||
} else if (kind === "pdf") {
|
||||
return {
|
||||
type: "pdf",
|
||||
icon: <FileText className="w-6 h-6" />,
|
||||
color: "text-red-600",
|
||||
};
|
||||
} else if (textExts.includes(ext)) {
|
||||
} else if (kind === "text") {
|
||||
return {
|
||||
type: "text",
|
||||
icon: <FileText className="w-6 h-6" />,
|
||||
color: "text-blue-500",
|
||||
};
|
||||
} else if (codeExts.includes(ext)) {
|
||||
} else if (kind === "code") {
|
||||
return {
|
||||
type: "code",
|
||||
icon: getLanguageIcon(filename),
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
connectSSH,
|
||||
} from "@/main-axios.ts";
|
||||
import { toast } from "sonner";
|
||||
import type { SSHHost } from "@/types/index";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface FileItem {
|
||||
@@ -24,20 +25,6 @@ interface FileItem {
|
||||
group?: string;
|
||||
}
|
||||
|
||||
interface SSHHost {
|
||||
id: number;
|
||||
name: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
password?: string;
|
||||
key?: string;
|
||||
keyPassword?: string;
|
||||
authType: "password" | "key";
|
||||
credentialId?: number;
|
||||
userId?: number;
|
||||
}
|
||||
|
||||
interface FileWindowProps {
|
||||
windowId: string;
|
||||
file: FileItem;
|
||||
@@ -284,7 +271,9 @@ export function FileWindow({
|
||||
|
||||
await ensureSSHConnection();
|
||||
|
||||
const response = await readSSHFile(sshSessionId, file.path);
|
||||
const response = await readSSHFile(sshSessionId, file.path, {
|
||||
force: true,
|
||||
});
|
||||
const fileContent = response.content || "";
|
||||
setContent(fileContent);
|
||||
setPendingContent("");
|
||||
|
||||
@@ -51,8 +51,11 @@ export function MarkdownRenderer({
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
code({ inline, className, children, ...props }) {
|
||||
code({ className, children, ...props }) {
|
||||
const match = /language-(\w+)/.exec(className || "");
|
||||
// v9 removed the `inline` flag; a fenced block always carries a
|
||||
// language- class, an inline span never does.
|
||||
const inline = !className;
|
||||
return !inline && match ? (
|
||||
<SyntaxHighlighter
|
||||
style={syntaxTheme}
|
||||
|
||||
@@ -41,7 +41,7 @@ export function PdfPreview({
|
||||
>
|
||||
{t("fileManager.previous")}
|
||||
</Button>
|
||||
<span className="text-sm text-foreground px-3 py-1 bg-background rounded border">
|
||||
<span className="text-sm text-foreground px-3 py-1 bg-background border border-border">
|
||||
{t("fileManager.pageXOfY", {
|
||||
current: pageNumber,
|
||||
total: numPages || 0,
|
||||
@@ -66,7 +66,7 @@ export function PdfPreview({
|
||||
>
|
||||
{t("fileManager.zoomOut")}
|
||||
</Button>
|
||||
<span className="text-sm text-foreground px-3 py-1 bg-background rounded border min-w-[80px] text-center">
|
||||
<span className="text-sm text-foreground px-3 py-1 bg-background border border-border min-w-[80px] text-center">
|
||||
{Math.round(pdfScale * 100)}%
|
||||
</span>
|
||||
<Button
|
||||
|
||||
@@ -49,6 +49,10 @@ export function TransferProgressToast({
|
||||
let title = t("transfer.progressTransferring");
|
||||
if (status.phase === "reconnecting") {
|
||||
title = t("transfer.progressReconnecting");
|
||||
} else if (status.phase === "benchmarking") {
|
||||
title = t("transfer.progressBenchmarking");
|
||||
} else if (status.phase === "verifying") {
|
||||
title = t("transfer.progressVerifying");
|
||||
} else if (status.phase === "compressing") {
|
||||
title = t("transfer.progressCompressing");
|
||||
} else if (status.phase === "extracting") {
|
||||
|
||||
@@ -75,7 +75,7 @@ interface TransferToHostDialogProps {
|
||||
destPath: string,
|
||||
destPathLabel: string,
|
||||
methodPreference: TransferMethodPreference,
|
||||
parallelSegmentCount: number,
|
||||
parallelSegmentCount?: number,
|
||||
) => void;
|
||||
}
|
||||
|
||||
@@ -190,7 +190,7 @@ export function TransferToHostDialog({
|
||||
const destPathInputRef = useRef<HTMLInputElement>(null);
|
||||
const [methodPreference, setMethodPreference] =
|
||||
useState<TransferMethodPreference>("auto");
|
||||
const [parallelSegmentCount, setParallelSegmentCount] = useState("2");
|
||||
const [parallelSegmentCount, setParallelSegmentCount] = useState("auto");
|
||||
const [methodPreview, setMethodPreview] =
|
||||
useState<TransferMethodPreview | null>(null);
|
||||
const [methodPreviewLoading, setMethodPreviewLoading] = useState(false);
|
||||
@@ -664,7 +664,9 @@ export function TransferToHostDialog({
|
||||
fullPath,
|
||||
destPath.trim(),
|
||||
methodPreference,
|
||||
Math.max(1, Math.min(8, parseInt(parallelSegmentCount, 10) || 2)),
|
||||
parallelSegmentCount === "auto"
|
||||
? undefined
|
||||
: Math.max(1, Math.min(8, parseInt(parallelSegmentCount, 10))),
|
||||
);
|
||||
onOpenChange(false);
|
||||
};
|
||||
@@ -981,6 +983,7 @@ export function TransferToHostDialog({
|
||||
}
|
||||
className="w-full appearance-none px-2.5 py-1.5 text-xs bg-background border border-border text-foreground outline-none focus:ring-1 focus:ring-ring pr-7"
|
||||
>
|
||||
<option value="auto">{t("transfer.methodAuto")}</option>
|
||||
{[1, 2, 3, 4].map((n) => (
|
||||
<option key={n} value={n.toString()}>
|
||||
{t("transfer.parallelSegmentsOption", { count: n })}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import {
|
||||
markAdaptiveResourceUsed,
|
||||
runAdaptiveBackgroundTask,
|
||||
} from "@/lib/adaptive-resource-budget";
|
||||
|
||||
export type FilePreviewKind =
|
||||
| "image"
|
||||
| "video"
|
||||
| "audio"
|
||||
| "markdown"
|
||||
| "pdf"
|
||||
| "text"
|
||||
| "code"
|
||||
| "unknown";
|
||||
|
||||
const extensions = {
|
||||
image: new Set(["png", "jpg", "jpeg", "gif", "bmp", "svg", "webp"]),
|
||||
video: new Set(["mp4", "avi", "mkv", "mov", "wmv", "flv", "webm"]),
|
||||
audio: new Set(["mp3", "wav", "flac", "ogg", "aac", "m4a"]),
|
||||
markdown: new Set(["md", "markdown", "mdown", "mkdn", "mdx"]),
|
||||
pdf: new Set(["pdf"]),
|
||||
text: new Set(["txt", "readme"]),
|
||||
code: new Set([
|
||||
"js",
|
||||
"ts",
|
||||
"jsx",
|
||||
"tsx",
|
||||
"py",
|
||||
"java",
|
||||
"cpp",
|
||||
"c",
|
||||
"cs",
|
||||
"php",
|
||||
"rb",
|
||||
"go",
|
||||
"rs",
|
||||
"html",
|
||||
"css",
|
||||
"scss",
|
||||
"less",
|
||||
"json",
|
||||
"xml",
|
||||
"yaml",
|
||||
"yml",
|
||||
"toml",
|
||||
"ini",
|
||||
"conf",
|
||||
"sh",
|
||||
"bash",
|
||||
"zsh",
|
||||
"sql",
|
||||
"vue",
|
||||
"svelte",
|
||||
]),
|
||||
} satisfies Record<Exclude<FilePreviewKind, "unknown">, Set<string>>;
|
||||
|
||||
const extensionlessCodeFiles = new Set([
|
||||
"dockerfile",
|
||||
"makefile",
|
||||
"rakefile",
|
||||
"gemfile",
|
||||
]);
|
||||
|
||||
export function resolveFilePreviewKind(filename: string): FilePreviewKind {
|
||||
const lowerName = filename.toLowerCase();
|
||||
if (extensionlessCodeFiles.has(lowerName)) return "code";
|
||||
|
||||
const extension = lowerName.split(".").pop() || "";
|
||||
for (const [kind, knownExtensions] of Object.entries(extensions)) {
|
||||
if (knownExtensions.has(extension)) return kind as FilePreviewKind;
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
export const loadCodeEditor = () =>
|
||||
import("./components/CodeEditor.tsx").then((module) => ({
|
||||
default: module.CodeEditor,
|
||||
}));
|
||||
export const loadImagePreview = () =>
|
||||
import("./components/ImagePreview.tsx").then((module) => ({
|
||||
default: module.ImagePreview,
|
||||
}));
|
||||
export const loadMarkdownRenderer = () =>
|
||||
import("./components/MarkdownRenderer.tsx").then((module) => ({
|
||||
default: module.MarkdownRenderer,
|
||||
}));
|
||||
export const loadPdfPreview = () =>
|
||||
import("./components/PdfPreview.tsx").then((module) => ({
|
||||
default: module.PdfPreview,
|
||||
}));
|
||||
export const loadAudioPreview = () =>
|
||||
import("./components/AudioPreview.tsx").then((module) => ({
|
||||
default: module.AudioPreview,
|
||||
}));
|
||||
|
||||
const previewLoaders: Partial<Record<FilePreviewKind, () => Promise<unknown>>> =
|
||||
{
|
||||
image: loadImagePreview,
|
||||
audio: loadAudioPreview,
|
||||
markdown: loadMarkdownRenderer,
|
||||
pdf: loadPdfPreview,
|
||||
text: loadCodeEditor,
|
||||
code: loadCodeEditor,
|
||||
unknown: loadCodeEditor,
|
||||
};
|
||||
|
||||
export function preloadFilePreview(filename: string): void {
|
||||
const kind = resolveFilePreviewKind(filename);
|
||||
const loader = previewLoaders[kind];
|
||||
if (loader) runAdaptiveBackgroundTask("module", `preview:${kind}`, loader);
|
||||
}
|
||||
|
||||
export function markFilePreviewUsed(filename: string): void {
|
||||
markAdaptiveResourceUsed(
|
||||
"module",
|
||||
`preview:${resolveFilePreviewKind(filename)}`,
|
||||
);
|
||||
}
|
||||
@@ -181,7 +181,7 @@ export function useDragToDesktop({ sshSessionId }: UseDragToDesktopProps) {
|
||||
const filesData = fileList.map((file, index) => ({
|
||||
relativePath: file.name,
|
||||
content: responses[index]?.content || "",
|
||||
encoding: "base64",
|
||||
encoding: "base64" as const,
|
||||
}));
|
||||
|
||||
const tempResult = await window.electronAPI.createTempFolder({
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { FileItem } from "@/types/index";
|
||||
|
||||
export function childPath(parent: string, name: string): string {
|
||||
return parent === "/" ? `/${name}` : `${parent.replace(/\/$/, "")}/${name}`;
|
||||
}
|
||||
|
||||
export function addOptimisticItem(
|
||||
files: FileItem[],
|
||||
parent: string,
|
||||
name: string,
|
||||
type: "file" | "directory",
|
||||
now = Date.now(),
|
||||
): FileItem[] {
|
||||
const path = childPath(parent, name);
|
||||
if (files.some((file) => file.path === path)) return files;
|
||||
return [
|
||||
...files,
|
||||
{
|
||||
name,
|
||||
path,
|
||||
type,
|
||||
size: type === "file" ? 0 : undefined,
|
||||
modified: new Date(now).toISOString(),
|
||||
modifiedTimestamp: now,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function removePaths(files: FileItem[], paths: Set<string>): FileItem[] {
|
||||
return files.filter((file) => !paths.has(file.path));
|
||||
}
|
||||
|
||||
export function restoreItems(
|
||||
files: FileItem[],
|
||||
restored: FileItem[],
|
||||
): FileItem[] {
|
||||
const existing = new Set(files.map((file) => file.path));
|
||||
return [...files, ...restored.filter((file) => !existing.has(file.path))];
|
||||
}
|
||||
|
||||
export function renameOptimisticItem(
|
||||
files: FileItem[],
|
||||
originalPath: string,
|
||||
newName: string,
|
||||
): FileItem[] {
|
||||
const slash = originalPath.lastIndexOf("/");
|
||||
const parent = slash <= 0 ? "/" : originalPath.slice(0, slash);
|
||||
return files.map((file) =>
|
||||
file.path === originalPath
|
||||
? { ...file, name: newName, path: childPath(parent, newName) }
|
||||
: file,
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,17 @@ export function createFormatTransferMetrics(t: TFunction) {
|
||||
return (timings?: TransferTimings): string => {
|
||||
if (!timings) return "";
|
||||
const parts: string[] = [];
|
||||
if (
|
||||
timings.directBenchmarkMs !== undefined &&
|
||||
timings.relayBenchmarkMs !== undefined
|
||||
) {
|
||||
parts.push(
|
||||
t("transfer.metricsRouteBenchmark", {
|
||||
direct: formatDurationMs(timings.directBenchmarkMs),
|
||||
relay: formatDurationMs(timings.relayBenchmarkMs),
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (timings.prepareDestMs !== undefined) {
|
||||
parts.push(
|
||||
t("transfer.metricsPrepare", {
|
||||
@@ -55,6 +66,13 @@ export function createFormatTransferMetrics(t: TFunction) {
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (timings.verifyMs !== undefined) {
|
||||
parts.push(
|
||||
t("transfer.metricsVerify", {
|
||||
duration: formatDurationMs(timings.verifyMs),
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (timings.sourceDeleteMs !== undefined) {
|
||||
parts.push(
|
||||
t("transfer.metricsSourceDelete", {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getErrorMessage } from "../../lib/error-message.js";
|
||||
import { toast } from "sonner";
|
||||
import type { TFunction } from "i18next";
|
||||
import {
|
||||
@@ -314,8 +315,7 @@ export function beginTransferProgressMonitoring(
|
||||
return finalStatus;
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
const message =
|
||||
error instanceof Error ? error.message : t("fileManager.unknownError");
|
||||
const message = getErrorMessage(error, t("fileManager.unknownError"));
|
||||
toast.error(`${t("transfer.transferError")}: ${message}`, {
|
||||
id: progressToast,
|
||||
className: TOAST_CLASS,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getErrorMessage } from "../../lib/error-message.js";
|
||||
import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
@@ -5,6 +6,7 @@ import React, {
|
||||
useCallback,
|
||||
useImperativeHandle,
|
||||
} from "react";
|
||||
import type Guacamole from "guacamole-common-js";
|
||||
import {
|
||||
GuacamoleDisplay,
|
||||
type GuacamoleDisplayHandle,
|
||||
@@ -18,10 +20,11 @@ import {
|
||||
isElectron,
|
||||
} from "@/main-axios.ts";
|
||||
import { readConfiguredDimension } from "@/features/guacamole/guacamole-display-size.ts";
|
||||
import { parseGuacamoleConfig } from "@/api/guacamole-api";
|
||||
import { resolveConnectionOrigin } from "@/lib/connection-origin.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AlertCircle, RefreshCw } from "lucide-react";
|
||||
import { GuacamoleToolbar } from "@/features/guacamole/GuacamoleToolbar.tsx";
|
||||
import { GuacamoleFileBrowser } from "@/features/guacamole/GuacamoleFileBrowser.tsx";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import { Input } from "@/components/input.tsx";
|
||||
import { PasswordInput } from "@/components/password-input.tsx";
|
||||
@@ -32,9 +35,16 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/dialog.tsx";
|
||||
import { SimpleLoader } from "@/lib/SimpleLoader.tsx";
|
||||
import { ConnectionScreen } from "@/components/connection/ConnectionScreen.tsx";
|
||||
import {
|
||||
ConnectionLogProvider,
|
||||
useConnectionLog,
|
||||
} from "@/ssh/connection-log/ConnectionLogContext.tsx";
|
||||
import { useConnectionRetry } from "@/lib/useConnectionRetry.ts";
|
||||
import { ShareSessionModal } from "@/features/session-sharing/ShareSessionModal.tsx";
|
||||
import type { SSHHost } from "@/types";
|
||||
import { useConnectionDefaults } from "@/contexts/ConnectionDefaultsContext";
|
||||
import { resolveConnectionDefaults } from "@/lib/connection-defaults";
|
||||
|
||||
interface GuacamoleAppProps {
|
||||
hostId?: string;
|
||||
@@ -53,10 +63,12 @@ export interface GuacamoleAppHandle {
|
||||
const GuacamoleApp = React.forwardRef<GuacamoleAppHandle, GuacamoleAppProps>(
|
||||
function GuacamoleApp({ hostId, tabId, protocol, isVisible = true }, ref) {
|
||||
const { t } = useTranslation();
|
||||
const defaults = useConnectionDefaults();
|
||||
const [hostConfig, setHostConfig] = useState<SSHHost | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!defaults.ready) return;
|
||||
if (!hostId) {
|
||||
setLoading(false);
|
||||
return;
|
||||
@@ -64,50 +76,55 @@ const GuacamoleApp = React.forwardRef<GuacamoleAppHandle, GuacamoleAppProps>(
|
||||
getSSHHosts()
|
||||
.then((hosts) => {
|
||||
const host = hosts.find((h) => h.id === parseInt(hostId, 10));
|
||||
setHostConfig(host ?? null);
|
||||
if (!host) {
|
||||
setHostConfig(null);
|
||||
return;
|
||||
}
|
||||
const connectionType = protocol ?? host.connectionType;
|
||||
const protocolDefaults = connectionType === "rdp" ? defaults.rdp : {};
|
||||
setHostConfig({
|
||||
...host,
|
||||
guacamoleConfig: resolveConnectionDefaults(
|
||||
protocolDefaults,
|
||||
parseGuacamoleConfig(host.guacamoleConfig),
|
||||
),
|
||||
});
|
||||
})
|
||||
.catch(() => setHostConfig(null))
|
||||
.finally(() => setLoading(false));
|
||||
}, [hostId]);
|
||||
}, [hostId, protocol, defaults.ready, defaults.rdp]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="relative w-full h-full">
|
||||
<SimpleLoader visible={true} message={t("common.loading")} />
|
||||
<ConnectionScreen status="connecting" message={t("common.loading")} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hostConfig || !hostId) {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col items-center justify-center h-full gap-4"
|
||||
style={{ backgroundColor: "var(--bg-base)" }}
|
||||
>
|
||||
<AlertCircle
|
||||
className="size-10"
|
||||
style={{ color: "var(--foreground)" }}
|
||||
<div className="relative w-full h-full">
|
||||
<ConnectionScreen
|
||||
status="disconnected"
|
||||
message={t("guacamole.hostNotFound")}
|
||||
/>
|
||||
<span
|
||||
className="text-sm font-semibold"
|
||||
style={{ color: "var(--foreground)" }}
|
||||
>
|
||||
{t("guacamole.hostNotFound")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<GuacamoleAppInner
|
||||
hostId={parseInt(hostId, 10)}
|
||||
hostConfig={hostConfig}
|
||||
hostName={hostConfig.name || hostConfig.ip || String(hostId)}
|
||||
tabId={tabId}
|
||||
protocol={protocol}
|
||||
isVisible={isVisible}
|
||||
ref={ref}
|
||||
/>
|
||||
<ConnectionLogProvider>
|
||||
<GuacamoleAppInner
|
||||
hostId={parseInt(hostId, 10)}
|
||||
hostConfig={hostConfig}
|
||||
hostName={hostConfig.name || hostConfig.ip || String(hostId)}
|
||||
tabId={tabId}
|
||||
protocol={protocol}
|
||||
isVisible={isVisible}
|
||||
ref={ref}
|
||||
/>
|
||||
</ConnectionLogProvider>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -116,7 +133,7 @@ interface GuacamoleAppInnerProps {
|
||||
hostId: number;
|
||||
hostConfig: Pick<
|
||||
SSHHost,
|
||||
"connectionType" | "guacamoleConfig" | "rdpAuthType"
|
||||
"connectionType" | "domain" | "guacamoleConfig" | "rdpAuthType"
|
||||
>;
|
||||
hostName: string;
|
||||
tabId?: string;
|
||||
@@ -132,6 +149,7 @@ const GuacamoleAppInner = React.forwardRef<
|
||||
ref,
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const { addLog, clearLogs } = useConnectionLog();
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [guacamoleConnectionId, setGuacamoleConnectionId] = useState<
|
||||
string | null
|
||||
@@ -139,7 +157,7 @@ const GuacamoleAppInner = React.forwardRef<
|
||||
const [shareModalOpen, setShareModalOpen] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [connectionError, setConnectionError] = useState<string | null>(null);
|
||||
const [retryCount, setRetryCount] = useState(0);
|
||||
const [isDisplayReady, setIsDisplayReady] = useState(false);
|
||||
const [touchMode, setTouchMode] = useState<GuacamoleTouchMode | null>(() =>
|
||||
typeof window !== "undefined" &&
|
||||
(navigator.maxTouchPoints > 0 || "ontouchstart" in window)
|
||||
@@ -147,6 +165,20 @@ const GuacamoleAppInner = React.forwardRef<
|
||||
: null,
|
||||
);
|
||||
const displayRef = useRef<GuacamoleDisplayHandle>(null);
|
||||
const [filesystem, setFilesystem] = useState<Guacamole.Object | null>(null);
|
||||
const [fileBrowserOpen, setFileBrowserOpen] = useState(false);
|
||||
const [pendingUploads, setPendingUploads] = useState<File[]>([]);
|
||||
|
||||
const guacConfig = parseGuacamoleConfig(hostConfig.guacamoleConfig);
|
||||
const allowUpload = guacConfig.disableUpload !== true;
|
||||
const allowDownload = guacConfig.disableDownload !== true;
|
||||
|
||||
// A dropped file has nowhere to go until the browser is showing the target
|
||||
// directory, so opening it is part of accepting the drop.
|
||||
const handleDropFiles = useCallback((files: File[]) => {
|
||||
setPendingUploads(files);
|
||||
setFileBrowserOpen(true);
|
||||
}, []);
|
||||
|
||||
const resolvedProtocolForConnect = (protocol ??
|
||||
hostConfig.connectionType ??
|
||||
@@ -157,10 +189,12 @@ const GuacamoleAppInner = React.forwardRef<
|
||||
const [promptedCredentials, setPromptedCredentials] = useState<{
|
||||
username: string;
|
||||
password: string;
|
||||
domain: string;
|
||||
} | null>(null);
|
||||
const [promptOpen, setPromptOpen] = useState(needsCredentialPrompt);
|
||||
const [promptUsername, setPromptUsername] = useState("");
|
||||
const [promptPassword, setPromptPassword] = useState("");
|
||||
const [promptDomain, setPromptDomain] = useState(hostConfig.domain ?? "");
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
disconnect: () => displayRef.current?.disconnect(),
|
||||
@@ -169,79 +203,110 @@ const GuacamoleAppInner = React.forwardRef<
|
||||
canShare: () => guacamoleConnectionId !== null,
|
||||
}));
|
||||
|
||||
const fetchToken = useCallback(async (): Promise<void> => {
|
||||
setToken(null);
|
||||
setGuacamoleConnectionId(null);
|
||||
setError(null);
|
||||
|
||||
if (isElectron()) {
|
||||
const origin = await resolveConnectionOrigin({
|
||||
connectionType: resolvedProtocolForConnect,
|
||||
});
|
||||
if (origin === "remote") {
|
||||
const remoteConfig = (await window.electronAPI?.invoke?.(
|
||||
"get-remote-sync-config",
|
||||
)) as { serverUrl?: string } | null;
|
||||
if (!remoteConfig?.serverUrl) {
|
||||
throw new Error(t("errors.remoteServerRequired"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addLog({
|
||||
type: "info",
|
||||
stage: "guac_guacd",
|
||||
message: t("guacamole.connecting", {
|
||||
type: resolvedProtocolForConnect.toUpperCase(),
|
||||
}),
|
||||
});
|
||||
const status = await getGuacdStatus();
|
||||
if (status.guacd.status !== "connected") {
|
||||
throw new Error(t("guacamole.guacdUnavailable"));
|
||||
}
|
||||
|
||||
addLog({
|
||||
type: "info",
|
||||
stage: "guac_token",
|
||||
message: t("guacamole.connecting", {
|
||||
type: resolvedProtocolForConnect.toUpperCase(),
|
||||
}),
|
||||
});
|
||||
const result = await getGuacamoleTokenFromHost(
|
||||
hostId,
|
||||
protocol,
|
||||
promptedCredentials ?? undefined,
|
||||
);
|
||||
if (result) {
|
||||
setToken(result.token);
|
||||
setGuacamoleConnectionId(result.guacamoleConnectionId ?? null);
|
||||
logActivity(resolvedProtocolForConnect, hostId, hostName).catch(() => {});
|
||||
}
|
||||
}, [
|
||||
hostId,
|
||||
hostName,
|
||||
protocol,
|
||||
promptedCredentials,
|
||||
resolvedProtocolForConnect,
|
||||
addLog,
|
||||
t,
|
||||
]);
|
||||
|
||||
const tokenRetry = useConnectionRetry({
|
||||
connect: async () => {
|
||||
try {
|
||||
await fetchToken();
|
||||
tokenRetryRef.current.markConnected();
|
||||
} catch (err: unknown) {
|
||||
const message = getErrorMessage(err, t("guacamole.failedToConnect"));
|
||||
setError(message || t("guacamole.failedToConnect"));
|
||||
addLog({ type: "error", stage: "error", message });
|
||||
tokenRetryRef.current.markFailed();
|
||||
}
|
||||
},
|
||||
enabled: !needsCredentialPrompt || !!promptedCredentials,
|
||||
autoStart: false,
|
||||
});
|
||||
const tokenRetryRef = useRef(tokenRetry);
|
||||
tokenRetryRef.current = tokenRetry;
|
||||
|
||||
useEffect(() => {
|
||||
if (needsCredentialPrompt && !promptedCredentials) {
|
||||
setPromptOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setToken(null);
|
||||
setGuacamoleConnectionId(null);
|
||||
setError(null);
|
||||
|
||||
(async () => {
|
||||
if (isElectron()) {
|
||||
const origin = await resolveConnectionOrigin({
|
||||
connectionType: resolvedProtocolForConnect,
|
||||
});
|
||||
if (origin === "remote") {
|
||||
const remoteConfig = (await window.electronAPI?.invoke?.(
|
||||
"get-remote-sync-config",
|
||||
)) as { serverUrl?: string } | null;
|
||||
if (!remoteConfig?.serverUrl) {
|
||||
setError(t("errors.remoteServerRequired"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const status = await getGuacdStatus();
|
||||
if (status.guacd.status !== "connected") {
|
||||
setError(t("guacamole.guacdUnavailable"));
|
||||
return;
|
||||
}
|
||||
const result = await getGuacamoleTokenFromHost(
|
||||
hostId,
|
||||
protocol,
|
||||
promptedCredentials ?? undefined,
|
||||
);
|
||||
if (result) {
|
||||
setToken(result.token);
|
||||
setGuacamoleConnectionId(result.guacamoleConnectionId ?? null);
|
||||
logActivity(resolvedProtocolForConnect, hostId, hostName).catch(
|
||||
() => {},
|
||||
);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : t("guacamole.failedToConnect");
|
||||
setError(message || t("guacamole.failedToConnect"));
|
||||
}
|
||||
})();
|
||||
}, [
|
||||
hostId,
|
||||
hostName,
|
||||
protocol,
|
||||
retryCount,
|
||||
t,
|
||||
needsCredentialPrompt,
|
||||
promptedCredentials,
|
||||
resolvedProtocolForConnect,
|
||||
]);
|
||||
clearLogs();
|
||||
tokenRetryRef.current.reset();
|
||||
tokenRetryRef.current.retryNow();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [hostId, protocol, needsCredentialPrompt, promptedCredentials]);
|
||||
|
||||
const handleReconnect = useCallback(() => {
|
||||
setConnectionError(null);
|
||||
setError(null);
|
||||
setToken(null);
|
||||
setIsDisplayReady(false);
|
||||
if (needsCredentialPrompt) {
|
||||
setPromptedCredentials(null);
|
||||
setPromptUsername("");
|
||||
setPromptPassword("");
|
||||
setPromptDomain(hostConfig.domain ?? "");
|
||||
setPromptOpen(true);
|
||||
return;
|
||||
}
|
||||
setRetryCount((c) => c + 1);
|
||||
}, [needsCredentialPrompt]);
|
||||
clearLogs();
|
||||
tokenRetryRef.current.reset();
|
||||
tokenRetryRef.current.retryNow();
|
||||
}, [needsCredentialPrompt, hostConfig.domain, clearLogs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!tabId) return;
|
||||
@@ -278,6 +343,7 @@ const GuacamoleAppInner = React.forwardRef<
|
||||
setPromptedCredentials({
|
||||
username: promptUsername,
|
||||
password: promptPassword,
|
||||
domain: promptDomain,
|
||||
});
|
||||
setPromptOpen(false);
|
||||
}}
|
||||
@@ -293,6 +359,16 @@ const GuacamoleAppInner = React.forwardRef<
|
||||
onChange={(e) => setPromptUsername(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold">
|
||||
{t("hosts.guac.domain")}
|
||||
</label>
|
||||
<Input
|
||||
placeholder="WORKGROUP"
|
||||
value={promptDomain}
|
||||
onChange={(e) => setPromptDomain(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold">
|
||||
{t("hosts.guac.password")}
|
||||
@@ -315,41 +391,11 @@ const GuacamoleAppInner = React.forwardRef<
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col items-center justify-center h-full gap-4"
|
||||
style={{ backgroundColor: "var(--bg-base)" }}
|
||||
>
|
||||
<AlertCircle
|
||||
className="size-10"
|
||||
style={{ color: "var(--foreground)" }}
|
||||
/>
|
||||
<p
|
||||
className="text-sm font-semibold"
|
||||
style={{ color: "var(--foreground)" }}
|
||||
>
|
||||
{t("guacamole.connectionFailed")}
|
||||
</p>
|
||||
<p
|
||||
className="text-xs max-w-xs text-center"
|
||||
style={{ color: "var(--foreground-secondary)" }}
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={handleReconnect}>
|
||||
<RefreshCw className="size-4 mr-2" />
|
||||
{t("guacamole.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
if (error || !token) {
|
||||
return (
|
||||
<div className="relative w-full h-full">
|
||||
<SimpleLoader
|
||||
visible={true}
|
||||
<ConnectionScreen
|
||||
status={error ? tokenRetry.status : "connecting"}
|
||||
message={t("guacamole.connecting", {
|
||||
type: (
|
||||
protocol ||
|
||||
@@ -357,50 +403,33 @@ const GuacamoleAppInner = React.forwardRef<
|
||||
"remote"
|
||||
).toUpperCase(),
|
||||
})}
|
||||
attempt={tokenRetry.attempt}
|
||||
maxAttempts={tokenRetry.maxAttempts}
|
||||
nextRetryInMs={tokenRetry.nextRetryInMs}
|
||||
onManualRetry={handleReconnect}
|
||||
retryLabel={t("guacamole.retry")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const resolvedProtocol = resolvedProtocolForConnect;
|
||||
const configuredDpi = readConfiguredDimension(
|
||||
hostConfig.guacamoleConfig?.dpi,
|
||||
);
|
||||
const configuredWidth = readConfiguredDimension(
|
||||
hostConfig.guacamoleConfig?.width,
|
||||
);
|
||||
const configuredHeight = readConfiguredDimension(
|
||||
hostConfig.guacamoleConfig?.height,
|
||||
);
|
||||
const configuredDpi = readConfiguredDimension(guacConfig.dpi);
|
||||
const configuredWidth = readConfiguredDimension(guacConfig.width);
|
||||
const configuredHeight = readConfiguredDimension(guacConfig.height);
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-full">
|
||||
{connectionError && (
|
||||
<div
|
||||
className="absolute inset-0 flex flex-col items-center justify-center gap-4 z-50"
|
||||
style={{ backgroundColor: "var(--bg-base)" }}
|
||||
>
|
||||
<AlertCircle
|
||||
className="size-10"
|
||||
style={{ color: "var(--foreground)" }}
|
||||
/>
|
||||
<p
|
||||
className="text-sm font-semibold"
|
||||
style={{ color: "var(--foreground)" }}
|
||||
>
|
||||
{t("guacamole.connectionFailed")}
|
||||
</p>
|
||||
<p
|
||||
className="text-xs max-w-xs text-center"
|
||||
style={{ color: "var(--foreground-secondary)" }}
|
||||
>
|
||||
{connectionError}
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={handleReconnect}>
|
||||
<RefreshCw className="size-4 mr-2" />
|
||||
{t("guacamole.reconnect")}
|
||||
</Button>
|
||||
</div>
|
||||
{(!isDisplayReady || connectionError) && (
|
||||
<ConnectionScreen
|
||||
status={connectionError ? "disconnected" : "connecting"}
|
||||
message={t("guacamole.connecting", {
|
||||
type: resolvedProtocol.toUpperCase(),
|
||||
})}
|
||||
onManualRetry={handleReconnect}
|
||||
retryLabel={t("guacamole.reconnect")}
|
||||
className="z-50"
|
||||
/>
|
||||
)}
|
||||
<GuacamoleDisplay
|
||||
key={`${token}-${touchMode}`}
|
||||
@@ -415,12 +444,41 @@ const GuacamoleAppInner = React.forwardRef<
|
||||
}}
|
||||
isVisible={isVisible}
|
||||
touchMode={touchMode}
|
||||
onError={(err) => setConnectionError(err)}
|
||||
allowUpload={allowUpload && filesystem !== null}
|
||||
onConnect={() => setIsDisplayReady(true)}
|
||||
onError={(err) => {
|
||||
setConnectionError(err);
|
||||
addLog({ type: "error", stage: "error", message: err });
|
||||
}}
|
||||
onStageChange={(stage) =>
|
||||
addLog({
|
||||
type: "info",
|
||||
stage,
|
||||
message: t("guacamole.connecting", {
|
||||
type: resolvedProtocol.toUpperCase(),
|
||||
}),
|
||||
})
|
||||
}
|
||||
onFilesystem={setFilesystem}
|
||||
onDropFiles={handleDropFiles}
|
||||
/>
|
||||
{filesystem && fileBrowserOpen && (
|
||||
<GuacamoleFileBrowser
|
||||
filesystem={filesystem}
|
||||
allowUpload={allowUpload}
|
||||
allowDownload={allowDownload}
|
||||
pendingUploads={pendingUploads}
|
||||
onPendingUploadsHandled={() => setPendingUploads([])}
|
||||
onClose={() => setFileBrowserOpen(false)}
|
||||
/>
|
||||
)}
|
||||
<GuacamoleToolbar
|
||||
displayRef={displayRef}
|
||||
protocol={resolvedProtocol}
|
||||
touchMode={touchMode}
|
||||
hasFilesystem={filesystem !== null}
|
||||
fileBrowserOpen={fileBrowserOpen}
|
||||
onToggleFileBrowser={() => setFileBrowserOpen((open) => !open)}
|
||||
onTouchModeChange={setTouchMode}
|
||||
/>
|
||||
{shareModalOpen && guacamoleConnectionId && (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { getErrorMessage } from "../../lib/error-message.js";
|
||||
import type React from "react";
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
@@ -7,9 +9,9 @@ import {
|
||||
useCallback,
|
||||
} from "react";
|
||||
import Guacamole from "guacamole-common-js";
|
||||
import { Upload } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getGuacamoleToken, isElectron } from "@/main-axios.ts";
|
||||
import { SimpleLoader } from "@/lib/SimpleLoader.tsx";
|
||||
import { getBasePath } from "@/lib/base-path.ts";
|
||||
import { buildGuacamoleWebSocketBaseUrl } from "./guacamole-websocket-url.ts";
|
||||
import {
|
||||
@@ -22,6 +24,9 @@ import {
|
||||
pasteTextToRemote,
|
||||
} from "./guacamole-clipboard.ts";
|
||||
import { getGuacamoleDisplaySize } from "./guacamole-display-size.ts";
|
||||
import { bindPointerInput } from "./guacamole-pointer.ts";
|
||||
import { guacStateToStage } from "@/components/connection/connection-status.ts";
|
||||
import type { ConnectionStage } from "@/types/connection-log.ts";
|
||||
|
||||
export type GuacamoleConnectionType = "rdp" | "vnc" | "telnet";
|
||||
|
||||
@@ -46,6 +51,7 @@ export interface GuacamoleDisplayHandle {
|
||||
sendKey: (keysym: number, pressed: boolean) => void;
|
||||
sendMouse: (x: number, y: number, buttonMask: number) => void;
|
||||
setClipboard: (data: string) => void;
|
||||
getFilesystem: () => Guacamole.Object | null;
|
||||
}
|
||||
|
||||
export type GuacamoleTouchMode = "touchscreen" | "touchpad";
|
||||
@@ -54,9 +60,13 @@ interface GuacamoleDisplayProps {
|
||||
connectionConfig: GuacamoleConnectionConfig;
|
||||
isVisible: boolean;
|
||||
touchMode?: GuacamoleTouchMode | null;
|
||||
allowUpload?: boolean;
|
||||
onConnect?: () => void;
|
||||
onDisconnect?: () => void;
|
||||
onError?: (error: string) => void;
|
||||
onFilesystem?: (filesystem: Guacamole.Object | null) => void;
|
||||
onDropFiles?: (files: File[]) => void;
|
||||
onStageChange?: (stage: ConnectionStage) => void;
|
||||
}
|
||||
|
||||
const isDev = import.meta.env.DEV;
|
||||
@@ -65,7 +75,18 @@ export const GuacamoleDisplay = forwardRef<
|
||||
GuacamoleDisplayHandle,
|
||||
GuacamoleDisplayProps
|
||||
>(function GuacamoleDisplay(
|
||||
{ connectionConfig, isVisible, touchMode, onConnect, onDisconnect, onError },
|
||||
{
|
||||
connectionConfig,
|
||||
isVisible,
|
||||
touchMode,
|
||||
allowUpload = false,
|
||||
onConnect,
|
||||
onDisconnect,
|
||||
onError,
|
||||
onFilesystem,
|
||||
onDropFiles,
|
||||
onStageChange,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
@@ -77,6 +98,13 @@ export const GuacamoleDisplay = forwardRef<
|
||||
const displayRef = useRef<HTMLDivElement>(null);
|
||||
const displayElementRef = useRef<HTMLElement | null>(null);
|
||||
const clientRef = useRef<Guacamole.Client | null>(null);
|
||||
const filesystemRef = useRef<Guacamole.Object | null>(null);
|
||||
// Held in a ref so tearing down the client can report the loss without
|
||||
// rebuilding the connect callback whenever the parent re-renders.
|
||||
const onFilesystemRef = useRef(onFilesystem);
|
||||
onFilesystemRef.current = onFilesystem;
|
||||
const onStageChangeRef = useRef(onStageChange);
|
||||
onStageChangeRef.current = onStageChange;
|
||||
const keyboardRef = useRef<Guacamole.Keyboard | null>(null);
|
||||
const scaleRef = useRef<number>(1);
|
||||
const resizeTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
@@ -94,6 +122,10 @@ export const GuacamoleDisplay = forwardRef<
|
||||
const client = clientRef.current;
|
||||
clientRef.current = null;
|
||||
isConnectingRef.current = false;
|
||||
if (filesystemRef.current) {
|
||||
filesystemRef.current = null;
|
||||
onFilesystemRef.current?.(null);
|
||||
}
|
||||
if (!client) return;
|
||||
|
||||
try {
|
||||
@@ -132,6 +164,7 @@ export const GuacamoleDisplay = forwardRef<
|
||||
writer.sendEnd();
|
||||
}
|
||||
},
|
||||
getFilesystem: () => filesystemRef.current,
|
||||
}));
|
||||
|
||||
const getWebSocketConnection = useCallback(
|
||||
@@ -211,8 +244,7 @@ export const GuacamoleDisplay = forwardRef<
|
||||
if (displaySize.dpi) params.set("dpi", String(displaySize.dpi));
|
||||
return { url: wsBase, query: params.toString() };
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Unknown error";
|
||||
const errorMessage = getErrorMessage(error);
|
||||
onError?.(errorMessage);
|
||||
return null;
|
||||
}
|
||||
@@ -421,10 +453,9 @@ export const GuacamoleDisplay = forwardRef<
|
||||
setIsReady(true);
|
||||
}
|
||||
|
||||
const sendMouseEvent = (event: Guacamole.Mouse.MouseEvent) => {
|
||||
const sendMouseState = (state: Guacamole.Mouse.State) => {
|
||||
displayElement.focus({ preventScroll: true });
|
||||
const scale = scaleRef.current;
|
||||
const state = event.state;
|
||||
const adjustedState = new Guacamole.Mouse.State(
|
||||
Math.round(state.x / scale),
|
||||
Math.round(state.y / scale),
|
||||
@@ -437,30 +468,7 @@ export const GuacamoleDisplay = forwardRef<
|
||||
client.sendMouseState(adjustedState);
|
||||
};
|
||||
|
||||
if (touchMode === "touchscreen") {
|
||||
const touchscreen = new Guacamole.Mouse.Touchscreen(displayElement);
|
||||
touchscreen.onEach(["mousedown", "mousemove", "mouseup"], sendMouseEvent);
|
||||
} else if (touchMode === "touchpad") {
|
||||
const touchpad = new Guacamole.Mouse.Touchpad(displayElement);
|
||||
touchpad.onEach(["mousedown", "mousemove", "mouseup"], sendMouseEvent);
|
||||
} else {
|
||||
const mouse = new Guacamole.Mouse(displayElement);
|
||||
const sendMouseState = (state: Guacamole.Mouse.State) => {
|
||||
displayElement.focus({ preventScroll: true });
|
||||
const scale = scaleRef.current;
|
||||
const adjustedState = new Guacamole.Mouse.State(
|
||||
Math.round(state.x / scale),
|
||||
Math.round(state.y / scale),
|
||||
state.left,
|
||||
state.middle,
|
||||
state.right,
|
||||
state.up,
|
||||
state.down,
|
||||
) as Guacamole.Mouse.State;
|
||||
client.sendMouseState(adjustedState);
|
||||
};
|
||||
mouse.onmousedown = mouse.onmouseup = mouse.onmousemove = sendMouseState;
|
||||
}
|
||||
bindPointerInput(displayElement, touchMode, sendMouseState);
|
||||
|
||||
const keyboard = new Guacamole.Keyboard(displayElement);
|
||||
keyboardRef.current = keyboard;
|
||||
@@ -485,6 +493,7 @@ export const GuacamoleDisplay = forwardRef<
|
||||
|
||||
client.onstatechange = (state: number) => {
|
||||
if (!isMountedRef.current || clientRef.current !== client) return;
|
||||
onStageChangeRef.current?.(guacStateToStage(state));
|
||||
switch (state) {
|
||||
case 0:
|
||||
break;
|
||||
@@ -555,6 +564,20 @@ export const GuacamoleDisplay = forwardRef<
|
||||
Guacamole.AudioPlayer.getInstance(stream, mimetype);
|
||||
};
|
||||
|
||||
// Only fires when the connection enables drive redirection; guacd exposes
|
||||
// the redirected drive as a single filesystem object.
|
||||
client.onfilesystem = (filesystem: Guacamole.Object) => {
|
||||
if (!isMountedRef.current || clientRef.current !== client) return;
|
||||
filesystemRef.current = filesystem;
|
||||
onFilesystemRef.current?.(filesystem);
|
||||
|
||||
filesystem.onundefine = () => {
|
||||
if (filesystemRef.current !== filesystem) return;
|
||||
filesystemRef.current = null;
|
||||
onFilesystemRef.current?.(null);
|
||||
};
|
||||
};
|
||||
|
||||
client.onfile = (
|
||||
stream: Guacamole.InputStream,
|
||||
mimetype: string,
|
||||
@@ -593,9 +616,7 @@ export const GuacamoleDisplay = forwardRef<
|
||||
if (!isMountedRef.current) return;
|
||||
setIsReady(false);
|
||||
setHasError(true);
|
||||
onError?.(
|
||||
error instanceof Error ? error.message : t("guacamole.connectionError"),
|
||||
);
|
||||
onError?.(getErrorMessage(error, t("guacamole.connectionError")));
|
||||
}
|
||||
}, [
|
||||
getWebSocketConnection,
|
||||
@@ -747,19 +768,49 @@ export const GuacamoleDisplay = forwardRef<
|
||||
};
|
||||
}, [isReady, syncClipboard]);
|
||||
|
||||
const connectingMessage = t("guacamole.connecting", {
|
||||
type: (
|
||||
connectionConfig.protocol ||
|
||||
connectionConfig.type ||
|
||||
"remote"
|
||||
).toUpperCase(),
|
||||
});
|
||||
const canDropFiles = allowUpload && onDropFiles != null;
|
||||
const [isDraggingFiles, setIsDraggingFiles] = useState(false);
|
||||
// Nested elements fire dragleave as the pointer crosses them, so track depth
|
||||
// rather than clearing the highlight on the first leave.
|
||||
const dragDepthRef = useRef(0);
|
||||
|
||||
const handleDragEnter = useCallback(
|
||||
(event: React.DragEvent) => {
|
||||
if (!canDropFiles || !event.dataTransfer.types.includes("Files")) return;
|
||||
event.preventDefault();
|
||||
dragDepthRef.current += 1;
|
||||
setIsDraggingFiles(true);
|
||||
},
|
||||
[canDropFiles],
|
||||
);
|
||||
|
||||
const handleDragLeave = useCallback(() => {
|
||||
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
|
||||
if (dragDepthRef.current === 0) setIsDraggingFiles(false);
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(event: React.DragEvent) => {
|
||||
if (!canDropFiles) return;
|
||||
event.preventDefault();
|
||||
dragDepthRef.current = 0;
|
||||
setIsDraggingFiles(false);
|
||||
|
||||
const files = Array.from(event.dataTransfer.files);
|
||||
if (files.length > 0) onDropFiles?.(files);
|
||||
},
|
||||
[canDropFiles, onDropFiles],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="absolute inset-0 overflow-hidden"
|
||||
style={{ backgroundColor: "var(--bg-base)" }}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragOver={canDropFiles ? (e) => e.preventDefault() : undefined}
|
||||
onDragLeave={canDropFiles ? handleDragLeave : undefined}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<div
|
||||
ref={displayRef}
|
||||
@@ -770,10 +821,14 @@ export const GuacamoleDisplay = forwardRef<
|
||||
}}
|
||||
/>
|
||||
|
||||
<SimpleLoader
|
||||
visible={!isReady && !hasError}
|
||||
message={connectingMessage}
|
||||
/>
|
||||
{isDraggingFiles && (
|
||||
<div className="absolute inset-0 z-30 flex items-center justify-center pointer-events-none bg-background/70 backdrop-blur-sm">
|
||||
<div className="flex items-center gap-2 rounded-sm border-2 border-dashed border-border px-4 py-3 text-sm font-semibold">
|
||||
<Upload className="size-4" />
|
||||
{t("guacamole.files.dropToUpload")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
import { getErrorMessage } from "../../lib/error-message.js";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import Guacamole from "guacamole-common-js";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ArrowUp,
|
||||
Download,
|
||||
File as FileIcon,
|
||||
Folder,
|
||||
RefreshCw,
|
||||
Upload,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import {
|
||||
downloadFile,
|
||||
listDirectory,
|
||||
parentPath,
|
||||
saveBlobAs,
|
||||
uploadFile,
|
||||
type RemoteFileEntry,
|
||||
} from "./guacamole-filesystem.ts";
|
||||
|
||||
interface GuacamoleFileBrowserProps {
|
||||
filesystem: Guacamole.Object;
|
||||
allowUpload: boolean;
|
||||
allowDownload: boolean;
|
||||
pendingUploads: File[];
|
||||
onPendingUploadsHandled: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function GuacamoleFileBrowser({
|
||||
filesystem,
|
||||
allowUpload,
|
||||
allowDownload,
|
||||
pendingUploads,
|
||||
onPendingUploadsHandled,
|
||||
onClose,
|
||||
}: GuacamoleFileBrowserProps) {
|
||||
const { t } = useTranslation();
|
||||
const [path, setPath] = useState("/");
|
||||
const [entries, setEntries] = useState<RemoteFileEntry[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busyName, setBusyName] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const refresh = useCallback(
|
||||
async (target: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
setEntries(await listDirectory(filesystem, target));
|
||||
} catch (err) {
|
||||
setEntries([]);
|
||||
setError(getErrorMessage(err, t("guacamole.files.listFailed")));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[filesystem, t],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh(path);
|
||||
}, [refresh, path]);
|
||||
|
||||
const upload = useCallback(
|
||||
async (files: File[]) => {
|
||||
for (const file of files) {
|
||||
setBusyName(file.name);
|
||||
try {
|
||||
await uploadFile(filesystem, path, file);
|
||||
toast.success(t("guacamole.files.uploaded", { name: file.name }));
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: t("guacamole.files.uploadFailed", { name: file.name }),
|
||||
);
|
||||
} finally {
|
||||
setBusyName(null);
|
||||
}
|
||||
}
|
||||
void refresh(path);
|
||||
},
|
||||
[filesystem, path, refresh, t],
|
||||
);
|
||||
|
||||
// Files dropped on the display land here so they share the browser's current
|
||||
// directory rather than always going to the drive root.
|
||||
useEffect(() => {
|
||||
if (pendingUploads.length === 0) return;
|
||||
onPendingUploadsHandled();
|
||||
if (!allowUpload) {
|
||||
toast.error(t("guacamole.files.uploadDisabled"));
|
||||
return;
|
||||
}
|
||||
void upload(pendingUploads);
|
||||
}, [pendingUploads, onPendingUploadsHandled, allowUpload, upload, t]);
|
||||
|
||||
const download = useCallback(
|
||||
async (entry: RemoteFileEntry) => {
|
||||
setBusyName(entry.name);
|
||||
try {
|
||||
const { blob, filename } = await downloadFile(filesystem, entry.path);
|
||||
saveBlobAs(blob, filename);
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: t("guacamole.files.downloadFailed", { name: entry.name }),
|
||||
);
|
||||
} finally {
|
||||
setBusyName(null);
|
||||
}
|
||||
},
|
||||
[filesystem, t],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="absolute right-2 top-2 bottom-2 z-30 flex w-80 flex-col rounded-sm border border-border bg-background/95 shadow-lg backdrop-blur-sm">
|
||||
<div className="flex items-center gap-1 border-b border-border px-2 py-1.5">
|
||||
<span className="flex-1 truncate text-xs font-semibold">
|
||||
{t("guacamole.files.title")}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6"
|
||||
onClick={onClose}
|
||||
aria-label={t("common.close")}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 border-b border-border px-2 py-1.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6"
|
||||
disabled={path === "/"}
|
||||
onClick={() => setPath(parentPath(path))}
|
||||
aria-label={t("guacamole.files.parent")}
|
||||
>
|
||||
<ArrowUp className="size-3.5" />
|
||||
</Button>
|
||||
<span className="flex-1 truncate text-[11px] text-muted-foreground">
|
||||
{path}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6"
|
||||
onClick={() => void refresh(path)}
|
||||
aria-label={t("guacamole.files.refresh")}
|
||||
>
|
||||
<RefreshCw className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{loading && (
|
||||
<p className="px-2 py-3 text-[11px] text-muted-foreground">
|
||||
{t("common.loading")}
|
||||
</p>
|
||||
)}
|
||||
{!loading && error && (
|
||||
<p className="px-2 py-3 text-[11px] text-red-500">{error}</p>
|
||||
)}
|
||||
{!loading && !error && entries.length === 0 && (
|
||||
<p className="px-2 py-3 text-[11px] text-muted-foreground">
|
||||
{t("guacamole.files.empty")}
|
||||
</p>
|
||||
)}
|
||||
{!loading &&
|
||||
!error &&
|
||||
entries.map((entry) => (
|
||||
<div
|
||||
key={entry.path}
|
||||
className="flex items-center gap-1.5 px-2 py-1 hover:bg-muted"
|
||||
>
|
||||
{entry.isDirectory ? (
|
||||
<Folder className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<FileIcon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 truncate text-left text-[11px] disabled:opacity-60"
|
||||
disabled={!entry.isDirectory}
|
||||
onClick={() => entry.isDirectory && setPath(entry.path)}
|
||||
>
|
||||
{entry.name}
|
||||
</button>
|
||||
{!entry.isDirectory && allowDownload && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-5 shrink-0"
|
||||
disabled={busyName === entry.name}
|
||||
onClick={() => void download(entry)}
|
||||
aria-label={t("guacamole.files.download")}
|
||||
>
|
||||
<Download className="size-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{allowUpload && (
|
||||
<div className="border-t border-border px-2 py-1.5">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const files = Array.from(e.target.files ?? []);
|
||||
e.target.value = "";
|
||||
if (files.length > 0) void upload(files);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 w-full text-[11px]"
|
||||
disabled={busyName !== null}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<Upload className="mr-1 size-3" />
|
||||
{busyName !== null
|
||||
? t("guacamole.files.uploading", { name: busyName })
|
||||
: t("guacamole.files.upload")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
ChevronsLeftRight,
|
||||
FolderOpen,
|
||||
Touchpad,
|
||||
MousePointer,
|
||||
} from "lucide-react";
|
||||
@@ -33,6 +34,9 @@ interface GuacamoleToolbarProps {
|
||||
displayRef: React.RefObject<GuacamoleDisplayHandle>;
|
||||
protocol: "rdp" | "vnc" | "telnet";
|
||||
touchMode?: GuacamoleTouchMode | null;
|
||||
hasFilesystem?: boolean;
|
||||
fileBrowserOpen?: boolean;
|
||||
onToggleFileBrowser?: () => void;
|
||||
onTouchModeChange?: (mode: GuacamoleTouchMode) => void;
|
||||
}
|
||||
|
||||
@@ -40,7 +44,7 @@ const MODIFIER_KEYSYMS = {
|
||||
ctrl: 0xffe3,
|
||||
alt: 0xffe9,
|
||||
shift: 0xffe1,
|
||||
win: 0xff67,
|
||||
win: 0xffeb,
|
||||
} as const;
|
||||
|
||||
const FKEY_KEYSYMS = Array.from({ length: 12 }, (_, i) => 0xffbe + i);
|
||||
@@ -115,6 +119,9 @@ export const GuacamoleToolbar: React.FC<GuacamoleToolbarProps> = ({
|
||||
displayRef,
|
||||
protocol,
|
||||
touchMode,
|
||||
hasFilesystem = false,
|
||||
fileBrowserOpen = false,
|
||||
onToggleFileBrowser,
|
||||
onTouchModeChange,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -329,6 +336,20 @@ export const GuacamoleToolbar: React.FC<GuacamoleToolbarProps> = ({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Drive files — only once guacd reports a redirected filesystem */}
|
||||
{hasFilesystem && onToggleFileBrowser && (
|
||||
<>
|
||||
<div className={SEP} />
|
||||
<TipIconBtn
|
||||
tooltip={t("guacamole.files.title")}
|
||||
onClick={onToggleFileBrowser}
|
||||
className={cn(fileBrowserOpen && "bg-muted text-foreground")}
|
||||
>
|
||||
<FolderOpen className="size-3.5" />
|
||||
</TipIconBtn>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* System combos — RDP/VNC only */}
|
||||
{isRdpVnc && (
|
||||
<>
|
||||
@@ -341,13 +362,13 @@ export const GuacamoleToolbar: React.FC<GuacamoleToolbarProps> = ({
|
||||
</TipBtn>
|
||||
<TipBtn
|
||||
tooltip={t("guacamole.toolbar.winL")}
|
||||
onClick={() => sendCombo(0xff67, 0x006c)}
|
||||
onClick={() => sendCombo(MODIFIER_KEYSYMS.win, 0x006c)}
|
||||
>
|
||||
Win+L
|
||||
</TipBtn>
|
||||
<TipBtn
|
||||
tooltip={t("guacamole.toolbar.winKey")}
|
||||
onClick={() => sendCombo(0xff67)}
|
||||
onClick={() => sendCombo(MODIFIER_KEYSYMS.win)}
|
||||
>
|
||||
Win
|
||||
</TipBtn>
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import Guacamole from "guacamole-common-js";
|
||||
|
||||
// The root stream of a Guacamole.Object maps stream name to mimetype; a stream
|
||||
// carrying that same mimetype is itself a directory.
|
||||
export const STREAM_INDEX_MIMETYPE =
|
||||
"application/vnd.glyptodon.guacamole.stream-index+json";
|
||||
|
||||
export interface RemoteFileEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
mimetype: string;
|
||||
isDirectory: boolean;
|
||||
}
|
||||
|
||||
export function isDirectoryMimetype(mimetype: string): boolean {
|
||||
return mimetype === STREAM_INDEX_MIMETYPE;
|
||||
}
|
||||
|
||||
export function joinPath(parent: string, name: string): string {
|
||||
return parent === "/" ? `/${name}` : `${parent}/${name}`;
|
||||
}
|
||||
|
||||
export function parentPath(path: string): string {
|
||||
const cut = path.lastIndexOf("/");
|
||||
return cut <= 0 ? "/" : path.slice(0, cut);
|
||||
}
|
||||
|
||||
export function basename(path: string): string {
|
||||
return path.slice(path.lastIndexOf("/") + 1) || path;
|
||||
}
|
||||
|
||||
// guacd returns absolute paths as keys, but a malformed or relative key would
|
||||
// otherwise produce a broken path on the next descent.
|
||||
export function parseDirectoryIndex(
|
||||
json: string,
|
||||
path: string,
|
||||
): RemoteFileEntry[] {
|
||||
const index = JSON.parse(json) as Record<string, string>;
|
||||
|
||||
return Object.entries(index)
|
||||
.map(([key, mimetype]) => {
|
||||
const name = basename(key);
|
||||
return {
|
||||
name,
|
||||
path: key.startsWith("/") ? key : joinPath(path, name),
|
||||
mimetype,
|
||||
isDirectory: isDirectoryMimetype(mimetype),
|
||||
};
|
||||
})
|
||||
.sort((a, b) =>
|
||||
a.isDirectory === b.isDirectory
|
||||
? a.name.localeCompare(b.name)
|
||||
: a.isDirectory
|
||||
? -1
|
||||
: 1,
|
||||
);
|
||||
}
|
||||
|
||||
// A refused stream is answered with an error ack that never reaches the body
|
||||
// callback, so every request needs its own deadline to avoid hanging forever.
|
||||
const REQUEST_TIMEOUT_MS = 15000;
|
||||
|
||||
function requestStream(
|
||||
filesystem: Guacamole.Object,
|
||||
path: string,
|
||||
): Promise<{ stream: Guacamole.InputStream; mimetype: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(
|
||||
() => reject(new Error(`Timed out reading ${path}`)),
|
||||
REQUEST_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
filesystem.requestInputStream(path, (stream, mimetype) => {
|
||||
clearTimeout(timeout);
|
||||
resolve({ stream, mimetype });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function listDirectory(
|
||||
filesystem: Guacamole.Object,
|
||||
path: string,
|
||||
): Promise<RemoteFileEntry[]> {
|
||||
const { stream, mimetype } = await requestStream(filesystem, path);
|
||||
|
||||
if (!isDirectoryMimetype(mimetype)) {
|
||||
throw new Error(`${path} is not a directory`);
|
||||
}
|
||||
|
||||
const json = await new Promise<string>((resolve) => {
|
||||
const reader = new Guacamole.StringReader(stream);
|
||||
let text = "";
|
||||
reader.ontext = (chunk: string) => {
|
||||
text += chunk;
|
||||
};
|
||||
reader.onend = () => resolve(text);
|
||||
});
|
||||
|
||||
return parseDirectoryIndex(json, path);
|
||||
}
|
||||
|
||||
export async function downloadFile(
|
||||
filesystem: Guacamole.Object,
|
||||
path: string,
|
||||
): Promise<{ blob: Blob; filename: string }> {
|
||||
const { stream, mimetype } = await requestStream(filesystem, path);
|
||||
|
||||
if (isDirectoryMimetype(mimetype)) {
|
||||
throw new Error(`${path} is a directory`);
|
||||
}
|
||||
|
||||
const blob = await new Promise<Blob>((resolve) => {
|
||||
const reader = new Guacamole.BlobReader(stream, mimetype);
|
||||
reader.onend = () => resolve(reader.getBlob());
|
||||
});
|
||||
|
||||
return { blob, filename: basename(path) };
|
||||
}
|
||||
|
||||
export function uploadFile(
|
||||
filesystem: Guacamole.Object,
|
||||
directory: string,
|
||||
file: File,
|
||||
onProgress?: (sent: number, total: number) => void,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const path = joinPath(directory, file.name);
|
||||
const stream = filesystem.createOutputStream(
|
||||
file.type || "application/octet-stream",
|
||||
path,
|
||||
);
|
||||
const writer = new Guacamole.BlobWriter(stream);
|
||||
|
||||
// A rejected blob stops the writer without firing onerror or oncomplete —
|
||||
// only the error ack reports it, so without this the upload hangs forever.
|
||||
writer.onack = (status: Guacamole.Status) => {
|
||||
if (status.isError()) {
|
||||
reject(
|
||||
new Error(
|
||||
status.message || `Server refused the upload of ${file.name}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
writer.onerror = () => reject(new Error(`Failed to upload ${file.name}`));
|
||||
writer.onprogress = (_blob: Blob, offset: number) =>
|
||||
onProgress?.(offset, file.size);
|
||||
writer.oncomplete = () => {
|
||||
writer.sendEnd();
|
||||
onProgress?.(file.size, file.size);
|
||||
resolve();
|
||||
};
|
||||
|
||||
writer.sendBlob(file);
|
||||
});
|
||||
}
|
||||
|
||||
export function saveBlobAs(blob: Blob, filename: string): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import Guacamole from "guacamole-common-js";
|
||||
|
||||
export type GuacamoleTouchMode = "touchscreen" | "touchpad";
|
||||
|
||||
/**
|
||||
* Guacamole.Mouse listens for mousedown/mousemove/mouseup; the Touchscreen and
|
||||
* Touchpad emulators listen only for touch events. The two sets do not overlap,
|
||||
* so binding one instead of the other leaves that input dead.
|
||||
*
|
||||
* A touch-capable device is not a device without a pointer: laptops with a
|
||||
* touchscreen report maxTouchPoints > 0 and are still driven by a mouse. The
|
||||
* physical pointer is therefore always bound, and a touch emulator is layered
|
||||
* on top when one is selected.
|
||||
*/
|
||||
export function bindPointerInput(
|
||||
element: HTMLElement,
|
||||
touchMode: GuacamoleTouchMode | null | undefined,
|
||||
onState: (state: Guacamole.Mouse.State) => void,
|
||||
): void {
|
||||
const mouse = new Guacamole.Mouse(element);
|
||||
mouse.onmousedown = mouse.onmouseup = mouse.onmousemove = onState;
|
||||
|
||||
if (touchMode === "touchscreen") {
|
||||
const touchscreen = new Guacamole.Mouse.Touchscreen(element);
|
||||
touchscreen.onEach(["mousedown", "mousemove", "mouseup"], (event) =>
|
||||
onState(event.state),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (touchMode === "touchpad") {
|
||||
const touchpad = new Guacamole.Mouse.Touchpad(element);
|
||||
touchpad.onEach(["mousedown", "mousemove", "mouseup"], (event) =>
|
||||
onState(event.state),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import { useCallback, useRef } from "react";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import type { CanvasWidget, ResizeState } from "@/types/homepage-types";
|
||||
import {
|
||||
GRID_SIZE,
|
||||
type CanvasWidget,
|
||||
type ResizeState,
|
||||
} from "@/types/homepage-types";
|
||||
import { snapToGrid } from "./snapToGrid";
|
||||
import { getWidgetType } from "../widgets/WidgetRegistry";
|
||||
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "@/components/input";
|
||||
import type {
|
||||
DockerActivityConfig,
|
||||
WidgetEditFormProps,
|
||||
} from "@/types/homepage-types";
|
||||
|
||||
export function DockerActivityEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<DockerActivityConfig>) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.maxItems")}
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={50}
|
||||
value={config.maxItems}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...config,
|
||||
maxItems: Math.max(1, Number(e.target.value)),
|
||||
})
|
||||
}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.showHostName}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, showHostName: e.target.checked })
|
||||
}
|
||||
className="accent-accent-brand"
|
||||
/>
|
||||
{t("homepage.showHostName")}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type {
|
||||
SshQuickConnectConfig,
|
||||
WidgetEditFormProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { getSSHHosts } from "@/api/ssh-host-management-api";
|
||||
|
||||
export function SshQuickConnectEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<SshQuickConnectConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const [hosts, setHosts] = useState<{ id: number; name: string }[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
getSSHHosts()
|
||||
.then((h) => setHosts(h.map((x) => ({ id: x.id, name: x.name }))))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const toggleHost = (id: number) => {
|
||||
const next = config.hostIds.includes(id)
|
||||
? config.hostIds.filter((x) => x !== id)
|
||||
: [...config.hostIds, id];
|
||||
onChange({ ...config, hostIds: next });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.host")}
|
||||
</label>
|
||||
<div className="flex flex-col gap-0.5 max-h-40 overflow-y-auto border border-border/40 p-1">
|
||||
{hosts.map((h) => (
|
||||
<label
|
||||
key={h.id}
|
||||
className="flex items-center gap-2 text-[10px] cursor-pointer px-1 py-0.5 hover:bg-muted/40"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.hostIds.includes(h.id)}
|
||||
onChange={() => toggleHost(h.id)}
|
||||
className="accent-accent-brand"
|
||||
/>
|
||||
{h.name}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-[9px] text-muted-foreground">
|
||||
{t("homepage.hostGridAllHint")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.connectionType")}
|
||||
</label>
|
||||
<div className="flex gap-1">
|
||||
{(["terminal", "files", "docker"] as const).map((ct) => (
|
||||
<button
|
||||
key={ct}
|
||||
type="button"
|
||||
onClick={() => onChange({ ...config, connectionType: ct })}
|
||||
className={`px-2 py-0.5 text-[10px] border transition-colors capitalize ${config.connectionType === ct ? "bg-accent-brand border-accent-brand text-white" : "border-border text-muted-foreground"}`}
|
||||
>
|
||||
{ct}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.layout")}
|
||||
</label>
|
||||
<div className="flex gap-1">
|
||||
{(["list", "grid"] as const).map((l) => (
|
||||
<button
|
||||
key={l}
|
||||
type="button"
|
||||
onClick={() => onChange({ ...config, layout: l })}
|
||||
className={`px-2 py-0.5 text-[10px] border transition-colors capitalize ${config.layout === l ? "bg-accent-brand border-accent-brand text-white" : "border-border text-muted-foreground"}`}
|
||||
>
|
||||
{l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.showStatus}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, showStatus: e.target.checked })
|
||||
}
|
||||
className="accent-accent-brand"
|
||||
/>
|
||||
{t("homepage.showStatus")}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -110,224 +110,224 @@ export function WidgetEditDialog({
|
||||
case "service_link":
|
||||
return (
|
||||
<ServiceLinkEditForm
|
||||
config={config as ServiceLinkConfig}
|
||||
config={config as unknown as ServiceLinkConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "folder":
|
||||
return (
|
||||
<FolderEditForm
|
||||
config={config as FolderConfig}
|
||||
config={config as unknown as FolderConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "notes":
|
||||
return (
|
||||
<NotesEditForm
|
||||
config={config as NotesConfig}
|
||||
config={config as unknown as NotesConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "bookmark_list":
|
||||
return (
|
||||
<BookmarkListEditForm
|
||||
config={config as BookmarkListConfig}
|
||||
config={config as unknown as BookmarkListConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "clock":
|
||||
return (
|
||||
<ClockEditForm
|
||||
config={config as ClockConfig}
|
||||
config={config as unknown as ClockConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "host_status":
|
||||
return (
|
||||
<HostStatusEditForm
|
||||
config={config as HostStatusConfig}
|
||||
config={config as unknown as HostStatusConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "weather":
|
||||
return (
|
||||
<WeatherEditForm
|
||||
config={config as WeatherConfig}
|
||||
config={config as unknown as WeatherConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "iframe_embed":
|
||||
return (
|
||||
<IframeEditForm
|
||||
config={config as IframeConfig}
|
||||
config={config as unknown as IframeConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "rss_feed":
|
||||
return (
|
||||
<RssFeedEditForm
|
||||
config={config as RssFeedConfig}
|
||||
config={config as unknown as RssFeedConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "metrics_chart":
|
||||
return (
|
||||
<MetricsChartEditForm
|
||||
config={config as MetricsChartConfig}
|
||||
config={config as unknown as MetricsChartConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "host_grid":
|
||||
return (
|
||||
<HostGridEditForm
|
||||
config={config as HostGridConfig}
|
||||
config={config as unknown as HostGridConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "alert_feed":
|
||||
return (
|
||||
<AlertFeedEditForm
|
||||
config={config as AlertFeedConfig}
|
||||
config={config as unknown as AlertFeedConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "ping_status":
|
||||
return (
|
||||
<PingStatusEditForm
|
||||
config={config as PingStatusConfig}
|
||||
config={config as unknown as PingStatusConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "recent_activity":
|
||||
return (
|
||||
<RecentActivityEditForm
|
||||
config={config as RecentActivityConfig}
|
||||
config={config as unknown as RecentActivityConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "termix_uptime":
|
||||
return (
|
||||
<TermixUptimeEditForm
|
||||
config={config as TermixUptimeConfig}
|
||||
config={config as unknown as TermixUptimeConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "system_overview":
|
||||
return (
|
||||
<SystemOverviewEditForm
|
||||
config={config as SystemOverviewConfig}
|
||||
config={config as unknown as SystemOverviewConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "ssh_terminal":
|
||||
return (
|
||||
<SshTerminalEditForm
|
||||
config={config as SshTerminalConfig}
|
||||
config={config as unknown as SshTerminalConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "quick_connect":
|
||||
return (
|
||||
<QuickConnectEditForm
|
||||
config={config as QuickConnectConfig}
|
||||
config={config as unknown as QuickConnectConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "file_manager_widget":
|
||||
return (
|
||||
<FileManagerWidgetEditForm
|
||||
config={config as FileManagerWidgetConfig}
|
||||
config={config as unknown as FileManagerWidgetConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "docker_widget":
|
||||
return (
|
||||
<DockerWidgetEditForm
|
||||
config={config as DockerWidgetConfig}
|
||||
config={config as unknown as DockerWidgetConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "tunnel_widget":
|
||||
return (
|
||||
<TunnelWidgetEditForm
|
||||
config={config as TunnelWidgetConfig}
|
||||
config={config as unknown as TunnelWidgetConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "calendar":
|
||||
return (
|
||||
<CalendarEditForm
|
||||
config={config as CalendarConfig}
|
||||
config={config as unknown as CalendarConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "countdown":
|
||||
return (
|
||||
<CountdownEditForm
|
||||
config={config as CountdownConfig}
|
||||
config={config as unknown as CountdownConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "search_bar":
|
||||
return (
|
||||
<SearchBarEditForm
|
||||
config={config as SearchBarConfig}
|
||||
config={config as unknown as SearchBarConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "text_banner":
|
||||
return (
|
||||
<TextBannerEditForm
|
||||
config={config as TextBannerConfig}
|
||||
config={config as unknown as TextBannerConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "image_widget":
|
||||
return (
|
||||
<ImageWidgetEditForm
|
||||
config={config as ImageWidgetConfig}
|
||||
config={config as unknown as ImageWidgetConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "markdown_notes":
|
||||
return (
|
||||
<MarkdownNotesEditForm
|
||||
config={config as MarkdownNotesConfig}
|
||||
config={config as unknown as MarkdownNotesConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "custom_api":
|
||||
return (
|
||||
<CustomApiEditForm
|
||||
config={config as CustomApiConfig}
|
||||
config={config as unknown as CustomApiConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "service_grid":
|
||||
return (
|
||||
<ServiceGridEditForm
|
||||
config={config as ServiceGridConfig}
|
||||
config={config as unknown as ServiceGridConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "dashboard_links":
|
||||
return (
|
||||
<DashboardLinksEditForm
|
||||
config={config as DashboardLinksConfig}
|
||||
config={config as unknown as DashboardLinksConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "search_links":
|
||||
return (
|
||||
<SearchLinksEditForm
|
||||
config={config as SearchLinksConfig}
|
||||
config={config as unknown as SearchLinksConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "link_tree":
|
||||
return (
|
||||
<LinkTreeEditForm
|
||||
config={config as LinkTreeConfig}
|
||||
config={config as unknown as LinkTreeConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -14,8 +14,11 @@ import type {
|
||||
WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import { getAlertFirings, acknowledgeAlertFiring } from "@/api/alerts-api";
|
||||
import type { AlertFiring } from "@/api/alerts-api";
|
||||
import {
|
||||
acknowledgeAlertFiring,
|
||||
getAlertFirings,
|
||||
type AlertFiring,
|
||||
} from "@/api/alerts-api";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
|
||||
function relativeTime(ts: string): string {
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { useState } from "react";
|
||||
import { Clock } from "lucide-react";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type { ClockConfig, WidgetComponentProps } from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import {
|
||||
GRID_SIZE,
|
||||
type ClockConfig,
|
||||
type WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
import { usePageVisibleInterval } from "@/hooks/use-page-visible-interval";
|
||||
|
||||
|
||||
@@ -7,8 +7,7 @@ import type {
|
||||
WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import { getServiceLinks } from "@/api/dashboard-api";
|
||||
import type { ServiceLink } from "@/api/dashboard-api";
|
||||
import { getServiceLinks, type ServiceLink } from "@/api/dashboard-api";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
|
||||
function getFaviconUrl(url: string): string {
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Container } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
DockerActivityConfig,
|
||||
WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import { getRecentActivity } from "@/api/dashboard-api";
|
||||
import type { RecentActivityItem } from "@/api/dashboard-api";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
import { runVisibleInterval } from "../use-visible-interval";
|
||||
|
||||
function relativeTime(ts: string): string {
|
||||
const diff = Date.now() - new Date(ts).getTime();
|
||||
const m = Math.floor(diff / 60_000);
|
||||
if (m < 1) return "just now";
|
||||
if (m < 60) return `${m}m ago`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h}h ago`;
|
||||
return `${Math.floor(h / 24)}d ago`;
|
||||
}
|
||||
|
||||
function DockerActivityWidget({
|
||||
widget,
|
||||
config,
|
||||
}: WidgetComponentProps<DockerActivityConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const { maxItems, showHostName } = config;
|
||||
const [items, setItems] = useState<RecentActivityItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const data = await getRecentActivity(maxItems * 5);
|
||||
setItems(data.filter((i) => i.type === "docker").slice(0, maxItems));
|
||||
} catch {
|
||||
/* ignore */
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
return runVisibleInterval(() => {
|
||||
void fetchData();
|
||||
}, 30_000);
|
||||
}, [maxItems]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
|
||||
{t("homepage.loading")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
|
||||
{t("homepage.noDockerActivity")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
<WidgetTitle
|
||||
title={widget.title || t("homepage.widgetDockerActivityName")}
|
||||
icon={<Container size={11} />}
|
||||
/>
|
||||
<div className="flex-1 overflow-auto">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center gap-2 px-2 py-1.5 border-b border-border/30"
|
||||
>
|
||||
<Container size={10} className="shrink-0 text-muted-foreground" />
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
{showHostName && (
|
||||
<span className="text-[10px] font-medium text-foreground truncate">
|
||||
{item.hostName}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[9px] text-muted-foreground">
|
||||
{relativeTime(item.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<DockerActivityConfig>({
|
||||
id: "docker_activity",
|
||||
name: "Docker Activity",
|
||||
description: "Recent Docker connection activity across your hosts",
|
||||
category: "system",
|
||||
icon: <Container size={14} />,
|
||||
defaultConfig: { maxItems: 10, showHostName: true },
|
||||
defaultSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 8 },
|
||||
minSize: { w: GRID_SIZE * 4, h: GRID_SIZE * 4 },
|
||||
component: DockerActivityWidget,
|
||||
});
|
||||
|
||||
export { DockerActivityWidget };
|
||||
@@ -24,9 +24,11 @@ function StatusDot({ status }: { status: string }) {
|
||||
const color =
|
||||
status === "online"
|
||||
? getAccentColor()
|
||||
: status === "offline"
|
||||
? "#ef4444"
|
||||
: "#6b7280";
|
||||
: status === "reachable"
|
||||
? "#fbbf24"
|
||||
: status === "offline"
|
||||
? "#ef4444"
|
||||
: "#6b7280";
|
||||
return (
|
||||
<span
|
||||
className="w-1.5 h-1.5 rounded-full shrink-0 inline-block"
|
||||
|
||||
@@ -93,6 +93,7 @@ function HostStatusWidget({
|
||||
const { hostId } = config;
|
||||
const [hostName, setHostName] = useState<string | null>(null);
|
||||
const [online, setOnline] = useState<boolean | null>(null);
|
||||
const [reachable, setReachable] = useState(false);
|
||||
const [metrics, setMetrics] = useState<ServerMetrics | null>(null);
|
||||
|
||||
const needsMetrics = shownMetrics.length > 0;
|
||||
@@ -114,9 +115,15 @@ function HostStatusWidget({
|
||||
const poll = async () => {
|
||||
try {
|
||||
const s = await getServerStatusById(hostId);
|
||||
if (!cancelled) setOnline(s.status === "online");
|
||||
if (!cancelled) {
|
||||
setOnline(s.status === "online");
|
||||
setReachable(s.status === "reachable");
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setOnline(false);
|
||||
if (!cancelled) {
|
||||
setOnline(false);
|
||||
setReachable(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (needsMetrics) {
|
||||
@@ -173,13 +180,21 @@ function HostStatusWidget({
|
||||
}
|
||||
|
||||
const onlineColor =
|
||||
online === null ? "#6b7280" : online ? getAccentColor() : "#ef4444";
|
||||
online === null
|
||||
? "#6b7280"
|
||||
: online
|
||||
? getAccentColor()
|
||||
: reachable
|
||||
? "#fbbf24"
|
||||
: "#ef4444";
|
||||
const onlineLabel =
|
||||
online === null
|
||||
? t("common.unknown")
|
||||
: online
|
||||
? t("common.online")
|
||||
: t("common.offline");
|
||||
: reachable
|
||||
? t("common.reachable", { defaultValue: "Reachable" })
|
||||
: t("common.offline");
|
||||
|
||||
const networkIfaces = metrics?.network?.interfaces ?? [];
|
||||
const totalRx = networkIfaces.reduce((sum, iface) => {
|
||||
|
||||
@@ -8,9 +8,11 @@ import type {
|
||||
WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import { getMetricsHistory } from "@/api/host-metrics-api";
|
||||
import {
|
||||
getMetricsHistory,
|
||||
type MetricsHistoryRow,
|
||||
} from "@/api/host-metrics-api";
|
||||
import { getSSHHosts } from "@/api/ssh-host-management-api";
|
||||
import type { MetricsHistoryRow } from "@/api/host-metrics-api";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
import { runVisibleInterval } from "../use-visible-interval";
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { StickyNote } from "lucide-react";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type { NotesConfig, WidgetComponentProps } from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import {
|
||||
GRID_SIZE,
|
||||
type NotesConfig,
|
||||
type WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
|
||||
function NotesWidget({ widget, config }: WidgetComponentProps<NotesConfig>) {
|
||||
|
||||
@@ -15,8 +15,10 @@ import type {
|
||||
WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import { getRecentActivity } from "@/api/dashboard-api";
|
||||
import type { RecentActivityItem } from "@/api/dashboard-api";
|
||||
import {
|
||||
getRecentActivity,
|
||||
type RecentActivityItem,
|
||||
} from "@/api/dashboard-api";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
import { runVisibleInterval } from "../use-visible-interval";
|
||||
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { Zap, Terminal, FolderOpen, Container } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
SshQuickConnectConfig,
|
||||
WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import { getSSHHosts } from "@/api/ssh-host-management-api";
|
||||
import type { SSHHostWithStatus } from "@/main-axios";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
import { usePageVisibleInterval } from "@/hooks/use-page-visible-interval";
|
||||
|
||||
function getAccentColor(): string {
|
||||
return (
|
||||
getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--accent-brand")
|
||||
.trim() || "#f59145"
|
||||
);
|
||||
}
|
||||
|
||||
function TypeIcon({ type }: { type: string }) {
|
||||
if (type === "files") return <FolderOpen size={10} />;
|
||||
if (type === "docker") return <Container size={10} />;
|
||||
return <Terminal size={10} />;
|
||||
}
|
||||
|
||||
function openTab(host: SSHHostWithStatus, connectionType: string) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("termix:open-tab", {
|
||||
detail: {
|
||||
type:
|
||||
connectionType === "files"
|
||||
? "files"
|
||||
: connectionType === "docker"
|
||||
? "docker"
|
||||
: "terminal",
|
||||
host,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function SshQuickConnectWidget({
|
||||
widget,
|
||||
config,
|
||||
}: WidgetComponentProps<SshQuickConnectConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const { hostIds, connectionType, showStatus, layout } = config;
|
||||
const [hosts, setHosts] = useState<SSHHostWithStatus[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
const allHosts = await getSSHHosts();
|
||||
const filtered =
|
||||
hostIds.length > 0
|
||||
? allHosts.filter((h) => hostIds.includes(h.id))
|
||||
: allHosts;
|
||||
setHosts(filtered);
|
||||
} catch {
|
||||
/* ignore */
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [hostIds.join(",")]);
|
||||
|
||||
usePageVisibleInterval(() => {
|
||||
void fetchData();
|
||||
}, 30_000);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
|
||||
{t("homepage.loading")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (hosts.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
|
||||
{t("homepage.noHosts")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const accent = getAccentColor();
|
||||
const isGrid = layout === "grid";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
<WidgetTitle title={widget.title} icon={<Zap size={11} />} />
|
||||
<div
|
||||
className={`${isGrid ? "grid grid-cols-2 content-start" : "flex flex-col"} gap-1.5 p-2 flex-1 overflow-auto`}
|
||||
>
|
||||
{hosts.map((host) => {
|
||||
const online = host.status === "online";
|
||||
const statusColor =
|
||||
host.status === "online"
|
||||
? accent
|
||||
: host.status === "offline"
|
||||
? "#ef4444"
|
||||
: "#6b7280";
|
||||
return (
|
||||
<button
|
||||
key={host.id}
|
||||
onClick={() => openTab(host, connectionType)}
|
||||
className="flex items-center gap-1.5 p-2 bg-muted/30 border border-border/40 hover:border-accent-brand/50 hover:bg-muted/60 transition-colors text-left overflow-hidden"
|
||||
>
|
||||
<TypeIcon type={connectionType} />
|
||||
<span className="text-[10px] font-medium text-foreground truncate flex-1">
|
||||
{host.name}
|
||||
</span>
|
||||
{showStatus && (
|
||||
<span
|
||||
className="w-1.5 h-1.5 rounded-full shrink-0"
|
||||
style={{ background: statusColor }}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<SshQuickConnectConfig>({
|
||||
id: "ssh_quick_connect",
|
||||
name: "Quick Connect",
|
||||
description: "One-click buttons to open SSH, file, or Docker tabs",
|
||||
category: "system",
|
||||
icon: <Zap size={14} />,
|
||||
defaultConfig: {
|
||||
hostIds: [],
|
||||
connectionType: "terminal",
|
||||
showStatus: true,
|
||||
layout: "list",
|
||||
},
|
||||
defaultSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 7 },
|
||||
minSize: { w: GRID_SIZE * 4, h: GRID_SIZE * 3 },
|
||||
component: SshQuickConnectWidget,
|
||||
});
|
||||
|
||||
export { SshQuickConnectWidget };
|
||||
@@ -8,8 +8,7 @@ import type {
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import { CommandHistoryProvider } from "@/features/terminal/command-history/CommandHistoryContext";
|
||||
import { Terminal } from "@/features/terminal/Terminal";
|
||||
import type { TerminalHandle } from "@/features/terminal/Terminal";
|
||||
import { Terminal, type TerminalHandle } from "@/features/terminal/Terminal";
|
||||
import { getSSHHosts } from "@/api/ssh-host-management-api";
|
||||
import type { SSHHostWithStatus } from "@/main-axios";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
|
||||
@@ -47,9 +47,14 @@ function SystemOverviewWidget({
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
if (showVersion) {
|
||||
const info = await getVersionInfo(false);
|
||||
// `checkRemote=false` makes /version return early with just
|
||||
// {localVersion, status: "update_check_disabled"}, and the response has
|
||||
// no `updateAvailable` field in either mode -- so the previous read was
|
||||
// false twice over. `status` is what the endpoint actually answers with,
|
||||
// and what the dashboard and profile badges already read.
|
||||
const info = await getVersionInfo();
|
||||
setVersion((info?.localVersion as string) ?? null);
|
||||
setUpdateAvailable(Boolean(info?.updateAvailable));
|
||||
setUpdateAvailable(info?.status === "requires_update");
|
||||
}
|
||||
if (showDbHealth) {
|
||||
const health = await getDatabaseHealth();
|
||||
@@ -93,7 +98,7 @@ function SystemOverviewWidget({
|
||||
)}
|
||||
{showVersion && updateAvailable && (
|
||||
<InfoRow
|
||||
label={t("homepage.overviewUpdate")}
|
||||
label={t("homepage.overviewUpdateLabel")}
|
||||
value={t("homepage.overviewUpdateAvailable")}
|
||||
valueColor="#f97316"
|
||||
/>
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { HostMetricsTab } from "@/features/host-metrics/HostMetricsTab.tsx";
|
||||
import { FullScreenAppWrapper } from "@/features/FullScreenAppWrapper.tsx";
|
||||
import { ConnectionScreen } from "@/components/connection/ConnectionScreen.tsx";
|
||||
|
||||
interface HostMetricsAppProps {
|
||||
hostId?: string;
|
||||
@@ -11,26 +12,25 @@ const HostMetricsApp: React.FC<HostMetricsAppProps> = ({ hostId }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<FullScreenAppWrapper hostId={hostId}>
|
||||
{(hostConfig, loading) => {
|
||||
if (loading) {
|
||||
{(hostConfig, phase) => {
|
||||
if (phase === "loading") {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="mx-auto mb-2 h-8 w-8 animate-spin rounded-full border-b-2 border-white"></div>
|
||||
<p className="text-muted-foreground">
|
||||
{t("hosts.loadingHost")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="relative h-full w-full">
|
||||
<ConnectionScreen
|
||||
status="connecting"
|
||||
message={t("hosts.loadingHost")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hostConfig) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="mb-4 text-red-500">{t("hosts.hostNotFound")}</p>
|
||||
</div>
|
||||
<div className="relative h-full w-full">
|
||||
<ConnectionScreen
|
||||
status="disconnected"
|
||||
message={t("hosts.hostNotFound")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getErrorMessage } from "../../lib/error-message.js";
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import React from "react";
|
||||
import { Separator } from "@/components/separator.tsx";
|
||||
@@ -9,11 +10,17 @@ import {
|
||||
stopMetricsPolling,
|
||||
submitMetricsTOTP,
|
||||
executeSnippet,
|
||||
getSnippets,
|
||||
logActivity,
|
||||
sendMetricsHeartbeat,
|
||||
getSSHHosts,
|
||||
type ServerMetrics,
|
||||
} from "@/main-axios.ts";
|
||||
import { SnippetVariablesDialog } from "@/components/SnippetVariablesDialog";
|
||||
import { useAreaPreferences } from "@/contexts/UiPreferencesContext";
|
||||
import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
||||
import { hasSnippetInputs } from "@/lib/snippet-variables.ts";
|
||||
import type { Snippet } from "@/types/ui-types.ts";
|
||||
import { TOTPDialog } from "@/ssh/dialogs/TOTPDialog.tsx";
|
||||
import { useTabsSafe } from "@/shell/TabContext.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -26,13 +33,14 @@ import {
|
||||
defaultLayoutFromWidgets,
|
||||
type HostMetricsLayout,
|
||||
} from "@/types/host-metrics.ts";
|
||||
import { SimpleLoader } from "@/lib/SimpleLoader.tsx";
|
||||
import { RefreshCw, Server, LayoutDashboard } from "lucide-react";
|
||||
import {
|
||||
ConnectionLogProvider,
|
||||
useConnectionLog,
|
||||
} from "@/ssh/connection-log/ConnectionLogContext.tsx";
|
||||
import { ConnectionLog } from "@/ssh/connection-log/ConnectionLog.tsx";
|
||||
import { ConnectionScreen } from "@/components/connection/ConnectionScreen.tsx";
|
||||
import { useConnectionRetry } from "@/lib/useConnectionRetry.ts";
|
||||
import { runAdaptivePolling } from "@/lib/adaptive-polling.ts";
|
||||
import type { LogEntry } from "@/types/connection-log.ts";
|
||||
import {
|
||||
CardGridCanvas,
|
||||
@@ -51,6 +59,19 @@ import {
|
||||
|
||||
const HISTORY_LEN = 30;
|
||||
|
||||
function metricsChangeKey(data: ServerMetrics): string {
|
||||
const bucket = (value: number | null | undefined) =>
|
||||
value == null ? null : Math.round(value / 5) * 5;
|
||||
return JSON.stringify({
|
||||
cpu: bucket(data.cpu.percent),
|
||||
memory: bucket(data.memory.percent),
|
||||
disk: bucket(data.disk.percent),
|
||||
running: data.processes?.running ?? null,
|
||||
ports: data.ports?.ports?.length ?? 0,
|
||||
firewall: data.firewall?.status ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
interface QuickAction {
|
||||
name: string;
|
||||
snippetId: number;
|
||||
@@ -99,11 +120,7 @@ function HostMetricsInner({
|
||||
embedded = false,
|
||||
}: HostMetricsProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
addLog,
|
||||
clearLogs,
|
||||
isExpanded: isConnectionLogExpanded,
|
||||
} = useConnectionLog();
|
||||
const { addLog, clearLogs } = useConnectionLog();
|
||||
const { currentTab, removeTab } = useTabsSafe();
|
||||
|
||||
const [serverStatus, setServerStatus] = React.useState<"online" | "offline">(
|
||||
@@ -116,7 +133,6 @@ function HostMetricsInner({
|
||||
disk: [],
|
||||
});
|
||||
const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig);
|
||||
const [isLoadingMetrics, setIsLoadingMetrics] = React.useState(false);
|
||||
const [isRefreshing, setIsRefreshing] = React.useState(false);
|
||||
const [executingActions, setExecutingActions] = React.useState<Set<number>>(
|
||||
new Set(),
|
||||
@@ -129,11 +145,16 @@ function HostMetricsInner({
|
||||
const [viewerSessionId, setViewerSessionId] = React.useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [hasConnectionError, setHasConnectionError] = React.useState(false);
|
||||
const [editMode, setEditMode] = React.useState(false);
|
||||
const [runningAction, setRunningAction] = React.useState<{
|
||||
action: QuickAction;
|
||||
snippet: Snippet;
|
||||
} | null>(null);
|
||||
const { confirmWithToast } = useConfirmation();
|
||||
|
||||
const activityLoggedRef = React.useRef(false);
|
||||
const activityLoggingRef = React.useRef(false);
|
||||
const snippetsCacheRef = React.useRef<Snippet[] | null>(null);
|
||||
|
||||
const statsConfig = React.useMemo(
|
||||
() => parseStatsConfig(currentHostConfig?.statsConfig),
|
||||
@@ -145,10 +166,17 @@ function HostMetricsInner({
|
||||
const hostId = currentHostConfig?.id ?? null;
|
||||
const { layout, setLayout } = useHostMetricsPreferences(hostId);
|
||||
|
||||
const metricsPrefs = useAreaPreferences("hostMetrics");
|
||||
|
||||
const effectiveLayout: HostMetricsLayout = React.useMemo(() => {
|
||||
// A saved layout is user-authored, so the preset never rewrites it -- it
|
||||
// only decides the shape of the first layout a host gets.
|
||||
if (layout) return layout;
|
||||
return defaultLayoutFromWidgets(statsConfig.enabledWidgets ?? []);
|
||||
}, [layout, statsConfig.enabledWidgets]);
|
||||
return defaultLayoutFromWidgets(
|
||||
statsConfig.enabledWidgets ?? [],
|
||||
metricsPrefs.columns,
|
||||
);
|
||||
}, [layout, statsConfig.enabledWidgets, metricsPrefs.columns]);
|
||||
|
||||
// Only render/keep cards that are implemented (metric cards in Phase A).
|
||||
const visibleSlots = React.useMemo(
|
||||
@@ -291,24 +319,31 @@ function HostMetricsInner({
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
let lastStatus = serverStatus;
|
||||
const fetchStatus = async () => {
|
||||
try {
|
||||
const res = await getServerStatusById(currentHostConfig.id);
|
||||
const nextStatus = res?.status === "online" ? "online" : "offline";
|
||||
if (!cancelled) {
|
||||
setServerStatus(res?.status === "online" ? "online" : "offline");
|
||||
setServerStatus(nextStatus);
|
||||
}
|
||||
const changed = nextStatus !== lastStatus;
|
||||
lastStatus = nextStatus;
|
||||
return changed;
|
||||
} catch {
|
||||
if (!cancelled) setServerStatus("offline");
|
||||
throw new Error("Host status check failed");
|
||||
}
|
||||
};
|
||||
fetchStatus();
|
||||
const id = window.setInterval(
|
||||
fetchStatus,
|
||||
statsConfig.statusCheckInterval * 1000,
|
||||
);
|
||||
const minIntervalMs = statsConfig.statusCheckInterval * 1000;
|
||||
const stop = runAdaptivePolling(fetchStatus, {
|
||||
minIntervalMs,
|
||||
maxIntervalMs: Math.min(120_000, minIntervalMs * 6),
|
||||
stablePollsPerStep: 3,
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(id);
|
||||
stop();
|
||||
};
|
||||
}, [
|
||||
currentHostConfig?.id,
|
||||
@@ -317,98 +352,89 @@ function HostMetricsInner({
|
||||
isActuallyVisible,
|
||||
]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!metricsEnabled || !currentHostConfig?.id) return;
|
||||
const stopMetricsPollingRef = React.useRef<(() => void) | null>(null);
|
||||
|
||||
let cancelled = false;
|
||||
let pollingId: number | undefined;
|
||||
if (isActuallyVisible && !metrics) setIsLoadingMetrics(true);
|
||||
else if (!isActuallyVisible) setIsLoadingMetrics(false);
|
||||
const fetchMetrics = React.useCallback(async (): Promise<void> => {
|
||||
if (!currentHostConfig?.id) return;
|
||||
if (currentHostConfig.authType === "none") {
|
||||
toast.error(t("hostMetrics.noneAuthNotSupported"));
|
||||
if (currentTab !== null) removeTab(currentTab);
|
||||
throw new Error(t("hostMetrics.noneAuthNotSupported"));
|
||||
}
|
||||
|
||||
const startMetrics = async () => {
|
||||
if (cancelled) return;
|
||||
if (currentHostConfig.authType === "none") {
|
||||
toast.error(t("hostMetrics.noneAuthNotSupported"));
|
||||
setIsLoadingMetrics(false);
|
||||
if (currentTab !== null) removeTab(currentTab);
|
||||
if (!totpVerified) {
|
||||
addLog({
|
||||
type: "info",
|
||||
stage: "stats_connecting",
|
||||
message: `Connecting to ${currentHostConfig.username}@${currentHostConfig.ip}:${currentHostConfig.port}`,
|
||||
});
|
||||
const result = await startMetricsPolling(currentHostConfig.id);
|
||||
result?.connectionLogs?.forEach((log) =>
|
||||
addLog(log as ConnectionLogPayload),
|
||||
);
|
||||
if (result.requires_totp) {
|
||||
setTotpRequired(true);
|
||||
setTotpSessionId(result.sessionId || null);
|
||||
setTotpPrompt(result.prompt || "Verification code");
|
||||
// The TOTP dialog gates further progress; retry loop pauses via `enabled`.
|
||||
return;
|
||||
}
|
||||
if (result.viewerSessionId) setViewerSessionId(result.viewerSessionId);
|
||||
}
|
||||
|
||||
const hadMetrics = metrics !== null;
|
||||
if (!hadMetrics) setIsLoadingMetrics(true);
|
||||
setHasConnectionError(false);
|
||||
clearLogs();
|
||||
const data = await getServerMetricsById(currentHostConfig.id);
|
||||
if (!data) {
|
||||
throw new Error(t("hostMetrics.connectionFailed"));
|
||||
}
|
||||
|
||||
setMetrics(data);
|
||||
setServerStatus("online");
|
||||
logServerActivity();
|
||||
addLog({
|
||||
type: "success",
|
||||
stage: "connected",
|
||||
message: t("terminal.connected"),
|
||||
});
|
||||
|
||||
let signature = metricsChangeKey(data);
|
||||
const minIntervalMs = statsConfig.metricsInterval * 1000;
|
||||
stopMetricsPollingRef.current?.();
|
||||
stopMetricsPollingRef.current = runAdaptivePolling(
|
||||
async () => {
|
||||
const next = await getServerMetricsById(currentHostConfig.id);
|
||||
if (!next) throw new Error(t("hostMetrics.connectionFailed"));
|
||||
const nextSignature = metricsChangeKey(next);
|
||||
const changed = nextSignature !== signature;
|
||||
signature = nextSignature;
|
||||
setMetrics(next);
|
||||
pushHistory(next);
|
||||
return changed;
|
||||
},
|
||||
{
|
||||
minIntervalMs,
|
||||
maxIntervalMs: Math.min(120_000, minIntervalMs * 6),
|
||||
stablePollsPerStep: 3,
|
||||
},
|
||||
{ runImmediately: false },
|
||||
);
|
||||
}, [
|
||||
currentHostConfig,
|
||||
totpVerified,
|
||||
statsConfig.metricsInterval,
|
||||
addLog,
|
||||
t,
|
||||
currentTab,
|
||||
removeTab,
|
||||
pushHistory,
|
||||
]);
|
||||
|
||||
const metricsRetry = useConnectionRetry({
|
||||
connect: async () => {
|
||||
try {
|
||||
if (!totpVerified) {
|
||||
addLog({
|
||||
type: "info",
|
||||
stage: "stats_connecting",
|
||||
message: `Connecting to ${currentHostConfig.username}@${currentHostConfig.ip}:${currentHostConfig.port}`,
|
||||
});
|
||||
const result = await startMetricsPolling(currentHostConfig.id);
|
||||
if (cancelled) return;
|
||||
result?.connectionLogs?.forEach((log) =>
|
||||
addLog(log as ConnectionLogPayload),
|
||||
);
|
||||
if (result.requires_totp) {
|
||||
setTotpRequired(true);
|
||||
setTotpSessionId(result.sessionId || null);
|
||||
setTotpPrompt(result.prompt || "Verification code");
|
||||
setIsLoadingMetrics(false);
|
||||
return;
|
||||
}
|
||||
if (result.viewerSessionId)
|
||||
setViewerSessionId(result.viewerSessionId);
|
||||
}
|
||||
|
||||
// Poll until first metrics arrive. The backend's initial SSH collection
|
||||
// can take up to ~60s (queue timeout), so we wait up to 90s total.
|
||||
let retry = 0;
|
||||
let data: ServerMetrics | null = null;
|
||||
const maxRetries = 30;
|
||||
while (retry < maxRetries && !cancelled) {
|
||||
try {
|
||||
data = await getServerMetricsById(currentHostConfig.id);
|
||||
if (data) break;
|
||||
} catch {
|
||||
// non-404 error — keep retrying
|
||||
}
|
||||
retry++;
|
||||
if (retry < maxRetries && !cancelled) {
|
||||
await new Promise((r) => setTimeout(r, 3000));
|
||||
}
|
||||
}
|
||||
if (cancelled) return;
|
||||
|
||||
if (data) {
|
||||
setMetrics(data);
|
||||
setServerStatus("online");
|
||||
if (!hadMetrics) {
|
||||
setIsLoadingMetrics(false);
|
||||
logServerActivity();
|
||||
}
|
||||
} else {
|
||||
throw new Error(t("hostMetrics.connectionFailed"));
|
||||
}
|
||||
|
||||
pollingId = window.setInterval(async () => {
|
||||
if (cancelled) return;
|
||||
try {
|
||||
const next = await getServerMetricsById(currentHostConfig.id);
|
||||
if (!cancelled && next) {
|
||||
setMetrics(next);
|
||||
pushHistory(next);
|
||||
}
|
||||
} catch {
|
||||
/* keep prior */
|
||||
}
|
||||
}, statsConfig.metricsInterval * 1000);
|
||||
await fetchMetrics();
|
||||
if (!totpRequired) metricsRetry.markConnected();
|
||||
} catch (error: unknown) {
|
||||
if (cancelled) return;
|
||||
const logError = error as ConnectionLogError;
|
||||
setIsLoadingMetrics(false);
|
||||
setHasConnectionError(true);
|
||||
if (logError.connectionLogs) {
|
||||
logError.connectionLogs.forEach((log) => addLog(log));
|
||||
} else {
|
||||
@@ -421,14 +447,28 @@ function HostMetricsInner({
|
||||
: t("hostMetrics.connectionFailed"),
|
||||
});
|
||||
}
|
||||
metricsRetry.markFailed();
|
||||
}
|
||||
};
|
||||
},
|
||||
enabled:
|
||||
isActuallyVisible &&
|
||||
metricsEnabled &&
|
||||
!totpRequired &&
|
||||
!!currentHostConfig?.id,
|
||||
autoStart: false,
|
||||
});
|
||||
|
||||
const metricsRetryRef = React.useRef(metricsRetry);
|
||||
metricsRetryRef.current = metricsRetry;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!metricsEnabled || !currentHostConfig?.id) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const stopMetrics = async () => {
|
||||
if (pollingId) {
|
||||
window.clearInterval(pollingId);
|
||||
pollingId = undefined;
|
||||
}
|
||||
stopMetricsPollingRef.current?.();
|
||||
stopMetricsPollingRef.current = null;
|
||||
if (currentHostConfig?.id) {
|
||||
await stopMetricsPolling(
|
||||
currentHostConfig.id,
|
||||
@@ -438,8 +478,11 @@ function HostMetricsInner({
|
||||
};
|
||||
|
||||
const debounce = setTimeout(() => {
|
||||
if (cancelled) return;
|
||||
if (isActuallyVisible) {
|
||||
if (!hasConnectionError) startMetrics();
|
||||
clearLogs();
|
||||
metricsRetryRef.current.reset();
|
||||
metricsRetryRef.current.retryNow();
|
||||
} else {
|
||||
stopMetrics();
|
||||
}
|
||||
@@ -448,19 +491,21 @@ function HostMetricsInner({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(debounce);
|
||||
if (pollingId) window.clearInterval(pollingId);
|
||||
stopMetricsPollingRef.current?.();
|
||||
stopMetricsPollingRef.current = null;
|
||||
if (currentHostConfig?.id) {
|
||||
stopMetricsPolling(currentHostConfig.id).catch(() => {});
|
||||
}
|
||||
};
|
||||
}, [
|
||||
currentHostConfig?.id,
|
||||
isActuallyVisible,
|
||||
metricsEnabled,
|
||||
statsConfig.metricsInterval,
|
||||
totpVerified,
|
||||
hasConnectionError,
|
||||
]);
|
||||
}, [currentHostConfig?.id, isActuallyVisible, metricsEnabled]);
|
||||
|
||||
// After a successful TOTP submit, resume the connect flow immediately.
|
||||
React.useEffect(() => {
|
||||
if (totpVerified && !totpRequired) {
|
||||
metricsRetryRef.current.reset();
|
||||
metricsRetryRef.current.retryNow();
|
||||
}
|
||||
}, [totpVerified, totpRequired]);
|
||||
|
||||
const wrapperStyle: React.CSSProperties = embedded
|
||||
? { opacity: isVisible ? 1 : 0, height: "100%", width: "100%" }
|
||||
@@ -472,9 +517,8 @@ function HostMetricsInner({
|
||||
|
||||
const handleRefresh = async () => {
|
||||
if (!currentHostConfig?.id) return;
|
||||
if (hasConnectionError) {
|
||||
setHasConnectionError(false);
|
||||
clearLogs();
|
||||
if (metricsRetry.status !== "connected") {
|
||||
metricsRetry.retryNow();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -493,183 +537,205 @@ function HostMetricsInner({
|
||||
}
|
||||
};
|
||||
|
||||
async function executeQuickAction(
|
||||
action: QuickAction,
|
||||
inputValues: Record<string, string>,
|
||||
) {
|
||||
if (!currentHostConfig) return;
|
||||
setExecutingActions((prev) => new Set(prev).add(action.snippetId));
|
||||
toast.loading(
|
||||
t("hostMetrics.executingQuickAction", { name: action.name }),
|
||||
{
|
||||
id: `quick-action-${action.snippetId}`,
|
||||
},
|
||||
);
|
||||
try {
|
||||
const result = await executeSnippet(
|
||||
action.snippetId,
|
||||
currentHostConfig.id,
|
||||
Object.keys(inputValues).length > 0 ? inputValues : undefined,
|
||||
);
|
||||
if (result.success) {
|
||||
toast.success(
|
||||
t("hostMetrics.quickActionSuccess", { name: action.name }),
|
||||
{
|
||||
id: `quick-action-${action.snippetId}`,
|
||||
description: result.output?.substring(0, 200),
|
||||
duration: 5000,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
toast.error(t("hostMetrics.quickActionFailed", { name: action.name }), {
|
||||
id: `quick-action-${action.snippetId}`,
|
||||
description: result.error || result.output,
|
||||
duration: 5000,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(t("hostMetrics.quickActionError", { name: action.name }), {
|
||||
id: `quick-action-${action.snippetId}`,
|
||||
description: getErrorMessage(error),
|
||||
duration: 5000,
|
||||
});
|
||||
} finally {
|
||||
setExecutingActions((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(action.snippetId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function runQuickActionWithConfirm(
|
||||
action: QuickAction,
|
||||
inputValues: Record<string, string>,
|
||||
) {
|
||||
const shouldConfirm =
|
||||
localStorage.getItem("confirmSnippetExecution") === "true";
|
||||
const run = () => void executeQuickAction(action, inputValues);
|
||||
if (!shouldConfirm) {
|
||||
run();
|
||||
return;
|
||||
}
|
||||
confirmWithToast(
|
||||
t("newUi.sidebar.snippets.confirmRunMessage", { name: action.name }),
|
||||
run,
|
||||
t("newUi.sidebar.snippets.confirmRunButton"),
|
||||
t("newUi.sidebar.snippets.cancel"),
|
||||
{ confirmOnEnter: true, duration: 6000 },
|
||||
);
|
||||
}
|
||||
|
||||
async function runQuickAction(action: QuickAction) {
|
||||
if (!currentHostConfig) return;
|
||||
try {
|
||||
if (!snippetsCacheRef.current) {
|
||||
snippetsCacheRef.current =
|
||||
(await getSnippets()) as unknown as Snippet[];
|
||||
}
|
||||
const snippet = snippetsCacheRef.current?.find(
|
||||
(s) => s.id === action.snippetId,
|
||||
);
|
||||
if (snippet && hasSnippetInputs(snippet.content)) {
|
||||
setRunningAction({ action, snippet });
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// fall through and run with no inputs if the snippet lookup fails
|
||||
}
|
||||
runQuickActionWithConfirm(action, {});
|
||||
}
|
||||
|
||||
const showCards =
|
||||
metricsEnabled && !isLoadingMetrics && metrics && !hasConnectionError;
|
||||
metricsEnabled && metricsRetry.status === "connected" && metrics;
|
||||
const showOffline =
|
||||
metricsEnabled &&
|
||||
!isLoadingMetrics &&
|
||||
metricsRetry.status !== "connecting" &&
|
||||
!metrics &&
|
||||
serverStatus === "offline" &&
|
||||
!hasConnectionError;
|
||||
serverStatus === "offline";
|
||||
|
||||
return (
|
||||
<div
|
||||
style={wrapperStyle}
|
||||
className="relative flex flex-col overflow-hidden"
|
||||
>
|
||||
<div
|
||||
className="flex min-h-0 flex-1 flex-col overflow-hidden"
|
||||
style={{
|
||||
visibility:
|
||||
hasConnectionError && isConnectionLogExpanded
|
||||
? "hidden"
|
||||
: "visible",
|
||||
}}
|
||||
>
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<div className="flex flex-1 flex-col overflow-x-hidden overflow-y-auto">
|
||||
{!totpRequired && !isLoadingMetrics && !hasConnectionError && (
|
||||
<div className="mx-3 mt-3 flex shrink-0 items-center justify-between border border-border bg-card px-3 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center border border-border bg-muted">
|
||||
<Server className="size-5 text-accent-brand" />
|
||||
{!totpRequired &&
|
||||
(metricsRetry.status === "connected" || showOffline) && (
|
||||
<div className="mx-3 mt-3 flex shrink-0 items-center justify-between border border-border bg-card px-3 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center border border-border bg-muted">
|
||||
<Server className="size-5 text-accent-brand" />
|
||||
</div>
|
||||
<h1 className="text-lg font-bold md:text-2xl">{title}</h1>
|
||||
</div>
|
||||
<h1 className="text-lg font-bold md:text-2xl">{title}</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-0">
|
||||
{currentHostConfig?.quickActions &&
|
||||
currentHostConfig.quickActions.length > 0 && (
|
||||
<div className="flex items-center gap-0">
|
||||
{currentHostConfig?.quickActions &&
|
||||
currentHostConfig.quickActions.length > 0 && (
|
||||
<>
|
||||
<div className="mr-3 flex flex-wrap gap-2">
|
||||
{currentHostConfig.quickActions.map(
|
||||
(action, index) => {
|
||||
const isExecuting = executingActions.has(
|
||||
action.snippetId,
|
||||
);
|
||||
return (
|
||||
<Button
|
||||
key={index}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 text-xs font-semibold"
|
||||
disabled={isExecuting}
|
||||
onClick={() => void runQuickAction(action)}
|
||||
>
|
||||
{isExecuting ? (
|
||||
<>
|
||||
<RefreshCw className="mr-1 size-3 animate-spin" />
|
||||
{action.name}
|
||||
</>
|
||||
) : (
|
||||
action.name
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
className="mx-3 h-8"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{editMode && (
|
||||
<>
|
||||
<div className="mr-3 flex flex-wrap gap-2">
|
||||
{currentHostConfig.quickActions.map((action, index) => {
|
||||
const isExecuting = executingActions.has(
|
||||
action.snippetId,
|
||||
);
|
||||
return (
|
||||
<Button
|
||||
key={index}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 text-xs font-semibold"
|
||||
disabled={isExecuting}
|
||||
onClick={async () => {
|
||||
if (!currentHostConfig) return;
|
||||
setExecutingActions((prev) =>
|
||||
new Set(prev).add(action.snippetId),
|
||||
);
|
||||
toast.loading(
|
||||
t("hostMetrics.executingQuickAction", {
|
||||
name: action.name,
|
||||
}),
|
||||
{ id: `quick-action-${action.snippetId}` },
|
||||
);
|
||||
try {
|
||||
const result = await executeSnippet(
|
||||
action.snippetId,
|
||||
currentHostConfig.id,
|
||||
);
|
||||
if (result.success) {
|
||||
toast.success(
|
||||
t("hostMetrics.quickActionSuccess", {
|
||||
name: action.name,
|
||||
}),
|
||||
{
|
||||
id: `quick-action-${action.snippetId}`,
|
||||
description: result.output?.substring(
|
||||
0,
|
||||
200,
|
||||
),
|
||||
duration: 5000,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
toast.error(
|
||||
t("hostMetrics.quickActionFailed", {
|
||||
name: action.name,
|
||||
}),
|
||||
{
|
||||
id: `quick-action-${action.snippetId}`,
|
||||
description:
|
||||
result.error || result.output,
|
||||
duration: 5000,
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
t("hostMetrics.quickActionError", {
|
||||
name: action.name,
|
||||
}),
|
||||
{
|
||||
id: `quick-action-${action.snippetId}`,
|
||||
description:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Unknown error",
|
||||
duration: 5000,
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
setExecutingActions((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(action.snippetId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isExecuting ? (
|
||||
<>
|
||||
<RefreshCw className="mr-1 size-3 animate-spin" />
|
||||
{action.name}
|
||||
</>
|
||||
) : (
|
||||
action.name
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Separator orientation="vertical" className="mx-3 h-8" />
|
||||
<ColumnCountStepper
|
||||
columns={effectiveLayout.columns}
|
||||
onChange={(columns) =>
|
||||
setLayout({ ...effectiveLayout, columns })
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="ml-2 text-xs text-muted-foreground"
|
||||
onClick={() =>
|
||||
setLayout(
|
||||
defaultLayoutFromWidgets(
|
||||
statsConfig.enabledWidgets ?? [],
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("hostMetrics.reset")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{editMode && (
|
||||
<>
|
||||
<ColumnCountStepper
|
||||
columns={effectiveLayout.columns}
|
||||
onChange={(columns) =>
|
||||
setLayout({ ...effectiveLayout, columns })
|
||||
}
|
||||
<Button
|
||||
variant={editMode ? "default" : "ghost"}
|
||||
size="icon"
|
||||
className="ml-1"
|
||||
title={t("hostMetrics.customize")}
|
||||
onClick={() => setEditMode((v) => !v)}
|
||||
>
|
||||
<LayoutDashboard className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
className="ml-1 gap-2 font-semibold"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`size-3.5 ${isRefreshing ? "animate-spin" : ""}`}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="ml-2 text-xs text-muted-foreground"
|
||||
onClick={() =>
|
||||
setLayout(
|
||||
defaultLayoutFromWidgets(
|
||||
statsConfig.enabledWidgets ?? [],
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("hostMetrics.reset")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
variant={editMode ? "default" : "ghost"}
|
||||
size="icon"
|
||||
className="ml-1"
|
||||
title={t("hostMetrics.customize")}
|
||||
onClick={() => setEditMode((v) => !v)}
|
||||
>
|
||||
<LayoutDashboard className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
className="ml-1 gap-2 font-semibold"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`size-3.5 ${isRefreshing ? "animate-spin" : ""}`}
|
||||
/>
|
||||
{t("hostMetrics.refresh")}
|
||||
</Button>
|
||||
{t("hostMetrics.refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{editMode && (
|
||||
<div className="mx-3 mt-3 flex shrink-0 items-center gap-2 border border-dashed border-accent-brand/40 bg-accent-brand/5 px-4 py-2">
|
||||
@@ -680,20 +746,6 @@ function HostMetricsInner({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showOffline && (
|
||||
<div className="flex flex-1 items-center justify-center py-20">
|
||||
<div className="text-center opacity-40">
|
||||
<Server className="mx-auto mb-4 size-16" />
|
||||
<p className="text-xl font-bold uppercase tracking-widest">
|
||||
{t("hostMetrics.serverOffline")}
|
||||
</p>
|
||||
<p className="text-sm font-semibold">
|
||||
{t("hostMetrics.cannotFetchMetrics")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCards && (
|
||||
<div className="px-3 pt-3 pb-3">
|
||||
<CardGridCanvas
|
||||
@@ -714,10 +766,33 @@ function HostMetricsInner({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{metricsEnabled && (
|
||||
<SimpleLoader
|
||||
visible={isLoadingMetrics && !metrics && !isConnectionLogExpanded}
|
||||
{metricsEnabled && !totpRequired && (
|
||||
<ConnectionScreen
|
||||
status={showOffline ? "connected" : metricsRetry.status}
|
||||
message={t("hostMetrics.connecting")}
|
||||
attempt={metricsRetry.attempt}
|
||||
maxAttempts={metricsRetry.maxAttempts}
|
||||
nextRetryInMs={metricsRetry.nextRetryInMs}
|
||||
onManualRetry={metricsRetry.retryNow}
|
||||
logPosition={
|
||||
metricsRetry.status === "error" ||
|
||||
metricsRetry.status === "disconnected"
|
||||
? "top"
|
||||
: "bottom"
|
||||
}
|
||||
emptyState={
|
||||
showOffline ? (
|
||||
<div className="text-center opacity-40">
|
||||
<Server className="mx-auto mb-4 size-16" />
|
||||
<p className="text-xl font-bold uppercase tracking-widest">
|
||||
{t("hostMetrics.serverOffline")}
|
||||
</p>
|
||||
<p className="text-sm font-semibold">
|
||||
{t("hostMetrics.cannotFetchMetrics")}
|
||||
</p>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -729,12 +804,28 @@ function HostMetricsInner({
|
||||
onCancel={handleTOTPCancel}
|
||||
backgroundColor="var(--bg-canvas)"
|
||||
/>
|
||||
<ConnectionLog
|
||||
isConnecting={isLoadingMetrics}
|
||||
isConnected={serverStatus === "online" && !hasConnectionError}
|
||||
hasConnectionError={hasConnectionError}
|
||||
position={hasConnectionError ? "top" : "bottom"}
|
||||
/>
|
||||
|
||||
{runningAction && (
|
||||
<SnippetVariablesDialog
|
||||
snippet={runningAction.snippet}
|
||||
host={
|
||||
currentHostConfig
|
||||
? {
|
||||
ip: currentHostConfig.ip,
|
||||
username: currentHostConfig.username,
|
||||
port: currentHostConfig.port,
|
||||
name: currentHostConfig.name,
|
||||
}
|
||||
: null
|
||||
}
|
||||
onCancel={() => setRunningAction(null)}
|
||||
onConfirm={(_resolvedContent, inputValues) => {
|
||||
const action = runningAction.action;
|
||||
setRunningAction(null);
|
||||
runQuickActionWithConfirm(action, inputValues);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,222 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LineChart, type LineChartSeries } from "@/components/charts/LineChart";
|
||||
import {
|
||||
getMetricsHistory,
|
||||
type MetricsHistoryRow,
|
||||
} from "@/api/host-metrics-api";
|
||||
|
||||
export type HistoryRange = "1h" | "6h" | "24h" | "7d" | "30d";
|
||||
|
||||
interface MetricsHistorySectionProps {
|
||||
hostId: number;
|
||||
timeRange: HistoryRange;
|
||||
}
|
||||
|
||||
function computeNetRates(rows: MetricsHistoryRow[]): {
|
||||
rxRates: Array<number | null>;
|
||||
txRates: Array<number | null>;
|
||||
} {
|
||||
const rxRates: Array<number | null> = [];
|
||||
const txRates: Array<number | null> = [];
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
if (i === 0) {
|
||||
rxRates.push(null);
|
||||
txRates.push(null);
|
||||
continue;
|
||||
}
|
||||
const prev = rows[i - 1];
|
||||
const curr = rows[i];
|
||||
const dtSec =
|
||||
(new Date(curr.ts).getTime() - new Date(prev.ts).getTime()) / 1000;
|
||||
if (
|
||||
dtSec < 0.5 ||
|
||||
prev.net_rx_bytes == null ||
|
||||
curr.net_rx_bytes == null ||
|
||||
prev.net_tx_bytes == null ||
|
||||
curr.net_tx_bytes == null
|
||||
) {
|
||||
rxRates.push(null);
|
||||
txRates.push(null);
|
||||
continue;
|
||||
}
|
||||
const rxDelta = curr.net_rx_bytes - prev.net_rx_bytes;
|
||||
const txDelta = curr.net_tx_bytes - prev.net_tx_bytes;
|
||||
rxRates.push(rxDelta < 0 ? null : rxDelta / dtSec);
|
||||
txRates.push(txDelta < 0 ? null : txDelta / dtSec);
|
||||
}
|
||||
return { rxRates, txRates };
|
||||
}
|
||||
|
||||
function formatBytes(bps: number): string {
|
||||
if (bps >= 1024 * 1024) return `${(bps / 1024 / 1024).toFixed(1)} MB/s`;
|
||||
if (bps >= 1024) return `${(bps / 1024).toFixed(1)} KB/s`;
|
||||
return `${Math.round(bps)} B/s`;
|
||||
}
|
||||
|
||||
function maxOrZero(arr: Array<number | null>): number {
|
||||
return arr.reduce<number>((m, v) => (v != null && v > m ? v : m), 0);
|
||||
}
|
||||
|
||||
export function MetricsHistorySection({
|
||||
hostId,
|
||||
timeRange,
|
||||
}: MetricsHistorySectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const [rows, setRows] = useState<MetricsHistoryRow[] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
getMetricsHistory(hostId, { range: timeRange })
|
||||
.then((res) => {
|
||||
if (!cancelled) {
|
||||
setRows(res.rows);
|
||||
setLoading(false);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Failed to load history",
|
||||
);
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [hostId, timeRange]);
|
||||
|
||||
const timestamps = rows?.map((r) => r.ts) ?? [];
|
||||
|
||||
const cpuSeries: LineChartSeries = {
|
||||
key: "cpu",
|
||||
label: t("hostMetrics.cpuUsage"),
|
||||
color: "var(--accent-brand)",
|
||||
data: rows?.map((r) => r.cpu_percent) ?? [],
|
||||
};
|
||||
const memSeries: LineChartSeries = {
|
||||
key: "mem",
|
||||
label: t("hostMetrics.memoryUsage"),
|
||||
color: "var(--chart-3)",
|
||||
data: rows?.map((r) => r.mem_percent) ?? [],
|
||||
};
|
||||
const diskSeries: LineChartSeries = {
|
||||
key: "disk",
|
||||
label: t("hostMetrics.diskUsage"),
|
||||
color: "var(--chart-4)",
|
||||
data: rows?.map((r) => r.disk_percent) ?? [],
|
||||
};
|
||||
|
||||
const { rxRates, txRates } = rows
|
||||
? computeNetRates(rows)
|
||||
: { rxRates: [], txRates: [] };
|
||||
const netMax = Math.max(maxOrZero(rxRates), maxOrZero(txRates), 1024);
|
||||
const rxSeries: LineChartSeries = {
|
||||
key: "rx",
|
||||
label: t("metricsHistory.download"),
|
||||
color: "var(--chart-2)",
|
||||
data: rxRates,
|
||||
};
|
||||
const txSeries: LineChartSeries = {
|
||||
key: "tx",
|
||||
label: t("metricsHistory.upload"),
|
||||
color: "var(--chart-5)",
|
||||
data: txRates,
|
||||
};
|
||||
|
||||
const hasData = rows !== null && rows.length > 0;
|
||||
const hasNetData = rxRates.some((v) => v !== null);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 px-3 pb-3">
|
||||
{["cpu", "mem", "disk", "net"].map((k) => (
|
||||
<div key={k} className="border border-border bg-card p-4">
|
||||
<div className="mb-3 h-3 w-24 animate-pulse rounded bg-muted" />
|
||||
<div className="h-40 animate-pulse rounded bg-muted" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="mx-3 flex h-40 items-center justify-center border border-border bg-card text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasData) {
|
||||
return (
|
||||
<div className="mx-3 flex h-40 items-center justify-center border border-border bg-card text-sm text-muted-foreground">
|
||||
{t("metricsHistory.noData")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 px-3 pb-3">
|
||||
<div className="border border-border bg-card p-4">
|
||||
<div className="mb-3 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hostMetrics.cpuUsage")}
|
||||
</div>
|
||||
<LineChart
|
||||
series={[cpuSeries]}
|
||||
timestamps={timestamps}
|
||||
domain={[0, 100]}
|
||||
yFormatter={(v) => `${v.toFixed(0)}%`}
|
||||
height={160}
|
||||
/>
|
||||
</div>
|
||||
<div className="border border-border bg-card p-4">
|
||||
<div className="mb-3 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hostMetrics.memoryUsage")}
|
||||
</div>
|
||||
<LineChart
|
||||
series={[memSeries]}
|
||||
timestamps={timestamps}
|
||||
domain={[0, 100]}
|
||||
yFormatter={(v) => `${v.toFixed(0)}%`}
|
||||
height={160}
|
||||
/>
|
||||
</div>
|
||||
<div className="border border-border bg-card p-4">
|
||||
<div className="mb-3 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hostMetrics.diskUsage")}
|
||||
</div>
|
||||
<LineChart
|
||||
series={[diskSeries]}
|
||||
timestamps={timestamps}
|
||||
domain={[0, 100]}
|
||||
yFormatter={(v) => `${v.toFixed(0)}%`}
|
||||
height={160}
|
||||
/>
|
||||
</div>
|
||||
<div className="border border-border bg-card p-4">
|
||||
<div className="mb-3 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("metricsHistory.network")}
|
||||
</div>
|
||||
{hasNetData ? (
|
||||
<LineChart
|
||||
series={[rxSeries, txSeries]}
|
||||
timestamps={timestamps}
|
||||
domain={[0, netMax]}
|
||||
yFormatter={formatBytes}
|
||||
height={160}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-40 items-center justify-center text-sm text-muted-foreground">
|
||||
{t("metricsHistory.noData")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -37,7 +37,9 @@ export function FilesystemPicker({
|
||||
)}
|
||||
title={t("hostMetrics.selectFilesystem")}
|
||||
>
|
||||
<span className="truncate">{selected?.mount ?? value ?? "-"}</span>
|
||||
<span className="truncate">
|
||||
{selected?.label || selected?.mount || value || "-"}
|
||||
</span>
|
||||
<ChevronDown className="size-3 shrink-0" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
@@ -57,8 +59,13 @@ export function FilesystemPicker({
|
||||
)}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate font-medium">
|
||||
{fs.mount}
|
||||
{fs.label || fs.mount}
|
||||
</span>
|
||||
{fs.label && (
|
||||
<span className="max-w-24 truncate font-mono text-[10px] text-muted-foreground">
|
||||
{fs.mount}
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-auto shrink-0 whitespace-nowrap text-[10px] tabular-nums text-muted-foreground">
|
||||
{fs.usedHuman && fs.totalHuman
|
||||
? `${fs.usedHuman}/${fs.totalHuman}`
|
||||
|
||||
@@ -161,9 +161,16 @@ export function NetworkCard({
|
||||
</div>
|
||||
<div className="flex justify-between font-mono text-[10px] text-muted-foreground">
|
||||
<span className="truncate">{iface.ip}</span>
|
||||
{(iface.rx || iface.tx) && (
|
||||
{(iface.rxRateBps != null || iface.txRateBps != null) && (
|
||||
<span className="shrink-0">
|
||||
↓ {iface.rx ?? "—"} / ↑ {iface.tx ?? "—"}
|
||||
↓{" "}
|
||||
{iface.rxRateBps == null
|
||||
? "—"
|
||||
: formatBytes(iface.rxRateBps)}{" "}
|
||||
/ ↑{" "}
|
||||
{iface.txRateBps == null
|
||||
? "—"
|
||||
: formatBytes(iface.txRateBps)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
import type { ServerMetrics } from "@/main-axios";
|
||||
import type { HostMetricsCardId } from "@/types/host-metrics";
|
||||
import { defaultColSpanFor, defaultHeightFor } from "@/types/host-metrics";
|
||||
import {
|
||||
defaultColSpanFor,
|
||||
defaultHeightFor,
|
||||
type HostMetricsCardId,
|
||||
} from "@/types/host-metrics";
|
||||
import { CpuCard } from "./CpuCard";
|
||||
import { MemoryCard } from "./MemoryCard";
|
||||
import { DiskCard } from "./DiskCard";
|
||||
|
||||
@@ -3,8 +3,7 @@ import { ShieldCheck, Plus, Trash2, Save } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/button";
|
||||
import { managerPost } from "@/main-axios";
|
||||
import type { FirewallChain } from "@/main-axios";
|
||||
import { managerPost, type FirewallChain } from "@/main-axios";
|
||||
import { useManagerData, extractError } from "./useManagerData";
|
||||
import { ManagerCardShell } from "./ManagerCardShell";
|
||||
import { ManagerSearch } from "./ManagerToolbar";
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ScrollText } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { managerGet, managerGetSub } from "@/main-axios";
|
||||
import { extractError } from "./useManagerData";
|
||||
import { ManagerCardShell } from "./ManagerCardShell";
|
||||
import { useAdaptivePolling } from "@/hooks/use-adaptive-polling";
|
||||
|
||||
interface LogFiles {
|
||||
common: string[];
|
||||
@@ -26,6 +27,8 @@ export function LogViewerCard({ hostId }: { hostId: number | null }) {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [files, setFiles] = useState<string[]>([]);
|
||||
const bodyRef = useRef<HTMLPreElement | null>(null);
|
||||
const contentRef = useRef(content);
|
||||
contentRef.current = content;
|
||||
|
||||
// Load the host's actual log files once.
|
||||
useEffect(() => {
|
||||
@@ -42,44 +45,56 @@ export function LogViewerCard({ hostId }: { hostId: number | null }) {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [hostId]);
|
||||
|
||||
const fetchLog = async () => {
|
||||
if (hostId == null) return;
|
||||
const fetchLog = useCallback(async () => {
|
||||
if (hostId == null) return false;
|
||||
const params: Record<string, string | number> = { lines };
|
||||
if (mode === "unit") {
|
||||
if (!unit.trim()) return;
|
||||
if (!unit.trim()) return false;
|
||||
params.unit = unit.trim();
|
||||
} else {
|
||||
const p = customPath.trim() || path;
|
||||
if (!p) return;
|
||||
if (!p) return false;
|
||||
params.path = p;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await managerGet<{ content: string }>(hostId, "logs", params);
|
||||
setContent(res.content || "");
|
||||
requestAnimationFrame(() => {
|
||||
if (bodyRef.current)
|
||||
bodyRef.current.scrollTop = bodyRef.current.scrollHeight;
|
||||
});
|
||||
const next = res.content || "";
|
||||
const changed = next !== contentRef.current;
|
||||
if (changed) {
|
||||
contentRef.current = next;
|
||||
setContent(next);
|
||||
requestAnimationFrame(() => {
|
||||
if (bodyRef.current)
|
||||
bodyRef.current.scrollTop = bodyRef.current.scrollHeight;
|
||||
});
|
||||
}
|
||||
return changed;
|
||||
} catch (e) {
|
||||
setError(extractError(e).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, [customPath, hostId, lines, mode, path, unit]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchLog();
|
||||
void fetchLog();
|
||||
// Custom paths are applied on blur/manual refresh, not on every keystroke.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [mode, path, unit, lines]);
|
||||
}, [hostId, mode, path, unit, lines]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!follow) return;
|
||||
const id = setInterval(fetchLog, 3000);
|
||||
return () => clearInterval(id);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [follow, mode, path, unit, lines, customPath]);
|
||||
useAdaptivePolling(
|
||||
fetchLog,
|
||||
{
|
||||
minIntervalMs: 3_000,
|
||||
maxIntervalMs: 18_000,
|
||||
stablePollsPerStep: 2,
|
||||
maxRequestDutyCycle: 0.2,
|
||||
},
|
||||
follow,
|
||||
{ runImmediately: false },
|
||||
);
|
||||
|
||||
const shown = useMemo(() => {
|
||||
if (!grep) return content;
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { useXTerm } from "react-xtermjs";
|
||||
import { useTheme } from "@/components/theme-provider";
|
||||
import { resolveTermixThemeColors } from "@/features/terminal/terminal-theme";
|
||||
import { DEFAULT_TERMINAL_CONFIG, TERMINAL_FONTS } from "@/lib/terminal-themes";
|
||||
import { ensureTerminalFontsLoaded } from "@/features/terminal/terminal-global-styles";
|
||||
|
||||
export function LocalTerminal({
|
||||
instanceId,
|
||||
isVisible,
|
||||
}: {
|
||||
instanceId: string;
|
||||
isVisible: boolean;
|
||||
}) {
|
||||
const { theme: appTheme } = useTheme();
|
||||
const { instance: terminal, ref: xtermRef } = useXTerm();
|
||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||
const sessionIdRef = useRef<string | null>(null);
|
||||
|
||||
const fit = useCallback(() => {
|
||||
const fitAddon = fitAddonRef.current;
|
||||
const sessionId = sessionIdRef.current;
|
||||
if (!terminal || !fitAddon) return;
|
||||
fitAddon.fit();
|
||||
if (sessionId) {
|
||||
window.electronAPI.resizeLocalTerminal(
|
||||
sessionId,
|
||||
terminal.cols,
|
||||
terminal.rows,
|
||||
);
|
||||
}
|
||||
}, [terminal]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!terminal) return;
|
||||
const colors = resolveTermixThemeColors("termix", appTheme);
|
||||
const font = TERMINAL_FONTS.find(
|
||||
(item) => item.value === DEFAULT_TERMINAL_CONFIG.fontFamily,
|
||||
);
|
||||
ensureTerminalFontsLoaded(font?.value ?? TERMINAL_FONTS[0].value);
|
||||
terminal.options.theme = colors;
|
||||
terminal.options.fontFamily = font?.fallback ?? TERMINAL_FONTS[0].fallback;
|
||||
terminal.options.fontSize = DEFAULT_TERMINAL_CONFIG.fontSize;
|
||||
}, [appTheme, terminal]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!terminal || !window.electronAPI?.isElectron) return;
|
||||
const fitAddon = new FitAddon();
|
||||
fitAddonRef.current = fitAddon;
|
||||
terminal.loadAddon(fitAddon);
|
||||
fitAddon.fit();
|
||||
|
||||
let disposed = false;
|
||||
let removeData = () => {};
|
||||
let removeExit = () => {};
|
||||
const input = terminal.onData((data) => {
|
||||
const sessionId = sessionIdRef.current;
|
||||
if (sessionId) window.electronAPI.writeLocalTerminal(sessionId, data);
|
||||
});
|
||||
|
||||
window.electronAPI
|
||||
.startLocalTerminal({ cols: terminal.cols, rows: terminal.rows })
|
||||
.then(({ sessionId }) => {
|
||||
if (disposed) {
|
||||
window.electronAPI.closeLocalTerminal(sessionId);
|
||||
return;
|
||||
}
|
||||
sessionIdRef.current = sessionId;
|
||||
removeData = window.electronAPI.onLocalTerminalData(sessionId, (data) =>
|
||||
terminal.write(data),
|
||||
);
|
||||
removeExit = window.electronAPI.onLocalTerminalExit(
|
||||
sessionId,
|
||||
(exitCode) => {
|
||||
sessionIdRef.current = null;
|
||||
terminal.write(
|
||||
`\r\n\x1b[33mProcess exited (${exitCode})\x1b[0m\r\n`,
|
||||
);
|
||||
},
|
||||
);
|
||||
return window.electronAPI.readyLocalTerminal(sessionId);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
terminal.write(`\r\n\x1b[31m${message}\x1b[0m\r\n`);
|
||||
});
|
||||
|
||||
const observer = new ResizeObserver(() => fit());
|
||||
if (xtermRef.current) observer.observe(xtermRef.current);
|
||||
return () => {
|
||||
disposed = true;
|
||||
observer.disconnect();
|
||||
input.dispose();
|
||||
removeData();
|
||||
removeExit();
|
||||
const sessionId = sessionIdRef.current;
|
||||
sessionIdRef.current = null;
|
||||
if (sessionId) window.electronAPI.closeLocalTerminal(sessionId);
|
||||
fitAddonRef.current = null;
|
||||
fitAddon.dispose();
|
||||
};
|
||||
}, [fit, instanceId, terminal, xtermRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isVisible) requestAnimationFrame(fit);
|
||||
}, [fit, isVisible]);
|
||||
|
||||
return <div ref={xtermRef} className="h-full w-full bg-background p-2" />;
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Box, Search, Server as ServerIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Input } from "@/components/input";
|
||||
import type { ProxmoxGuestSummary } from "@/types/proxmox";
|
||||
|
||||
type StatusFilter = "all" | "running" | "stopped";
|
||||
type TypeFilter = "all" | "qemu" | "lxc";
|
||||
|
||||
function formatUptime(seconds: number | null): string {
|
||||
if (seconds === null || seconds <= 0) return "-";
|
||||
const days = Math.floor(seconds / 86400);
|
||||
const hours = Math.floor((seconds % 86400) / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
if (days > 0) return `${days}d ${hours}h`;
|
||||
if (hours > 0) return `${hours}h ${minutes}m`;
|
||||
return `${minutes}m`;
|
||||
}
|
||||
|
||||
function UsageCell({
|
||||
percent,
|
||||
usedGiB,
|
||||
totalGiB,
|
||||
}: {
|
||||
percent: number | null;
|
||||
usedGiB?: number | null;
|
||||
totalGiB?: number | null;
|
||||
}) {
|
||||
if (percent === null) {
|
||||
return <span className="text-[11px] text-muted-foreground">-</span>;
|
||||
}
|
||||
const clamped = Math.min(100, Math.max(0, percent));
|
||||
const barColor =
|
||||
clamped >= 90
|
||||
? "bg-red-500"
|
||||
: clamped >= 75
|
||||
? "bg-yellow-500"
|
||||
: "bg-accent-brand";
|
||||
return (
|
||||
<div className="flex min-w-[92px] flex-col gap-1">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="text-xs font-semibold tabular-nums">
|
||||
{clamped.toFixed(0)}%
|
||||
</span>
|
||||
{usedGiB != null && totalGiB != null && (
|
||||
<span className="text-[10px] tabular-nums text-muted-foreground">
|
||||
{usedGiB.toFixed(1)}/{totalGiB.toFixed(1)}G
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className={cn("h-full rounded-full", barColor)}
|
||||
style={{ width: `${clamped}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function GuestTable({ guests }: { guests: ProxmoxGuestSummary[] }) {
|
||||
const { t } = useTranslation();
|
||||
const [query, setQuery] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
|
||||
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all");
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return guests
|
||||
.filter(
|
||||
(g) =>
|
||||
statusFilter === "all" ||
|
||||
(statusFilter === "running") === (g.status === "running"),
|
||||
)
|
||||
.filter((g) => typeFilter === "all" || g.type === typeFilter)
|
||||
.filter(
|
||||
(g) =>
|
||||
!q || g.name.toLowerCase().includes(q) || String(g.vmid).includes(q),
|
||||
)
|
||||
.sort((a, b) => {
|
||||
if (a.status === b.status) return a.name.localeCompare(b.name);
|
||||
return a.status === "running" ? -1 : 1;
|
||||
});
|
||||
}, [guests, query, statusFilter, typeFilter]);
|
||||
|
||||
const running = guests.filter((g) => g.status === "running").length;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[420px] flex-1 flex-col overflow-hidden border border-border bg-card">
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2 border-b border-border px-3 py-2.5">
|
||||
<span className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("proxmoxStats.guestsSummary")}
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{t("proxmoxStats.guestCounts", { running, total: guests.length })}
|
||||
</span>
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-2 top-1/2 size-3 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t("proxmoxStats.searchGuests")}
|
||||
className="h-7 w-40 pl-6 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex overflow-hidden rounded-none border border-border">
|
||||
{(["all", "qemu", "lxc"] as TypeFilter[]).map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setTypeFilter(f)}
|
||||
className={cn(
|
||||
"px-2 py-1 text-[10px] font-semibold uppercase tracking-wide transition-colors",
|
||||
typeFilter === f
|
||||
? "bg-accent-brand text-white"
|
||||
: "bg-background text-muted-foreground hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
{f === "all"
|
||||
? t("proxmoxStats.filterAll")
|
||||
: f === "qemu"
|
||||
? t("proxmoxStats.filterVm")
|
||||
: t("proxmoxStats.filterLxc")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex overflow-hidden rounded-none border border-border">
|
||||
{(["all", "running", "stopped"] as StatusFilter[]).map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setStatusFilter(f)}
|
||||
className={cn(
|
||||
"px-2 py-1 text-[10px] font-semibold uppercase tracking-wide transition-colors",
|
||||
statusFilter === f
|
||||
? "bg-accent-brand text-white"
|
||||
: "bg-background text-muted-foreground hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
{f === "all"
|
||||
? t("proxmoxStats.filterAll")
|
||||
: f === "running"
|
||||
? t("proxmoxStats.running")
|
||||
: t("proxmoxStats.stopped")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto thin-scrollbar">
|
||||
{guests.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center py-16 text-xs text-muted-foreground">
|
||||
{t("proxmoxStats.noGuests")}
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center py-16 text-xs text-muted-foreground">
|
||||
{t("proxmoxStats.noGuestsMatch")}
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full border-collapse text-xs">
|
||||
<thead className="sticky top-0 z-10 bg-card">
|
||||
<tr className="border-b border-border text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
<th className="px-3 py-2 text-left font-semibold">
|
||||
{t("proxmoxStats.name")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-semibold">
|
||||
{t("proxmoxStats.status")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-semibold">ID</th>
|
||||
<th className="px-3 py-2 text-left font-semibold">
|
||||
{t("proxmoxStats.cpu")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-semibold">
|
||||
{t("proxmoxStats.mem")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-semibold">
|
||||
{t("proxmoxStats.disk")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-semibold">
|
||||
{t("proxmoxStats.uptime")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{filtered.map((guest) => (
|
||||
<tr
|
||||
key={`${guest.type}-${guest.vmid}`}
|
||||
className="transition-colors hover:bg-muted/40"
|
||||
>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{guest.type === "lxc" ? (
|
||||
<Box className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<ServerIcon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="min-w-0 truncate font-semibold">
|
||||
{guest.name}
|
||||
</span>
|
||||
<span className="shrink-0 rounded-full border border-border px-1.5 py-0.5 text-[9px] uppercase text-muted-foreground">
|
||||
{guest.type === "lxc" ? "LXC" : "VM"}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
"size-1.5 shrink-0 rounded-full",
|
||||
guest.status === "running"
|
||||
? "bg-green-500"
|
||||
: "bg-muted-foreground/50",
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"font-medium",
|
||||
guest.status === "running"
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{guest.status === "running"
|
||||
? t("proxmoxStats.running")
|
||||
: t("proxmoxStats.stopped")}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono text-muted-foreground">
|
||||
{guest.vmid}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<UsageCell percent={guest.cpuPercent} />
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<UsageCell
|
||||
percent={guest.memPercent}
|
||||
usedGiB={guest.memUsedGiB}
|
||||
totalGiB={guest.memTotalGiB}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<UsageCell
|
||||
percent={guest.diskPercent}
|
||||
usedGiB={guest.diskUsedGiB}
|
||||
totalGiB={guest.diskTotalGiB}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap text-muted-foreground">
|
||||
{formatUptime(guest.uptimeSeconds)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Cpu, MemoryStick, HardDrive, Clock, Server } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ProxmoxNodeStats } from "@/types/proxmox";
|
||||
|
||||
function Tile({
|
||||
icon,
|
||||
label,
|
||||
percent,
|
||||
detail,
|
||||
sparkline,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
percent: number | null;
|
||||
detail?: string | null;
|
||||
sparkline?: number[];
|
||||
}) {
|
||||
const clamped = percent === null ? null : Math.min(100, Math.max(0, percent));
|
||||
const barColor =
|
||||
clamped === null
|
||||
? "bg-muted-foreground/30"
|
||||
: clamped >= 90
|
||||
? "bg-red-500"
|
||||
: clamped >= 75
|
||||
? "bg-yellow-500"
|
||||
: "bg-accent-brand";
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-2 border border-border bg-card px-4 py-3">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
{icon}
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="text-2xl font-bold tabular-nums">
|
||||
{clamped === null ? "N/A" : `${clamped.toFixed(0)}%`}
|
||||
</span>
|
||||
{detail && (
|
||||
<span className="truncate text-[11px] text-muted-foreground">
|
||||
{detail}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className={cn("h-full rounded-full transition-all", barColor)}
|
||||
style={{ width: `${clamped ?? 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
{sparkline && sparkline.length > 1 && <MiniSparkline data={sparkline} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MiniSparkline({ data }: { data: number[] }) {
|
||||
const w = 100;
|
||||
const h = 20;
|
||||
const max = 100;
|
||||
const points = data
|
||||
.map((v, i) => {
|
||||
const x = (i / (data.length - 1)) * w;
|
||||
const y = h - (Math.min(100, Math.max(0, v)) / max) * h;
|
||||
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
||||
})
|
||||
.join(" ");
|
||||
return (
|
||||
<svg viewBox={`0 0 ${w} ${h}`} className="h-5 w-full text-accent-brand/70">
|
||||
<polyline
|
||||
points={points}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function NodeSummaryStrip({
|
||||
node,
|
||||
histories,
|
||||
}: {
|
||||
node: ProxmoxNodeStats | undefined;
|
||||
histories: { cpu: number[]; memory: number[]; disk: number[] };
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Tile
|
||||
icon={<Cpu className="size-3.5" />}
|
||||
label={t("proxmoxStats.cpuUsage")}
|
||||
percent={node?.cpu.percent ?? null}
|
||||
detail={
|
||||
node?.cpu.cores != null
|
||||
? t("proxmoxStats.cpuCores", { count: node.cpu.cores })
|
||||
: undefined
|
||||
}
|
||||
sparkline={histories.cpu}
|
||||
/>
|
||||
<Tile
|
||||
icon={<MemoryStick className="size-3.5" />}
|
||||
label={t("proxmoxStats.memoryUsage")}
|
||||
percent={node?.memory.percent ?? null}
|
||||
detail={
|
||||
node?.memory.usedGiB != null && node?.memory.totalGiB != null
|
||||
? `${node.memory.usedGiB.toFixed(1)} / ${node.memory.totalGiB.toFixed(1)} GiB`
|
||||
: undefined
|
||||
}
|
||||
sparkline={histories.memory}
|
||||
/>
|
||||
<Tile
|
||||
icon={<HardDrive className="size-3.5" />}
|
||||
label={t("proxmoxStats.diskUsage")}
|
||||
percent={node?.disk.percent ?? null}
|
||||
detail={
|
||||
node?.disk.usedGiB != null && node?.disk.totalGiB != null
|
||||
? `${node.disk.usedGiB.toFixed(1)} / ${node.disk.totalGiB.toFixed(1)} GiB`
|
||||
: undefined
|
||||
}
|
||||
sparkline={histories.disk}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 flex-col justify-between gap-2 border border-border bg-card px-4 py-3">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Clock className="size-3.5" />
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest">
|
||||
{t("proxmoxStats.uptime")}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-2xl font-bold">
|
||||
{node?.uptime.formatted ?? "N/A"}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5 truncate text-[11px] text-muted-foreground">
|
||||
<Server className="size-3 shrink-0" />
|
||||
<span className="truncate">
|
||||
{node?.system.hostname ?? "-"}
|
||||
{node?.system.pveVersion ? ` · ${node.system.pveVersion}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ProxmoxStatsTab } from "@/features/proxmox-stats/ProxmoxStatsTab.tsx";
|
||||
import { FullScreenAppWrapper } from "@/features/FullScreenAppWrapper.tsx";
|
||||
import { ConnectionScreen } from "@/components/connection/ConnectionScreen.tsx";
|
||||
|
||||
interface ProxmoxStatsAppProps {
|
||||
hostId?: string;
|
||||
}
|
||||
|
||||
const ProxmoxStatsApp: React.FC<ProxmoxStatsAppProps> = ({ hostId }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<FullScreenAppWrapper hostId={hostId}>
|
||||
{(hostConfig, phase) => {
|
||||
if (phase === "loading") {
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
<ConnectionScreen
|
||||
status="connecting"
|
||||
message={t("hosts.loadingHost")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hostConfig) {
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
<ConnectionScreen
|
||||
status="disconnected"
|
||||
message={t("hosts.hostNotFound")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ProxmoxStatsTab
|
||||
hostConfig={hostConfig}
|
||||
title={hostConfig.name || `${hostConfig.username}@${hostConfig.ip}`}
|
||||
isVisible={true}
|
||||
isTopbarOpen={false}
|
||||
embedded={true}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</FullScreenAppWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProxmoxStatsApp;
|
||||
@@ -0,0 +1,381 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import React from "react";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import {
|
||||
getProxmoxStats,
|
||||
startProxmoxStatsPolling,
|
||||
stopProxmoxStatsPolling,
|
||||
sendProxmoxStatsHeartbeat,
|
||||
getSSHHosts,
|
||||
} from "@/main-axios.ts";
|
||||
import type { ProxmoxStatsSnapshot } from "@/types/proxmox";
|
||||
import type { ProxmoxStatsConfig } from "@/types";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { RefreshCw, Server } from "lucide-react";
|
||||
import { NodeSummaryStrip } from "./NodeSummaryStrip";
|
||||
import { GuestTable } from "./GuestTable";
|
||||
import { NodeNetworkCard } from "./cards/NodeNetworkCard";
|
||||
import { StoragePoolsCard } from "./cards/StoragePoolsCard";
|
||||
import { ClusterHealthCard } from "./cards/ClusterHealthCard";
|
||||
import { ConnectionScreen } from "@/components/connection/ConnectionScreen.tsx";
|
||||
import {
|
||||
ConnectionLogProvider,
|
||||
useConnectionLog,
|
||||
} from "@/ssh/connection-log/ConnectionLogContext.tsx";
|
||||
import { useConnectionRetry } from "@/lib/useConnectionRetry.ts";
|
||||
import { runAdaptivePolling } from "@/lib/adaptive-polling.ts";
|
||||
|
||||
const HISTORY_LEN = 30;
|
||||
|
||||
function statsChangeKey(data: ProxmoxStatsSnapshot): string {
|
||||
const bucket = (value: number | null | undefined) =>
|
||||
value == null ? null : Math.round(value / 5) * 5;
|
||||
return JSON.stringify({
|
||||
cpu: bucket(data.node.cpu.percent),
|
||||
memory: bucket(data.node.memory.percent),
|
||||
disk: bucket(data.node.disk.percent),
|
||||
guests: data.guests.counts,
|
||||
cluster: data.cluster,
|
||||
});
|
||||
}
|
||||
const DEFAULT_POLL_INTERVAL = 60;
|
||||
|
||||
interface HostConfig {
|
||||
id: number;
|
||||
name: string;
|
||||
ip: string;
|
||||
username: string;
|
||||
enableProxmoxStats?: boolean;
|
||||
proxmoxStatsConfig?: string | ProxmoxStatsConfig | null;
|
||||
authType?: string;
|
||||
port?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface ProxmoxStatsProps {
|
||||
hostConfig?: HostConfig;
|
||||
title?: string;
|
||||
isVisible?: boolean;
|
||||
isTopbarOpen?: boolean;
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
interface ProxmoxStatsHistories {
|
||||
cpu: number[];
|
||||
memory: number[];
|
||||
disk: number[];
|
||||
}
|
||||
|
||||
function parseProxmoxStatsConfig(
|
||||
raw?: string | ProxmoxStatsConfig | null,
|
||||
): Required<Pick<ProxmoxStatsConfig, "pollInterval">> & ProxmoxStatsConfig {
|
||||
const defaults = { pollInterval: DEFAULT_POLL_INTERVAL, nodeName: null };
|
||||
if (!raw) return defaults;
|
||||
try {
|
||||
const parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
|
||||
return { ...defaults, ...parsed };
|
||||
} catch {
|
||||
return defaults;
|
||||
}
|
||||
}
|
||||
|
||||
export function ProxmoxStatsTab(props: ProxmoxStatsProps): React.ReactElement {
|
||||
return (
|
||||
<ConnectionLogProvider>
|
||||
<ProxmoxStatsInner {...props} />
|
||||
</ConnectionLogProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function ProxmoxStatsInner({
|
||||
hostConfig,
|
||||
title,
|
||||
isVisible = true,
|
||||
isTopbarOpen = true,
|
||||
embedded = false,
|
||||
}: ProxmoxStatsProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { addLog, clearLogs } = useConnectionLog();
|
||||
|
||||
const [snapshot, setSnapshot] = React.useState<ProxmoxStatsSnapshot | null>(
|
||||
null,
|
||||
);
|
||||
const [histories, setHistories] = React.useState<ProxmoxStatsHistories>({
|
||||
cpu: [],
|
||||
memory: [],
|
||||
disk: [],
|
||||
});
|
||||
const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig);
|
||||
const [isRefreshing, setIsRefreshing] = React.useState(false);
|
||||
const [isPageVisible, setIsPageVisible] = React.useState(!document.hidden);
|
||||
const [viewerSessionId, setViewerSessionId] = React.useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const statsConfig = React.useMemo(
|
||||
() => parseProxmoxStatsConfig(currentHostConfig?.proxmoxStatsConfig),
|
||||
[currentHostConfig?.proxmoxStatsConfig],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const onVis = () => setIsPageVisible(!document.hidden);
|
||||
document.addEventListener("visibilitychange", onVis);
|
||||
return () => document.removeEventListener("visibilitychange", onVis);
|
||||
}, []);
|
||||
|
||||
const isActuallyVisible = isVisible && isPageVisible;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!viewerSessionId || !isActuallyVisible) return;
|
||||
const interval = setInterval(() => {
|
||||
sendProxmoxStatsHeartbeat(viewerSessionId).catch(() => {});
|
||||
}, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [viewerSessionId, isActuallyVisible]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (hostConfig?.id !== currentHostConfig?.id) {
|
||||
setSnapshot(null);
|
||||
setHistories({ cpu: [], memory: [], disk: [] });
|
||||
}
|
||||
setCurrentHostConfig(hostConfig);
|
||||
}, [hostConfig?.id]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const fetchLatest = async () => {
|
||||
if (!hostConfig?.id) return;
|
||||
try {
|
||||
const hosts = await getSSHHosts();
|
||||
const updated = hosts.find((h) => h.id === hostConfig.id);
|
||||
if (updated) setCurrentHostConfig(updated as unknown as HostConfig);
|
||||
} catch {
|
||||
// keep previous config
|
||||
}
|
||||
};
|
||||
fetchLatest();
|
||||
const onChanged = () => fetchLatest();
|
||||
window.addEventListener("ssh-hosts:changed", onChanged);
|
||||
return () => window.removeEventListener("ssh-hosts:changed", onChanged);
|
||||
}, [hostConfig?.id]);
|
||||
|
||||
const pushHistory = React.useCallback((data: ProxmoxStatsSnapshot) => {
|
||||
setHistories((prev) => {
|
||||
const add = (arr: number[], v: number | null | undefined) =>
|
||||
[...arr, v ?? 0].slice(-HISTORY_LEN);
|
||||
return {
|
||||
cpu: add(prev.cpu, data.node?.cpu?.percent),
|
||||
memory: add(prev.memory, data.node?.memory?.percent),
|
||||
disk: add(prev.disk, data.node?.disk?.percent),
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const notEnabled = currentHostConfig?.enableProxmoxStats !== true;
|
||||
const stopPollingRef = React.useRef<(() => void) | null>(null);
|
||||
|
||||
const fetchSnapshot = React.useCallback(async (): Promise<void> => {
|
||||
if (!currentHostConfig?.id) return;
|
||||
|
||||
addLog({
|
||||
type: "info",
|
||||
stage: "stats_connecting",
|
||||
message: t("proxmoxStats.connecting"),
|
||||
});
|
||||
|
||||
const result = await startProxmoxStatsPolling(currentHostConfig.id);
|
||||
if (result.viewerSessionId) setViewerSessionId(result.viewerSessionId);
|
||||
|
||||
addLog({
|
||||
type: "info",
|
||||
stage: "stats_polling",
|
||||
message: t("proxmoxStats.connecting"),
|
||||
});
|
||||
|
||||
const data = await getProxmoxStats(currentHostConfig.id);
|
||||
if (!data) {
|
||||
throw new Error(t("proxmoxStats.connectionFailed"));
|
||||
}
|
||||
|
||||
setSnapshot(data);
|
||||
addLog({
|
||||
type: "success",
|
||||
stage: "connected",
|
||||
message: t("terminal.connected"),
|
||||
});
|
||||
|
||||
const intervalMs =
|
||||
(statsConfig.pollInterval ?? DEFAULT_POLL_INTERVAL) * 1000;
|
||||
let signature = statsChangeKey(data);
|
||||
stopPollingRef.current?.();
|
||||
stopPollingRef.current = runAdaptivePolling(
|
||||
async () => {
|
||||
const next = await getProxmoxStats(currentHostConfig.id);
|
||||
if (!next) throw new Error(t("proxmoxStats.connectionFailed"));
|
||||
const nextSignature = statsChangeKey(next);
|
||||
const changed = nextSignature !== signature;
|
||||
signature = nextSignature;
|
||||
setSnapshot(next);
|
||||
pushHistory(next);
|
||||
return changed;
|
||||
},
|
||||
{
|
||||
minIntervalMs: intervalMs,
|
||||
maxIntervalMs: Math.min(120_000, intervalMs * 6),
|
||||
stablePollsPerStep: 3,
|
||||
},
|
||||
{ runImmediately: false },
|
||||
);
|
||||
}, [currentHostConfig?.id, statsConfig.pollInterval, addLog, t, pushHistory]);
|
||||
|
||||
const retry = useConnectionRetry({
|
||||
connect: async () => {
|
||||
try {
|
||||
await fetchSnapshot();
|
||||
retry.markConnected();
|
||||
} catch (error: unknown) {
|
||||
addLog({
|
||||
type: "error",
|
||||
stage: "error",
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("proxmoxStats.connectionFailed"),
|
||||
});
|
||||
retry.markFailed();
|
||||
}
|
||||
},
|
||||
enabled: isActuallyVisible && !notEnabled && !!currentHostConfig?.id,
|
||||
autoStart: false,
|
||||
});
|
||||
|
||||
const retryRef = React.useRef(retry);
|
||||
retryRef.current = retry;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (notEnabled || !currentHostConfig?.id) return;
|
||||
|
||||
let cancelled = false;
|
||||
const debounce = setTimeout(() => {
|
||||
if (isActuallyVisible && !cancelled) {
|
||||
clearLogs();
|
||||
retryRef.current.reset();
|
||||
retryRef.current.retryNow();
|
||||
} else if (!isActuallyVisible) {
|
||||
stopPollingRef.current?.();
|
||||
stopPollingRef.current = null;
|
||||
if (currentHostConfig?.id) {
|
||||
stopProxmoxStatsPolling(currentHostConfig.id, undefined).catch(
|
||||
() => {},
|
||||
);
|
||||
}
|
||||
}
|
||||
}, 500);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(debounce);
|
||||
stopPollingRef.current?.();
|
||||
stopPollingRef.current = null;
|
||||
if (currentHostConfig?.id) {
|
||||
stopProxmoxStatsPolling(currentHostConfig.id).catch(() => {});
|
||||
}
|
||||
};
|
||||
}, [currentHostConfig?.id, notEnabled, isActuallyVisible]);
|
||||
|
||||
const wrapperStyle: React.CSSProperties = embedded
|
||||
? { opacity: isVisible ? 1 : 0, height: "100%", width: "100%" }
|
||||
: {
|
||||
opacity: isVisible ? 1 : 0,
|
||||
margin: isTopbarOpen ? "74px 17px 8px 8px" : "16px 17px 8px 8px",
|
||||
height: isTopbarOpen ? "calc(100vh - 82px)" : "calc(100vh - 24px)",
|
||||
};
|
||||
|
||||
const handleRefresh = async () => {
|
||||
if (!currentHostConfig?.id) return;
|
||||
if (retry.status !== "connected") {
|
||||
retry.retryNow();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setIsRefreshing(true);
|
||||
const data = await getProxmoxStats(currentHostConfig.id);
|
||||
if (data) {
|
||||
setSnapshot(data);
|
||||
pushHistory(data);
|
||||
}
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const showContent = !notEnabled && retry.status === "connected" && snapshot;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={wrapperStyle}
|
||||
className="relative flex flex-col overflow-hidden"
|
||||
>
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
{retry.status === "connected" && !notEnabled && (
|
||||
<div className="mx-3 mt-3 flex shrink-0 items-center justify-between border border-border bg-card px-3 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center border border-border bg-muted">
|
||||
<Server className="size-5 text-accent-brand" />
|
||||
</div>
|
||||
<h1 className="text-lg font-bold md:text-2xl">{title}</h1>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
className="gap-2 font-semibold"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`size-3.5 ${isRefreshing ? "animate-spin" : ""}`}
|
||||
/>
|
||||
{t("proxmoxStats.refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showContent && (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto px-3 pb-3 pt-3">
|
||||
<div className="shrink-0">
|
||||
<NodeSummaryStrip node={snapshot.node} histories={histories} />
|
||||
</div>
|
||||
|
||||
<GuestTable guests={snapshot.guests.guests} />
|
||||
|
||||
<div className="grid shrink-0 grid-cols-1 gap-3 pb-1 md:grid-cols-3">
|
||||
<NodeNetworkCard snapshot={snapshot} />
|
||||
<StoragePoolsCard snapshot={snapshot} />
|
||||
{snapshot.cluster.clustered && (
|
||||
<ClusterHealthCard snapshot={snapshot} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConnectionScreen
|
||||
status={notEnabled ? "connected" : retry.status}
|
||||
message={t("proxmoxStats.connecting")}
|
||||
attempt={retry.attempt}
|
||||
maxAttempts={retry.maxAttempts}
|
||||
nextRetryInMs={retry.nextRetryInMs}
|
||||
onManualRetry={retry.retryNow}
|
||||
emptyState={
|
||||
notEnabled ? (
|
||||
<div className="text-center opacity-40">
|
||||
<Server className="mx-auto mb-4 size-16" />
|
||||
<p className="text-xl font-bold uppercase tracking-widest">
|
||||
{t("proxmoxStats.noHostSelected")}
|
||||
</p>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Network } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ProxmoxStatsSnapshot } from "@/types/proxmox";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MetricCard } from "@/features/host-metrics/cards/MetricCard";
|
||||
|
||||
export function ClusterHealthCard({
|
||||
snapshot,
|
||||
}: {
|
||||
snapshot: ProxmoxStatsSnapshot | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const cluster = snapshot?.cluster;
|
||||
|
||||
return (
|
||||
<MetricCard
|
||||
title={t("proxmoxStats.clusterHealth")}
|
||||
icon={<Network className="size-3.5" />}
|
||||
scroll
|
||||
>
|
||||
{!cluster || !cluster.clustered ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("proxmoxStats.notClustered")}
|
||||
</span>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"size-1.5 shrink-0 rounded-full",
|
||||
cluster.quorate ? "bg-green-500" : "bg-red-500",
|
||||
)}
|
||||
/>
|
||||
<span className="font-medium">
|
||||
{cluster.clusterName ?? t("proxmoxStats.clusterHealth")}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{cluster.quorate
|
||||
? t("proxmoxStats.quorate")
|
||||
: t("proxmoxStats.notQuorate")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col divide-y divide-border">
|
||||
{cluster.nodes.map((node) => (
|
||||
<div key={node.name} className="flex items-center gap-2 py-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
"size-1.5 shrink-0 rounded-full",
|
||||
node.online ? "bg-green-500" : "bg-muted-foreground/50",
|
||||
)}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate font-medium">
|
||||
{node.name}
|
||||
</span>
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground">
|
||||
{node.online
|
||||
? t("proxmoxStats.nodeOnline")
|
||||
: t("proxmoxStats.nodeOffline")}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</MetricCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Network } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ProxmoxStatsSnapshot } from "@/types/proxmox";
|
||||
import { StatRow } from "@/components/charts";
|
||||
import { MetricCard } from "@/features/host-metrics/cards/MetricCard";
|
||||
|
||||
export function NodeNetworkCard({
|
||||
snapshot,
|
||||
}: {
|
||||
snapshot: ProxmoxStatsSnapshot | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const interfaces = snapshot?.network.interfaces ?? [];
|
||||
|
||||
return (
|
||||
<MetricCard
|
||||
title={t("proxmoxStats.networkInterfaces")}
|
||||
icon={<Network className="size-3.5" />}
|
||||
scroll
|
||||
>
|
||||
{interfaces.length === 0 ? (
|
||||
<span className="text-xs text-muted-foreground">N/A</span>
|
||||
) : (
|
||||
<div className="flex flex-col divide-y divide-border">
|
||||
{interfaces.map((iface) => (
|
||||
<StatRow
|
||||
key={iface.name}
|
||||
label={iface.name}
|
||||
value={iface.ip ?? iface.state ?? "-"}
|
||||
mono
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</MetricCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Database } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ProxmoxStatsSnapshot } from "@/types/proxmox";
|
||||
import { MetricCard } from "@/features/host-metrics/cards/MetricCard";
|
||||
|
||||
export function StoragePoolsCard({
|
||||
snapshot,
|
||||
}: {
|
||||
snapshot: ProxmoxStatsSnapshot | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const pools = snapshot?.storage.pools ?? [];
|
||||
|
||||
return (
|
||||
<MetricCard
|
||||
title={t("proxmoxStats.storagePools")}
|
||||
icon={<Database className="size-3.5" />}
|
||||
scroll
|
||||
>
|
||||
{pools.length === 0 ? (
|
||||
<span className="text-xs text-muted-foreground">N/A</span>
|
||||
) : (
|
||||
<div className="flex flex-col divide-y divide-border">
|
||||
{pools.map((pool) => (
|
||||
<div key={pool.name} className="flex flex-col gap-1 py-2 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate font-medium">
|
||||
{pool.name}
|
||||
</span>
|
||||
<span className="shrink-0 rounded-full border border-border px-1.5 py-0.5 text-[10px] uppercase text-muted-foreground">
|
||||
{pool.type}
|
||||
</span>
|
||||
{!pool.active && (
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground">
|
||||
{t("proxmoxStats.inactive")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full bg-accent-brand"
|
||||
style={{
|
||||
width: `${Math.min(100, Math.max(0, pool.percent ?? 0))}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="shrink-0 text-[10px] tabular-nums text-muted-foreground">
|
||||
{pool.usedGiB !== null && pool.totalGiB !== null
|
||||
? `${pool.usedGiB.toFixed(1)} / ${pool.totalGiB.toFixed(1)} GiB`
|
||||
: "N/A"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</MetricCard>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getErrorMessage } from "../../lib/error-message.js";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Pause, Play } from "lucide-react";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
@@ -138,9 +139,7 @@ function AsciicastPlayer({ blob }: { blob: Blob }) {
|
||||
terminal.resize(recording.width, recording.height);
|
||||
renderAt(0);
|
||||
})
|
||||
.catch((reason) =>
|
||||
setError(reason instanceof Error ? reason.message : String(reason)),
|
||||
);
|
||||
.catch((reason) => setError(getErrorMessage(reason, String(reason))));
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
|
||||
export type AsciicastEvent = [
|
||||
time: number,
|
||||
type: "i" | "o" | "r",
|
||||
@@ -37,3 +39,40 @@ export function parseAsciicast(source: string): Asciicast {
|
||||
events,
|
||||
};
|
||||
}
|
||||
|
||||
function writeAsync(terminal: Terminal, data: string): Promise<void> {
|
||||
return new Promise((resolve) => terminal.write(data, resolve));
|
||||
}
|
||||
|
||||
// Replays the recording into an offscreen terminal and reads back its
|
||||
// rendered buffer, so cursor movement/overwrites resolve the same way
|
||||
// they would on screen instead of leaving raw ANSI escapes in the text.
|
||||
export async function asciicastToPlainText(
|
||||
recording: Asciicast,
|
||||
): Promise<string> {
|
||||
const terminal = new Terminal({
|
||||
cols: recording.width,
|
||||
rows: recording.height,
|
||||
allowProposedApi: true,
|
||||
});
|
||||
|
||||
try {
|
||||
for (const [, type, data] of recording.events) {
|
||||
if (type === "o") await writeAsync(terminal, data);
|
||||
if (type === "r") {
|
||||
const [cols, rows] = data.split("x").map(Number);
|
||||
if (cols > 0 && rows > 0) terminal.resize(cols, rows);
|
||||
}
|
||||
}
|
||||
|
||||
const buffer = terminal.buffer.active;
|
||||
const lines: string[] = [];
|
||||
for (let i = 0; i < buffer.length; i++) {
|
||||
lines.push(buffer.getLine(i)?.translateToString(true) ?? "");
|
||||
}
|
||||
while (lines.length > 0 && lines.at(-1) === "") lines.pop();
|
||||
return lines.join("\n");
|
||||
} finally {
|
||||
terminal.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getErrorMessage } from "../../lib/error-message.js";
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import {
|
||||
useEffect,
|
||||
@@ -12,7 +13,7 @@ import { useXTerm } from "react-xtermjs";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { ClipboardAddon } from "@xterm/addon-clipboard";
|
||||
import { RobustClipboardProvider } from "@/lib/clipboard-provider";
|
||||
import { copyToClipboard } from "@/lib/clipboard";
|
||||
import { copyToClipboard, readFromClipboard } from "@/lib/clipboard";
|
||||
import { Unicode11Addon } from "@xterm/addon-unicode11";
|
||||
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||
import { SearchAddon } from "@xterm/addon-search";
|
||||
@@ -42,6 +43,7 @@ import { TmuxSessionPicker } from "@/ssh/dialogs/TmuxSessionPicker.tsx";
|
||||
import {
|
||||
DEFAULT_TERMINAL_CONFIG,
|
||||
TERMINAL_FONTS,
|
||||
resolveTerminalFontFamily,
|
||||
} from "@/lib/terminal-themes.ts";
|
||||
import { ensureTerminalFontsLoaded } from "./terminal-global-styles.ts";
|
||||
import { useTheme } from "@/components/theme-provider.tsx";
|
||||
@@ -53,33 +55,51 @@ import {
|
||||
} from "@/lib/terminal-syntax-highlighter.ts";
|
||||
import { useCommandHistory } from "@/features/terminal/command-history/CommandHistoryContext.tsx";
|
||||
import { getAndroidHardwareKeySequence } from "@/features/terminal/android-hardware-keyboard.ts";
|
||||
import {
|
||||
buildImageUploadFormData,
|
||||
type TerminalImageUploadSource,
|
||||
} from "@/features/terminal/terminal-image-upload.ts";
|
||||
import { CommandAutocomplete } from "./command-history/CommandAutocomplete.tsx";
|
||||
import { TerminalSearchBar } from "./search/TerminalSearchBar.tsx";
|
||||
import { SimpleLoader } from "@/lib/SimpleLoader.tsx";
|
||||
import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
||||
import {
|
||||
ConnectionLogProvider,
|
||||
useConnectionLog,
|
||||
} from "@/ssh/connection-log/ConnectionLogContext.tsx";
|
||||
import { ConnectionLog } from "@/ssh/connection-log/ConnectionLog.tsx";
|
||||
import { ConnectionScreen } from "@/components/connection/ConnectionScreen.tsx";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/button";
|
||||
import { Save } from "lucide-react";
|
||||
import { authApi } from "@/main-axios.ts";
|
||||
import { resolveTermixThemeColors } from "./terminal-theme.ts";
|
||||
import { ShareSessionModal } from "@/features/session-sharing/ShareSessionModal.tsx";
|
||||
import { TerminalToolbar } from "./TerminalToolbar.tsx";
|
||||
import type { TerminalHandle, TerminalHostConfig } from "./terminal-types.ts";
|
||||
import { type Host, type Snippet, type TabType } from "@/types/ui-types";
|
||||
import {
|
||||
getNextTerminalFontSize,
|
||||
getTerminalFontZoomDirection,
|
||||
} from "./terminal-font-zoom.ts";
|
||||
import { isTabKeyEvent } from "./terminal-key-event.ts";
|
||||
import { installTouchWheelCoordinator } from "./touch-wheel-coordinator.ts";
|
||||
import { loadTouchInputSettings } from "./touch-input-settings-store.ts";
|
||||
import { quoteTerminalImagePath } from "./terminal-image-path.ts";
|
||||
import {
|
||||
getUserPreferences,
|
||||
parseCustomKeybindings,
|
||||
} from "@/api/open-tabs-api";
|
||||
import { findMatchingKeybinding } from "@/lib/keybinding-match";
|
||||
import { dispatchKeybindingAction } from "@/lib/keybinding-dispatch";
|
||||
import {
|
||||
dispatchKeybindingAction,
|
||||
sendRawToSocket,
|
||||
} from "@/lib/keybinding-dispatch";
|
||||
import { SnippetVariablesDialog } from "@/components/SnippetVariablesDialog";
|
||||
import type { CustomKeybinding } from "@/types/keybindings";
|
||||
import { useConnectionDefaults } from "@/contexts/ConnectionDefaultsContext";
|
||||
import {
|
||||
TerminalLocalEcho,
|
||||
resolveLocalEchoMode,
|
||||
} from "@/lib/terminal-local-echo";
|
||||
export type { TerminalHandle, TerminalHostConfig } from "./terminal-types.ts";
|
||||
|
||||
type HostKeyVerificationData = Omit<
|
||||
@@ -106,6 +126,11 @@ interface SSHTerminalProps {
|
||||
disableAutoFocus?: boolean;
|
||||
isQuickConnect?: boolean;
|
||||
onSaveQuickConnect?: () => Promise<void>;
|
||||
/** Full host record, used to drive the context-aware terminal toolbar. */
|
||||
host?: Host;
|
||||
onOpenTab?: (type: TabType) => void;
|
||||
/** False when this terminal sits in an unfocused split pane. */
|
||||
isFocusedPane?: boolean;
|
||||
}
|
||||
|
||||
const ALTERNATE_SCREEN_SEQUENCE = /\x1b\[\?(47|1047|1049)([hl])/g;
|
||||
@@ -141,6 +166,9 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
disableAutoFocus = false,
|
||||
isQuickConnect = false,
|
||||
onSaveQuickConnect,
|
||||
host,
|
||||
onOpenTab,
|
||||
isFocusedPane = true,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
@@ -149,17 +177,21 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
const commandHistoryContext = useCommandHistory();
|
||||
const { confirmWithToast } = useConfirmation();
|
||||
const { theme: appTheme } = useTheme();
|
||||
const { addLog, isExpanded: isConnectionLogExpanded } = useConnectionLog();
|
||||
const { addLog } = useConnectionLog();
|
||||
const { terminal: terminalDefaults } = useConnectionDefaults();
|
||||
const outputListenersRef = useRef(new Set<(data: string) => void>());
|
||||
|
||||
const savedTheme = localStorage.getItem(
|
||||
`terminal_theme_host_${hostConfig.id}`,
|
||||
);
|
||||
const config = {
|
||||
...DEFAULT_TERMINAL_CONFIG,
|
||||
...terminalDefaults,
|
||||
...hostConfig.terminalConfig,
|
||||
theme:
|
||||
savedTheme ||
|
||||
hostConfig.terminalConfig?.theme ||
|
||||
terminalDefaults.theme ||
|
||||
DEFAULT_TERMINAL_CONFIG.theme,
|
||||
};
|
||||
|
||||
@@ -176,10 +208,17 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
: themeColors.background;
|
||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||
const webSocketRef = useRef<WebSocket | null>(null);
|
||||
const terminalInputDisposableRef = useRef<{ dispose(): void } | null>(null);
|
||||
const localEchoRef = useRef<TerminalLocalEcho | null>(null);
|
||||
const customKeybindingsRef = useRef<CustomKeybinding[]>([]);
|
||||
const cachedSnippetsRef = useRef<{ id: number; content: string }[] | null>(
|
||||
null,
|
||||
);
|
||||
const [pendingKeybindingSnippet, setPendingKeybindingSnippet] = useState<{
|
||||
id: string;
|
||||
content: string;
|
||||
appendEnter: boolean;
|
||||
} | null>(null);
|
||||
const resizeTimeout = useRef<NodeJS.Timeout | null>(null);
|
||||
const wasDisconnectedBySSH = useRef(false);
|
||||
const pingIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
@@ -189,6 +228,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
const [shareModalOpen, setShareModalOpen] = useState(false);
|
||||
const [isSavingQuickConnect, setIsSavingQuickConnect] = useState(false);
|
||||
const [isQuickConnectSaved, setIsQuickConnectSaved] = useState(false);
|
||||
const [isImageUploading, setIsImageUploading] = useState(false);
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const [isFitted, setIsFitted] = useState(false);
|
||||
const [connectionError, setConnectionError] = useState<string | null>(null);
|
||||
@@ -478,6 +518,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
|
||||
const activityLoggingRef = useRef(false);
|
||||
const passwordPromptShownRef = useRef(false);
|
||||
const passwordPromptBufferRef = useRef("");
|
||||
const alternateScreenModeRef = useRef(false);
|
||||
const controlStringModeRef = useRef(false);
|
||||
|
||||
@@ -874,9 +915,17 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
}
|
||||
|
||||
function maybeOfferPasswordFill(strippedData: string) {
|
||||
// PTY output can split a short prompt like "[sudo] password for user: "
|
||||
// across multiple WebSocket chunks, so match against a rolling buffer
|
||||
// of recent output rather than each chunk in isolation.
|
||||
const buffered = (passwordPromptBufferRef.current + strippedData).slice(
|
||||
-200,
|
||||
);
|
||||
passwordPromptBufferRef.current = buffered;
|
||||
|
||||
const passwordPromptPattern =
|
||||
/(?:\[sudo\][^\n\r]*:\s*$|sudo:[^\n\r]*password[^\n\r]*required|password for [^\n\r]*:\s*$|Password:\s*$|password:\s*$)/im;
|
||||
if (!passwordPromptPattern.test(strippedData)) return;
|
||||
if (!passwordPromptPattern.test(buffered)) return;
|
||||
|
||||
const hasStoredPassword =
|
||||
hostConfig.terminalConfig?.sudoPassword ||
|
||||
@@ -886,7 +935,8 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
if (!hasStoredPassword || passwordPromptShownRef.current) return;
|
||||
|
||||
passwordPromptShownRef.current = true;
|
||||
const isSudoPrompt = /(?:\[sudo\]|sudo:)/i.test(strippedData);
|
||||
passwordPromptBufferRef.current = "";
|
||||
const isSudoPrompt = /(?:\[sudo\]|sudo:)/i.test(buffered);
|
||||
|
||||
confirmWithToast(
|
||||
t("terminal.passwordPromptFillTitle"),
|
||||
@@ -1004,6 +1054,10 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
webSocketRef.current.send(JSON.stringify({ type: "input", data }));
|
||||
}
|
||||
},
|
||||
subscribeOutput: (listener: (data: string) => void) => {
|
||||
outputListenersRef.current.add(listener);
|
||||
return () => outputListenersRef.current.delete(listener);
|
||||
},
|
||||
paste: (text: string) => {
|
||||
terminal?.paste(text);
|
||||
},
|
||||
@@ -1178,6 +1232,8 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
webSocketRef.current &&
|
||||
webSocketRef.current.readyState !== WebSocket.CLOSED
|
||||
) {
|
||||
terminalInputDisposableRef.current?.dispose();
|
||||
terminalInputDisposableRef.current = null;
|
||||
webSocketRef.current.close();
|
||||
}
|
||||
|
||||
@@ -1289,7 +1345,15 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
}),
|
||||
);
|
||||
}
|
||||
terminal.onData((data) => {
|
||||
terminalInputDisposableRef.current?.dispose();
|
||||
localEchoRef.current = new TerminalLocalEcho(
|
||||
resolveLocalEchoMode(
|
||||
hostConfig.terminalConfig?.localEcho,
|
||||
localStorage.getItem("terminalLocalEchoMode"),
|
||||
),
|
||||
);
|
||||
terminalInputDisposableRef.current = terminal.onData((data) => {
|
||||
if (ws.readyState !== WebSocket.OPEN) return;
|
||||
if (data === "\r" || data === "\n") {
|
||||
const currentCmd = getCurrentCommand().trim();
|
||||
const termixMatch = currentCmd.match(/^termix\s+(.+)$/);
|
||||
@@ -1309,6 +1373,8 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
}
|
||||
}
|
||||
trackInput(data);
|
||||
const predicted = localEchoRef.current?.handleInput(data);
|
||||
if (predicted) terminal.write(predicted);
|
||||
ws.send(JSON.stringify({ type: "input", data }));
|
||||
});
|
||||
|
||||
@@ -1337,6 +1403,9 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
}
|
||||
if (msg.type === "data") {
|
||||
if (typeof msg.data === "string") {
|
||||
outputListenersRef.current.forEach((listener) =>
|
||||
listener(msg.data),
|
||||
);
|
||||
if (showAutocompleteRef.current) {
|
||||
showAutocompleteRef.current = false;
|
||||
setShowAutocomplete(false);
|
||||
@@ -1344,17 +1413,21 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
currentAutocompleteCommand.current = "";
|
||||
}
|
||||
|
||||
terminal.write(formatTerminalOutput(msg.data));
|
||||
const output =
|
||||
localEchoRef.current?.handleOutput(msg.data) ?? msg.data;
|
||||
terminal.write(formatTerminalOutput(output));
|
||||
// Strip ANSI escape codes before testing — newer sudo versions (Ubuntu 26.04+)
|
||||
// emit colored prompts with embedded escape sequences that break the regex.
|
||||
const strippedData = msg.data.replace(
|
||||
/\x1b(?:[@-Z\\-_]|\[[0-9;?>=!]*[@-~])/g,
|
||||
/\x1b(?:[@-Z\\-_]|\[[0-9:;<=>?!]*[@-~])/g,
|
||||
"",
|
||||
);
|
||||
maybeOfferPasswordFill(strippedData);
|
||||
} else {
|
||||
const stringData = String(msg.data);
|
||||
terminal.write(formatTerminalOutput(stringData));
|
||||
const output =
|
||||
localEchoRef.current?.handleOutput(stringData) ?? stringData;
|
||||
terminal.write(formatTerminalOutput(output));
|
||||
}
|
||||
} else if (msg.type === "error") {
|
||||
const errorMessage = msg.message || t("terminal.unknownError");
|
||||
@@ -1433,6 +1506,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
setTimeout(async () => {
|
||||
const terminalConfig = {
|
||||
...DEFAULT_TERMINAL_CONFIG,
|
||||
...terminalDefaults,
|
||||
...hostConfig.terminalConfig,
|
||||
};
|
||||
|
||||
@@ -1988,6 +2062,9 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
return;
|
||||
}
|
||||
|
||||
terminalInputDisposableRef.current?.dispose();
|
||||
terminalInputDisposableRef.current = null;
|
||||
|
||||
setIsConnected(false);
|
||||
isConnectingRef.current = false;
|
||||
|
||||
@@ -2120,20 +2197,11 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
}
|
||||
|
||||
async function readTextFromClipboard(): Promise<string> {
|
||||
try {
|
||||
if (window.electronClipboard) {
|
||||
return window.electronClipboard.readText();
|
||||
}
|
||||
if (navigator.clipboard && navigator.clipboard.readText) {
|
||||
return await navigator.clipboard.readText();
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
if (window.location.protocol !== "https:" && !isElectron()) {
|
||||
const text = await readFromClipboard();
|
||||
if (!text && window.location.protocol !== "https:" && !isElectron()) {
|
||||
toast.error(t("terminal.clipboardHttpWarning"));
|
||||
}
|
||||
return "";
|
||||
return text;
|
||||
}
|
||||
|
||||
const handleSelectCommand = useCallback(
|
||||
@@ -2216,6 +2284,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
|
||||
const config = {
|
||||
...DEFAULT_TERMINAL_CONFIG,
|
||||
...terminalDefaults,
|
||||
...hostConfig.terminalConfig,
|
||||
};
|
||||
|
||||
@@ -2226,11 +2295,8 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
config.customThemeColors,
|
||||
);
|
||||
|
||||
const fontConfig = TERMINAL_FONTS.find(
|
||||
(f) => f.value === config.fontFamily,
|
||||
);
|
||||
const fontFamily = fontConfig?.fallback || TERMINAL_FONTS[0].fallback;
|
||||
ensureTerminalFontsLoaded(fontConfig?.value || TERMINAL_FONTS[0].value);
|
||||
const fontFamily = resolveTerminalFontFamily(config.fontFamily);
|
||||
ensureTerminalFontsLoaded(config.fontFamily || TERMINAL_FONTS[0].value);
|
||||
|
||||
// Update terminal options individually to avoid re-initialization flashes
|
||||
terminal.options.cursorBlink = config.cursorBlink;
|
||||
@@ -2240,12 +2306,11 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
terminalFontSizeRef.current = config.fontSize;
|
||||
terminal.options.fontFamily = fontFamily;
|
||||
terminal.options.rightClickSelectsWord = config.rightClickSelectsWord;
|
||||
terminal.options.macOptionIsMeta = config.macOptionIsMeta;
|
||||
terminal.options.fastScrollSensitivity = config.fastScrollSensitivity;
|
||||
terminal.options.minimumContrastRatio = config.minimumContrastRatio;
|
||||
terminal.options.letterSpacing = config.letterSpacing;
|
||||
terminal.options.lineHeight = config.lineHeight;
|
||||
terminal.options.bellStyle = config.bellStyle as
|
||||
"none" | "sound" | "visual" | "both";
|
||||
|
||||
terminal.options.theme = {
|
||||
background: config.backgroundImage
|
||||
@@ -2281,21 +2346,26 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
|
||||
// Refresh terminal to apply new theme colors to existing buffer content
|
||||
hardRefresh();
|
||||
}, [terminal, hostConfig.terminalConfig, previewTheme, appTheme, isFitted]);
|
||||
}, [
|
||||
terminal,
|
||||
terminalDefaults,
|
||||
hostConfig.terminalConfig,
|
||||
previewTheme,
|
||||
appTheme,
|
||||
isFitted,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!terminal || !xtermRef.current) return;
|
||||
|
||||
const config = {
|
||||
...DEFAULT_TERMINAL_CONFIG,
|
||||
...terminalDefaults,
|
||||
...hostConfig.terminalConfig,
|
||||
};
|
||||
|
||||
const fontConfig = TERMINAL_FONTS.find(
|
||||
(f) => f.value === config.fontFamily,
|
||||
);
|
||||
const fontFamily = fontConfig?.fallback || TERMINAL_FONTS[0].fallback;
|
||||
ensureTerminalFontsLoaded(fontConfig?.value || TERMINAL_FONTS[0].value);
|
||||
const fontFamily = resolveTerminalFontFamily(config.fontFamily);
|
||||
ensureTerminalFontsLoaded(config.fontFamily || TERMINAL_FONTS[0].value);
|
||||
|
||||
const activeTheme = previewTheme || config.theme;
|
||||
const themeColors = resolveTermixThemeColors(
|
||||
@@ -2313,7 +2383,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
fontFamily,
|
||||
allowTransparency: true, // MUST be set before open()
|
||||
convertEol: false,
|
||||
macOptionIsMeta: true,
|
||||
macOptionIsMeta: config.macOptionIsMeta,
|
||||
macOptionClickForcesSelection: false,
|
||||
rightClickSelectsWord: config.rightClickSelectsWord,
|
||||
fastScrollSensitivity: config.fastScrollSensitivity,
|
||||
@@ -2321,7 +2391,6 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
minimumContrastRatio: config.minimumContrastRatio,
|
||||
letterSpacing: config.letterSpacing,
|
||||
lineHeight: config.lineHeight,
|
||||
bellStyle: config.bellStyle as "none" | "sound" | "visual" | "both",
|
||||
theme: {
|
||||
background: config.backgroundImage
|
||||
? "transparent"
|
||||
@@ -2417,6 +2486,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
|
||||
const cfg = {
|
||||
...DEFAULT_TERMINAL_CONFIG,
|
||||
...terminalDefaults,
|
||||
...hostConfig.terminalConfig,
|
||||
};
|
||||
const mod = cfg.fastScrollModifier;
|
||||
@@ -2444,6 +2514,20 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
});
|
||||
});
|
||||
|
||||
// Send one-finger drags through xterm's real wheel DOM path. This keeps
|
||||
// scrollback, alternate-buffer/tmux, mouse reporting and wheel remainder
|
||||
// handling identical to desktop wheel input.
|
||||
let disposeTouchWheel = () => {};
|
||||
let touchWheelDisposed = false;
|
||||
loadTouchInputSettings().then((settings) => {
|
||||
if (!touchWheelDisposed && xtermRef.current) {
|
||||
disposeTouchWheel = installTouchWheelCoordinator(
|
||||
xtermRef.current,
|
||||
undefined,
|
||||
settings,
|
||||
);
|
||||
}
|
||||
});
|
||||
const element = xtermRef.current;
|
||||
const handleContextMenu = (e: MouseEvent) => {
|
||||
if (e.ctrlKey && onOpenFileManager) {
|
||||
@@ -2505,6 +2589,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
|
||||
const config = {
|
||||
...DEFAULT_TERMINAL_CONFIG,
|
||||
...terminalDefaults,
|
||||
...hostConfig.terminalConfig,
|
||||
};
|
||||
if (config.backspaceMode !== "control-h") return;
|
||||
@@ -2548,6 +2633,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
resizeObserver.observe(observeTarget);
|
||||
|
||||
return () => {
|
||||
touchWheelDisposed = true;
|
||||
isFittingRef.current = false;
|
||||
resizeObserver.disconnect();
|
||||
clipboardProvider.dispose();
|
||||
@@ -2558,6 +2644,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
element?.removeEventListener("mouseup", handleTmuxDragEnd);
|
||||
element?.removeEventListener("keydown", handleBackspaceMode, true);
|
||||
element?.removeEventListener("keydown", handleTabCapture, true);
|
||||
disposeTouchWheel();
|
||||
if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current);
|
||||
if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
|
||||
};
|
||||
@@ -2565,6 +2652,14 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
|
||||
const isMountedRef = useRef(false);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
terminalInputDisposableRef.current?.dispose();
|
||||
terminalInputDisposableRef.current = null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
|
||||
@@ -2670,6 +2765,17 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
writeTextToClipboard,
|
||||
readTextFromClipboard,
|
||||
getSnippetById,
|
||||
hostContext: {
|
||||
ip: hostConfig.ip,
|
||||
username: hostConfig.username,
|
||||
port: hostConfig.port,
|
||||
name: hostConfig.name,
|
||||
},
|
||||
onSnippetNeedsInputs: (snippet) =>
|
||||
setPendingKeybindingSnippet({
|
||||
...snippet,
|
||||
appendEnter: matched.action.appendEnter !== false,
|
||||
}),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
@@ -2743,7 +2849,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
"ArrowUp",
|
||||
"ArrowDown",
|
||||
];
|
||||
if (arrowCodes.includes(e.code)) {
|
||||
if (arrowCodes.includes(e.code) || /^Digit[1-9]$/.test(e.code)) {
|
||||
e.stopPropagation();
|
||||
globalShortcutHandler.current?.(e);
|
||||
return false;
|
||||
@@ -3119,6 +3225,131 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
|
||||
const hasConnectionError = !!connectionError;
|
||||
|
||||
function getImageUploadErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error && error.message) return error.message;
|
||||
const response = (
|
||||
error as {
|
||||
response?: {
|
||||
data?: { error?: string; message?: string };
|
||||
};
|
||||
}
|
||||
)?.response;
|
||||
return (
|
||||
response?.data?.error ||
|
||||
response?.data?.message ||
|
||||
"Image upload failed"
|
||||
);
|
||||
}
|
||||
|
||||
async function handleImageUpload(
|
||||
file: File,
|
||||
source: TerminalImageUploadSource,
|
||||
) {
|
||||
if (file.type && !file.type.startsWith("image/")) {
|
||||
toast.error("Choose an image file");
|
||||
return;
|
||||
}
|
||||
setIsImageUploading(true);
|
||||
try {
|
||||
const form = buildImageUploadFormData(
|
||||
file,
|
||||
hostConfig.instanceId ?? "",
|
||||
source,
|
||||
);
|
||||
const response = await authApi.post("/terminal/image-upload", form, {
|
||||
headers: { "Content-Type": undefined },
|
||||
});
|
||||
const { shellPath } = response.data as {
|
||||
shellPath: string;
|
||||
};
|
||||
const pathInserted =
|
||||
webSocketRef.current?.readyState === WebSocket.OPEN;
|
||||
if (pathInserted) {
|
||||
webSocketRef.current?.send(
|
||||
JSON.stringify({
|
||||
type: "input",
|
||||
data: quoteTerminalImagePath(shellPath),
|
||||
}),
|
||||
);
|
||||
toast.success(`Image uploaded: ${shellPath}`);
|
||||
} else {
|
||||
toast.warning(
|
||||
`Image uploaded, but the terminal was not available to paste it: ${shellPath}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
const response = (error as { response?: { data?: { code?: string } } })
|
||||
?.response;
|
||||
const code = response?.data?.code;
|
||||
const message = getImageUploadErrorMessage(error);
|
||||
toast.error(code ? `${message} (${code})` : message);
|
||||
} finally {
|
||||
setIsImageUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClipboardImage() {
|
||||
if (!navigator.clipboard?.read) {
|
||||
toast.error("Clipboard image access is not available in this browser");
|
||||
return;
|
||||
}
|
||||
setIsImageUploading(true);
|
||||
try {
|
||||
const items = await navigator.clipboard.read();
|
||||
for (const item of items) {
|
||||
const imageType = item.types.find((type) =>
|
||||
type.startsWith("image/"),
|
||||
);
|
||||
if (!imageType) continue;
|
||||
const blob = await item.getType(imageType);
|
||||
let clipboardFile = new File([blob], "clipboard-image.png", {
|
||||
type: imageType,
|
||||
});
|
||||
// Preserve native PNG clipboard bytes. Some browser/platform
|
||||
// clipboard implementations decode transparent PNGs incorrectly
|
||||
// through canvas, producing an all-black/transparent re-encode.
|
||||
// Only rasterize formats that need conversion; Sharp validates the
|
||||
// resulting image server-side.
|
||||
if (
|
||||
imageType !== "image/png" &&
|
||||
typeof createImageBitmap === "function"
|
||||
) {
|
||||
try {
|
||||
const bitmap = await createImageBitmap(blob);
|
||||
try {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = bitmap.width;
|
||||
canvas.height = bitmap.height;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) throw new Error("Canvas unavailable");
|
||||
context.drawImage(bitmap, 0, 0);
|
||||
const png = await new Promise<Blob>((resolve, reject) => {
|
||||
canvas.toBlob((result) => {
|
||||
if (result) resolve(result);
|
||||
else reject(new Error("Clipboard image conversion failed"));
|
||||
}, "image/png");
|
||||
});
|
||||
clipboardFile = new File([png], "clipboard-image.png", {
|
||||
type: "image/png",
|
||||
});
|
||||
} finally {
|
||||
bitmap.close();
|
||||
}
|
||||
} catch {
|
||||
// Fall back to the original clipboard blob.
|
||||
}
|
||||
}
|
||||
await handleImageUpload(clipboardFile, "clipboard");
|
||||
return;
|
||||
}
|
||||
toast.error("No image found in the clipboard");
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, "Clipboard read failed"));
|
||||
} finally {
|
||||
setIsImageUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="h-full w-full relative"
|
||||
@@ -3158,20 +3389,31 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
}}
|
||||
/>
|
||||
|
||||
{isTmuxAttached && isConnected && (
|
||||
<button
|
||||
onClick={() => {
|
||||
{host && host.enableTerminalToolbar !== false && (
|
||||
<TerminalToolbar
|
||||
host={host}
|
||||
isConnected={isConnected}
|
||||
isTmuxAttached={isTmuxAttached}
|
||||
onTmuxDetach={() => {
|
||||
if (webSocketRef.current?.readyState === WebSocket.OPEN) {
|
||||
webSocketRef.current.send(
|
||||
JSON.stringify({ type: "tmux_detach" }),
|
||||
);
|
||||
}
|
||||
}}
|
||||
title={t("terminal.tmuxDetach")}
|
||||
className="absolute top-2 right-2 z-[110] px-2 py-1 text-xs rounded bg-black/60 text-white/70 hover:text-white hover:bg-black/80 transition-colors"
|
||||
>
|
||||
tmux:detach
|
||||
</button>
|
||||
isImageUploading={isImageUploading}
|
||||
onUploadImage={(file) => void handleImageUpload(file, "file")}
|
||||
onPasteImage={() => void handleClipboardImage()}
|
||||
onOpenTab={onOpenTab}
|
||||
onOpenFiles={() => {
|
||||
if (webSocketRef.current?.readyState === WebSocket.OPEN) {
|
||||
webSocketRef.current.send(JSON.stringify({ type: "get_cwd" }));
|
||||
} else {
|
||||
onOpenFileManager?.("/");
|
||||
}
|
||||
}}
|
||||
isFocused={isFocusedPane}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isQuickConnect &&
|
||||
@@ -3200,54 +3442,45 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<SimpleLoader
|
||||
visible={isConnecting && !isConnectionLogExpanded}
|
||||
<ConnectionScreen
|
||||
status={
|
||||
showDisconnectedOverlay
|
||||
? "disconnected"
|
||||
: isConnecting
|
||||
? "connecting"
|
||||
: hasConnectionError
|
||||
? "error"
|
||||
: "connected"
|
||||
}
|
||||
message={t("terminal.connecting")}
|
||||
backgroundColor={backgroundColor}
|
||||
/>
|
||||
|
||||
{showDisconnectedOverlay && !isConnecting && (
|
||||
<div
|
||||
className="absolute inset-0 flex flex-col items-center justify-center gap-3 z-[120]"
|
||||
style={{ backgroundColor }}
|
||||
>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("terminal.connectionLost")}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowDisconnectedOverlay(false);
|
||||
isUnmountingRef.current = false;
|
||||
shouldNotReconnectRef.current = false;
|
||||
isReconnectingRef.current = false;
|
||||
isConnectingRef.current = false;
|
||||
reconnectAttempts.current = 0;
|
||||
wasDisconnectedBySSH.current = false;
|
||||
wasConnectedRef.current = false;
|
||||
updateConnectionError(null);
|
||||
if (terminal) {
|
||||
terminal.clear();
|
||||
connectToHost(terminal.cols, terminal.rows);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("terminal.reconnect")}
|
||||
attempt={reconnectAttempts.current}
|
||||
maxAttempts={maxReconnectAttempts}
|
||||
disconnectedMessage={t("terminal.connectionLost")}
|
||||
retryLabel={t("terminal.reconnect")}
|
||||
onManualRetry={() => {
|
||||
setShowDisconnectedOverlay(false);
|
||||
isUnmountingRef.current = false;
|
||||
shouldNotReconnectRef.current = false;
|
||||
isReconnectingRef.current = false;
|
||||
isConnectingRef.current = false;
|
||||
reconnectAttempts.current = 0;
|
||||
wasDisconnectedBySSH.current = false;
|
||||
wasConnectedRef.current = false;
|
||||
updateConnectionError(null);
|
||||
if (terminal) {
|
||||
terminal.clear();
|
||||
connectToHost(terminal.cols, terminal.rows);
|
||||
}
|
||||
}}
|
||||
extraActions={
|
||||
onClose && (
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
{t("terminal.closeTab")}
|
||||
</Button>
|
||||
{onClose && (
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
{t("terminal.closeTab")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConnectionLog
|
||||
isConnecting={isConnecting}
|
||||
isConnected={isConnected}
|
||||
hasConnectionError={hasConnectionError && !showDisconnectedOverlay}
|
||||
position={hasConnectionError ? "top" : "bottom"}
|
||||
)
|
||||
}
|
||||
logPosition={hasConnectionError ? "top" : "bottom"}
|
||||
/>
|
||||
|
||||
<TOTPDialog
|
||||
@@ -3475,6 +3708,51 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
/>
|
||||
)}
|
||||
|
||||
{pendingKeybindingSnippet && (
|
||||
<SnippetVariablesDialog
|
||||
snippet={
|
||||
{
|
||||
id: 0,
|
||||
name: t("newUi.sidebar.keybindings.actionRunSnippet"),
|
||||
content: pendingKeybindingSnippet.content,
|
||||
folder: null,
|
||||
order: 0,
|
||||
} as Snippet
|
||||
}
|
||||
host={{
|
||||
ip: hostConfig.ip,
|
||||
username: hostConfig.username,
|
||||
port: hostConfig.port,
|
||||
name: hostConfig.name,
|
||||
}}
|
||||
onCancel={() => setPendingKeybindingSnippet(null)}
|
||||
onConfirm={(resolvedContent) => {
|
||||
const appendEnter = pendingKeybindingSnippet.appendEnter;
|
||||
setPendingKeybindingSnippet(null);
|
||||
const send = () =>
|
||||
sendRawToSocket(
|
||||
webSocketRef,
|
||||
resolvedContent + (appendEnter ? "\r" : ""),
|
||||
);
|
||||
const shouldConfirm =
|
||||
localStorage.getItem("confirmSnippetExecution") === "true";
|
||||
if (shouldConfirm) {
|
||||
confirmWithToast(
|
||||
t("newUi.sidebar.snippets.confirmRunMessage", {
|
||||
name: t("newUi.sidebar.keybindings.actionRunSnippet"),
|
||||
}),
|
||||
send,
|
||||
t("newUi.sidebar.snippets.confirmRunButton"),
|
||||
t("newUi.sidebar.snippets.cancel"),
|
||||
{ confirmOnEnter: true, duration: 6000 },
|
||||
);
|
||||
} else {
|
||||
send();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{tmuxSessionPicker && (
|
||||
<TmuxSessionPicker
|
||||
isOpen={true}
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Terminal } from "@/features/terminal/Terminal.tsx";
|
||||
import { FullScreenAppWrapper } from "@/features/FullScreenAppWrapper.tsx";
|
||||
import { ConnectionScreen } from "@/components/connection/ConnectionScreen.tsx";
|
||||
|
||||
interface TerminalAppProps {
|
||||
hostId?: string;
|
||||
@@ -19,26 +20,25 @@ const TerminalApp: React.FC<TerminalAppProps> = ({ hostId, tmuxSession }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<FullScreenAppWrapper hostId={hostId}>
|
||||
{(hostConfig, loading) => {
|
||||
if (loading) {
|
||||
{(hostConfig, phase) => {
|
||||
if (phase === "loading") {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto mb-2"></div>
|
||||
<p className="text-muted-foreground">
|
||||
{t("hosts.loadingHost")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="relative h-full w-full">
|
||||
<ConnectionScreen
|
||||
status="connecting"
|
||||
message={t("hosts.loadingHost")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hostConfig) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<p className="text-red-500 mb-4">{t("hosts.hostNotFound")}</p>
|
||||
</div>
|
||||
<div className="relative h-full w-full">
|
||||
<ConnectionScreen
|
||||
status="disconnected"
|
||||
message={t("hosts.hostNotFound")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { useTheme } from "@/components/theme-provider";
|
||||
import { TERMINAL_THEMES, TERMINAL_FONTS } from "@/lib/terminal-themes";
|
||||
import {
|
||||
TERMINAL_THEMES,
|
||||
resolveTerminalFontFamily,
|
||||
} from "@/lib/terminal-themes";
|
||||
import type { TerminalConfig } from "@/types";
|
||||
|
||||
interface TerminalPreviewProps {
|
||||
@@ -38,9 +41,7 @@ export function TerminalPreview({
|
||||
theme === "custom" && customThemeColors
|
||||
? customThemeColors
|
||||
: TERMINAL_THEMES[resolvedTheme]?.colors;
|
||||
const fontFallback =
|
||||
TERMINAL_FONTS.find((f) => f.value === fontFamily)?.fallback ||
|
||||
TERMINAL_FONTS[0].fallback;
|
||||
const fontFallback = resolveTerminalFontFamily(fontFamily);
|
||||
|
||||
return (
|
||||
<div className="border border-input overflow-hidden">
|
||||
|
||||
@@ -0,0 +1,851 @@
|
||||
import React, {
|
||||
useEffect,
|
||||
useId,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ChevronDown,
|
||||
ClipboardPaste,
|
||||
GripVertical,
|
||||
ImagePlus,
|
||||
LayoutGrid,
|
||||
LogOut,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from "@/components/select";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { getSshActions } from "@/sidebar/tree/HostItem/HostItem";
|
||||
import {
|
||||
getServerMetricsById,
|
||||
startMetricsPolling,
|
||||
stopMetricsPolling,
|
||||
} from "@/api/host-metrics-status-api";
|
||||
import type { Host, TabType } from "@/types/ui-types";
|
||||
import {
|
||||
getPollingEnvironmentMultiplier,
|
||||
runAdaptivePolling,
|
||||
} from "@/lib/adaptive-polling";
|
||||
import {
|
||||
clampToolbarPosition,
|
||||
getResponsiveToolbarDensity,
|
||||
persistToolbarPosition,
|
||||
readStoredToolbarPosition,
|
||||
type ToolbarPosition,
|
||||
type ToolbarDensity,
|
||||
} from "./toolbar-geometry";
|
||||
type SelectedToolbarDensity = ToolbarDensity;
|
||||
|
||||
const DENSITY_STORAGE_KEY = "termix-terminal-toolbar-density";
|
||||
|
||||
const DENSITY_OPTIONS: { value: ToolbarDensity }[] = [
|
||||
{ value: "icon" },
|
||||
{ value: "labeled" },
|
||||
{ value: "expanded" },
|
||||
];
|
||||
const DENSITIES = DENSITY_OPTIONS.map((option) => option.value);
|
||||
|
||||
function readStoredDensity(): ToolbarDensity {
|
||||
if (typeof window === "undefined") return "labeled";
|
||||
try {
|
||||
const stored = window.localStorage.getItem(DENSITY_STORAGE_KEY);
|
||||
return DENSITIES.includes(stored as ToolbarDensity)
|
||||
? (stored as ToolbarDensity)
|
||||
: "labeled";
|
||||
} catch {
|
||||
return "labeled";
|
||||
}
|
||||
}
|
||||
|
||||
const CONTROL =
|
||||
"inline-flex min-h-8 min-w-8 items-center justify-center gap-1.5 rounded-sm px-2 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground active:translate-y-px active:bg-muted/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-40 data-[state=open]:bg-muted data-[state=open]:text-foreground";
|
||||
const SEPARATOR = "mx-0.5 h-5 w-px shrink-0 bg-border";
|
||||
|
||||
function StatBar({
|
||||
label,
|
||||
percent,
|
||||
}: {
|
||||
label: string;
|
||||
percent: number | null;
|
||||
}) {
|
||||
const value = percent == null ? null : Math.max(0, Math.min(100, percent));
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 px-1.5">
|
||||
<span className="w-12 shrink-0 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{label}
|
||||
</span>
|
||||
<div className="h-1.5 w-12 shrink-0 overflow-hidden rounded-full bg-muted">
|
||||
{value != null && (
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full transition-all",
|
||||
value >= 90
|
||||
? "bg-red-500"
|
||||
: value >= 70
|
||||
? "bg-yellow-500"
|
||||
: "bg-accent-brand",
|
||||
)}
|
||||
style={{ width: `${value}%` }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<span className="w-7 shrink-0 text-right text-[10px] tabular-nums text-muted-foreground">
|
||||
{value == null ? "--" : `${Math.round(value)}%`}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface TerminalToolbarProps {
|
||||
host: Host;
|
||||
terminalIdentity?: string;
|
||||
isConnected: boolean;
|
||||
isTmuxAttached: boolean;
|
||||
onTmuxDetach: () => void;
|
||||
isImageUploading: boolean;
|
||||
onUploadImage: (file: File) => void | Promise<void>;
|
||||
onPasteImage: () => void | Promise<void>;
|
||||
onOpenTab?: (type: TabType) => void;
|
||||
onOpenFiles?: () => void;
|
||||
isFocused: boolean;
|
||||
}
|
||||
|
||||
export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
host,
|
||||
terminalIdentity,
|
||||
isConnected,
|
||||
isTmuxAttached,
|
||||
onTmuxDetach,
|
||||
isImageUploading,
|
||||
onUploadImage,
|
||||
onPasteImage,
|
||||
onOpenTab,
|
||||
onOpenFiles,
|
||||
isFocused,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [density, setDensity] =
|
||||
useState<SelectedToolbarDensity>(readStoredDensity);
|
||||
const [responsiveDensity, setResponsiveDensity] =
|
||||
useState<SelectedToolbarDensity>(readStoredDensity);
|
||||
const [position, setPosition] = useState<ToolbarPosition>(
|
||||
readStoredToolbarPosition,
|
||||
);
|
||||
const positionRef = useRef(position);
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [densityOpen, setDensityOpen] = useState(false);
|
||||
const [metrics, setMetrics] = useState<{
|
||||
cpu: number | null;
|
||||
memory: number | null;
|
||||
disk: number | null;
|
||||
} | null>(null);
|
||||
const [statsAvailable, setStatsAvailable] = useState(true);
|
||||
const [imageError, setImageError] = useState<string | null>(null);
|
||||
const [retryImageAction, setRetryImageAction] = useState<(() => void) | null>(
|
||||
null,
|
||||
);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const hideToolbarRef = useRef<HTMLButtonElement>(null);
|
||||
const pendingExpansionFocusRef = useRef(false);
|
||||
const pendingRightEdgeRef = useRef<number | null>(null);
|
||||
const mountedRef = useRef(false);
|
||||
const imageGenerationRef = useRef(0);
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
const toolbarRef = useRef<HTMLDivElement>(null);
|
||||
const measurementRef = useRef<HTMLDivElement>(null);
|
||||
const dragRef = useRef<{
|
||||
pointerId: number;
|
||||
startX: number;
|
||||
startY: number;
|
||||
basePosition: ToolbarPosition;
|
||||
} | null>(null);
|
||||
const capturedPointerIdRef = useRef<number | null>(null);
|
||||
const dragMovedRef = useRef(false);
|
||||
const densityId = useId();
|
||||
const [desktopViewportReady, setDesktopViewportReady] = useState<boolean>();
|
||||
const hostIdentity = terminalIdentity ?? `${host.id ?? "unknown"}`;
|
||||
positionRef.current = position;
|
||||
const hostActions = getSshActions(host).filter(
|
||||
(action) => action.type !== "terminal",
|
||||
);
|
||||
const effectiveDensity: ToolbarDensity = isFocused
|
||||
? responsiveDensity
|
||||
: "icon";
|
||||
const showStats = effectiveDensity === "expanded";
|
||||
const hostId = host.id ? Number(host.id) : null;
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
setDesktopViewportReady(
|
||||
typeof window !== "undefined" && window.innerWidth >= 768,
|
||||
);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isMobile === false) setDesktopViewportReady(true);
|
||||
}, [isMobile]);
|
||||
|
||||
useEffect(() => {
|
||||
if (desktopViewportReady !== true || isMobile !== false) return;
|
||||
try {
|
||||
window.localStorage.setItem(DENSITY_STORAGE_KEY, density);
|
||||
} catch {
|
||||
// Storage can be unavailable in hardened browser contexts.
|
||||
}
|
||||
}, [density, desktopViewportReady, isMobile]);
|
||||
|
||||
// Picking an interface preset seeds this key, so pick the new value up
|
||||
// without needing the tab to remount.
|
||||
useEffect(() => {
|
||||
if (desktopViewportReady !== true || isMobile !== false) return;
|
||||
const handler = () => setDensity(readStoredDensity());
|
||||
window.addEventListener("terminalToolbarDensityChanged", handler);
|
||||
return () =>
|
||||
window.removeEventListener("terminalToolbarDensityChanged", handler);
|
||||
}, [desktopViewportReady, isMobile]);
|
||||
useEffect(() => {
|
||||
if (desktopViewportReady !== true || isMobile !== false) return;
|
||||
const measurement = measurementRef.current;
|
||||
const host = hostRef.current;
|
||||
if (!measurement || !host) return;
|
||||
let frameId: number | null = null;
|
||||
let remeasurements = 0;
|
||||
const measure = () => {
|
||||
frameId = null;
|
||||
const available = host.getBoundingClientRect().width;
|
||||
const required = measurement.getBoundingClientRect().width;
|
||||
if (available > 0 && required > 0) {
|
||||
setResponsiveDensity((current) =>
|
||||
getResponsiveToolbarDensity(density, current, available, required),
|
||||
);
|
||||
remeasurements = 0;
|
||||
} else if (remeasurements < 2) {
|
||||
remeasurements += 1;
|
||||
frameId = window.requestAnimationFrame(measure);
|
||||
}
|
||||
};
|
||||
const schedule = () => {
|
||||
if (frameId == null) frameId = window.requestAnimationFrame(measure);
|
||||
};
|
||||
schedule();
|
||||
const observer =
|
||||
typeof ResizeObserver === "undefined"
|
||||
? null
|
||||
: new ResizeObserver(schedule);
|
||||
observer?.observe(host);
|
||||
observer?.observe(measurement);
|
||||
return () => {
|
||||
if (frameId != null) window.cancelAnimationFrame(frameId);
|
||||
observer?.disconnect();
|
||||
};
|
||||
}, [
|
||||
density,
|
||||
desktopViewportReady,
|
||||
hostActions.length,
|
||||
isConnected,
|
||||
isMobile,
|
||||
isTmuxAttached,
|
||||
t,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
imageGenerationRef.current += 1;
|
||||
dragRef.current = null;
|
||||
capturedPointerIdRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (desktopViewportReady !== true || isMobile !== false) return;
|
||||
const toolbar = toolbarRef.current;
|
||||
const host = hostRef.current;
|
||||
if (!toolbar || !host || dragRef.current) return;
|
||||
const hostRect = host.getBoundingClientRect();
|
||||
const toolbarRect = toolbar.getBoundingClientRect();
|
||||
const extremeOffset =
|
||||
hostRect.width > 0 &&
|
||||
hostRect.height > 0 &&
|
||||
Math.max(
|
||||
Math.abs(positionRef.current.x),
|
||||
Math.abs(positionRef.current.y),
|
||||
) >
|
||||
Math.max(hostRect.width, hostRect.height) * 4;
|
||||
if (extremeOffset) {
|
||||
positionRef.current = { x: 0, y: 0 };
|
||||
setPosition({ x: 0, y: 0 });
|
||||
persistToolbarPosition({ x: 0, y: 0 });
|
||||
return;
|
||||
}
|
||||
if (toolbarRect.width > 0 && toolbarRect.height > 0) {
|
||||
const pendingRightEdge = pendingRightEdgeRef.current;
|
||||
pendingRightEdgeRef.current = null;
|
||||
const baseRight = toolbarRect.right - positionRef.current.x;
|
||||
const next = clampToolbarPosition(
|
||||
{
|
||||
x:
|
||||
pendingRightEdge == null
|
||||
? positionRef.current.x
|
||||
: pendingRightEdge - baseRight,
|
||||
y: positionRef.current.y,
|
||||
},
|
||||
toolbarRect,
|
||||
hostRect,
|
||||
positionRef.current,
|
||||
);
|
||||
if (
|
||||
next.x !== positionRef.current.x ||
|
||||
next.y !== positionRef.current.y
|
||||
) {
|
||||
positionRef.current = next;
|
||||
setPosition(next);
|
||||
persistToolbarPosition(next);
|
||||
}
|
||||
}
|
||||
if (!collapsed && pendingExpansionFocusRef.current) {
|
||||
pendingExpansionFocusRef.current = false;
|
||||
hideToolbarRef.current?.focus();
|
||||
}
|
||||
}, [
|
||||
collapsed,
|
||||
density,
|
||||
desktopViewportReady,
|
||||
effectiveDensity,
|
||||
hostActions.length,
|
||||
isConnected,
|
||||
isMobile,
|
||||
isTmuxAttached,
|
||||
responsiveDensity,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
imageGenerationRef.current += 1;
|
||||
setImageError(null);
|
||||
setRetryImageAction(null);
|
||||
}, [isConnected, hostIdentity]);
|
||||
|
||||
useEffect(() => {
|
||||
if (collapsed || !pendingExpansionFocusRef.current) return;
|
||||
pendingExpansionFocusRef.current = false;
|
||||
hideToolbarRef.current?.focus();
|
||||
}, [collapsed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!showStats ||
|
||||
desktopViewportReady !== true ||
|
||||
isMobile !== false ||
|
||||
!hostId ||
|
||||
collapsed ||
|
||||
!isConnected
|
||||
) {
|
||||
setMetrics(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
let stopPolling: (() => void) | undefined;
|
||||
let viewerSessionId: string | undefined;
|
||||
let previousMetrics: typeof metrics = null;
|
||||
setStatsAvailable(true);
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const data = await getServerMetricsById(hostId);
|
||||
if (cancelled || !data) return;
|
||||
const next = {
|
||||
cpu: data.cpu?.percent ?? null,
|
||||
memory: data.memory?.percent ?? null,
|
||||
disk: data.disk?.percent ?? null,
|
||||
};
|
||||
const changed =
|
||||
!previousMetrics ||
|
||||
(["cpu", "memory", "disk"] as const).some((key) => {
|
||||
const previous = previousMetrics?.[key];
|
||||
const current = next[key];
|
||||
return (
|
||||
previous == null ||
|
||||
current == null ||
|
||||
Math.abs(current - previous) >= 2
|
||||
);
|
||||
});
|
||||
previousMetrics = next;
|
||||
setMetrics(next);
|
||||
return changed;
|
||||
} catch {
|
||||
// Retain the previous sample after a transient polling error.
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const start = async () => {
|
||||
try {
|
||||
const result = await startMetricsPolling(hostId);
|
||||
if (cancelled) {
|
||||
if (result.viewerSessionId) {
|
||||
void stopMetricsPolling(hostId, result.viewerSessionId).catch(
|
||||
() => {},
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (result.requires_totp) {
|
||||
setStatsAvailable(false);
|
||||
return;
|
||||
}
|
||||
viewerSessionId = result.viewerSessionId;
|
||||
stopPolling = runAdaptivePolling(
|
||||
poll,
|
||||
{
|
||||
minIntervalMs: 5_000,
|
||||
maxIntervalMs: 20_000,
|
||||
stablePollsPerStep: 2,
|
||||
maxRequestDutyCycle: 0.2,
|
||||
},
|
||||
{ intervalMultiplier: getPollingEnvironmentMultiplier },
|
||||
);
|
||||
} catch {
|
||||
if (!cancelled) setStatsAvailable(false);
|
||||
}
|
||||
};
|
||||
void start();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
stopPolling?.();
|
||||
void stopMetricsPolling(hostId, viewerSessionId).catch(() => {});
|
||||
};
|
||||
}, [
|
||||
desktopViewportReady,
|
||||
showStats,
|
||||
isMobile,
|
||||
hostId,
|
||||
collapsed,
|
||||
isConnected,
|
||||
hostIdentity,
|
||||
]);
|
||||
|
||||
if (!isConnected || desktopViewportReady !== true || isMobile !== false)
|
||||
return null;
|
||||
|
||||
const setAndStoreDensity = (next: ToolbarDensity) => {
|
||||
setDensity(next);
|
||||
};
|
||||
const reportImageResult = (
|
||||
action: () => void | Promise<void>,
|
||||
retry: () => void,
|
||||
) => {
|
||||
const generation = ++imageGenerationRef.current;
|
||||
let result: void | Promise<void>;
|
||||
try {
|
||||
result = action();
|
||||
} catch (error) {
|
||||
result = Promise.reject(error);
|
||||
}
|
||||
void Promise.resolve(result).then(
|
||||
() => {
|
||||
if (!mountedRef.current || generation !== imageGenerationRef.current)
|
||||
return;
|
||||
setImageError(null);
|
||||
setRetryImageAction(null);
|
||||
},
|
||||
(error: unknown) => {
|
||||
if (!mountedRef.current || generation !== imageGenerationRef.current)
|
||||
return;
|
||||
setImageError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("terminalToolbar.imageActionFailed"),
|
||||
);
|
||||
setRetryImageAction(() => retry);
|
||||
},
|
||||
);
|
||||
};
|
||||
const uploadFile = (file: File) => {
|
||||
setImageError(null);
|
||||
reportImageResult(
|
||||
() => onUploadImage(file),
|
||||
() => uploadFile(file),
|
||||
);
|
||||
};
|
||||
const pasteImage = () => {
|
||||
setImageError(null);
|
||||
// Invoke clipboard access in this click's user-activation call chain.
|
||||
reportImageResult(onPasteImage, pasteImage);
|
||||
};
|
||||
const chooseFile = () => fileInputRef.current?.click();
|
||||
const handleGrabPointerDown = (
|
||||
event: React.PointerEvent<HTMLButtonElement>,
|
||||
) => {
|
||||
if (event.button !== 0 || event.pointerType === "touch") return;
|
||||
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||||
capturedPointerIdRef.current = event.pointerId;
|
||||
dragMovedRef.current = false;
|
||||
dragRef.current = {
|
||||
pointerId: event.pointerId,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
basePosition: positionRef.current,
|
||||
};
|
||||
};
|
||||
const handleGrabPointerMove = (
|
||||
event: React.PointerEvent<HTMLButtonElement>,
|
||||
) => {
|
||||
const drag = dragRef.current;
|
||||
if (!drag || drag.pointerId !== event.pointerId) return;
|
||||
const deltaX = event.clientX - drag.startX;
|
||||
const deltaY = event.clientY - drag.startY;
|
||||
const toolbar = toolbarRef.current;
|
||||
const host = hostRef.current;
|
||||
if (!toolbar || !host) return;
|
||||
const next = clampToolbarPosition(
|
||||
{
|
||||
x: drag.basePosition.x + deltaX,
|
||||
y: drag.basePosition.y + deltaY,
|
||||
},
|
||||
toolbar.getBoundingClientRect(),
|
||||
host.getBoundingClientRect(),
|
||||
positionRef.current,
|
||||
);
|
||||
if (event.clientX !== drag.startX || event.clientY !== drag.startY) {
|
||||
dragMovedRef.current = true;
|
||||
}
|
||||
positionRef.current = next;
|
||||
setPosition(next);
|
||||
};
|
||||
const handleGrabPointerEnd = (
|
||||
event: React.PointerEvent<HTMLButtonElement>,
|
||||
) => {
|
||||
const drag = dragRef.current;
|
||||
if (!drag || drag.pointerId !== event.pointerId) return;
|
||||
persistToolbarPosition(positionRef.current);
|
||||
dragRef.current = null;
|
||||
if (capturedPointerIdRef.current === event.pointerId) {
|
||||
capturedPointerIdRef.current = null;
|
||||
if (event.type !== "lostpointercapture") {
|
||||
try {
|
||||
event.currentTarget.releasePointerCapture?.(event.pointerId);
|
||||
} catch {
|
||||
// The browser may have already released capture for this pointer.
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
const densityLabels: Record<ToolbarDensity, string> = {
|
||||
icon: t("terminalToolbar.layoutIcon"),
|
||||
labeled: t("terminalToolbar.layoutLabeled"),
|
||||
expanded: t("terminalToolbar.layoutExpanded"),
|
||||
};
|
||||
|
||||
const densitySelect = (
|
||||
<Select
|
||||
value={density}
|
||||
open={densityOpen}
|
||||
onOpenChange={setDensityOpen}
|
||||
onValueChange={(value) => setAndStoreDensity(value as ToolbarDensity)}
|
||||
>
|
||||
<SelectTrigger
|
||||
id={densityId}
|
||||
size="sm"
|
||||
aria-label={t("terminalToolbar.layout")}
|
||||
title={t("terminalToolbar.layout")}
|
||||
className={cn(
|
||||
CONTROL,
|
||||
"h-8 border-0 bg-transparent px-2 shadow-none dark:bg-transparent dark:hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
<LayoutGrid className="size-4 shrink-0" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DENSITY_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{densityLabels[option.value]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
|
||||
const renderHostAction = (action: (typeof hostActions)[number]) => {
|
||||
const Icon = action.icon;
|
||||
const invoke = () =>
|
||||
action.type === "files" && onOpenFiles
|
||||
? onOpenFiles()
|
||||
: onOpenTab?.(action.type);
|
||||
return (
|
||||
<button
|
||||
key={action.type}
|
||||
type="button"
|
||||
className={CONTROL}
|
||||
aria-label={action.label}
|
||||
title={action.label}
|
||||
onClick={invoke}
|
||||
>
|
||||
<Icon className="size-4 shrink-0" />
|
||||
{effectiveDensity !== "icon" && action.label}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const imageButtons = (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={CONTROL}
|
||||
aria-label={t("terminalToolbar.uploadImage")}
|
||||
title={t("terminalToolbar.uploadImage")}
|
||||
disabled={isImageUploading}
|
||||
onClick={chooseFile}
|
||||
>
|
||||
<ImagePlus className="size-4 shrink-0" />
|
||||
{effectiveDensity !== "icon" && t("terminalToolbar.upload")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={CONTROL}
|
||||
aria-label={t("terminalToolbar.pasteImage")}
|
||||
title={t("terminalToolbar.pasteImage")}
|
||||
disabled={isImageUploading}
|
||||
onClick={pasteImage}
|
||||
>
|
||||
<ClipboardPaste className="size-4 shrink-0" />
|
||||
{effectiveDensity !== "icon" && t("terminalToolbar.paste")}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
const expandButton = (
|
||||
<button
|
||||
type="button"
|
||||
className={CONTROL}
|
||||
aria-label={t("terminalToolbar.showToolbar")}
|
||||
title={t("terminalToolbar.showToolbar")}
|
||||
onClick={() => {
|
||||
pendingRightEdgeRef.current =
|
||||
toolbarRef.current?.getBoundingClientRect().right ?? null;
|
||||
pendingExpansionFocusRef.current = true;
|
||||
setCollapsed(false);
|
||||
}}
|
||||
>
|
||||
<Maximize2 className="size-4" />
|
||||
<span className="sr-only">{t("terminalToolbar.showToolbar")}</span>
|
||||
</button>
|
||||
);
|
||||
const grabButton = () => (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(CONTROL, "cursor-grab active:cursor-grabbing")}
|
||||
aria-label={t("terminalToolbar.moveToolbar")}
|
||||
title={t("terminalToolbar.moveToolbarHint")}
|
||||
onPointerDown={handleGrabPointerDown}
|
||||
onPointerMove={handleGrabPointerMove}
|
||||
onPointerUp={handleGrabPointerEnd}
|
||||
onPointerCancel={handleGrabPointerEnd}
|
||||
onLostPointerCapture={handleGrabPointerEnd}
|
||||
>
|
||||
<GripVertical className="size-4" />
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={hostRef}
|
||||
data-terminal-toolbar-host
|
||||
className={cn(
|
||||
"pointer-events-none @container absolute inset-0 z-[110] flex items-end justify-center pb-2",
|
||||
)}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="sr-only"
|
||||
tabIndex={-1}
|
||||
disabled={isImageUploading}
|
||||
aria-hidden="true"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (file) uploadFile(file);
|
||||
}}
|
||||
/>
|
||||
<span role="status" aria-live="polite" className="sr-only">
|
||||
{isImageUploading ? t("terminalToolbar.uploadingImage") : ""}
|
||||
</span>
|
||||
{imageError && (
|
||||
<div
|
||||
role="alert"
|
||||
className="pointer-events-auto absolute bottom-full mb-2 flex min-h-11 max-w-sm items-center gap-2 rounded-sm border border-destructive bg-background p-2 text-xs text-destructive shadow-lg"
|
||||
>
|
||||
<span>{imageError}</span>
|
||||
{retryImageAction && (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(CONTROL, "text-foreground")}
|
||||
title={t("terminalToolbar.retry")}
|
||||
disabled={isImageUploading}
|
||||
onClick={retryImageAction}
|
||||
>
|
||||
{t("terminalToolbar.retry")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={cn(CONTROL, "px-2")}
|
||||
aria-label={t("terminalToolbar.dismissError")}
|
||||
title={t("terminalToolbar.dismissError")}
|
||||
onClick={() => {
|
||||
setImageError(null);
|
||||
setRetryImageAction(null);
|
||||
}}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={measurementRef}
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none invisible absolute left-0 top-0 flex whitespace-nowrap"
|
||||
>
|
||||
{hostActions.map((action) => (
|
||||
<span key={action.type} className={CONTROL}>
|
||||
<action.icon className="size-4" />
|
||||
{density !== "icon" && action.label}
|
||||
</span>
|
||||
))}
|
||||
{isTmuxAttached && (
|
||||
<span className={CONTROL}>
|
||||
<LogOut className="size-4" />
|
||||
{density !== "icon" && t("terminalToolbar.detachTmux")}
|
||||
</span>
|
||||
)}
|
||||
<span className={CONTROL}>
|
||||
<ImagePlus className="size-4" />
|
||||
{density !== "icon" && t("terminalToolbar.upload")}
|
||||
</span>
|
||||
<span className={CONTROL}>
|
||||
<ClipboardPaste className="size-4" />
|
||||
{density !== "icon" && t("terminalToolbar.paste")}
|
||||
</span>
|
||||
<span className={cn(CONTROL, "h-8 px-2")}>
|
||||
<LayoutGrid className="size-4" />
|
||||
<ChevronDown className="size-4" />
|
||||
</span>
|
||||
<span className={CONTROL}>
|
||||
<Minimize2 className="size-4" />
|
||||
</span>
|
||||
<span className={CONTROL}>
|
||||
<GripVertical className="size-4" />
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
ref={toolbarRef}
|
||||
data-terminal-toolbar-wide
|
||||
className={cn(
|
||||
"pointer-events-auto transition-opacity duration-300 hover:duration-0 focus-within:duration-0 hover:opacity-100 focus-within:opacity-100",
|
||||
collapsed || densityOpen ? "opacity-100" : "opacity-30",
|
||||
)}
|
||||
style={{
|
||||
transform: `translate(${position.x}px, ${position.y}px)`,
|
||||
}}
|
||||
>
|
||||
{collapsed ? (
|
||||
<div className="terminal-toolbar-collapsed-shell flex rounded-sm border border-border bg-background/90 p-0.5 shadow-lg backdrop-blur-sm">
|
||||
{expandButton}
|
||||
{grabButton()}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col overflow-hidden rounded-sm border border-border bg-background/90 shadow-lg backdrop-blur-sm">
|
||||
{showStats && (
|
||||
<div className="flex flex-wrap items-center justify-center gap-x-1 border-b border-border px-1.5 py-0.5">
|
||||
{statsAvailable ? (
|
||||
<>
|
||||
<StatBar
|
||||
label={t("terminalToolbar.cpu")}
|
||||
percent={metrics?.cpu ?? null}
|
||||
/>
|
||||
<StatBar
|
||||
label={t("terminalToolbar.memory")}
|
||||
percent={metrics?.memory ?? null}
|
||||
/>
|
||||
<StatBar
|
||||
label={t("terminalToolbar.disk")}
|
||||
percent={metrics?.disk ?? null}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<span
|
||||
role="status"
|
||||
className="px-2 py-1 text-xs text-muted-foreground"
|
||||
>
|
||||
{t("terminalToolbar.statsUnavailable")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className={cn("flex gap-0.5 p-0.5", "items-center")}>
|
||||
{hostActions.map(renderHostAction)}
|
||||
{isTmuxAttached && (
|
||||
<button
|
||||
type="button"
|
||||
className={CONTROL}
|
||||
aria-label={t("terminalToolbar.detachTmuxDescription")}
|
||||
title={t("terminalToolbar.detachTmuxDescription")}
|
||||
onClick={onTmuxDetach}
|
||||
>
|
||||
<LogOut className="size-4" />
|
||||
{effectiveDensity !== "icon" &&
|
||||
t("terminalToolbar.detachTmux")}
|
||||
</button>
|
||||
)}
|
||||
{(hostActions.length > 0 || isTmuxAttached) && (
|
||||
<div className={SEPARATOR} />
|
||||
)}
|
||||
<div
|
||||
role="group"
|
||||
aria-label={t("terminalToolbar.image")}
|
||||
className="flex items-center gap-0.5"
|
||||
>
|
||||
{imageButtons}
|
||||
</div>
|
||||
<div className={SEPARATOR} />
|
||||
{densitySelect}
|
||||
<button
|
||||
ref={hideToolbarRef}
|
||||
type="button"
|
||||
className={CONTROL}
|
||||
aria-label={t("terminalToolbar.hideToolbar")}
|
||||
title={t("terminalToolbar.hideToolbar")}
|
||||
onClick={() => {
|
||||
pendingRightEdgeRef.current =
|
||||
toolbarRef.current?.getBoundingClientRect().right ?? null;
|
||||
setCollapsed(true);
|
||||
}}
|
||||
>
|
||||
<Minimize2 className="size-4" />
|
||||
<span className="sr-only">
|
||||
{t("terminalToolbar.hideToolbar")}
|
||||
</span>
|
||||
</button>
|
||||
<div className={SEPARATOR} />
|
||||
{grabButton()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -4,7 +4,7 @@ import React, {
|
||||
useContext,
|
||||
useState,
|
||||
useCallback,
|
||||
ReactNode,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
interface CommandHistoryContextType {
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Return an absolute POSIX path as one safe shell argument for terminal input.
|
||||
*
|
||||
* The backend already constrains this to an absolute agent-visible path. This
|
||||
* helper handles spaces and shell metacharacters at the final terminal-input
|
||||
* boundary so configured paths cannot become shell syntax.
|
||||
*/
|
||||
export function quoteTerminalImagePath(value: string): string {
|
||||
return `'${value.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export type TerminalImageUploadSource = "file" | "clipboard";
|
||||
|
||||
export function buildImageUploadFormData(
|
||||
file: File,
|
||||
instanceId: string,
|
||||
source: TerminalImageUploadSource,
|
||||
clientUploadTimestamp = new Date().toISOString(),
|
||||
): FormData {
|
||||
const form = new FormData();
|
||||
form.append("image", file);
|
||||
form.append("instanceId", instanceId);
|
||||
form.append("source", source);
|
||||
form.append("clientUploadTimestamp", clientUploadTimestamp);
|
||||
return form;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { TERMINAL_THEMES } from "@/lib/terminal-themes.ts";
|
||||
import { TERMINAL_THEMES, type TerminalTheme } from "@/lib/terminal-themes.ts";
|
||||
import type { TerminalConfig } from "@/types/index.ts";
|
||||
|
||||
// Background/foreground per UI theme for "Termix Default" - must match index.css
|
||||
@@ -21,7 +21,7 @@ export function resolveTermixThemeColors(
|
||||
activeTheme: string,
|
||||
appTheme: string,
|
||||
customThemeColors?: TerminalConfig["customThemeColors"],
|
||||
) {
|
||||
): TerminalTheme["colors"] {
|
||||
if (activeTheme === "custom") {
|
||||
if (customThemeColors) {
|
||||
return customThemeColors;
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface TerminalHostConfig {
|
||||
/** Set when this tab joins someone else's live shared SSH session instead of connecting/attaching. */
|
||||
joinSharedSessionId?: string | null;
|
||||
joinShareId?: string | null;
|
||||
name?: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
@@ -16,7 +17,7 @@ export interface TerminalHostConfig {
|
||||
keyType?: string;
|
||||
authType?: string;
|
||||
credentialId?: number;
|
||||
terminalConfig?: TerminalConfig;
|
||||
terminalConfig?: Partial<TerminalConfig>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@@ -27,6 +28,7 @@ export interface TerminalHandle {
|
||||
fit: () => void;
|
||||
focus: () => void;
|
||||
sendInput: (data: string) => void;
|
||||
subscribeOutput: (listener: (data: string) => void) => () => void;
|
||||
paste: (text: string) => void;
|
||||
notifyResize: () => void;
|
||||
refresh: () => void;
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
export interface ToolbarPosition {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface RectLike {
|
||||
left: number;
|
||||
right: number;
|
||||
top: number;
|
||||
bottom: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export type ToolbarDensity = "icon" | "labeled" | "expanded";
|
||||
|
||||
export const TOOLBAR_POSITION_STORAGE_KEY =
|
||||
"termix-terminal-toolbar-position-v2";
|
||||
const TOOLBAR_MARGIN = 8;
|
||||
const RECOVERY_SIZE = 44;
|
||||
|
||||
export function getDefaultToolbarPosition(): ToolbarPosition {
|
||||
return { x: 0, y: 0 };
|
||||
}
|
||||
|
||||
export function sanitizeToolbarPosition(value: unknown): ToolbarPosition {
|
||||
if (!value || typeof value !== "object") return getDefaultToolbarPosition();
|
||||
const candidate = value as Partial<ToolbarPosition>;
|
||||
return Number.isFinite(candidate.x) && Number.isFinite(candidate.y)
|
||||
? { x: Number(candidate.x), y: Number(candidate.y) }
|
||||
: getDefaultToolbarPosition();
|
||||
}
|
||||
|
||||
export function readStoredToolbarPosition(): ToolbarPosition {
|
||||
if (typeof window === "undefined") return getDefaultToolbarPosition();
|
||||
try {
|
||||
return sanitizeToolbarPosition(
|
||||
JSON.parse(
|
||||
window.localStorage.getItem(TOOLBAR_POSITION_STORAGE_KEY) ?? "null",
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
return getDefaultToolbarPosition();
|
||||
}
|
||||
}
|
||||
|
||||
export function persistToolbarPosition(position: ToolbarPosition): void {
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
TOOLBAR_POSITION_STORAGE_KEY,
|
||||
JSON.stringify(sanitizeToolbarPosition(position)),
|
||||
);
|
||||
} catch {
|
||||
// Storage may be unavailable in hardened browser contexts.
|
||||
}
|
||||
}
|
||||
|
||||
export function clampToolbarPosition(
|
||||
position: ToolbarPosition,
|
||||
toolbarRect: RectLike,
|
||||
hostRect: RectLike,
|
||||
renderedPosition: ToolbarPosition = position,
|
||||
): ToolbarPosition {
|
||||
if (
|
||||
!toolbarRect.width ||
|
||||
!toolbarRect.height ||
|
||||
!hostRect.width ||
|
||||
!hostRect.height
|
||||
)
|
||||
return sanitizeToolbarPosition(position);
|
||||
const left = hostRect.left + TOOLBAR_MARGIN;
|
||||
const right = hostRect.right - TOOLBAR_MARGIN;
|
||||
const top = hostRect.top + TOOLBAR_MARGIN;
|
||||
const bottom = hostRect.bottom - TOOLBAR_MARGIN;
|
||||
const baseLeft = toolbarRect.left - renderedPosition.x;
|
||||
const baseRight = toolbarRect.right - renderedPosition.x;
|
||||
const baseTop = toolbarRect.top - renderedPosition.y;
|
||||
const baseBottom = toolbarRect.bottom - renderedPosition.y;
|
||||
const clamp = (min: number, max: number, value: number) =>
|
||||
Math.min(max, Math.max(min, value));
|
||||
const xMin =
|
||||
toolbarRect.width <= right - left
|
||||
? left - baseLeft
|
||||
: left + RECOVERY_SIZE - baseRight;
|
||||
const xMax = right - baseRight;
|
||||
const yMin =
|
||||
toolbarRect.height <= bottom - top
|
||||
? top - baseTop
|
||||
: top + RECOVERY_SIZE - baseBottom;
|
||||
const yMax = bottom - baseBottom;
|
||||
return { x: clamp(xMin, xMax, position.x), y: clamp(yMin, yMax, position.y) };
|
||||
}
|
||||
|
||||
export function getResponsiveToolbarDensity(
|
||||
selectedDensity: ToolbarDensity,
|
||||
currentDensity: ToolbarDensity,
|
||||
availableInlineSize: number,
|
||||
requiredInlineSize: number,
|
||||
): ToolbarDensity {
|
||||
if (selectedDensity === "icon") return "icon";
|
||||
if (!Number.isFinite(availableInlineSize) || availableInlineSize <= 0)
|
||||
return currentDensity;
|
||||
if (!Number.isFinite(requiredInlineSize) || requiredInlineSize <= 0)
|
||||
return currentDensity;
|
||||
if (currentDensity === "icon")
|
||||
return availableInlineSize >= requiredInlineSize * 1.15
|
||||
? selectedDensity
|
||||
: currentDensity;
|
||||
return availableInlineSize < requiredInlineSize * 1.1
|
||||
? "icon"
|
||||
: selectedDensity;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { getTouchInputSettings } from "@/api/touch-input-settings-api";
|
||||
import {
|
||||
TOUCH_INPUT_DEFAULTS,
|
||||
type TouchInputSettings,
|
||||
} from "@/types/touch-input-settings";
|
||||
|
||||
let cached: TouchInputSettings | undefined;
|
||||
let pending: Promise<TouchInputSettings> | undefined;
|
||||
|
||||
export function loadTouchInputSettings(): Promise<TouchInputSettings> {
|
||||
if (cached) return Promise.resolve(cached);
|
||||
pending ??= getTouchInputSettings()
|
||||
.then((settings) => (cached = settings))
|
||||
.catch(() => (cached = { ...TOUCH_INPUT_DEFAULTS }))
|
||||
.finally(() => {
|
||||
pending = undefined;
|
||||
});
|
||||
return pending;
|
||||
}
|
||||
|
||||
export function cacheTouchInputSettings(settings: TouchInputSettings): void {
|
||||
cached = settings;
|
||||
}
|
||||
|
||||
export function resetTouchInputSettingsCache(): void {
|
||||
cached = undefined;
|
||||
pending = undefined;
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
import {
|
||||
TOUCH_INPUT_DEFAULTS,
|
||||
type TouchInputSettings,
|
||||
} from "@/types/touch-input-settings";
|
||||
|
||||
export interface TouchWheelPoint {
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
}
|
||||
|
||||
export interface SyntheticWheelInput extends TouchWheelPoint {
|
||||
deltaY: number;
|
||||
deltaMode: number;
|
||||
}
|
||||
|
||||
interface TouchWheelTiming {
|
||||
now: () => number;
|
||||
requestFrame: (callback: FrameRequestCallback) => number;
|
||||
cancelFrame: (handle: number) => void;
|
||||
momentumAllowed: () => boolean;
|
||||
}
|
||||
|
||||
const defaultTiming: TouchWheelTiming = {
|
||||
now: () => performance.now(),
|
||||
requestFrame: (callback) => requestAnimationFrame(callback),
|
||||
cancelFrame: (handle) => cancelAnimationFrame(handle),
|
||||
momentumAllowed: () => true,
|
||||
};
|
||||
|
||||
interface VelocitySample {
|
||||
deltaY: number;
|
||||
durationMs: number;
|
||||
endedAt: number;
|
||||
}
|
||||
|
||||
export class TouchWheelCoordinator {
|
||||
private startY: number | undefined;
|
||||
private lastPoint: TouchWheelPoint | undefined;
|
||||
private pendingDeltaY = 0;
|
||||
private scrolling = false;
|
||||
private cancelled = false;
|
||||
private velocitySamples: VelocitySample[] = [];
|
||||
private lastMoveTime: number | undefined;
|
||||
private momentumFrame: number | undefined;
|
||||
private momentumRemainderY = 0;
|
||||
|
||||
constructor(
|
||||
private readonly emitWheel: (input: SyntheticWheelInput) => void,
|
||||
private readonly dragThresholdPx: number = TOUCH_INPUT_DEFAULTS.dragThresholdPx,
|
||||
private readonly maxWheelDeltaPx: number = TOUCH_INPUT_DEFAULTS.maxWheelDeltaPx,
|
||||
private readonly timing: TouchWheelTiming = defaultTiming,
|
||||
private readonly settings: TouchInputSettings = TOUCH_INPUT_DEFAULTS,
|
||||
) {}
|
||||
|
||||
start(point: TouchWheelPoint | undefined, time = this.timing.now()): void {
|
||||
this.reset();
|
||||
if (!point) {
|
||||
this.cancelled = true;
|
||||
return;
|
||||
}
|
||||
this.startY = point.clientY;
|
||||
this.lastPoint = point;
|
||||
this.lastMoveTime = time;
|
||||
}
|
||||
|
||||
move(point: TouchWheelPoint | undefined, time = this.timing.now()): boolean {
|
||||
if (this.cancelled || !point || !this.lastPoint) {
|
||||
if (!point) this.cancel();
|
||||
return false;
|
||||
}
|
||||
|
||||
const deltaY = this.lastPoint.clientY - point.clientY;
|
||||
this.pendingDeltaY += deltaY;
|
||||
this.lastPoint = point;
|
||||
if (this.lastMoveTime !== undefined && time > this.lastMoveTime) {
|
||||
this.velocitySamples.push({
|
||||
deltaY,
|
||||
durationMs: time - this.lastMoveTime,
|
||||
endedAt: time,
|
||||
});
|
||||
this.velocitySamples = this.velocitySamples.filter(
|
||||
(sample) =>
|
||||
time - sample.endedAt <= this.settings.momentumSampleWindowMs,
|
||||
);
|
||||
}
|
||||
this.lastMoveTime = time;
|
||||
|
||||
if (!this.scrolling) {
|
||||
if (Math.abs(point.clientY - this.startY!) < this.dragThresholdPx) {
|
||||
return true;
|
||||
}
|
||||
this.scrolling = true;
|
||||
}
|
||||
|
||||
this.flush(point);
|
||||
return true;
|
||||
}
|
||||
|
||||
end(time = this.timing.now()): void {
|
||||
const point = this.lastPoint;
|
||||
const velocity = this.scrolling ? this.recentVelocity(time) : 0;
|
||||
this.clearGesture();
|
||||
if (
|
||||
point &&
|
||||
this.timing.momentumAllowed() &&
|
||||
Math.abs(velocity) >= this.settings.minimumVelocityPxPerMs
|
||||
) {
|
||||
this.startMomentum(point, velocity, time);
|
||||
}
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.reset();
|
||||
this.cancelled = true;
|
||||
}
|
||||
|
||||
private flush(point: TouchWheelPoint): void {
|
||||
while (Math.abs(this.pendingDeltaY) > this.maxWheelDeltaPx) {
|
||||
const deltaY = Math.sign(this.pendingDeltaY) * this.maxWheelDeltaPx;
|
||||
this.emitWheel({
|
||||
...point,
|
||||
deltaY,
|
||||
deltaMode: WheelEvent.DOM_DELTA_PIXEL,
|
||||
});
|
||||
this.pendingDeltaY -= deltaY;
|
||||
}
|
||||
if (this.pendingDeltaY !== 0) {
|
||||
this.emitWheel({
|
||||
...point,
|
||||
deltaY: this.pendingDeltaY,
|
||||
deltaMode: WheelEvent.DOM_DELTA_PIXEL,
|
||||
});
|
||||
this.pendingDeltaY = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private reset(): void {
|
||||
this.stopMomentum();
|
||||
this.clearGesture();
|
||||
this.cancelled = false;
|
||||
}
|
||||
|
||||
private clearGesture(): void {
|
||||
this.startY = undefined;
|
||||
this.lastPoint = undefined;
|
||||
this.pendingDeltaY = 0;
|
||||
this.scrolling = false;
|
||||
this.velocitySamples = [];
|
||||
this.lastMoveTime = undefined;
|
||||
}
|
||||
|
||||
private recentVelocity(time: number): number {
|
||||
const newestSampleTime = this.velocitySamples.at(-1)?.endedAt;
|
||||
if (
|
||||
newestSampleTime === undefined ||
|
||||
time - newestSampleTime > this.settings.releaseGracePeriodMs
|
||||
) {
|
||||
return 0;
|
||||
}
|
||||
const samples = this.velocitySamples.filter(
|
||||
(sample) =>
|
||||
newestSampleTime - sample.endedAt <=
|
||||
this.settings.momentumSampleWindowMs,
|
||||
);
|
||||
const duration = samples.reduce(
|
||||
(total, sample) => total + sample.durationMs,
|
||||
0,
|
||||
);
|
||||
if (duration === 0) return 0;
|
||||
const distance = samples.reduce(
|
||||
(total, sample) => total + sample.deltaY,
|
||||
0,
|
||||
);
|
||||
return Math.max(
|
||||
-this.settings.maximumVelocityPxPerMs,
|
||||
Math.min(this.settings.maximumVelocityPxPerMs, distance / duration),
|
||||
);
|
||||
}
|
||||
|
||||
private startMomentum(
|
||||
point: TouchWheelPoint,
|
||||
initialVelocity: number,
|
||||
startedAt: number,
|
||||
): void {
|
||||
let lastTime = startedAt;
|
||||
let travelled = 0;
|
||||
|
||||
const step = (time: number) => {
|
||||
if (!this.timing.momentumAllowed()) {
|
||||
this.momentumFrame = undefined;
|
||||
this.momentumRemainderY = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const elapsed = Math.max(0, time - startedAt);
|
||||
const remainingTravel = this.settings.maximumTravelPx - travelled;
|
||||
if (elapsed > this.settings.maximumDurationMs || remainingTravel <= 0) {
|
||||
this.momentumFrame = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
const duration = Math.min(
|
||||
Math.max(0, time - lastTime),
|
||||
this.settings.maximumFrameIntervalMs,
|
||||
);
|
||||
if (duration > 0) {
|
||||
const frameStart = Math.max(0, elapsed - duration);
|
||||
const distance =
|
||||
Math.abs(initialVelocity) *
|
||||
this.settings.decayTimeMs *
|
||||
(Math.exp(-frameStart / this.settings.decayTimeMs) -
|
||||
Math.exp(-elapsed / this.settings.decayTimeMs));
|
||||
const deltaY =
|
||||
Math.sign(initialVelocity) * Math.min(distance, remainingTravel);
|
||||
this.flushMomentum(point, deltaY);
|
||||
travelled += Math.abs(deltaY);
|
||||
}
|
||||
|
||||
lastTime = time;
|
||||
if (
|
||||
elapsed < this.settings.maximumDurationMs &&
|
||||
travelled < this.settings.maximumTravelPx
|
||||
) {
|
||||
this.momentumFrame = this.timing.requestFrame(step);
|
||||
} else {
|
||||
this.momentumFrame = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
this.momentumFrame = this.timing.requestFrame(step);
|
||||
}
|
||||
|
||||
private flushMomentum(point: TouchWheelPoint, deltaY: number): void {
|
||||
this.momentumRemainderY += deltaY;
|
||||
const availableTicks = Math.trunc(
|
||||
Math.abs(this.momentumRemainderY) / this.settings.pixelsPerTick,
|
||||
);
|
||||
const tickCount = Math.min(
|
||||
availableTicks,
|
||||
this.settings.maximumTicksPerFrame,
|
||||
);
|
||||
const direction = Math.sign(this.momentumRemainderY);
|
||||
for (let index = 0; index < tickCount; index++) {
|
||||
this.emitWheel({
|
||||
...point,
|
||||
deltaY: direction,
|
||||
deltaMode: WheelEvent.DOM_DELTA_LINE,
|
||||
});
|
||||
}
|
||||
this.momentumRemainderY -=
|
||||
direction * tickCount * this.settings.pixelsPerTick;
|
||||
}
|
||||
|
||||
private stopMomentum(): void {
|
||||
if (this.momentumFrame !== undefined) {
|
||||
this.timing.cancelFrame(this.momentumFrame);
|
||||
this.momentumFrame = undefined;
|
||||
}
|
||||
this.momentumRemainderY = 0;
|
||||
}
|
||||
}
|
||||
|
||||
export function installTouchWheelCoordinator(
|
||||
terminalElement: HTMLElement,
|
||||
momentumAllowed: () => boolean = () => true,
|
||||
settings: TouchInputSettings = TOUCH_INPUT_DEFAULTS,
|
||||
): () => void {
|
||||
if (!settings.enabled) return () => {};
|
||||
const wheelElement = terminalElement.querySelector<HTMLElement>(
|
||||
".xterm-scrollable-element",
|
||||
);
|
||||
if (!wheelElement) return () => {};
|
||||
|
||||
const coordinator = new TouchWheelCoordinator(
|
||||
({ deltaY, deltaMode, clientX, clientY }) => {
|
||||
wheelElement.dispatchEvent(
|
||||
new WheelEvent("wheel", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
composed: true,
|
||||
clientX,
|
||||
clientY,
|
||||
deltaMode,
|
||||
deltaY,
|
||||
}),
|
||||
);
|
||||
},
|
||||
settings.dragThresholdPx,
|
||||
settings.maxWheelDeltaPx,
|
||||
{
|
||||
...defaultTiming,
|
||||
momentumAllowed: () =>
|
||||
settings.momentumEnabled &&
|
||||
momentumAllowed() &&
|
||||
!window.matchMedia?.("(prefers-reduced-motion: reduce)").matches,
|
||||
},
|
||||
settings,
|
||||
);
|
||||
|
||||
const getSingleTouch = (event: TouchEvent): Touch | undefined =>
|
||||
event.touches.length === 1
|
||||
? (event.touches.item(0) ?? undefined)
|
||||
: undefined;
|
||||
|
||||
const handleTouchStart = (event: TouchEvent) => {
|
||||
const touch = getSingleTouch(event);
|
||||
coordinator.start(touch);
|
||||
};
|
||||
const handleTouchMove = (event: TouchEvent) => {
|
||||
if (coordinator.move(getSingleTouch(event))) event.preventDefault();
|
||||
};
|
||||
const handleTouchEnd = () => coordinator.end();
|
||||
const handleTouchCancel = () => coordinator.cancel();
|
||||
|
||||
terminalElement.addEventListener("touchstart", handleTouchStart, {
|
||||
passive: true,
|
||||
});
|
||||
terminalElement.addEventListener("touchmove", handleTouchMove, {
|
||||
passive: false,
|
||||
});
|
||||
terminalElement.addEventListener("touchend", handleTouchEnd, {
|
||||
passive: true,
|
||||
});
|
||||
terminalElement.addEventListener("touchcancel", handleTouchCancel, {
|
||||
passive: true,
|
||||
});
|
||||
|
||||
return () => {
|
||||
coordinator.cancel();
|
||||
terminalElement.removeEventListener("touchstart", handleTouchStart);
|
||||
terminalElement.removeEventListener("touchmove", handleTouchMove);
|
||||
terminalElement.removeEventListener("touchend", handleTouchEnd);
|
||||
terminalElement.removeEventListener("touchcancel", handleTouchCancel);
|
||||
};
|
||||
}
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
type TmuxSearchResult,
|
||||
} from "@/api/tmux-monitor-api";
|
||||
import type { TerminalHandle } from "@/features/terminal/Terminal";
|
||||
import { useAdaptivePolling } from "@/hooks/use-adaptive-polling";
|
||||
import { SessionTree, type SessionMetricsAgg } from "./SessionTree";
|
||||
import { SearchResults } from "./SearchResults";
|
||||
import { PanePreview } from "./PanePreview";
|
||||
@@ -116,6 +117,8 @@ export function TmuxMonitor({
|
||||
// stale (the user switched away while the request was in flight) and must
|
||||
// not overwrite the current tree.
|
||||
const activeHostRef = useRef<number | null>(null);
|
||||
const overviewSignatureRef = useRef("");
|
||||
const metricsSignatureRef = useRef("");
|
||||
// True when the expanded-session set for the current host was restored from
|
||||
// localStorage (or touched by the user) and must not be overwritten by the
|
||||
// default expand-all behavior.
|
||||
@@ -256,12 +259,15 @@ export function TmuxMonitor({
|
||||
try {
|
||||
const data = await getTmuxOverview(hostId);
|
||||
if (activeHostRef.current !== hostId) return true;
|
||||
const signature = JSON.stringify(data);
|
||||
const changed = signature !== overviewSignatureRef.current;
|
||||
overviewSignatureRef.current = signature;
|
||||
setOverview(data);
|
||||
setExpandedSessions((prev) => {
|
||||
if (expandedRestoredRef.current || prev.size > 0) return prev;
|
||||
return new Set(data.sessions.map((s) => s.name));
|
||||
});
|
||||
return true;
|
||||
return changed;
|
||||
} catch (err) {
|
||||
if (activeHostRef.current !== hostId) return true;
|
||||
if (!silent) {
|
||||
@@ -293,6 +299,7 @@ export function TmuxMonitor({
|
||||
);
|
||||
}
|
||||
}
|
||||
if (silent) throw err;
|
||||
return false;
|
||||
} finally {
|
||||
if (!silent && activeHostRef.current === hostId)
|
||||
@@ -343,33 +350,36 @@ export function TmuxMonitor({
|
||||
// Poll only while this app tab is shown: the component stays mounted when
|
||||
// the user switches tabs (AppShell keeps tab content alive) and would
|
||||
// otherwise keep running SSH execs against the host forever.
|
||||
useEffect(() => {
|
||||
if (selectedHostId === null || !isVisible) return;
|
||||
const interval = setInterval(() => {
|
||||
if (!document.hidden) loadOverview(selectedHostId, true);
|
||||
}, OVERVIEW_POLL_MS);
|
||||
return () => clearInterval(interval);
|
||||
}, [selectedHostId, isVisible, loadOverview]);
|
||||
useAdaptivePolling(
|
||||
() =>
|
||||
selectedHostId === null ? undefined : loadOverview(selectedHostId, true),
|
||||
{
|
||||
minIntervalMs: OVERVIEW_POLL_MS,
|
||||
maxIntervalMs: 60_000,
|
||||
stablePollsPerStep: 3,
|
||||
},
|
||||
selectedHostId !== null && isVisible,
|
||||
{ runImmediately: false },
|
||||
);
|
||||
|
||||
// -- metrics polling ------------------------------------------------------
|
||||
useEffect(() => {
|
||||
if (selectedHostId === null || !overview?.available || !isVisible) return;
|
||||
let cancelled = false;
|
||||
const load = () => {
|
||||
if (document.hidden) return;
|
||||
getTmuxMetrics(selectedHostId)
|
||||
.then((m) => {
|
||||
if (!cancelled) setMetrics(m);
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
load();
|
||||
const interval = setInterval(load, METRICS_POLL_MS);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [selectedHostId, overview?.available, isVisible]);
|
||||
useAdaptivePolling(
|
||||
async () => {
|
||||
if (selectedHostId === null) return;
|
||||
const next = await getTmuxMetrics(selectedHostId);
|
||||
const signature = JSON.stringify(next);
|
||||
const changed = signature !== metricsSignatureRef.current;
|
||||
metricsSignatureRef.current = signature;
|
||||
setMetrics(next);
|
||||
return changed;
|
||||
},
|
||||
{
|
||||
minIntervalMs: METRICS_POLL_MS,
|
||||
maxIntervalMs: 60_000,
|
||||
stablePollsPerStep: 3,
|
||||
},
|
||||
selectedHostId !== null && !!overview?.available && isVisible,
|
||||
);
|
||||
|
||||
// -- keyboard shortcuts -----------------------------------------------------
|
||||
// Gated on isVisible: the listener is global, and a hidden-but-mounted tab
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
import type { TFunction } from "i18next";
|
||||
|
||||
export function formatRelativeTime(
|
||||
unixSeconds: number,
|
||||
nowMs: number,
|
||||
t: (k: string, o?: object) => string,
|
||||
t: TFunction,
|
||||
): string {
|
||||
if (!unixSeconds) return "";
|
||||
const diff = Math.max(0, nowMs / 1000 - unixSeconds);
|
||||
if (diff < 60) return t("tmuxMonitor.timeJustNow");
|
||||
if (diff < 60) return String(t("tmuxMonitor.timeJustNow"));
|
||||
if (diff < 3600)
|
||||
return t("tmuxMonitor.timeMinutes", { count: Math.floor(diff / 60) });
|
||||
return String(
|
||||
t("tmuxMonitor.timeMinutes", { count: Math.floor(diff / 60) }),
|
||||
);
|
||||
if (diff < 86400)
|
||||
return t("tmuxMonitor.timeHours", { count: Math.floor(diff / 3600) });
|
||||
return t("tmuxMonitor.timeDays", { count: Math.floor(diff / 86400) });
|
||||
return String(
|
||||
t("tmuxMonitor.timeHours", { count: Math.floor(diff / 3600) }),
|
||||
);
|
||||
return String(t("tmuxMonitor.timeDays", { count: Math.floor(diff / 86400) }));
|
||||
}
|
||||
|
||||
export function formatMem(kb: number): string {
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FullScreenAppWrapper } from "@/features/FullScreenAppWrapper.tsx";
|
||||
import { TunnelTab } from "@/features/tunnel/TunnelTab.tsx";
|
||||
import { ConnectionScreen } from "@/components/connection/ConnectionScreen.tsx";
|
||||
import type { Host } from "@/types/ui-types";
|
||||
import type { SSHHost } from "@/types";
|
||||
|
||||
@@ -25,6 +26,11 @@ function sshHostToMinimalHost(h: SSHHost): Host {
|
||||
pin: h.pin ?? false,
|
||||
authType: h.authType,
|
||||
enableTerminal: h.enableTerminal ?? false,
|
||||
enableCommandHistory: h.enableCommandHistory ?? false,
|
||||
enableProxmox: h.enableProxmox ?? false,
|
||||
enableProxmoxStats: h.enableProxmoxStats ?? false,
|
||||
enableTmuxMonitor: h.enableTmuxMonitor ?? false,
|
||||
enableTerminalToolbar: h.enableTerminalToolbar ?? true,
|
||||
enableTunnel: h.enableTunnel ?? false,
|
||||
enableFileManager: h.enableFileManager ?? false,
|
||||
enableDocker: h.enableDocker ?? false,
|
||||
@@ -45,19 +51,25 @@ const TunnelApp: React.FC<TunnelAppProps> = ({ hostId }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<FullScreenAppWrapper hostId={hostId}>
|
||||
{(hostConfig, loading) => {
|
||||
if (loading) {
|
||||
{(hostConfig, phase) => {
|
||||
if (phase === "loading") {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto" />
|
||||
<div className="relative h-full w-full">
|
||||
<ConnectionScreen
|
||||
status="connecting"
|
||||
message={t("hosts.loadingHost")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hostConfig) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<p className="text-muted-foreground">{t("hosts.hostNotFound")}</p>
|
||||
<div className="relative h-full w-full">
|
||||
<ConnectionScreen
|
||||
status="disconnected"
|
||||
message={t("hosts.hostNotFound")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import type { TunnelStatus } from "@/types/index.js";
|
||||
import {
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
Play,
|
||||
Square,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type TunnelInlineControlsProps = {
|
||||
status?: TunnelStatus;
|
||||
loading?: boolean;
|
||||
onStart?: () => void;
|
||||
onStop?: () => void;
|
||||
startDisabled?: boolean;
|
||||
startDisabledReason?: string;
|
||||
};
|
||||
|
||||
function getStatusKind(status?: TunnelStatus) {
|
||||
const value = status?.status?.toUpperCase() || "DISCONNECTED";
|
||||
|
||||
if (value === "CONNECTED") return "connected";
|
||||
if (value === "ERROR" || value === "FAILED") return "error";
|
||||
if (
|
||||
value === "CONNECTING" ||
|
||||
value === "DISCONNECTING" ||
|
||||
value === "RETRYING" ||
|
||||
value === "WAITING"
|
||||
) {
|
||||
return "connecting";
|
||||
}
|
||||
|
||||
return "disconnected";
|
||||
}
|
||||
|
||||
function getStatusTitle(
|
||||
status: TunnelStatus | undefined,
|
||||
statusText: string,
|
||||
t: ReturnType<typeof useTranslation>["t"],
|
||||
) {
|
||||
if (!status) return statusText;
|
||||
|
||||
const details = [];
|
||||
if (status.reason) details.push(status.reason);
|
||||
if (status.retryCount && status.maxRetries) {
|
||||
details.push(
|
||||
t("tunnels.attempt", {
|
||||
current: status.retryCount,
|
||||
max: status.maxRetries,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (status.nextRetryIn) {
|
||||
details.push(
|
||||
t("tunnels.nextRetryIn", {
|
||||
seconds: status.nextRetryIn,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (status.errorType && !status.reason) details.push(status.errorType);
|
||||
|
||||
return details.length > 0 ? details.join("\n") : statusText;
|
||||
}
|
||||
|
||||
export function TunnelInlineControls({
|
||||
status,
|
||||
loading = false,
|
||||
onStart,
|
||||
onStop,
|
||||
startDisabled,
|
||||
startDisabledReason,
|
||||
}: TunnelInlineControlsProps) {
|
||||
const { t } = useTranslation();
|
||||
const kind = getStatusKind(status);
|
||||
const isDisconnected = kind === "disconnected";
|
||||
const statusText =
|
||||
kind === "connected"
|
||||
? t("tunnels.connected")
|
||||
: kind === "connecting"
|
||||
? t("tunnels.connecting")
|
||||
: kind === "error"
|
||||
? t("tunnels.error")
|
||||
: t("tunnels.disconnected");
|
||||
const title = getStatusTitle(status, statusText, t);
|
||||
|
||||
const statusClass =
|
||||
kind === "connected"
|
||||
? "text-accent-brand border-accent-brand/40 bg-accent-brand/10"
|
||||
: kind === "connecting"
|
||||
? "text-blue-400 border-blue-400/40 bg-blue-400/10"
|
||||
: kind === "error"
|
||||
? "text-destructive border-destructive/40 bg-destructive/10"
|
||||
: "text-muted-foreground border-border bg-muted/30";
|
||||
|
||||
const statusIcon =
|
||||
kind === "connected" ? (
|
||||
<Wifi className="size-3" />
|
||||
) : kind === "connecting" ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : kind === "error" ? (
|
||||
<AlertCircle className="size-3" />
|
||||
) : (
|
||||
<WifiOff className="size-3" />
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<span
|
||||
className={`inline-flex h-8 items-center gap-1.5 border px-2 text-[10px] font-bold uppercase tracking-wide ${statusClass}`}
|
||||
title={title}
|
||||
>
|
||||
{statusIcon}
|
||||
{statusText}
|
||||
</span>
|
||||
{loading ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled
|
||||
className="h-8 px-3 text-xs text-muted-foreground border-border"
|
||||
>
|
||||
<Loader2 className="size-3 mr-1 animate-spin" />
|
||||
{isDisconnected ? t("tunnels.start") : t("tunnels.stop")}
|
||||
</Button>
|
||||
) : isDisconnected ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onStart}
|
||||
disabled={startDisabled}
|
||||
title={startDisabled ? startDisabledReason : undefined}
|
||||
className="h-8 px-3 text-xs text-accent-brand border-accent-brand/40 hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
>
|
||||
<Play className="size-3 mr-1" />
|
||||
{t("tunnels.start")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onStop}
|
||||
className="h-8 px-3 text-xs text-destructive border-destructive/40 hover:bg-destructive/10 hover:text-destructive"
|
||||
>
|
||||
<Square className="size-3 mr-1" />
|
||||
{t("tunnels.stop")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TunnelMode } from "@/types/index.js";
|
||||
|
||||
type TunnelModeSelectorProps = {
|
||||
mode: TunnelMode;
|
||||
scope: "client" | "server";
|
||||
onChange: (mode: TunnelMode) => void;
|
||||
};
|
||||
|
||||
export function TunnelModeSelector({
|
||||
mode,
|
||||
scope,
|
||||
onChange,
|
||||
}: TunnelModeSelectorProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const options: Array<{
|
||||
value: TunnelMode;
|
||||
label: string;
|
||||
description: string;
|
||||
}> = [
|
||||
{
|
||||
value: "local",
|
||||
label: t("tunnels.typeLocal"),
|
||||
description:
|
||||
scope === "client"
|
||||
? t("tunnels.typeClientLocalDesc")
|
||||
: t("tunnels.typeServerLocalDesc"),
|
||||
},
|
||||
{
|
||||
value: "remote",
|
||||
label: t("tunnels.typeRemote"),
|
||||
description:
|
||||
scope === "client"
|
||||
? t("tunnels.typeClientRemoteDesc")
|
||||
: t("tunnels.typeServerRemoteDesc"),
|
||||
},
|
||||
{
|
||||
value: "dynamic",
|
||||
label: t("tunnels.typeDynamic"),
|
||||
description:
|
||||
scope === "client"
|
||||
? t("tunnels.typeClientDynamicDesc")
|
||||
: t("tunnels.typeDynamicDesc"),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid gap-2 lg:grid-cols-3">
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => onChange(option.value)}
|
||||
className={`flex items-start gap-2.5 border p-3 text-left transition-colors ${
|
||||
mode === option.value
|
||||
? "border-accent-brand bg-accent-brand/5 text-foreground"
|
||||
: "border-border bg-muted/20 text-muted-foreground hover:border-border/80 hover:bg-muted/30"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`mt-0.5 size-3.5 shrink-0 rounded-full border-2 ${
|
||||
mode === option.value
|
||||
? "border-accent-brand bg-accent-brand"
|
||||
: "border-muted-foreground/40"
|
||||
}`}
|
||||
/>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs font-semibold">{option.label}</span>
|
||||
<span className="text-[10px] text-muted-foreground leading-tight">
|
||||
{option.description}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user