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
+1013
-217
File diff suppressed because it is too large
Load Diff
@@ -36,7 +36,7 @@ export async function updateAcmeSslSettings(
|
||||
}
|
||||
|
||||
export async function requestAcmeCertificate(): Promise<
|
||||
AcmeSettings & { success: boolean }
|
||||
AcmeSettings & { success: boolean; reloadMessage?: string }
|
||||
> {
|
||||
try {
|
||||
const response = await authApi.post("/users/acme-ssl-request", {});
|
||||
@@ -49,7 +49,7 @@ export async function requestAcmeCertificate(): Promise<
|
||||
export async function uploadManualSslCertificate(payload: {
|
||||
certificate: string;
|
||||
privateKey: string;
|
||||
}): Promise<AcmeSettings & { success: boolean }> {
|
||||
}): Promise<AcmeSettings & { success: boolean; reloadMessage?: string }> {
|
||||
try {
|
||||
const response = await authApi.post("/users/manual-ssl-upload", payload);
|
||||
return response.data;
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
import { authApi, handleApiError } from "@/main-axios";
|
||||
|
||||
export type AiProviderType =
|
||||
"ollama" | "anthropic" | "openai" | "gemini" | "openai_compatible";
|
||||
|
||||
export interface AiProvider {
|
||||
id: number;
|
||||
providerType: AiProviderType;
|
||||
label: string;
|
||||
baseUrl: string | null;
|
||||
/** The first few characters only; the key itself never leaves the server. */
|
||||
apiKeyPrefix: string | null;
|
||||
defaultModel: string | null;
|
||||
enabled: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface AiConversation {
|
||||
id: number;
|
||||
title: string | null;
|
||||
providerId: number | null;
|
||||
model: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface AiMessage {
|
||||
id: number;
|
||||
conversationId: number;
|
||||
role: "user" | "assistant" | "tool";
|
||||
content: string;
|
||||
toolCalls: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface AiProposal {
|
||||
id: number;
|
||||
conversationId: number;
|
||||
kind: string;
|
||||
summary: string | null;
|
||||
payload: string;
|
||||
status: "pending" | "applied" | "rejected" | "expired";
|
||||
resultSummary: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface AiStatus {
|
||||
globallyEnabled: boolean;
|
||||
enabled: boolean;
|
||||
allowReadOnlyCommands: boolean;
|
||||
}
|
||||
|
||||
export async function getAiStatus(): Promise<AiStatus> {
|
||||
try {
|
||||
return (await authApi.get("/ai/status")).data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "get AI status");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAiProviders(): Promise<AiProvider[]> {
|
||||
try {
|
||||
return (await authApi.get("/ai/providers")).data.providers;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "list AI providers");
|
||||
}
|
||||
}
|
||||
|
||||
export async function createAiProvider(input: {
|
||||
providerType: AiProviderType;
|
||||
label: string;
|
||||
baseUrl?: string | null;
|
||||
apiKey?: string | null;
|
||||
defaultModel?: string | null;
|
||||
}): Promise<AiProvider> {
|
||||
try {
|
||||
return (await authApi.post("/ai/providers", input)).data.provider;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "create AI provider");
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateAiProvider(
|
||||
id: number,
|
||||
input: Partial<{
|
||||
label: string;
|
||||
baseUrl: string | null;
|
||||
apiKey: string | null;
|
||||
defaultModel: string | null;
|
||||
enabled: boolean;
|
||||
}>,
|
||||
): Promise<AiProvider> {
|
||||
try {
|
||||
return (await authApi.patch(`/ai/providers/${id}`, input)).data.provider;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "update AI provider");
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteAiProvider(id: number): Promise<void> {
|
||||
try {
|
||||
await authApi.delete(`/ai/providers/${id}`);
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "delete AI provider");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Model list for a provider that may not be saved yet, so the add form can
|
||||
* fill its picker before anything is persisted.
|
||||
*/
|
||||
export async function probeAiModels(input: {
|
||||
providerType: AiProviderType;
|
||||
baseUrl?: string | null;
|
||||
apiKey?: string | null;
|
||||
providerId?: number | null;
|
||||
}): Promise<{ models: string[]; source: "live" | "fallback" | "none" }> {
|
||||
try {
|
||||
return (await authApi.post("/ai/probe-models", input)).data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "detect models");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAiProviderModels(id: number): Promise<string[]> {
|
||||
try {
|
||||
return (await authApi.get(`/ai/providers/${id}/models`)).data.models;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "list provider models");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAiConversations(): Promise<AiConversation[]> {
|
||||
try {
|
||||
return (await authApi.get("/ai/conversations")).data.conversations;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "list AI conversations");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAiConversation(id: number): Promise<{
|
||||
conversation: AiConversation;
|
||||
messages: AiMessage[];
|
||||
proposals: AiProposal[];
|
||||
}> {
|
||||
try {
|
||||
return (await authApi.get(`/ai/conversations/${id}`)).data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "load AI conversation");
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteAiConversation(id: number): Promise<void> {
|
||||
try {
|
||||
await authApi.delete(`/ai/conversations/${id}`);
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "delete AI conversation");
|
||||
}
|
||||
}
|
||||
|
||||
export async function applyAiProposal(
|
||||
id: number,
|
||||
): Promise<{ success: boolean; summary: string }> {
|
||||
try {
|
||||
return (await authApi.post(`/ai/proposals/${id}/apply`)).data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "apply AI proposal");
|
||||
}
|
||||
}
|
||||
|
||||
export async function rejectAiProposal(id: number): Promise<void> {
|
||||
try {
|
||||
await authApi.post(`/ai/proposals/${id}/reject`);
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "reject AI proposal");
|
||||
}
|
||||
}
|
||||
|
||||
// --- admin ---
|
||||
|
||||
export async function getAiGloballyEnabled(): Promise<boolean> {
|
||||
try {
|
||||
return (await authApi.get("/users/ai-enabled")).data.enabled;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "get AI enabled setting");
|
||||
}
|
||||
}
|
||||
|
||||
export async function setAiGloballyEnabled(enabled: boolean): Promise<boolean> {
|
||||
try {
|
||||
return (await authApi.patch("/users/ai-enabled", { enabled })).data.enabled;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "update AI enabled setting");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAiPrivateEndpoints(): Promise<string[]> {
|
||||
try {
|
||||
return (await authApi.get("/users/ai-private-endpoints")).data.hosts;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "get AI endpoint allowlist");
|
||||
}
|
||||
}
|
||||
|
||||
export async function setAiPrivateEndpoints(
|
||||
hosts: string[],
|
||||
): Promise<string[]> {
|
||||
try {
|
||||
return (await authApi.patch("/users/ai-private-endpoints", { hosts })).data
|
||||
.hosts;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "update AI endpoint allowlist");
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ export interface NotificationChannel {
|
||||
id: number;
|
||||
userId: string;
|
||||
name: string;
|
||||
type: "webhook" | "ntfy";
|
||||
type: "webhook" | "ntfy" | "discord";
|
||||
config: string;
|
||||
enabled: boolean;
|
||||
createdAt: string;
|
||||
@@ -69,8 +69,19 @@ export async function getNotificationChannels(): Promise<
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export type NotificationChannelPayload = Partial<
|
||||
Omit<NotificationChannel, "config">
|
||||
> & {
|
||||
// When creating/updating the channel the UI may pass a parsed object
|
||||
// for `config` (e.g., { url, username }) — the backend stores it as
|
||||
// a JSON string. Accept either a string or any structured object here.
|
||||
// Use `unknown` to allow the component-local config types to be passed
|
||||
// without importing them into this module.
|
||||
config: string | unknown;
|
||||
};
|
||||
|
||||
export async function createNotificationChannel(
|
||||
data: Partial<NotificationChannel>,
|
||||
data: NotificationChannelPayload,
|
||||
): Promise<NotificationChannel> {
|
||||
const res = await rbacApi.post("/notification-channels", data);
|
||||
return res.data;
|
||||
@@ -78,7 +89,7 @@ export async function createNotificationChannel(
|
||||
|
||||
export async function updateNotificationChannel(
|
||||
id: number,
|
||||
data: Partial<NotificationChannel>,
|
||||
data: NotificationChannelPayload,
|
||||
): Promise<NotificationChannel> {
|
||||
const res = await rbacApi.put(`/notification-channels/${id}`, data);
|
||||
return res.data;
|
||||
@@ -89,7 +100,11 @@ export async function deleteNotificationChannel(id: number): Promise<void> {
|
||||
}
|
||||
|
||||
export async function testNotificationChannel(id: number): Promise<void> {
|
||||
await rbacApi.post(`/notification-channels/${id}/test`);
|
||||
const res = await rbacApi.post(`/notification-channels/${id}/test`);
|
||||
const data = res.data as { success?: boolean; error?: string };
|
||||
if (data && data.success === false) {
|
||||
throw new Error(data.error || "Test notification failed");
|
||||
}
|
||||
}
|
||||
|
||||
function mapRule(r: Record<string, unknown>): AlertRule {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { authApi, handleApiError } from "@/main-axios";
|
||||
|
||||
export interface AuditLog {
|
||||
export type AuditLog = {
|
||||
id: number;
|
||||
userId: string;
|
||||
username: string;
|
||||
@@ -14,7 +14,7 @@ export interface AuditLog {
|
||||
success: boolean;
|
||||
errorMessage: string | null;
|
||||
timestamp: string;
|
||||
}
|
||||
};
|
||||
|
||||
export interface AuditLogFilters {
|
||||
page?: number;
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { authApi, handleApiError } from "@/main-axios";
|
||||
import type {
|
||||
AutomationDefinition,
|
||||
ConcurrencyPolicy,
|
||||
RunStatus,
|
||||
StepStatus,
|
||||
StepType,
|
||||
TriggerKind,
|
||||
} from "@/types/automations";
|
||||
|
||||
export interface AutomationRow {
|
||||
id: number;
|
||||
user_id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
enabled: number;
|
||||
definition: AutomationDefinition | null;
|
||||
definition_version: number;
|
||||
concurrency_policy: ConcurrencyPolicy;
|
||||
max_run_seconds: number;
|
||||
dry_run: number;
|
||||
last_run_at: string | null;
|
||||
last_run_status: RunStatus | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
channels: number[];
|
||||
/** Returned once, only when a webhook automation is created. */
|
||||
webhookToken?: string;
|
||||
}
|
||||
|
||||
export interface AutomationRunRow {
|
||||
id: number;
|
||||
automation_id: number;
|
||||
user_id: string;
|
||||
trigger_type: TriggerKind | "manual";
|
||||
trigger_context: string | null;
|
||||
status: RunStatus;
|
||||
started_at: string;
|
||||
finished_at: string | null;
|
||||
duration_ms: number | null;
|
||||
error: string | null;
|
||||
dry_run: number;
|
||||
parent_run_id: number | null;
|
||||
automation_name?: string | null;
|
||||
}
|
||||
|
||||
export interface AutomationRunStepRow {
|
||||
id: number;
|
||||
run_id: number;
|
||||
step_index: number;
|
||||
step_id: string;
|
||||
step_type: StepType;
|
||||
status: StepStatus;
|
||||
started_at: string;
|
||||
finished_at: string | null;
|
||||
output: string | null;
|
||||
error: string | null;
|
||||
truncated: number;
|
||||
}
|
||||
|
||||
export interface AutomationRunOutcome {
|
||||
runId: number | null;
|
||||
status: RunStatus;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface AutomationInput {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
enabled?: boolean;
|
||||
definition: AutomationDefinition;
|
||||
concurrencyPolicy?: ConcurrencyPolicy;
|
||||
channels?: number[];
|
||||
}
|
||||
|
||||
export async function listAutomations(): Promise<AutomationRow[]> {
|
||||
try {
|
||||
return (await authApi.get("/automations")).data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "fetch automations");
|
||||
}
|
||||
}
|
||||
|
||||
export async function createAutomation(
|
||||
input: AutomationInput,
|
||||
): Promise<AutomationRow> {
|
||||
try {
|
||||
return (await authApi.post("/automations", input)).data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "create automation");
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateAutomation(
|
||||
id: number,
|
||||
input: Partial<AutomationInput>,
|
||||
): Promise<AutomationRow> {
|
||||
try {
|
||||
return (await authApi.put(`/automations/${id}`, input)).data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "update automation");
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteAutomation(id: number): Promise<void> {
|
||||
try {
|
||||
await authApi.delete(`/automations/${id}`);
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "delete automation");
|
||||
}
|
||||
}
|
||||
|
||||
export async function runAutomation(
|
||||
id: number,
|
||||
options: { dryRun?: boolean } = {},
|
||||
): Promise<AutomationRunOutcome> {
|
||||
try {
|
||||
return (await authApi.post(`/automations/${id}/run`, options)).data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "run automation");
|
||||
}
|
||||
}
|
||||
|
||||
export async function listAutomationRuns(
|
||||
options: { automationId?: number; limit?: number; offset?: number } = {},
|
||||
): Promise<AutomationRunRow[]> {
|
||||
try {
|
||||
return (await authApi.get("/automations/runs/history", { params: options }))
|
||||
.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "fetch automation runs");
|
||||
}
|
||||
}
|
||||
|
||||
export async function listAutomationRunSteps(
|
||||
runId: number,
|
||||
): Promise<AutomationRunStepRow[]> {
|
||||
try {
|
||||
return (await authApi.get(`/automations/runs/${runId}/steps`)).data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "fetch run steps");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { authApi, handleApiError } from "@/main-axios";
|
||||
import {
|
||||
sanitizeCredentialSidebarPreferences,
|
||||
type CredentialSidebarPreferences,
|
||||
} from "@/types/credential-sidebar-preferences";
|
||||
|
||||
// CREDENTIAL SIDEBAR PREFERENCES API
|
||||
// ============================================================================
|
||||
|
||||
export async function getCredentialSidebarPreferences(): Promise<CredentialSidebarPreferences> {
|
||||
try {
|
||||
const response = await authApi.get("/credential-sidebar/preferences");
|
||||
return sanitizeCredentialSidebarPreferences(response.data?.preferences);
|
||||
} catch (error) {
|
||||
handleApiError(error, "fetch credential sidebar preferences");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveCredentialSidebarPreferences(
|
||||
preferences: CredentialSidebarPreferences,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await authApi.put("/credential-sidebar/preferences", preferences);
|
||||
} catch (error) {
|
||||
handleApiError(error, "save credential sidebar preferences");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
import { authApi, handleApiError, sshHostApi } from "@/main-axios";
|
||||
import type { SSHFolder } from "@/types/index";
|
||||
import { sshLogger } from "@/lib/frontend-logger";
|
||||
import {
|
||||
getCachedSSHFolders,
|
||||
invalidateSSHFoldersCache,
|
||||
invalidateHostsAndStatusCaches,
|
||||
} from "@/lib/hosts-request-cache";
|
||||
|
||||
export async function getCredentials(): Promise<
|
||||
Record<string, unknown>[] | Record<string, unknown>
|
||||
@@ -174,6 +179,7 @@ export async function renameFolder(
|
||||
oldName,
|
||||
newName,
|
||||
});
|
||||
invalidateSSHFoldersCache();
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "rename folder");
|
||||
@@ -186,14 +192,17 @@ export async function getSSHFolders(): Promise<SSHFolder[]> {
|
||||
operation: "fetch_ssh_folders",
|
||||
});
|
||||
|
||||
const response = await authApi.get("/host/folders");
|
||||
const folders = await getCachedSSHFolders(async () => {
|
||||
const response = await authApi.get("/host/folders");
|
||||
return response.data;
|
||||
});
|
||||
|
||||
sshLogger.success("SSH folders fetched successfully", {
|
||||
operation: "fetch_ssh_folders",
|
||||
count: response.data.length,
|
||||
count: folders.length,
|
||||
});
|
||||
|
||||
return response.data;
|
||||
return folders;
|
||||
} catch (error) {
|
||||
sshLogger.error("Failed to fetch SSH folders", error, {
|
||||
operation: "fetch_ssh_folders",
|
||||
@@ -225,6 +234,8 @@ export async function updateFolderMetadata(
|
||||
credentialId,
|
||||
});
|
||||
|
||||
invalidateSSHFoldersCache();
|
||||
|
||||
sshLogger.success("Folder metadata updated successfully", {
|
||||
operation: "update_folder_metadata",
|
||||
name,
|
||||
@@ -239,6 +250,21 @@ export async function updateFolderMetadata(
|
||||
}
|
||||
}
|
||||
|
||||
export async function reorderFolders(
|
||||
positions: { name: string; sortOrder: number }[],
|
||||
): Promise<{ updated: number }> {
|
||||
try {
|
||||
const response = await authApi.put("/host/folders/reorder", {
|
||||
positions,
|
||||
});
|
||||
invalidateSSHFoldersCache();
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "reorder folders");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteAllHostsInFolder(
|
||||
folderName: string,
|
||||
): Promise<{ deletedCount: number }> {
|
||||
@@ -252,6 +278,8 @@ export async function deleteAllHostsInFolder(
|
||||
`/host/folders/${encodeURIComponent(folderName)}/hosts`,
|
||||
);
|
||||
|
||||
invalidateHostsAndStatusCaches();
|
||||
|
||||
sshLogger.success("All hosts in folder deleted successfully", {
|
||||
operation: "delete_folder_hosts",
|
||||
folderName,
|
||||
@@ -278,12 +306,27 @@ export async function renameCredentialFolder(
|
||||
oldName,
|
||||
newName,
|
||||
});
|
||||
invalidateSSHFoldersCache();
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "rename credential folder");
|
||||
}
|
||||
}
|
||||
|
||||
export async function reorderCredentials(
|
||||
positions: { id: number; sortOrder: number }[],
|
||||
): Promise<{ updated: number }> {
|
||||
try {
|
||||
const response = await authApi.put("/credentials/reorder", {
|
||||
positions,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "reorder credentials");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function detectKeyType(
|
||||
privateKey: string,
|
||||
keyPassword?: string,
|
||||
@@ -329,10 +372,16 @@ export async function validateKeyPair(
|
||||
}
|
||||
}
|
||||
|
||||
export interface GeneratedPublicKey {
|
||||
success?: boolean;
|
||||
publicKey?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function generatePublicKeyFromPrivate(
|
||||
privateKey: string,
|
||||
keyPassword?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
): Promise<GeneratedPublicKey> {
|
||||
try {
|
||||
const response = await authApi.post("/credentials/generate-public-key", {
|
||||
privateKey,
|
||||
@@ -344,11 +393,23 @@ export async function generatePublicKeyFromPrivate(
|
||||
}
|
||||
}
|
||||
|
||||
export interface GeneratedKeyPair {
|
||||
success: boolean;
|
||||
privateKey?: string;
|
||||
publicKey?: string;
|
||||
keyType?: string;
|
||||
format?: string;
|
||||
algorithm?: string;
|
||||
keySize?: number;
|
||||
curve?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function generateKeyPair(
|
||||
keyType: "ssh-ed25519" | "ssh-rsa" | "ecdsa-sha2-nistp256",
|
||||
keySize?: number,
|
||||
passphrase?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
): Promise<GeneratedKeyPair> {
|
||||
try {
|
||||
const response = await authApi.post("/credentials/generate-key-pair", {
|
||||
keyType,
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
import { authApi, handleApiError } from "@/main-axios";
|
||||
|
||||
/** Row shape shared by the recent / pinned / shortcut list endpoints. */
|
||||
export interface FileManagerEntry {
|
||||
id: number;
|
||||
name: string;
|
||||
path: string;
|
||||
lastOpened?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// FILE MANAGER DATA
|
||||
// ============================================================================
|
||||
|
||||
export async function getRecentFiles(
|
||||
hostId: number,
|
||||
): Promise<Record<string, unknown>> {
|
||||
): Promise<FileManagerEntry[]> {
|
||||
try {
|
||||
const response = await authApi.get("/host/file_manager/recent", {
|
||||
params: { hostId },
|
||||
@@ -52,7 +61,7 @@ export async function removeRecentFile(
|
||||
|
||||
export async function getPinnedFiles(
|
||||
hostId: number,
|
||||
): Promise<Record<string, unknown>> {
|
||||
): Promise<FileManagerEntry[]> {
|
||||
try {
|
||||
const response = await authApi.get("/host/file_manager/pinned", {
|
||||
params: { hostId },
|
||||
@@ -99,7 +108,7 @@ export async function removePinnedFile(
|
||||
|
||||
export async function getFolderShortcuts(
|
||||
hostId: number,
|
||||
): Promise<Record<string, unknown>> {
|
||||
): Promise<FileManagerEntry[]> {
|
||||
try {
|
||||
const response = await authApi.get("/host/file_manager/shortcuts", {
|
||||
params: { hostId },
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
import { authApi, handleApiError } from "@/main-axios";
|
||||
|
||||
export interface FleetRow {
|
||||
id: number;
|
||||
userId: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
color: string | null;
|
||||
icon: string | null;
|
||||
tagRules: string[];
|
||||
syncId: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
memberCount: number;
|
||||
}
|
||||
|
||||
export interface FleetMemberRow {
|
||||
id: number;
|
||||
name: string;
|
||||
ip: string;
|
||||
tags: string[];
|
||||
static: boolean;
|
||||
permissionLevel: "connect" | "view" | "edit" | "manage" | null;
|
||||
}
|
||||
|
||||
export interface FleetHostResult {
|
||||
hostId: number;
|
||||
hostName: string;
|
||||
success: boolean;
|
||||
output?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface FleetInventoryRecord {
|
||||
id: number;
|
||||
hostId: number;
|
||||
userId: string;
|
||||
osPrettyName: string | null;
|
||||
kernel: string | null;
|
||||
architecture: string | null;
|
||||
hostname: string | null;
|
||||
uptimeSeconds: number | null;
|
||||
ip: string | null;
|
||||
packageManager: string | null;
|
||||
collectedAt: string;
|
||||
}
|
||||
|
||||
export interface FleetInventoryEntry {
|
||||
hostId: number;
|
||||
hostName: string;
|
||||
inventory: FleetInventoryRecord | null;
|
||||
}
|
||||
|
||||
export type FleetPackageAction = "install" | "remove" | "upgrade-all";
|
||||
|
||||
export async function listFleets(): Promise<FleetRow[]> {
|
||||
try {
|
||||
const response = await authApi.get("/fleets");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "fetch fleets");
|
||||
}
|
||||
}
|
||||
|
||||
export async function createFleet(fleetData: {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
color?: string | null;
|
||||
icon?: string | null;
|
||||
tagRules?: string[];
|
||||
}): Promise<FleetRow> {
|
||||
try {
|
||||
const response = await authApi.post("/fleets", fleetData);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "create fleet");
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateFleet(
|
||||
fleetId: number,
|
||||
fleetData: {
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
color?: string | null;
|
||||
icon?: string | null;
|
||||
tagRules?: string[];
|
||||
},
|
||||
): Promise<FleetRow> {
|
||||
try {
|
||||
const response = await authApi.patch(`/fleets/${fleetId}`, fleetData);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "update fleet");
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteFleet(
|
||||
fleetId: number,
|
||||
): Promise<{ success: boolean }> {
|
||||
try {
|
||||
const response = await authApi.delete(`/fleets/${fleetId}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "delete fleet");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getFleetMembers(
|
||||
fleetId: number,
|
||||
): Promise<FleetMemberRow[]> {
|
||||
try {
|
||||
const response = await authApi.get(`/fleets/${fleetId}/members`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "fetch fleet members");
|
||||
}
|
||||
}
|
||||
|
||||
export async function addFleetMember(
|
||||
fleetId: number,
|
||||
hostId: number,
|
||||
): Promise<{ success: boolean }> {
|
||||
try {
|
||||
const response = await authApi.post(`/fleets/${fleetId}/members`, {
|
||||
hostId,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "add fleet member");
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeFleetMember(
|
||||
fleetId: number,
|
||||
hostId: number,
|
||||
): Promise<{ success: boolean }> {
|
||||
try {
|
||||
const response = await authApi.delete(
|
||||
`/fleets/${fleetId}/members/${hostId}`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "remove fleet member");
|
||||
}
|
||||
}
|
||||
|
||||
export async function runFleetCommand(
|
||||
fleetId: number,
|
||||
command: string,
|
||||
inputValues?: Record<string, string>,
|
||||
): Promise<{ results: FleetHostResult[] }> {
|
||||
try {
|
||||
const response = await authApi.post(`/fleets/${fleetId}/execute`, {
|
||||
command,
|
||||
...(inputValues ? { inputValues } : {}),
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "run fleet command");
|
||||
}
|
||||
}
|
||||
|
||||
export async function pushFleetFile(
|
||||
fleetId: number,
|
||||
file: File,
|
||||
remotePath: string,
|
||||
): Promise<{ results: FleetHostResult[] }> {
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
form.append("remotePath", remotePath);
|
||||
const response = await authApi.post(
|
||||
`/fleets/${fleetId}/transfer/push`,
|
||||
form,
|
||||
{ headers: { "Content-Type": "multipart/form-data" } },
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "push file to fleet");
|
||||
}
|
||||
}
|
||||
|
||||
export async function pullFleetFile(
|
||||
fleetId: number,
|
||||
remotePath: string,
|
||||
): Promise<{ results: FleetHostResult[]; blob: Blob; fileName: string }> {
|
||||
try {
|
||||
const response = await authApi.post(
|
||||
`/fleets/${fleetId}/transfer/pull`,
|
||||
{ remotePath },
|
||||
{ responseType: "blob" },
|
||||
);
|
||||
|
||||
const resultsHeader = response.headers["x-fleet-transfer-results"];
|
||||
const results: FleetHostResult[] = resultsHeader
|
||||
? JSON.parse(atob(resultsHeader))
|
||||
: [];
|
||||
|
||||
const disposition = response.headers["content-disposition"] as
|
||||
string | undefined;
|
||||
const match = disposition?.match(/filename="([^"]+)"/);
|
||||
const fileName = match?.[1] ?? "fleet-transfer.zip";
|
||||
|
||||
return { results, blob: response.data, fileName };
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "pull file from fleet");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getFleetInventory(
|
||||
fleetId: number,
|
||||
): Promise<FleetInventoryEntry[]> {
|
||||
try {
|
||||
const response = await authApi.get(`/fleets/${fleetId}/inventory`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "fetch fleet inventory");
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshFleetInventory(
|
||||
fleetId: number,
|
||||
): Promise<{ results: FleetHostResult[] }> {
|
||||
try {
|
||||
const response = await authApi.post(`/fleets/${fleetId}/inventory`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "refresh fleet inventory");
|
||||
}
|
||||
}
|
||||
|
||||
export async function runFleetPackageAction(
|
||||
fleetId: number,
|
||||
action: FleetPackageAction,
|
||||
packageName?: string,
|
||||
): Promise<{ results: FleetHostResult[] }> {
|
||||
try {
|
||||
const response = await authApi.post(`/fleets/${fleetId}/packages`, {
|
||||
action,
|
||||
...(packageName ? { package: packageName } : {}),
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "run fleet package action");
|
||||
}
|
||||
}
|
||||
+22
-60
@@ -5,6 +5,7 @@ import {
|
||||
isElectron,
|
||||
} from "@/main-axios";
|
||||
import type { AxiosInstance } from "axios";
|
||||
import type { GuacamoleConfig } from "@/types/guacamole-config";
|
||||
|
||||
/**
|
||||
* The embedded desktop backend does not bundle guacd, which is why
|
||||
@@ -25,65 +26,7 @@ export interface GuacamoleTokenRequest {
|
||||
domain?: string;
|
||||
security?: string;
|
||||
ignoreCert?: boolean;
|
||||
guacamoleConfig?: {
|
||||
colorDepth?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
dpi?: number;
|
||||
resizeMethod?: string;
|
||||
forceLossless?: boolean;
|
||||
disableAudio?: boolean;
|
||||
enableAudioInput?: boolean;
|
||||
enableWallpaper?: boolean;
|
||||
enableTheming?: boolean;
|
||||
enableFontSmoothing?: boolean;
|
||||
enableFullWindowDrag?: boolean;
|
||||
enableDesktopComposition?: boolean;
|
||||
enableMenuAnimations?: boolean;
|
||||
disableBitmapCaching?: boolean;
|
||||
disableOffscreenCaching?: boolean;
|
||||
disableGlyphCaching?: boolean;
|
||||
disableGfx?: boolean;
|
||||
enablePrinting?: boolean;
|
||||
printerName?: string;
|
||||
enableDrive?: boolean;
|
||||
driveName?: string;
|
||||
drivePath?: string;
|
||||
createDrivePath?: boolean;
|
||||
disableDownload?: boolean;
|
||||
disableUpload?: boolean;
|
||||
enableTouch?: boolean;
|
||||
clientName?: string;
|
||||
console?: boolean;
|
||||
initialProgram?: string;
|
||||
serverLayout?: string;
|
||||
timezone?: string;
|
||||
gatewayHostname?: string;
|
||||
gatewayPort?: number;
|
||||
gatewayUsername?: string;
|
||||
gatewayPassword?: string;
|
||||
gatewayDomain?: string;
|
||||
remoteApp?: string;
|
||||
remoteAppDir?: string;
|
||||
remoteAppArgs?: string;
|
||||
normalizeClipboard?: string;
|
||||
disableCopy?: boolean;
|
||||
disablePaste?: boolean;
|
||||
cursor?: string;
|
||||
swapRedBlue?: boolean;
|
||||
readOnly?: boolean;
|
||||
recordingPath?: string;
|
||||
recordingName?: string;
|
||||
createRecordingPath?: boolean;
|
||||
recordingExcludeOutput?: boolean;
|
||||
recordingExcludeMouse?: boolean;
|
||||
recordingIncludeKeys?: boolean;
|
||||
wolSendPacket?: boolean;
|
||||
wolMacAddr?: string;
|
||||
wolBroadcastAddr?: string;
|
||||
wolUdpPort?: number;
|
||||
wolWaitTime?: number;
|
||||
};
|
||||
guacamoleConfig?: GuacamoleConfig;
|
||||
}
|
||||
|
||||
export interface GuacamoleTokenResponse {
|
||||
@@ -95,6 +38,18 @@ type GuacamoleConfigSource = {
|
||||
guacamoleConfig?: string | Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export function parseGuacamoleConfig(
|
||||
config?: string | GuacamoleConfig | null,
|
||||
): GuacamoleConfig {
|
||||
if (!config) return {};
|
||||
if (typeof config !== "string") return config;
|
||||
try {
|
||||
return JSON.parse(config) as GuacamoleConfig;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function getGuacamoleDpi(
|
||||
source?: GuacamoleConfigSource,
|
||||
): number | undefined {
|
||||
@@ -225,7 +180,11 @@ export async function getGuacamoleToken(
|
||||
export async function getGuacamoleTokenFromHost(
|
||||
hostId: number,
|
||||
protocol?: "rdp" | "vnc" | "telnet",
|
||||
promptedCredentials?: { username?: string; password?: string },
|
||||
promptedCredentials?: {
|
||||
username?: string;
|
||||
password?: string;
|
||||
domain?: string;
|
||||
},
|
||||
): Promise<GuacamoleTokenResponse> {
|
||||
try {
|
||||
const response = await guacamoleApi().post(
|
||||
@@ -238,6 +197,9 @@ export async function getGuacamoleTokenFromHost(
|
||||
...(promptedCredentials?.password
|
||||
? { promptedPassword: promptedCredentials.password }
|
||||
: {}),
|
||||
...(promptedCredentials
|
||||
? { promptedDomain: promptedCredentials.domain ?? "" }
|
||||
: {}),
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
|
||||
@@ -70,12 +70,3 @@ export async function saveHomepageLayout(
|
||||
throw handleApiError(error, "save homepage layout");
|
||||
}
|
||||
}
|
||||
|
||||
export function getHomepageFaviconUrl(url: string): string {
|
||||
try {
|
||||
const base = (homepageApi.defaults.baseURL ?? "").replace(/\/$/, "");
|
||||
return `${base}/favicon?url=${encodeURIComponent(url)}`;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,13 @@ import {
|
||||
statsApi,
|
||||
getRemoteStatsApi,
|
||||
isElectron,
|
||||
sshHostApi,
|
||||
} from "@/main-axios";
|
||||
import type { ServerMetrics, ServerStatus } from "@/main-axios";
|
||||
import { getCachedServerStatuses } from "@/lib/hosts-request-cache";
|
||||
import { resolveConnectionOrigin } from "@/lib/connection-origin";
|
||||
import type { SSHHost } from "@/types/index";
|
||||
import { createKeyedRequestCache } from "@/lib/keyed-request-cache";
|
||||
|
||||
// Metrics collection/viewer registration below (startMetricsPolling,
|
||||
// registerMetricsViewer, etc.) is NOT origin-routed: the backend that
|
||||
@@ -50,6 +54,8 @@ type ConnectErrorResponse = {
|
||||
// SERVER STATISTICS
|
||||
// ============================================================================
|
||||
|
||||
const metricsCache = createKeyedRequestCache<ServerMetrics | null>(1_500, 100);
|
||||
|
||||
/**
|
||||
* Progressive retry schedule for the background /status poll.
|
||||
*
|
||||
@@ -100,6 +106,26 @@ export async function getAllServerStatuses(): Promise<
|
||||
return getCachedServerStatuses(async () => {
|
||||
let lastError: unknown = null;
|
||||
let localStatuses: Record<number, ServerStatus> = {};
|
||||
let localHostIds: number[] | null = null;
|
||||
|
||||
if (isElectron()) {
|
||||
try {
|
||||
const response = await sshHostApi.get<SSHHost[]>("/db/host");
|
||||
const defaultOrigin = await resolveConnectionOrigin({
|
||||
connectionType: "ssh",
|
||||
connectionOrigin: null,
|
||||
});
|
||||
localHostIds = (response.data || [])
|
||||
.filter(
|
||||
(host) => (host.connectionOrigin ?? defaultOrigin) === "local",
|
||||
)
|
||||
.map((host) => host.id);
|
||||
} catch {
|
||||
// A host-list failure must not start local probes for hosts whose
|
||||
// configured origin may be remote.
|
||||
localHostIds = [];
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < STATUS_RETRY_SCHEDULE.length; i++) {
|
||||
const { timeoutMs, pauseAfterMs } = STATUS_RETRY_SCHEDULE[i];
|
||||
@@ -108,6 +134,9 @@ export async function getAllServerStatuses(): Promise<
|
||||
try {
|
||||
const response = await statsApi.get("/status", {
|
||||
timeout: timeoutMs,
|
||||
...(localHostIds === null
|
||||
? {}
|
||||
: { params: { hostIds: localHostIds.join(",") } }),
|
||||
// Silence per-attempt interceptor logging & health-monitor side
|
||||
// effects on all attempts except the final one, so background
|
||||
// blips don't look like real outages.
|
||||
@@ -162,25 +191,24 @@ export async function getServerStatusById(id: number): Promise<ServerStatus> {
|
||||
export async function getServerMetricsById(
|
||||
id: number,
|
||||
): Promise<ServerMetrics | null> {
|
||||
try {
|
||||
const response = await statsApi.get(`/metrics/${id}`, {
|
||||
// Treat 404 as an expected "no metrics yet / disabled" signal rather
|
||||
// than an error so we don't spam warn logs on the client.
|
||||
validateStatus: (status) => status === 200 || status === 404,
|
||||
});
|
||||
if (response.status === 404) {
|
||||
return null;
|
||||
return metricsCache.get(String(id), async () => {
|
||||
try {
|
||||
const response = await statsApi.get(`/metrics/${id}`, {
|
||||
// Treat 404 as an expected "no metrics yet / disabled" signal rather
|
||||
// than an error so we don't spam warn logs on the client.
|
||||
validateStatus: (status) => status === 200 || status === 404,
|
||||
});
|
||||
if (response.status === 404) return null;
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
// If a 404 still slips through (e.g. intercepted before reaching here),
|
||||
// swallow it quietly; everything else still flows through handleApiError.
|
||||
if (axios.isAxiosError(error) && error.response?.status === 404) {
|
||||
return null;
|
||||
}
|
||||
handleApiError(error, "fetch server metrics");
|
||||
}
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
// If a 404 still slips through (e.g. intercepted before reaching here),
|
||||
// swallow it quietly; everything else still flows through handleApiError.
|
||||
if (axios.isAxiosError(error) && error.response?.status === 404) {
|
||||
return null;
|
||||
}
|
||||
handleApiError(error, "fetch server metrics");
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function startMetricsPolling(hostId: number): Promise<{
|
||||
@@ -193,6 +221,7 @@ export async function startMetricsPolling(hostId: number): Promise<{
|
||||
}> {
|
||||
try {
|
||||
const response = await statsApi.post(`/metrics/start/${hostId}`);
|
||||
metricsCache.invalidate(String(hostId));
|
||||
return response.data;
|
||||
} catch (error: unknown) {
|
||||
if (
|
||||
@@ -246,6 +275,7 @@ export async function registerMetricsViewer(hostId: number): Promise<{
|
||||
const response = await statsApi.post("/metrics/register-viewer", {
|
||||
hostId,
|
||||
});
|
||||
metricsCache.invalidate(String(hostId));
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "register metrics viewer");
|
||||
@@ -280,6 +310,7 @@ export async function submitMetricsTOTP(
|
||||
sessionId,
|
||||
totpCode,
|
||||
});
|
||||
metricsCache.invalidate();
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "submit metrics TOTP");
|
||||
@@ -300,6 +331,7 @@ export async function notifyHostCreatedOrUpdated(
|
||||
): Promise<void> {
|
||||
try {
|
||||
await statsApi.post("/host-updated", { hostId });
|
||||
metricsCache.invalidate(String(hostId));
|
||||
} catch (error) {
|
||||
console.warn("Failed to notify stats server of host update:", error);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { authApi, handleApiError } from "@/main-axios";
|
||||
import {
|
||||
sanitizeHostSidebarPreferences,
|
||||
type HostSidebarPreferences,
|
||||
} from "@/types/host-sidebar-preferences";
|
||||
|
||||
// HOST SIDEBAR PREFERENCES API
|
||||
// ============================================================================
|
||||
|
||||
export async function getHostSidebarPreferences(): Promise<HostSidebarPreferences> {
|
||||
try {
|
||||
const response = await authApi.get("/host-sidebar/preferences");
|
||||
return sanitizeHostSidebarPreferences(response.data?.preferences);
|
||||
} catch (error) {
|
||||
handleApiError(error, "fetch host sidebar preferences");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveHostSidebarPreferences(
|
||||
preferences: HostSidebarPreferences,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await authApi.put("/host-sidebar/preferences", preferences);
|
||||
} catch (error) {
|
||||
handleApiError(error, "save host sidebar preferences");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -67,7 +67,7 @@ export async function deleteOpenTab(instanceId: string): Promise<void> {
|
||||
export async function patchOpenTab(
|
||||
instanceId: string,
|
||||
updates: Partial<
|
||||
Pick<OpenTabRecord, "label" | "tabOrder" | "backendSessionId">
|
||||
Pick<OpenTabRecord, "hostId" | "label" | "tabOrder" | "backendSessionId">
|
||||
>,
|
||||
): Promise<void> {
|
||||
await authApi.patch(`/open-tabs/${instanceId}`, updates);
|
||||
@@ -112,10 +112,15 @@ export interface UserPreferences {
|
||||
disableUpdateCheck?: boolean | null;
|
||||
confirmTabClose?: boolean | null;
|
||||
hiddenRailTabs?: string | null;
|
||||
aiAssistantEnabled?: boolean | null;
|
||||
aiReadOnlyCommands?: boolean | null;
|
||||
compactHostView?: boolean | null;
|
||||
statusColorScheme?: string | null;
|
||||
customThemes?: string | null;
|
||||
customKeybindings?: string | null;
|
||||
terminalDefaults?: string | null;
|
||||
rdpDefaults?: string | null;
|
||||
terminalMacros?: string | null;
|
||||
}
|
||||
|
||||
export function parseCustomThemes(raw?: string | null): SavedCustomTheme[] {
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import axios from "axios";
|
||||
import { handleApiError, statsApi } from "@/main-axios";
|
||||
import type { ProxmoxStatsSnapshot } from "@/types/proxmox";
|
||||
|
||||
// Every function below is keyed by a host's numeric database id, and the
|
||||
// receiving backend must own that host in its own database -- same caveat as
|
||||
// host-metrics-api.ts / host-metrics-status-api.ts. All routes live under the
|
||||
// `/proxmox-stats/*` prefix on the stats app (port 30005).
|
||||
|
||||
export async function getProxmoxStats(
|
||||
hostId: number,
|
||||
): Promise<ProxmoxStatsSnapshot | null> {
|
||||
try {
|
||||
const response = await statsApi.get(`/proxmox-stats/${hostId}`, {
|
||||
// Treat 404 as an expected "no stats yet / disabled" signal rather than
|
||||
// an error so we don't spam warn logs on the client.
|
||||
validateStatus: (status) => status === 200 || status === 404,
|
||||
});
|
||||
if (response.status === 404) {
|
||||
return null;
|
||||
}
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error) && error.response?.status === 404) {
|
||||
return null;
|
||||
}
|
||||
handleApiError(error, "fetch proxmox stats");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function startProxmoxStatsPolling(hostId: number): Promise<{
|
||||
success: boolean;
|
||||
viewerSessionId?: string;
|
||||
status?: string;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const response = await statsApi.post(`/proxmox-stats/start/${hostId}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "start proxmox stats polling");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function stopProxmoxStatsPolling(
|
||||
hostId: number,
|
||||
viewerSessionId?: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await statsApi.post(`/proxmox-stats/stop/${hostId}`, { viewerSessionId });
|
||||
} catch (error) {
|
||||
handleApiError(error, "stop proxmox stats polling");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendProxmoxStatsHeartbeat(
|
||||
viewerSessionId: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await statsApi.post("/proxmox-stats/heartbeat", { viewerSessionId });
|
||||
} catch (error) {
|
||||
handleApiError(error, "send proxmox stats heartbeat");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ProxmoxStatsHistoryRow {
|
||||
ts: string;
|
||||
cpu_percent: number | null;
|
||||
mem_percent: number | null;
|
||||
disk_percent: number | null;
|
||||
net_rx_bytes: number | null;
|
||||
net_tx_bytes: number | null;
|
||||
}
|
||||
|
||||
export interface ProxmoxStatsHistoryResponse {
|
||||
rows: ProxmoxStatsHistoryRow[];
|
||||
fromTs: string;
|
||||
toTs: string;
|
||||
}
|
||||
|
||||
export async function getProxmoxStatsHistory(
|
||||
hostId: number,
|
||||
opts: { range?: string; from?: string; to?: string },
|
||||
): Promise<ProxmoxStatsHistoryResponse> {
|
||||
const res = await statsApi.get(`/proxmox-stats/history/${hostId}`, {
|
||||
params: opts,
|
||||
});
|
||||
return res.data as ProxmoxStatsHistoryResponse;
|
||||
}
|
||||
+42
-12
@@ -1,10 +1,30 @@
|
||||
import { handleApiError, rbacApi } from "@/main-axios";
|
||||
import type { AccessRecord, Role, UserRole } from "@/main-axios";
|
||||
import {
|
||||
handleApiError,
|
||||
rbacApi,
|
||||
type AccessRecord,
|
||||
type Role,
|
||||
type UserRole,
|
||||
} from "@/main-axios";
|
||||
import type { AuthOverrideProtocol } from "@/types/auth-protocols";
|
||||
import {
|
||||
getConnectedRemoteApi,
|
||||
resolveRemoteHostId,
|
||||
} from "@/lib/remote-server-api";
|
||||
|
||||
async function getSharingTarget(hostId: number, syncId?: string | null) {
|
||||
const api = await getConnectedRemoteApi();
|
||||
if (!api || !syncId) return { api: rbacApi, hostId };
|
||||
const remoteHostId = await resolveRemoteHostId(syncId);
|
||||
if (remoteHostId === null) {
|
||||
throw new Error("The synced host does not exist on the remote server");
|
||||
}
|
||||
return { api, hostId: remoteHostId };
|
||||
}
|
||||
|
||||
export async function getRoles(): Promise<{ roles: Role[] }> {
|
||||
try {
|
||||
const response = await rbacApi.get("/rbac/roles");
|
||||
const api = (await getConnectedRemoteApi()) ?? rbacApi;
|
||||
const response = await api.get("/rbac/roles");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "fetch roles");
|
||||
@@ -104,6 +124,7 @@ export async function shareHost(
|
||||
permissionLevel: SharePermissionLevel;
|
||||
durationHours?: number;
|
||||
},
|
||||
syncId?: string | null,
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
expiresAt: string | null;
|
||||
@@ -115,8 +136,9 @@ export async function shareHost(
|
||||
}>;
|
||||
}> {
|
||||
try {
|
||||
const response = await rbacApi.post(
|
||||
`/rbac/host/${hostId}/share`,
|
||||
const target = await getSharingTarget(hostId, syncId);
|
||||
const response = await target.api.post(
|
||||
`/rbac/host/${target.hostId}/share`,
|
||||
shareData,
|
||||
);
|
||||
return response.data;
|
||||
@@ -140,7 +162,8 @@ export async function shareFolder(
|
||||
hostResults: Array<{ hostId: number; shared: boolean; reason?: string }>;
|
||||
}> {
|
||||
try {
|
||||
const response = await rbacApi.post("/rbac/folder/share", {
|
||||
const api = (await getConnectedRemoteApi()) ?? rbacApi;
|
||||
const response = await api.post("/rbac/folder/share", {
|
||||
folder,
|
||||
...shareData,
|
||||
});
|
||||
@@ -157,10 +180,12 @@ export async function updateHostAccess(
|
||||
permissionLevel?: SharePermissionLevel;
|
||||
durationHours?: number | null;
|
||||
},
|
||||
syncId?: string | null,
|
||||
): Promise<{ success: boolean; expiresAt: string | null }> {
|
||||
try {
|
||||
const response = await rbacApi.patch(
|
||||
`/rbac/host/${hostId}/access/${accessId}`,
|
||||
const target = await getSharingTarget(hostId, syncId);
|
||||
const response = await target.api.patch(
|
||||
`/rbac/host/${target.hostId}/access/${accessId}`,
|
||||
update,
|
||||
);
|
||||
return response.data;
|
||||
@@ -171,9 +196,11 @@ export async function updateHostAccess(
|
||||
|
||||
export async function getHostAccess(
|
||||
hostId: number,
|
||||
syncId?: string | null,
|
||||
): Promise<{ accessList: AccessRecord[]; isOwner?: boolean }> {
|
||||
try {
|
||||
const response = await rbacApi.get(`/rbac/host/${hostId}/access`);
|
||||
const target = await getSharingTarget(hostId, syncId);
|
||||
const response = await target.api.get(`/rbac/host/${target.hostId}/access`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "fetch host access");
|
||||
@@ -212,7 +239,8 @@ export async function getSharedHosts(): Promise<{
|
||||
}>;
|
||||
}> {
|
||||
try {
|
||||
const response = await rbacApi.get("/rbac/shared-hosts");
|
||||
const api = (await getConnectedRemoteApi()) ?? rbacApi;
|
||||
const response = await api.get("/rbac/shared-hosts");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "fetch shared hosts");
|
||||
@@ -222,10 +250,12 @@ export async function getSharedHosts(): Promise<{
|
||||
export async function revokeHostAccess(
|
||||
hostId: number,
|
||||
accessId: number,
|
||||
syncId?: string | null,
|
||||
): Promise<{ success: boolean }> {
|
||||
try {
|
||||
const response = await rbacApi.delete(
|
||||
`/rbac/host/${hostId}/access/${accessId}`,
|
||||
const target = await getSharingTarget(hostId, syncId);
|
||||
const response = await target.api.delete(
|
||||
`/rbac/host/${target.hostId}/access/${accessId}`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
|
||||
@@ -152,17 +152,6 @@ export async function revokeSessionShare(
|
||||
}
|
||||
}
|
||||
|
||||
export async function endSessionShareSession(
|
||||
shareId: string,
|
||||
): Promise<{ success: true }> {
|
||||
try {
|
||||
const response = await authApi.post(`/session-sharing/${shareId}/end`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "end shared session");
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// GLOBAL ADMIN TOGGLE
|
||||
// ============================================================================
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import axios from "axios";
|
||||
import { authApi, handleApiError, statsApi } from "@/main-axios";
|
||||
|
||||
// GLOBAL MONITORING SETTINGS
|
||||
@@ -77,6 +78,7 @@ export async function updateSessionTimeout(
|
||||
export async function getTailscaleSettings(): Promise<{
|
||||
apiKey: string;
|
||||
hasApiKey: boolean;
|
||||
apiBaseUrl: string;
|
||||
}> {
|
||||
try {
|
||||
const response = await authApi.get("/users/tailscale-settings");
|
||||
@@ -88,10 +90,12 @@ export async function getTailscaleSettings(): Promise<{
|
||||
|
||||
export async function updateTailscaleSettings(
|
||||
apiKey: string,
|
||||
apiBaseUrl?: string,
|
||||
): Promise<{ hasApiKey: boolean }> {
|
||||
try {
|
||||
const response = await authApi.patch("/users/tailscale-settings", {
|
||||
apiKey,
|
||||
apiBaseUrl,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
@@ -109,12 +113,29 @@ export async function getTailscaleDevices(): Promise<{
|
||||
lastSeen: string;
|
||||
}>;
|
||||
hasApiKey: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const response = await authApi.get("/tailscale/devices");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const data = error.response?.data;
|
||||
if (
|
||||
data &&
|
||||
typeof data === "object" &&
|
||||
"hasApiKey" in data &&
|
||||
typeof data.hasApiKey === "boolean"
|
||||
) {
|
||||
return data as {
|
||||
devices: [];
|
||||
hasApiKey: boolean;
|
||||
error?: string;
|
||||
};
|
||||
}
|
||||
}
|
||||
handleApiError(error, "fetch Tailscale devices");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,6 +195,79 @@ export async function updateAnalyticsEnabled(
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TERMINAL IMAGE STORAGE SETTINGS
|
||||
// ============================================================================
|
||||
|
||||
export type TerminalImageStorageMode = "auto" | "local" | "remote-sftp";
|
||||
|
||||
/** Public settings shape: the backend-internal localDir is never returned. */
|
||||
export interface TerminalImageStorageSettings {
|
||||
mode: TerminalImageStorageMode;
|
||||
hostPath: string;
|
||||
ttlMs: number;
|
||||
maxCount: number;
|
||||
maxBytes: number;
|
||||
localMappingConfigured: boolean;
|
||||
}
|
||||
|
||||
export interface TerminalImageStorageSettingsUpdate {
|
||||
mode?: TerminalImageStorageMode;
|
||||
localDir?: string;
|
||||
hostPath?: string;
|
||||
ttlMs?: number;
|
||||
maxCount?: number;
|
||||
maxBytes?: number;
|
||||
}
|
||||
|
||||
export interface TerminalImageStorageTestResult {
|
||||
mode: TerminalImageStorageMode;
|
||||
connected: boolean;
|
||||
remoteSftpAvailable: boolean;
|
||||
localHostVisible: boolean | null;
|
||||
selectedMode: "local" | "remote-sftp" | "unavailable";
|
||||
localMappingConfigured: boolean;
|
||||
}
|
||||
|
||||
export async function getTerminalImageStorageSettings(): Promise<TerminalImageStorageSettings> {
|
||||
try {
|
||||
const response = await authApi.get(
|
||||
"/users/terminal-image-storage-settings",
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "fetch terminal image storage settings");
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateTerminalImageStorageSettings(
|
||||
settings: TerminalImageStorageSettingsUpdate,
|
||||
): Promise<TerminalImageStorageSettings> {
|
||||
try {
|
||||
const response = await authApi.patch(
|
||||
"/users/terminal-image-storage-settings",
|
||||
settings,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "update terminal image storage settings");
|
||||
}
|
||||
}
|
||||
|
||||
export async function testTerminalImageStorage(
|
||||
instanceId: string,
|
||||
): Promise<TerminalImageStorageTestResult> {
|
||||
try {
|
||||
const response = await authApi.post(
|
||||
"/users/terminal-image-storage-settings/test",
|
||||
{ instanceId },
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "test terminal image storage");
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HOST DEFAULTS SETTINGS
|
||||
// ============================================================================
|
||||
|
||||
@@ -9,6 +9,9 @@ export interface NetworkTopologyNode {
|
||||
tags?: string[];
|
||||
parent?: string;
|
||||
color?: string;
|
||||
/** Absent on nodes; callers tell nodes from edges by testing these. */
|
||||
source?: undefined;
|
||||
target?: undefined;
|
||||
};
|
||||
position?: { x: number; y: number };
|
||||
}
|
||||
@@ -18,6 +21,8 @@ export interface NetworkTopologyEdge {
|
||||
id?: string;
|
||||
source: string;
|
||||
target: string;
|
||||
label?: undefined;
|
||||
ip?: undefined;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -26,7 +31,16 @@ export interface NetworkTopologyData {
|
||||
edges: NetworkTopologyEdge[];
|
||||
}
|
||||
|
||||
export async function getSnippets(): Promise<Record<string, unknown>> {
|
||||
/**
|
||||
* A snippet row as the list endpoint returns it. Callers that need the full
|
||||
* shape narrow it themselves; only the id is relied on across the app.
|
||||
*/
|
||||
export interface SnippetRow {
|
||||
id: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export async function getSnippets(): Promise<SnippetRow[]> {
|
||||
try {
|
||||
const response = await authApi.get("/snippets");
|
||||
return response.data;
|
||||
@@ -72,11 +86,13 @@ export async function deleteSnippet(
|
||||
export async function executeSnippet(
|
||||
snippetId: number,
|
||||
hostId: number,
|
||||
inputValues?: Record<string, string>,
|
||||
): Promise<{ success: boolean; output: string; error?: string }> {
|
||||
try {
|
||||
const response = await authApi.post("/snippets/execute", {
|
||||
snippetId,
|
||||
hostId,
|
||||
...(inputValues ? { inputValues } : {}),
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
export interface ServerSentEvent {
|
||||
event: string;
|
||||
data: string;
|
||||
}
|
||||
|
||||
export async function streamServerSentEvents(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
onEvent: (event: ServerSentEvent) => void,
|
||||
): Promise<void> {
|
||||
const response = await fetch(url, init);
|
||||
if (!response.ok) {
|
||||
throw new Error(`SSE request failed with status ${response.status}`);
|
||||
}
|
||||
if (!response.body) throw new Error("SSE response has no body");
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let eventName = "message";
|
||||
let data: string[] = [];
|
||||
|
||||
const dispatch = () => {
|
||||
if (data.length > 0) onEvent({ event: eventName, data: data.join("\n") });
|
||||
eventName = "message";
|
||||
data = [];
|
||||
};
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
buffer += decoder.decode(value, { stream: !done });
|
||||
const lines = buffer.split(/\r?\n/);
|
||||
buffer = done ? "" : (lines.pop() ?? "");
|
||||
|
||||
for (const line of lines) {
|
||||
if (line === "") dispatch();
|
||||
else if (line.startsWith("event:")) eventName = line.slice(6).trimStart();
|
||||
else if (line.startsWith("data:")) data.push(line.slice(5).trimStart());
|
||||
}
|
||||
|
||||
if (done) {
|
||||
dispatch();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import { getErrorMessage } from "../lib/error-message.js";
|
||||
import axios from "axios";
|
||||
import { asHttpError } from "@/lib/http-error";
|
||||
import {
|
||||
authApi,
|
||||
fileManagerApi,
|
||||
@@ -9,7 +11,13 @@ import {
|
||||
} from "@/main-axios";
|
||||
import { resolveConnectionOrigin } from "@/lib/connection-origin";
|
||||
import { fileLogger } from "@/lib/frontend-logger";
|
||||
import type { SSHHost } from "@/types/index";
|
||||
import type { FileItem, SSHHost } from "@/types/index";
|
||||
import { getCachedFileList } from "@/lib/file-list-request-cache";
|
||||
import {
|
||||
getCachedFileContent,
|
||||
invalidateCachedFileContent,
|
||||
type FileContentResult,
|
||||
} from "@/lib/file-content-request-cache";
|
||||
|
||||
type ApiConnectionLog = {
|
||||
type: "info" | "success" | "warning" | "error";
|
||||
@@ -32,6 +40,22 @@ type ConnectErrorResponse = {
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The interactive-auth branches /ssh/connect can take before a session exists.
|
||||
* Callers switch on these, so they cannot stay behind Record<string, unknown>.
|
||||
*/
|
||||
export interface SSHConnectResult {
|
||||
success?: boolean;
|
||||
status?: string;
|
||||
reason?: "timeout" | "no_keyboard" | "auth_failed";
|
||||
requires_totp?: boolean;
|
||||
requires_warpgate?: boolean;
|
||||
sessionId?: string;
|
||||
prompt?: string;
|
||||
url?: string;
|
||||
securityKey?: string;
|
||||
}
|
||||
|
||||
function buildFileManagerUrl(path: string): string {
|
||||
const baseURL = String(fileManagerApi.defaults.baseURL || "");
|
||||
return `${baseURL.replace(/\/$/, "")}${path}`;
|
||||
@@ -60,6 +84,8 @@ export async function connectSSH(
|
||||
sessionId: string,
|
||||
config: {
|
||||
hostId?: number;
|
||||
/** Names the host across a sync pair; hostId only names it locally. */
|
||||
syncId?: string | null;
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
@@ -78,7 +104,7 @@ export async function connectSSH(
|
||||
socks5ProxyChain?: unknown;
|
||||
jumpHosts?: Array<{ hostId: number }>;
|
||||
},
|
||||
): Promise<Record<string, unknown>> {
|
||||
): Promise<SSHConnectResult> {
|
||||
try {
|
||||
const response = await getFileManagerApiForSession(sessionId).post(
|
||||
"/ssh/connect",
|
||||
@@ -278,17 +304,24 @@ export async function keepSSHAlive(
|
||||
export async function listSSHFiles(
|
||||
sessionId: string,
|
||||
path: string,
|
||||
): Promise<{ files: unknown[]; path: string }> {
|
||||
try {
|
||||
const response = await getFileManagerApiForSession(sessionId).get(
|
||||
"/ssh/listFiles",
|
||||
{ params: { sessionId, path } },
|
||||
);
|
||||
return response.data || { files: [], path };
|
||||
} catch (error) {
|
||||
handleApiError(error, "list SSH files");
|
||||
return { files: [], path };
|
||||
}
|
||||
options: { force?: boolean } = {},
|
||||
): Promise<{ files: FileItem[]; path: string }> {
|
||||
return getCachedFileList(
|
||||
sessionId,
|
||||
path,
|
||||
async () => {
|
||||
try {
|
||||
const response = await getFileManagerApiForSession(sessionId).get(
|
||||
"/ssh/listFiles",
|
||||
{ params: { sessionId, path } },
|
||||
);
|
||||
return response.data || { files: [], path };
|
||||
} catch (error) {
|
||||
handleApiError(error, "list SSH files");
|
||||
}
|
||||
},
|
||||
options.force,
|
||||
);
|
||||
}
|
||||
|
||||
export async function identifySSHSymlink(
|
||||
@@ -324,30 +357,41 @@ export async function resolveSSHPath(
|
||||
export async function readSSHFile(
|
||||
sessionId: string,
|
||||
path: string,
|
||||
): Promise<{
|
||||
content: string;
|
||||
path: string;
|
||||
encoding?: "base64" | "utf8";
|
||||
}> {
|
||||
try {
|
||||
const response = await getFileManagerApiForSession(sessionId).get(
|
||||
"/ssh/readFile",
|
||||
{ params: { sessionId, path } },
|
||||
);
|
||||
return response.data;
|
||||
} catch (error: unknown) {
|
||||
if (error.response?.status === 404) {
|
||||
const customError = new Error("File not found");
|
||||
(
|
||||
customError as Error & { response?: unknown; isFileNotFound?: boolean }
|
||||
).response = error.response;
|
||||
(
|
||||
customError as Error & { response?: unknown; isFileNotFound?: boolean }
|
||||
).isFileNotFound = error.response.data?.fileNotFound || true;
|
||||
throw customError;
|
||||
}
|
||||
handleApiError(error, "read SSH file");
|
||||
}
|
||||
options: { force?: boolean } = {},
|
||||
): Promise<FileContentResult> {
|
||||
return getCachedFileContent(
|
||||
sessionId,
|
||||
path,
|
||||
async () => {
|
||||
try {
|
||||
const response = await getFileManagerApiForSession(sessionId).get(
|
||||
"/ssh/readFile",
|
||||
{ params: { sessionId, path } },
|
||||
);
|
||||
return response.data;
|
||||
} catch (error: unknown) {
|
||||
const httpError = asHttpError(error);
|
||||
if (httpError.response?.status === 404) {
|
||||
const customError = new Error("File not found");
|
||||
(
|
||||
customError as Error & {
|
||||
response?: unknown;
|
||||
isFileNotFound?: boolean;
|
||||
}
|
||||
).response = httpError.response;
|
||||
(
|
||||
customError as Error & {
|
||||
response?: unknown;
|
||||
isFileNotFound?: boolean;
|
||||
}
|
||||
).isFileNotFound = httpError.response.data?.fileNotFound || true;
|
||||
throw customError;
|
||||
}
|
||||
handleApiError(error, "read SSH file");
|
||||
}
|
||||
},
|
||||
options.force,
|
||||
);
|
||||
}
|
||||
|
||||
export async function writeSSHFile(
|
||||
@@ -368,6 +412,7 @@ export async function writeSSHFile(
|
||||
(response.data.message === "File written successfully" ||
|
||||
response.status === 200)
|
||||
) {
|
||||
invalidateCachedFileContent(sessionId, path);
|
||||
return response.data;
|
||||
} else {
|
||||
throw new Error("File write operation did not return success status");
|
||||
@@ -472,12 +517,21 @@ export async function uploadSSHFile(
|
||||
}
|
||||
}
|
||||
|
||||
export interface DownloadedSSHFile {
|
||||
/** base64-encoded file contents */
|
||||
content: string;
|
||||
fileName: string;
|
||||
size: number;
|
||||
mimeType: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export async function downloadSSHFile(
|
||||
sessionId: string,
|
||||
filePath: string,
|
||||
hostId?: number,
|
||||
userId?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
): Promise<DownloadedSSHFile> {
|
||||
try {
|
||||
const response = await getFileManagerApiForSession(sessionId).post(
|
||||
"/ssh/downloadFile",
|
||||
@@ -552,6 +606,7 @@ export async function deleteSSHItem(
|
||||
isDirectory: boolean,
|
||||
hostId?: number,
|
||||
userId?: string,
|
||||
permanent = false,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await getFileManagerApiForSession(sessionId).delete(
|
||||
@@ -563,15 +618,97 @@ export async function deleteSSHItem(
|
||||
isDirectory,
|
||||
hostId,
|
||||
userId,
|
||||
permanent,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!isDirectory) invalidateCachedFileContent(sessionId, path);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
if (
|
||||
axios.isAxiosError(error) &&
|
||||
error.response?.data?.trashUnavailable === true
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
handleApiError(error, "delete SSH item");
|
||||
}
|
||||
}
|
||||
|
||||
export interface TrashItem {
|
||||
id: string;
|
||||
name: string;
|
||||
originalPath: string;
|
||||
isDirectory: boolean;
|
||||
deletedAt: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export async function getSSHTrash(sessionId: string): Promise<{
|
||||
items: TrashItem[];
|
||||
retentionDays: number;
|
||||
canManageRetention: boolean;
|
||||
}> {
|
||||
try {
|
||||
const response = await getFileManagerApiForSession(sessionId).get(
|
||||
"/ssh/trash",
|
||||
{ params: { sessionId } },
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "list SSH trash");
|
||||
}
|
||||
}
|
||||
|
||||
export async function restoreSSHTrashItem(sessionId: string, id: string) {
|
||||
try {
|
||||
const response = await getFileManagerApiForSession(sessionId).post(
|
||||
`/ssh/trash/${encodeURIComponent(id)}/restore`,
|
||||
{ sessionId },
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "restore SSH trash item");
|
||||
}
|
||||
}
|
||||
|
||||
export async function permanentlyDeleteSSHTrashItem(
|
||||
sessionId: string,
|
||||
id: string,
|
||||
) {
|
||||
try {
|
||||
await getFileManagerApiForSession(sessionId).delete(
|
||||
`/ssh/trash/${encodeURIComponent(id)}`,
|
||||
{ data: { sessionId } },
|
||||
);
|
||||
} catch (error) {
|
||||
handleApiError(error, "permanently delete SSH trash item");
|
||||
}
|
||||
}
|
||||
|
||||
export async function emptySSHTrash(sessionId: string) {
|
||||
try {
|
||||
await getFileManagerApiForSession(sessionId).delete("/ssh/trash", {
|
||||
data: { sessionId },
|
||||
});
|
||||
} catch (error) {
|
||||
handleApiError(error, "empty SSH trash");
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateSSHTrashRetention(
|
||||
sessionId: string,
|
||||
retentionDays: number,
|
||||
) {
|
||||
try {
|
||||
await getFileManagerApiForSession(sessionId).put("/ssh/trash-retention", {
|
||||
retentionDays,
|
||||
});
|
||||
} catch (error) {
|
||||
handleApiError(error, "update SSH trash retention");
|
||||
}
|
||||
}
|
||||
|
||||
export async function setSudoPassword(
|
||||
sessionId: string,
|
||||
password: string,
|
||||
@@ -586,13 +723,20 @@ export async function setSudoPassword(
|
||||
}
|
||||
}
|
||||
|
||||
export interface CopySSHItemResult {
|
||||
message?: string;
|
||||
/** Set when the copy was renamed to avoid clobbering an existing entry. */
|
||||
uniqueName?: string;
|
||||
targetPath?: string;
|
||||
}
|
||||
|
||||
export async function copySSHItem(
|
||||
sessionId: string,
|
||||
sourcePath: string,
|
||||
targetDir: string,
|
||||
hostId?: number,
|
||||
userId?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
): Promise<CopySSHItemResult> {
|
||||
try {
|
||||
const response = await getFileManagerApiForSession(sessionId).post(
|
||||
"/ssh/copyItem",
|
||||
@@ -626,6 +770,10 @@ export async function renameSSHItem(
|
||||
"/ssh/renameItem",
|
||||
{ sessionId, oldPath, newName, hostId, userId },
|
||||
);
|
||||
invalidateCachedFileContent(sessionId, oldPath);
|
||||
const separator = oldPath.lastIndexOf("/");
|
||||
const newPath = `${oldPath.slice(0, separator + 1)}${newName}`;
|
||||
invalidateCachedFileContent(sessionId, newPath);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "rename SSH item");
|
||||
@@ -654,6 +802,8 @@ export async function moveSSHItem(
|
||||
timeout: 60000,
|
||||
},
|
||||
);
|
||||
invalidateCachedFileContent(sessionId, oldPath);
|
||||
invalidateCachedFileContent(sessionId, newPath);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "move SSH item");
|
||||
@@ -830,6 +980,7 @@ export async function ensureSSHSessionForHost(
|
||||
try {
|
||||
const result = await connectSSH(sessionId, {
|
||||
hostId: host.id,
|
||||
syncId: host.syncId ?? null,
|
||||
ip: host.ip,
|
||||
port: host.port,
|
||||
username: host.username,
|
||||
@@ -859,7 +1010,7 @@ export async function ensureSSHSessionForHost(
|
||||
|
||||
return { state: "ready", sessionId };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Connection failed";
|
||||
const message = getErrorMessage(err, "Connection failed");
|
||||
return { state: "error", error: message };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,11 @@ import {
|
||||
getCachedSSHHosts,
|
||||
invalidateHostsAndStatusCaches,
|
||||
} from "@/lib/hosts-request-cache";
|
||||
import { requestRemoteSync } from "@/lib/remote-sync-trigger";
|
||||
import {
|
||||
getConnectedRemoteApi,
|
||||
markRemoteSharedHosts,
|
||||
} from "@/lib/remote-server-api";
|
||||
|
||||
// SSH HOST MANAGEMENT
|
||||
// ============================================================================
|
||||
@@ -23,7 +28,22 @@ export type GetSSHHostsOptions = {
|
||||
|
||||
async function loadSSHHostsFromApi(): Promise<SSHHost[]> {
|
||||
const hostsResponse = await sshHostApi.get("/db/host");
|
||||
return Array.isArray(hostsResponse.data) ? hostsResponse.data : [];
|
||||
const localHosts = Array.isArray(hostsResponse.data)
|
||||
? hostsResponse.data
|
||||
: [];
|
||||
const remoteApi = await getConnectedRemoteApi();
|
||||
if (!remoteApi) return localHosts;
|
||||
|
||||
try {
|
||||
const remoteResponse = await remoteApi.get("/host/db/host");
|
||||
const remoteSharedHosts = Array.isArray(remoteResponse.data)
|
||||
? markRemoteSharedHosts(remoteResponse.data)
|
||||
: [];
|
||||
return [...localHosts, ...remoteSharedHosts];
|
||||
} catch {
|
||||
// Keep the last locally synced host set usable while the server is offline.
|
||||
return localHosts;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSSHHosts(
|
||||
@@ -68,10 +88,12 @@ export async function createSSHHost(hostData: SSHHostData): Promise<SSHHost> {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
invalidateHostsAndStatusCaches();
|
||||
void requestRemoteSync();
|
||||
return response.data;
|
||||
}
|
||||
const response = await sshHostApi.post("/db/host", hostData);
|
||||
invalidateHostsAndStatusCaches();
|
||||
void requestRemoteSync();
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "create SSH host");
|
||||
@@ -92,10 +114,12 @@ export async function updateSSHHost(
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
invalidateHostsAndStatusCaches();
|
||||
void requestRemoteSync();
|
||||
return response.data;
|
||||
}
|
||||
const response = await sshHostApi.put(`/db/host/${hostId}`, hostData);
|
||||
invalidateHostsAndStatusCaches();
|
||||
void requestRemoteSync();
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "update SSH host");
|
||||
@@ -259,12 +283,25 @@ export async function bulkUpdateSSHHosts(
|
||||
}
|
||||
}
|
||||
|
||||
export async function reorderSSHHosts(
|
||||
positions: { id: number; sortOrder: number }[],
|
||||
): Promise<{ updated: number }> {
|
||||
try {
|
||||
const response = await sshHostApi.put("/reorder", { positions });
|
||||
invalidateHostsAndStatusCaches();
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "reorder SSH hosts");
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteSSHHost(
|
||||
hostId: number,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await sshHostApi.delete(`/db/host/${hostId}`);
|
||||
invalidateHostsAndStatusCaches();
|
||||
void requestRemoteSync();
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "delete SSH host");
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { AxiosError } from "axios";
|
||||
import { authApi, handleApiError, markUserAuthenticated } from "@/main-axios";
|
||||
import type { AuthResponse } from "@/main-axios";
|
||||
import type { TermixAlert } from "@/types";
|
||||
import {
|
||||
authApi,
|
||||
handleApiError,
|
||||
markUserAuthenticated,
|
||||
type AuthResponse,
|
||||
} from "@/main-axios";
|
||||
|
||||
// ALERTS
|
||||
// ============================================================================
|
||||
@@ -86,7 +91,7 @@ export async function generateBackupCodes(
|
||||
}
|
||||
|
||||
export async function getUserAlerts(): Promise<{
|
||||
alerts: Array<Record<string, unknown>>;
|
||||
alerts: TermixAlert[];
|
||||
}> {
|
||||
try {
|
||||
const response = await authApi.get(`/alerts`);
|
||||
@@ -113,9 +118,34 @@ export async function dismissAlert(
|
||||
// UPDATES & RELEASES
|
||||
// ============================================================================
|
||||
|
||||
export interface ReleaseItem {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
link: string;
|
||||
pubDate: string;
|
||||
version: string;
|
||||
isPrerelease: boolean;
|
||||
isDraft: boolean;
|
||||
assets: Array<{
|
||||
name: string;
|
||||
size: number;
|
||||
download_count: number;
|
||||
download_url: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ReleasesRSSResponse {
|
||||
feed: { title: string; description: string; link: string; updated: string };
|
||||
items: ReleaseItem[];
|
||||
total_count: number;
|
||||
cached: boolean;
|
||||
cache_age?: number;
|
||||
}
|
||||
|
||||
export async function getReleasesRSS(
|
||||
perPage: number = 100,
|
||||
): Promise<Record<string, unknown>> {
|
||||
): Promise<ReleasesRSSResponse> {
|
||||
try {
|
||||
const response = await authApi.get(`/releases/rss?per_page=${perPage}`);
|
||||
return response.data;
|
||||
@@ -124,9 +154,37 @@ export async function getReleasesRSS(
|
||||
}
|
||||
}
|
||||
|
||||
export async function getVersionInfo(
|
||||
checkRemote = true,
|
||||
): Promise<Record<string, unknown>> {
|
||||
export interface VersionInfo {
|
||||
status?: "up_to_date" | "requires_update" | "beta";
|
||||
/** Same value as remoteVersion; the endpoint sends both. */
|
||||
version?: string;
|
||||
localVersion?: string;
|
||||
remoteVersion?: string;
|
||||
latest_release?: {
|
||||
tag_name?: string;
|
||||
name?: string;
|
||||
published_at?: string;
|
||||
html_url?: string;
|
||||
body?: string;
|
||||
};
|
||||
cached?: boolean;
|
||||
cache_age?: number;
|
||||
// Callers reach for fields beyond the ones above -- SystemOverviewWidget
|
||||
// reads `updateAvailable`, which this endpoint does not in fact return --
|
||||
// so keep the index signature the previous `Record<string, unknown>` gave
|
||||
// them. Typing those reads out of existence is a separate change.
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// Where the release page lives inside a version response. Both surfaces that
|
||||
// render a version badge read it, and an empty string is what they treat as
|
||||
// "no link to offer", so keep the shape in one place rather than repeating the
|
||||
// optional chain at each call site.
|
||||
export function releaseUrlFrom(info: VersionInfo | null | undefined): string {
|
||||
return info?.latest_release?.html_url ?? "";
|
||||
}
|
||||
|
||||
export async function getVersionInfo(checkRemote = true): Promise<VersionInfo> {
|
||||
try {
|
||||
const response = await authApi.get(
|
||||
`/version${checkRemote ? "" : "?checkRemote=false"}`,
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { authApi, handleApiError } from "@/main-axios";
|
||||
import {
|
||||
normalizeTouchInputSettings,
|
||||
type TouchInputSettings,
|
||||
} from "@/types/touch-input-settings";
|
||||
|
||||
export async function getTouchInputSettings(): Promise<TouchInputSettings> {
|
||||
try {
|
||||
const response = await authApi.get("/users/touch-input-settings");
|
||||
return normalizeTouchInputSettings(response.data);
|
||||
} catch (error) {
|
||||
handleApiError(error, "fetch touch input settings");
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateTouchInputSettings(
|
||||
settings: TouchInputSettings,
|
||||
): Promise<TouchInputSettings> {
|
||||
try {
|
||||
const response = await authApi.patch(
|
||||
"/users/touch-input-settings",
|
||||
settings,
|
||||
);
|
||||
return normalizeTouchInputSettings(response.data);
|
||||
} catch (error) {
|
||||
handleApiError(error, "update touch input settings");
|
||||
}
|
||||
}
|
||||
+71
-27
@@ -12,6 +12,8 @@ import type {
|
||||
TunnelConnection,
|
||||
TunnelStatus,
|
||||
} from "@/types/index";
|
||||
import { streamServerSentEvents } from "./sse-stream";
|
||||
import { runAdaptivePolling } from "@/lib/adaptive-polling";
|
||||
|
||||
// TUNNEL MANAGEMENT
|
||||
// ============================================================================
|
||||
@@ -65,51 +67,93 @@ export function subscribeTunnelStatuses(
|
||||
onError?: () => void,
|
||||
): () => void {
|
||||
const baseURL = (tunnelApi.defaults.baseURL || "").replace(/\/$/, "");
|
||||
const source = new EventSource(`${baseURL}/tunnel/status/stream`, {
|
||||
withCredentials: true,
|
||||
});
|
||||
const controller = new AbortController();
|
||||
|
||||
let latestLocal: Record<string, TunnelStatus> = {};
|
||||
let latestRemote: Record<string, TunnelStatus> = {};
|
||||
let remotePollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let stopRemotePolling: (() => void) | null = null;
|
||||
|
||||
const emitMerged = () => {
|
||||
onStatuses({ ...latestLocal, ...latestRemote });
|
||||
};
|
||||
|
||||
source.addEventListener("statuses", (event) => {
|
||||
try {
|
||||
latestLocal = JSON.parse(event.data) as Record<string, TunnelStatus>;
|
||||
emitMerged();
|
||||
} catch {
|
||||
onError?.();
|
||||
}
|
||||
});
|
||||
const waitToReconnect = () =>
|
||||
new Promise<void>((resolve) => {
|
||||
const onAbort = () => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
};
|
||||
const timeout = setTimeout(() => {
|
||||
controller.signal.removeEventListener("abort", onAbort);
|
||||
resolve();
|
||||
}, 1000);
|
||||
controller.signal.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
|
||||
source.onerror = () => {
|
||||
onError?.();
|
||||
};
|
||||
void (async () => {
|
||||
while (!controller.signal.aborted) {
|
||||
const headers = new Headers({ Accept: "text/event-stream" });
|
||||
if (isElectron()) {
|
||||
headers.set("X-Electron-App", "true");
|
||||
const jwt = localStorage.getItem("jwt");
|
||||
if (jwt) headers.set("Authorization", `Bearer ${jwt}`);
|
||||
}
|
||||
try {
|
||||
await streamServerSentEvents(
|
||||
`${baseURL}/tunnel/status/stream`,
|
||||
{ credentials: "include", headers, signal: controller.signal },
|
||||
(event) => {
|
||||
if (event.event !== "statuses") return;
|
||||
try {
|
||||
latestLocal = JSON.parse(event.data) as Record<
|
||||
string,
|
||||
TunnelStatus
|
||||
>;
|
||||
emitMerged();
|
||||
} catch {
|
||||
onError?.();
|
||||
}
|
||||
},
|
||||
);
|
||||
if (!controller.signal.aborted) onError?.();
|
||||
} catch (error) {
|
||||
const aborted =
|
||||
controller.signal.aborted ||
|
||||
(error instanceof DOMException && error.name === "AbortError");
|
||||
if (!aborted) onError?.();
|
||||
}
|
||||
if (!controller.signal.aborted) await waitToReconnect();
|
||||
}
|
||||
})();
|
||||
|
||||
// Remote tunnel status has no SSE stream exposed to the desktop app yet,
|
||||
// so poll it at a modest interval when a remote server is connected.
|
||||
isRemoteSyncConnected().then((connected) => {
|
||||
if (!connected) return;
|
||||
const pollRemote = async () => {
|
||||
try {
|
||||
if (!connected || controller.signal.aborted) return;
|
||||
let signature = "";
|
||||
stopRemotePolling = runAdaptivePolling(
|
||||
async () => {
|
||||
const result = await getRemoteTunnelApi().get("/tunnel/status");
|
||||
latestRemote = result.data || {};
|
||||
const next = result.data || {};
|
||||
const nextSignature = JSON.stringify(next);
|
||||
const changed = nextSignature !== signature;
|
||||
signature = nextSignature;
|
||||
latestRemote = next;
|
||||
emitMerged();
|
||||
} catch {
|
||||
// remote unreachable this tick -- keep last known remote statuses
|
||||
}
|
||||
};
|
||||
pollRemote();
|
||||
remotePollTimer = setInterval(pollRemote, 5000);
|
||||
return changed;
|
||||
},
|
||||
{
|
||||
minIntervalMs: 5000,
|
||||
maxIntervalMs: 30000,
|
||||
stablePollsPerStep: 3,
|
||||
},
|
||||
{ enabled: () => !controller.signal.aborted },
|
||||
);
|
||||
});
|
||||
|
||||
return () => {
|
||||
source.close();
|
||||
if (remotePollTimer) clearInterval(remotePollTimer);
|
||||
controller.abort();
|
||||
stopRemotePolling?.();
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { authApi, handleApiError } from "@/main-axios";
|
||||
import {
|
||||
sanitizeUiPreferences,
|
||||
type UiPreferences,
|
||||
} from "@/types/ui-preferences";
|
||||
|
||||
// UI PREFERENCES API
|
||||
// ============================================================================
|
||||
|
||||
export async function getUiPreferences(): Promise<UiPreferences> {
|
||||
try {
|
||||
const response = await authApi.get("/ui-preferences");
|
||||
return sanitizeUiPreferences(response.data?.preferences);
|
||||
} catch (error) {
|
||||
handleApiError(error, "fetch UI preferences");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a partial document. The backend merges overrides two levels deep, so
|
||||
* only changed keys need to be sent; a null clears an override and hands the
|
||||
* knob back to the preset.
|
||||
*/
|
||||
export async function saveUiPreferences(
|
||||
preferences: Partial<UiPreferences> & Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await authApi.put("/ui-preferences", preferences);
|
||||
} catch (error) {
|
||||
handleApiError(error, "save UI preferences");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,29 @@
|
||||
import { authApi, handleApiError } from "@/main-axios";
|
||||
import type { UserInfo } from "@/main-axios";
|
||||
import { authApi, handleApiError, type UserInfo } from "@/main-axios";
|
||||
import { getConnectedRemoteApi } from "@/lib/remote-server-api";
|
||||
|
||||
// USER MANAGEMENT
|
||||
// ============================================================================
|
||||
|
||||
export async function getUserList(): Promise<{ users: UserInfo[] }> {
|
||||
export type UserListOptions = {
|
||||
/** Case-insensitive username substring filter. */
|
||||
search?: string;
|
||||
/** Page size. Omit to fetch every user (what the share pickers want). */
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
};
|
||||
|
||||
export async function getUserList(
|
||||
options: UserListOptions = {},
|
||||
): Promise<{ users: UserInfo[]; total?: number }> {
|
||||
try {
|
||||
const response = await authApi.get("/users/list");
|
||||
const api = (await getConnectedRemoteApi()) ?? authApi;
|
||||
const response = await api.get("/users/list", {
|
||||
params: {
|
||||
...(options.search ? { search: options.search } : {}),
|
||||
...(options.limit ? { limit: options.limit } : {}),
|
||||
...(options.offset ? { offset: options.offset } : {}),
|
||||
},
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "fetch user list");
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import type {
|
||||
AuthenticationResponseJSON,
|
||||
PublicKeyCredentialCreationOptionsJSON,
|
||||
PublicKeyCredentialRequestOptionsJSON,
|
||||
RegistrationResponseJSON,
|
||||
} from "@simplewebauthn/browser";
|
||||
import {
|
||||
startAuthentication,
|
||||
startRegistration,
|
||||
} from "@simplewebauthn/browser";
|
||||
import { authApi, handleApiError, type AuthResponse } from "@/main-axios";
|
||||
import { startRegistration } from "@simplewebauthn/browser";
|
||||
import { authApi, handleApiError } from "@/main-axios";
|
||||
|
||||
export type WebAuthnUserVerification = "discouraged" | "preferred" | "required";
|
||||
|
||||
@@ -28,11 +23,6 @@ type RegistrationOptionsResponse = {
|
||||
challengeId: string;
|
||||
};
|
||||
|
||||
type AuthenticationOptionsResponse = {
|
||||
options: PublicKeyCredentialRequestOptionsJSON;
|
||||
challengeId: string;
|
||||
};
|
||||
|
||||
export async function listWebAuthnCredentials(): Promise<{
|
||||
credentials: WebAuthnCredentialSummary[];
|
||||
}> {
|
||||
@@ -70,41 +60,6 @@ export async function registerWebAuthnCredential(
|
||||
}
|
||||
}
|
||||
|
||||
export async function authenticateWithWebAuthn(
|
||||
username: string,
|
||||
rememberMe: boolean,
|
||||
userVerification: WebAuthnUserVerification = "preferred",
|
||||
): Promise<AuthResponse> {
|
||||
try {
|
||||
const optionsResponse = await authApi.post<AuthenticationOptionsResponse>(
|
||||
"/users/webauthn/authenticate/options",
|
||||
{
|
||||
username: username.trim() || undefined,
|
||||
userVerification,
|
||||
},
|
||||
);
|
||||
const credential = await startAuthentication({
|
||||
optionsJSON: optionsResponse.data.options,
|
||||
});
|
||||
const verifyResponse = await authApi.post<AuthResponse>(
|
||||
"/users/webauthn/authenticate/verify",
|
||||
{
|
||||
challengeId: optionsResponse.data.challengeId,
|
||||
rememberMe,
|
||||
response: credential as AuthenticationResponseJSON,
|
||||
},
|
||||
);
|
||||
|
||||
if (verifyResponse.data.token) {
|
||||
localStorage.setItem("jwt", verifyResponse.data.token);
|
||||
}
|
||||
|
||||
return verifyResponse.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "authenticate with passkey");
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteWebAuthnCredential(
|
||||
credentialId: string,
|
||||
): Promise<{ success: boolean }> {
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { authApi, handleApiError } from "@/main-axios";
|
||||
import type { Workspace, WorkspacePayload } from "@/types/ui-types";
|
||||
|
||||
export async function listWorkspaces(): Promise<Workspace[]> {
|
||||
try {
|
||||
const response = await authApi.get("/workspaces");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "fetch workspaces");
|
||||
}
|
||||
}
|
||||
|
||||
export async function createWorkspace(data: {
|
||||
name: string;
|
||||
color?: string | null;
|
||||
icon?: string | null;
|
||||
payload: WorkspacePayload;
|
||||
}): Promise<Workspace> {
|
||||
try {
|
||||
const response = await authApi.post("/workspaces", data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "create workspace");
|
||||
}
|
||||
}
|
||||
|
||||
export async function renameWorkspace(
|
||||
id: number,
|
||||
data: { name?: string; color?: string | null; icon?: string | null },
|
||||
): Promise<Workspace> {
|
||||
try {
|
||||
const response = await authApi.patch(`/workspaces/${id}`, data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "update workspace");
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateWorkspaceContent(
|
||||
id: number,
|
||||
payload: WorkspacePayload,
|
||||
): Promise<Workspace> {
|
||||
try {
|
||||
const response = await authApi.put(`/workspaces/${id}/content`, {
|
||||
payload,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "update workspace content");
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteWorkspace(
|
||||
id: number,
|
||||
): Promise<{ success: boolean }> {
|
||||
try {
|
||||
const response = await authApi.delete(`/workspaces/${id}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "delete workspace");
|
||||
}
|
||||
}
|
||||
|
||||
export async function duplicateWorkspace(
|
||||
id: number,
|
||||
name: string,
|
||||
): Promise<Workspace> {
|
||||
try {
|
||||
const response = await authApi.post(`/workspaces/${id}/duplicate`, {
|
||||
name,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "duplicate workspace");
|
||||
}
|
||||
}
|
||||
|
||||
export async function setDefaultWorkspace(id: number): Promise<Workspace> {
|
||||
try {
|
||||
const response = await authApi.post(`/workspaces/${id}/set-default`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "set default workspace");
|
||||
}
|
||||
}
|
||||
|
||||
export async function unsetDefaultWorkspace(id: number): Promise<Workspace> {
|
||||
try {
|
||||
const response = await authApi.post(`/workspaces/${id}/unset-default`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "unset default workspace");
|
||||
}
|
||||
}
|
||||
|
||||
export async function applyWorkspaceServer(id: number): Promise<Workspace> {
|
||||
try {
|
||||
const response = await authApi.post(`/workspaces/${id}/apply`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "apply workspace");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getLastSessionWorkspace(): Promise<Workspace | null> {
|
||||
try {
|
||||
const response = await authApi.get("/workspaces/last-session");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "fetch last session workspace");
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveLastSessionWorkspace(
|
||||
payload: WorkspacePayload,
|
||||
): Promise<Workspace> {
|
||||
try {
|
||||
const response = await authApi.put("/workspaces/last-session", {
|
||||
payload,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "save last session workspace");
|
||||
}
|
||||
}
|
||||
+62
-10
@@ -33,6 +33,7 @@ import {
|
||||
getCurrentToken,
|
||||
getOidcSilentLoginDefault,
|
||||
requestDesktopAutoSession,
|
||||
requestTrustedProxyLogin,
|
||||
} from "@/main-axios";
|
||||
import { getSSOProviders, ldapLogin } from "@/api/sso-provider-api";
|
||||
import type { SSOProviderPublic } from "@/types/index";
|
||||
@@ -258,6 +259,7 @@ export function Auth({ onLogin }: AuthProps) {
|
||||
const [ldapUsername, setLdapUsername] = useState("");
|
||||
const [ldapPassword, setLdapPassword] = useState("");
|
||||
const silentSigninHandledRef = useRef(false);
|
||||
const proxySigninHandledRef = useRef(false);
|
||||
const [oidcSilentLoginDefault, setOidcSilentLoginDefault] = useState(false);
|
||||
const [oidcSilentLoginDefaultLoaded, setOidcSilentLoginDefaultLoaded] =
|
||||
useState(false);
|
||||
@@ -281,6 +283,25 @@ export function Auth({ onLogin }: AuthProps) {
|
||||
>(!isElectron() || isInElectronWebView() ? true : null);
|
||||
const [desktopAutoSessionRetries, setDesktopAutoSessionRetries] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (proxySigninHandledRef.current || isElectron()) return;
|
||||
proxySigninHandledRef.current = true;
|
||||
requestTrustedProxyLogin()
|
||||
.then((result) => {
|
||||
if (!result.enabled || !result.success) return;
|
||||
storeAuth(result.username || "");
|
||||
onLogin(
|
||||
result.username || "",
|
||||
result.userId || undefined,
|
||||
!!result.is_admin,
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
// Leave the login screen visible. The backend logs the reason without
|
||||
// exposing trusted proxy configuration to an untrusted client.
|
||||
});
|
||||
}, [onLogin]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem("rememberMe", rememberMe.toString());
|
||||
@@ -336,17 +357,48 @@ export function Auth({ onLogin }: AuthProps) {
|
||||
// Electron). Waiting avoids flashing a login screen the user is about
|
||||
// to skip past via auto-login.
|
||||
if (desktopAutoSessionDone !== true) return;
|
||||
let cancelled = false;
|
||||
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// Right after a server update/restart (or behind a reverse proxy that's
|
||||
// still warming up), the very first request can transiently fail even
|
||||
// though the backend/database is fine seconds later. A single failure
|
||||
// here used to permanently show the "could not connect to the database"
|
||||
// screen, forcing users to manually reload -- sometimes repeatedly.
|
||||
// Retry a few times with backoff before treating it as a real failure.
|
||||
const maxAttempts = 5;
|
||||
const attempt = (attemptNumber: number) => {
|
||||
getSetupRequired()
|
||||
.then((res) => {
|
||||
if (cancelled) return;
|
||||
if (res.setup_required) {
|
||||
setFirstUser(true);
|
||||
setView("register");
|
||||
}
|
||||
setDbConnectionFailed(false);
|
||||
setDbHealthChecking(false);
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
if (attemptNumber >= maxAttempts) {
|
||||
setDbConnectionFailed(true);
|
||||
setDbHealthChecking(false);
|
||||
return;
|
||||
}
|
||||
const delay = Math.min(500 * 2 ** attemptNumber, 5000);
|
||||
retryTimer = setTimeout(() => {
|
||||
if (!cancelled) attempt(attemptNumber + 1);
|
||||
}, delay);
|
||||
});
|
||||
};
|
||||
|
||||
setDbHealthChecking(true);
|
||||
getSetupRequired()
|
||||
.then((res) => {
|
||||
if (res.setup_required) {
|
||||
setFirstUser(true);
|
||||
setView("register");
|
||||
}
|
||||
setDbConnectionFailed(false);
|
||||
})
|
||||
.catch(() => setDbConnectionFailed(true))
|
||||
.finally(() => setDbHealthChecking(false));
|
||||
attempt(0);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (retryTimer) clearTimeout(retryTimer);
|
||||
};
|
||||
}, [desktopAutoSessionDone]);
|
||||
|
||||
// A cold first launch spawns the embedded backend as a separate process
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getErrorMessage } from "../lib/error-message.js";
|
||||
import React, { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/alert.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -15,6 +16,12 @@ interface ElectronLoginFormProps {
|
||||
targetPurpose?: "local" | "remoteSync";
|
||||
}
|
||||
|
||||
interface SaveRemoteSyncJwtResult {
|
||||
success: boolean;
|
||||
reason?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const AUTH_MESSAGE_SOURCES = new Set([
|
||||
"auth_component",
|
||||
"totp_auth_component",
|
||||
@@ -51,14 +58,33 @@ export function ElectronLoginForm({
|
||||
try {
|
||||
if (token) {
|
||||
if (targetPurpose === "remoteSync") {
|
||||
await window.electronAPI?.invoke?.("save-remote-sync-jwt", token);
|
||||
// The main process refuses to persist the JWT when it has no OS
|
||||
// keyring to encrypt it with, and reports that by resolving with
|
||||
// success: false. Dropping the result signs the user in against a
|
||||
// store that kept nothing, so the next sync tick calls a session
|
||||
// that was never saved expired.
|
||||
const result = (await window.electronAPI?.invoke?.(
|
||||
"save-remote-sync-jwt",
|
||||
token,
|
||||
)) as SaveRemoteSyncJwtResult | undefined;
|
||||
if (!result?.success) {
|
||||
throw new Error(
|
||||
result?.reason === "encryption_unavailable"
|
||||
? t("errors.keyringUnavailable")
|
||||
: result?.error || t("errors.authTokenSaveFailed"),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
localStorage.setItem("jwt", token);
|
||||
}
|
||||
}
|
||||
await onAuthSuccessRef.current(token);
|
||||
} catch {
|
||||
setError(t("errors.authTokenSaveFailed"));
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: t("errors.authTokenSaveFailed"),
|
||||
);
|
||||
isAuthenticatingRef.current = false;
|
||||
setIsAuthenticating(false);
|
||||
hasAuthenticatedRef.current = false;
|
||||
@@ -151,8 +177,7 @@ export function ElectronLoginForm({
|
||||
typeof event.data.providerId === "number"
|
||||
? event.data.providerId
|
||||
: undefined;
|
||||
const error =
|
||||
err instanceof Error ? err.message : t("errors.failedOidcLogin");
|
||||
const error = getErrorMessage(err, t("errors.failedOidcLogin"));
|
||||
iframeRef.current?.contentWindow?.postMessage(
|
||||
{
|
||||
type: "OIDC_SYSTEM_BROWSER_AUTH_RESULT",
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/button";
|
||||
import { Input } from "@/components/input";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@/components/dialog";
|
||||
import {
|
||||
extractSnippetInputs,
|
||||
resolveSnippetContent,
|
||||
type SnippetHostContext,
|
||||
} from "@/lib/snippet-variables";
|
||||
import type { Snippet } from "@/types/ui-types";
|
||||
|
||||
/**
|
||||
* Shown before running a snippet that contains $INPUT_n placeholders --
|
||||
* collects a value per placeholder and previews the fully resolved command
|
||||
* (host vars + inputs) before handing the result back to the caller.
|
||||
*/
|
||||
export function SnippetVariablesDialog({
|
||||
snippet,
|
||||
host,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: {
|
||||
snippet: Snippet;
|
||||
host: SnippetHostContext | null;
|
||||
onCancel: () => void;
|
||||
onConfirm: (
|
||||
resolvedContent: string,
|
||||
inputValues: Record<string, string>,
|
||||
) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const inputs = useMemo(
|
||||
() => extractSnippetInputs(snippet.content),
|
||||
[snippet.content],
|
||||
);
|
||||
const [values, setValues] = useState<Record<string, string>>({});
|
||||
|
||||
useEffect(() => {
|
||||
setValues({});
|
||||
}, [snippet]);
|
||||
|
||||
const preview = resolveSnippetContent(snippet.content, host, values);
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(v) => !v && onCancel()}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-lg font-bold">
|
||||
{t("newUi.sidebar.snippets.variablesDialogTitle", {
|
||||
name: snippet.name,
|
||||
})}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs text-muted-foreground">
|
||||
{t("newUi.sidebar.snippets.variablesDialogDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4 mt-1">
|
||||
{inputs.map((input) => (
|
||||
<div key={input.key} className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold">{input.label}</label>
|
||||
<Input
|
||||
autoFocus={inputs[0]?.key === input.key}
|
||||
value={values[input.key] ?? ""}
|
||||
onChange={(e) =>
|
||||
setValues((prev) => ({
|
||||
...prev,
|
||||
[input.key]: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold text-muted-foreground">
|
||||
{t("newUi.sidebar.snippets.variablesPreviewLabel")}
|
||||
</label>
|
||||
<span className="text-xs text-muted-foreground font-mono px-2.5 py-2 border border-border bg-muted/20 min-w-0 break-all whitespace-pre-wrap">
|
||||
{preview}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 mt-2">
|
||||
<Button variant="ghost" onClick={onCancel}>
|
||||
{t("newUi.sidebar.snippets.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
onClick={() => onConfirm(preview, values)}
|
||||
>
|
||||
{t("newUi.sidebar.snippets.variablesConfirmButton")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import * as React from "react";
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion";
|
||||
import { ChevronDownIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Accordion({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
||||
return <AccordionPrimitive.Root data-slot="accordion" {...props} />;
|
||||
}
|
||||
|
||||
function AccordionItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
|
||||
return (
|
||||
<AccordionPrimitive.Item
|
||||
data-slot="accordion-item"
|
||||
className={cn("border-b last:border-b-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AccordionTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
|
||||
return (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
data-slot="accordion-trigger"
|
||||
className={cn(
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
);
|
||||
}
|
||||
|
||||
function AccordionContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
|
||||
return (
|
||||
<AccordionPrimitive.Content
|
||||
data-slot="accordion-content"
|
||||
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
|
||||
{...props}
|
||||
>
|
||||
<div className={cn("pt-0 pb-4", className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
);
|
||||
}
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
|
||||
@@ -52,7 +52,7 @@ function AlertDialogContent({
|
||||
<AlertDialogPrimitive.Content
|
||||
data-slot="alert-dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-none border border-border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||
"relative w-full border border-border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
"inline-flex items-center justify-center rounded-none border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
@@ -17,7 +17,7 @@ const badgeVariants = cva(
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-foreground [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import {
|
||||
Children,
|
||||
type ReactElement,
|
||||
cloneElement,
|
||||
isValidElement,
|
||||
} from "react";
|
||||
|
||||
import { type ButtonProps } from "@/components/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ButtonGroupProps {
|
||||
className?: string;
|
||||
orientation?: "horizontal" | "vertical";
|
||||
children: ReactElement<ButtonProps>[] | React.ReactNode;
|
||||
}
|
||||
|
||||
export const ButtonGroup = ({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
children,
|
||||
}: ButtonGroupProps) => {
|
||||
const isHorizontal = orientation === "horizontal";
|
||||
const isVertical = orientation === "vertical";
|
||||
|
||||
// Normalize and filter only valid React elements
|
||||
const childArray = Children.toArray(children).filter(
|
||||
(child): child is ReactElement<ButtonProps> => isValidElement(child),
|
||||
);
|
||||
const totalButtons = childArray.length;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex",
|
||||
{
|
||||
"flex-col": isVertical,
|
||||
"w-fit": isVertical,
|
||||
},
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{childArray.map((child, index) => {
|
||||
const isFirst = index === 0;
|
||||
const isLast = index === totalButtons - 1;
|
||||
|
||||
return cloneElement(child, {
|
||||
className: cn(
|
||||
{
|
||||
"rounded-l-none": isHorizontal && !isFirst,
|
||||
"rounded-r-none": isHorizontal && !isLast,
|
||||
"border-l-0": isHorizontal && !isFirst,
|
||||
|
||||
"rounded-t-none": isVertical && !isFirst,
|
||||
"rounded-b-none": isVertical && !isLast,
|
||||
"border-t-0": isVertical && !isFirst,
|
||||
},
|
||||
child.props.className,
|
||||
),
|
||||
});
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -12,7 +12,7 @@ function Checkbox({
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-none border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import * as React from "react";
|
||||
import { Command as CommandPrimitive } from "cmdk";
|
||||
import { Search } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Command = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
React.ElementRef<typeof CommandPrimitive>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
<CommandPrimitive
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full flex-col overflow-hidden rounded-none bg-popover text-popover-foreground",
|
||||
@@ -18,15 +19,12 @@ const Command = React.forwardRef<
|
||||
Command.displayName = "Command";
|
||||
|
||||
const CommandInput = React.forwardRef<
|
||||
HTMLInputElement,
|
||||
React.InputHTMLAttributes<HTMLInputElement>
|
||||
React.ElementRef<typeof CommandPrimitive.Input>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
className="flex items-center border-b border-border px-3"
|
||||
cm-input-wrapper=""
|
||||
>
|
||||
<div className="flex items-center border-b border-border px-3">
|
||||
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
<input
|
||||
<CommandPrimitive.Input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-11 w-full rounded-none bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
||||
@@ -39,52 +37,55 @@ const CommandInput = React.forwardRef<
|
||||
CommandInput.displayName = "CommandInput";
|
||||
|
||||
const CommandList = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
React.ElementRef<typeof CommandPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
<CommandPrimitive.List
|
||||
ref={ref}
|
||||
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
|
||||
className={cn(
|
||||
"max-h-[300px] overflow-y-auto overflow-x-hidden [&_[cmdk-list-sizer]]:flex [&_[cmdk-list-sizer]]:flex-col",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CommandList.displayName = "CommandList";
|
||||
|
||||
const CommandGroup = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & { heading?: string }
|
||||
>(({ className, heading, children, ...props }, ref) => (
|
||||
<div
|
||||
React.ElementRef<typeof CommandPrimitive.Group>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Group
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"overflow-hidden p-1 text-foreground [&_[cm-group-heading]]:px-2 [&_[cm-group-heading]]:py-1.5 [&_[cm-group-heading]]:text-xs [&_[cm-group-heading]]:font-medium [&_[cm-group-heading]]:text-muted-foreground",
|
||||
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{heading && <div cm-group-heading="">{heading}</div>}
|
||||
{children}
|
||||
</div>
|
||||
/>
|
||||
));
|
||||
CommandGroup.displayName = "CommandGroup";
|
||||
|
||||
const CommandSeparator = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
React.ElementRef<typeof CommandPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("-mx-1 h-px bg-border", className)} {...props} />
|
||||
<CommandPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CommandSeparator.displayName = "CommandSeparator";
|
||||
|
||||
const CommandItem = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & { onSelect?: () => void }
|
||||
>(({ className, onSelect, ...props }, ref) => (
|
||||
<div
|
||||
React.ElementRef<typeof CommandPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Item
|
||||
ref={ref}
|
||||
onClick={onSelect}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-muted hover:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 transition-colors",
|
||||
"relative flex cursor-default select-none items-center rounded-none px-2 py-1.5 text-sm outline-none data-[selected=true]:bg-muted data-[selected=true]:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 transition-colors",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -93,10 +94,10 @@ const CommandItem = React.forwardRef<
|
||||
CommandItem.displayName = "CommandItem";
|
||||
|
||||
const CommandEmpty = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
React.ElementRef<typeof CommandPrimitive.Empty>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
<CommandPrimitive.Empty
|
||||
ref={ref}
|
||||
className={cn("py-6 text-center text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { useConnectionLog } from "@/ssh/connection-log/ConnectionLogContext.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { copyToClipboard } from "@/lib/clipboard.ts";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import { cn } from "@/lib/utils.ts";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Copy,
|
||||
Info,
|
||||
CheckCircle2,
|
||||
AlertTriangle,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface ConnectionLogPanelProps {
|
||||
isConnecting: boolean;
|
||||
isConnected: boolean;
|
||||
hasConnectionError: boolean;
|
||||
position?: "top" | "bottom";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const COLLAPSED_HEIGHT = "h-[140px]";
|
||||
const EXPANDED_HEIGHT = "h-[45%] min-h-[240px]";
|
||||
|
||||
export function ConnectionLogPanel({
|
||||
isConnecting,
|
||||
isConnected,
|
||||
hasConnectionError,
|
||||
position = "bottom",
|
||||
className,
|
||||
}: ConnectionLogPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const { logs, clearLogs, isExpanded, toggleExpanded, setIsExpanded } =
|
||||
useConnectionLog();
|
||||
const lastLogRef = useRef<HTMLDivElement>(null);
|
||||
const [manuallyCollapsed, setManuallyCollapsed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasConnectionError) {
|
||||
setManuallyCollapsed(false);
|
||||
setIsExpanded(true);
|
||||
}
|
||||
}, [hasConnectionError, setIsExpanded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isConnected && !hasConnectionError && !isConnecting) {
|
||||
clearLogs();
|
||||
setManuallyCollapsed(false);
|
||||
}
|
||||
}, [isConnected, hasConnectionError, isConnecting, clearLogs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (lastLogRef.current) {
|
||||
lastLogRef.current.scrollIntoView({ block: "end" });
|
||||
}
|
||||
}, [logs]);
|
||||
|
||||
const shouldShow =
|
||||
!isConnected && (isConnecting || hasConnectionError || logs.length > 0);
|
||||
|
||||
if (!shouldShow) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const expanded = isExpanded && !manuallyCollapsed;
|
||||
|
||||
const handleToggle = () => {
|
||||
if (hasConnectionError) {
|
||||
setManuallyCollapsed((prev) => !prev);
|
||||
return;
|
||||
}
|
||||
toggleExpanded();
|
||||
};
|
||||
|
||||
const copyLogsToClipboard = async () => {
|
||||
const logsText = logs
|
||||
.map((log) => {
|
||||
const time = log.timestamp.toLocaleTimeString();
|
||||
return `[${time}] [${log.type.toUpperCase()}] ${log.message}`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
const ok = await copyToClipboard(logsText);
|
||||
if (ok) toast.success(t("terminal.connectionLogCopied"));
|
||||
else toast.error(t("terminal.connectionLogCopyFailed"));
|
||||
};
|
||||
|
||||
const getIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case "info":
|
||||
return <Info className="h-4 w-4 text-blue-500 shrink-0" />;
|
||||
case "success":
|
||||
return <CheckCircle2 className="h-4 w-4 text-green-500 shrink-0" />;
|
||||
case "warning":
|
||||
return <AlertTriangle className="h-4 w-4 text-yellow-500 shrink-0" />;
|
||||
case "error":
|
||||
return <XCircle className="h-4 w-4 text-red-500 shrink-0" />;
|
||||
default:
|
||||
return <Info className="h-4 w-4 shrink-0" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getTextColor = (type: string) => {
|
||||
switch (type) {
|
||||
case "info":
|
||||
return "text-blue-400";
|
||||
case "success":
|
||||
return "text-green-400";
|
||||
case "warning":
|
||||
return "text-yellow-400";
|
||||
case "error":
|
||||
return "text-red-400";
|
||||
default:
|
||||
return "text-muted-foreground";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative z-10 shrink-0 flex flex-col bg-bg-base",
|
||||
position === "bottom"
|
||||
? "border-t border-border"
|
||||
: "border-b border-border",
|
||||
expanded ? EXPANDED_HEIGHT : COLLAPSED_HEIGHT,
|
||||
"transition-[height] duration-150",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between px-3 py-1.5 shrink-0 border-b border-border/60">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleToggle}
|
||||
className="flex items-center gap-2 -ml-2"
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
)}
|
||||
<span className="text-sm font-medium">
|
||||
{t("terminal.connectionLogTitle")} ({logs.length})
|
||||
</span>
|
||||
</Button>
|
||||
{logs.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={copyLogsToClipboard}
|
||||
title={t("terminal.connectionLogCopy")}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden thin-scrollbar">
|
||||
<div className="px-3 py-2">
|
||||
{logs.length === 0 ? (
|
||||
<div className="py-4 text-center text-sm text-muted-foreground">
|
||||
{isConnecting
|
||||
? t("terminal.connectionLogWaiting")
|
||||
: t("terminal.connectionLogEmpty")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1 font-mono text-xs">
|
||||
{logs.map((log, index) => (
|
||||
<div
|
||||
key={log.id}
|
||||
ref={index === logs.length - 1 ? lastLogRef : null}
|
||||
className="flex items-start gap-2"
|
||||
>
|
||||
<span className="shrink-0 text-muted-foreground">
|
||||
{log.timestamp.toLocaleTimeString()}
|
||||
</span>
|
||||
{getIcon(log.type)}
|
||||
<span
|
||||
className={cn(
|
||||
"flex-1 min-w-0 break-all whitespace-pre-wrap",
|
||||
getTextColor(log.type),
|
||||
)}
|
||||
>
|
||||
{log.message}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/utils.ts";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { ConnectionLogPanel } from "@/components/connection/ConnectionLogPanel.tsx";
|
||||
import type { ConnectionStatus } from "@/components/connection/connection-status.ts";
|
||||
|
||||
interface ConnectionScreenProps {
|
||||
status: ConnectionStatus;
|
||||
message?: string;
|
||||
backgroundColor?: string;
|
||||
attempt?: number;
|
||||
maxAttempts?: number;
|
||||
nextRetryInMs?: number | null;
|
||||
onManualRetry?: () => void;
|
||||
retryLabel?: string;
|
||||
disconnectedMessage?: string;
|
||||
extraActions?: React.ReactNode;
|
||||
logPosition?: "top" | "bottom";
|
||||
emptyState?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ConnectionScreen({
|
||||
status,
|
||||
message,
|
||||
backgroundColor,
|
||||
attempt = 0,
|
||||
maxAttempts = 0,
|
||||
nextRetryInMs = null,
|
||||
onManualRetry,
|
||||
retryLabel,
|
||||
disconnectedMessage,
|
||||
extraActions,
|
||||
logPosition = "bottom",
|
||||
emptyState,
|
||||
className,
|
||||
}: ConnectionScreenProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (status === "connected" && !emptyState) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const showSpinner = status === "connecting";
|
||||
const showRetryButton = status === "disconnected" && !!onManualRetry;
|
||||
const showLog = status !== "connected";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("absolute inset-0 z-[100] flex flex-col", className)}
|
||||
style={{ backgroundColor: backgroundColor || "var(--bg-base)" }}
|
||||
>
|
||||
<div className="flex-1 min-h-0 flex items-center justify-center">
|
||||
{emptyState ? (
|
||||
emptyState
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-4 px-6 text-center">
|
||||
{showSpinner && <div className="simple-spinner" />}
|
||||
{message && (
|
||||
<p className="text-sm text-foreground-secondary font-medium">
|
||||
{message}
|
||||
</p>
|
||||
)}
|
||||
{attempt > 0 && status !== "disconnected" && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{nextRetryInMs && nextRetryInMs > 0
|
||||
? t("connection.retryingIn", {
|
||||
seconds: Math.ceil(nextRetryInMs / 1000),
|
||||
attempt,
|
||||
max: maxAttempts,
|
||||
})
|
||||
: t("connection.retryingNow", { attempt, max: maxAttempts })}
|
||||
</p>
|
||||
)}
|
||||
{showRetryButton && (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<p className="text-sm text-foreground-secondary font-medium">
|
||||
{disconnectedMessage || t("connection.disconnected")}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
onClick={onManualRetry}
|
||||
className="gap-2 font-semibold"
|
||||
>
|
||||
<RefreshCw className="size-3.5" />
|
||||
{retryLabel || t("connection.reconnect")}
|
||||
</Button>
|
||||
{extraActions}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showLog && !emptyState && (
|
||||
<ConnectionLogPanel
|
||||
isConnecting={status === "connecting"}
|
||||
isConnected={false}
|
||||
hasConnectionError={status === "error" || status === "disconnected"}
|
||||
position={logPosition}
|
||||
/>
|
||||
)}
|
||||
|
||||
<style>
|
||||
{`
|
||||
@keyframes connection-screen-spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
.simple-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 4px solid var(--border-base);
|
||||
border-top-color: var(--foreground);
|
||||
border-radius: 50%;
|
||||
animation: connection-screen-spin 0.8s linear infinite;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { ConnectionStage } from "@/types/connection-log.ts";
|
||||
|
||||
export type ConnectionStatus =
|
||||
"connecting" | "connected" | "error" | "disconnected";
|
||||
|
||||
// Guacamole client states, per guacamole-common-js Guacamole.Client#STATE_*.
|
||||
const GUAC_STATE_IDLE = 0;
|
||||
const GUAC_STATE_CONNECTING = 1;
|
||||
const GUAC_STATE_WAITING = 2;
|
||||
const GUAC_STATE_CONNECTED = 3;
|
||||
const GUAC_STATE_DISCONNECTING = 4;
|
||||
const GUAC_STATE_DISCONNECTED = 5;
|
||||
|
||||
export function guacStateToStage(state: number): ConnectionStage {
|
||||
switch (state) {
|
||||
case GUAC_STATE_CONNECTING:
|
||||
return "guac_connecting";
|
||||
case GUAC_STATE_WAITING:
|
||||
return "guac_handshake";
|
||||
case GUAC_STATE_CONNECTED:
|
||||
return "guac_ready";
|
||||
case GUAC_STATE_DISCONNECTING:
|
||||
case GUAC_STATE_DISCONNECTED:
|
||||
return "guac_disconnected";
|
||||
case GUAC_STATE_IDLE:
|
||||
default:
|
||||
return "guac_connecting";
|
||||
}
|
||||
}
|
||||
|
||||
export function guacStateToStatus(state: number): ConnectionStatus {
|
||||
switch (state) {
|
||||
case GUAC_STATE_CONNECTED:
|
||||
return "connected";
|
||||
case GUAC_STATE_DISCONNECTED:
|
||||
return "error";
|
||||
default:
|
||||
return "connecting";
|
||||
}
|
||||
}
|
||||
@@ -238,11 +238,15 @@ function DropdownMenuSubTrigger({
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
alignOffset = -4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
sideOffset={sideOffset}
|
||||
alignOffset={alignOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[96px] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-none bg-popover text-popover-foreground shadow-lg ring-1 ring-border duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className,
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { useAreaPreferences } from "@/contexts/UiPreferencesContext";
|
||||
|
||||
/**
|
||||
* Shared empty state. Every feature used to hand-roll its own, so some
|
||||
* surfaces explained what to do next and others showed a bare line of text.
|
||||
*
|
||||
* The `hint` and `action` only render at "guided" verbosity, which the Simple
|
||||
* preset turns on: someone who has just picked the simplest interface is the
|
||||
* one who most needs telling what to do next, while an experienced user on
|
||||
* Advanced does not want the extra paragraph on every empty panel.
|
||||
*/
|
||||
export function EmptyState({
|
||||
icon: Icon,
|
||||
title,
|
||||
hint,
|
||||
action,
|
||||
className,
|
||||
}: {
|
||||
icon?: LucideIcon;
|
||||
title: string;
|
||||
/** Guidance shown only at "guided" verbosity. */
|
||||
hint?: string;
|
||||
/** Primary call to action, also guided-only. */
|
||||
action?: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
const { emptyStateVerbosity } = useAreaPreferences("chrome");
|
||||
const guided = emptyStateVerbosity === "guided";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col items-center justify-center gap-2 px-4 py-8 text-center ${className ?? ""}`}
|
||||
>
|
||||
{Icon && <Icon className="size-5 shrink-0 text-muted-foreground/40" />}
|
||||
<span className="text-xs text-muted-foreground">{title}</span>
|
||||
{guided && hint && (
|
||||
<span className="max-w-xs text-[11px] leading-snug text-muted-foreground/70">
|
||||
{hint}
|
||||
</span>
|
||||
)}
|
||||
{guided && action}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -28,7 +28,7 @@ function PopoverContent({
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-none border border-border p-4 shadow-md outline-hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useRef, useState } from "react";
|
||||
import type { HostData } from "@/types/index";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Server, RefreshCw, CheckSquare, Square, Download } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
@@ -166,7 +167,13 @@ export function ProxmoxDiscoverDialog({
|
||||
enableRdp: g.connectionType === "rdp",
|
||||
enableDocker: g.enableDocker,
|
||||
connectionType: g.connectionType,
|
||||
tags: ["proxmox", g.type, g.node],
|
||||
tags: [
|
||||
"proxmox",
|
||||
g.type,
|
||||
g.node,
|
||||
g.type === "lxc" ? `ct-${g.vmid}` : `vm-${g.vmid}`,
|
||||
...(g.enableDocker ? ["docker"] : []),
|
||||
],
|
||||
proxmoxConfig: {
|
||||
source: {
|
||||
source: "proxmox",
|
||||
@@ -182,7 +189,7 @@ export function ProxmoxDiscoverDialog({
|
||||
}));
|
||||
|
||||
const result = toImport.length
|
||||
? await bulkImportSSHHosts(toImport, false)
|
||||
? await bulkImportSSHHosts(toImport as unknown as HostData[], false)
|
||||
: { success: 0, updated: 0, skipped: 0, failed: 0 };
|
||||
|
||||
if (toImport.length) {
|
||||
|
||||
@@ -6,14 +6,18 @@ export function SectionCard({
|
||||
icon,
|
||||
action,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
action?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col border border-border bg-card overflow-hidden">
|
||||
<div
|
||||
className={`flex flex-col border border-border bg-card overflow-hidden ${className ?? ""}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-border shrink-0">
|
||||
<span className="text-muted-foreground">{icon}</span>
|
||||
<span className="text-xs font-semibold uppercase tracking-widest text-muted-foreground flex-1">
|
||||
|
||||
@@ -35,7 +35,7 @@ function SelectTrigger({
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-none border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -59,7 +59,7 @@ function SelectContent({
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto thin-scrollbar rounded-md border shadow-md",
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto thin-scrollbar rounded-none border border-border shadow-md",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className,
|
||||
@@ -105,7 +105,7 @@ function SelectItem({
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-none py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import type { ComponentProps, HTMLAttributes } from "react";
|
||||
import { Badge } from "@/components/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export type StatusProps = ComponentProps<typeof Badge> & {
|
||||
status: "online" | "offline" | "maintenance" | "degraded";
|
||||
};
|
||||
|
||||
export const Status = ({ className, status, ...props }: StatusProps) => (
|
||||
<Badge
|
||||
className={cn("flex items-center gap-2", "group", status, className)}
|
||||
variant="secondary"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type StatusIndicatorProps = HTMLAttributes<HTMLSpanElement>;
|
||||
|
||||
export const StatusIndicator = ({ ...props }: StatusIndicatorProps) => (
|
||||
<span className="relative flex h-2 w-2" {...props}>
|
||||
<span
|
||||
className={cn(
|
||||
"absolute inline-flex h-full w-full animate-ping rounded-full opacity-75",
|
||||
"group-[.online]:bg-emerald-500",
|
||||
"group-[.offline]:bg-red-500",
|
||||
"group-[.maintenance]:bg-blue-500",
|
||||
"group-[.degraded]:bg-amber-500",
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"relative inline-flex h-2 w-2 rounded-full",
|
||||
"group-[.online]:bg-emerald-500",
|
||||
"group-[.offline]:bg-red-500",
|
||||
"group-[.maintenance]:bg-blue-500",
|
||||
"group-[.degraded]:bg-amber-500",
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
|
||||
export type StatusLabelProps = HTMLAttributes<HTMLSpanElement>;
|
||||
|
||||
export const StatusLabel = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: StatusLabelProps) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<span className={cn("text-muted-foreground", className)} {...props}>
|
||||
{children ?? (
|
||||
<>
|
||||
<span className="hidden group-[.online]:block">
|
||||
{t("common.online")}
|
||||
</span>
|
||||
<span className="hidden group-[.offline]:block">
|
||||
{t("common.offline")}
|
||||
</span>
|
||||
<span className="hidden group-[.maintenance]:block">
|
||||
{t("common.maintenance")}
|
||||
</span>
|
||||
<span className="hidden group-[.degraded]:block">
|
||||
{t("common.degraded")}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -24,7 +24,7 @@ function TabsList({
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
className={cn(
|
||||
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
|
||||
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-none p-[3px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -40,7 +40,7 @@ function TabsTrigger({
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] duration-200 focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-none border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] duration-200 focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -9,7 +9,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[80px] w-full rounded-md border border-input bg-transparent dark:bg-input/30 px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] duration-200 outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 disabled:pointer-events-none",
|
||||
"flex min-h-[80px] w-full rounded-none border border-input bg-transparent dark:bg-input/30 px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] duration-200 outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 disabled:pointer-events-none",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface VersionBadgeProps {
|
||||
status: "up_to_date" | "requires_update" | "beta";
|
||||
releaseUrl?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function VersionBadge({
|
||||
status,
|
||||
releaseUrl,
|
||||
className = "",
|
||||
}: VersionBadgeProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const badgeClassName = `text-[10px] px-1.5 py-0.5 font-semibold leading-none ${
|
||||
status === "beta"
|
||||
? "bg-blue-500/20 text-blue-400"
|
||||
: status === "requires_update"
|
||||
? "bg-yellow-500/20 text-yellow-400"
|
||||
: "bg-accent-brand/20 text-accent-brand"
|
||||
}${className ? ` ${className}` : ""}`;
|
||||
|
||||
const label =
|
||||
status === "beta"
|
||||
? t("dashboard.beta").toUpperCase()
|
||||
: status === "requires_update"
|
||||
? t("dashboard.updateAvailable").toUpperCase()
|
||||
: t("dashboardTab.stable");
|
||||
|
||||
// Only the update case leads anywhere. Wherever this badge is rendered it is
|
||||
// the one place a pending release is announced, so it also has to be the way
|
||||
// to reach it -- otherwise "UPDATE AVAILABLE" is a dead end. The label alone
|
||||
// does not say where the link goes, hence the spelled-out accessible name.
|
||||
if (status === "requires_update" && releaseUrl) {
|
||||
const linkLabel = t("versionCheck.updateLinkLabel");
|
||||
return (
|
||||
<a
|
||||
href={releaseUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={linkLabel}
|
||||
aria-label={linkLabel}
|
||||
className={`${badgeClassName} cursor-pointer hover:underline`}
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return <span className={badgeClassName}>{label}</span>;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { getUserPreferences, saveUserPreferences } from "@/api/open-tabs-api";
|
||||
import {
|
||||
parseRemoteDesktopDefaults,
|
||||
parseTerminalDefaults,
|
||||
type RemoteDesktopDefaults,
|
||||
type TerminalDefaults,
|
||||
} from "@/lib/connection-defaults";
|
||||
|
||||
type DefaultsKind = "terminal" | "rdp";
|
||||
|
||||
interface ConnectionDefaultsContextValue {
|
||||
ready: boolean;
|
||||
terminal: TerminalDefaults;
|
||||
rdp: RemoteDesktopDefaults;
|
||||
saveDefaults: (
|
||||
kind: DefaultsKind,
|
||||
value: TerminalDefaults | RemoteDesktopDefaults,
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
const ConnectionDefaultsContext = createContext<ConnectionDefaultsContextValue>(
|
||||
{
|
||||
ready: true,
|
||||
terminal: {},
|
||||
rdp: {},
|
||||
saveDefaults: async () => {},
|
||||
},
|
||||
);
|
||||
|
||||
export function ConnectionDefaultsProvider({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [ready, setReady] = useState(false);
|
||||
const [terminal, setTerminal] = useState<TerminalDefaults>({});
|
||||
const [rdp, setRdp] = useState<RemoteDesktopDefaults>({});
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getUserPreferences()
|
||||
.then((preferences) => {
|
||||
if (cancelled) return;
|
||||
setTerminal(parseTerminalDefaults(preferences.terminalDefaults));
|
||||
setRdp(parseRemoteDesktopDefaults(preferences.rdpDefaults));
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
if (!cancelled) setReady(true);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const saveDefaults = useCallback(
|
||||
async (
|
||||
kind: DefaultsKind,
|
||||
value: TerminalDefaults | RemoteDesktopDefaults,
|
||||
) => {
|
||||
const serialized = JSON.stringify(value);
|
||||
await saveUserPreferences({ [`${kind}Defaults`]: serialized });
|
||||
if (kind === "terminal") setTerminal(value as TerminalDefaults);
|
||||
if (kind === "rdp") setRdp(value as RemoteDesktopDefaults);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ ready, terminal, rdp, saveDefaults }),
|
||||
[ready, terminal, rdp, saveDefaults],
|
||||
);
|
||||
return (
|
||||
<ConnectionDefaultsContext.Provider value={value}>
|
||||
{children}
|
||||
</ConnectionDefaultsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useConnectionDefaults(): ConnectionDefaultsContextValue {
|
||||
return useContext(ConnectionDefaultsContext);
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import {
|
||||
getUiPreferences,
|
||||
saveUiPreferences,
|
||||
getUserPreferences,
|
||||
} from "@/main-axios";
|
||||
import {
|
||||
defaultUiPreferences,
|
||||
PRESETS,
|
||||
resolveArea,
|
||||
sanitizeUiPreferences,
|
||||
UI_ONBOARDING_VERSION,
|
||||
type UiAreaKey,
|
||||
type UiAreaPreferences,
|
||||
type UiOverrides,
|
||||
type UiPreferences,
|
||||
type UiPreset,
|
||||
} from "@/types/ui-preferences";
|
||||
|
||||
const SAVE_DEBOUNCE_MS = 500;
|
||||
const LS_KEY = "uiPreferences";
|
||||
const SYNC_EVENT = "uiPreferencesChanged";
|
||||
|
||||
function readCache(): UiPreferences | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_KEY);
|
||||
if (!raw) return null;
|
||||
return sanitizeUiPreferences(JSON.parse(raw));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeCache(preferences: UiPreferences) {
|
||||
try {
|
||||
localStorage.setItem(LS_KEY, JSON.stringify(preferences));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
interface UiPreferencesContextValue {
|
||||
preferences: UiPreferences;
|
||||
loaded: boolean;
|
||||
resolve: <A extends UiAreaKey>(area: A) => UiAreaPreferences[A];
|
||||
setPreset: (preset: UiPreset) => void;
|
||||
setOverride: <A extends UiAreaKey, K extends keyof UiAreaPreferences[A]>(
|
||||
area: A,
|
||||
key: K,
|
||||
value: UiAreaPreferences[A][K] | null,
|
||||
) => void;
|
||||
clearArea: (area: UiAreaKey) => void;
|
||||
clearAllOverrides: () => void;
|
||||
completeOnboarding: (skipped: boolean) => void;
|
||||
}
|
||||
|
||||
const UiPreferencesContext = createContext<UiPreferencesContextValue | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
/**
|
||||
* App-wide interface preset and per-area overrides. Cached in localStorage for
|
||||
* instant paint and synced to the backend when storageMode is "cloud", the
|
||||
* same shape as useHostSidebarPreferences -- but provided once at the app root
|
||||
* rather than mounted per consumer, since almost every panel reads it.
|
||||
*
|
||||
* Writes send only the changed slice: the backend merges overrides two levels
|
||||
* deep and treats null as "clear this", so handing a knob back to the preset is
|
||||
* a single PUT rather than a read-modify-write of the whole document.
|
||||
*/
|
||||
export function UiPreferencesProvider({ children }: { children: ReactNode }) {
|
||||
const [preferences, setPreferences] = useState<UiPreferences>(
|
||||
() => readCache() ?? defaultUiPreferences(),
|
||||
);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const pendingPatch = useRef<Record<string, unknown>>({});
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
getUiPreferences()
|
||||
.then((remote) => {
|
||||
if (cancelled) return;
|
||||
setPreferences(remote);
|
||||
writeCache(remote);
|
||||
})
|
||||
.catch(() => {
|
||||
/* keep cache/default */
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoaded(true);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
const cached = readCache();
|
||||
if (cached) setPreferences(cached);
|
||||
};
|
||||
window.addEventListener(SYNC_EVENT, handler);
|
||||
return () => window.removeEventListener(SYNC_EVENT, handler);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (saveTimer.current) clearTimeout(saveTimer.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const queueSave = useCallback((patch: Record<string, unknown>) => {
|
||||
// Overrides accumulate key by key so a burst of edits still sends every
|
||||
// change; anything else is last-write-wins.
|
||||
const merged = { ...pendingPatch.current };
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
if (key === "overrides" && value && typeof value === "object") {
|
||||
merged.overrides = {
|
||||
...((merged.overrides as Record<string, unknown>) ?? {}),
|
||||
...(value as Record<string, unknown>),
|
||||
};
|
||||
} else {
|
||||
merged[key] = value;
|
||||
}
|
||||
}
|
||||
pendingPatch.current = merged;
|
||||
|
||||
if (saveTimer.current) clearTimeout(saveTimer.current);
|
||||
saveTimer.current = setTimeout(() => {
|
||||
const body = pendingPatch.current;
|
||||
pendingPatch.current = {};
|
||||
getUserPreferences()
|
||||
.then((prefs) => {
|
||||
if (prefs.storageMode !== "cloud") return;
|
||||
return saveUiPreferences(body);
|
||||
})
|
||||
.catch(() => {
|
||||
/* best-effort; cache already holds it */
|
||||
});
|
||||
}, SAVE_DEBOUNCE_MS);
|
||||
}, []);
|
||||
|
||||
/** Applies the local mirror of a patch, then queues the same patch remotely. */
|
||||
const applyLocal = useCallback(
|
||||
(
|
||||
mutate: (prev: UiPreferences) => UiPreferences,
|
||||
patch: Record<string, unknown>,
|
||||
) => {
|
||||
setPreferences((prev) => {
|
||||
const next = sanitizeUiPreferences(mutate(prev));
|
||||
writeCache(next);
|
||||
window.dispatchEvent(new Event(SYNC_EVENT));
|
||||
return next;
|
||||
});
|
||||
queueSave(patch);
|
||||
},
|
||||
[queueSave],
|
||||
);
|
||||
|
||||
const setPreset = useCallback(
|
||||
(preset: UiPreset) => {
|
||||
applyLocal((prev) => ({ ...prev, preset }), { preset });
|
||||
},
|
||||
[applyLocal],
|
||||
);
|
||||
|
||||
const setOverride = useCallback(
|
||||
<A extends UiAreaKey, K extends keyof UiAreaPreferences[A]>(
|
||||
area: A,
|
||||
key: K,
|
||||
value: UiAreaPreferences[A][K] | null,
|
||||
) => {
|
||||
applyLocal(
|
||||
(prev) => {
|
||||
const areaOverrides = {
|
||||
...((prev.overrides[area] ?? {}) as Record<string, unknown>),
|
||||
};
|
||||
if (value === null) delete areaOverrides[key as string];
|
||||
else areaOverrides[key as string] = value;
|
||||
|
||||
const overrides = { ...prev.overrides } as Record<string, unknown>;
|
||||
if (Object.keys(areaOverrides).length > 0)
|
||||
overrides[area] = areaOverrides;
|
||||
else delete overrides[area];
|
||||
|
||||
return {
|
||||
...prev,
|
||||
overrides: overrides as UiOverrides,
|
||||
};
|
||||
},
|
||||
{ overrides: { [area]: { [key]: value } } },
|
||||
);
|
||||
},
|
||||
[applyLocal],
|
||||
);
|
||||
|
||||
const clearArea = useCallback(
|
||||
(area: UiAreaKey) => {
|
||||
applyLocal(
|
||||
(prev) => {
|
||||
const overrides = { ...prev.overrides } as Record<string, unknown>;
|
||||
delete overrides[area];
|
||||
return { ...prev, overrides: overrides as UiOverrides };
|
||||
},
|
||||
{ overrides: { [area]: null } },
|
||||
);
|
||||
},
|
||||
[applyLocal],
|
||||
);
|
||||
|
||||
const clearAllOverrides = useCallback(() => {
|
||||
applyLocal((prev) => ({ ...prev, overrides: {} }), { overrides: null });
|
||||
}, [applyLocal]);
|
||||
|
||||
const completeOnboarding = useCallback(
|
||||
(skipped: boolean) => {
|
||||
const onboarding = {
|
||||
completedVersion: UI_ONBOARDING_VERSION,
|
||||
completedAt: new Date().toISOString(),
|
||||
skipped,
|
||||
};
|
||||
applyLocal((prev) => ({ ...prev, onboarding }), { onboarding });
|
||||
},
|
||||
[applyLocal],
|
||||
);
|
||||
|
||||
const value = useMemo<UiPreferencesContextValue>(
|
||||
() => ({
|
||||
preferences,
|
||||
loaded,
|
||||
resolve: (area) => resolveArea(preferences, area),
|
||||
setPreset,
|
||||
setOverride,
|
||||
clearArea,
|
||||
clearAllOverrides,
|
||||
completeOnboarding,
|
||||
}),
|
||||
[
|
||||
preferences,
|
||||
loaded,
|
||||
setPreset,
|
||||
setOverride,
|
||||
clearArea,
|
||||
clearAllOverrides,
|
||||
completeOnboarding,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<UiPreferencesContext.Provider value={value}>
|
||||
{children}
|
||||
</UiPreferencesContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useUiPreferencesContext(): UiPreferencesContextValue | null {
|
||||
return useContext(UiPreferencesContext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective settings for one area. Falls back to the balanced preset (today's
|
||||
* behavior) outside a provider, so components rendered in isolation -- tests,
|
||||
* Electron sub-windows -- behave exactly as they did before presets existed
|
||||
* rather than crashing or silently going Simple.
|
||||
*/
|
||||
export function useAreaPreferences<A extends UiAreaKey>(
|
||||
area: A,
|
||||
): UiAreaPreferences[A] {
|
||||
const ctx = useContext(UiPreferencesContext);
|
||||
if (!ctx) return PRESETS.balanced[area];
|
||||
return ctx.resolve(area);
|
||||
}
|
||||
|
||||
export function useUiPreference<
|
||||
A extends UiAreaKey,
|
||||
K extends keyof UiAreaPreferences[A],
|
||||
>(area: A, key: K): UiAreaPreferences[A][K] {
|
||||
return useAreaPreferences(area)[key];
|
||||
}
|
||||
@@ -24,12 +24,15 @@ import {
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { Kbd } from "@/components/kbd";
|
||||
import { VersionBadge } from "@/components/version-badge";
|
||||
import { DASHBOARD_CARDS } from "@/lib/theme";
|
||||
import { CONNECTION_STATES } from "@/types/index";
|
||||
import type { DashboardCardId, TabType, Host } from "@/types/ui-types";
|
||||
import {
|
||||
getSSHHosts,
|
||||
getUptime,
|
||||
getVersionInfo,
|
||||
releaseUrlFrom,
|
||||
getDatabaseHealth,
|
||||
getRecentActivity,
|
||||
getTunnelStatuses,
|
||||
@@ -130,28 +133,18 @@ function StatsBarCard({
|
||||
uptimeFormatted,
|
||||
versionText,
|
||||
versionStatus,
|
||||
releaseUrl,
|
||||
dbHealth,
|
||||
}: {
|
||||
hosts: Host[];
|
||||
uptimeFormatted: string;
|
||||
versionText: string;
|
||||
versionStatus: "up_to_date" | "requires_update" | "beta";
|
||||
releaseUrl: string;
|
||||
dbHealth: "healthy" | "error";
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const online = hosts.filter((h) => h.online).length;
|
||||
const statusLabel =
|
||||
versionStatus === "beta"
|
||||
? t("dashboard.beta").toUpperCase()
|
||||
: versionStatus === "requires_update"
|
||||
? t("dashboard.updateAvailable").toUpperCase()
|
||||
: t("dashboardTab.stable");
|
||||
const statusColor =
|
||||
versionStatus === "beta"
|
||||
? "bg-blue-500/20 text-blue-400"
|
||||
: versionStatus === "requires_update"
|
||||
? "bg-yellow-500/20 text-yellow-400"
|
||||
: "bg-accent-brand/20 text-accent-brand";
|
||||
const online = hosts.filter((h) => h.status === "online").length;
|
||||
return (
|
||||
<Card className="grid grid-cols-4 divide-x divide-border overflow-hidden w-full h-full py-0 gap-0">
|
||||
<div className="flex flex-col justify-center px-4 py-2 gap-1">
|
||||
@@ -161,11 +154,11 @@ function StatsBarCard({
|
||||
<span className="text-xl font-bold text-accent-brand leading-none">
|
||||
{versionText || "—"}
|
||||
</span>
|
||||
<span
|
||||
className={`text-[10px] px-1.5 py-0.5 w-fit font-semibold leading-none ${statusColor}`}
|
||||
>
|
||||
{statusLabel}
|
||||
</span>
|
||||
<VersionBadge
|
||||
status={versionStatus}
|
||||
releaseUrl={releaseUrl}
|
||||
className="w-fit"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col justify-center px-4 py-2 gap-1">
|
||||
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold">
|
||||
@@ -189,7 +182,9 @@ function StatsBarCard({
|
||||
</div>
|
||||
<div className="flex flex-col justify-center px-4 py-2 gap-1">
|
||||
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold">
|
||||
{t("dashboardTab.hostsOnline")}
|
||||
{t("dashboardTab.hostsAvailable", {
|
||||
defaultValue: "Hosts Available",
|
||||
})}
|
||||
</span>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-xl font-bold leading-none">{online}</span>
|
||||
@@ -426,7 +421,7 @@ function MetricBar({ label, value }: { label: string; value: number }) {
|
||||
);
|
||||
}
|
||||
|
||||
function HostStatusCard({
|
||||
export function HostStatusCard({
|
||||
hosts,
|
||||
hostMetrics,
|
||||
onOpenTab,
|
||||
@@ -442,7 +437,7 @@ function HostStatusCard({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const statusScheme = useStatusColorScheme();
|
||||
const online = hosts.filter((h) => h.online).length;
|
||||
const online = hosts.filter((h) => h.status === "online").length;
|
||||
return (
|
||||
<Card className="flex flex-col overflow-hidden w-full h-full py-0 gap-0">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border shrink-0">
|
||||
@@ -453,7 +448,8 @@ function HostStatusCard({
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{online}/{hosts.length} {t("dashboardTab.onlineLower")}
|
||||
{online}/{hosts.length}{" "}
|
||||
{t("dashboardTab.availableLower", { defaultValue: "Available" })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col overflow-auto flex-1">
|
||||
@@ -463,6 +459,12 @@ function HostStatusCard({
|
||||
</div>
|
||||
)}
|
||||
{hosts.map((host, i) => {
|
||||
const availability =
|
||||
host.status && host.status !== "unknown"
|
||||
? host.status
|
||||
: host.online
|
||||
? "online"
|
||||
: "offline";
|
||||
const metrics = hostMetrics.get(host.id);
|
||||
const cpu = metrics?.cpu ?? null;
|
||||
const ram = metrics?.ram ?? null;
|
||||
@@ -472,24 +474,32 @@ function HostStatusCard({
|
||||
<div
|
||||
key={i}
|
||||
onClick={() => onOpenTab(host, "host-metrics")}
|
||||
className="flex items-center justify-between px-4 py-2.5 border-b border-border last:border-0 hover:bg-muted/50 cursor-pointer group/row"
|
||||
className="flex min-w-0 items-center justify-between px-4 py-2.5 border-b border-border last:border-0 hover:bg-muted/50 cursor-pointer group/row"
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2.5">
|
||||
<span
|
||||
className={`size-1.5 rounded-full shrink-0 ${getStatusClasses(host.online, statusScheme, "dot", statusLoading)}`}
|
||||
className={`size-1.5 rounded-full shrink-0 ${getStatusClasses(availability, statusScheme, "dot", statusLoading)}`}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs font-semibold">{host.name}</span>
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<span
|
||||
className="truncate text-xs font-semibold"
|
||||
title={host.name}
|
||||
>
|
||||
{host.name}
|
||||
</span>
|
||||
<ExternalLink className="size-2.5 text-muted-foreground/0 group-hover/row:text-muted-foreground/60 transition-colors shrink-0" />
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground font-mono">
|
||||
<span
|
||||
className="truncate text-[10px] text-muted-foreground font-mono"
|
||||
title={host.ip}
|
||||
>
|
||||
{host.ip}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{host.online && hasMetrics ? (
|
||||
<div className="flex shrink-0 items-center gap-3">
|
||||
{availability === "online" && hasMetrics ? (
|
||||
<div className="flex items-center gap-3">
|
||||
{cpu !== null && (
|
||||
<MetricBar label={t("dashboard.cpu")} value={cpu} />
|
||||
@@ -515,13 +525,19 @@ function HostStatusCard({
|
||||
</div>
|
||||
)}
|
||||
<span
|
||||
className={`text-[10px] px-2 py-0.5 font-semibold border ${getStatusClasses(host.online, statusScheme, "badge", statusLoading)}`}
|
||||
className={`text-[10px] px-2 py-0.5 font-semibold border ${getStatusClasses(availability, statusScheme, "badge", statusLoading)}`}
|
||||
>
|
||||
{statusLoading
|
||||
? t("dashboardTab.checking")
|
||||
: host.online
|
||||
? t("dashboardTab.online")
|
||||
: t("dashboardTab.offline")}
|
||||
: availability === "online"
|
||||
? t("dashboardTab.available", {
|
||||
defaultValue: "AVAILABLE",
|
||||
})
|
||||
: availability === "reachable"
|
||||
? t("dashboardTab.reachable", {
|
||||
defaultValue: "REACHABLE",
|
||||
})
|
||||
: t("dashboardTab.offline")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -792,6 +808,7 @@ function CardItem({
|
||||
uptimeFormatted,
|
||||
versionText,
|
||||
versionStatus,
|
||||
releaseUrl,
|
||||
dbHealth,
|
||||
credentialCount,
|
||||
activeTunnelCount,
|
||||
@@ -822,6 +839,7 @@ function CardItem({
|
||||
uptimeFormatted: string;
|
||||
versionText: string;
|
||||
versionStatus: "up_to_date" | "requires_update" | "beta";
|
||||
releaseUrl: string;
|
||||
dbHealth: "healthy" | "error";
|
||||
credentialCount: number;
|
||||
activeTunnelCount: number;
|
||||
@@ -890,6 +908,7 @@ function CardItem({
|
||||
uptimeFormatted={uptimeFormatted}
|
||||
versionText={versionText}
|
||||
versionStatus={versionStatus}
|
||||
releaseUrl={releaseUrl}
|
||||
dbHealth={dbHealth}
|
||||
/>
|
||||
)}
|
||||
@@ -906,6 +925,7 @@ function CardItem({
|
||||
onOpenSingletonTab={onOpenSingletonTab}
|
||||
hosts={hosts}
|
||||
onOpenTab={onOpenTab}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
)}
|
||||
{slot.id === "host_status" && (
|
||||
@@ -913,7 +933,6 @@ function CardItem({
|
||||
hosts={hosts}
|
||||
hostMetrics={hostMetrics}
|
||||
onOpenTab={onOpenTab}
|
||||
isAdmin={isAdmin}
|
||||
statusLoading={statusLoading}
|
||||
/>
|
||||
)}
|
||||
@@ -1048,6 +1067,7 @@ type PanelColumnProps = {
|
||||
uptimeFormatted: string;
|
||||
versionText: string;
|
||||
versionStatus: "up_to_date" | "requires_update" | "beta";
|
||||
releaseUrl: string;
|
||||
dbHealth: "healthy" | "error";
|
||||
credentialCount: number;
|
||||
activeTunnelCount: number;
|
||||
@@ -1080,6 +1100,7 @@ function PanelColumn({
|
||||
uptimeFormatted,
|
||||
versionText,
|
||||
versionStatus,
|
||||
releaseUrl,
|
||||
dbHealth,
|
||||
credentialCount,
|
||||
activeTunnelCount,
|
||||
@@ -1138,6 +1159,7 @@ function PanelColumn({
|
||||
uptimeFormatted={uptimeFormatted}
|
||||
versionText={versionText}
|
||||
versionStatus={versionStatus}
|
||||
releaseUrl={releaseUrl}
|
||||
dbHealth={dbHealth}
|
||||
credentialCount={credentialCount}
|
||||
activeTunnelCount={activeTunnelCount}
|
||||
@@ -1223,6 +1245,25 @@ export function DashboardTab({
|
||||
return DEFAULT_SLOTS;
|
||||
});
|
||||
|
||||
// Picking an interface preset rewrites the stored layout from the settings
|
||||
// panel, so pick it up without waiting for a remount.
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
try {
|
||||
const saved = localStorage.getItem("dashboardTab.slots");
|
||||
if (!saved) return;
|
||||
const parsed = JSON.parse(saved) as CardSlot[];
|
||||
setSlots(
|
||||
parsed.map((s, i) => ({ key: s.key ?? `${s.id}_${i}`, ...s })),
|
||||
);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
window.addEventListener("dashboardSlotsChanged", handler);
|
||||
return () => window.removeEventListener("dashboardSlotsChanged", handler);
|
||||
}, []);
|
||||
|
||||
const [homepageLinkCopied, setHomepageLinkCopied] = useState(false);
|
||||
|
||||
const handleCopyHomepageLink = () => {
|
||||
@@ -1288,6 +1329,7 @@ export function DashboardTab({
|
||||
const [versionStatus, setVersionStatus] = useState<
|
||||
"up_to_date" | "requires_update" | "beta"
|
||||
>("up_to_date");
|
||||
const [releaseUrl, setReleaseUrl] = useState("");
|
||||
const [dbHealth, setDbHealth] = useState<"healthy" | "error">("healthy");
|
||||
const [credentialCount, setCredentialCount] = useState(0);
|
||||
const [activeTunnelCount, setActiveTunnelCount] = useState(0);
|
||||
@@ -1380,6 +1422,7 @@ export function DashboardTab({
|
||||
.then((info) => {
|
||||
setVersionText(info.localVersion ?? "");
|
||||
setVersionStatus(info.status ?? "up_to_date");
|
||||
setReleaseUrl(releaseUrlFrom(info));
|
||||
})
|
||||
.catch(() => {});
|
||||
getDatabaseHealth()
|
||||
@@ -1410,7 +1453,7 @@ export function DashboardTab({
|
||||
getTunnelStatuses()
|
||||
.then((statuses) => {
|
||||
const active = Object.values(statuses ?? {}).filter(
|
||||
(s) => s?.status === "CONNECTED",
|
||||
(s) => s?.status === CONNECTION_STATES.CONNECTED,
|
||||
).length;
|
||||
setActiveTunnelCount(active);
|
||||
})
|
||||
@@ -1606,6 +1649,7 @@ export function DashboardTab({
|
||||
uptimeFormatted,
|
||||
versionText,
|
||||
versionStatus,
|
||||
releaseUrl,
|
||||
dbHealth,
|
||||
credentialCount,
|
||||
activeTunnelCount,
|
||||
@@ -1720,6 +1764,7 @@ export function DashboardTab({
|
||||
uptimeFormatted={uptimeFormatted}
|
||||
versionText={versionText}
|
||||
versionStatus={versionStatus}
|
||||
releaseUrl={releaseUrl}
|
||||
dbHealth={dbHealth}
|
||||
/>
|
||||
)}
|
||||
@@ -1736,6 +1781,7 @@ export function DashboardTab({
|
||||
onOpenSingletonTab={onOpenSingletonTab}
|
||||
hosts={hosts}
|
||||
onOpenTab={onOpenTab}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
)}
|
||||
{slot.id === "host_status" && (
|
||||
@@ -1743,7 +1789,6 @@ export function DashboardTab({
|
||||
hosts={statusCheckHosts}
|
||||
hostMetrics={hostMetrics}
|
||||
onOpenTab={onOpenTab}
|
||||
isAdmin={isAdmin}
|
||||
statusLoading={statusLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -62,6 +62,7 @@ import {
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTabsSafe } from "@/shell/TabContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { readStatusColorScheme } from "@/hooks/use-status-color-scheme";
|
||||
|
||||
const AVAILABLE_COLORS = [
|
||||
{ value: "#ef4444", label: "Red" },
|
||||
@@ -130,12 +131,14 @@ function buildNodeSvg(
|
||||
): string {
|
||||
const isOnline = status === "online";
|
||||
const isOffline = status === "offline";
|
||||
const useRealColors = localStorage.getItem("statusColorScheme") === "status";
|
||||
const useRealColors = readStatusColorScheme() === "status";
|
||||
let statusColor: string;
|
||||
if (isOnline) {
|
||||
statusColor = useRealColors
|
||||
? "rgb(16,185,129)"
|
||||
: resolveCssVar("--accent-brand", "rgb(16,185,129)");
|
||||
} else if (status === "reachable") {
|
||||
statusColor = "rgb(251,191,36)";
|
||||
} else if (isOffline) {
|
||||
statusColor = useRealColors ? "rgb(239,68,68)" : "rgba(16,185,129,0.2)";
|
||||
} else {
|
||||
@@ -495,9 +498,13 @@ export function NetworkGraphCard({
|
||||
(cy: cytoscape.Core) => {
|
||||
cyRef.current = cy;
|
||||
if (embedded) {
|
||||
cy.nodes().forEach((n) => n.ungrabify());
|
||||
cy.nodes().forEach((n) => {
|
||||
n.ungrabify();
|
||||
});
|
||||
} else {
|
||||
cy.nodes().forEach((n) => n.grabify());
|
||||
cy.nodes().forEach((n) => {
|
||||
n.grabify();
|
||||
});
|
||||
}
|
||||
applyStyle(cy);
|
||||
|
||||
|
||||
@@ -1,222 +0,0 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/alert.tsx";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import { Sheet, SheetContent } from "@/components/sheet.tsx";
|
||||
import { getReleasesRSS, getVersionInfo } from "@/main-axios.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
interface UpdateLogProps extends React.ComponentProps<"div"> {
|
||||
loggedIn: boolean;
|
||||
}
|
||||
|
||||
interface ReleaseItem {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
link: string;
|
||||
pubDate: string;
|
||||
version: string;
|
||||
isPrerelease: boolean;
|
||||
isDraft: boolean;
|
||||
assets: Array<{
|
||||
name: string;
|
||||
size: number;
|
||||
download_count: number;
|
||||
download_url: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface RSSResponse {
|
||||
feed: {
|
||||
title: string;
|
||||
description: string;
|
||||
link: string;
|
||||
updated: string;
|
||||
};
|
||||
items: ReleaseItem[];
|
||||
total_count: number;
|
||||
cached: boolean;
|
||||
cache_age?: number;
|
||||
}
|
||||
|
||||
interface VersionResponse {
|
||||
status: "up_to_date" | "requires_update" | "beta";
|
||||
version: string;
|
||||
localVersion?: string;
|
||||
latest_release: {
|
||||
name: string;
|
||||
published_at: string;
|
||||
html_url: string;
|
||||
};
|
||||
cached: boolean;
|
||||
cache_age?: number;
|
||||
}
|
||||
|
||||
export function UpdateLog({ loggedIn }: UpdateLogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [releases, setReleases] = useState<RSSResponse | null>(null);
|
||||
const [versionInfo, setVersionInfo] = useState<VersionResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (loggedIn && isOpen) {
|
||||
setLoading(true);
|
||||
Promise.all([getReleasesRSS(100), getVersionInfo()])
|
||||
.then(([releasesRes, versionRes]) => {
|
||||
setReleases(releasesRes);
|
||||
setVersionInfo(versionRes);
|
||||
setError(null);
|
||||
})
|
||||
.catch(() => {
|
||||
setError(t("common.failedToFetchUpdateInfo"));
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
}, [loggedIn, isOpen, t]);
|
||||
|
||||
if (!loggedIn) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const formatDescription = (description: string) => {
|
||||
const firstLine = description.split("\n")[0];
|
||||
return firstLine.replace(/[#*`]/g, "").replace(/\s+/g, " ").trim();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="ml-2 text-sm border-1 border-edge text-muted-foreground"
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
{t("common.updatesAndReleases")}
|
||||
</Button>
|
||||
|
||||
<Sheet open={isOpen} onOpenChange={setIsOpen}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="w-[500px] bg-canvas border-l-2 border-edge text-foreground sm:max-w-[500px] p-0 flex flex-col [&>button]:hidden"
|
||||
>
|
||||
<div className="flex items-center justify-between p-4 border-b border-edge">
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
{t("common.updatesAndReleases")}
|
||||
</h2>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="h-8 w-8 p-0 hover:bg-red-500 hover:text-white transition-colors flex items-center justify-center"
|
||||
title={t("common.close")}
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 thin-scrollbar">
|
||||
{versionInfo && versionInfo.status === "requires_update" && (
|
||||
<Alert className="bg-elevated border-edge text-foreground mb-3">
|
||||
<AlertTitle className="text-foreground">
|
||||
{t("common.updateAvailable")}
|
||||
</AlertTitle>
|
||||
<AlertDescription className="text-foreground-secondary">
|
||||
{t("common.newVersionAvailable", {
|
||||
version: versionInfo.version,
|
||||
})}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{versionInfo && versionInfo.status === "beta" && (
|
||||
<Alert className="bg-elevated border-edge text-foreground mb-3">
|
||||
<AlertTitle className="text-foreground">
|
||||
{t("versionCheck.betaVersion")}
|
||||
</AlertTitle>
|
||||
<AlertDescription className="text-foreground-secondary">
|
||||
{t("versionCheck.betaVersionDesc", {
|
||||
current: versionInfo.localVersion,
|
||||
latest: versionInfo.version,
|
||||
})}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert
|
||||
variant="destructive"
|
||||
className="bg-red-900/20 border-red-500 text-red-300 mb-3"
|
||||
>
|
||||
<AlertTitle className="text-red-300">
|
||||
{t("common.error")}
|
||||
</AlertTitle>
|
||||
<AlertDescription className="text-red-300">
|
||||
{error}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{releases?.items.map((release) => (
|
||||
<div
|
||||
key={release.id}
|
||||
className="border border-edge rounded-lg p-3 hover:bg-elevated transition-colors cursor-pointer bg-elevated/50"
|
||||
onClick={() => window.open(release.link, "_blank")}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<h4 className="font-semibold text-sm leading-tight flex-1 text-foreground">
|
||||
{release.title}
|
||||
</h4>
|
||||
{release.isPrerelease && (
|
||||
<span className="text-xs bg-yellow-600 text-yellow-100 px-2 py-1 rounded ml-2 flex-shrink-0 font-medium">
|
||||
{t("common.preRelease")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-foreground-secondary mb-2 leading-relaxed">
|
||||
{formatDescription(release.description)}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center text-xs text-muted-foreground">
|
||||
<span>
|
||||
{new Date(release.pubDate).toLocaleDateString()}
|
||||
</span>
|
||||
{release.assets.length > 0 && (
|
||||
<>
|
||||
<span className="mx-2">•</span>
|
||||
<span>
|
||||
{release.assets.length} asset
|
||||
{release.assets.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{releases && releases.items.length === 0 && !loading && (
|
||||
<Alert className="bg-elevated border-edge text-foreground-secondary">
|
||||
<AlertTitle className="text-foreground-secondary">
|
||||
{t("common.noReleases")}
|
||||
</AlertTitle>
|
||||
<AlertDescription className="text-muted-foreground">
|
||||
{t("common.noReleasesFound")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user