mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-16 05:13:36 +00:00
v2.1.0 (#711)
* feat: enhance terminal theme preview and persistence (#637) - Refactor Terminal.tsx to optimize theme update logic and eliminate flashes - Implement localStorage persistence for terminal themes per host - Fix hover preview to redraw buffer content instantly - Ensure theme preferences survive cookie clears Co-authored-by: Gemini CLI <gemini@cli.local> * fix: update darwin platform identifier to osx (#626) * feat: implement SSH algorithms mapping and refactor cipher usage across SSH modules (#627) * feat: enhance WebSocket connection handling for embedded mode (#628) * fix(auth): pass JWT token via URL param for Electron/mobile OIDC callback (#630) The OIDC callback redirect did not include the JWT token as a URL parameter for desktop/mobile device types, causing Electron and React Native webviews to have jwt = undefined after login. Closes Termix-SSH/Support#562 * fix: remove hardcoded version number from dashboard (#632) The version text was initialized to "v1.8.0" which displayed incorrect version on the dashboard before the API response. Changed to empty string so it shows nothing until the real version is fetched. Closes Termix-SSH/Support#550 * fix: admin role toggle showing incorrect state after update (#633) After successfully toggling admin status, onSuccess() closes the dialog and clears the user reference, but onOpenChange(true) then reopens the dialog with null user, causing isAdmin state to not sync properly. Removed the redundant dialog reopen after success - let onSuccess handle the cleanup normally. Closes Termix-SSH/Support#549 * fix: allow disabling password login when OIDC is configured via env vars (#634) The admin OIDC config endpoint only checked the database for OIDC configuration, ignoring environment variables. This caused the frontend to incorrectly block disabling password login when OIDC was configured via OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, etc. Now falls back to getOIDCConfigFromEnv() when no database config exists, matching the behavior of the public /oidc-config endpoint. Closes Termix-SSH/Support#561 * fix: sync snippet selected terminals count when tabs are closed (#635) selectedSnippetTabIds was not cleaned up when terminal tabs were closed, causing the snippet dialog to show stale terminal count. Added useEffect to filter out IDs of closed tabs. Closes Termix-SSH/Support#534 * feature: toggle history globally (#636) * Fix RDP audio output and dynamic session resize (#625) Co-authored-by: AllX <contact@alexmaftei.com> * fix: check connection state before fallback exec in file manager (#644) * Update sha256 value for v2.0.0 universal dmg (#629) * fix: check connection state before fallback exec in file manager When SFTP operations fail and tryFallbackMethod is called, the SSH client may already be disconnected. Calling client.exec() on a disconnected client throws an unhandled exception that crashes the backend process. Added connection state check at the start of all three tryFallbackMethod closures (listFiles, writeFile, uploadFile). Closes Termix-SSH/Support#451 --------- Co-authored-by: Razvan Aurariu <38325118+rzv-me@users.noreply.github.com> * fix: restrict postMessage targetOrigin to prevent JWT leakage (#645) * Update sha256 value for v2.0.0 universal dmg (#629) * fix: restrict postMessage targetOrigin to prevent JWT leakage Multiple postMessage calls used wildcard "*" as targetOrigin, allowing any parent window to intercept JWT tokens if Termix is embedded in an iframe. Changes: - main-axios.ts: Only send postMessage in Electron iframe context (added isElectron() check), use window.location.origin as target - Auth.tsx: Replace "*" with window.location.origin for all three AUTH_SUCCESS postMessage calls (already gated by isInElectronWebView) - ElectronLoginForm.tsx: Use server URL origin for iframe postMessage, fall back to "*" only if origin parsing fails --------- Co-authored-by: Razvan Aurariu <38325118+rzv-me@users.noreply.github.com> * fix: stats monitoring resolves SSH key from credential privateKey field (#643) * Update sha256 value for v2.0.0 universal dmg (#629) * fix: stats monitoring resolves SSH key from credential privateKey field When loading credentials for status monitoring, only the `key` field was checked but not `privateKey`. The ssh_credentials table has both fields and some credentials store the key in `privateKey`. This caused stats polling to fail with auth errors for key-based credentials. Closes Termix-SSH/Support#429 --------- Co-authored-by: Razvan Aurariu <38325118+rzv-me@users.noreply.github.com> * feat: add collapse/expand all button for host manager folders (#642) * Update sha256 value for v2.0.0 universal dmg (#629) * feat: add collapse/expand all button for host manager folders All folders were always auto-expanded with no way to collapse them all at once. Added a toggle button in the toolbar that collapses or expands all folder accordions. Icon switches between ChevronsDownUp (collapse) and ChevronsUpDown (expand) to indicate current action. Closes Termix-SSH/Support#488 --------- Co-authored-by: Razvan Aurariu <38325118+rzv-me@users.noreply.github.com> * fix: prevent invalid SSH key from crashing stats polling loop (#640) * Update sha256 value for v2.0.0 universal dmg (#629) * fix: prevent invalid SSH key from crashing stats polling loop Two fixes: 1. Add .catch() to pollHostMetrics() call inside setInterval to prevent unhandled promise rejections from crashing the process 2. Add "Invalid SSH key format" to the auth failure error patterns in collectMetrics so it's properly tracked instead of re-thrown Closes Termix-SSH/Support#478 --------- Co-authored-by: Razvan Aurariu <38325118+rzv-me@users.noreply.github.com> * fix: revoke all sessions when password is changed or reset (#647) * Update sha256 value for v2.0.0 universal dmg (#629) * fix: revoke all sessions when password is changed or reset logoutUser() without sessionId only cleared in-memory crypto state but did not delete session records from the database. This meant old JWT tokens remained valid after password change/reset. Now deletes all session records for the user when no specific sessionId is provided, which is the code path used by password reset and password change handlers. --------- Co-authored-by: Razvan Aurariu <38325118+rzv-me@users.noreply.github.com> * fix: isolate RDP keyboard input to active tab (#663) * Update sha256 value for v2.0.0 universal dmg (#629) * fix: disable RDP keyboard input when tab is not visible --------- Co-authored-by: Razvan Aurariu <38325118+rzv-me@users.noreply.github.com> * Fix clipboard paste browser popup (#667) * feat: switch to adjacent tab when closing current tab (#661) * Update sha256 value for v2.0.0 universal dmg (#629) * feat: switch to adjacent tab when closing current tab Previously closing the current tab always switched to the first remaining tab (often Dashboard). Now switches to the adjacent tab — the next one in order, or the previous if the closed tab was last. This matches the tab-close behavior of browsers and IDEs. Closes Termix-SSH/Support#606 --------- Co-authored-by: Razvan Aurariu <38325118+rzv-me@users.noreply.github.com> * fix: add auth token to database export/import in Electron app (#664) * Update sha256 value for v2.0.0 universal dmg (#629) * fix: add Bearer token to database export/import requests in Electron --------- Co-authored-by: Razvan Aurariu <38325118+rzv-me@users.noreply.github.com> * Fix WebSocket reconnection and add connection lost overlay (#668) * fix: skip metrics collection for hosts with authType none or opkssh (#639) * Update sha256 value for v2.0.0 universal dmg (#629) * fix: skip metrics collection for hosts with authType none or opkssh supportsMetrics() only checked connectionType but ignored authType. Hosts configured with Authentication: None (e.g. Tailscale SSH) or opkssh would trigger SSH metrics polling, causing repeated auth failures since no credentials are available. Closes Termix-SSH/Support#515 --------- Co-authored-by: Razvan Aurariu <38325118+rzv-me@users.noreply.github.com> * fix: align cookie maxAge with JWT expiration to prevent early logout (#658) * Update sha256 value for v2.0.0 universal dmg (#629) * fix: align cookie maxAge with JWT expiration to prevent early logout The JWT cookie maxAge for regular (non-rememberMe) logins was set to 2 hours, while the JWT token itself was valid for 24 hours. After 2 hours the cookie expired and the user was logged out, even during active SSH sessions. Changed cookie maxAge from 2h to 24h for regular logins to match the JWT expiration. Affects both password login and OIDC login paths. Closes Termix-SSH/Support#595 Closes Termix-SSH/Support#583 --------- Co-authored-by: Razvan Aurariu <38325118+rzv-me@users.noreply.github.com> * fix: remove sensitive data from log output (#649) - Password reset: stop logging the 6-digit reset code in plaintext. The code is still stored in the settings table for retrieval. - Password reset: return identical response for non-existent users and OIDC users to prevent username enumeration. - OPKSSH callback: remove URL, query params, and forwarded headers from log output to prevent token/code leakage. * feat: display file owner and group in file manager list view (#654) * Update sha256 value for v2.0.0 universal dmg (#629) * feat: display file owner and group in file manager list view Added an Owner column to the file manager list view showing owner:group for each file. The data was already returned by the backend (SFTP returns uid/gid, ls fallback returns usernames) but not displayed. Column is hidden on small screens (md:block) to avoid crowding. Closes Termix-SSH/Support#603 --------- Co-authored-by: Razvan Aurariu <38325118+rzv-me@users.noreply.github.com> * fix: prevent file manager from showing stale directory contents (#655) * Update sha256 value for v2.0.0 universal dmg (#629) * fix: prevent file manager from showing stale directory contents Two issues caused the file browser to get stuck showing outdated directory contents after folder operations: 1. handleRefreshDirectory could be blocked by a lingering isLoading state from a previous request. Now force-resets loading state before initiating the refresh. 2. debouncedLoadDirectory skipped requests when the path hadn't changed (path === lastPathChangeRef), but after create/move/delete operations the path stays the same while contents change. Added force parameter to bypass the path equality check. Closes Termix-SSH/Support#599 --------- Co-authored-by: Razvan Aurariu <38325118+rzv-me@users.noreply.github.com> * fix: fallback to default layout when dashboard preferences lack cards (#652) * Update sha256 value for v2.0.0 universal dmg (#629) * fix: fallback to default layout when dashboard preferences lack cards getDashboardPreferences may return null, empty object, or an object without a cards array (e.g. when behind a reverse proxy that alters the response, or on first load for a new user). This caused layout.cards.filter() to throw, leaving the dashboard as a black screen after login. Now validates that the response has a cards array before using it, falling back to DEFAULT_LAYOUT otherwise. --------- Co-authored-by: Razvan Aurariu <38325118+rzv-me@users.noreply.github.com> * fix: bind SSH sessions to userId and verify ownership on access (#650) * Update sha256 value for v2.0.0 universal dmg (#629) * fix: bind SSH sessions to userId and verify ownership on access SSHSession objects in file-manager and docker did not store userId, allowing any authenticated user to operate on another user's session if they knew the sessionId. Changes: - Added userId field to SSHSession interface in both modules - Store userId when creating sessions (connect, TOTP, Warpgate paths) - Added verifySessionOwnership() helper in file-manager - Applied ownership checks to sudo-password, status, keepalive, listFiles endpoints in file-manager - Applied ownership check to keepalive endpoint in docker - Session creation in docker now stores userId in all 3 paths --------- Co-authored-by: Razvan Aurariu <38325118+rzv-me@users.noreply.github.com> * fix: use cookie-based auth for WebSocket instead of URL token (#646) * Update sha256 value for v2.0.0 universal dmg (#629) * fix: use cookie-based auth for WebSocket instead of URL token JWT tokens in WebSocket URL query strings are exposed in nginx access logs, browser history, and proxy logs. Backend: terminal and docker-console WebSocket servers now read JWT from the cookie header as fallback when no URL token is provided. Frontend: desktop terminal and docker console no longer append token to WebSocket URL, relying on cookies sent automatically by the browser. Mobile and Guacamole WebSocket connections are unchanged as they may not have cookie access. --------- Co-authored-by: Razvan Aurariu <38325118+rzv-me@users.noreply.github.com> * fix: prevent long Docker container names from overflowing card bounds (#653) * Update sha256 value for v2.0.0 universal dmg (#629) * fix: prevent long Docker container names from overflowing card bounds Container names were not constrained to the card width, causing long names to overlay adjacent container cards. Added overflow-hidden and min-w-0 to the Card root element so the existing truncate class on CardTitle takes effect within the grid layout. Closes Termix-SSH/Support#601 --------- Co-authored-by: Razvan Aurariu <38325118+rzv-me@users.noreply.github.com> * fix: preserve original timestamps in SSH login statistics (#657) * Update sha256 value for v2.0.0 universal dmg (#629) * fix: preserve original timestamps in SSH login statistics Failed login attempts showed the current time instead of the actual attempt time. Two issues: 1. auth.log dates (e.g. "Mar 15 10:23:45") were parsed with a format that could fail on some platforms, falling back to new Date() which gives the current time. Changed to a more reliable format ("Mar 15, 2026 10:23:45") and fall back to the raw string instead of the current time. 2. Dates from previous years (e.g. December logs viewed in January) were assigned the current year, producing future dates. Now checks if the parsed date is in the future and subtracts a year. Also fixed the same fallback issue for successful login timestamps. Closes Termix-SSH/Support#570 --------- Co-authored-by: Razvan Aurariu <38325118+rzv-me@users.noreply.github.com> * fix: remove plaintext credentials from internal host API responses (#651) * Update sha256 value for v2.0.0 universal dmg (#629) * fix: remove plaintext credentials from internal host API responses /db/host/internal and /db/host/internal/all returned password, key, keyPassword, and autostart credentials in plaintext, protected only by a static INTERNAL_AUTH_TOKEN. If the token leaked, all SSH credentials would be exposed. Changes: - Stripped password, key, keyPassword, autostartPassword, autostartKey, autostartKeyPassword from both internal API responses - Only return hostId, userId, and non-sensitive connection metadata - Updated tunnel.ts endpoint resolution to use resolveHostById() for credentials instead of reading from HTTP response - Autostart tunnel initialization no longer receives credentials from internal API, relying on server-side DB resolution at connect time --------- Co-authored-by: Razvan Aurariu <38325118+rzv-me@users.noreply.github.com> * fix: default keyType to auto instead of blocking host update (#641) * Update sha256 value for v2.0.0 universal dmg (#629) * fix: default keyType to auto instead of blocking host update When editing a host with key authentication, missing keyType value caused form validation to fail silently, preventing the Update Host button from saving changes. Now defaults keyType to "auto" instead of raising a validation error. Closes Termix-SSH/Support#510 --------- Co-authored-by: Razvan Aurariu <38325118+rzv-me@users.noreply.github.com> * fix: downgrade credential migration errors to warnings (#659) * Update sha256 value for v2.0.0 universal dmg (#629) * fix: downgrade credential migration errors to warnings Credential migration failures during login (e.g. corrupted encrypted data from older versions) were logged at ERROR level, causing alarm in Docker logs. The migration is non-blocking — login succeeds regardless — so these should be warnings. Changed individual credential decryption failures and overall migration failures from error to warn level. Also improved log messages to be more descriptive. Closes Termix-SSH/Support#541 --------- Co-authored-by: Razvan Aurariu <38325118+rzv-me@users.noreply.github.com> * feat: add Select All / Deselect All buttons for snippet terminal selection (#660) * Update sha256 value for v2.0.0 universal dmg (#629) * feat: add Select All / Deselect All buttons for snippet terminal selection When running snippets on many terminals, users had to click each terminal individually. Added Select All and Deselect All buttons above the terminal list for batch selection. Closes Termix-SSH/Support#535 --------- Co-authored-by: Razvan Aurariu <38325118+rzv-me@users.noreply.github.com> * fix: admin user list not reading OIDC and admin status correctly (#665) * Update sha256 value for v2.0.0 universal dmg (#629) * fix: align admin user list field names with API response (camelCase) --------- Co-authored-by: Razvan Aurariu <38325118+rzv-me@users.noreply.github.com> * fix: enable clipboard paste from host to RDP session (#666) * Update sha256 value for v2.0.0 universal dmg (#629) * fix: sync host clipboard to RDP session on tab focus and mouse enter --------- Co-authored-by: Razvan Aurariu <38325118+rzv-me@users.noreply.github.com> * fix: validate containerId and timestamp params to prevent command injection (#648) * Update sha256 value for v2.0.0 universal dmg (#629) * fix: validate containerId and timestamp params to prevent command injection Docker API endpoints passed containerId, since, and until parameters directly into shell commands via SSH exec without validation. An authenticated user with Docker access could inject arbitrary shell commands on the remote host. Added Express param middleware to validate containerId against ^[a-zA-Z0-9][a-zA-Z0-9_.-]*$ for all 9 endpoints. Also validate since/until timestamps in the logs endpoint against a strict regex. --------- Co-authored-by: Razvan Aurariu <38325118+rzv-me@users.noreply.github.com> * Merge commit from fork * Merge commit from fork Replace double-quoted shell string interpolation with single-quoted escaping in extractArchive and compressFiles endpoints. Double quotes allow $(command) substitution, enabling arbitrary command execution on the remote SSH host via crafted archive paths or file names. Now uses the same single-quote escaping pattern used by all other file manager operations in this file. * Merge commit from fork CORS: Replace permissive origin checks (any http/https) across all 6 microservices with a shared cors-config module that only allows: - Same-origin requests (derived from Host header) - Configured origins via CORS_ALLOWED_ORIGINS env var - Dev origins (localhost:5173) Docker console: Validate containerId against ^[a-zA-Z0-9][a-zA-Z0-9_.-]*$ and restrict shell to allowlist [bash, sh, ash, zsh] to prevent command injection via WebSocket messages. * Merge commit from fork * refactor: add shared host-resolver for server-side credential resolution Creates resolveHostById() utility that loads a host from DB and resolves its credentials entirely server-side. This will be used by connection modules to avoid receiving credentials from the frontend. Also adds checkHostAccess() for permission validation. * fix: strip sensitive credentials from host API responses Remove password, key, keyPassword, sudoPassword, and other credential fields from GET /db/host and GET /db/host/:id responses. Add boolean indicators (hasPassword, hasKey, hasSudoPassword) so the frontend knows capabilities without seeing actual values. Add GET /db/host/:id/password endpoint for the copy-password feature to fetch a specific password on demand. * refactor: docker-console resolves credentials server-side by hostId Instead of receiving the full hostConfig with credentials from the frontend WebSocket message, docker-console now extracts hostId and uses resolveHostById() to load credentials from the database. Also validates containerId format and restricts shell to allowlist. * feat: add getHostPassword API and update copy-password to use it Add getHostPassword() frontend function that calls the new server-side password endpoint instead of reading from the host object. Update Tab component to use boolean indicators (hasPassword, hasKey, hasSudoPassword) from the sanitized API response, with backward compatibility for the old response format. Add boolean indicator fields to Host type definition. * refactor: file-manager resolves credentials server-side via host-resolver When frontend doesn't provide password/sshKey (due to API stripping), file-manager now uses resolveHostById() to load credentials from DB. Falls back to provided credentials for backward compatibility. * refactor: terminal resolves credentials server-side via host-resolver When frontend doesn't provide password/key (due to API stripping), terminal now uses resolveHostById() to load credentials from DB. Preserves backward compatibility with reconnect_with_credentials where user provides credentials interactively. * refactor: tunnel resolves source credentials server-side via host-resolver When frontend doesn't provide sourcePassword/sourceSSHKey (due to API stripping), tunnel now uses resolveHostById() to load credentials from DB for both the connect and cleanup paths. * fix: terminal sudo auto-fill fetches password from server on demand After credentials are stripped from API responses, hostConfig.password is no longer available. Sudo auto-fill now checks boolean indicators to show the prompt, then fetches the actual password via getHostPassword API only when the user confirms the auto-fill action. * fix: host editor fetches full credentials via export API for editing After credentials are stripped from the host list API, the editor would show empty password/key fields. Now uses exportSSHHostWithCredentials() to fetch the full host data with credentials when opening the editor. Applies to all paths: direct edit, sidebar click, and external navigation. --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * feat: update jsdoc comments for /host instead of /ssh * fix: align OIDC login cookie maxAge with JWT expiration (2h → 24h) (#671) * fix: persist OIDC JWT token to localStorage in Electron app (#672) * fix: add error toast for empty file download and remove stray prop in tab bar (#674) * fix: prevent server status failure from blocking host list loading (#673) * fix: show server config dialog on first launch instead of auto-selecting embedded (#675) * fix: remove unnecessary registration disabled toast on login page (#670) * fix: add clipboard fallback and toast feedback for Copy Password button (#669) Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * fix: allow file origin for packaged Electron desktop app (#676) * Add AWS logo to README * fix: allow file origin for packaged Electron desktop app --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * fix: backend compliation errors * feat: remove theme selector from nav bar * fix: validate and fallback credentialId during JSON host bulk import (#677) * Add AWS logo to README * fix: validate and fallback credentialId during JSON host bulk import --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * fix: await tunnel cleanup to prevent new connection from being killed (#678) * Add AWS logo to README * fix: await tunnel cleanup before creating new connection to prevent race condition --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * fix: disable keyboard-interactive when host auth is set to None (#682) * Add AWS logo to README * fix: disable keyboard-interactive auth when host authType is none --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * fix: use carriage return for mobile startup snippet execution (#680) * Add AWS logo to README * fix: use carriage return for mobile startup snippet execution --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * fix: prevent file upload from crashing backend on permission denied (#681) * Add AWS logo to README * fix: add stderr error handlers and connection check to prevent upload crash --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * fix: add missing stream error handlers in Docker console (#684) * Add AWS logo to README * fix: add missing stream error handlers in Docker console to prevent crashes --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * fix: use carriage return for snippet execution to support PowerShell (#679) * Add AWS logo to README * fix: use carriage return instead of line feed for snippet and command execution --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * fix: restrict remaining postMessage targetOrigin from wildcard to origin (#685) * Add AWS logo to README * fix: restrict postMessage targetOrigin to prevent JWT token leakage --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * feat: add SESSION_TIMEOUT_HOURS environment variable for customizable session duration (#662) * feat: add SESSION_TIMEOUT_HOURS environment variable for session duration Session timeout was hardcoded to 24h (JWT) and 2h (cookie). Now both are configurable via SESSION_TIMEOUT_HOURS env var (default: 24). Set in docker-compose.yml: environment: SESSION_TIMEOUT_HOURS: "72" Also fixes the cookie maxAge mismatch (was 2h, now matches JWT). Remember Me sessions remain at 30 days regardless of this setting. Closes Termix-SSH/Support#609 Closes Termix-SSH/Support#595 * refactor: move session timeout from env var to Admin Settings Replace SESSION_TIMEOUT_HOURS environment variable with a database-backed setting configurable from Admin Settings UI. Default remains 24 hours. --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * feat: add port knocking support for SSH connections (#694) * Add AWS logo to README * feat: add port knocking support for SSH connections Send TCP/UDP knock packets to a configurable port sequence before establishing SSH connections. Configured per-host in the host editor under a new Port Knocking accordion section. Supports custom protocol (TCP/UDP) and delay between knocks. Knocking failures don't block the connection attempt. Closes Termix-SSH/Support#524 --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * feat: add Wake-on-LAN support for hosts (#696) * Add AWS logo to README * feat: add Wake-on-LAN support for hosts --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * feat: add export all hosts as JSON (#688) * Add AWS logo to README * feat: add export all hosts as JSON Add GET /ssh/db/hosts/export endpoint and Export All button in the host manager toolbar. Exported format is compatible with existing bulk import. Includes sensitive data warning confirmation before download. Closes Termix-SSH/Support#582 --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * feat: add snippet sharing with users and roles (#691) * Add AWS logo to README * feat: add snippet sharing with users and roles Add snippetAccess table and RBAC routes for sharing snippets, following the same pattern as host sharing. Users can share snippets with other users or roles via a share dialog. Shared snippets appear in a dedicated section in the snippets sidebar as read-only with copy support. Closes Termix-SSH/Support#474 --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * fix: auth errors and ws connection errors in dev env * feat: add opt-in tmux integration for persistent terminal sessions (#683) * Add backend tmux integration with native scrollback Detect tmux on remote hosts via SSH exec channel, auto-attach or create sessions with mouse mode, history-limit 50000, set-clipboard on, and allow-passthrough on for native scrollback, OSC 52 clipboard sync, and safe paste handling. Use && exit so the shell only closes if tmux started successfully. Query session name after auto-creation. * Add frontend tmux session handling and picker dialog Desktop: handle tmux WebSocket messages, show session picker with window count, attached clients, and last activity when multiple sessions exist. Toast warning when Auto-tmux is enabled but tmux is missing on remote. Mobile: auto-attach to first available session. All user-facing strings are localized via i18n. * Add Auto-tmux toggle in host settings and i18n strings Per-host opt-in toggle following the existing autoMosh pattern. English i18n strings for all tmux-related UI elements. * Show a toast hint on first drag inside a tmux session When the user drags the mouse inside a tmux-wrapped terminal, show a localized toast ("Adjust selection and press Enter to copy") once per tab session. Purely frontend so the hint is i18n-ready and doesn't pollute the tmux status bar. * chore: increment ver * feat: add right-click context menu in terminal to open file manager (#695) * Add AWS logo to README * feat: add right-click context menu in terminal to open file manager --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: LukeGus <lukegustafson06@gmail.com> * Fix/desktop guac connect flow (#687) * fix: use direct guacamole websocket port in embedded electron mode * Fix desktop remote token flow for redacted hosts --------- Co-authored-by: LukeGus <lukegustafson06@gmail.com> Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * fix: allow file:// origin in shared cors middleware (#686) Co-authored-by: LukeGus <lukegustafson06@gmail.com> Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * feat: add command history toggle and sensitive command filtering (#693) * Add AWS logo to README * feat: add command history toggle and sensitive command filtering Add on/off toggle for command history recording in User Profile settings. Commands matching sensitive patterns (passwords, secrets, tokens, API keys) are automatically filtered on both frontend and backend, never stored. Closes Termix-SSH/Support#461 --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: LukeGus <lukegustafson06@gmail.com> * OPKSSH proxy, certificate auth, and inline provider selection (#692) * fix: OPKSSH proxy integration for remote deployments Migrate proxy routes from /ssh/ to /host/ prefix. Use session's remote redirect URI for callback path instead of hardcoded /login-callback. Add OAuth callback fallback for external browser redirects with state parameter binding to prevent cross-session mixup. Reject cookie-less callbacks that can't be identified. * fix: implement OPKSSH certificate authentication for ssh2 Extract OPKSSH certificate auth into shared module that works around ssh2's lack of native certificate support: grafts cert blob onto parsed key, wraps ECDSA sign() for DER-to-SSH conversion, and patches Protocol.authPK for correct algorithm. Applied across terminal, file-manager, docker, and server-stats. Removes legacy temp file approach in favor of in-memory keys. * feat: inline OPKSSH provider selection in dialog Parse OIDC provider aliases and issuers from config.yml using js-yaml and send them to the frontend via the WebSocket message. The dialog renders a "Sign in with {Provider}" button per provider, opening the browser directly to the OAuth flow and skipping the external chooser page. Falls back to the existing "Open in Browser" behavior when providers aren't available. --------- Co-authored-by: LukeGus <lukegustafson06@gmail.com> Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * feat: add configurable log level via Admin Settings (#690) * Add AWS logo to README * feat: add configurable log level via Admin Settings Add log verbosity control (debug/info/warn/error) through Admin Settings UI and LOG_LEVEL environment variable. Database setting takes precedence. Changes take effect immediately without restart. Closes Termix-SSH/Support#499 --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: LukeGus <lukegustafson06@gmail.com> * feat: add reconnect button for disconnected SSH sessions (#689) * Add AWS logo to README * feat: add reconnect button for disconnected SSH sessions When an SSH connection drops, show a reconnect overlay instead of closing the tab. Users can click Reconnect to re-establish the connection or Close to dismiss. Also triggers after auto-reconnect attempts are exhausted. Closes Termix-SSH/Support#596 Closes Termix-SSH/Support#542 Closes Termix-SSH/Support#604 --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: LukeGus <lukegustafson06@gmail.com> * feat: fix port knocking and mac address not saving to backend * fix: fix snippets table not being created * fix: command history logic error, snippet sharing failing and improved UI for it * Reset stale trust state when TOTP is enabled (#697) * Add AWS logo to README * Reset stale trust state when TOTP is enabled Enablement now updates the user record, revokes existing sessions, clears trusted devices, and persists the result using the existing route flow. The change stays narrow and avoids introducing a one-off auth-manager wrapper or changing the save helper contract. Constraint: Keep the change close to the existing auth route and avoid extra abstractions Rejected: Keep the dedicated auth-manager helper | it was single-use and widened the surface area Confidence: high Scope-risk: narrow Directive: If this behavior changes again, keep the reset logic at the route boundary unless another caller appears Tested: tsc -p tsconfig.node.json --pretty false, git diff --check Not-tested: full frontend build --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * fix: version disabling in user profile not properly disabling and mac address not saving in host manager * feat: improved reconnect ui for terminals * feat: improve right click copy/paste * fix: some themes not including all the needed colors * chore: remove donate button * fix: schema errors, password logic errors, sswapped line order in host.ts * fix: cors being too strict * fix: passphrase erorr and tmux error * fix: guacd improvements, ui bugs, connection problems, etc * fix: shrink image, opkssh fixes, desktop ui changes * feat: dont require password to export and fixed export failures * feat: opkssh fixes, guacamole ui fixes, update readme for release * fix: tabs closing fast causiung no tab to be active and electron header persistance issue * fix: guacd params getting malformed * fix: desktop app header persistance * fix: desktop app header persistance * feat: desktop app not logging in * feat: improve okpkssh implementation and fix redirect uri bug * fix: opkssh redirect * fix: backend hang (ongoing) * fix: tunnels not being able to be saved * fix: c2s networking stability (activity/log, metrics, status) (#701) - /activity/log: the trim-over-100 path called SimpleDBOps.delete with a userId instead of a where clause and 500'd every call. Use inArray on the actual ids, best-effort (trim failures don't fail the log). - /metrics/register-viewer: now a graceful 200 no-op when the host can't be found, metrics are disabled, or the connection type doesn't support metrics. Any internal error is reported as skipped instead of a 500, and the fire-and-forget startMetricsForHost can no longer leak an unhandled rejection. - /metrics/🆔 treat 404 as "no metrics yet / disabled" rather than an error. Dashboard skips hosts known to be offline before asking for metrics. - /status: retry with 2s/5s/8s timeouts and 3s/5s pauses (23s worst case, fits in the 30s poll cycle) before surfacing a network error; intermediate attempts stay silent. - Replace the blocking "connection lost" overlay with a persistent, non-dismissible toast ("Unstable server connection, recovering…") carrying a Reload action. Users keep full access to the UI; if they try to connect to a host and it fails, that's on them. The toast clears to the usual "Server connection restored" success toast on the next healthy API response. The toast triggers on any of ERR_NETWORK / ECONNREFUSED / ECONNABORTED / ECONNRESET / ETIMEDOUT / ERR_CANCELED, "Request aborted"/timeout messages, or database/drizzle/sqlite errors. * fix(guacamole): honor host RDP DPI in client and tab params (#703) * fix(file-manager): preserve remote file mode after SFTP write (#704) * fix(admin): target admin toggle APIs by user id (#705) * fix(terminal): resolve Electron SSH websocket URL from server config (#706) * fix(snippets): accept snippets or legacy updates in reorder API (#707) * fix(admin): fetch users list when users tab is opened (#708) * fix(docker): improve list layout and overflow for container cards (#709) * fix(guacamole): gate keyboard capture on focus and visibility (#710) * fix: remove snippets test file * chore: run linter * fix: increase macos memory for building * Potential fix for pull request finding 'Unused variable, import, function or class' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --------- Co-authored-by: Will Moore <will@clevercode.ca> Co-authored-by: Gemini CLI <gemini@cli.local> Co-authored-by: Chakyiu <49145984+Chakyiu@users.noreply.github.com> Co-authored-by: Jozef Rebjak <jozefrebjak@icloud.com> Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> Co-authored-by: Daniel Quinan <68088383+DanielQuinan@users.noreply.github.com> Co-authored-by: allxm4 <77125344+allxm4@users.noreply.github.com> Co-authored-by: AllX <contact@alexmaftei.com> Co-authored-by: Razvan Aurariu <38325118+rzv-me@users.noreply.github.com> Co-authored-by: Dylan Ysmal <Xenthys@users.noreply.github.com> Co-authored-by: vvbbnn00 <vvbbnn00@foxmail.com> Co-authored-by: Lbubeer <Lbubeer1@gmail.com> Co-authored-by: Dominik <DL6ER@users.noreply.github.com> Co-authored-by: LukeGus <lukegustafson06@gmail.com> Co-authored-by: TerrifiedBug <35064668+TerrifiedBug@users.noreply.github.com> Co-authored-by: JIHUN <asdfgl98@naver.com> Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
This commit is contained in:
@@ -52,10 +52,13 @@ function AppContent({
|
||||
const { theme, setTheme } = useTheme();
|
||||
const [rightSidebarOpen, setRightSidebarOpen] = useState(false);
|
||||
const [rightSidebarWidth, setRightSidebarWidth] = useState(400);
|
||||
const [dbConnectionFailed, setDbConnectionFailed] = useState(false);
|
||||
|
||||
const isDarkMode =
|
||||
theme === "dark" ||
|
||||
theme === "dracula" ||
|
||||
theme === "gentlemansChoice" ||
|
||||
theme === "midnightEspresso" ||
|
||||
theme === "catppuccinMocha" ||
|
||||
(theme === "system" &&
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||
const lineColor = isDarkMode ? "#151517" : "#f9f9f9";
|
||||
@@ -65,12 +68,29 @@ function AppContent({
|
||||
const lastAltPressTime = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const handleDatabaseConnectionLost = () => {
|
||||
setDbConnectionFailed(true);
|
||||
const DEGRADED_TOAST_ID = "db-connection-degraded";
|
||||
|
||||
const handleDatabaseConnectionDegraded = () => {
|
||||
// Non-blocking, non-dismissible status toast that stays visible until
|
||||
// connectivity is recovered. A Reload action lets users force-refresh
|
||||
// the page if they want to, but the app itself remains fully usable.
|
||||
toast.loading(
|
||||
t("common.connectionDegraded", "Server connection lost, recovering…"),
|
||||
{
|
||||
id: DEGRADED_TOAST_ID,
|
||||
duration: Infinity,
|
||||
dismissible: false,
|
||||
closeButton: false,
|
||||
action: {
|
||||
label: t("common.reload", "Reload"),
|
||||
onClick: () => window.location.reload(),
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleDatabaseConnectionRestored = () => {
|
||||
setDbConnectionFailed(false);
|
||||
const handleDatabaseConnectionDegradedCleared = () => {
|
||||
toast.dismiss(DEGRADED_TOAST_ID);
|
||||
toast.success(t("common.backendReconnected"));
|
||||
};
|
||||
|
||||
@@ -79,27 +99,28 @@ function AppContent({
|
||||
};
|
||||
|
||||
dbHealthMonitor.on(
|
||||
"database-connection-lost",
|
||||
handleDatabaseConnectionLost,
|
||||
"database-connection-degraded",
|
||||
handleDatabaseConnectionDegraded,
|
||||
);
|
||||
dbHealthMonitor.on(
|
||||
"database-connection-restored",
|
||||
handleDatabaseConnectionRestored,
|
||||
"database-connection-degraded-cleared",
|
||||
handleDatabaseConnectionDegradedCleared,
|
||||
);
|
||||
dbHealthMonitor.on("session-expired", handleSessionExpired);
|
||||
|
||||
return () => {
|
||||
dbHealthMonitor.off(
|
||||
"database-connection-lost",
|
||||
handleDatabaseConnectionLost,
|
||||
"database-connection-degraded",
|
||||
handleDatabaseConnectionDegraded,
|
||||
);
|
||||
dbHealthMonitor.off(
|
||||
"database-connection-restored",
|
||||
handleDatabaseConnectionRestored,
|
||||
"database-connection-degraded-cleared",
|
||||
handleDatabaseConnectionDegradedCleared,
|
||||
);
|
||||
dbHealthMonitor.off("session-expired", handleSessionExpired);
|
||||
toast.dismiss(DEGRADED_TOAST_ID);
|
||||
};
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
@@ -156,8 +177,9 @@ function AppContent({
|
||||
if (hostIdentifier) {
|
||||
const openTerminal = async () => {
|
||||
try {
|
||||
const { getSSHHostById, getSSHHosts } =
|
||||
await import("@/ui/main-axios.ts");
|
||||
const { getSSHHostById, getSSHHosts } = await import(
|
||||
"@/ui/main-axios.ts"
|
||||
);
|
||||
let host = null;
|
||||
|
||||
if (/^\d+$/.test(hostIdentifier)) {
|
||||
@@ -188,8 +210,12 @@ function AppContent({
|
||||
}
|
||||
}, [addTab]);
|
||||
|
||||
const isCheckingAuth = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const checkAuth = () => {
|
||||
if (isCheckingAuth.current) return;
|
||||
isCheckingAuth.current = true;
|
||||
setAuthLoading(true);
|
||||
getUserInfo()
|
||||
.then((meRes) => {
|
||||
@@ -197,7 +223,6 @@ function AppContent({
|
||||
setIsAuthenticated(false);
|
||||
setIsAdmin(false);
|
||||
setUsername(null);
|
||||
localStorage.removeItem("jwt");
|
||||
} else {
|
||||
setIsAuthenticated(true);
|
||||
setIsAdmin(!!meRes.is_admin);
|
||||
@@ -209,8 +234,6 @@ function AppContent({
|
||||
setIsAdmin(false);
|
||||
setUsername(null);
|
||||
|
||||
localStorage.removeItem("jwt");
|
||||
|
||||
const errorCode = err?.response?.data?.code;
|
||||
if (errorCode === "SESSION_EXPIRED") {
|
||||
console.warn("Session expired - please log in again");
|
||||
@@ -218,6 +241,7 @@ function AppContent({
|
||||
})
|
||||
.finally(() => {
|
||||
setAuthLoading(false);
|
||||
isCheckingAuth.current = false;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -292,7 +316,7 @@ function AppContent({
|
||||
const showAdmin = currentTabData?.type === "admin";
|
||||
const showProfile = currentTabData?.type === "user_profile";
|
||||
|
||||
if (authLoading && !dbConnectionFailed) {
|
||||
if (authLoading) {
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 flex items-center justify-center"
|
||||
@@ -321,30 +345,6 @@ function AppContent({
|
||||
);
|
||||
}
|
||||
|
||||
if (dbConnectionFailed) {
|
||||
return (
|
||||
<div className="h-screen w-screen overflow-hidden bg-background">
|
||||
<div className="fixed inset-0 flex items-center justify-center z-[10000] bg-background">
|
||||
<Dashboard
|
||||
isAuthenticated={false}
|
||||
authLoading={false}
|
||||
onAuthSuccess={handleAuthSuccess}
|
||||
isTopbarOpen={isTopbarOpen}
|
||||
onSelectView={() => {}}
|
||||
initialDbError="Database connection failed"
|
||||
/>
|
||||
</div>
|
||||
<Toaster
|
||||
position="bottom-right"
|
||||
richColors={false}
|
||||
closeButton
|
||||
duration={5000}
|
||||
offset={20}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen w-screen overflow-hidden bg-background">
|
||||
<CommandPalette
|
||||
|
||||
@@ -68,9 +68,9 @@ export function AdminSettings({
|
||||
Array<{
|
||||
id: string;
|
||||
username: string;
|
||||
is_admin: boolean;
|
||||
is_oidc: boolean;
|
||||
password_hash?: string;
|
||||
isAdmin: boolean;
|
||||
isOidc: boolean;
|
||||
passwordHash?: string;
|
||||
}>
|
||||
>([]);
|
||||
const [usersLoading, setUsersLoading] = React.useState(false);
|
||||
@@ -80,9 +80,9 @@ export function AdminSettings({
|
||||
const [selectedUserForEdit, setSelectedUserForEdit] = React.useState<{
|
||||
id: string;
|
||||
username: string;
|
||||
is_admin: boolean;
|
||||
is_oidc: boolean;
|
||||
password_hash?: string;
|
||||
isAdmin: boolean;
|
||||
isOidc: boolean;
|
||||
passwordHash?: string;
|
||||
} | null>(null);
|
||||
|
||||
const [currentUser, setCurrentUser] = React.useState<{
|
||||
@@ -148,7 +148,6 @@ export function AdminSettings({
|
||||
console.warn("Failed to fetch current user info", err);
|
||||
}
|
||||
});
|
||||
fetchUsers();
|
||||
fetchSessions();
|
||||
}, []);
|
||||
|
||||
@@ -340,7 +339,15 @@ export function AdminSettings({
|
||||
<Separator className="p-0.25 w-full" />
|
||||
|
||||
<div className="px-6 py-4 overflow-auto thin-scrollbar">
|
||||
<Tabs defaultValue="registration" className="w-full">
|
||||
<Tabs
|
||||
defaultValue="registration"
|
||||
onValueChange={(value) => {
|
||||
if (value === "users") {
|
||||
fetchUsers();
|
||||
}
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList className="mb-4 bg-elevated border-2 border-edge">
|
||||
<TabsTrigger
|
||||
value="registration"
|
||||
|
||||
@@ -42,9 +42,9 @@ import {
|
||||
interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
is_admin: boolean;
|
||||
is_oidc: boolean;
|
||||
password_hash?: string;
|
||||
isAdmin: boolean;
|
||||
isOidc: boolean;
|
||||
passwordHash?: string;
|
||||
}
|
||||
|
||||
interface UserEditDialogProps {
|
||||
@@ -81,7 +81,7 @@ export function UserEditDialog({
|
||||
|
||||
useEffect(() => {
|
||||
if (open && user) {
|
||||
setIsAdmin(user.is_admin);
|
||||
setIsAdmin(user.isAdmin);
|
||||
loadRoles();
|
||||
}
|
||||
}, [open, user]);
|
||||
@@ -135,19 +135,18 @@ export function UserEditDialog({
|
||||
setAdminLoading(true);
|
||||
try {
|
||||
if (checked) {
|
||||
await makeUserAdmin(userToUpdate.username);
|
||||
await makeUserAdmin(userToUpdate.id);
|
||||
toast.success(
|
||||
t("admin.userIsNowAdmin", { username: userToUpdate.username }),
|
||||
);
|
||||
} else {
|
||||
await removeAdminStatus(userToUpdate.username);
|
||||
await removeAdminStatus(userToUpdate.id);
|
||||
toast.success(
|
||||
t("admin.adminStatusRemoved", { username: userToUpdate.username }),
|
||||
);
|
||||
}
|
||||
setIsAdmin(checked);
|
||||
onSuccess();
|
||||
onOpenChange(true);
|
||||
} catch (error) {
|
||||
console.error("Failed to toggle admin status:", error);
|
||||
toast.error(
|
||||
@@ -332,9 +331,9 @@ export function UserEditDialog({
|
||||
|
||||
const getAuthTypeDisplay = (): string => {
|
||||
if (!user) return "";
|
||||
if (user.is_oidc && user.password_hash) {
|
||||
if (user.isOidc && user.passwordHash) {
|
||||
return t("admin.dualAuth");
|
||||
} else if (user.is_oidc) {
|
||||
} else if (user.isOidc) {
|
||||
return t("admin.externalOIDC");
|
||||
} else {
|
||||
return t("admin.localPassword");
|
||||
@@ -344,7 +343,7 @@ export function UserEditDialog({
|
||||
if (!user) return null;
|
||||
|
||||
const showPasswordReset =
|
||||
allowPasswordLogin && (user.password_hash || !user.is_oidc);
|
||||
allowPasswordLogin && (user.passwordHash || !user.isOidc);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { Label } from "@/components/ui/label.tsx";
|
||||
import { PasswordInput } from "@/components/ui/password-input.tsx";
|
||||
import { Download, Upload } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
@@ -22,33 +20,8 @@ export function DatabaseSecurityTab({
|
||||
const [exportLoading, setExportLoading] = React.useState(false);
|
||||
const [importLoading, setImportLoading] = React.useState(false);
|
||||
const [importFile, setImportFile] = React.useState<File | null>(null);
|
||||
const [exportPassword, setExportPassword] = React.useState("");
|
||||
const [showPasswordInput, setShowPasswordInput] = React.useState(false);
|
||||
const [importPassword, setImportPassword] = React.useState("");
|
||||
|
||||
const requiresImportPassword = React.useMemo(
|
||||
() => !currentUser?.is_oidc,
|
||||
[currentUser?.is_oidc],
|
||||
);
|
||||
|
||||
const requiresExportPassword = React.useMemo(
|
||||
() => !currentUser?.is_oidc,
|
||||
[currentUser?.is_oidc],
|
||||
);
|
||||
|
||||
const handleExportDatabase = async () => {
|
||||
if (requiresExportPassword) {
|
||||
if (!showPasswordInput) {
|
||||
setShowPasswordInput(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!exportPassword.trim()) {
|
||||
toast.error(t("admin.passwordRequired"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setExportLoading(true);
|
||||
try {
|
||||
const isDev =
|
||||
@@ -66,15 +39,21 @@ export function DatabaseSecurityTab({
|
||||
? `http://localhost:30001/database/export`
|
||||
: `${window.location.protocol}//${window.location.host}${getBasePath()}/database/export`;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
if (isElectron()) {
|
||||
const token = localStorage.getItem("jwt");
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
headers,
|
||||
credentials: "include",
|
||||
body: JSON.stringify(
|
||||
requiresExportPassword ? { password: exportPassword } : {},
|
||||
),
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
@@ -94,15 +73,9 @@ export function DatabaseSecurityTab({
|
||||
document.body.removeChild(a);
|
||||
|
||||
toast.success(t("admin.databaseExportedSuccessfully"));
|
||||
setExportPassword("");
|
||||
setShowPasswordInput(false);
|
||||
} else {
|
||||
const error = await response.json();
|
||||
if (error.code === "PASSWORD_REQUIRED") {
|
||||
toast.error(t("admin.passwordRequired"));
|
||||
} else {
|
||||
toast.error(error.error || t("admin.databaseExportFailed"));
|
||||
}
|
||||
toast.error(error.error || t("admin.databaseExportFailed"));
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("admin.databaseExportFailed"));
|
||||
@@ -117,11 +90,6 @@ export function DatabaseSecurityTab({
|
||||
return;
|
||||
}
|
||||
|
||||
if (requiresImportPassword && !importPassword.trim()) {
|
||||
toast.error(t("admin.passwordRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
setImportLoading(true);
|
||||
try {
|
||||
const isDev =
|
||||
@@ -141,12 +109,18 @@ export function DatabaseSecurityTab({
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("file", importFile);
|
||||
if (requiresImportPassword) {
|
||||
formData.append("password", importPassword);
|
||||
|
||||
const importHeaders: Record<string, string> = {};
|
||||
if (isElectron()) {
|
||||
const token = localStorage.getItem("jwt");
|
||||
if (token) {
|
||||
importHeaders["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
method: "POST",
|
||||
headers: importHeaders,
|
||||
credentials: "include",
|
||||
body: formData,
|
||||
});
|
||||
@@ -181,7 +155,6 @@ export function DatabaseSecurityTab({
|
||||
`Import completed: ${imported} items imported${details.length > 0 ? ` (${details.join(", ")})` : ""}, ${skipped} items skipped`,
|
||||
);
|
||||
setImportFile(null);
|
||||
setImportPassword("");
|
||||
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
@@ -193,11 +166,7 @@ export function DatabaseSecurityTab({
|
||||
}
|
||||
} else {
|
||||
const error = await response.json();
|
||||
if (error.code === "PASSWORD_REQUIRED") {
|
||||
toast.error(t("admin.passwordRequired"));
|
||||
} else {
|
||||
toast.error(error.error || t("admin.databaseImportFailed"));
|
||||
}
|
||||
toast.error(error.error || t("admin.databaseImportFailed"));
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("admin.databaseImportFailed"));
|
||||
@@ -220,45 +189,13 @@ export function DatabaseSecurityTab({
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("admin.exportDescription")}
|
||||
</p>
|
||||
{showPasswordInput && requiresExportPassword && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="export-password">Password</Label>
|
||||
<PasswordInput
|
||||
id="export-password"
|
||||
value={exportPassword}
|
||||
onChange={(e) => setExportPassword(e.target.value)}
|
||||
placeholder="Enter your password"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
handleExportDatabase();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
onClick={handleExportDatabase}
|
||||
disabled={exportLoading}
|
||||
className="w-full"
|
||||
>
|
||||
{exportLoading
|
||||
? t("admin.exporting")
|
||||
: showPasswordInput && requiresExportPassword
|
||||
? t("admin.confirmExport")
|
||||
: t("admin.export")}
|
||||
{exportLoading ? t("admin.exporting") : t("admin.export")}
|
||||
</Button>
|
||||
{showPasswordInput && requiresExportPassword && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowPasswordInput(false);
|
||||
setExportPassword("");
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -294,29 +231,9 @@ export function DatabaseSecurityTab({
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
{importFile && requiresImportPassword && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="import-password">Password</Label>
|
||||
<PasswordInput
|
||||
id="import-password"
|
||||
value={importPassword}
|
||||
onChange={(e) => setImportPassword(e.target.value)}
|
||||
placeholder="Enter your password"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
handleImportDatabase();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
onClick={handleImportDatabase}
|
||||
disabled={
|
||||
importLoading ||
|
||||
!importFile ||
|
||||
(requiresImportPassword && !importPassword.trim())
|
||||
}
|
||||
disabled={importLoading || !importFile}
|
||||
className="w-full"
|
||||
>
|
||||
{importLoading ? t("admin.importing") : t("admin.import")}
|
||||
|
||||
@@ -19,6 +19,10 @@ import {
|
||||
updateGlobalMonitoringSettings,
|
||||
getGuacamoleSettings,
|
||||
updateGuacamoleSettings,
|
||||
getLogLevel,
|
||||
updateLogLevel,
|
||||
getSessionTimeout,
|
||||
updateSessionTimeout,
|
||||
} from "@/ui/main-axios.ts";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
|
||||
@@ -66,10 +70,64 @@ export function GeneralSettingsTab({
|
||||
);
|
||||
const [monitoringLoading, setMonitoringLoading] = React.useState(false);
|
||||
|
||||
const [logLevel, setLogLevel] = React.useState("info");
|
||||
const [logLevelLoading, setLogLevelLoading] = React.useState(false);
|
||||
|
||||
const [sessionTimeoutHours, setSessionTimeoutHours] = React.useState(24);
|
||||
const [sessionTimeoutInput, setSessionTimeoutInput] = React.useState("24");
|
||||
const [sessionTimeoutLoading, setSessionTimeoutLoading] =
|
||||
React.useState(false);
|
||||
|
||||
const [guacEnabled, setGuacEnabled] = React.useState(true);
|
||||
const [guacUrl, setGuacUrl] = React.useState("guacd:4822");
|
||||
const [guacLoading, setGuacLoading] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
getLogLevel()
|
||||
.then((data) => {
|
||||
setLogLevel(data.level);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
getSessionTimeout()
|
||||
.then((data) => {
|
||||
setSessionTimeoutHours(data.timeoutHours);
|
||||
setSessionTimeoutInput(String(data.timeoutHours));
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const handleLogLevelChange = async (value: string) => {
|
||||
setLogLevel(value);
|
||||
setLogLevelLoading(true);
|
||||
try {
|
||||
await updateLogLevel(value);
|
||||
toast.success(t("admin.logLevelSaved"));
|
||||
} catch {
|
||||
toast.error(t("admin.failedToSaveLogLevel"));
|
||||
} finally {
|
||||
setLogLevelLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSessionTimeoutBlur = async () => {
|
||||
const num = parseInt(sessionTimeoutInput) || 24;
|
||||
const clamped = Math.max(1, Math.min(720, num));
|
||||
setSessionTimeoutHours(clamped);
|
||||
setSessionTimeoutInput(String(clamped));
|
||||
setSessionTimeoutLoading(true);
|
||||
try {
|
||||
await updateSessionTimeout(clamped);
|
||||
toast.success(t("admin.sessionTimeoutSaved"));
|
||||
} catch {
|
||||
toast.error(t("admin.failedToSaveSessionTimeout"));
|
||||
} finally {
|
||||
setSessionTimeoutLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
getGuacamoleSettings()
|
||||
.then((data) => {
|
||||
@@ -275,6 +333,34 @@ export function GeneralSettingsTab({
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border-2 border-border bg-card p-4 space-y-4">
|
||||
<h3 className="text-lg font-semibold">{t("admin.sessionTimeout")}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("admin.sessionTimeoutDesc")}
|
||||
</p>
|
||||
<div>
|
||||
<label className="text-sm font-medium">
|
||||
{t("admin.sessionTimeoutHours")}
|
||||
</label>
|
||||
<div className="flex gap-2 mt-1">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={720}
|
||||
value={sessionTimeoutInput}
|
||||
onChange={(e) => setSessionTimeoutInput(e.target.value)}
|
||||
onBlur={handleSessionTimeoutBlur}
|
||||
disabled={sessionTimeoutLoading}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-sm font-medium py-2">{t("admin.hours")}</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{t("admin.sessionTimeoutNote")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border-2 border-border bg-card p-4 space-y-4">
|
||||
<h3 className="text-lg font-semibold">
|
||||
{t("admin.monitoringDefaults")}
|
||||
@@ -410,6 +496,36 @@ export function GeneralSettingsTab({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border-2 border-border bg-card p-4 space-y-4">
|
||||
<h3 className="text-lg font-semibold">{t("admin.logLevel")}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("admin.logLevelDesc")}
|
||||
</p>
|
||||
<div>
|
||||
<label className="text-sm font-medium">
|
||||
{t("admin.logVerbosity")}
|
||||
</label>
|
||||
<Select
|
||||
value={logLevel}
|
||||
onValueChange={handleLogLevelChange}
|
||||
disabled={logLevelLoading}
|
||||
>
|
||||
<SelectTrigger className="w-[200px] mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="debug">Debug</SelectItem>
|
||||
<SelectItem value="info">Info</SelectItem>
|
||||
<SelectItem value="warn">Warning</SelectItem>
|
||||
<SelectItem value="error">Error</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{t("admin.logLevelNote")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,9 +17,9 @@ import { deleteUser } from "@/ui/main-axios.ts";
|
||||
interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
is_admin: boolean;
|
||||
is_oidc: boolean;
|
||||
password_hash?: string;
|
||||
isAdmin: boolean;
|
||||
isOidc: boolean;
|
||||
passwordHash?: string;
|
||||
}
|
||||
|
||||
interface UserManagementTabProps {
|
||||
@@ -47,9 +47,9 @@ export function UserManagementTab({
|
||||
const { confirmWithToast } = useConfirmation();
|
||||
|
||||
const getAuthTypeDisplay = (user: User): string => {
|
||||
if (user.is_oidc && user.password_hash) {
|
||||
if (user.isOidc && user.passwordHash) {
|
||||
return t("admin.dualAuth");
|
||||
} else if (user.is_oidc) {
|
||||
} else if (user.isOidc) {
|
||||
return t("admin.externalOIDC");
|
||||
} else {
|
||||
return t("admin.localPassword");
|
||||
@@ -111,7 +111,7 @@ export function UserManagementTab({
|
||||
<TableRow key={user.id}>
|
||||
<TableCell className="font-medium">
|
||||
{user.username}
|
||||
{user.is_admin && (
|
||||
{user.isAdmin && (
|
||||
<span className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">
|
||||
{t("admin.adminBadge")}
|
||||
</span>
|
||||
@@ -129,7 +129,7 @@ export function UserManagementTab({
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
{user.is_oidc && !user.password_hash && (
|
||||
{user.isOidc && !user.passwordHash && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -145,7 +145,7 @@ export function UserManagementTab({
|
||||
<Link2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{user.is_oidc && user.password_hash && (
|
||||
{user.isOidc && user.passwordHash && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -161,7 +161,7 @@ export function UserManagementTab({
|
||||
size="sm"
|
||||
onClick={() => handleDeleteUserQuick(user.username)}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
disabled={user.is_admin}
|
||||
disabled={user.isAdmin}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
@@ -33,7 +33,8 @@ import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
|
||||
import {
|
||||
getRecentActivity,
|
||||
getSSHHosts,
|
||||
getGuacamoleToken,
|
||||
getGuacamoleDpi,
|
||||
getGuacamoleTokenFromHost,
|
||||
logActivity,
|
||||
} from "@/ui/main-axios.ts";
|
||||
import type { RecentActivityItem } from "@/ui/main-axios.ts";
|
||||
@@ -235,17 +236,7 @@ export function CommandPalette({
|
||||
) {
|
||||
try {
|
||||
const protocol = host.connectionType as "rdp" | "vnc" | "telnet";
|
||||
const result = await getGuacamoleToken({
|
||||
protocol,
|
||||
hostname: host.ip,
|
||||
port: host.port,
|
||||
username: host.username,
|
||||
password: host.password,
|
||||
domain: host.domain,
|
||||
security: host.security,
|
||||
ignoreCert: host.ignoreCert,
|
||||
guacamoleConfig: host.guacamoleConfig as any,
|
||||
});
|
||||
const result = await getGuacamoleTokenFromHost(host.id);
|
||||
|
||||
addTab({
|
||||
type: protocol,
|
||||
@@ -262,6 +253,7 @@ export function CommandPalette({
|
||||
domain: host.domain,
|
||||
security: host.security,
|
||||
"ignore-cert": host.ignoreCert,
|
||||
dpi: getGuacamoleDpi(host),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -12,10 +12,12 @@ import {
|
||||
getCredentials,
|
||||
getRecentActivity,
|
||||
resetRecentActivity,
|
||||
getAllServerStatuses,
|
||||
getServerMetricsById,
|
||||
registerMetricsViewer,
|
||||
sendMetricsHeartbeat,
|
||||
getGuacamoleToken,
|
||||
getGuacamoleDpi,
|
||||
getGuacamoleTokenFromHost,
|
||||
type RecentActivityItem,
|
||||
} from "@/ui/main-axios.ts";
|
||||
import { useSidebar } from "@/components/ui/sidebar.tsx";
|
||||
@@ -68,7 +70,7 @@ export function Dashboard({
|
||||
const [versionStatus, setVersionStatus] = useState<
|
||||
"up_to_date" | "requires_update"
|
||||
>("up_to_date");
|
||||
const [versionText, setVersionText] = useState<string>("v1.8.0");
|
||||
const [versionText, setVersionText] = useState<string>("");
|
||||
const [dbHealth, setDbHealth] = useState<"healthy" | "error">("healthy");
|
||||
const [totalServers, setTotalServers] = useState<number>(0);
|
||||
const [totalTunnels, setTotalTunnels] = useState<number>(0);
|
||||
@@ -223,72 +225,108 @@ export function Dashboard({
|
||||
setRecentActivityLoading(false);
|
||||
|
||||
setServerStatsLoading(true);
|
||||
|
||||
// Fetch current host statuses once so we can skip offline hosts
|
||||
// before issuing per-host register-viewer / metrics requests.
|
||||
let hostStatuses: Record<number, { status?: string }> = {};
|
||||
try {
|
||||
hostStatuses = (await getAllServerStatuses()) as Record<
|
||||
number,
|
||||
{ status?: string }
|
||||
>;
|
||||
} catch {
|
||||
// Best-effort: if the status endpoint is unavailable, fall back
|
||||
// to the previous behavior and still attempt each host.
|
||||
hostStatuses = {};
|
||||
}
|
||||
|
||||
const newViewerSessions = new Map<number, string>();
|
||||
const serversWithStats = await Promise.all(
|
||||
hosts
|
||||
.slice(0, 50)
|
||||
.map(
|
||||
async (host: {
|
||||
id: number;
|
||||
name: string;
|
||||
authType?: string;
|
||||
statsConfig?: string | { metricsEnabled?: boolean };
|
||||
}) => {
|
||||
try {
|
||||
let statsConfig: { metricsEnabled?: boolean } = {
|
||||
metricsEnabled: true,
|
||||
hosts.slice(0, 50).map(
|
||||
async (host: {
|
||||
id: number;
|
||||
name: string;
|
||||
authType?: string;
|
||||
statsConfig?:
|
||||
| string
|
||||
| {
|
||||
metricsEnabled?: boolean;
|
||||
statusCheckEnabled?: boolean;
|
||||
};
|
||||
if (host.statsConfig) {
|
||||
if (typeof host.statsConfig === "string") {
|
||||
statsConfig = JSON.parse(host.statsConfig);
|
||||
} else {
|
||||
statsConfig = host.statsConfig;
|
||||
}
|
||||
}
|
||||
|
||||
if (statsConfig.metricsEnabled === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (host.authType === "none") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (host.authType === "opkssh") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const existingSession = viewerSessions.get(host.id);
|
||||
let sessionId = existingSession;
|
||||
|
||||
if (!existingSession) {
|
||||
try {
|
||||
const viewerResult = await registerMetricsViewer(host.id);
|
||||
if (
|
||||
viewerResult.success &&
|
||||
viewerResult.viewerSessionId
|
||||
) {
|
||||
sessionId = viewerResult.viewerSessionId;
|
||||
newViewerSessions.set(host.id, sessionId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to register viewer for host ${host.id}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}) => {
|
||||
try {
|
||||
let statsConfig: {
|
||||
metricsEnabled?: boolean;
|
||||
statusCheckEnabled?: boolean;
|
||||
} = {
|
||||
metricsEnabled: true,
|
||||
statusCheckEnabled: true,
|
||||
};
|
||||
if (host.statsConfig) {
|
||||
if (typeof host.statsConfig === "string") {
|
||||
statsConfig = JSON.parse(host.statsConfig);
|
||||
} else {
|
||||
newViewerSessions.set(host.id, existingSession);
|
||||
statsConfig = host.statsConfig;
|
||||
}
|
||||
}
|
||||
|
||||
const metrics = await getServerMetricsById(host.id);
|
||||
return {
|
||||
id: host.id,
|
||||
name: host.name || `Host ${host.id}`,
|
||||
cpu: metrics.cpu.percent,
|
||||
ram: metrics.memory.percent,
|
||||
};
|
||||
} catch {
|
||||
if (statsConfig.metricsEnabled === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (host.authType === "none") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (host.authType === "opkssh") {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Skip hosts that are known to be offline: no metrics can
|
||||
// possibly exist for them, and hitting /metrics/:id would
|
||||
// just 404. If the status is unknown (e.g. no entry yet
|
||||
// or statusCheckEnabled === false) we still attempt.
|
||||
if (statsConfig.statusCheckEnabled !== false) {
|
||||
const knownStatus = hostStatuses?.[host.id]?.status;
|
||||
if (knownStatus === "offline") {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const existingSession = viewerSessions.get(host.id);
|
||||
let sessionId = existingSession;
|
||||
let registrationSkipped = false;
|
||||
|
||||
if (!existingSession) {
|
||||
try {
|
||||
const viewerResult = await registerMetricsViewer(host.id);
|
||||
if (viewerResult.skipped) {
|
||||
// Metrics disabled/unsupported on this host; don't
|
||||
// poll and don't surface this as an error.
|
||||
registrationSkipped = true;
|
||||
} else if (
|
||||
viewerResult.success &&
|
||||
viewerResult.viewerSessionId
|
||||
) {
|
||||
sessionId = viewerResult.viewerSessionId;
|
||||
newViewerSessions.set(host.id, sessionId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to register viewer for host ${host.id}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
newViewerSessions.set(host.id, existingSession);
|
||||
}
|
||||
|
||||
if (registrationSkipped) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const metrics = await getServerMetricsById(host.id);
|
||||
if (!metrics) {
|
||||
return {
|
||||
id: host.id,
|
||||
name: host.name || `Host ${host.id}`,
|
||||
@@ -296,8 +334,22 @@ export function Dashboard({
|
||||
ram: null,
|
||||
};
|
||||
}
|
||||
},
|
||||
),
|
||||
return {
|
||||
id: host.id,
|
||||
name: host.name || `Host ${host.id}`,
|
||||
cpu: metrics.cpu?.percent ?? null,
|
||||
ram: metrics.memory?.percent ?? null,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
id: host.id,
|
||||
name: host.name || `Host ${host.id}`,
|
||||
cpu: null,
|
||||
ram: null,
|
||||
};
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
setViewerSessions(newViewerSessions);
|
||||
const validServerStats = serversWithStats.filter(
|
||||
@@ -388,17 +440,7 @@ export function Dashboard({
|
||||
hostConfig: host,
|
||||
});
|
||||
} else if (item.type === "telnet") {
|
||||
getGuacamoleToken({
|
||||
protocol: "telnet",
|
||||
hostname: host.ip,
|
||||
port: host.port,
|
||||
username: host.username,
|
||||
password: host.password,
|
||||
domain: host.domain,
|
||||
security: host.security,
|
||||
ignoreCert: host.ignoreCert,
|
||||
guacamoleConfig: host.guacamoleConfig as any,
|
||||
})
|
||||
getGuacamoleTokenFromHost(host.id)
|
||||
.then((result) => {
|
||||
addTab({
|
||||
type: "telnet",
|
||||
@@ -415,6 +457,7 @@ export function Dashboard({
|
||||
domain: host.domain,
|
||||
security: host.security,
|
||||
"ignore-cert": host.ignoreCert,
|
||||
dpi: getGuacamoleDpi(host),
|
||||
},
|
||||
});
|
||||
})
|
||||
@@ -422,17 +465,7 @@ export function Dashboard({
|
||||
console.error("Failed to get telnet token:", error);
|
||||
});
|
||||
} else if (item.type === "vnc") {
|
||||
getGuacamoleToken({
|
||||
protocol: "vnc",
|
||||
hostname: host.ip,
|
||||
port: host.port,
|
||||
username: host.username,
|
||||
password: host.password,
|
||||
domain: host.domain,
|
||||
security: host.security,
|
||||
ignoreCert: host.ignoreCert,
|
||||
guacamoleConfig: host.guacamoleConfig as any,
|
||||
})
|
||||
getGuacamoleTokenFromHost(host.id)
|
||||
.then((result) => {
|
||||
addTab({
|
||||
type: "vnc",
|
||||
@@ -449,6 +482,7 @@ export function Dashboard({
|
||||
domain: host.domain,
|
||||
security: host.security,
|
||||
"ignore-cert": host.ignoreCert,
|
||||
dpi: getGuacamoleDpi(host),
|
||||
},
|
||||
});
|
||||
})
|
||||
@@ -456,17 +490,7 @@ export function Dashboard({
|
||||
console.error("Failed to get vnc token:", error);
|
||||
});
|
||||
} else if (item.type === "rdp") {
|
||||
getGuacamoleToken({
|
||||
protocol: "rdp",
|
||||
hostname: host.ip,
|
||||
port: host.port,
|
||||
username: host.username,
|
||||
password: host.password,
|
||||
domain: host.domain,
|
||||
security: host.security,
|
||||
ignoreCert: host.ignoreCert,
|
||||
guacamoleConfig: host.guacamoleConfig as any,
|
||||
})
|
||||
getGuacamoleTokenFromHost(host.id)
|
||||
.then((result) => {
|
||||
addTab({
|
||||
type: "rdp",
|
||||
@@ -483,6 +507,7 @@ export function Dashboard({
|
||||
domain: host.domain,
|
||||
security: host.security,
|
||||
"ignore-cert": host.ignoreCert,
|
||||
dpi: getGuacamoleDpi(host),
|
||||
},
|
||||
});
|
||||
})
|
||||
@@ -650,15 +675,6 @@ export function Dashboard({
|
||||
>
|
||||
{t("dashboard.discord")}
|
||||
</Button>
|
||||
<Button
|
||||
className="font-semibold shrink-0 !bg-canvas"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
window.open("https://github.com/sponsors/LukeGus", "_blank")
|
||||
}
|
||||
>
|
||||
{t("dashboard.donate")}
|
||||
</Button>
|
||||
<Button
|
||||
className="font-semibold shrink-0 !bg-canvas"
|
||||
variant="outline"
|
||||
|
||||
@@ -110,7 +110,7 @@ export function ServerOverviewCard({
|
||||
<div className="flex flex-row items-center min-w-0">
|
||||
<Server size={16} className="mr-3 shrink-0" />
|
||||
<p className="m-0 leading-none truncate">
|
||||
{t("dashboard.totalServers")}
|
||||
{t("dashboard.totalHosts")}
|
||||
</p>
|
||||
</div>
|
||||
<p className="m-0 leading-none text-muted-foreground font-semibold">
|
||||
|
||||
@@ -30,7 +30,11 @@ export function useDashboardPreferences(enabled: boolean = true) {
|
||||
const fetchPreferences = async () => {
|
||||
try {
|
||||
const preferences = await getDashboardPreferences();
|
||||
setLayout(preferences);
|
||||
if (preferences?.cards && Array.isArray(preferences.cards)) {
|
||||
setLayout(preferences);
|
||||
} else {
|
||||
setLayout(DEFAULT_LAYOUT);
|
||||
}
|
||||
} catch (error) {
|
||||
setLayout(DEFAULT_LAYOUT);
|
||||
} finally {
|
||||
|
||||
@@ -661,7 +661,7 @@ function DockerManagerInner({
|
||||
|
||||
<div className="flex-1 overflow-hidden min-h-0 relative">
|
||||
{viewMode === "list" ? (
|
||||
<div className="h-full px-4 py-4">
|
||||
<div className="h-full min-h-0 px-4 py-4">
|
||||
{sessionId ? (
|
||||
isLoadingContainers && containers.length === 0 ? (
|
||||
<SimpleLoader
|
||||
|
||||
@@ -263,8 +263,7 @@ export function ConsoleTerminal({
|
||||
})()
|
||||
: `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}${getBasePath()}/docker/console/`;
|
||||
|
||||
const wsUrl = `${baseWsUrl}?token=${encodeURIComponent(token)}`;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
const ws = new WebSocket(baseWsUrl);
|
||||
|
||||
ws.onopen = () => {
|
||||
const cols = terminal.cols || 80;
|
||||
|
||||
@@ -246,7 +246,7 @@ export function ContainerCard({
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
className={`cursor-pointer transition-all hover:shadow-lg ${
|
||||
className={`cursor-pointer transition-all hover:shadow-lg overflow-hidden min-w-0 ${
|
||||
isSelected
|
||||
? "ring-2 ring-primary border-primary"
|
||||
: `border-2 ${colors.border}`
|
||||
@@ -267,27 +267,27 @@ export function ContainerCard({
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 px-4 pb-3">
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-muted-foreground min-w-[50px] text-xs">
|
||||
{t("docker.image")}
|
||||
</span>
|
||||
<span className="truncate text-foreground text-xs">
|
||||
<span className="flex-1 min-w-0 truncate text-foreground text-xs">
|
||||
{container.image}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-muted-foreground min-w-[50px] text-xs">
|
||||
{t("docker.idLabel")}
|
||||
</span>
|
||||
<span className="font-mono text-xs text-foreground">
|
||||
<span className="flex-1 min-w-0 truncate font-mono text-xs text-foreground">
|
||||
{container.id.substring(0, 12)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex items-start gap-2 min-w-0">
|
||||
<span className="text-muted-foreground min-w-[50px] text-xs shrink-0">
|
||||
{t("docker.ports")}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<div className="flex flex-1 min-w-0 flex-wrap gap-1">
|
||||
{portsList.length > 0 ? (
|
||||
portsList.map((port, idx) => (
|
||||
<Badge
|
||||
@@ -308,11 +308,11 @@ export function ContainerCard({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-muted-foreground min-w-[50px] text-xs">
|
||||
{t("docker.created")}
|
||||
</span>
|
||||
<span className="text-foreground text-xs">
|
||||
<span className="flex-1 min-w-0 truncate text-foreground text-xs">
|
||||
{formatCreatedDate(container.created)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -55,7 +55,7 @@ export function ContainerList({
|
||||
|
||||
if (containers.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="flex items-center justify-center h-full min-h-0">
|
||||
<div className="text-center space-y-2">
|
||||
<p className="text-muted-foreground text-lg">
|
||||
{t("docker.noContainersFound")}
|
||||
@@ -69,7 +69,7 @@ export function ContainerList({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full gap-3">
|
||||
<div className="flex flex-col h-full min-h-0 gap-3">
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
@@ -106,7 +106,7 @@ export function ContainerList({
|
||||
</div>
|
||||
|
||||
{filteredContainers.length === 0 ? (
|
||||
<div className="flex items-center justify-center flex-1">
|
||||
<div className="flex items-center justify-center flex-1 min-h-0">
|
||||
<div className="text-center space-y-2">
|
||||
<p className="text-muted-foreground">
|
||||
{t("docker.noContainersMatchFilters")}
|
||||
@@ -117,17 +117,19 @@ export function ContainerList({
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-3 overflow-auto thin-scrollbar pb-2">
|
||||
{filteredContainers.map((container) => (
|
||||
<ContainerCard
|
||||
key={container.id}
|
||||
container={container}
|
||||
sessionId={sessionId}
|
||||
onSelect={() => onSelectContainer(container.id)}
|
||||
isSelected={selectedContainerId === container.id}
|
||||
onRefresh={onRefresh}
|
||||
/>
|
||||
))}
|
||||
<div className="min-h-0 flex-1 overflow-auto thin-scrollbar pr-1">
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full pb-2">
|
||||
{filteredContainers.map((container) => (
|
||||
<ContainerCard
|
||||
key={container.id}
|
||||
container={container}
|
||||
sessionId={sessionId}
|
||||
onSelect={() => onSelectContainer(container.id)}
|
||||
isSelected={selectedContainerId === container.id}
|
||||
onRefresh={onRefresh}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -599,13 +599,13 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
);
|
||||
|
||||
const debouncedLoadDirectory = useCallback(
|
||||
(path: string) => {
|
||||
(path: string, force?: boolean) => {
|
||||
if (pathChangeTimerRef.current) {
|
||||
clearTimeout(pathChangeTimerRef.current);
|
||||
}
|
||||
|
||||
pathChangeTimerRef.current = setTimeout(() => {
|
||||
if (path !== lastPathChangeRef.current && sshSessionId) {
|
||||
if ((force || path !== lastPathChangeRef.current) && sshSessionId) {
|
||||
lastPathChangeRef.current = path;
|
||||
loadDirectory(path);
|
||||
}
|
||||
@@ -641,6 +641,9 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
}
|
||||
|
||||
setLastRefreshTime(now);
|
||||
// Force reset loading state to ensure refresh is not blocked
|
||||
setIsLoading(false);
|
||||
currentLoadingPathRef.current = "";
|
||||
loadDirectory(currentPath);
|
||||
}, [currentPath, lastRefreshTime, loadDirectory]);
|
||||
|
||||
@@ -778,6 +781,8 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
toast.success(
|
||||
t("fileManager.fileDownloadedSuccessfully", { name: file.name }),
|
||||
);
|
||||
} else {
|
||||
toast.error(t("fileManager.failedToDownloadFile"));
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (
|
||||
|
||||
@@ -1177,6 +1177,9 @@ export function FileManagerGrid({
|
||||
<ArrowDown className="w-3 h-3" />
|
||||
))}
|
||||
</div>
|
||||
<div className="flex-shrink-0 w-24 text-right hidden md:block">
|
||||
{t("fileManager.owner", "Owner")}
|
||||
</div>
|
||||
<div className="flex-shrink-0 w-20 text-right">
|
||||
{t("fileManager.permissions")}
|
||||
</div>
|
||||
@@ -1275,6 +1278,14 @@ export function FileManagerGrid({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0 text-right w-24 hidden md:block">
|
||||
{file.owner && (
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{file.owner}
|
||||
{file.group ? `:${file.group}` : ""}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-shrink-0 text-right w-20">
|
||||
{file.permissions && (
|
||||
<p className="text-xs text-muted-foreground font-mono">
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from "react";
|
||||
import Guacamole from "guacamole-common-js";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getCookie, isElectron } from "@/ui/main-axios.ts";
|
||||
import { getCookie, isElectron, isEmbeddedMode } from "@/ui/main-axios.ts";
|
||||
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
|
||||
|
||||
export type GuacamoleConnectionType = "rdp" | "vnc" | "telnet";
|
||||
@@ -55,9 +55,15 @@ export const GuacamoleDisplay = forwardRef<
|
||||
const { t } = useTranslation();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const displayRef = useRef<HTMLDivElement>(null);
|
||||
const displayElementRef = useRef<HTMLElement | null>(null);
|
||||
const clientRef = useRef<Guacamole.Client | null>(null);
|
||||
const keyboardRef = useRef<Guacamole.Keyboard | null>(null);
|
||||
const scaleRef = useRef<number>(1);
|
||||
const resizeTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const hasKeyboardFocusRef = useRef(false);
|
||||
const windowFocusedRef = useRef(
|
||||
typeof document === "undefined" ? true : document.hasFocus(),
|
||||
);
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
|
||||
@@ -138,22 +144,44 @@ export const GuacamoleDisplay = forwardRef<
|
||||
token = data.token;
|
||||
}
|
||||
|
||||
const width = connectionConfig.width || containerWidth || 1280;
|
||||
const height = connectionConfig.height || containerHeight || 720;
|
||||
const dpi = connectionConfig.dpi || 96;
|
||||
const width = connectionConfig.width ?? containerWidth ?? 1280;
|
||||
const height = connectionConfig.height ?? containerHeight ?? 720;
|
||||
const protocol = connectionConfig.protocol ?? connectionConfig.type;
|
||||
const dpi = protocol === "rdp" ? (connectionConfig.dpi ?? 96) : null;
|
||||
|
||||
const wsBase = isDev
|
||||
? `ws://localhost:30008`
|
||||
: isElectron()
|
||||
? (() => {
|
||||
const base =
|
||||
(window as { configuredServerUrl?: string })
|
||||
.configuredServerUrl || "http://127.0.0.1:30001";
|
||||
return `${base.startsWith("https://") ? "wss://" : "ws://"}${base.replace(/^https?:\/\//, "")}/guacamole/websocket/`;
|
||||
const configuredUrl = (
|
||||
window as { configuredServerUrl?: string }
|
||||
).configuredServerUrl;
|
||||
|
||||
// Embedded mode or no configured remote server: connect directly
|
||||
// to the local guacamole websocket service.
|
||||
if (isEmbeddedMode() || !configuredUrl) {
|
||||
return "ws://127.0.0.1:30008";
|
||||
}
|
||||
|
||||
const wsProtocol = configuredUrl.startsWith("https://")
|
||||
? "wss://"
|
||||
: "ws://";
|
||||
const wsHost = configuredUrl
|
||||
.replace(/^https?:\/\//, "")
|
||||
.replace(/\/$/, "");
|
||||
return `${wsProtocol}${wsHost}/guacamole/websocket/`;
|
||||
})()
|
||||
: `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/guacamole/websocket/`;
|
||||
|
||||
return `${wsBase}?token=${encodeURIComponent(token)}&width=${width}&height=${height}&dpi=${dpi}`;
|
||||
const params = new URLSearchParams({
|
||||
token,
|
||||
width: String(width),
|
||||
height: String(height),
|
||||
});
|
||||
if (dpi !== null && dpi !== undefined) {
|
||||
params.set("dpi", String(dpi));
|
||||
}
|
||||
return `${wsBase}?${params.toString()}`;
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Unknown error";
|
||||
@@ -164,6 +192,63 @@ export const GuacamoleDisplay = forwardRef<
|
||||
[connectionConfig, onError],
|
||||
);
|
||||
|
||||
const refreshKeyboardHandlers = useCallback(() => {
|
||||
const keyboard = keyboardRef.current;
|
||||
const client = clientRef.current;
|
||||
const displayElement = displayElementRef.current;
|
||||
|
||||
if (!keyboard) return;
|
||||
|
||||
const documentVisible =
|
||||
typeof document === "undefined" || document.visibilityState === "visible";
|
||||
const displayIsFocused =
|
||||
!!displayElement &&
|
||||
typeof document !== "undefined" &&
|
||||
document.activeElement === displayElement;
|
||||
const shouldCaptureInput =
|
||||
!!client &&
|
||||
!!displayElement &&
|
||||
isVisible &&
|
||||
documentVisible &&
|
||||
windowFocusedRef.current &&
|
||||
(hasKeyboardFocusRef.current || displayIsFocused);
|
||||
|
||||
if (!shouldCaptureInput) {
|
||||
keyboard.onkeydown = null;
|
||||
keyboard.onkeyup = null;
|
||||
keyboard.reset();
|
||||
return;
|
||||
}
|
||||
|
||||
keyboard.onkeydown = (keysym: number) => {
|
||||
if (!clientRef.current) return;
|
||||
if (!isVisible || !windowFocusedRef.current) return;
|
||||
|
||||
const activeDisplay = displayElementRef.current;
|
||||
const stillFocused =
|
||||
!!activeDisplay &&
|
||||
typeof document !== "undefined" &&
|
||||
document.activeElement === activeDisplay;
|
||||
|
||||
if (!hasKeyboardFocusRef.current && !stillFocused) return;
|
||||
clientRef.current.sendKeyEvent(1, keysym);
|
||||
};
|
||||
|
||||
keyboard.onkeyup = (keysym: number) => {
|
||||
if (!clientRef.current) return;
|
||||
if (!isVisible || !windowFocusedRef.current) return;
|
||||
|
||||
const activeDisplay = displayElementRef.current;
|
||||
const stillFocused =
|
||||
!!activeDisplay &&
|
||||
typeof document !== "undefined" &&
|
||||
document.activeElement === activeDisplay;
|
||||
|
||||
if (!hasKeyboardFocusRef.current && !stillFocused) return;
|
||||
clientRef.current.sendKeyEvent(0, keysym);
|
||||
};
|
||||
}, [isVisible]);
|
||||
|
||||
const rescaleDisplay = useCallback((immediate: boolean = false) => {
|
||||
if (!clientRef.current || !containerRef.current) return;
|
||||
|
||||
@@ -220,12 +305,16 @@ export const GuacamoleDisplay = forwardRef<
|
||||
|
||||
const display = client.getDisplay();
|
||||
const displayElement = display.getElement();
|
||||
displayElementRef.current = displayElement;
|
||||
|
||||
if (displayRef.current) {
|
||||
displayRef.current.innerHTML = "";
|
||||
displayRef.current.appendChild(displayElement);
|
||||
}
|
||||
|
||||
displayElement.setAttribute("tabindex", "0");
|
||||
displayElement.style.outline = "none";
|
||||
|
||||
display.onresize = () => {
|
||||
rescaleDisplay(true);
|
||||
setIsReady(true);
|
||||
@@ -233,6 +322,7 @@ export const GuacamoleDisplay = forwardRef<
|
||||
|
||||
const mouse = new Guacamole.Mouse(displayElement);
|
||||
const sendMouseState = (state: Guacamole.Mouse.State) => {
|
||||
displayElement.focus({ preventScroll: true });
|
||||
const scale = scaleRef.current;
|
||||
const adjustedX = Math.round(state.x / scale);
|
||||
const adjustedY = Math.round(state.y / scale);
|
||||
@@ -251,14 +341,24 @@ export const GuacamoleDisplay = forwardRef<
|
||||
};
|
||||
mouse.onmousedown = mouse.onmouseup = mouse.onmousemove = sendMouseState;
|
||||
|
||||
const keyboard = new Guacamole.Keyboard(document);
|
||||
keyboard.onkeydown = (keysym: number) => {
|
||||
client.sendKeyEvent(1, keysym);
|
||||
const keyboard = new Guacamole.Keyboard(displayElement);
|
||||
keyboardRef.current = keyboard;
|
||||
|
||||
const handleDisplayFocus = () => {
|
||||
hasKeyboardFocusRef.current = true;
|
||||
refreshKeyboardHandlers();
|
||||
};
|
||||
keyboard.onkeyup = (keysym: number) => {
|
||||
client.sendKeyEvent(0, keysym);
|
||||
|
||||
const handleDisplayBlur = () => {
|
||||
hasKeyboardFocusRef.current = false;
|
||||
refreshKeyboardHandlers();
|
||||
};
|
||||
|
||||
displayElement.addEventListener("focus", handleDisplayFocus);
|
||||
displayElement.addEventListener("blur", handleDisplayBlur);
|
||||
displayElement.addEventListener("mousedown", handleDisplayFocus);
|
||||
refreshKeyboardHandlers();
|
||||
|
||||
client.onstatechange = (state: number) => {
|
||||
switch (state) {
|
||||
case 0:
|
||||
@@ -270,6 +370,7 @@ export const GuacamoleDisplay = forwardRef<
|
||||
break;
|
||||
case 3:
|
||||
setIsConnecting(false);
|
||||
setIsReady(true);
|
||||
onConnect?.();
|
||||
break;
|
||||
case 4:
|
||||
@@ -277,8 +378,8 @@ export const GuacamoleDisplay = forwardRef<
|
||||
case 5:
|
||||
setIsConnecting(false);
|
||||
setIsReady(false);
|
||||
keyboard.onkeydown = null;
|
||||
keyboard.onkeyup = null;
|
||||
hasKeyboardFocusRef.current = false;
|
||||
refreshKeyboardHandlers();
|
||||
onDisconnect?.();
|
||||
break;
|
||||
}
|
||||
@@ -304,8 +405,19 @@ export const GuacamoleDisplay = forwardRef<
|
||||
}
|
||||
};
|
||||
|
||||
client.onaudio = (stream: Guacamole.InputStream, mimetype: string) => {
|
||||
Guacamole.AudioPlayer.getInstance(stream, mimetype);
|
||||
};
|
||||
|
||||
client.connect();
|
||||
}, [getWebSocketUrl, onConnect, onDisconnect, onError, rescaleDisplay]);
|
||||
}, [
|
||||
getWebSocketUrl,
|
||||
onConnect,
|
||||
onDisconnect,
|
||||
onError,
|
||||
refreshKeyboardHandlers,
|
||||
rescaleDisplay,
|
||||
]);
|
||||
|
||||
const hasInitiatedRef = useRef(false);
|
||||
const isMountedRef = useRef(false);
|
||||
@@ -324,6 +436,46 @@ export const GuacamoleDisplay = forwardRef<
|
||||
}
|
||||
}, [isVisible, connect]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) {
|
||||
hasKeyboardFocusRef.current = false;
|
||||
}
|
||||
|
||||
refreshKeyboardHandlers();
|
||||
}, [isVisible, refreshKeyboardHandlers]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleWindowFocus = () => {
|
||||
windowFocusedRef.current = true;
|
||||
refreshKeyboardHandlers();
|
||||
};
|
||||
|
||||
const handleWindowBlur = () => {
|
||||
windowFocusedRef.current = false;
|
||||
hasKeyboardFocusRef.current = false;
|
||||
refreshKeyboardHandlers();
|
||||
};
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
windowFocusedRef.current =
|
||||
document.visibilityState === "visible" && document.hasFocus();
|
||||
if (document.visibilityState !== "visible") {
|
||||
hasKeyboardFocusRef.current = false;
|
||||
}
|
||||
refreshKeyboardHandlers();
|
||||
};
|
||||
|
||||
window.addEventListener("focus", handleWindowFocus);
|
||||
window.addEventListener("blur", handleWindowBlur);
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("focus", handleWindowFocus);
|
||||
window.removeEventListener("blur", handleWindowBlur);
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
};
|
||||
}, [refreshKeyboardHandlers]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
@@ -336,6 +488,7 @@ export const GuacamoleDisplay = forwardRef<
|
||||
clientRef.current.disconnect();
|
||||
clientRef.current = null;
|
||||
}
|
||||
displayElementRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -344,6 +497,14 @@ export const GuacamoleDisplay = forwardRef<
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
rescaleDisplay(false);
|
||||
if (resizeTimeoutRef.current) clearTimeout(resizeTimeoutRef.current);
|
||||
resizeTimeoutRef.current = setTimeout(() => {
|
||||
if (clientRef.current && containerRef.current) {
|
||||
const w = containerRef.current.clientWidth;
|
||||
const h = containerRef.current.clientHeight;
|
||||
if (w > 0 && h > 0) clientRef.current.sendSize(w, h);
|
||||
}
|
||||
}, 200);
|
||||
});
|
||||
|
||||
resizeObserver.observe(containerRef.current);
|
||||
@@ -356,6 +517,40 @@ export const GuacamoleDisplay = forwardRef<
|
||||
};
|
||||
}, [rescaleDisplay]);
|
||||
|
||||
const syncClipboard = useCallback(() => {
|
||||
const client = clientRef.current;
|
||||
if (!client) return;
|
||||
navigator.clipboard
|
||||
.readText()
|
||||
.then((text) => {
|
||||
if (text) {
|
||||
const stream = client.createClipboardStream("text/plain");
|
||||
const writer = new Guacamole.StringWriter(stream);
|
||||
writer.sendText(text);
|
||||
writer.sendEnd();
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isVisible && isReady) {
|
||||
syncClipboard();
|
||||
}
|
||||
}, [isVisible, isReady, syncClipboard]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container || !isReady) return;
|
||||
|
||||
const handleFocus = () => syncClipboard();
|
||||
container.addEventListener("mouseenter", handleFocus);
|
||||
|
||||
return () => {
|
||||
container.removeEventListener("mouseenter", handleFocus);
|
||||
};
|
||||
}, [isReady, syncClipboard]);
|
||||
|
||||
const connectingMessage = t("guacamole.connecting", {
|
||||
type: (
|
||||
connectionConfig.protocol ||
|
||||
|
||||
@@ -479,7 +479,7 @@ function ServerStatsInner({
|
||||
if (cancelled) return;
|
||||
try {
|
||||
const data = await getServerMetricsById(currentHostConfig.id);
|
||||
if (!cancelled) {
|
||||
if (!cancelled && data) {
|
||||
setMetrics(data);
|
||||
setMetricsHistory((prev) => {
|
||||
const newHistory = [...prev, data];
|
||||
@@ -636,7 +636,9 @@ function ServerStatsInner({
|
||||
const data = await getServerMetricsById(
|
||||
currentHostConfig.id,
|
||||
);
|
||||
setMetrics(data);
|
||||
if (data) {
|
||||
setMetrics(data);
|
||||
}
|
||||
setShowStatsUI(true);
|
||||
} catch (error: unknown) {
|
||||
const err = error as {
|
||||
|
||||
@@ -17,16 +17,20 @@ import { getBasePath } from "@/lib/base-path";
|
||||
import {
|
||||
getCookie,
|
||||
isElectron,
|
||||
isEmbeddedMode,
|
||||
logActivity,
|
||||
getSnippets,
|
||||
deleteCommandFromHistory,
|
||||
getCommandHistory,
|
||||
getHostPassword,
|
||||
getServerConfig,
|
||||
} from "@/ui/main-axios.ts";
|
||||
import { TOTPDialog } from "@/ui/desktop/navigation/dialogs/TOTPDialog.tsx";
|
||||
import { SSHAuthDialog } from "@/ui/desktop/navigation/dialogs/SSHAuthDialog.tsx";
|
||||
import { WarpgateDialog } from "@/ui/desktop/navigation/dialogs/WarpgateDialog.tsx";
|
||||
import { OPKSSHDialog } from "@/ui/desktop/navigation/dialogs/OPKSSHDialog.tsx";
|
||||
import { HostKeyVerificationDialog } from "@/ui/desktop/navigation/dialogs/HostKeyVerificationDialog.tsx";
|
||||
import { TmuxSessionPicker } from "@/ui/desktop/navigation/dialogs/TmuxSessionPicker.tsx";
|
||||
import {
|
||||
TERMINAL_THEMES,
|
||||
DEFAULT_TERMINAL_CONFIG,
|
||||
@@ -47,6 +51,7 @@ import {
|
||||
} from "@/ui/desktop/navigation/connection-log/ConnectionLogContext.tsx";
|
||||
import { ConnectionLog } from "@/ui/desktop/navigation/connection-log/ConnectionLog.tsx";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface HostConfig {
|
||||
id?: number;
|
||||
@@ -66,6 +71,7 @@ interface HostConfig {
|
||||
|
||||
interface TerminalHandle {
|
||||
disconnect: () => void;
|
||||
reconnect: () => void;
|
||||
fit: () => void;
|
||||
sendInput: (data: string) => void;
|
||||
notifyResize: () => void;
|
||||
@@ -82,6 +88,110 @@ interface SSHTerminalProps {
|
||||
onTitleChange?: (title: string) => void;
|
||||
initialPath?: string;
|
||||
executeCommand?: string;
|
||||
onOpenFileManager?: () => void;
|
||||
previewTheme?: string | null;
|
||||
}
|
||||
|
||||
function TerminalContextMenu({
|
||||
x,
|
||||
y,
|
||||
hasSelection,
|
||||
showCopyPaste,
|
||||
showOpenFileManager,
|
||||
onCopy,
|
||||
onPaste,
|
||||
onOpenFileManager,
|
||||
onClose,
|
||||
}: {
|
||||
x: number;
|
||||
y: number;
|
||||
hasSelection: boolean;
|
||||
showCopyPaste: boolean;
|
||||
showOpenFileManager: boolean;
|
||||
onCopy: () => void;
|
||||
onPaste: () => void;
|
||||
onOpenFileManager: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const menuX = x + 180 > window.innerWidth ? window.innerWidth - 190 : x;
|
||||
const menuY = y + 150 > window.innerHeight ? window.innerHeight - 160 : y;
|
||||
|
||||
useEffect(() => {
|
||||
let cleanup: (() => void) | null = null;
|
||||
const timeoutId = setTimeout(() => {
|
||||
const handleClose = (e: MouseEvent) => {
|
||||
if (!menuRef.current?.contains(e.target as Element)) onClose();
|
||||
};
|
||||
const handleRightClick = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
};
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("mousedown", handleClose, true);
|
||||
document.addEventListener("contextmenu", handleRightClick);
|
||||
document.addEventListener("keydown", handleKey);
|
||||
window.addEventListener("blur", onClose);
|
||||
|
||||
cleanup = () => {
|
||||
document.removeEventListener("mousedown", handleClose, true);
|
||||
document.removeEventListener("contextmenu", handleRightClick);
|
||||
document.removeEventListener("keydown", handleKey);
|
||||
window.removeEventListener("blur", onClose);
|
||||
};
|
||||
}, 50);
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
cleanup?.();
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
const items: { label: string; action: () => void; disabled?: boolean }[] = [];
|
||||
|
||||
if (showCopyPaste) {
|
||||
items.push(
|
||||
{ label: t("terminal.copy"), action: onCopy, disabled: !hasSelection },
|
||||
{ label: t("terminal.paste"), action: onPaste },
|
||||
);
|
||||
}
|
||||
|
||||
if (showOpenFileManager) {
|
||||
items.push({
|
||||
label: t("terminal.openFileManagerHere"),
|
||||
action: onOpenFileManager,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 z-[99990]" />
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="fixed bg-canvas border border-edge rounded-lg shadow-xl min-w-[180px] z-[99995] overflow-hidden"
|
||||
style={{ left: menuX, top: menuY }}
|
||||
>
|
||||
{items.map((item, i) => (
|
||||
<button
|
||||
key={i}
|
||||
className={`w-full px-3 py-2 text-left text-sm flex items-center hover:bg-hover transition-colors first:rounded-t-lg last:rounded-b-lg ${item.disabled ? "opacity-50 cursor-not-allowed" : ""}`}
|
||||
disabled={item.disabled}
|
||||
onClick={() => {
|
||||
if (!item.disabled) {
|
||||
item.action();
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
@@ -94,6 +204,8 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
onTitleChange,
|
||||
initialPath,
|
||||
executeCommand,
|
||||
onOpenFileManager,
|
||||
previewTheme,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
@@ -114,21 +226,37 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
const { theme: appTheme } = useTheme();
|
||||
const { addLog, isExpanded: isConnectionLogExpanded } = useConnectionLog();
|
||||
|
||||
const config = { ...DEFAULT_TERMINAL_CONFIG, ...hostConfig.terminalConfig };
|
||||
const savedTheme = localStorage.getItem(
|
||||
`terminal_theme_host_${hostConfig.id}`,
|
||||
);
|
||||
const config = {
|
||||
...DEFAULT_TERMINAL_CONFIG,
|
||||
...hostConfig.terminalConfig,
|
||||
theme:
|
||||
savedTheme ||
|
||||
hostConfig.terminalConfig?.theme ||
|
||||
DEFAULT_TERMINAL_CONFIG.theme,
|
||||
};
|
||||
|
||||
const isDarkMode =
|
||||
appTheme === "dark" ||
|
||||
appTheme === "dracula" ||
|
||||
appTheme === "gentlemansChoice" ||
|
||||
appTheme === "midnightEspresso" ||
|
||||
appTheme === "catppuccinMocha" ||
|
||||
(appTheme === "system" &&
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||
|
||||
let themeColors;
|
||||
if (config.theme === "termix") {
|
||||
const activeTheme = previewTheme || config.theme;
|
||||
|
||||
if (activeTheme === "termix") {
|
||||
themeColors = isDarkMode
|
||||
? TERMINAL_THEMES.termixDark.colors
|
||||
: TERMINAL_THEMES.termixLight.colors;
|
||||
} else {
|
||||
themeColors =
|
||||
TERMINAL_THEMES[config.theme]?.colors ||
|
||||
TERMINAL_THEMES[activeTheme]?.colors ||
|
||||
TERMINAL_THEMES.termixDark.colors;
|
||||
}
|
||||
const backgroundColor = themeColors.background;
|
||||
@@ -137,11 +265,15 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
const resizeTimeout = useRef<NodeJS.Timeout | null>(null);
|
||||
const wasDisconnectedBySSH = useRef(false);
|
||||
const pingIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const pongReceivedRef = useRef(true);
|
||||
const pongTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const [isFitted, setIsFitted] = useState(false);
|
||||
const [connectionError, setConnectionError] = useState<string | null>(null);
|
||||
const connectionErrorRef = useRef<string | null>(null);
|
||||
const [showDisconnectedOverlay, setShowDisconnectedOverlay] =
|
||||
useState(false);
|
||||
|
||||
const updateConnectionError = useCallback((error: string | null) => {
|
||||
connectionErrorRef.current = error;
|
||||
@@ -169,8 +301,15 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
requestId: string;
|
||||
stage: "chooser" | "waiting" | "authenticating" | "completed" | "error";
|
||||
error?: string;
|
||||
providers?: Array<{ alias: string; issuer: string }>;
|
||||
} | null>(null);
|
||||
const opksshTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const [contextMenu, setContextMenu] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
hasSelection: boolean;
|
||||
} | null>(null);
|
||||
const opksshFailedRef = useRef(false);
|
||||
const currentHostIdRef = useRef<number | null>(null);
|
||||
const currentHostConfigRef = useRef<any>(null);
|
||||
@@ -183,12 +322,23 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
|
||||
const sessionIdRef = useRef<string | null>(null);
|
||||
const isAttachingSessionRef = useRef<boolean>(false);
|
||||
const [tmuxSessionPicker, setTmuxSessionPicker] = useState<{
|
||||
sessions: Array<{
|
||||
name: string;
|
||||
created: number;
|
||||
lastActivity: number;
|
||||
windows: number;
|
||||
attachedClients: number;
|
||||
}>;
|
||||
} | null>(null);
|
||||
const tmuxSessionNameRef = useRef<string | null>(null);
|
||||
const tmuxCopyModeHintShownRef = useRef(false);
|
||||
|
||||
const isVisibleRef = useRef<boolean>(false);
|
||||
const isFittingRef = useRef(false);
|
||||
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const reconnectAttempts = useRef(0);
|
||||
const maxReconnectAttempts = 3;
|
||||
const maxReconnectAttempts = 8;
|
||||
const isUnmountingRef = useRef(false);
|
||||
const shouldNotReconnectRef = useRef(false);
|
||||
const isReconnectingRef = useRef(false);
|
||||
@@ -211,11 +361,35 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
const connectionTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const activityLoggedRef = useRef(false);
|
||||
const keyHandlerAttachedRef = useRef(false);
|
||||
const [commandHistoryTrackingEnabled, setCommandHistoryTrackingEnabled] =
|
||||
useState<boolean>(
|
||||
() => localStorage.getItem("commandHistoryTracking") === "true",
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleCommandHistoryTrackingChanged = () => {
|
||||
setCommandHistoryTrackingEnabled(
|
||||
localStorage.getItem("commandHistoryTracking") === "true",
|
||||
);
|
||||
};
|
||||
|
||||
window.addEventListener(
|
||||
"commandHistoryTrackingChanged",
|
||||
handleCommandHistoryTrackingChanged,
|
||||
);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(
|
||||
"commandHistoryTrackingChanged",
|
||||
handleCommandHistoryTrackingChanged,
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const { trackInput, getCurrentCommand, updateCurrentCommand } =
|
||||
useCommandTracker({
|
||||
hostId: hostConfig.id,
|
||||
enabled: true,
|
||||
enabled: commandHistoryTrackingEnabled,
|
||||
onCommandExecuted: (command) => {
|
||||
if (!autocompleteHistory.current.includes(command)) {
|
||||
autocompleteHistory.current = [
|
||||
@@ -558,6 +732,10 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
clearInterval(pingIntervalRef.current);
|
||||
pingIntervalRef.current = null;
|
||||
}
|
||||
if (pongTimeoutRef.current) {
|
||||
clearTimeout(pongTimeoutRef.current);
|
||||
pongTimeoutRef.current = null;
|
||||
}
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
reconnectTimeoutRef.current = null;
|
||||
@@ -584,6 +762,23 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
setIsConnected(false);
|
||||
setIsConnecting(false);
|
||||
},
|
||||
reconnect: () => {
|
||||
isUnmountingRef.current = false;
|
||||
shouldNotReconnectRef.current = false;
|
||||
isReconnectingRef.current = false;
|
||||
isConnectingRef.current = false;
|
||||
reconnectAttempts.current = 0;
|
||||
wasDisconnectedBySSH.current = false;
|
||||
wasConnectedRef.current = false;
|
||||
updateConnectionError(null);
|
||||
setShowDisconnectedOverlay(false);
|
||||
if (terminal) {
|
||||
terminal.clear();
|
||||
const cols = terminal.cols;
|
||||
const rows = terminal.rows;
|
||||
connectToHost(cols, rows);
|
||||
}
|
||||
},
|
||||
fit: () => {
|
||||
fitAddonRef.current?.fit();
|
||||
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
||||
@@ -628,9 +823,9 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
}
|
||||
|
||||
if (reconnectAttempts.current >= maxReconnectAttempts) {
|
||||
updateConnectionError(t("terminal.maxReconnectAttemptsReached"));
|
||||
setIsConnecting(false);
|
||||
shouldNotReconnectRef.current = true;
|
||||
setShowDisconnectedOverlay(true);
|
||||
addLog({
|
||||
type: "error",
|
||||
stage: "connection",
|
||||
@@ -706,7 +901,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
}, delay);
|
||||
}
|
||||
|
||||
function connectToHost(cols: number, rows: number) {
|
||||
async function connectToHost(cols: number, rows: number) {
|
||||
if (isConnectingRef.current) {
|
||||
return;
|
||||
}
|
||||
@@ -738,20 +933,52 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
return;
|
||||
}
|
||||
|
||||
const baseWsUrl = isDev
|
||||
? `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30002`
|
||||
: isElectron()
|
||||
? (() => {
|
||||
const baseUrl =
|
||||
(window as { configuredServerUrl?: string })
|
||||
.configuredServerUrl || "http://127.0.0.1:30001";
|
||||
const wsProtocol = baseUrl.startsWith("https://")
|
||||
? "wss://"
|
||||
: "ws://";
|
||||
const wsHost = baseUrl.replace(/^https?:\/\//, "");
|
||||
return `${wsProtocol}${wsHost}/ssh/websocket/`;
|
||||
})()
|
||||
: `${getBasePath()}/ssh/websocket/`;
|
||||
let baseWsUrl: string;
|
||||
|
||||
if (isDev) {
|
||||
baseWsUrl = `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30002`;
|
||||
} else if (isElectron()) {
|
||||
let configuredUrl = (window as { configuredServerUrl?: string | null })
|
||||
.configuredServerUrl;
|
||||
|
||||
if (!configuredUrl && !isEmbeddedMode()) {
|
||||
try {
|
||||
const serverConfig = await getServerConfig();
|
||||
configuredUrl = serverConfig?.serverUrl || null;
|
||||
if (configuredUrl) {
|
||||
(
|
||||
window as Window &
|
||||
typeof globalThis & {
|
||||
configuredServerUrl?: string | null;
|
||||
}
|
||||
).configuredServerUrl = configuredUrl;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to resolve Electron server URL:", error);
|
||||
}
|
||||
}
|
||||
|
||||
if (isEmbeddedMode()) {
|
||||
baseWsUrl = "ws://127.0.0.1:30002";
|
||||
} else if (!configuredUrl) {
|
||||
console.error("No configured server URL available for Electron SSH");
|
||||
setIsConnected(false);
|
||||
setIsConnecting(false);
|
||||
updateConnectionError(t("errors.failedToLoadServer"));
|
||||
isConnectingRef.current = false;
|
||||
return;
|
||||
} else {
|
||||
const wsProtocol = configuredUrl.startsWith("https://")
|
||||
? "wss://"
|
||||
: "ws://";
|
||||
const wsHost = configuredUrl
|
||||
.replace(/^https?:\/\//, "")
|
||||
.replace(/\/$/, "");
|
||||
baseWsUrl = `${wsProtocol}${wsHost}/ssh/websocket/`;
|
||||
}
|
||||
} else {
|
||||
baseWsUrl = `${getBasePath()}/ssh/websocket/`;
|
||||
}
|
||||
|
||||
if (
|
||||
webSocketRef.current &&
|
||||
@@ -769,9 +996,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
connectionTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
const wsUrl = `${baseWsUrl}?token=${encodeURIComponent(jwtToken)}`;
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
const ws = new WebSocket(baseWsUrl);
|
||||
webSocketRef.current = ws;
|
||||
wasDisconnectedBySSH.current = false;
|
||||
updateConnectionError(null);
|
||||
@@ -857,8 +1082,17 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
ws.send(JSON.stringify({ type: "input", data }));
|
||||
});
|
||||
|
||||
pongReceivedRef.current = true;
|
||||
pingIntervalRef.current = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
if (!pongReceivedRef.current) {
|
||||
console.warn(
|
||||
"[WebSocket] Pong timeout - connection appears dead, closing",
|
||||
);
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
pongReceivedRef.current = false;
|
||||
ws.send(JSON.stringify({ type: "ping" }));
|
||||
}
|
||||
}, 30000);
|
||||
@@ -867,6 +1101,10 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
ws.addEventListener("message", (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === "pong") {
|
||||
pongReceivedRef.current = true;
|
||||
return;
|
||||
}
|
||||
if (msg.type === "data") {
|
||||
if (typeof msg.data === "string") {
|
||||
const syntaxHighlightingEnabled =
|
||||
@@ -879,19 +1117,36 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
terminal.write(outputData);
|
||||
const sudoPasswordPattern =
|
||||
/(?:\[sudo\][^\n]*:\s*$|sudo:[^\n]*password[^\n]*required)/i;
|
||||
const passwordToFill =
|
||||
hostConfig.terminalConfig?.sudoPassword || hostConfig.password;
|
||||
const hasSudoPw =
|
||||
hostConfig.terminalConfig?.sudoPassword ||
|
||||
hostConfig.password ||
|
||||
hostConfig.hasSudoPassword ||
|
||||
hostConfig.hasPassword;
|
||||
if (
|
||||
config.sudoPasswordAutoFill &&
|
||||
sudoPasswordPattern.test(msg.data) &&
|
||||
passwordToFill &&
|
||||
hasSudoPw &&
|
||||
!sudoPromptShownRef.current
|
||||
) {
|
||||
sudoPromptShownRef.current = true;
|
||||
confirmWithToast(
|
||||
t("terminal.sudoPasswordPopupTitle"),
|
||||
async () => {
|
||||
// Fetch password on-demand from server
|
||||
let passwordToFill =
|
||||
hostConfig.terminalConfig?.sudoPassword ||
|
||||
hostConfig.password;
|
||||
if (!passwordToFill && hostConfig.id) {
|
||||
passwordToFill =
|
||||
(await getHostPassword(
|
||||
hostConfig.id,
|
||||
"sudoPassword",
|
||||
)) ||
|
||||
(await getHostPassword(hostConfig.id, "password")) ||
|
||||
undefined;
|
||||
}
|
||||
if (
|
||||
passwordToFill &&
|
||||
webSocketRef.current &&
|
||||
webSocketRef.current.readyState === WebSocket.OPEN
|
||||
) {
|
||||
@@ -1052,19 +1307,10 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
} else if (msg.type === "disconnected") {
|
||||
wasDisconnectedBySSH.current = true;
|
||||
setIsConnected(false);
|
||||
if (terminal) {
|
||||
terminal.clear();
|
||||
}
|
||||
setIsConnecting(false);
|
||||
if (wasConnectedRef.current) {
|
||||
wasConnectedRef.current = false;
|
||||
if (
|
||||
onClose &&
|
||||
!connectionErrorRef.current &&
|
||||
!opksshFailedRef.current
|
||||
) {
|
||||
onClose();
|
||||
}
|
||||
setShowDisconnectedOverlay(true);
|
||||
} else if (!connectionErrorRef.current) {
|
||||
updateConnectionError(
|
||||
msg.message || t("terminal.connectionRejected"),
|
||||
@@ -1158,6 +1404,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
authUrl: msg.url || "",
|
||||
requestId: msg.requestId || "",
|
||||
stage: "chooser",
|
||||
providers: msg.providers,
|
||||
});
|
||||
if (opksshTimeoutRef.current) {
|
||||
clearTimeout(opksshTimeoutRef.current);
|
||||
@@ -1345,6 +1592,40 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
const cols = terminal?.cols || 80;
|
||||
const rows = terminal?.rows || 24;
|
||||
connectToHost(cols, rows);
|
||||
} else if (msg.type === "tmux_sessions_available") {
|
||||
setTmuxSessionPicker({
|
||||
sessions: msg.sessions,
|
||||
});
|
||||
} else if (
|
||||
msg.type === "tmux_session_created" ||
|
||||
msg.type === "tmux_session_attached"
|
||||
) {
|
||||
const sessionName =
|
||||
typeof msg.sessionName === "string" ? msg.sessionName : "";
|
||||
tmuxSessionNameRef.current = sessionName || "(active)";
|
||||
addLog({
|
||||
type: "info",
|
||||
stage: "connection",
|
||||
message:
|
||||
msg.type === "tmux_session_created"
|
||||
? t("terminal.tmuxSessionCreated", {
|
||||
name: sessionName || "new",
|
||||
})
|
||||
: t("terminal.tmuxSessionAttached", {
|
||||
name: sessionName,
|
||||
}),
|
||||
});
|
||||
} else if (msg.type === "tmux_unavailable") {
|
||||
setTimeout(() => {
|
||||
toast.warning(t("terminal.tmuxUnavailable"), {
|
||||
duration: 8000,
|
||||
});
|
||||
}, 500);
|
||||
addLog({
|
||||
type: "warning",
|
||||
stage: "connection",
|
||||
message: t("terminal.tmuxUnavailable"),
|
||||
});
|
||||
} else if (msg.type === "connection_log") {
|
||||
if (msg.data) {
|
||||
addLog({
|
||||
@@ -1369,8 +1650,10 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
|
||||
setIsConnected(false);
|
||||
isConnectingRef.current = false;
|
||||
if (terminal) {
|
||||
terminal.clear();
|
||||
|
||||
if (pongTimeoutRef.current) {
|
||||
clearTimeout(pongTimeoutRef.current);
|
||||
pongTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
if (totpTimeoutRef.current) {
|
||||
@@ -1379,17 +1662,21 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
}
|
||||
|
||||
if (event.code === 1006) {
|
||||
console.error(
|
||||
"[WebSocket] Abnormal closure detected - possible HTTPS/proxy issue",
|
||||
console.warn(
|
||||
"[WebSocket] Abnormal closure detected - attempting reconnection",
|
||||
);
|
||||
addLog({
|
||||
type: "error",
|
||||
type: "warning",
|
||||
stage: "connection",
|
||||
message: t("terminal.websocketAbnormalClose"),
|
||||
});
|
||||
updateConnectionError(t("terminal.websocketAbnormalClose"));
|
||||
setIsConnecting(false);
|
||||
shouldNotReconnectRef.current = true;
|
||||
|
||||
if (wasConnectedRef.current) {
|
||||
attemptReconnection();
|
||||
} else {
|
||||
updateConnectionError(t("terminal.websocketAbnormalClose"));
|
||||
setIsConnecting(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1406,10 +1693,6 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
|
||||
localStorage.removeItem("jwt");
|
||||
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1581,22 +1864,25 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
commandHistoryContext.setOnDeleteCommand(handleDeleteCommand);
|
||||
}, [handleDeleteCommand]);
|
||||
|
||||
// Separate theme and options updates to avoid terminal re-initialization flashes
|
||||
useEffect(() => {
|
||||
if (!terminal || !xtermRef.current) return;
|
||||
if (!terminal) return;
|
||||
|
||||
const config = {
|
||||
...DEFAULT_TERMINAL_CONFIG,
|
||||
...hostConfig.terminalConfig,
|
||||
...(hostConfig.terminalConfig as any),
|
||||
};
|
||||
|
||||
let themeColors;
|
||||
if (config.theme === "termix") {
|
||||
const activeTheme = previewTheme || config.theme;
|
||||
|
||||
if (activeTheme === "termix") {
|
||||
themeColors = isDarkMode
|
||||
? TERMINAL_THEMES.termixDark.colors
|
||||
: TERMINAL_THEMES.termixLight.colors;
|
||||
} else {
|
||||
themeColors =
|
||||
TERMINAL_THEMES[config.theme]?.colors ||
|
||||
TERMINAL_THEMES[activeTheme]?.colors ||
|
||||
TERMINAL_THEMES.termixDark.colors;
|
||||
}
|
||||
|
||||
@@ -1605,13 +1891,98 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
);
|
||||
const fontFamily = fontConfig?.fallback || TERMINAL_FONTS[0].fallback;
|
||||
|
||||
// Update terminal options individually to avoid re-initialization flashes
|
||||
terminal.options.cursorBlink = config.cursorBlink;
|
||||
terminal.options.cursorStyle = config.cursorStyle;
|
||||
terminal.options.scrollback = config.scrollback;
|
||||
terminal.options.fontSize = config.fontSize;
|
||||
terminal.options.fontFamily = fontFamily;
|
||||
terminal.options.rightClickSelectsWord = config.rightClickSelectsWord;
|
||||
terminal.options.fastScrollModifier = config.fastScrollModifier;
|
||||
terminal.options.fastScrollSensitivity = config.fastScrollSensitivity;
|
||||
terminal.options.minimumContrastRatio = config.minimumContrastRatio;
|
||||
terminal.options.letterSpacing = config.letterSpacing;
|
||||
terminal.options.lineHeight = config.lineHeight;
|
||||
terminal.options.bellStyle = config.bellStyle as
|
||||
| "none"
|
||||
| "sound"
|
||||
| "visual"
|
||||
| "both";
|
||||
|
||||
terminal.options.theme = {
|
||||
background: themeColors.background,
|
||||
foreground: themeColors.foreground,
|
||||
cursor: themeColors.cursor,
|
||||
cursorAccent: themeColors.cursorAccent,
|
||||
selectionBackground: themeColors.selectionBackground,
|
||||
selectionForeground: themeColors.selectionForeground,
|
||||
black: themeColors.black,
|
||||
red: themeColors.red,
|
||||
green: themeColors.green,
|
||||
yellow: themeColors.yellow,
|
||||
blue: themeColors.blue,
|
||||
magenta: themeColors.magenta,
|
||||
cyan: themeColors.cyan,
|
||||
white: themeColors.white,
|
||||
brightBlack: themeColors.brightBlack,
|
||||
brightRed: themeColors.brightRed,
|
||||
brightGreen: themeColors.brightGreen,
|
||||
brightYellow: themeColors.brightYellow,
|
||||
brightBlue: themeColors.brightBlue,
|
||||
brightMagenta: themeColors.brightMagenta,
|
||||
brightCyan: themeColors.brightCyan,
|
||||
brightWhite: themeColors.brightWhite,
|
||||
};
|
||||
|
||||
// Ensure terminal is correctly fitted if font-related options change
|
||||
if (fitAddonRef.current && isFitted) {
|
||||
performFit();
|
||||
}
|
||||
|
||||
// Refresh terminal to apply new theme colors to existing buffer content
|
||||
hardRefresh();
|
||||
}, [
|
||||
terminal,
|
||||
hostConfig.terminalConfig,
|
||||
previewTheme,
|
||||
isDarkMode,
|
||||
isFitted,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!terminal || !xtermRef.current) return;
|
||||
|
||||
const config = {
|
||||
...DEFAULT_TERMINAL_CONFIG,
|
||||
...(hostConfig.terminalConfig as any),
|
||||
};
|
||||
|
||||
const fontConfig = TERMINAL_FONTS.find(
|
||||
(f) => f.value === config.fontFamily,
|
||||
);
|
||||
const fontFamily = fontConfig?.fallback || TERMINAL_FONTS[0].fallback;
|
||||
|
||||
let themeColors;
|
||||
const activeTheme = previewTheme || config.theme;
|
||||
|
||||
if (activeTheme === "termix") {
|
||||
themeColors = isDarkMode
|
||||
? TERMINAL_THEMES.termixDark.colors
|
||||
: TERMINAL_THEMES.termixLight.colors;
|
||||
} else {
|
||||
themeColors =
|
||||
TERMINAL_THEMES[activeTheme]?.colors ||
|
||||
TERMINAL_THEMES.termixDark.colors;
|
||||
}
|
||||
|
||||
// Set initial options before opening the terminal
|
||||
terminal.options = {
|
||||
cursorBlink: config.cursorBlink,
|
||||
cursorStyle: config.cursorStyle,
|
||||
scrollback: config.scrollback,
|
||||
fontSize: config.fontSize,
|
||||
fontFamily,
|
||||
allowTransparency: true,
|
||||
allowTransparency: true, // MUST be set before open()
|
||||
convertEol: false,
|
||||
windowsMode: false,
|
||||
macOptionIsMeta: false,
|
||||
@@ -1624,7 +1995,6 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
letterSpacing: config.letterSpacing,
|
||||
lineHeight: config.lineHeight,
|
||||
bellStyle: config.bellStyle as "none" | "sound" | "visual" | "both",
|
||||
|
||||
theme: {
|
||||
background: themeColors.background,
|
||||
foreground: themeColors.foreground,
|
||||
@@ -1678,26 +2048,71 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
}
|
||||
|
||||
const element = xtermRef.current;
|
||||
const handleContextMenu = async (e: MouseEvent) => {
|
||||
if (!getUseRightClickCopyPaste()) return;
|
||||
const handleContextMenu = (e: MouseEvent) => {
|
||||
if (getUseRightClickCopyPaste()) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (terminal.hasSelection()) {
|
||||
const text = terminal.getSelection();
|
||||
navigator.clipboard
|
||||
.writeText(text)
|
||||
.then(() => terminal.clearSelection());
|
||||
} else {
|
||||
navigator.clipboard.readText().then((text) => {
|
||||
if (text) terminal.paste(text);
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!onOpenFileManager) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (terminal.hasSelection()) {
|
||||
const selection = terminal.getSelection();
|
||||
if (selection) {
|
||||
await writeTextToClipboard(selection);
|
||||
terminal.clearSelection();
|
||||
}
|
||||
} else {
|
||||
const text = await readTextFromClipboard();
|
||||
if (text) terminal.paste(text);
|
||||
}
|
||||
setContextMenu({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
hasSelection: terminal.hasSelection(),
|
||||
});
|
||||
};
|
||||
element?.addEventListener("contextmenu", handleContextMenu);
|
||||
|
||||
const handlePaste = (e: ClipboardEvent) => {
|
||||
const text = e.clipboardData?.getData("text");
|
||||
if (text) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
terminal.paste(text);
|
||||
}
|
||||
};
|
||||
element?.addEventListener("paste", handlePaste);
|
||||
|
||||
let tmuxDragTracking = false;
|
||||
const handleTmuxDragStart = (e: MouseEvent) => {
|
||||
if (e.button !== 0) return;
|
||||
if (!tmuxSessionNameRef.current) return;
|
||||
tmuxDragTracking = true;
|
||||
};
|
||||
const handleTmuxDragMove = () => {
|
||||
if (!tmuxDragTracking) return;
|
||||
tmuxDragTracking = false;
|
||||
if (tmuxCopyModeHintShownRef.current) return;
|
||||
tmuxCopyModeHintShownRef.current = true;
|
||||
toast.info(t("terminal.tmuxCopyHint"), { duration: 5000 });
|
||||
};
|
||||
const handleTmuxDragEnd = () => {
|
||||
tmuxDragTracking = false;
|
||||
};
|
||||
element?.addEventListener("mousedown", handleTmuxDragStart);
|
||||
element?.addEventListener("mousemove", handleTmuxDragMove);
|
||||
element?.addEventListener("mouseup", handleTmuxDragEnd);
|
||||
|
||||
const handleBackspaceMode = (e: KeyboardEvent) => {
|
||||
if (e.key !== "Backspace") return;
|
||||
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
||||
|
||||
const config = {
|
||||
...DEFAULT_TERMINAL_CONFIG,
|
||||
...(hostConfig.terminalConfig as any),
|
||||
};
|
||||
if (config.backspaceMode !== "control-h") return;
|
||||
|
||||
e.preventDefault();
|
||||
@@ -1729,11 +2144,15 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
resizeObserver.disconnect();
|
||||
clipboardProvider.dispose();
|
||||
element?.removeEventListener("contextmenu", handleContextMenu);
|
||||
element?.removeEventListener("paste", handlePaste);
|
||||
element?.removeEventListener("mousedown", handleTmuxDragStart);
|
||||
element?.removeEventListener("mousemove", handleTmuxDragMove);
|
||||
element?.removeEventListener("mouseup", handleTmuxDragEnd);
|
||||
element?.removeEventListener("keydown", handleBackspaceMode, true);
|
||||
if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current);
|
||||
if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
|
||||
};
|
||||
}, [xtermRef, terminal, hostConfig, isDarkMode]);
|
||||
}, [xtermRef, terminal]);
|
||||
|
||||
const isMountedRef = useRef(false);
|
||||
|
||||
@@ -1765,6 +2184,10 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
clearInterval(pingIntervalRef.current);
|
||||
pingIntervalRef.current = null;
|
||||
}
|
||||
if (pongTimeoutRef.current) {
|
||||
clearTimeout(pongTimeoutRef.current);
|
||||
pongTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
const persistenceEnabled =
|
||||
localStorage.getItem("enableTerminalSessionPersistence") === "true";
|
||||
@@ -1838,11 +2261,9 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
!e.metaKey &&
|
||||
e.key.toLowerCase() === "v"
|
||||
) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
readTextFromClipboard().then((text) => {
|
||||
if (text) terminal.paste(text);
|
||||
});
|
||||
// Let the browser handle Ctrl+V natively, the paste event
|
||||
// listener will intercept the result without triggering the
|
||||
// clipboard permission popup
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -2145,6 +2566,40 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
backgroundColor={backgroundColor}
|
||||
/>
|
||||
|
||||
{showDisconnectedOverlay && !isConnecting && (
|
||||
<div
|
||||
className="absolute inset-0 flex flex-col items-center justify-center gap-4 z-10"
|
||||
style={{ backgroundColor }}
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowDisconnectedOverlay(false);
|
||||
isUnmountingRef.current = false;
|
||||
shouldNotReconnectRef.current = false;
|
||||
isReconnectingRef.current = false;
|
||||
isConnectingRef.current = false;
|
||||
reconnectAttempts.current = 0;
|
||||
wasDisconnectedBySSH.current = false;
|
||||
wasConnectedRef.current = false;
|
||||
updateConnectionError(null);
|
||||
if (terminal) {
|
||||
terminal.clear();
|
||||
connectToHost(terminal.cols, terminal.rows);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("terminal.reconnect")}
|
||||
</Button>
|
||||
{onClose && (
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
{t("terminal.closeTab")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConnectionLog
|
||||
isConnecting={isConnecting}
|
||||
isConnected={isConnected}
|
||||
@@ -2191,6 +2646,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
requestId={opksshDialog.requestId}
|
||||
stage={opksshDialog.stage}
|
||||
error={opksshDialog.error}
|
||||
providers={opksshDialog.providers}
|
||||
onCancel={() => {
|
||||
if (webSocketRef.current) {
|
||||
webSocketRef.current.send(
|
||||
@@ -2217,6 +2673,22 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
);
|
||||
}
|
||||
}}
|
||||
onSelectProvider={(alias) => {
|
||||
if (!opksshDialog.authUrl) return;
|
||||
const selectUrl = `${opksshDialog.authUrl}/select?op=${encodeURIComponent(alias)}`;
|
||||
window.open(selectUrl, "_blank");
|
||||
if (webSocketRef.current) {
|
||||
webSocketRef.current.send(
|
||||
JSON.stringify({
|
||||
type: "opkssh_browser_opened",
|
||||
data: { requestId: opksshDialog.requestId },
|
||||
}),
|
||||
);
|
||||
}
|
||||
setOpksshDialog((prev) =>
|
||||
prev ? { ...prev, stage: "waiting" } : null,
|
||||
);
|
||||
}}
|
||||
backgroundColor={backgroundColor}
|
||||
/>
|
||||
)}
|
||||
@@ -2254,6 +2726,37 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
/>
|
||||
)}
|
||||
|
||||
{tmuxSessionPicker && (
|
||||
<TmuxSessionPicker
|
||||
isOpen={true}
|
||||
sessions={tmuxSessionPicker.sessions}
|
||||
onSelect={(sessionName) => {
|
||||
setTmuxSessionPicker(null);
|
||||
if (webSocketRef.current?.readyState === WebSocket.OPEN) {
|
||||
webSocketRef.current.send(
|
||||
JSON.stringify({
|
||||
type: "tmux_attach",
|
||||
data: { sessionName },
|
||||
}),
|
||||
);
|
||||
}
|
||||
}}
|
||||
onCreateNew={() => {
|
||||
setTmuxSessionPicker(null);
|
||||
if (webSocketRef.current?.readyState === WebSocket.OPEN) {
|
||||
webSocketRef.current.send(
|
||||
JSON.stringify({
|
||||
type: "tmux_attach",
|
||||
data: { sessionName: "" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
}}
|
||||
onCancel={() => setTmuxSessionPicker(null)}
|
||||
backgroundColor={backgroundColor}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CommandAutocomplete
|
||||
visible={showAutocomplete}
|
||||
suggestions={autocompleteSuggestions}
|
||||
@@ -2261,6 +2764,29 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
position={autocompletePosition}
|
||||
onSelect={handleAutocompleteSelect}
|
||||
/>
|
||||
|
||||
{contextMenu && (
|
||||
<TerminalContextMenu
|
||||
x={contextMenu.x}
|
||||
y={contextMenu.y}
|
||||
hasSelection={contextMenu.hasSelection}
|
||||
showCopyPaste={getUseRightClickCopyPaste()}
|
||||
showOpenFileManager={!!onOpenFileManager}
|
||||
onCopy={async () => {
|
||||
const selection = terminal?.getSelection();
|
||||
if (selection) {
|
||||
await writeTextToClipboard(selection);
|
||||
terminal?.clearSelection();
|
||||
}
|
||||
}}
|
||||
onPaste={async () => {
|
||||
const text = await readTextFromClipboard();
|
||||
if (text) terminal?.paste(text);
|
||||
}}
|
||||
onOpenFileManager={() => onOpenFileManager?.()}
|
||||
onClose={() => setContextMenu(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -12,6 +12,7 @@ import { CredentialsManager } from "@/ui/desktop/apps/host-manager/credentials/C
|
||||
import { CredentialEditor } from "@/ui/desktop/apps/host-manager/credentials/CredentialEditor.tsx";
|
||||
import { useSidebar } from "@/components/ui/sidebar.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { exportSSHHostWithCredentials } from "@/ui/main-axios.ts";
|
||||
import type { SSHHost, HostManagerProps } from "../../../types/index";
|
||||
|
||||
export function HostManager({
|
||||
@@ -93,9 +94,13 @@ export function HostManager({
|
||||
}
|
||||
|
||||
if (hostConfig && hostConfig.id !== lastProcessedHostIdRef.current) {
|
||||
setEditingHost(hostConfig);
|
||||
setIsAddingHost(false);
|
||||
lastProcessedHostIdRef.current = hostConfig.id;
|
||||
setIsAddingHost(false);
|
||||
exportSSHHostWithCredentials(hostConfig.id)
|
||||
.then((fullHost) =>
|
||||
setEditingHost({ ...hostConfig, ...fullHost } as SSHHost),
|
||||
)
|
||||
.catch(() => setEditingHost(hostConfig));
|
||||
} else if (!hostConfig && editingHost) {
|
||||
setEditingHost(null);
|
||||
setIsAddingHost(false);
|
||||
@@ -111,17 +116,26 @@ export function HostManager({
|
||||
setActiveTab(normalizedTab);
|
||||
}
|
||||
if (hostConfig && hostConfig.id !== lastProcessedHostIdRef.current) {
|
||||
setEditingHost(hostConfig);
|
||||
setIsAddingHost(false);
|
||||
lastProcessedHostIdRef.current = hostConfig.id;
|
||||
setIsAddingHost(false);
|
||||
exportSSHHostWithCredentials(hostConfig.id)
|
||||
.then((fullHost) =>
|
||||
setEditingHost({ ...hostConfig, ...fullHost } as SSHHost),
|
||||
)
|
||||
.catch(() => setEditingHost(hostConfig));
|
||||
}
|
||||
}
|
||||
}, [_updateTimestamp, initialTab, hostConfig]);
|
||||
|
||||
const handleEditHost = (host: SSHHost) => {
|
||||
setEditingHost(host);
|
||||
const handleEditHost = async (host: SSHHost) => {
|
||||
setIsAddingHost(false);
|
||||
lastProcessedHostIdRef.current = host.id;
|
||||
try {
|
||||
const fullHost = await exportSSHHostWithCredentials(host.id);
|
||||
setEditingHost({ ...host, ...fullHost } as SSHHost);
|
||||
} catch {
|
||||
setEditingHost(host);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddHost = () => {
|
||||
|
||||
@@ -413,6 +413,7 @@ export function HostManagerEditor({
|
||||
sudoPassword: z.string().optional(),
|
||||
keepaliveInterval: z.number().min(0).max(300000).optional(),
|
||||
keepaliveCountMax: z.number().min(0).max(100).optional(),
|
||||
autoTmux: z.boolean(),
|
||||
})
|
||||
.optional(),
|
||||
forceKeyboardInteractive: z.boolean().optional(),
|
||||
@@ -448,6 +449,16 @@ export function HostManagerEditor({
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
macAddress: z.string().optional(),
|
||||
portKnockSequence: z
|
||||
.array(
|
||||
z.object({
|
||||
port: z.coerce.number().min(1).max(65535),
|
||||
protocol: z.enum(["tcp", "udp"]).default("tcp"),
|
||||
delay: z.coerce.number().min(0).max(60000).default(100),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
enableDocker: z.boolean().default(false),
|
||||
domain: z.string().optional(),
|
||||
security: z.string().optional(),
|
||||
@@ -507,11 +518,7 @@ export function HostManagerEditor({
|
||||
});
|
||||
}
|
||||
if (!data.keyType) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: t("hosts.keyTypeRequired", "Key type is required"),
|
||||
path: ["keyType"],
|
||||
});
|
||||
data.keyType = "auto";
|
||||
}
|
||||
} else if (data.authType === "credential") {
|
||||
if (!data.credentialId) {
|
||||
@@ -522,19 +529,6 @@ export function HostManagerEditor({
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
data.tunnelConnections.forEach((connection, index) => {
|
||||
if (
|
||||
connection.endpointHost &&
|
||||
!sshConfigurations.includes(connection.endpointHost)
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: t("hosts.mustSelectValidSshConfig"),
|
||||
path: ["tunnelConnections", index, "endpointHost"],
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof formSchema>;
|
||||
@@ -580,6 +574,8 @@ export function HostManagerEditor({
|
||||
socks5Username: "",
|
||||
socks5Password: "",
|
||||
socks5ProxyChain: [],
|
||||
macAddress: "",
|
||||
portKnockSequence: [],
|
||||
enableDocker: false,
|
||||
domain: "",
|
||||
security: "any",
|
||||
@@ -821,6 +817,10 @@ export function HostManagerEditor({
|
||||
socks5ProxyChain: Array.isArray(cleanedHost.socks5ProxyChain)
|
||||
? cleanedHost.socks5ProxyChain
|
||||
: [],
|
||||
macAddress: cleanedHost.macAddress || "",
|
||||
portKnockSequence: Array.isArray(cleanedHost.portKnockSequence)
|
||||
? cleanedHost.portKnockSequence
|
||||
: [],
|
||||
enableDocker: Boolean(cleanedHost.enableDocker),
|
||||
domain: (cleanedHost as any).domain || "",
|
||||
security: (cleanedHost as any).security || "any",
|
||||
@@ -1086,6 +1086,7 @@ export function HostManagerEditor({
|
||||
socks5Username: "general",
|
||||
socks5Password: "general",
|
||||
socks5ProxyChain: "general",
|
||||
portKnockSequence: "general",
|
||||
quickActions: "general",
|
||||
enableTerminal: "terminal",
|
||||
terminalConfig: "terminal",
|
||||
@@ -1248,7 +1249,10 @@ export function HostManagerEditor({
|
||||
};
|
||||
|
||||
const handleSshConfigClick = (config: string, index: number) => {
|
||||
form.setValue(`tunnelConnections.${index}.endpointHost`, config);
|
||||
form.setValue(`tunnelConnections.${index}.endpointHost`, config, {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
});
|
||||
setSshConfigDropdownOpen((prev) => ({ ...prev, [index]: false }));
|
||||
};
|
||||
|
||||
|
||||
@@ -35,14 +35,15 @@ import {
|
||||
updateSSHHost,
|
||||
renameFolder,
|
||||
exportSSHHostWithCredentials,
|
||||
exportAllSSHHosts,
|
||||
getSSHFolders,
|
||||
updateFolderMetadata,
|
||||
deleteAllHostsInFolder,
|
||||
refreshServerPolling,
|
||||
isElectron,
|
||||
getConfiguredServerUrl,
|
||||
getGuacamoleDpi,
|
||||
getGuacamoleTokenFromHost,
|
||||
getGuacamoleToken,
|
||||
logActivity,
|
||||
} from "@/ui/main-axios.ts";
|
||||
import { useServerStatus } from "@/ui/contexts/ServerStatusContext";
|
||||
@@ -89,6 +90,8 @@ import {
|
||||
Monitor,
|
||||
MessagesSquare,
|
||||
Eye,
|
||||
ChevronsDownUp,
|
||||
ChevronsUpDown,
|
||||
} from "lucide-react";
|
||||
import type {
|
||||
SSHHost,
|
||||
@@ -843,6 +846,39 @@ export function HostManagerViewer({
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
const handleExportAll = () => {
|
||||
confirmWithToast(
|
||||
t("hosts.exportAllSensitiveWarning"),
|
||||
async () => {
|
||||
setExporting(true);
|
||||
try {
|
||||
const data = await exportAllSSHHosts();
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], {
|
||||
type: "application/json",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `termix-hosts-export-${new Date().toISOString().slice(0, 10)}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success(
|
||||
t("hosts.exportedAllHosts", { count: data.hosts.length }),
|
||||
);
|
||||
} catch {
|
||||
toast.error(t("hosts.failedToExportAllHosts"));
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
},
|
||||
"destructive",
|
||||
);
|
||||
};
|
||||
|
||||
const handleJsonImport = async (
|
||||
event: React.ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
@@ -1160,6 +1196,15 @@ export function HostManagerViewer({
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={exporting || hosts.length === 0}
|
||||
onClick={handleExportAll}
|
||||
>
|
||||
{exporting ? t("hosts.exporting") : t("hosts.exportAllJson")}
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" size="sm" onClick={handleDownloadSample}>
|
||||
{t("hosts.downloadSample")}
|
||||
</Button>
|
||||
@@ -1215,6 +1260,28 @@ export function HostManagerViewer({
|
||||
<ListChecks className="h-4 w-4 mr-2" />
|
||||
{selectionMode ? t("hosts.exitSelectMode") : t("hosts.selectMode")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-9"
|
||||
onClick={() => {
|
||||
if (openAccordions.length > 0) {
|
||||
setOpenAccordions([]);
|
||||
} else {
|
||||
setOpenAccordions(folderKeys);
|
||||
}
|
||||
}}
|
||||
title={
|
||||
openAccordions.length > 0
|
||||
? t("hosts.collapseAll", "Collapse All")
|
||||
: t("hosts.expandAll", "Expand All")
|
||||
}
|
||||
>
|
||||
{openAccordions.length > 0 ? (
|
||||
<ChevronsDownUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronsUpDown className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1 min-h-0">
|
||||
@@ -1921,19 +1988,9 @@ export function HostManagerViewer({
|
||||
| "vnc"
|
||||
| "telnet";
|
||||
const result =
|
||||
await getGuacamoleToken({
|
||||
protocol,
|
||||
hostname: host.ip,
|
||||
port: host.port,
|
||||
username: host.username,
|
||||
password: host.password,
|
||||
domain: host.domain,
|
||||
security: host.security,
|
||||
ignoreCert:
|
||||
host.ignoreCert,
|
||||
guacamoleConfig:
|
||||
host.guacamoleConfig as any,
|
||||
});
|
||||
await getGuacamoleTokenFromHost(
|
||||
host.id,
|
||||
);
|
||||
|
||||
addTab({
|
||||
type: protocol,
|
||||
@@ -1951,6 +2008,9 @@ export function HostManagerViewer({
|
||||
security: host.security,
|
||||
"ignore-cert":
|
||||
host.ignoreCert,
|
||||
dpi: getGuacamoleDpi(
|
||||
host,
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -224,49 +224,73 @@ export function HostGeneralTab({
|
||||
)}
|
||||
/>
|
||||
|
||||
{connectionType !== "vnc" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => {
|
||||
const isCredentialAuth = authTab === "credential";
|
||||
const credentialId = form.watch("credentialId");
|
||||
const overrideEnabled = form.watch("overrideCredentialUsername");
|
||||
const selectedCredential = credentials.find(
|
||||
(c) => c.id === credentialId,
|
||||
);
|
||||
const shouldDisable =
|
||||
isCredentialAuth &&
|
||||
selectedCredential?.username &&
|
||||
!overrideEnabled;
|
||||
|
||||
return (
|
||||
<FormItem className="col-span-6">
|
||||
<FormLabel>{t("hosts.username")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t("placeholders.username")}
|
||||
disabled={shouldDisable}
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
field.onChange(e.target.value);
|
||||
if (
|
||||
isCredentialAuth &&
|
||||
selectedCredential &&
|
||||
!selectedCredential.username &&
|
||||
e.target.value.trim() !== ""
|
||||
) {
|
||||
form.setValue("overrideCredentialUsername", true);
|
||||
}
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
field.onChange(e.target.value.trim());
|
||||
field.onBlur();
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-12 gap-4 mt-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => {
|
||||
const isCredentialAuth = authTab === "credential";
|
||||
const credentialId = form.watch("credentialId");
|
||||
const overrideEnabled = form.watch("overrideCredentialUsername");
|
||||
const selectedCredential = credentials.find(
|
||||
(c) => c.id === credentialId,
|
||||
);
|
||||
const shouldDisable =
|
||||
isCredentialAuth &&
|
||||
selectedCredential?.username &&
|
||||
!overrideEnabled;
|
||||
|
||||
return (
|
||||
<FormItem className="col-span-6">
|
||||
<FormLabel>{t("hosts.username")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t("placeholders.username")}
|
||||
disabled={shouldDisable}
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
field.onChange(e.target.value);
|
||||
if (
|
||||
isCredentialAuth &&
|
||||
selectedCredential &&
|
||||
!selectedCredential.username &&
|
||||
e.target.value.trim() !== ""
|
||||
) {
|
||||
form.setValue("overrideCredentialUsername", true);
|
||||
}
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
field.onChange(e.target.value.trim());
|
||||
field.onBlur();
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
name="macAddress"
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-5">
|
||||
<FormLabel>{t("hosts.macAddress")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="AA:BB:CC:DD:EE:FF"
|
||||
{...field}
|
||||
onBlur={(e) => {
|
||||
field.onChange(e.target.value.trim());
|
||||
field.onBlur();
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{t("hosts.macAddressDesc")}</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<FormLabel className="mb-3 mt-3 font-bold">
|
||||
@@ -1438,6 +1462,120 @@ export function HostGeneralTab({
|
||||
)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="port-knocking">
|
||||
<AccordionTrigger>{t("hosts.portKnocking")}</AccordionTrigger>
|
||||
<AccordionContent className="space-y-3 pt-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("hosts.portKnockingDesc")}
|
||||
</p>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="portKnockSequence"
|
||||
render={({ field }) => {
|
||||
const sequence = field.value || [];
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{sequence.map(
|
||||
(
|
||||
knock: {
|
||||
port: number;
|
||||
protocol?: string;
|
||||
delay?: number;
|
||||
},
|
||||
index: number,
|
||||
) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={t("hosts.port")}
|
||||
value={knock.port || ""}
|
||||
onChange={(e) => {
|
||||
const updated = [...sequence];
|
||||
updated[index] = {
|
||||
...updated[index],
|
||||
port: parseInt(e.target.value) || 0,
|
||||
};
|
||||
field.onChange(updated);
|
||||
}}
|
||||
className="w-24"
|
||||
/>
|
||||
<Select
|
||||
value={knock.protocol || "tcp"}
|
||||
onValueChange={(v) => {
|
||||
const updated = [...sequence];
|
||||
updated[index] = {
|
||||
...updated[index],
|
||||
protocol: v,
|
||||
};
|
||||
field.onChange(updated);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-20">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="tcp">TCP</SelectItem>
|
||||
<SelectItem value="udp">UDP</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={t("hosts.delayMs")}
|
||||
value={knock.delay ?? 100}
|
||||
onChange={(e) => {
|
||||
const updated = [...sequence];
|
||||
updated[index] = {
|
||||
...updated[index],
|
||||
delay: parseInt(e.target.value) || 0,
|
||||
};
|
||||
field.onChange(updated);
|
||||
}}
|
||||
className="w-20"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
ms
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
field.onChange(
|
||||
sequence.filter(
|
||||
(_: unknown, i: number) => i !== index,
|
||||
),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
field.onChange([
|
||||
...sequence,
|
||||
{ port: 0, protocol: "tcp", delay: 100 },
|
||||
]);
|
||||
}}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
{t("hosts.addKnock")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -47,9 +47,11 @@ import {
|
||||
import { TerminalPreview } from "@/ui/desktop/apps/features/terminal/TerminalPreview.tsx";
|
||||
import type { HostTerminalTabProps } from "./shared/tab-types";
|
||||
import React from "react";
|
||||
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
|
||||
|
||||
export function HostTerminalTab({ form, snippets, t }: HostTerminalTabProps) {
|
||||
const [snippetPopoverOpen, setSnippetPopoverOpen] = React.useState(false);
|
||||
const { setPreviewTerminalTheme } = useTabs() as any;
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<FormField
|
||||
@@ -103,9 +105,15 @@ export function HostTerminalTab({ form, snippets, t }: HostTerminalTabProps) {
|
||||
<SelectValue placeholder={t("hosts.selectTheme")} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectContent
|
||||
onMouseLeave={() => setPreviewTerminalTheme(null)}
|
||||
>
|
||||
{Object.entries(TERMINAL_THEMES).map(([key, theme]) => (
|
||||
<SelectItem key={key} value={key}>
|
||||
<SelectItem
|
||||
key={key}
|
||||
value={key}
|
||||
onMouseEnter={() => setPreviewTerminalTheme(key)}
|
||||
>
|
||||
{theme.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
@@ -637,6 +645,25 @@ export function HostTerminalTab({ form, snippets, t }: HostTerminalTabProps) {
|
||||
/>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="terminalConfig.autoTmux"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 bg-elevated dark:bg-input/30">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>{t("hosts.autoTmux")}</FormLabel>
|
||||
<FormDescription>{t("hosts.autoTmuxDesc")}</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="terminalConfig.sudoPasswordAutoFill"
|
||||
|
||||
@@ -60,6 +60,8 @@ import {
|
||||
Archive,
|
||||
HardDrive,
|
||||
Globe,
|
||||
Share2,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -79,6 +81,13 @@ import {
|
||||
renameSnippetFolder,
|
||||
deleteSnippetFolder,
|
||||
reorderSnippets,
|
||||
getSharedSnippets,
|
||||
shareSnippet,
|
||||
getSnippetAccess,
|
||||
revokeSnippetAccess,
|
||||
getRoles,
|
||||
getUserList,
|
||||
type AccessRecord,
|
||||
} from "@/ui/main-axios.ts";
|
||||
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
|
||||
import type { Snippet, SnippetData, SnippetFolder } from "../../../../types";
|
||||
@@ -180,6 +189,30 @@ export function SSHToolsSidebar({
|
||||
|
||||
const [snippets, setSnippets] = useState<Snippet[]>([]);
|
||||
const [snippetFolders, setSnippetFolders] = useState<SnippetFolder[]>([]);
|
||||
const [sharedSnippetsList, setSharedSnippetsList] = useState<
|
||||
Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
content: string;
|
||||
description: string | null;
|
||||
folder: string | null;
|
||||
ownerUsername: string;
|
||||
}>
|
||||
>([]);
|
||||
const [shareDialogSnippet, setShareDialogSnippet] = useState<Snippet | null>(
|
||||
null,
|
||||
);
|
||||
const [shareTargetType, setShareTargetType] = useState<"user" | "role">(
|
||||
"user",
|
||||
);
|
||||
const [shareUsers, setShareUsers] = useState<
|
||||
Array<{ id: string; username: string }>
|
||||
>([]);
|
||||
const [shareRoles, setShareRoles] = useState<
|
||||
Array<{ id: number; name: string; displayName?: string }>
|
||||
>([]);
|
||||
const [shareTargetId, setShareTargetId] = useState("");
|
||||
const [shareAccessList, setShareAccessList] = useState<AccessRecord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showDialog, setShowDialog] = useState(false);
|
||||
const [editingSnippet, setEditingSnippet] = useState<Snippet | null>(null);
|
||||
@@ -222,6 +255,9 @@ export function SSHToolsSidebar({
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [historyRefreshCounter, setHistoryRefreshCounter] = useState(0);
|
||||
const commandHistoryScrollRef = React.useRef<HTMLDivElement>(null);
|
||||
const [commandHistoryEnabled, setCommandHistoryEnabled] = useState<boolean>(
|
||||
() => localStorage.getItem("commandHistoryTracking") === "true",
|
||||
);
|
||||
|
||||
const [splitMode, setSplitMode] = useState<
|
||||
"none" | "2" | "3" | "4" | "5" | "6"
|
||||
@@ -240,6 +276,14 @@ export function SSHToolsSidebar({
|
||||
const startWidthRef = React.useRef<number>(sidebarWidth);
|
||||
|
||||
const terminalTabs = tabs.filter((tab: TabData) => tab.type === "terminal");
|
||||
|
||||
useEffect(() => {
|
||||
const terminalIds = new Set(terminalTabs.map((t) => t.id));
|
||||
setSelectedSnippetTabIds((prev) =>
|
||||
prev.filter((id) => terminalIds.has(id)),
|
||||
);
|
||||
}, [terminalTabs.length]);
|
||||
|
||||
const activeUiTab = tabs.find((tab) => tab.id === currentTab);
|
||||
const activeTerminal =
|
||||
activeUiTab?.type === "terminal" ? activeUiTab : undefined;
|
||||
@@ -327,6 +371,17 @@ export function SSHToolsSidebar({
|
||||
}
|
||||
}, [isOpen, activeTab, activeTerminalHostId]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleChange = () => {
|
||||
setCommandHistoryEnabled(
|
||||
localStorage.getItem("commandHistoryTracking") === "true",
|
||||
);
|
||||
};
|
||||
window.addEventListener("commandHistoryTrackingChanged", handleChange);
|
||||
return () =>
|
||||
window.removeEventListener("commandHistoryTrackingChanged", handleChange);
|
||||
}, []);
|
||||
|
||||
const filteredCommands = searchQuery
|
||||
? commandHistory.filter((cmd) =>
|
||||
cmd.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
@@ -625,16 +680,19 @@ export function SSHToolsSidebar({
|
||||
const fetchSnippets = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [snippetsData, foldersData] = await Promise.all([
|
||||
const [snippetsData, foldersData, sharedData] = await Promise.all([
|
||||
getSnippets(),
|
||||
getSnippetFolders(),
|
||||
getSharedSnippets().catch(() => ({ sharedSnippets: [] })),
|
||||
]);
|
||||
setSnippets(Array.isArray(snippetsData) ? snippetsData : []);
|
||||
setSnippetFolders(Array.isArray(foldersData) ? foldersData : []);
|
||||
setSharedSnippetsList(sharedData.sharedSnippets || []);
|
||||
} catch {
|
||||
toast.error(t("snippets.failedToFetch"));
|
||||
setSnippets([]);
|
||||
setSnippetFolders([]);
|
||||
setSharedSnippetsList([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -675,6 +733,63 @@ export function SSHToolsSidebar({
|
||||
);
|
||||
};
|
||||
|
||||
const handleOpenShareDialog = async (snippet: Snippet) => {
|
||||
setShareDialogSnippet(snippet);
|
||||
setShareTargetId("");
|
||||
setShareTargetType("user");
|
||||
try {
|
||||
const [usersData, rolesData, accessData] = await Promise.all([
|
||||
getUserList(),
|
||||
getRoles(),
|
||||
getSnippetAccess(snippet.id),
|
||||
]);
|
||||
setShareUsers(
|
||||
(usersData?.users || []).map((u: Record<string, unknown>) => ({
|
||||
id: u.id as string,
|
||||
username: u.username as string,
|
||||
})),
|
||||
);
|
||||
setShareRoles(
|
||||
(rolesData?.roles || []).map(
|
||||
(r: { id: number; name: string; displayName?: string }) => r,
|
||||
),
|
||||
);
|
||||
setShareAccessList(accessData.accessList || []);
|
||||
} catch {
|
||||
toast.error(t("snippets.failedToLoadShareData"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleShare = async () => {
|
||||
if (!shareDialogSnippet || !shareTargetId) return;
|
||||
try {
|
||||
await shareSnippet(shareDialogSnippet.id, {
|
||||
targetType: shareTargetType,
|
||||
targetUserId: shareTargetType === "user" ? shareTargetId : undefined,
|
||||
targetRoleId:
|
||||
shareTargetType === "role" ? parseInt(shareTargetId) : undefined,
|
||||
});
|
||||
toast.success(t("snippets.shareSuccess"));
|
||||
const accessData = await getSnippetAccess(shareDialogSnippet.id);
|
||||
setShareAccessList(accessData.accessList || []);
|
||||
setShareTargetId("");
|
||||
} catch {
|
||||
toast.error(t("snippets.shareFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleRevokeSnippetAccess = async (accessId: number) => {
|
||||
if (!shareDialogSnippet) return;
|
||||
try {
|
||||
await revokeSnippetAccess(shareDialogSnippet.id, accessId);
|
||||
toast.success(t("snippets.revokeSuccess"));
|
||||
const accessData = await getSnippetAccess(shareDialogSnippet.id);
|
||||
setShareAccessList(accessData.accessList || []);
|
||||
} catch {
|
||||
toast.error(t("snippets.revokeFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const errors = {
|
||||
name: !formData.name.trim(),
|
||||
@@ -736,7 +851,7 @@ export function SSHToolsSidebar({
|
||||
selectedSnippetTabIds.forEach((tabId) => {
|
||||
const tab = tabs.find((t: TabData) => t.id === tabId);
|
||||
if (tab?.terminalRef?.current?.sendInput) {
|
||||
tab.terminalRef.current.sendInput(snippet.content + "\n");
|
||||
tab.terminalRef.current.sendInput(snippet.content + "\r");
|
||||
}
|
||||
});
|
||||
toast.success(
|
||||
@@ -1296,6 +1411,30 @@ export function SSHToolsSidebar({
|
||||
})
|
||||
: t("snippets.executeOnCurrent")}
|
||||
</p>
|
||||
<div className="flex gap-2 mb-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs h-6 px-2"
|
||||
onClick={() =>
|
||||
setSelectedSnippetTabIds(
|
||||
terminalTabs.map((t) => t.id),
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("snippets.selectAll", "Select All")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs h-6 px-2"
|
||||
onClick={() => setSelectedSnippetTabIds([])}
|
||||
>
|
||||
{t("snippets.deselectAll", "Deselect All")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 max-h-32 overflow-y-auto thin-scrollbar">
|
||||
{terminalTabs.map((tab) => (
|
||||
<Button
|
||||
@@ -1375,6 +1514,118 @@ export function SSHToolsSidebar({
|
||||
) : (
|
||||
<TooltipProvider>
|
||||
<div className="space-y-3 overflow-y-auto flex-1 min-h-0 thin-scrollbar">
|
||||
{sharedSnippetsList.length > 0 &&
|
||||
(() => {
|
||||
const isCollapsed =
|
||||
collapsedFolders.has("__shared__");
|
||||
const FolderIcon = getFolderIcon("__shared__");
|
||||
return (
|
||||
<div key="__shared__">
|
||||
<div className="flex items-center gap-2 mb-2 hover:bg-hover-alt p-2 rounded-lg transition-colors group/folder">
|
||||
<div
|
||||
className="flex items-center gap-2 flex-1 cursor-pointer"
|
||||
onClick={() => toggleFolder("__shared__")}
|
||||
>
|
||||
{isCollapsed ? (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<FolderIcon className="h-4 w-4" />
|
||||
<span className="text-sm font-semibold">
|
||||
{t("snippets.sharedWithYou")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground ml-auto">
|
||||
{sharedSnippetsList.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 opacity-0 pointer-events-none">
|
||||
<div className="h-6 w-6" />
|
||||
<div className="h-6 w-6" />
|
||||
</div>
|
||||
</div>
|
||||
{!isCollapsed && (
|
||||
<div className="space-y-2 ml-6">
|
||||
{sharedSnippetsList.map((snippet) => (
|
||||
<div
|
||||
key={`shared-${snippet.id}`}
|
||||
className="bg-field border border-input rounded-lg hover:shadow-lg hover:border-edge-hover hover:bg-hover-alt transition-all duration-200 p-3 group"
|
||||
>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-medium text-foreground mb-1">
|
||||
{snippet.name}
|
||||
</h3>
|
||||
{snippet.description && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{snippet.description}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{t("snippets.sharedBy", {
|
||||
username:
|
||||
snippet.ownerUsername,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-muted/30 rounded p-2 mb-3">
|
||||
<code className="text-xs font-mono break-all line-clamp-2 text-muted-foreground">
|
||||
{snippet.content}
|
||||
</code>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="flex-1"
|
||||
onClick={() =>
|
||||
handleExecute(
|
||||
snippet as unknown as Snippet,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Play className="w-3 h-3 mr-1" />
|
||||
{t("snippets.run")}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{t("snippets.runTooltip")}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
handleCopy(
|
||||
snippet as unknown as Snippet,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Copy className="w-3 h-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{t("snippets.copyTooltip")}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{Array.from(groupSnippetsByFolder()).map(
|
||||
([folderName, folderSnippets]) => {
|
||||
const folderMetadata = snippetFolders.find(
|
||||
@@ -1584,6 +1835,27 @@ export function SSHToolsSidebar({
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
handleOpenShareDialog(
|
||||
snippet,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Share2 className="w-3 h-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{t("snippets.shareTooltip")}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -1602,131 +1874,151 @@ export function SSHToolsSidebar({
|
||||
value="command-history"
|
||||
className="flex flex-col flex-1 overflow-hidden"
|
||||
>
|
||||
<div className="space-y-2 flex-shrink-0 mb-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t("commandHistory.searchPlaceholder")}
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value);
|
||||
}}
|
||||
className="pl-10 pr-10"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-1 top-1/2 transform -translate-y-1/2 h-7 w-7 p-0"
|
||||
onClick={() => setSearchQuery("")}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{!commandHistoryEnabled ? (
|
||||
<div className="flex flex-col items-center justify-center flex-1 py-8 text-center px-4">
|
||||
<div className="bg-muted/40 border rounded-lg p-5 space-y-2">
|
||||
<p className="font-medium text-sm">
|
||||
{t("commandHistory.disabledTitle")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("commandHistory.disabledDescription")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground bg-muted/30 px-2 py-1.5 rounded">
|
||||
{t("commandHistory.tabHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden min-h-0">
|
||||
{historyError ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="bg-destructive/10 border border-destructive/20 rounded-lg p-4 mb-4">
|
||||
<p className="text-destructive font-medium mb-2">
|
||||
{t("commandHistory.error")}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{historyError}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-2 flex-shrink-0 mb-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t(
|
||||
"commandHistory.searchPlaceholder",
|
||||
)}
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value);
|
||||
}}
|
||||
className="pl-10 pr-10"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-1 top-1/2 transform -translate-y-1/2 h-7 w-7 p-0"
|
||||
onClick={() => setSearchQuery("")}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
onClick={() =>
|
||||
setHistoryRefreshCounter((prev) => prev + 1)
|
||||
}
|
||||
variant="outline"
|
||||
>
|
||||
{t("common.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
) : !activeTerminal ? (
|
||||
<div className="text-center text-muted-foreground py-8">
|
||||
<Terminal className="h-12 w-12 mb-4 opacity-20 mx-auto" />
|
||||
<p className="mb-2 font-medium">
|
||||
{t("commandHistory.noTerminal")}{" "}
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
{t("commandHistory.noTerminalHint")}
|
||||
<p className="text-xs text-muted-foreground bg-muted/30 px-2 py-1.5 rounded">
|
||||
{t("commandHistory.tabHint")}
|
||||
</p>
|
||||
</div>
|
||||
) : isHistoryLoading && commandHistory.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-8">
|
||||
<Loader2 className="h-12 w-12 mb-4 opacity-20 mx-auto animate-spin" />
|
||||
<p className="mb-2 font-medium">
|
||||
{t("commandHistory.loading")}{" "}
|
||||
</p>
|
||||
</div>
|
||||
) : filteredCommands.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-8">
|
||||
{searchQuery ? (
|
||||
<>
|
||||
<Search className="h-12 w-12 mb-2 opacity-20 mx-auto" />
|
||||
|
||||
<div className="flex-1 overflow-hidden min-h-0">
|
||||
{historyError ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="bg-destructive/10 border border-destructive/20 rounded-lg p-4 mb-4">
|
||||
<p className="text-destructive font-medium mb-2">
|
||||
{t("commandHistory.error")}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{historyError}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() =>
|
||||
setHistoryRefreshCounter((prev) => prev + 1)
|
||||
}
|
||||
variant="outline"
|
||||
>
|
||||
{t("common.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
) : !activeTerminal ? (
|
||||
<div className="text-center text-muted-foreground py-8">
|
||||
<Terminal className="h-12 w-12 mb-4 opacity-20 mx-auto" />
|
||||
<p className="mb-2 font-medium">
|
||||
{t("commandHistory.noResults")}
|
||||
{t("commandHistory.noTerminal")}{" "}
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
{t("commandHistory.noResultsHint", {
|
||||
query: searchQuery,
|
||||
})}
|
||||
{t("commandHistory.noTerminalHint")}
|
||||
</p>
|
||||
</>
|
||||
</div>
|
||||
) : isHistoryLoading &&
|
||||
commandHistory.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-8">
|
||||
<Loader2 className="h-12 w-12 mb-4 opacity-20 mx-auto animate-spin" />
|
||||
<p className="mb-2 font-medium">
|
||||
{t("commandHistory.loading")}{" "}
|
||||
</p>
|
||||
</div>
|
||||
) : filteredCommands.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-8">
|
||||
{searchQuery ? (
|
||||
<>
|
||||
<Search className="h-12 w-12 mb-2 opacity-20 mx-auto" />
|
||||
<p className="mb-2 font-medium">
|
||||
{t("commandHistory.noResults")}
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
{t("commandHistory.noResultsHint", {
|
||||
query: searchQuery,
|
||||
})}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="mb-2 font-medium">
|
||||
{t("commandHistory.empty")}
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
{t("commandHistory.emptyHint")}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="mb-2 font-medium">
|
||||
{t("commandHistory.empty")}
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
{t("commandHistory.emptyHint")}
|
||||
</p>
|
||||
</>
|
||||
<div
|
||||
ref={commandHistoryScrollRef}
|
||||
className="space-y-2 overflow-y-auto h-full thin-scrollbar"
|
||||
>
|
||||
{filteredCommands.map((command, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="bg-canvas border-2 border-edge rounded-md px-3 py-2.5 hover:bg-hover-alt hover:border-edge-hover transition-all duration-200 group h-12 flex items-center"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 w-full min-w-0">
|
||||
<span
|
||||
className="flex-1 font-mono text-sm cursor-pointer text-foreground truncate"
|
||||
onClick={() =>
|
||||
handleCommandSelect(command)
|
||||
}
|
||||
title={command}
|
||||
>
|
||||
{command}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100 hover:bg-red-500/20 hover:text-red-400 flex-shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleCommandDelete(command);
|
||||
}}
|
||||
title={t("commandHistory.deleteTooltip")}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
ref={commandHistoryScrollRef}
|
||||
className="space-y-2 overflow-y-auto h-full thin-scrollbar"
|
||||
>
|
||||
{filteredCommands.map((command, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="bg-canvas border-2 border-edge rounded-md px-3 py-2.5 hover:bg-hover-alt hover:border-edge-hover transition-all duration-200 group h-12 flex items-center"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 w-full min-w-0">
|
||||
<span
|
||||
className="flex-1 font-mono text-sm cursor-pointer text-foreground truncate"
|
||||
onClick={() => handleCommandSelect(command)}
|
||||
title={command}
|
||||
>
|
||||
{command}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100 hover:bg-red-500/20 hover:text-red-400 flex-shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleCommandDelete(command);
|
||||
}}
|
||||
title={t("commandHistory.deleteTooltip")}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent
|
||||
@@ -2238,6 +2530,104 @@ export function SSHToolsSidebar({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{shareDialogSnippet && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60">
|
||||
<div className="bg-card border-2 border-border rounded-lg p-6 w-full max-w-md space-y-4 max-h-[80vh] overflow-auto">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">
|
||||
{t("snippets.shareSnippet")}
|
||||
</h3>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setShareDialogSnippet(null)}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{shareDialogSnippet.name}
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
value={shareTargetType}
|
||||
onValueChange={(v: "user" | "role") => {
|
||||
setShareTargetType(v);
|
||||
setShareTargetId("");
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[100px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="user">{t("snippets.user")}</SelectItem>
|
||||
<SelectItem value="role">{t("snippets.role")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={shareTargetId} onValueChange={setShareTargetId}>
|
||||
<SelectTrigger className="flex-1">
|
||||
<SelectValue placeholder={t("snippets.selectTarget")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{shareTargetType === "user"
|
||||
? shareUsers.map((u) => (
|
||||
<SelectItem key={u.id} value={u.id}>
|
||||
{u.username}
|
||||
</SelectItem>
|
||||
))
|
||||
: shareRoles.map((r) => (
|
||||
<SelectItem key={r.id} value={String(r.id)}>
|
||||
{r.displayName || r.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
onClick={handleShare}
|
||||
disabled={!shareTargetId}
|
||||
size="sm"
|
||||
>
|
||||
<Share2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{shareAccessList.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<span className="text-sm font-semibold">
|
||||
{t("snippets.currentAccess")}
|
||||
</span>
|
||||
{shareAccessList.map((access) => (
|
||||
<div
|
||||
key={access.id}
|
||||
className="flex items-center justify-between rounded-md border p-2 text-sm"
|
||||
>
|
||||
<span>
|
||||
{access.username ||
|
||||
access.roleDisplayName ||
|
||||
access.roleName}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="hover:bg-destructive hover:text-destructive-foreground"
|
||||
onClick={() => handleRevokeSnippetAccess(access.id)}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
getOIDCAuthorizeUrl,
|
||||
verifyTOTPLogin,
|
||||
getServerConfig,
|
||||
saveServerConfig,
|
||||
isElectron,
|
||||
getEmbeddedServerStatus,
|
||||
isEmbeddedMode,
|
||||
@@ -56,6 +57,7 @@ interface AuthProps extends React.ComponentProps<"div"> {
|
||||
loggedIn: boolean;
|
||||
authLoading: boolean;
|
||||
setDbError: (error: string | null) => void;
|
||||
dbError?: string | null;
|
||||
onAuthSuccess: (authData: {
|
||||
isAdmin: boolean;
|
||||
username: string | null;
|
||||
@@ -72,6 +74,7 @@ export function Auth({
|
||||
loggedIn,
|
||||
authLoading,
|
||||
setDbError,
|
||||
dbError: _dbError,
|
||||
onAuthSuccess,
|
||||
...props
|
||||
}: AuthProps) {
|
||||
@@ -80,6 +83,10 @@ export function Auth({
|
||||
|
||||
const isDarkMode =
|
||||
theme === "dark" ||
|
||||
theme === "dracula" ||
|
||||
theme === "gentlemansChoice" ||
|
||||
theme === "midnightEspresso" ||
|
||||
theme === "catppuccinMocha" ||
|
||||
(theme === "system" &&
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||
const lineColor = isDarkMode ? "#151517" : "#f9f9f9";
|
||||
@@ -93,7 +100,7 @@ export function Auth({
|
||||
return true;
|
||||
}
|
||||
} catch (_e) {
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
@@ -147,7 +154,24 @@ export function Auth({
|
||||
|
||||
const handleElectronAuthSuccess = useCallback(async () => {
|
||||
try {
|
||||
const meRes = await getUserInfo();
|
||||
let retries = 5;
|
||||
let meRes = null;
|
||||
while (retries-- > 0) {
|
||||
try {
|
||||
meRes = await getUserInfo();
|
||||
break;
|
||||
} catch (err: any) {
|
||||
const isNoServer =
|
||||
err?.code === "NO_SERVER_CONFIGURED" ||
|
||||
err?.message?.includes("no-server-configured");
|
||||
if (isNoServer && retries > 0) {
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!meRes) throw new Error("Failed to get user info");
|
||||
setInternalLoggedIn(true);
|
||||
setLoggedIn(true);
|
||||
setIsAdmin(!!meRes.is_admin);
|
||||
@@ -255,12 +279,6 @@ export function Auth({
|
||||
});
|
||||
}, [setDbError, firstUserToastShown, showServerConfig, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!registrationAllowed && !internalLoggedIn) {
|
||||
toast.warning(t("messages.registrationDisabled"));
|
||||
}
|
||||
}, [registrationAllowed, internalLoggedIn, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!passwordLoginAllowed && oidcConfigured && tab !== "external") {
|
||||
setTab("external");
|
||||
@@ -658,6 +676,11 @@ export function Auth({
|
||||
if (success) {
|
||||
setOidcLoading(true);
|
||||
|
||||
const urlToken = urlParams.get("token");
|
||||
if (urlToken && (isElectron() || isInElectronWebView())) {
|
||||
localStorage.setItem("jwt", urlToken);
|
||||
}
|
||||
|
||||
getUserInfo()
|
||||
.then((meRes) => {
|
||||
if (isInElectronWebView()) {
|
||||
@@ -683,6 +706,13 @@ export function Auth({
|
||||
}
|
||||
}
|
||||
|
||||
if (isElectron()) {
|
||||
const token = getCookie("jwt");
|
||||
if (token) {
|
||||
localStorage.setItem("jwt", token);
|
||||
}
|
||||
}
|
||||
|
||||
setInternalLoggedIn(true);
|
||||
setLoggedIn(true);
|
||||
setIsAdmin(!!meRes.is_admin);
|
||||
@@ -772,7 +802,12 @@ export function Auth({
|
||||
getEmbeddedServerStatus(),
|
||||
]);
|
||||
|
||||
if (status?.embedded && status?.running && !config?.serverUrl) {
|
||||
if (
|
||||
status?.embedded &&
|
||||
status?.running &&
|
||||
config &&
|
||||
!config.serverUrl
|
||||
) {
|
||||
setCurrentServerUrl("");
|
||||
setShowServerConfig(false);
|
||||
return;
|
||||
@@ -816,7 +851,11 @@ export function Auth({
|
||||
onServerConfigured={() => {
|
||||
window.location.reload();
|
||||
}}
|
||||
onUseEmbedded={() => {
|
||||
onUseEmbedded={async () => {
|
||||
await saveServerConfig({
|
||||
serverUrl: "",
|
||||
lastUpdated: new Date().toISOString(),
|
||||
});
|
||||
setShowServerConfig(false);
|
||||
setCurrentServerUrl("");
|
||||
}}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import React, { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AlertCircle, Loader2, ArrowLeft, RefreshCw } from "lucide-react";
|
||||
import { getCookie } from "@/ui/main-axios.ts";
|
||||
import { setCookie } from "@/ui/main-axios.ts";
|
||||
|
||||
interface ElectronLoginFormProps {
|
||||
serverUrl: string;
|
||||
@@ -20,64 +19,75 @@ export function ElectronLoginForm({
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isAuthenticating, setIsAuthenticating] = useState(false);
|
||||
const isAuthenticatingRef = useRef(false);
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const hasAuthenticatedRef = useRef(false);
|
||||
const [currentUrl, setCurrentUrl] = useState(serverUrl);
|
||||
const hasLoadedOnce = useRef(false);
|
||||
|
||||
const onAuthSuccessRef = useRef(onAuthSuccess);
|
||||
useEffect(() => {
|
||||
localStorage.removeItem("jwt");
|
||||
}, []);
|
||||
onAuthSuccessRef.current = onAuthSuccess;
|
||||
}, [onAuthSuccess]);
|
||||
|
||||
const handleAuthToken = useCallback(
|
||||
async (token: string) => {
|
||||
if (hasAuthenticatedRef.current || isAuthenticatingRef.current) return;
|
||||
hasAuthenticatedRef.current = true;
|
||||
isAuthenticatingRef.current = true;
|
||||
setIsAuthenticating(true);
|
||||
|
||||
try {
|
||||
setCookie("jwt", token);
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
onAuthSuccessRef.current();
|
||||
} catch (_err) {
|
||||
setError(t("errors.authTokenSaveFailed"));
|
||||
isAuthenticatingRef.current = false;
|
||||
setIsAuthenticating(false);
|
||||
hasAuthenticatedRef.current = false;
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
// postMessage from server's Auth.tsx (works if server has postMessage code)
|
||||
useEffect(() => {
|
||||
const handleMessage = async (event: MessageEvent) => {
|
||||
try {
|
||||
const serverOrigin = new URL(serverUrl).origin;
|
||||
if (event.origin !== serverOrigin) {
|
||||
return;
|
||||
if (!event.data || typeof event.data !== "object") return;
|
||||
const { type, token, platform } = event.data;
|
||||
if (type === "AUTH_SUCCESS" && token && platform === "desktop") {
|
||||
await handleAuthToken(token);
|
||||
}
|
||||
|
||||
if (event.data && typeof event.data === "object") {
|
||||
const data = event.data;
|
||||
|
||||
if (
|
||||
data.type === "AUTH_SUCCESS" &&
|
||||
data.token &&
|
||||
!hasAuthenticatedRef.current &&
|
||||
!isAuthenticating
|
||||
) {
|
||||
hasAuthenticatedRef.current = true;
|
||||
setIsAuthenticating(true);
|
||||
|
||||
try {
|
||||
localStorage.setItem("jwt", data.token);
|
||||
|
||||
const savedToken = localStorage.getItem("jwt");
|
||||
if (!savedToken) {
|
||||
throw new Error("Failed to save JWT to localStorage");
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
onAuthSuccess();
|
||||
} catch (err) {
|
||||
setError(t("errors.authTokenSaveFailed"));
|
||||
setIsAuthenticating(false);
|
||||
hasAuthenticatedRef.current = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Authentication operation failed:", err);
|
||||
} catch (_err) {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("message", handleMessage);
|
||||
return () => window.removeEventListener("message", handleMessage);
|
||||
}, [handleAuthToken]);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("message", handleMessage);
|
||||
};
|
||||
}, [serverUrl, isAuthenticating, onAuthSuccess, t]);
|
||||
// Poll iframe localStorage via main process executeJavaScriptInFrame (bypasses cross-origin)
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
const electronAPI = (window as any).electronAPI;
|
||||
if (!electronAPI?.invoke) return;
|
||||
|
||||
const poll = setInterval(async () => {
|
||||
if (hasAuthenticatedRef.current || isAuthenticatingRef.current) return;
|
||||
try {
|
||||
const token = await electronAPI.invoke("get-iframe-jwt");
|
||||
if (token && token.length > 20) {
|
||||
await handleAuthToken(token);
|
||||
}
|
||||
} catch (_e) {
|
||||
// ignore
|
||||
}
|
||||
}, 500);
|
||||
|
||||
return () => clearInterval(poll);
|
||||
}, [loading, handleAuthToken]);
|
||||
|
||||
useEffect(() => {
|
||||
const iframe = iframeRef.current;
|
||||
@@ -92,116 +102,9 @@ export function ElectronLoginForm({
|
||||
if (iframe.contentWindow) {
|
||||
setCurrentUrl(iframe.contentWindow.location.href);
|
||||
}
|
||||
} catch (e) {
|
||||
} catch (_e) {
|
||||
setCurrentUrl(serverUrl);
|
||||
}
|
||||
|
||||
try {
|
||||
const injectedScript = `
|
||||
(function() {
|
||||
let hasNotified = false;
|
||||
|
||||
function postJWTToParent(token, source) {
|
||||
if (hasNotified) {
|
||||
return;
|
||||
}
|
||||
hasNotified = true;
|
||||
|
||||
try {
|
||||
window.parent.postMessage({
|
||||
type: 'AUTH_SUCCESS',
|
||||
token: token,
|
||||
source: source,
|
||||
platform: 'desktop',
|
||||
timestamp: Date.now()
|
||||
}, '*');
|
||||
} catch (e) {
|
||||
}
|
||||
}
|
||||
|
||||
function checkAuth() {
|
||||
try {
|
||||
const localToken = localStorage.getItem('jwt');
|
||||
if (localToken && localToken.length > 20) {
|
||||
postJWTToParent(localToken, 'localStorage');
|
||||
return true;
|
||||
}
|
||||
|
||||
const sessionToken = sessionStorage.getItem('jwt');
|
||||
if (sessionToken && sessionToken.length > 20) {
|
||||
postJWTToParent(sessionToken, 'sessionStorage');
|
||||
return true;
|
||||
}
|
||||
|
||||
const cookies = document.cookie;
|
||||
if (cookies && cookies.length > 0) {
|
||||
const cookieArray = cookies.split('; ');
|
||||
const tokenCookie = cookieArray.find(row => row.startsWith('jwt='));
|
||||
|
||||
if (tokenCookie) {
|
||||
const token = tokenCookie.split('=')[1];
|
||||
if (token && token.length > 20) {
|
||||
postJWTToParent(token, 'cookie');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const originalSetItem = localStorage.setItem;
|
||||
localStorage.setItem = function(key, value) {
|
||||
originalSetItem.apply(this, arguments);
|
||||
if (key === 'jwt' && value && value.length > 20 && !hasNotified) {
|
||||
setTimeout(() => checkAuth(), 100);
|
||||
}
|
||||
};
|
||||
|
||||
const originalSessionSetItem = sessionStorage.setItem;
|
||||
sessionStorage.setItem = function(key, value) {
|
||||
originalSessionSetItem.apply(this, arguments);
|
||||
if (key === 'jwt' && value && value.length > 20 && !hasNotified) {
|
||||
setTimeout(() => checkAuth(), 100);
|
||||
}
|
||||
};
|
||||
|
||||
const intervalId = setInterval(() => {
|
||||
if (hasNotified) {
|
||||
clearInterval(intervalId);
|
||||
return;
|
||||
}
|
||||
if (checkAuth()) {
|
||||
clearInterval(intervalId);
|
||||
}
|
||||
}, 500);
|
||||
|
||||
setTimeout(() => {
|
||||
clearInterval(intervalId);
|
||||
}, 300000);
|
||||
|
||||
setTimeout(() => checkAuth(), 500);
|
||||
})();
|
||||
`;
|
||||
|
||||
try {
|
||||
if (iframe.contentWindow) {
|
||||
try {
|
||||
iframe.contentWindow.eval(injectedScript);
|
||||
} catch (evalError) {
|
||||
iframe.contentWindow.postMessage(
|
||||
{ type: "INJECT_SCRIPT", script: injectedScript },
|
||||
"*",
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Authentication operation failed:", err);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Authentication operation failed:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleError = () => {
|
||||
@@ -213,12 +116,11 @@ export function ElectronLoginForm({
|
||||
|
||||
iframe.addEventListener("load", handleLoad);
|
||||
iframe.addEventListener("error", handleError);
|
||||
|
||||
return () => {
|
||||
iframe.removeEventListener("load", handleLoad);
|
||||
iframe.removeEventListener("error", handleError);
|
||||
};
|
||||
}, [t]);
|
||||
}, [serverUrl, t]);
|
||||
|
||||
const handleRefresh = () => {
|
||||
if (iframeRef.current) {
|
||||
@@ -228,44 +130,47 @@ export function ElectronLoginForm({
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
onChangeServer();
|
||||
};
|
||||
|
||||
const displayUrl = currentUrl.replace(/^https?:\/\//, "");
|
||||
const isEmbeddedServer = serverUrl.includes("localhost:30001");
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 w-screen h-screen bg-canvas flex flex-col">
|
||||
<div className="flex items-center justify-between p-4 bg-canvas border-b border-edge">
|
||||
<button
|
||||
onClick={handleBack}
|
||||
className="flex items-center gap-2 text-foreground hover:text-primary transition-colors"
|
||||
disabled={isAuthenticating}
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
<span className="text-base font-medium">
|
||||
{t("serverConfig.changeServer")}
|
||||
</span>
|
||||
</button>
|
||||
{!isEmbeddedServer && (
|
||||
<div className="flex-1 mx-4 text-center">
|
||||
<span className="text-muted-foreground text-sm truncate block">
|
||||
{displayUrl}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{isEmbeddedServer && <div className="flex-1" />}
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
className="p-2 text-foreground hover:text-primary transition-colors"
|
||||
disabled={loading || isAuthenticating}
|
||||
>
|
||||
<RefreshCw className={`h-5 w-5 ${loading ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
</div>
|
||||
{isAuthenticating && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-canvas z-50">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
{!isAuthenticating && (
|
||||
<div className="flex items-center justify-between p-4 bg-canvas border-b border-edge">
|
||||
<button
|
||||
onClick={onChangeServer}
|
||||
className="flex items-center gap-2 text-foreground hover:text-primary transition-colors"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
<span className="text-base font-medium">
|
||||
{t("serverConfig.changeServer")}
|
||||
</span>
|
||||
</button>
|
||||
{!isEmbeddedServer && (
|
||||
<div className="flex-1 mx-4 text-center">
|
||||
<span className="text-muted-foreground text-sm truncate block">
|
||||
{displayUrl}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{isEmbeddedServer && <div className="flex-1" />}
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
className="p-2 text-foreground hover:text-primary transition-colors"
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw className={`h-5 w-5 ${loading ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && !isAuthenticating && (
|
||||
<div className="absolute top-20 left-1/2 transform -translate-x-1/2 z-50 w-full max-w-md px-4">
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
@@ -275,7 +180,7 @@ export function ElectronLoginForm({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
{loading && !isAuthenticating && (
|
||||
<div
|
||||
className="absolute inset-0 flex items-center justify-center bg-canvas z-40"
|
||||
style={{ marginTop: "60px" }}
|
||||
@@ -289,7 +194,10 @@ export function ElectronLoginForm({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div
|
||||
className="flex-1 overflow-hidden"
|
||||
style={{ visibility: isAuthenticating ? "hidden" : "visible" }}
|
||||
>
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={serverUrl}
|
||||
|
||||
@@ -39,6 +39,10 @@ export function ElectronServerConfig({
|
||||
|
||||
const isDarkMode =
|
||||
theme === "dark" ||
|
||||
theme === "dracula" ||
|
||||
theme === "gentlemansChoice" ||
|
||||
theme === "midnightEspresso" ||
|
||||
theme === "catppuccinMocha" ||
|
||||
(theme === "system" &&
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||
const lineColor = isDarkMode ? "#151517" : "#f9f9f9";
|
||||
|
||||
@@ -87,20 +87,34 @@ export function AppView({
|
||||
rightSidebarOpen = false,
|
||||
rightSidebarWidth = 400,
|
||||
}: TerminalViewProps): React.ReactElement {
|
||||
const { tabs, currentTab, allSplitScreenTab, removeTab, updateTab } =
|
||||
useTabs() as {
|
||||
tabs: TabData[];
|
||||
currentTab: number;
|
||||
allSplitScreenTab: number[];
|
||||
removeTab: (id: number) => void;
|
||||
updateTab: (tabId: number, updates: Partial<Omit<TabData, "id">>) => void;
|
||||
};
|
||||
const {
|
||||
tabs,
|
||||
currentTab,
|
||||
allSplitScreenTab,
|
||||
removeTab,
|
||||
updateTab,
|
||||
addTab,
|
||||
previewTerminalTheme,
|
||||
} = useTabs() as {
|
||||
tabs: TabData[];
|
||||
currentTab: number;
|
||||
allSplitScreenTab: number[];
|
||||
removeTab: (id: number) => void;
|
||||
updateTab: (tabId: number, updates: Partial<Omit<TabData, "id">>) => void;
|
||||
addTab: (tab: Omit<TabData, "id">) => number;
|
||||
previewTerminalTheme: string | null;
|
||||
};
|
||||
const { state: sidebarState } = useSidebar();
|
||||
const { theme: appTheme } = useTheme();
|
||||
|
||||
const isDarkMode = useMemo(() => {
|
||||
if (appTheme === "dark") return true;
|
||||
if (appTheme === "dracula") return true;
|
||||
if (appTheme === "gentlemansChoice") return true;
|
||||
if (appTheme === "midnightEspresso") return true;
|
||||
if (appTheme === "catppuccinMocha") return true;
|
||||
if (appTheme === "light") return false;
|
||||
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
}, [appTheme]);
|
||||
|
||||
@@ -362,13 +376,18 @@ export function AppView({
|
||||
};
|
||||
|
||||
let themeColors;
|
||||
if (terminalConfig.theme === "termix") {
|
||||
const activeTerminalTheme =
|
||||
t.id === currentTab && previewTerminalTheme
|
||||
? previewTerminalTheme
|
||||
: terminalConfig.theme;
|
||||
|
||||
if (activeTerminalTheme === "termix") {
|
||||
themeColors = isDarkMode
|
||||
? TERMINAL_THEMES.termixDark.colors
|
||||
: TERMINAL_THEMES.termixLight.colors;
|
||||
} else {
|
||||
themeColors =
|
||||
TERMINAL_THEMES[terminalConfig.theme]?.colors ||
|
||||
TERMINAL_THEMES[activeTerminalTheme]?.colors ||
|
||||
TERMINAL_THEMES.termixDark.colors;
|
||||
}
|
||||
const backgroundColor = themeColors.background;
|
||||
@@ -394,6 +413,19 @@ export function AppView({
|
||||
splitScreen={allSplitScreenTab.length > 0}
|
||||
onClose={() => removeTab(t.id)}
|
||||
onTitleChange={(title) => updateTab(t.id, { title })}
|
||||
onOpenFileManager={
|
||||
(t.hostConfig as any)?.enableFileManager
|
||||
? () =>
|
||||
addTab({
|
||||
type: "file_manager",
|
||||
title: t.title,
|
||||
hostConfig: t.hostConfig,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
previewTheme={
|
||||
t.id === currentTab ? previewTerminalTheme : null
|
||||
}
|
||||
/>
|
||||
) : t.type === "server_stats" ? (
|
||||
<ServerView
|
||||
|
||||
@@ -10,6 +10,23 @@ import { TabDropdown } from "@/ui/desktop/navigation/tabs/TabDropdown.tsx";
|
||||
import { SSHToolsSidebar } from "@/ui/desktop/apps/tools/SSHToolsSidebar.tsx";
|
||||
import { useCommandHistory } from "@/ui/desktop/apps/features/terminal/command-history/CommandHistoryContext.tsx";
|
||||
import { QuickConnectDialog } from "@/ui/desktop/navigation/dialogs/QuickConnectDialog.tsx";
|
||||
import { useTheme } from "@/components/theme-provider";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuLabel,
|
||||
} from "@/components/ui/dropdown-menu.tsx";
|
||||
import {
|
||||
Sun,
|
||||
Moon,
|
||||
Monitor,
|
||||
Palette,
|
||||
Terminal as TerminalIcon,
|
||||
} from "lucide-react";
|
||||
import { TERMINAL_THEMES } from "@/constants/terminal-themes.ts";
|
||||
|
||||
interface TabData {
|
||||
id: number;
|
||||
@@ -45,15 +62,10 @@ export function TopNavbar({
|
||||
removeTab,
|
||||
allSplitScreenTab,
|
||||
reorderTabs,
|
||||
} = useTabs() as {
|
||||
tabs: TabData[];
|
||||
currentTab: number;
|
||||
setCurrentTab: (id: number) => void;
|
||||
setSplitScreenTab: (id: number) => void;
|
||||
removeTab: (id: number) => void;
|
||||
allSplitScreenTab: number[];
|
||||
reorderTabs: (fromIndex: number, toIndex: number) => void;
|
||||
};
|
||||
updateTab,
|
||||
previewTerminalTheme,
|
||||
setPreviewTerminalTheme,
|
||||
} = useTabs() as any;
|
||||
const leftPosition =
|
||||
state === "collapsed" ? "26px" : "calc(var(--sidebar-width) + 8px)";
|
||||
const { t } = useTranslation();
|
||||
@@ -146,7 +158,7 @@ export function TopNavbar({
|
||||
const handleSnippetExecute = (content: string) => {
|
||||
const tab = tabs.find((t: TabData) => t.id === currentTab);
|
||||
if (tab?.terminalRef?.current?.sendInput) {
|
||||
tab.terminalRef.current.sendInput(content + "\n");
|
||||
tab.terminalRef.current.sendInput(content + "\r");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -378,6 +390,7 @@ export function TopNavbar({
|
||||
const isUserProfile = tab.type === "user_profile";
|
||||
const isRdp = tab.type === "rdp";
|
||||
const isVnc = tab.type === "vnc";
|
||||
const isTelnet = tab.type === "telnet";
|
||||
const isSplittable =
|
||||
isTerminal || isServer || isFileManager || isTunnel || isDocker;
|
||||
const disableSplit = !isSplittable;
|
||||
@@ -456,7 +469,6 @@ export function TopNavbar({
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
onDragEnd={handleDragEnd}
|
||||
e
|
||||
onMouseDown={(e) => {
|
||||
if (e.button === 1 && !disableClose) {
|
||||
e.preventDefault();
|
||||
@@ -499,6 +511,7 @@ export function TopNavbar({
|
||||
isUserProfile ||
|
||||
isRdp ||
|
||||
isVnc ||
|
||||
isTelnet ||
|
||||
tab.type === "network_graph"
|
||||
? () => handleTabClose(tab.id)
|
||||
: undefined
|
||||
@@ -518,6 +531,7 @@ export function TopNavbar({
|
||||
isUserProfile ||
|
||||
isRdp ||
|
||||
isVnc ||
|
||||
isTelnet ||
|
||||
tab.type === "network_graph"
|
||||
}
|
||||
disableActivate={disableActivate}
|
||||
@@ -535,6 +549,73 @@ export function TopNavbar({
|
||||
<div className="flex items-center justify-center gap-2 flex-1 px-2">
|
||||
<TabDropdown />
|
||||
|
||||
{/* Terminal Theme Switcher */}
|
||||
{(() => {
|
||||
const activeTab = tabs.find((t: any) => t.id === currentTab);
|
||||
if (activeTab?.type !== "terminal") return null;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-[30px] h-[30px] border-edge"
|
||||
title={t("hosts.selectTheme")}
|
||||
>
|
||||
<TerminalIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="bg-canvas border-edge text-foreground max-h-[400px] overflow-y-auto thin-scrollbar"
|
||||
onMouseLeave={() => setPreviewTerminalTheme(null)}
|
||||
>
|
||||
<DropdownMenuLabel className="text-xs opacity-70">
|
||||
Terminal Themes
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{Object.entries(TERMINAL_THEMES).map(([key, theme]) => (
|
||||
<DropdownMenuItem
|
||||
key={key}
|
||||
onClick={() => {
|
||||
const activeTab = tabs.find(
|
||||
(t: any) => t.id === currentTab,
|
||||
);
|
||||
if (activeTab?.hostConfig) {
|
||||
const updatedConfig = {
|
||||
...activeTab.hostConfig.terminalConfig,
|
||||
theme: key,
|
||||
};
|
||||
|
||||
// Persist terminal theme selection to localStorage
|
||||
localStorage.setItem(
|
||||
`terminal_theme_host_${activeTab.hostConfig.id}`,
|
||||
key,
|
||||
);
|
||||
|
||||
updateTab(currentTab, {
|
||||
hostConfig: {
|
||||
...activeTab.hostConfig,
|
||||
terminalConfig: updatedConfig,
|
||||
},
|
||||
});
|
||||
}
|
||||
}}
|
||||
onMouseEnter={() => setPreviewTerminalTheme(key)}
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
<div
|
||||
className="w-3 h-3 rounded-full border border-edge"
|
||||
style={{ backgroundColor: theme.colors.background }}
|
||||
/>
|
||||
<span>{theme.name}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
})()}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setToolsSidebarOpen(!toolsSidebarOpen)}
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import React, { useEffect } from "react";
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { Input } from "@/components/ui/input.tsx";
|
||||
import { Label } from "@/components/ui/label.tsx";
|
||||
import { Shield, Copy, ExternalLink, Loader2, AlertCircle } from "lucide-react";
|
||||
import { Shield, ExternalLink, Loader2, AlertCircle } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface OPKSSHDialogProps {
|
||||
isOpen: boolean;
|
||||
@@ -12,8 +9,10 @@ interface OPKSSHDialogProps {
|
||||
requestId: string;
|
||||
stage: "chooser" | "waiting" | "authenticating" | "completed" | "error";
|
||||
error?: string;
|
||||
providers?: Array<{ alias: string; issuer: string }>;
|
||||
onCancel: () => void;
|
||||
onOpenUrl: () => void;
|
||||
onSelectProvider?: (alias: string) => void;
|
||||
backgroundColor?: string;
|
||||
}
|
||||
|
||||
@@ -23,26 +22,15 @@ export function OPKSSHDialog({
|
||||
requestId,
|
||||
stage,
|
||||
error,
|
||||
providers,
|
||||
onCancel,
|
||||
onOpenUrl,
|
||||
onSelectProvider,
|
||||
backgroundColor,
|
||||
}: OPKSSHDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const hasOpenedRef = React.useRef(false);
|
||||
|
||||
useEffect(() => {}, [isOpen, authUrl, stage, onOpenUrl]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleCopyUrl = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(authUrl);
|
||||
toast.success(t("common.copied"));
|
||||
} catch (error) {
|
||||
toast.error(t("common.copyFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 flex items-center justify-center z-500 animate-in fade-in duration-200">
|
||||
<div
|
||||
@@ -58,47 +46,54 @@ export function OPKSSHDialog({
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{stage === "chooser" && authUrl && (
|
||||
{stage === "chooser" && (
|
||||
<>
|
||||
<p className="text-muted-foreground">
|
||||
{t("terminal.opksshAuthDescription")}
|
||||
</p>
|
||||
<div>
|
||||
<Label htmlFor="opksshUrl" className="text-base font-semibold">
|
||||
{t("terminal.opksshAuthUrl")}
|
||||
</Label>
|
||||
<div className="flex gap-2 mt-2">
|
||||
<Input
|
||||
id="opksshUrl"
|
||||
type="text"
|
||||
value={authUrl}
|
||||
readOnly
|
||||
className="flex-1 font-mono text-xs"
|
||||
/>
|
||||
{providers && providers.length > 0 && onSelectProvider ? (
|
||||
<div className="space-y-2">
|
||||
{providers.map((provider) => (
|
||||
<Button
|
||||
key={provider.alias}
|
||||
type="button"
|
||||
onClick={() => onSelectProvider(provider.alias)}
|
||||
className="w-full flex items-center justify-center gap-2"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
{t("terminal.opksshSignInWith", {
|
||||
provider:
|
||||
provider.alias.charAt(0).toUpperCase() +
|
||||
provider.alias.slice(1),
|
||||
})}
|
||||
</Button>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleCopyUrl}
|
||||
title={t("common.copy")}
|
||||
className="w-full"
|
||||
onClick={onCancel}
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onOpenUrl}
|
||||
className="flex-1 flex items-center justify-center gap-2"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
{t("terminal.opksshOpenBrowser")}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
) : authUrl ? (
|
||||
<div>
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onOpenUrl}
|
||||
className="flex-1 flex items-center justify-center gap-2"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
{t("terminal.opksshOpenBrowser")}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -121,7 +116,9 @@ export function OPKSSHDialog({
|
||||
<p className="text-sm font-medium text-destructive">
|
||||
{t("common.error")}
|
||||
</p>
|
||||
<p className="text-sm text-destructive/90 mt-1">{error}</p>
|
||||
<p className="text-sm text-destructive/90 mt-1 whitespace-pre-wrap break-words">
|
||||
{error}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end pt-2">
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { Terminal, Monitor, Users, Clock } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface TmuxSessionInfo {
|
||||
name: string;
|
||||
created: number;
|
||||
lastActivity: number;
|
||||
windows: number;
|
||||
attachedClients: number;
|
||||
}
|
||||
|
||||
interface TmuxSessionPickerProps {
|
||||
isOpen: boolean;
|
||||
sessions: TmuxSessionInfo[];
|
||||
onSelect: (sessionName: string) => void;
|
||||
onCreateNew: () => void;
|
||||
onCancel: () => void;
|
||||
backgroundColor?: string;
|
||||
}
|
||||
|
||||
function formatTimestamp(
|
||||
unix: number,
|
||||
t: (key: string, opts?: Record<string, unknown>) => string,
|
||||
): string {
|
||||
if (!unix) return "---";
|
||||
const date = new Date(unix * 1000);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMin = Math.floor(diffMs / 60000);
|
||||
const diffHr = Math.floor(diffMin / 60);
|
||||
const diffDays = Math.floor(diffHr / 24);
|
||||
|
||||
if (diffMin < 1) return t("terminal.tmuxTimeJustNow");
|
||||
if (diffMin < 60) return t("terminal.tmuxTimeMinutes", { count: diffMin });
|
||||
if (diffHr < 24) return t("terminal.tmuxTimeHours", { count: diffHr });
|
||||
if (diffDays < 7) return t("terminal.tmuxTimeDays", { count: diffDays });
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
export function TmuxSessionPicker({
|
||||
isOpen,
|
||||
sessions,
|
||||
onSelect,
|
||||
onCreateNew,
|
||||
onCancel,
|
||||
backgroundColor,
|
||||
}: TmuxSessionPickerProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 flex items-center justify-center z-500 animate-in fade-in duration-200">
|
||||
<div
|
||||
className="absolute inset-0 bg-canvas rounded-md"
|
||||
style={{ backgroundColor: backgroundColor || undefined }}
|
||||
/>
|
||||
<div className="bg-elevated border-2 border-edge rounded-lg p-6 max-w-md w-full mx-4 relative z-10 animate-in fade-in zoom-in-95 duration-200">
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Terminal className="w-5 h-5 text-primary" />
|
||||
<h3 className="text-lg font-semibold">
|
||||
{t("terminal.tmuxSessionPickerTitle")}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t("terminal.tmuxSessionPickerDesc")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2 mb-4 max-h-60 overflow-y-auto">
|
||||
{sessions.map((session) => (
|
||||
<button
|
||||
key={session.name}
|
||||
onClick={() => onSelect(session.name)}
|
||||
className="w-full text-left px-3 py-3 rounded-md border border-edge hover:bg-muted transition-colors"
|
||||
>
|
||||
<div className="font-mono text-sm font-medium">
|
||||
{session.name}
|
||||
</div>
|
||||
<div className="flex gap-3 mt-1 text-xs text-muted-foreground">
|
||||
<span
|
||||
className="flex items-center gap-1"
|
||||
title={t("terminal.tmuxWindows")}
|
||||
>
|
||||
<Monitor className="w-3 h-3" />
|
||||
{t("terminal.tmuxWindowCount", { count: session.windows })}
|
||||
</span>
|
||||
{session.attachedClients > 0 && (
|
||||
<span
|
||||
className="flex items-center gap-1"
|
||||
title={t("terminal.tmuxAttached")}
|
||||
>
|
||||
<Users className="w-3 h-3" />
|
||||
{t("terminal.tmuxAttachedCount", {
|
||||
count: session.attachedClients,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className="flex items-center gap-1"
|
||||
title={t("terminal.tmuxLastActivity")}
|
||||
>
|
||||
<Clock className="w-3 h-3" />
|
||||
{formatTimestamp(session.lastActivity, t)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={onCreateNew} variant="outline" className="flex-1">
|
||||
{t("terminal.tmuxCreateNew")}
|
||||
</Button>
|
||||
<Button onClick={onCancel} variant="outline" className="flex-1">
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
Pencil,
|
||||
ArrowDownUp,
|
||||
Container,
|
||||
Power,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -21,12 +22,20 @@ import {
|
||||
DropdownMenuItem,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext";
|
||||
import { getSSHHosts, getGuacamoleToken, logActivity } from "@/ui/main-axios";
|
||||
import {
|
||||
getSSHHosts,
|
||||
getGuacamoleToken,
|
||||
getGuacamoleDpi,
|
||||
getGuacamoleTokenFromHost,
|
||||
logActivity,
|
||||
wakeOnLan,
|
||||
} from "@/ui/main-axios";
|
||||
import type { HostProps } from "../../../../types";
|
||||
import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHostStatus } from "@/ui/contexts/ServerStatusContext";
|
||||
import { cn } from "@/lib/utils.ts";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function Host({ host: initialHost }: HostProps): React.ReactElement {
|
||||
const { addTab } = useTabs();
|
||||
@@ -118,17 +127,7 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
|
||||
) {
|
||||
try {
|
||||
const protocol = host.connectionType as "rdp" | "vnc" | "telnet";
|
||||
const result = await getGuacamoleToken({
|
||||
protocol,
|
||||
hostname: host.ip,
|
||||
port: host.port,
|
||||
username: host.username,
|
||||
password: host.password,
|
||||
domain: host.domain,
|
||||
security: host.security,
|
||||
ignoreCert: host.ignoreCert,
|
||||
guacamoleConfig: host.guacamoleConfig as any,
|
||||
});
|
||||
const result = await getGuacamoleTokenFromHost(host.id);
|
||||
addTab({
|
||||
type: protocol,
|
||||
title,
|
||||
@@ -144,6 +143,7 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
|
||||
domain: host.domain,
|
||||
security: host.security,
|
||||
"ignore-cert": host.ignoreCert,
|
||||
dpi: getGuacamoleDpi(host),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -297,7 +297,15 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
|
||||
) : (
|
||||
<Terminal className="h-4 w-4" />
|
||||
)}
|
||||
<span className="flex-1">{t("hosts.openTerminal")}</span>
|
||||
<span className="flex-1">
|
||||
{host.connectionType === "rdp"
|
||||
? t("hosts.openRdp")
|
||||
: host.connectionType === "vnc"
|
||||
? t("hosts.openVnc")
|
||||
: host.connectionType === "telnet"
|
||||
? t("hosts.openTelnet")
|
||||
: t("hosts.openTerminal")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isSSH &&
|
||||
@@ -353,6 +361,22 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
|
||||
<span className="flex-1">{t("hosts.openDocker")}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{host.macAddress && (
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
try {
|
||||
await wakeOnLan(host.id);
|
||||
toast.success(t("hosts.wolSent"));
|
||||
} catch {
|
||||
toast.error(t("hosts.wolFailed"));
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary"
|
||||
>
|
||||
<Power className="h-4 w-4" />
|
||||
<span className="flex-1">{t("hosts.wakeOnLan")}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
addTab({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getHostPassword } from "@/ui/main-axios.ts";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Home,
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
Container as DockerIcon,
|
||||
Key,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import type { SSHHost } from "@/types";
|
||||
|
||||
interface TabProps {
|
||||
@@ -66,42 +68,61 @@ export function Tab({
|
||||
|
||||
if (!hostConfig) return;
|
||||
|
||||
const hasSshPassword =
|
||||
hostConfig.authType === "password" && hostConfig.password;
|
||||
const hasSudoPassword = hostConfig.sudoPassword;
|
||||
const hasSshPw =
|
||||
hostConfig.authType === "password" &&
|
||||
(hostConfig.hasPassword || hostConfig.password);
|
||||
const hasSudoPw = hostConfig.hasSudoPassword || hostConfig.sudoPassword;
|
||||
|
||||
if (!hasSshPassword && !hasSudoPassword) {
|
||||
return;
|
||||
if (!hasSshPw && !hasSudoPw) return;
|
||||
|
||||
let passwordToCopy = "";
|
||||
|
||||
if (hasSshPw) {
|
||||
passwordToCopy = hostConfig.password || "";
|
||||
} else if (hasSudoPw) {
|
||||
passwordToCopy = hostConfig.sudoPassword;
|
||||
}
|
||||
|
||||
if (!passwordToCopy) return;
|
||||
|
||||
try {
|
||||
let passwordToCopy = "";
|
||||
|
||||
if (hasSshPassword) {
|
||||
passwordToCopy = hostConfig.password || "";
|
||||
} else if (hasSudoPassword) {
|
||||
passwordToCopy = hostConfig.sudoPassword;
|
||||
}
|
||||
|
||||
await navigator.clipboard.writeText(passwordToCopy);
|
||||
} catch {}
|
||||
toast.success(t("nav.passwordCopied"));
|
||||
} catch {
|
||||
try {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = passwordToCopy;
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.opacity = "0";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(textarea);
|
||||
toast.success(t("nav.passwordCopied"));
|
||||
} catch {
|
||||
toast.error(t("nav.failedToCopyPassword"));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const hasPassword =
|
||||
hostConfig &&
|
||||
((hostConfig.authType === "password" && hostConfig.password) ||
|
||||
((hostConfig.authType === "password" &&
|
||||
(hostConfig.hasPassword || hostConfig.password)) ||
|
||||
hostConfig.hasSudoPassword ||
|
||||
hostConfig.sudoPassword);
|
||||
|
||||
const getPasswordButtonTitle = () => {
|
||||
if (!hostConfig) return "";
|
||||
|
||||
const hasSshPassword =
|
||||
hostConfig.authType === "password" && hostConfig.password;
|
||||
const hasSudoPassword = hostConfig.sudoPassword;
|
||||
const hasSshPw =
|
||||
hostConfig.authType === "password" &&
|
||||
(hostConfig.hasPassword || hostConfig.password);
|
||||
const hasSudoPw = hostConfig.hasSudoPassword || hostConfig.sudoPassword;
|
||||
|
||||
if (hasSshPassword) {
|
||||
if (hasSshPw) {
|
||||
return t("nav.copyPassword");
|
||||
} else if (hasSudoPassword) {
|
||||
} else if (hasSudoPw) {
|
||||
return t("nav.copySudoPassword");
|
||||
}
|
||||
return t("nav.noPasswordAvailable");
|
||||
|
||||
@@ -34,6 +34,8 @@ interface TabContextType {
|
||||
},
|
||||
) => void;
|
||||
updateTab: (tabId: number, updates: Partial<Omit<Tab, "id">>) => void;
|
||||
previewTerminalTheme: string | null;
|
||||
setPreviewTerminalTheme: (theme: string | null) => void;
|
||||
}
|
||||
|
||||
const TabContext = createContext<TabContextType | undefined>(undefined);
|
||||
@@ -122,6 +124,9 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
return 1;
|
||||
});
|
||||
const [allSplitScreenTab, setAllSplitScreenTab] = useState<number[]>([]);
|
||||
const [previewTerminalTheme, setPreviewTerminalTheme] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [initialMaxId] = useState(() => {
|
||||
let maxId = 1;
|
||||
tabs.forEach((tab) => {
|
||||
@@ -151,6 +156,13 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
}
|
||||
}, [tabs, currentTab]);
|
||||
|
||||
// Safety net: if currentTab points to a tab that no longer exists, fall back to home
|
||||
useEffect(() => {
|
||||
if (tabs.length > 0 && !tabs.some((t) => t.id === currentTab)) {
|
||||
setCurrentTab(1);
|
||||
}
|
||||
}, [tabs, currentTab]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setTabs((prev) =>
|
||||
prev.map((tab) =>
|
||||
@@ -264,6 +276,8 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
return id;
|
||||
};
|
||||
|
||||
const pendingCurrentTabRef = useRef<number | null>(null);
|
||||
|
||||
const removeTab = (tabId: number) => {
|
||||
const tab = tabs.find((t) => t.id === tabId);
|
||||
if (
|
||||
@@ -274,31 +288,33 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
tab.terminalRef.current.disconnect();
|
||||
}
|
||||
|
||||
setTabs((prev) => prev.filter((tab) => tab.id !== tabId));
|
||||
setTabs((prev) => {
|
||||
const closedIndex = prev.findIndex((t) => t.id === tabId);
|
||||
const filtered = prev.filter((t) => t.id !== tabId);
|
||||
|
||||
if (filtered.length === 0) {
|
||||
pendingCurrentTabRef.current = 1;
|
||||
return [{ id: 1, type: "home", title: t("nav.home") }];
|
||||
}
|
||||
|
||||
// If the closed tab was active, compute the next tab to activate
|
||||
// using the latest prev so rapid closes don't use stale data
|
||||
const nextIndex =
|
||||
closedIndex < filtered.length ? closedIndex : filtered.length - 1;
|
||||
pendingCurrentTabRef.current = filtered[Math.max(0, nextIndex)]?.id ?? 1;
|
||||
|
||||
return filtered;
|
||||
});
|
||||
|
||||
setAllSplitScreenTab((prev) => {
|
||||
const newSplits = prev.filter((id) => id !== tabId);
|
||||
if (newSplits.length <= 1) {
|
||||
return [];
|
||||
}
|
||||
return newSplits;
|
||||
return newSplits.length <= 1 ? [] : newSplits;
|
||||
});
|
||||
|
||||
if (currentTab === tabId) {
|
||||
const remainingTabs = tabs.filter((tab) => tab.id !== tabId);
|
||||
if (remainingTabs.length > 0) {
|
||||
const remainingSplitTabs = allSplitScreenTab.filter(
|
||||
(id) => id !== tabId,
|
||||
);
|
||||
if (remainingSplitTabs.length > 0) {
|
||||
setCurrentTab(remainingSplitTabs[0]);
|
||||
} else {
|
||||
setCurrentTab(remainingTabs[0].id);
|
||||
}
|
||||
} else {
|
||||
setCurrentTab(1);
|
||||
}
|
||||
}
|
||||
setCurrentTab((prevCurrentTab) => {
|
||||
if (prevCurrentTab !== tabId) return prevCurrentTab;
|
||||
return pendingCurrentTabRef.current ?? 1;
|
||||
});
|
||||
};
|
||||
|
||||
const setSplitScreenTab = (tabId: number) => {
|
||||
@@ -412,6 +428,8 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
reorderTabs,
|
||||
updateHostConfig,
|
||||
updateTab,
|
||||
previewTerminalTheme,
|
||||
setPreviewTerminalTheme,
|
||||
}),
|
||||
[
|
||||
tabs,
|
||||
@@ -424,6 +442,8 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
reorderTabs,
|
||||
updateHostConfig,
|
||||
updateTab,
|
||||
previewTerminalTheme,
|
||||
setPreviewTerminalTheme,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -25,6 +25,10 @@ export function ElectronVersionCheck({
|
||||
|
||||
const isDarkMode =
|
||||
theme === "dark" ||
|
||||
theme === "dracula" ||
|
||||
theme === "gentlemansChoice" ||
|
||||
theme === "midnightEspresso" ||
|
||||
theme === "catppuccinMocha" ||
|
||||
(theme === "system" &&
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||
const lineColor = isDarkMode ? "#151517" : "#f9f9f9";
|
||||
|
||||
@@ -97,7 +97,7 @@ export function UserProfile({
|
||||
}: UserProfileProps) {
|
||||
const { t } = useTranslation();
|
||||
const { state: sidebarState } = useSidebar();
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { theme, setTheme, setThemePreview } = useTheme();
|
||||
const [userInfo, setUserInfo] = useState<{
|
||||
username: string;
|
||||
is_admin: boolean;
|
||||
@@ -121,6 +121,9 @@ export function UserProfile({
|
||||
const [commandAutocomplete, setCommandAutocomplete] = useState<boolean>(
|
||||
localStorage.getItem("commandAutocomplete") === "true",
|
||||
);
|
||||
const [commandHistoryTracking, setCommandHistoryTracking] = useState<boolean>(
|
||||
() => localStorage.getItem("commandHistoryTracking") === "true",
|
||||
);
|
||||
const [terminalSyntaxHighlighting, setTerminalSyntaxHighlighting] =
|
||||
useState<boolean>(
|
||||
() => localStorage.getItem("terminalSyntaxHighlighting") === "true",
|
||||
@@ -162,7 +165,7 @@ export function UserProfile({
|
||||
|
||||
const fetchVersion = async () => {
|
||||
try {
|
||||
const info = await getVersionInfo();
|
||||
const info = await getVersionInfo(!disableUpdateCheck);
|
||||
setVersionInfo({ version: info.localVersion });
|
||||
} catch {
|
||||
toast.error(t("user.failedToLoadVersionInfo"));
|
||||
@@ -214,6 +217,12 @@ export function UserProfile({
|
||||
localStorage.setItem("commandAutocomplete", enabled.toString());
|
||||
};
|
||||
|
||||
const handleCommandHistoryTrackingToggle = (enabled: boolean) => {
|
||||
setCommandHistoryTracking(enabled);
|
||||
localStorage.setItem("commandHistoryTracking", enabled.toString());
|
||||
window.dispatchEvent(new Event("commandHistoryTrackingChanged"));
|
||||
};
|
||||
|
||||
const handleTerminalSyntaxHighlightingToggle = (enabled: boolean) => {
|
||||
setTerminalSyntaxHighlighting(enabled);
|
||||
localStorage.setItem("terminalSyntaxHighlighting", enabled.toString());
|
||||
@@ -525,20 +534,73 @@ export function UserProfile({
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="light">
|
||||
<SelectContent
|
||||
onMouseLeave={() => setThemePreview(null)}
|
||||
>
|
||||
<SelectItem
|
||||
value="light"
|
||||
onMouseEnter={() => setThemePreview("light")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Sun className="w-4 h-4" />
|
||||
{t("profile.themeLight")}
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="dark">
|
||||
<SelectItem
|
||||
value="dark"
|
||||
onMouseEnter={() => setThemePreview("dark")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Moon className="w-4 h-4" />
|
||||
{t("profile.themeDark")}
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="system">
|
||||
<SelectItem
|
||||
value="dracula"
|
||||
onMouseEnter={() => setThemePreview("dracula")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Palette className="w-4 h-4" />
|
||||
Dracula
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
value="gentlemansChoice"
|
||||
onMouseEnter={() =>
|
||||
setThemePreview("gentlemansChoice")
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Palette className="w-4 h-4" />
|
||||
Gentleman's Choice
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
value="midnightEspresso"
|
||||
onMouseEnter={() =>
|
||||
setThemePreview("midnightEspresso")
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Palette className="w-4 h-4" />
|
||||
Midnight Espresso
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
value="catppuccinMocha"
|
||||
onMouseEnter={() =>
|
||||
setThemePreview("catppuccinMocha")
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Palette className="w-4 h-4" />
|
||||
Catppuccin Mocha
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
value="system"
|
||||
onMouseEnter={() => setThemePreview("system")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Monitor className="w-4 h-4" />
|
||||
{t("profile.themeSystem")}
|
||||
@@ -591,6 +653,20 @@ export function UserProfile({
|
||||
onCheckedChange={handleCommandAutocompleteToggle}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-foreground-secondary">
|
||||
{t("profile.commandHistoryTracking")}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t("profile.commandHistoryTrackingDesc")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={commandHistoryTracking}
|
||||
onCheckedChange={handleCommandHistoryTrackingToggle}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-foreground-secondary">
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
import { useRef, useCallback } from "react";
|
||||
import { saveCommandToHistory } from "@/ui/main-axios.ts";
|
||||
|
||||
const SENSITIVE_PATTERNS = [
|
||||
/\bpassw(or)?d\b/i,
|
||||
/\bsecret\b/i,
|
||||
/\btoken\b/i,
|
||||
/\bapi.?key\b/i,
|
||||
/\bPASS(WORD)?=/i,
|
||||
/\bAWS_SECRET/i,
|
||||
/\bmysql\b.*-p/i,
|
||||
/\bsudo\s+-S\b/,
|
||||
/\bhtpasswd\b/i,
|
||||
/\bsshpass\b/i,
|
||||
/\bcurl\b.*-u\s/i,
|
||||
/\bexport\b.*(?:PASSWORD|SECRET|TOKEN|KEY)=/i,
|
||||
];
|
||||
|
||||
interface UseCommandTrackerOptions {
|
||||
hostId?: number;
|
||||
enabled?: boolean;
|
||||
@@ -52,9 +67,13 @@ export function useCommandTracker({
|
||||
const command = currentCommandRef.current.trim();
|
||||
|
||||
if (command.length > 0) {
|
||||
saveCommandToHistory(hostId, command).catch((error) => {
|
||||
console.error("Failed to save command to history:", error);
|
||||
});
|
||||
const isSensitive = SENSITIVE_PATTERNS.some((p) => p.test(command));
|
||||
|
||||
if (!isSensitive) {
|
||||
saveCommandToHistory(hostId, command).catch((error) => {
|
||||
console.error("Failed to save command to history:", error);
|
||||
});
|
||||
}
|
||||
|
||||
if (onCommandExecuted) {
|
||||
onCommandExecuted(command);
|
||||
|
||||
+389
-63
@@ -149,28 +149,12 @@ interface OIDCAuthorize {
|
||||
// ============================================================================
|
||||
|
||||
export function isElectron(): boolean {
|
||||
const hasISElectron =
|
||||
(
|
||||
window as Window &
|
||||
typeof globalThis & {
|
||||
IS_ELECTRON?: boolean;
|
||||
electronAPI?: unknown;
|
||||
configuredServerUrl?: string;
|
||||
}
|
||||
).IS_ELECTRON === true;
|
||||
const win = window as any;
|
||||
const hasISElectron = win.IS_ELECTRON === true;
|
||||
const hasElectronAPI = !!win.electronAPI;
|
||||
const isElectronProp = win.electronAPI?.isElectron === true;
|
||||
|
||||
const hasElectronAPI = !!(
|
||||
window as Window &
|
||||
typeof globalThis & {
|
||||
IS_ELECTRON?: boolean;
|
||||
electronAPI?: unknown;
|
||||
configuredServerUrl?: string;
|
||||
}
|
||||
).electronAPI;
|
||||
|
||||
const result = hasISElectron || hasElectronAPI;
|
||||
|
||||
return result;
|
||||
return hasISElectron || hasElectronAPI || isElectronProp;
|
||||
}
|
||||
|
||||
function getLoggerForService(serviceName: string) {
|
||||
@@ -199,20 +183,22 @@ const electronSettingsCache = new Map<string, string>();
|
||||
if (isElectron()) {
|
||||
(async () => {
|
||||
try {
|
||||
const electronAPI = (
|
||||
window as Window &
|
||||
typeof globalThis & {
|
||||
electronAPI?: any;
|
||||
}
|
||||
).electronAPI;
|
||||
const electronAPI = (window as any).electronAPI;
|
||||
|
||||
if (electronAPI?.getSetting) {
|
||||
const settingsToLoad = ["rightClickCopyPaste", "jwt"];
|
||||
for (const key of settingsToLoad) {
|
||||
const value = await electronAPI.getSetting(key);
|
||||
if (value !== null && value !== undefined) {
|
||||
electronSettingsCache.set(key, value);
|
||||
localStorage.setItem(key, value);
|
||||
// Only populate if not already set to prevent overwriting new values during login
|
||||
if (!localStorage.getItem(key)) {
|
||||
electronSettingsCache.set(key, value);
|
||||
localStorage.setItem(key, value);
|
||||
console.log(`[Electron] Loaded setting ${key} from main process`);
|
||||
} else {
|
||||
// Even if we don't overwrite localStorage, update the cache
|
||||
electronSettingsCache.set(key, localStorage.getItem(key)!);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -312,16 +298,27 @@ function createApiInstance(
|
||||
|
||||
const logger = getLoggerForService(serviceName);
|
||||
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
const requestBaseURL = config.baseURL || "";
|
||||
const isDevMode = process.env.NODE_ENV === "development";
|
||||
|
||||
if (isDevMode) {
|
||||
logger.requestStart(method, fullUrl, context);
|
||||
}
|
||||
|
||||
if (isElectron()) {
|
||||
config.headers["X-Electron-App"] = "true";
|
||||
if (config.headers.set) {
|
||||
config.headers.set("X-Electron-App", "true");
|
||||
} else {
|
||||
config.headers["X-Electron-App"] = "true";
|
||||
}
|
||||
|
||||
const token = localStorage.getItem("jwt");
|
||||
if (token) {
|
||||
config.headers["Authorization"] = `Bearer ${token}`;
|
||||
if (config.headers.set) {
|
||||
config.headers.set("Authorization", `Bearer ${token}`);
|
||||
} else {
|
||||
config.headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
userWasAuthenticated = true;
|
||||
}
|
||||
}
|
||||
@@ -339,15 +336,42 @@ function createApiInstance(
|
||||
platform = "iOS";
|
||||
}
|
||||
}
|
||||
config.headers["User-Agent"] = `Termix-Mobile/${platform}`;
|
||||
if (config.headers.set) {
|
||||
config.headers.set("User-Agent", `Termix-Mobile/${platform}`);
|
||||
} else {
|
||||
config.headers["User-Agent"] = `Termix-Mobile/${platform}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isElectron()) {
|
||||
const token = document.cookie
|
||||
const tokenCookie = document.cookie
|
||||
.split("; ")
|
||||
.find((row) => row.startsWith("jwt="));
|
||||
if (token) {
|
||||
userWasAuthenticated = true;
|
||||
|
||||
if (tokenCookie) {
|
||||
const tokenValue = tokenCookie.split("=")[1];
|
||||
if (tokenValue) {
|
||||
// Always add Authorization header as fallback if token is present,
|
||||
// especially important for cross-origin requests where cookies might be blocked
|
||||
const decodedToken = decodeURIComponent(tokenValue);
|
||||
if (config.headers.set) {
|
||||
config.headers.set("Authorization", `Bearer ${decodedToken}`);
|
||||
} else {
|
||||
config.headers["Authorization"] = `Bearer ${decodedToken}`;
|
||||
}
|
||||
userWasAuthenticated = true;
|
||||
}
|
||||
} else {
|
||||
// Check localStorage as fallback even in browser mode
|
||||
const localToken = localStorage.getItem("jwt");
|
||||
if (localToken) {
|
||||
if (config.headers.set) {
|
||||
config.headers.set("Authorization", `Bearer ${localToken}`);
|
||||
} else {
|
||||
config.headers["Authorization"] = `Bearer ${localToken}`;
|
||||
}
|
||||
userWasAuthenticated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -426,8 +450,11 @@ function createApiInstance(
|
||||
};
|
||||
|
||||
const logger = getLoggerForService(serviceName);
|
||||
// A caller can mark a request as a silent retry (see progressive /status
|
||||
// retry) so we don't spam error logs / health events on each attempt.
|
||||
const isSilentRetry = !!(error.config as any)?.__silentRetry;
|
||||
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
if (process.env.NODE_ENV === "development" && !isSilentRetry) {
|
||||
if (status === 401) {
|
||||
logger.authError(method, fullUrl, context);
|
||||
} else if (status === 0 || !status) {
|
||||
@@ -457,7 +484,24 @@ function createApiInstance(
|
||||
errorMessage === "Authentication required" ||
|
||||
errorMessage === "Missing authentication token";
|
||||
|
||||
if (isSessionExpired || isSessionNotFound || isInvalidToken) {
|
||||
const headers = error.config?.headers;
|
||||
let hasAuthHeader = false;
|
||||
if (headers) {
|
||||
if (typeof headers.get === "function") {
|
||||
hasAuthHeader = !!(
|
||||
headers.get("Authorization") || headers.get("authorization")
|
||||
);
|
||||
} else {
|
||||
hasAuthHeader = !!(
|
||||
headers["Authorization"] || headers["authorization"]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
(isSessionExpired || isSessionNotFound || isInvalidToken) &&
|
||||
hasAuthHeader
|
||||
) {
|
||||
const wasAuthenticated = userWasAuthenticated;
|
||||
|
||||
localStorage.removeItem("jwt");
|
||||
@@ -488,7 +532,7 @@ function createApiInstance(
|
||||
|
||||
userWasAuthenticated = false;
|
||||
}
|
||||
} else {
|
||||
} else if (!isSilentRetry) {
|
||||
const wasAuthenticated = !!localStorage.getItem("jwt");
|
||||
dbHealthMonitor.reportDatabaseError(error, wasAuthenticated);
|
||||
}
|
||||
@@ -786,6 +830,9 @@ export let rbacApi: AxiosInstance;
|
||||
// Docker Management API (port 30007)
|
||||
export let dockerApi: AxiosInstance;
|
||||
|
||||
// Pre-initialize with default values to avoid undefined errors during early mounting
|
||||
initializeApiInstances();
|
||||
|
||||
function initializeApp() {
|
||||
if (isElectron()) {
|
||||
Promise.all([getServerConfig(), getEmbeddedServerStatus()])
|
||||
@@ -1006,10 +1053,16 @@ function handleApiError(error: unknown, operation: string): never {
|
||||
export async function getSSHHosts(): Promise<SSHHostWithStatus[]> {
|
||||
try {
|
||||
const hostsResponse = await sshHostApi.get("/db/host");
|
||||
const hosts: SSHHost[] = hostsResponse.data;
|
||||
const hosts: SSHHost[] = Array.isArray(hostsResponse.data)
|
||||
? hostsResponse.data
|
||||
: [];
|
||||
|
||||
const statusesResponse = await getAllServerStatuses();
|
||||
const statuses = statusesResponse || {};
|
||||
let statuses: Record<number, ServerStatus> = {};
|
||||
try {
|
||||
statuses = (await getAllServerStatuses()) || {};
|
||||
} catch {
|
||||
// Status fetch failure should not prevent host list from loading
|
||||
}
|
||||
|
||||
return hosts.map((host) => ({
|
||||
...host,
|
||||
@@ -1073,6 +1126,8 @@ export async function createSSHHost(hostData: SSHHostData): Promise<SSHHost> {
|
||||
socks5Username: hostData.socks5Username || null,
|
||||
socks5Password: hostData.socks5Password || null,
|
||||
socks5ProxyChain: hostData.socks5ProxyChain || null,
|
||||
macAddress: hostData.macAddress || null,
|
||||
portKnockSequence: hostData.portKnockSequence || null,
|
||||
};
|
||||
|
||||
if (!submitData.enableTunnel) {
|
||||
@@ -1160,6 +1215,8 @@ export async function updateSSHHost(
|
||||
socks5Username: hostData.socks5Username || null,
|
||||
socks5Password: hostData.socks5Password || null,
|
||||
socks5ProxyChain: hostData.socks5ProxyChain || null,
|
||||
macAddress: hostData.macAddress || null,
|
||||
portKnockSequence: hostData.portKnockSequence || null,
|
||||
};
|
||||
|
||||
if (!submitData.enableTunnel) {
|
||||
@@ -1190,6 +1247,15 @@ export async function updateSSHHost(
|
||||
}
|
||||
}
|
||||
|
||||
export async function wakeOnLan(hostId: number): Promise<{ success: boolean }> {
|
||||
try {
|
||||
const response = await sshHostApi.post(`/db/host/${hostId}/wake`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "wake on LAN");
|
||||
}
|
||||
}
|
||||
|
||||
export async function bulkImportSSHHosts(
|
||||
hosts: SSHHostData[],
|
||||
overwrite = false,
|
||||
@@ -1258,6 +1324,17 @@ export async function exportSSHHostWithCredentials(
|
||||
}
|
||||
}
|
||||
|
||||
export async function exportAllSSHHosts(): Promise<{
|
||||
hosts: SSHHost[];
|
||||
}> {
|
||||
try {
|
||||
const response = await sshHostApi.get("/db/hosts/export");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "export all SSH hosts");
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SSH AUTOSTART MANAGEMENT
|
||||
// ============================================================================
|
||||
@@ -2299,15 +2376,81 @@ export async function removeFolderShortcut(
|
||||
// SERVER STATISTICS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Progressive retry schedule for the background /status poll.
|
||||
*
|
||||
* Each entry describes one attempt's per-request timeout and the pause to
|
||||
* observe before the next attempt. The pause on the last entry is `null`:
|
||||
* after that final failure we surface the network error, which flows
|
||||
* through the response interceptor + dbHealthMonitor (which decides
|
||||
* between the degraded toast and the full-outage overlay based on whether
|
||||
* any WebSocket is still alive).
|
||||
*
|
||||
* Sequence: try(2s) -> wait 3s -> try(5s) -> wait 5s -> try(8s) -> fail.
|
||||
* Worst-case wall-clock = 23s, which fits inside the 30s ServerStatusContext
|
||||
* poll cadence, so the next tick acts as the next retry without overlap.
|
||||
*/
|
||||
const STATUS_RETRY_SCHEDULE: ReadonlyArray<{
|
||||
timeoutMs: number;
|
||||
pauseAfterMs: number | null;
|
||||
}> = [
|
||||
{ timeoutMs: 2000, pauseAfterMs: 3000 },
|
||||
{ timeoutMs: 5000, pauseAfterMs: 5000 },
|
||||
{ timeoutMs: 8000, pauseAfterMs: null },
|
||||
];
|
||||
|
||||
function isTransientStatusError(error: unknown): boolean {
|
||||
if (!axios.isAxiosError(error)) return false;
|
||||
if (error.response) {
|
||||
// Definitive server response (even 5xx) is not something more retries
|
||||
// will fix in a useful timeframe; bail out and report it normally.
|
||||
return false;
|
||||
}
|
||||
const code = error.code;
|
||||
if (!code) {
|
||||
// No code + no response means classic network error (offline / DNS / TCP)
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
code === "ECONNABORTED" ||
|
||||
code === "ETIMEDOUT" ||
|
||||
code === "ERR_NETWORK" ||
|
||||
code === "ECONNREFUSED" ||
|
||||
code === "ECONNRESET"
|
||||
);
|
||||
}
|
||||
|
||||
export async function getAllServerStatuses(): Promise<
|
||||
Record<number, ServerStatus>
|
||||
> {
|
||||
try {
|
||||
const response = await statsApi.get("/status");
|
||||
return response.data || {};
|
||||
} catch (error) {
|
||||
handleApiError(error, "fetch server statuses");
|
||||
let lastError: unknown = null;
|
||||
|
||||
for (let i = 0; i < STATUS_RETRY_SCHEDULE.length; i++) {
|
||||
const { timeoutMs, pauseAfterMs } = STATUS_RETRY_SCHEDULE[i];
|
||||
const isFinalAttempt = i === STATUS_RETRY_SCHEDULE.length - 1;
|
||||
|
||||
try {
|
||||
const response = await statsApi.get("/status", {
|
||||
timeout: timeoutMs,
|
||||
// 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.
|
||||
__silentRetry: !isFinalAttempt,
|
||||
} as AxiosRequestConfig & { __silentRetry?: boolean });
|
||||
return response.data || {};
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (!isTransientStatusError(error)) {
|
||||
break;
|
||||
}
|
||||
if (pauseAfterMs === null) {
|
||||
break;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, pauseAfterMs));
|
||||
}
|
||||
}
|
||||
|
||||
handleApiError(lastError, "fetch server statuses");
|
||||
}
|
||||
|
||||
export async function getServerStatusById(id: number): Promise<ServerStatus> {
|
||||
@@ -2320,11 +2463,25 @@ export async function getServerStatusById(id: number): Promise<ServerStatus> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function getServerMetricsById(id: number): Promise<ServerMetrics> {
|
||||
export async function getServerMetricsById(
|
||||
id: number,
|
||||
): Promise<ServerMetrics | null> {
|
||||
try {
|
||||
const response = await statsApi.get(`/metrics/${id}`);
|
||||
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");
|
||||
throw error;
|
||||
}
|
||||
@@ -2378,9 +2535,12 @@ export async function sendMetricsHeartbeat(
|
||||
}
|
||||
}
|
||||
|
||||
export async function registerMetricsViewer(
|
||||
hostId: number,
|
||||
): Promise<{ success: boolean; viewerSessionId: string }> {
|
||||
export async function registerMetricsViewer(hostId: number): Promise<{
|
||||
success: boolean;
|
||||
viewerSessionId?: string;
|
||||
skipped?: boolean;
|
||||
reason?: string;
|
||||
}> {
|
||||
try {
|
||||
const response = await statsApi.post("/metrics/register-viewer", {
|
||||
hostId,
|
||||
@@ -2471,6 +2631,50 @@ export async function updateGlobalMonitoringSettings(settings: {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// LOG LEVEL SETTINGS
|
||||
// ============================================================================
|
||||
|
||||
export async function getLogLevel(): Promise<{ level: string }> {
|
||||
try {
|
||||
const response = await authApi.get("/users/log-level");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "fetch log level");
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateLogLevel(level: string): Promise<void> {
|
||||
try {
|
||||
await authApi.patch("/users/log-level", { level });
|
||||
} catch (error) {
|
||||
handleApiError(error, "update log level");
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SESSION TIMEOUT SETTINGS
|
||||
// ============================================================================
|
||||
|
||||
export async function getSessionTimeout(): Promise<{ timeoutHours: number }> {
|
||||
try {
|
||||
const response = await authApi.get("/users/session-timeout");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "fetch session timeout");
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateSessionTimeout(
|
||||
timeoutHours: number,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await authApi.patch("/users/session-timeout", { timeoutHours });
|
||||
} catch (error) {
|
||||
handleApiError(error, "update session timeout");
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// GUACAMOLE SETTINGS
|
||||
// ============================================================================
|
||||
@@ -2538,7 +2742,7 @@ export async function loginUser(
|
||||
const isInIframe =
|
||||
typeof window !== "undefined" && window.self !== window.top;
|
||||
|
||||
if (isInIframe && hasToken) {
|
||||
if (isInIframe && isElectron() && hasToken) {
|
||||
localStorage.setItem("jwt", response.data.token);
|
||||
|
||||
try {
|
||||
@@ -2550,7 +2754,7 @@ export async function loginUser(
|
||||
platform: "desktop",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
"*",
|
||||
window.location.origin,
|
||||
);
|
||||
} catch (e) {
|
||||
console.error("[main-axios] Error posting message to parent:", e);
|
||||
@@ -2831,10 +3035,10 @@ export async function revokeAllUserSessions(
|
||||
}
|
||||
|
||||
export async function makeUserAdmin(
|
||||
username: string,
|
||||
userId: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await authApi.post("/users/make-admin", { username });
|
||||
const response = await authApi.post("/users/make-admin", { userId });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "make user admin");
|
||||
@@ -2842,10 +3046,10 @@ export async function makeUserAdmin(
|
||||
}
|
||||
|
||||
export async function removeAdminStatus(
|
||||
username: string,
|
||||
userId: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await authApi.post("/users/remove-admin", { username });
|
||||
const response = await authApi.post("/users/remove-admin", { userId });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "remove admin status");
|
||||
@@ -3012,7 +3216,7 @@ export async function verifyTOTPLogin(
|
||||
const isInIframe =
|
||||
typeof window !== "undefined" && window.self !== window.top;
|
||||
|
||||
if (isInIframe && hasToken) {
|
||||
if (isInIframe && isElectron() && hasToken) {
|
||||
localStorage.setItem("jwt", response.data.token);
|
||||
|
||||
try {
|
||||
@@ -3024,7 +3228,7 @@ export async function verifyTOTPLogin(
|
||||
platform: "desktop",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
"*",
|
||||
window.location.origin,
|
||||
);
|
||||
} catch (e) {
|
||||
console.error("[main-axios] Error posting message to parent:", e);
|
||||
@@ -3091,9 +3295,13 @@ export async function getReleasesRSS(
|
||||
}
|
||||
}
|
||||
|
||||
export async function getVersionInfo(): Promise<Record<string, unknown>> {
|
||||
export async function getVersionInfo(
|
||||
checkRemote = true,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await authApi.get("/version");
|
||||
const response = await authApi.get(
|
||||
`/version${checkRemote ? "" : "?checkRemote=false"}`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "fetch version info");
|
||||
@@ -3207,6 +3415,20 @@ export async function getSSHHostWithCredentials(
|
||||
}
|
||||
}
|
||||
|
||||
export async function getHostPassword(
|
||||
hostId: number,
|
||||
field: "password" | "sudoPassword" = "password",
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const response = await sshHostApi.get(
|
||||
`/db/host/${hostId}/password?field=${field}`,
|
||||
);
|
||||
return response.data?.value || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function applyCredentialToHost(
|
||||
hostId: number,
|
||||
credentialId: number,
|
||||
@@ -3629,7 +3851,9 @@ export async function reorderSnippets(
|
||||
updates: Array<{ id: number; order: number; folder?: string }>,
|
||||
): Promise<{ success: boolean }> {
|
||||
try {
|
||||
const response = await authApi.post("/snippets/reorder", { updates });
|
||||
const response = await authApi.post("/snippets/reorder", {
|
||||
snippets: updates,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "reorder snippets");
|
||||
@@ -3886,6 +4110,39 @@ export interface GuacamoleTokenResponse {
|
||||
token: string;
|
||||
}
|
||||
|
||||
type GuacamoleConfigSource = {
|
||||
guacamoleConfig?: string | Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export function getGuacamoleDpi(
|
||||
source?: GuacamoleConfigSource,
|
||||
): number | undefined {
|
||||
const config = source?.guacamoleConfig;
|
||||
if (!config) return undefined;
|
||||
|
||||
let dpi: unknown;
|
||||
if (typeof config === "string") {
|
||||
try {
|
||||
dpi = JSON.parse(config).dpi;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
} else {
|
||||
dpi = config.dpi;
|
||||
}
|
||||
|
||||
const parsedDpi = typeof dpi === "string" ? Number(dpi) : dpi;
|
||||
if (
|
||||
typeof parsedDpi !== "number" ||
|
||||
!Number.isFinite(parsedDpi) ||
|
||||
parsedDpi <= 0
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return Math.trunc(parsedDpi);
|
||||
}
|
||||
|
||||
function toGuacamoleParams(
|
||||
config: GuacamoleTokenRequest["guacamoleConfig"],
|
||||
): Record<string, unknown> {
|
||||
@@ -4132,6 +4389,75 @@ export async function revokeHostAccess(
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SNIPPET SHARING
|
||||
// ============================================================================
|
||||
|
||||
export async function shareSnippet(
|
||||
snippetId: number,
|
||||
shareData: {
|
||||
targetType: "user" | "role";
|
||||
targetUserId?: string;
|
||||
targetRoleId?: number;
|
||||
durationHours?: number;
|
||||
},
|
||||
): Promise<{ success: boolean }> {
|
||||
try {
|
||||
const response = await rbacApi.post(
|
||||
`/rbac/snippet/${snippetId}/share`,
|
||||
shareData,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "share snippet");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSnippetAccess(
|
||||
snippetId: number,
|
||||
): Promise<{ accessList: AccessRecord[] }> {
|
||||
try {
|
||||
const response = await rbacApi.get(`/rbac/snippet/${snippetId}/access`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "fetch snippet access");
|
||||
}
|
||||
}
|
||||
|
||||
export async function revokeSnippetAccess(
|
||||
snippetId: number,
|
||||
accessId: number,
|
||||
): Promise<{ success: boolean }> {
|
||||
try {
|
||||
const response = await rbacApi.delete(
|
||||
`/rbac/snippet/${snippetId}/access/${accessId}`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "revoke snippet access");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSharedSnippets(): Promise<{
|
||||
sharedSnippets: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
content: string;
|
||||
description: string | null;
|
||||
folder: string | null;
|
||||
ownerUsername: string;
|
||||
permissionLevel: string;
|
||||
expiresAt: string | null;
|
||||
}>;
|
||||
}> {
|
||||
try {
|
||||
const response = await rbacApi.get("/rbac/shared-snippets");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "fetch shared snippets");
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DOCKER MANAGEMENT API
|
||||
// ============================================================================
|
||||
|
||||
@@ -13,7 +13,13 @@ import { RobustClipboardProvider } from "@/lib/clipboard-provider";
|
||||
import { Unicode11Addon } from "@xterm/addon-unicode11";
|
||||
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { isElectron, getCookie, getSnippets } from "@/ui/main-axios.ts";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
isElectron,
|
||||
isEmbeddedMode,
|
||||
getCookie,
|
||||
getSnippets,
|
||||
} from "@/ui/main-axios.ts";
|
||||
import { getBasePath } from "@/lib/base-path";
|
||||
import { useTheme } from "@/components/theme-provider";
|
||||
import {
|
||||
@@ -121,7 +127,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
const isFittingRef = useRef(false);
|
||||
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const reconnectAttempts = useRef(0);
|
||||
const maxReconnectAttempts = 3;
|
||||
const maxReconnectAttempts = 8;
|
||||
const isUnmountingRef = useRef(false);
|
||||
const shouldNotReconnectRef = useRef(false);
|
||||
const isReconnectingRef = useRef(false);
|
||||
@@ -506,13 +512,17 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
? `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30002`
|
||||
: isElectron()
|
||||
? (() => {
|
||||
const baseUrl =
|
||||
(window as { configuredServerUrl?: string })
|
||||
.configuredServerUrl || "http://127.0.0.1:30001";
|
||||
const wsProtocol = baseUrl.startsWith("https://")
|
||||
const configuredUrl = (window as { configuredServerUrl?: string })
|
||||
.configuredServerUrl;
|
||||
if (isEmbeddedMode() || !configuredUrl) {
|
||||
return "ws://127.0.0.1:30002";
|
||||
}
|
||||
const wsProtocol = configuredUrl.startsWith("https://")
|
||||
? "wss://"
|
||||
: "ws://";
|
||||
const wsHost = baseUrl.replace(/^https?:\/\//, "");
|
||||
const wsHost = configuredUrl
|
||||
.replace(/^https?:\/\//, "")
|
||||
.replace(/\/$/, "");
|
||||
return `${wsProtocol}${wsHost}/ssh/websocket/`;
|
||||
})()
|
||||
: `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}${getBasePath()}/ssh/websocket/`;
|
||||
@@ -709,7 +719,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "input",
|
||||
data: snippet.content + "\n",
|
||||
data: snippet.content + "\r",
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -800,6 +810,46 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
clearTimeout(connectionTimeoutRef.current);
|
||||
connectionTimeoutRef.current = null;
|
||||
}
|
||||
} else if (msg.type === "tmux_sessions_available") {
|
||||
// On mobile, auto-attach to the first available session
|
||||
const sessions = msg.sessions as Array<{ name: string }>;
|
||||
if (sessions.length > 0 && ws.readyState === 1) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "tmux_attach",
|
||||
data: { sessionName: sessions[0].name },
|
||||
}),
|
||||
);
|
||||
}
|
||||
} else if (
|
||||
msg.type === "tmux_session_created" ||
|
||||
msg.type === "tmux_session_attached"
|
||||
) {
|
||||
const sessionName =
|
||||
typeof msg.sessionName === "string" ? msg.sessionName : "";
|
||||
addLog({
|
||||
type: "info",
|
||||
stage: "connection",
|
||||
message:
|
||||
msg.type === "tmux_session_created"
|
||||
? t("terminal.tmuxSessionCreated", {
|
||||
name: sessionName || "new",
|
||||
})
|
||||
: t("terminal.tmuxSessionAttached", {
|
||||
name: sessionName,
|
||||
}),
|
||||
});
|
||||
} else if (msg.type === "tmux_unavailable") {
|
||||
setTimeout(() => {
|
||||
toast.warning(t("terminal.tmuxUnavailable"), {
|
||||
duration: 8000,
|
||||
});
|
||||
}, 500);
|
||||
addLog({
|
||||
type: "warning",
|
||||
stage: "connection",
|
||||
message: t("terminal.tmuxUnavailable"),
|
||||
});
|
||||
} else if (msg.type === "connection_log") {
|
||||
if (msg.data) {
|
||||
addLog({
|
||||
@@ -834,17 +884,21 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
}
|
||||
|
||||
if (event.code === 1006) {
|
||||
console.error(
|
||||
"[WebSocket] Abnormal closure detected - possible HTTPS/proxy issue",
|
||||
console.warn(
|
||||
"[WebSocket] Abnormal closure detected - attempting reconnection",
|
||||
);
|
||||
addLog({
|
||||
type: "error",
|
||||
type: "warning",
|
||||
stage: "connection",
|
||||
message: t("terminal.websocketAbnormalClose"),
|
||||
});
|
||||
updateConnectionError(t("terminal.websocketAbnormalClose"));
|
||||
setIsConnecting(false);
|
||||
shouldNotReconnectRef.current = true;
|
||||
|
||||
if (wasConnectedRef.current) {
|
||||
attemptReconnection();
|
||||
} else {
|
||||
updateConnectionError(t("terminal.websocketAbnormalClose"));
|
||||
setIsConnecting(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -861,10 +915,6 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
|
||||
localStorage.removeItem("jwt");
|
||||
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -994,6 +1044,16 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
|
||||
terminal.open(xtermRef.current);
|
||||
|
||||
const handlePaste = (e: ClipboardEvent) => {
|
||||
const text = e.clipboardData?.getData("text");
|
||||
if (text) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
terminal.paste(text);
|
||||
}
|
||||
};
|
||||
xtermRef.current.addEventListener("paste", handlePaste);
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
|
||||
resizeTimeout.current = setTimeout(() => {
|
||||
@@ -1046,9 +1106,12 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
});
|
||||
});
|
||||
|
||||
const currentElement = xtermRef.current;
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
clipboardProvider.dispose();
|
||||
currentElement?.removeEventListener("paste", handlePaste);
|
||||
if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current);
|
||||
if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
|
||||
if (pingIntervalRef.current) {
|
||||
|
||||
@@ -165,12 +165,6 @@ export function Auth({
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!registrationAllowed && !internalLoggedIn) {
|
||||
toast.warning(t("messages.registrationDisabled"));
|
||||
}
|
||||
}, [registrationAllowed, internalLoggedIn, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!passwordLoginAllowed && oidcConfigured && tab !== "external") {
|
||||
setTab("external");
|
||||
@@ -595,6 +589,11 @@ export function Auth({
|
||||
setOidcLoading(true);
|
||||
setError(null);
|
||||
|
||||
const urlToken = urlParams.get("token");
|
||||
if (urlToken && (isElectron() || isReactNativeWebView())) {
|
||||
localStorage.setItem("jwt", urlToken);
|
||||
}
|
||||
|
||||
window.history.replaceState({}, document.title, window.location.pathname);
|
||||
|
||||
setTimeout(() => {
|
||||
|
||||
Reference in New Issue
Block a user